Compare commits
39 Commits
be37192fe9
...
7bd911d58b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd911d58b | |||
| 413967a205 | |||
| 9b1a49c640 | |||
| 0cfd9b6236 | |||
| 39389c1183 | |||
| 67215509cb | |||
| c9c708a338 | |||
| 4bef71b05a | |||
| 5420550ff3 | |||
| 68765da031 | |||
| d04045be69 | |||
| 25c63fb807 | |||
| b9d85161e5 | |||
| 1aea481688 | |||
| 3cb6b9de21 | |||
| d2364eb5ac | |||
| f12700c997 | |||
| 902030bfba | |||
| 7d42d7ed29 | |||
| 7af3c0c630 | |||
| 78e112d1f9 | |||
| e89568c1a8 | |||
| 9812690b96 | |||
| ae23ee0882 | |||
| ea52b14f2a | |||
| dfed1c77bb | |||
| fb12c53522 | |||
| 99fab6a4b6 | |||
| 2a0d10fab5 | |||
| 81f5861ba4 | |||
| 98c07d768f | |||
| e304f2b031 | |||
| 8d4ccbf841 | |||
| c91bf03ef4 | |||
| 875d5b4632 | |||
| 0800760833 | |||
| cd704cb0ad | |||
| 8c620f88e4 | |||
| a689fb75eb |
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
|
||||
|
||||
Per-module detail — what each file owns, its invariants — lives in the nineteen per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
||||
Per-module detail — what each file owns, its invariants — lives in the twenty per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
||||
|
||||
## Settled decisions
|
||||
|
||||
@@ -42,7 +42,11 @@ Vendors three submodules (see `.gitmodules`):
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
|
||||
Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. `CMakeLists.txt` is the authoritative target list. The two loadable-module targets are `reaper_reasampler` (the REAPER extension `.dll`/`.dylib`/`.so`) and `reasampler_vst` (the VST3 instrument; Windows-only, omitted if the `vendor/vst3sdk` slice is absent). The `sample_usage_tests` executable target runs the pure unit tests for `sample_usage` (no REAPER, no DAW).
|
||||
On a multi-config generator (Visual Studio, Xcode) the bare `ctest` command above
|
||||
reports every test as "Not Run" — add `-C Debug` (or whichever config was built) to
|
||||
resolve the test executables. Single-config generators (Ninja, Make) need no such flag.
|
||||
|
||||
Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. Targets are declared per directory: each `src/**/CMakeLists.txt` owns its own libraries and their test targets, added via `add_subdirectory` from the root, which keeps only repo-global settings (version, channel, vendor paths). `cmake/reasampler_targets.cmake` holds the two shared declaration helpers. The two loadable-module targets are `reaper_reasampler` (the REAPER extension `.dll`/`.dylib`/`.so`) and `reasampler_vst` (the VST3 instrument; Windows-only, omitted if the `vendor/vst3sdk` slice is absent). The `sample_usage_tests` executable target runs the pure unit tests for `sample_usage` (no REAPER, no DAW).
|
||||
|
||||
### Beta channel build
|
||||
|
||||
@@ -61,7 +65,7 @@ The VST3 target forks identically: `REASAMPLER_CHANNEL=beta` produces `reasample
|
||||
|
||||
php vendor/WDL/WDL/swell/swell_resgen.php src/resource.rc # macOS; Linux reuses the output
|
||||
|
||||
Add the generated file to the appropriate `APPLE` / Linux `target_sources` block in CMakeLists.txt. The SWS extension build is the canonical reference for this step.
|
||||
Add the generated file to the appropriate `APPLE` / Linux `target_sources` block in `src/app/CMakeLists.txt`. The SWS extension build is the canonical reference for this step.
|
||||
|
||||
### Install / reload
|
||||
|
||||
@@ -69,7 +73,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
||||
|
||||
## Architecture: the load-bearing split
|
||||
|
||||
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the nineteen directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
||||
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
||||
|
||||
| Directory | Scope |
|
||||
|---|---|
|
||||
@@ -77,6 +81,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
||||
| `src/core/audio/` | pure audio-data math |
|
||||
| `src/core/capture/` | pure logic behind the capture pillar |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) |
|
||||
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
||||
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
||||
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
||||
| `src/core/reclaim/` | pure prune orphan computation |
|
||||
|
||||
+48
-1292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
# The two shapes that repeat across src/: a pure static library and its CTest target.
|
||||
# Both are thin pass-throughs — LINK is forwarded to target_link_libraries verbatim, so
|
||||
# PUBLIC/PRIVATE keywords and link order stay visible at the call site rather than being
|
||||
# invented by the helper. Targets that genuinely deviate are written out longhand.
|
||||
|
||||
# Every pure library carries src/ as a PUBLIC include dir: headers are included rooted
|
||||
# there ("core/json/json.h"), so a consumer needs only the link edge.
|
||||
function(reasampler_pure_library name)
|
||||
cmake_parse_arguments(ARG "" "" "SOURCES;LINK" ${ARGN})
|
||||
add_library(${name} STATIC ${ARG_SOURCES})
|
||||
target_include_directories(${name} PUBLIC ${REASAMPLER_SRC_DIR})
|
||||
if(ARG_LINK)
|
||||
target_link_libraries(${name} ${ARG_LINK})
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# Test naming is exceptionless: target <name>_tests is built from tests/test_<name>.cpp
|
||||
# and registered under its own target name.
|
||||
function(reasampler_test name)
|
||||
cmake_parse_arguments(ARG "" "" "LINK" ${ARGN})
|
||||
add_executable(${name}_tests ${REASAMPLER_TESTS_DIR}/test_${name}.cpp)
|
||||
target_link_libraries(${name}_tests PRIVATE ${ARG_LINK})
|
||||
add_test(NAME ${name}_tests COMMAND ${name}_tests)
|
||||
endfunction()
|
||||
@@ -18,3 +18,213 @@ intentional exception (Daniel-approved) is two user-facing error strings in
|
||||
findings — all content cut that should have survived — and all ten were
|
||||
remediated and re-gated before merge. Driver: Daniel's instruction — *"Brief
|
||||
concise engineering comments. A little why, and maybe context, never WHAT."*
|
||||
|
||||
### CMake build-system split (ad-hoc, Daniel's request)
|
||||
|
||||
Split the 1423-line root `CMakeLists.txt` into a 91-line root plus 18 per-directory
|
||||
`CMakeLists.txt` files under `src/`, with two shared declaration helpers
|
||||
(`reasampler_pure_library`, `reasampler_test`) factored into a new
|
||||
`cmake/reasampler_targets.cmake`. The root now keeps only repo-global concerns:
|
||||
version/channel single-source-of-truth, `configure_file`, vendor path vars,
|
||||
`LICE_SRC`, `enable_testing()`, and the `add_subdirectory` calls. Comments throughout
|
||||
were rewritten to the project's comment conventions — phase/wave/ticket IDs removed,
|
||||
module semantics already owned by `src/**/CLAUDE.md` deleted, load-bearing build
|
||||
facts kept.
|
||||
|
||||
Also fixed two duplicate-object-code defects surfaced by the split: 18 `core/`
|
||||
translation units were previously compiled directly into the `reaper_reasampler`
|
||||
module *while also* being linked in as static libraries — those 18 source entries
|
||||
were removed from the module's source list and 3 missing link edges
|
||||
(`view_tree`, `guid_diff`, `lane_keys`) added so every `core/` TU now enters through
|
||||
exactly one static-library link edge. A dead `bridge_marshal` link edge was also
|
||||
dropped from `reaper_reasampler`, and two inaccurate comments in
|
||||
`src/app/CMakeLists.txt` were corrected.
|
||||
|
||||
No `.cpp`, `.h`, `tests/`, or `vendor/` file was touched. Behaviour is unchanged and
|
||||
was verified mechanically: same 130 targets, same 65 tests all passing,
|
||||
`reaper_reasampler.dll` byte-identical at 3,477,504 bytes, both channels
|
||||
(stable/beta) building to the same artifact names and locations as before.
|
||||
|
||||
### Θ-W1-T1 — zone-retirement
|
||||
|
||||
ReaSampler 9000's zone-mapping system is retired: one loaded capture, one parameter
|
||||
set, playing across the full keyboard repitched from root with key-tracking — no zones,
|
||||
no per-zone divergence, no keymap of captures. The dedicated zone-editing face and its
|
||||
authoring affordances (add/delete zone, per-zone parameter panel, Low/High/Root zone
|
||||
legend) are gone; the root note survives as a first-class parameter. `sampler_core`
|
||||
split along the note-routing/per-voice-render responsibility seam (no virtual `tick()`
|
||||
on the per-voice path), and the Sample face split into chrome/waveform/decks bands with
|
||||
a shared band-stack allocator, discharging the wave's two structural deliverables.
|
||||
Migration adopts a saved multi-zone instance's first zone; single-zone instances lift
|
||||
losslessly.
|
||||
|
||||
**Deviations from spec:**
|
||||
- The key-range open question (**[propose]**) is answered outright rather than left
|
||||
open: no key-range concept survives at all — `KeyZone`/`lowNote`/`highNote` are gone
|
||||
from the engine, the write format, and the strip. A low/high pair remains re-addable
|
||||
later as two ordinary parameters. Θ-W2-T3 consumes this decision.
|
||||
- **"Which zone is first" (**[verify]**) confirmed:** `PerformanceMap::zones` was an
|
||||
ordered vector and `Keymap::resolve` was first-match-in-order, so index 0 was the
|
||||
audible zone. Migration adopts index 0, and that zone's `sampleId` supersedes the
|
||||
envelope's stored `selectionId`.
|
||||
- The control row (root strip, preview, velocity knob, curve button, Mono|Stereo) moved
|
||||
into the chrome band, directly under the title rather than above the deck —
|
||||
user-visible and deliberate; it's what makes Θ-W2's band-disjointness real.
|
||||
- Loading a capture now clears the three capture-anchored overrides (root, loop span,
|
||||
start frame) while keeping the shaping parameters — strictly less destructive than the
|
||||
previous whole-zone drop.
|
||||
- The embed strip became a read-only readout; it lost its click handler since with no
|
||||
zones there is nothing to select.
|
||||
- **Migration side-effect:** a previously-zoned instance with implicit channel mode and
|
||||
a stereo capture persisted as Mono will reopen as **Stereo**. It converges on the
|
||||
documented rule and is reachable only for old blobs, but "sounds identical" was an
|
||||
acceptance bar, so it's a real deviation.
|
||||
- `sampler_core.{h,cpp}`'s 956-line documented hot-path exception is retired, not
|
||||
relocated — the tree now carries no over-ceiling exception at all.
|
||||
- `note_entry` was deleted as dead code (its only consumer was the removed zone editor).
|
||||
- **Still unverified:** a real pre-change project reopening through REAPER's `setState`
|
||||
has not been exercised in the DAW; migration is proven only in the pure domain against
|
||||
hand-laid legacy bytes.
|
||||
|
||||
### Θ-W1-T2 — capture-handoff-bugs
|
||||
|
||||
Fixed both extension-side capture-handoff defects: drag-out now delivers the capture's
|
||||
audio at the drop target every time (previously intermittent, retry-fixable); dropping a
|
||||
capture onto an FX container now loads the instrument with the capture, matching the
|
||||
FX-button drop path.
|
||||
|
||||
**Deviations from spec:**
|
||||
- Item 5's root cause was three defects, not one: non-atomic COM refcounts racing a drop
|
||||
target's background copy; an unverified assumption that REAPER had already called
|
||||
`OleInitialize` on the calling thread; and a teardown-before-payload-check ordering bug
|
||||
that let an unresolvable payload consume the gesture.
|
||||
- Item 6's fix rests on an unconfirmed hypothesis — that a bare `instantiate = -1` left
|
||||
placement to REAPER's ambient FX-chain insert point, which a container-focused chain
|
||||
window moves. It is now pinned to an explicit top-level position; nobody could confirm
|
||||
the mechanism without the DAW.
|
||||
- The opportunistic rider was partly taken: `src/ingest.{h,cpp}` re-homed to
|
||||
`src/shell/actions/`. `ext_keys.h` was declined (most consumers sit in another track's
|
||||
exclusive surface); `resource.h` was declined (it's a build input paired with
|
||||
`src/resource.rc` and the SWELL resgen step).
|
||||
- **Neither acceptance criterion has actually been met yet.** Item 5's gate is
|
||||
explicitly a soak ("a single pass is not a gate") and item 6 needs a live container
|
||||
drop; both require REAPER and are outstanding. The code is merged; the acceptance
|
||||
gates are not closed.
|
||||
|
||||
### Θ-W1-T3 — filter-dsp-port
|
||||
|
||||
Lands the per-voice resonant filter as a standalone pure module
|
||||
(`core/instrument/engine/filter/`, five files: `filter_params`, `filter_coeffs`,
|
||||
`filter_morph`, `filter_saturate`, `voice_filter`) — concrete `VoiceFilter` type, no
|
||||
vtable, no allocation in `process()`. **No call site**; Θ-W2-T1 wires it into the voice
|
||||
path.
|
||||
|
||||
**Deviations from spec — the spec itself changed mid-flight, headline first:**
|
||||
- **The Cortex-M4 biquad port was superseded entirely by a TPT/SVF topology, Daniel's
|
||||
call.** Measurement found the firmware's high-pass resonance feedback tap vestigial:
|
||||
its stated rationale was inverted (the HP numerator approaches 1 as cutoff falls, not
|
||||
zero — it's the LP numerator that collapses), it *reduced* HP resonance everywhere it
|
||||
ran, and it carried unwanted sample-rate and input-level dependence. It was a Q15
|
||||
fixed-point workaround for a ~17-bit cancellation float32 doesn't suffer. Daniel's
|
||||
ruling on the resulting level-dependent resonance bloom: *"was a feature on the
|
||||
hardware (one knob colorful HP for master FX), wrong choice for this approach."*
|
||||
- **Two discrete modes (HP/LP) became a continuous morph, with two selectable morph
|
||||
laws** — HP→BP→LP (default) and HP→notch→LP (Oberheim SEM) — chosen at `prepare()`
|
||||
via a `MorphLaw` enum on `FilterSettings`. Zero per-sample cost, verified by diffing
|
||||
emitted assembly (byte-identical between laws).
|
||||
- **A configurable drive stage was added:** an in-loop soft limiter on the band-pass
|
||||
integrator state, normalized 0..1, with a radial dial planned for Θ-W2. Drive at 0 is
|
||||
bit-exact linear.
|
||||
- **`Biquad1PoleLP` was struck (Daniel's call) and never ported.**
|
||||
- **Q spans the full 0.1–10 with √2 at the control centre**, replacing the firmware's
|
||||
0.707-floored mapping — as originally specified.
|
||||
- **No reference sample rate exists anywhere in the module** — rate reaches the DSP
|
||||
only via `g = tan(π·fc/sr)`. An interim fix that anchored a feedback tap to a
|
||||
`1/48000` constant was superseded by the rewrite.
|
||||
- **The rewrite fixed a float32 conditioning defect the biquad carried:** Direct Form I
|
||||
measured −27% peak error at 20 Hz / 192 kHz; TPT measures +0.034%.
|
||||
- **Still open, deliberately:** drive's maximum depth (4.0, set by measurement — at 64
|
||||
the resonant peak inverted below passband) and the absence of makeup gain both await
|
||||
an ear pass against the real dial.
|
||||
- **The module has no call site** — integration is Θ-W2-T1, which also carries three
|
||||
recorded decisions of its own: the envelope-order choice (drive is level-dependent, so
|
||||
pre- vs post-envelope placement is sound-defining), the drive dial's calibration, and
|
||||
the fact that drive authority varies ~11 dB across the morph sweep.
|
||||
|
||||
### Θ-W2-T1 — filter-voice-path
|
||||
|
||||
Wires the pure filter module into ReaSampler 9000's per-voice signal path as a new fixed
|
||||
processing point between the pitch envelope and the amp stage, gives it its own
|
||||
knob-deck group, and relays the deck row in signal-flow order (pitch → filter → amp).
|
||||
Each `Voice` owns its own `VoiceFilter` and a second `AdsrEnvelope` instance — per-voice,
|
||||
never shared — and neither adds allocation or virtual dispatch to the per-sample path.
|
||||
Parameters — morph position, cutoff, Q, drive, mod amount (bipolar ±100%, targeting
|
||||
cutoff), velocity and key-tracking modulation, then AHDSR — live in the one parameter
|
||||
set; `FilterParams` stores the filter module's own `FilterSettings` by value rather than
|
||||
a parallel copy of the normalized positions. The filter is off by default and bit-exact
|
||||
off — a project saved before the change reopens sounding identical, pinned by a
|
||||
bit-equality test. `ComponentState`'s params payload moves v8 → v9, appending the filter
|
||||
tail; a v8 blob is a strict prefix and lifts to the off/neutral filter default, with
|
||||
non-finite filter fields falling back to neutral, pinned by a golden byte-literal
|
||||
fixture. Deck composition was extracted into a new pure module
|
||||
`core/instrument/ui/deck_groups`, with the pitch → filter → amp row order pinned by
|
||||
test; the filter's AHDSR ships as its own `FILTER ENV` group sibling to `FILTER`,
|
||||
mirroring the existing `PITCH` / `PITCH ENV` split, and a morph-law toggle (`Band` |
|
||||
`Notch`) ships as the FILTER group's row toggle.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **An initial mod-quantizer was added, then rejected and removed.** A
|
||||
`kFilterModSteps = 2048` step gate on the coefficient re-solve stair-stepped the
|
||||
corner (~5.8 cents per step); Daniel rejected it. Replaced by a cutoff-only re-solve
|
||||
(`VoiceFilter::setCutoffNorm`) that re-derives only `g = tan(π·fc/sr)` — Q's parabola,
|
||||
the morph's cos/sin, and the folded mix are all cutoff-independent and stay cached
|
||||
from `prepare()`; `filter_params` hoists its constant logs. Measured at 48 kHz,
|
||||
Release, net of the sweep generator: kernel alone 2.8 ns/frame, full `prepare()` 56.9
|
||||
ns, cutoff-only re-solve 15.5 ns — about 1.2% of a core for 16 continuously-swept
|
||||
voices. The corner now sweeps continuously rather than stair-stepping.
|
||||
- **The editor floor was raised to 840×620**, which is also its new default size, up
|
||||
from a 560×460 floor at which the grown deck wrapped to four rows and pushed
|
||||
`FILTER ENV` / `AMP ENVELOPE` / `VOICE` / `MASTER` off-screen with no scroll.
|
||||
`kEditorMinWidth`/`kEditorMinHeight` now live in `sample_bands`, read by both the
|
||||
shell's `checkSizeConstraint` and the opening `ViewRect`. Consequence worth
|
||||
recording: a host with a saved editor rect below 840×620 is clamped up on reopen.
|
||||
|
||||
### Θ-W2-T2 — stereo-waveform-lanes
|
||||
|
||||
Delivered as specified: two stacked lanes, L above R, in stereo mode; one lane in mono;
|
||||
overlays draw once at full stacked height.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **A mono source under stereo mode draws one lane**, not two — lane count keys off
|
||||
`stereoMode && sourceChannels >= 2`, not channel mode alone, since a mono capture in
|
||||
stereo mode is dual-mono and a second lane would be the redundant duplicate the spec
|
||||
forbids. Review confirmed this is the only reading consistent with the decode path.
|
||||
- The full-height overlay contract Θ-W3 and Θ-W4 consume is **type-enforced, not
|
||||
merely documented**: an `OverlayArea` wrapper type that lane rects cannot satisfy.
|
||||
- A single-slot per-channel PCM cache was added to the editor session, invalidating
|
||||
alongside the existing PCM cache.
|
||||
|
||||
### Θ-W2-T3 — toolbar-and-piano-strip
|
||||
|
||||
Delivered as specified: one toolbar font, no zone-count label, full-width piano strip,
|
||||
uniform key widths, note-name tooltips, root displayed and settable.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **The non-uniform key widths were integer quantisation, not aliasing** — the old
|
||||
`keyEdgeToX` truncated an exact rational, alternating 6px/7px. Θ-W6's general
|
||||
antialiasing audit inherits nothing on key *widths* as a result, though key *edges*
|
||||
are still drawn unantialiased.
|
||||
- **Uniform integer key widths and gap-free edge-to-edge tiling are mutually
|
||||
exclusive** — 75 white keys do not divide an arbitrary width. The residue now lands
|
||||
in symmetric end gutters, 37px each side at the shipped 840px default — the maximum
|
||||
of a sawtooth with period 75px of window width. Daniel accepted this provisionally,
|
||||
pending how it looks in REAPER.
|
||||
- The whole control run moved into the toolbar row, not just preview and mono/stereo —
|
||||
the full-width strip left the velocity knob and curve button nowhere else to go.
|
||||
- Root drag became absolute-tracking rather than pixel-delta, since black keys
|
||||
overlaying white give no single pixels-per-semitone rate.
|
||||
- **Width uniformity is guaranteed in client pixels only.** Nothing in the instrument
|
||||
consumes a DPI scale factor, so host-side scaling is unverified — Θ-W6 already
|
||||
carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it.
|
||||
- A stale-hover latch was fixed across **all** drag kinds and both drag-termination
|
||||
paths, wider than the strip work that surfaced it.
|
||||
|
||||
+1478
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
||||
# TODO-1.0
|
||||
|
||||
> **`docs/PLAN.md` is now the roadmap.** All seventeen items below have been
|
||||
> consolidated into areas and sequenced into the Phase → Wave → Track hierarchy in
|
||||
> `docs/PLAN.md`; that file is what implementation specialists are dispatched against,
|
||||
> and each of its tracks is self-sufficient for a brief. **This file is retained as the
|
||||
> verbatim-provenance spec appendix** — Daniel's raw asks and every answer round,
|
||||
> unedited, are the source of truth behind PLAN.md's compressed behavior bullets. Its
|
||||
> traceability table maps each item number below onto the track that owns it. Nothing in
|
||||
> this file changes as work lands; PLAN.md points move to `docs/COMPLETED.md`.
|
||||
|
||||
Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two
|
||||
extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering
|
||||
(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day);
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||
|
||||
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||
|
||||
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||
|
||||
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||
|
||||
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||
|
||||
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||
|
||||
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||
|
||||
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||
|
||||
## Filter ring-out is truncated on the source-exhaustion path
|
||||
|
||||
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||
|
||||
**The wart.** The filter's tail is cut at source exhaustion instead of decaying to the filter's own denormal floor.
|
||||
|
||||
**Why the common case is unaffected.** A released Gate note's filter tail is shaped to silence by the **amp release** before the read head reaches the end — that is the pipeline ordering (pitch → filter → amp) working exactly as designed. Trigger's fade-out has already taken the amp to ~0 at `playEnd`, so the discarded state is multiplied by ~0 regardless. The exposed case is a voice that reaches source exhaustion with the amp envelope still open.
|
||||
|
||||
**Intended fix.** Let a voice keep rendering the filter past source exhaustion — zero input, filter ringing — until `VoiceFilter::isSilent()`.
|
||||
|
||||
**The constraint the fix MUST handle (why deferred).** Extending a voice past source exhaustion changes `active()` and `soundingNote()`, and those two predicates feed `VoiceEngine`'s oldest-first stealing policy and the Preserve-voice tally. A ring-out voice would hold an allocation slot and could suppress or be stolen by a note-on that today would be routed differently — a materially larger blast radius than the track that found the defect, which is why it is deferred rather than patched at the call site. The existing takeover declick already carves out an `active() && !soundingNote()` ring-out state; a filter ring-out would be a second occupant of that state and must compose with it rather than fight it.
|
||||
|
||||
**The caveat both reviewers recorded.** The discarded state can be roughly `2Q` larger than the source that produced it, so at high Q the cut **amplifies** the step that already existed at source exhaustion rather than merely preserving it. The defect gets worse the more resonance is dialled in — it is not a uniformly small residual.
|
||||
|
||||
**Priority / risk.** Low / deferred. Recorded during Θ-W2-T1 review and left for a track that can own the voice-lifetime predicates.
|
||||
|
||||
**Done looks like.** A high-Q filtered voice that reaches source exhaustion with the amp envelope still open decays to the filter's denormal floor rather than cutting, with no change to voice-stealing behavior, the Preserve tally, or the takeover-declick ring-out state.
|
||||
|
||||
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
|
||||
|
||||
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# The REAPER extension — a loadable module REAPER dlopen()s, never linked against. Paths
|
||||
# below are rooted at src/, not relative to this directory.
|
||||
# No core/ TU is ever compiled into this source list; every core/ TU enters through a link edge
|
||||
# instead. Compiling one here too would give it its own copy, built under this target's own
|
||||
# compile definitions and include dirs — free to diverge from the library copy every other
|
||||
# consumer (the <module>_tests targets, reasampler_vst) links, with nothing to detect it.
|
||||
|
||||
add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/app/main.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/session.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/ext_state_io.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/prune_fs.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/bank_ops/bank_ops.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_thumbnails.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_window.cpp
|
||||
# draw_kit is compiled into each module rather than being a static library — see root
|
||||
# CMakeLists.txt's LICE_SRC comment for why.
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp
|
||||
${LICE_SRC}
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/action_registry.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/design_view_actions.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
|
||||
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
|
||||
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
|
||||
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
|
||||
# though this target is declared in a subdirectory — the install step copies it from there.
|
||||
# ARCHIVE_OUTPUT_DIRECTORY pins the same for MODULE targets: CMake emits an import-lib
|
||||
# sidecar (.lib/.exp on MSVC) keyed off ARCHIVE_OUTPUT_DIRECTORY, not LIBRARY_OUTPUT_DIRECTORY,
|
||||
# so it needs pinning too even though nothing links against this import lib.
|
||||
set_target_properties(reaper_reasampler PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "${REASAMPLER_OUTPUT_NAME}"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
|
||||
|
||||
if(WIN32)
|
||||
# Native Win32; REAPER provides nothing extra to link. The bank-panel dialog template
|
||||
# is compiled from resource.rc by the platform RC compiler.
|
||||
target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc)
|
||||
|
||||
elseif(APPLE)
|
||||
# Use REAPER's OWN SWELL at runtime via the modstub. Do NOT build full SWELL —
|
||||
# SWELL_PROVIDED_BY_APP routes calls to the host.
|
||||
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub.mm)
|
||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit")
|
||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".dylib")
|
||||
# SWELL can't read a Win32 .rc directly. Run resgen once to turn resource.rc into a
|
||||
# C++ source, then add it here:
|
||||
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||
|
||||
else()
|
||||
# Linux: REAPER's libSwell.so is used at runtime via the generic modstub. With
|
||||
# SWELL_PROVIDED_BY_APP you can drop pkg-config / -lX11 entirely.
|
||||
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp)
|
||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".so")
|
||||
# Reuse the macOS resgen output (see CLAUDE.md, SWELL dialog resources), then add the
|
||||
# generated source:
|
||||
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||
endif()
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
|
||||
#include "core/capture/render_settings.h" // captureActionTable
|
||||
#include "core/version/app_version.h" // appVersion
|
||||
#include "ingest.h"
|
||||
#include "shell/actions/ingest.h"
|
||||
#include "shell/actions/action_registry.h" // the registration table
|
||||
#include "shell/actions/bank_actions.h" // multi-bank action family
|
||||
#include "shell/actions/design_view_actions.h" // Design View action family
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# The pure substrate — see root CLAUDE.md's architecture section for the core/ purity invariant.
|
||||
#
|
||||
# Declaration order below runs base-first so the file reads as a dependency ladder; CMake
|
||||
# itself does not require it (link names resolve at generate time), and a few edges do run
|
||||
# backwards — core/wire's instrument_drop reuses the instrument's own state codec.
|
||||
|
||||
add_subdirectory(json)
|
||||
add_subdirectory(util)
|
||||
add_subdirectory(wire)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(model)
|
||||
add_subdirectory(capture)
|
||||
add_subdirectory(reclaim)
|
||||
add_subdirectory(version)
|
||||
add_subdirectory(view)
|
||||
add_subdirectory(ui)
|
||||
add_subdirectory(instrument)
|
||||
@@ -0,0 +1,2 @@
|
||||
reasampler_pure_library(peaks SOURCES peaks.cpp)
|
||||
reasampler_test(peaks LINK peaks)
|
||||
@@ -0,0 +1,22 @@
|
||||
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
||||
reasampler_test(capture_paths LINK capture_paths)
|
||||
|
||||
reasampler_pure_library(insert_plan SOURCES insert_plan.cpp)
|
||||
reasampler_test(insert_plan LINK insert_plan)
|
||||
|
||||
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(render_settings LINK render_settings)
|
||||
|
||||
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||
reasampler_test(batch_capture LINK batch_capture)
|
||||
|
||||
reasampler_pure_library(tail_control
|
||||
SOURCES tail_control.cpp
|
||||
LINK PUBLIC render_settings PRIVATE json)
|
||||
reasampler_test(tail_control LINK tail_control)
|
||||
|
||||
reasampler_pure_library(capture_realtime SOURCES capture_realtime.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(capture_realtime LINK capture_realtime)
|
||||
|
||||
reasampler_pure_library(wav_codec SOURCES wav_codec.cpp LINK PUBLIC peaks)
|
||||
reasampler_test(wav_codec LINK wav_codec)
|
||||
@@ -5,16 +5,17 @@
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, per-zone play params, pitch shifting,
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the
|
||||
small pure helpers the engine/shell share (bank-generation sync, bridge-read
|
||||
marshalling, note-name parsing, Trigger frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
|
||||
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
|
||||
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
|
||||
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
|
||||
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
||||
the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
|
||||
`reasampler_embed`, and `vst_entry`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -39,6 +40,21 @@ subdirectories:
|
||||
audio — the bank index, the mapping, which project is active — the instrument reads
|
||||
the live `"reasampler"` ext-state via the bridge.
|
||||
|
||||
### One capture = one parameter set
|
||||
|
||||
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
|
||||
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
|
||||
captures: every playback parameter edits in exactly one place, and no gesture can express
|
||||
per-zone divergence. The root note survives as a first-class parameter of that one set.
|
||||
|
||||
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
|
||||
its root, with key-tracking applied. A user-settable low/high playable range is
|
||||
re-addable later as two ordinary parameters if it is ever missed.
|
||||
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
|
||||
one's capture and zone one's parameters; the rest drop, touching no file and no bank
|
||||
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
|
||||
relaxed for a genuinely multi-zone instance.
|
||||
|
||||
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
|
||||
|
||||
The split model is the settled answer, mirroring the capture/placement separation:
|
||||
@@ -47,20 +63,18 @@ The split model is the settled answer, mirroring the capture/placement separatio
|
||||
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
|
||||
notes) are facts about the file, added as an additive field extension (same shape as
|
||||
`provenance`).
|
||||
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
|
||||
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
|
||||
trim are a performance choice, not a fact about a file — they belong to the instrument,
|
||||
not the bank. This "who owns which field" rule (D-B) governs every performance-map
|
||||
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
|
||||
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
|
||||
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
|
||||
the bank.
|
||||
- **Performance choices live in the instrument.** Amplitude envelopes and per-sample
|
||||
tuning/gain trim are a performance choice, not a fact about a file — they belong to the
|
||||
instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
|
||||
added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
|
||||
envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
|
||||
per-instance `ComponentState`, never written to `Sample` or the bank.
|
||||
|
||||
### The pure core (D3 — the load-bearing split)
|
||||
|
||||
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
|
||||
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
|
||||
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
|
||||
a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
|
||||
`bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
|
||||
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
|
||||
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
|
||||
@@ -112,7 +126,7 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
held/out of scope (fork S15-F1).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (per-zone toggle, S16).** Varispeed (current/
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
|
||||
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
|
||||
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
|
||||
@@ -157,25 +171,25 @@ The amp envelope is drawn as a curve over the Sample view's hero waveform at the
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same zone envelope fields, so a drag updates the params,
|
||||
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth,
|
||||
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a
|
||||
node cannot be dragged past its neighbours) and range-clamped to the same per-param
|
||||
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't.
|
||||
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay`
|
||||
and `envelope_edit` in Modules below.
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
|
||||
### New performance-map parameters — ownership and persistence (D-B)
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
|
||||
- **Key-tracking** — additive/version-bumped component state, default 100%
|
||||
(absent field on an older blob lifts to 100%, bit-identical playback).
|
||||
- **Preview velocity** — a per-instance utility setting for the Sample view's
|
||||
preview-trigger button (not a musical parameter of the capture); **persists across
|
||||
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
||||
extension's `persist` ext-state module (that would make it project-global rather than
|
||||
per-instance and leak an instrument concern into the extension's key space).
|
||||
- **Velocity curve** — per-zone; the one non-back-compat surface in S-VIEW: an
|
||||
already-saved zone with no stored curve now plays every velocity at unity under the
|
||||
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
|
||||
already-saved instance with no stored curve now plays every velocity at unity under the
|
||||
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
|
||||
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
|
||||
|
||||
@@ -183,31 +197,37 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
### `engine/`
|
||||
|
||||
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — zone payload: zones keyed by note range. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). JSON round-trip.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine (`sampler_core`/`pitch_shift`) to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift.
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
|
||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||
|
||||
### `ui/`
|
||||
|
||||
- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry.
|
||||
- `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
|
||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
||||
- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
||||
@@ -216,8 +236,15 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
- **Two superseded designs are called out in Invariants above**: the earlier
|
||||
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
|
||||
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
|
||||
for both — do not reintroduce either superseded design.
|
||||
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
|
||||
covers client-pixel widths (including multiples standing in for larger client areas);
|
||||
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
|
||||
scaling of the plugin window — which would resample the uniform integer key widths at the
|
||||
physical-pixel level — is unverified.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(ui)
|
||||
@@ -0,0 +1,31 @@
|
||||
# The shifter is hand-rolled rather than WDL_SimplePitchShifter because that header drags
|
||||
# <windows.h> in via wdltypes.h, which cannot enter the pure engine.
|
||||
reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
|
||||
# Links only pitch_shift: linking more would break the plain-data-boundary proof — and
|
||||
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
|
||||
reasampler_test(pitch_shift LINK pitch_shift)
|
||||
|
||||
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
|
||||
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
|
||||
# depend on the curve without inheriting the editor's layout types.
|
||||
reasampler_test(velocity_curve LINK velocity_curve)
|
||||
|
||||
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
||||
reasampler_test(master_gain LINK master_gain)
|
||||
|
||||
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||
add_subdirectory(filter)
|
||||
|
||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
|
||||
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||
reasampler_test(sampler_filter LINK sampler_core)
|
||||
@@ -0,0 +1,266 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
double tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at
|
||||
// 1.0 beyond what Attack already emitted) so a zero-length hold emits no
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||
// the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on
|
||||
// readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,256 @@
|
||||
# src/core/instrument/engine/filter — the per-voice resonant filter
|
||||
|
||||
## Scope
|
||||
|
||||
The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
|
||||
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
||||
VST3, no allocation, no I/O. Everything
|
||||
here lives in `reasampler::instrument::engine::filter`, nested per the
|
||||
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
|
||||
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
|
||||
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
|
||||
merely tidy. Five files, one responsibility each:
|
||||
|
||||
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
||||
drive depth, plus the exact inverses for cutoff and Q.
|
||||
- `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
|
||||
(cutoff Hz, Q, sample rate).
|
||||
- `filter_morph` — the morph domain: `MorphLaw`, normalized position → per-tap weights under
|
||||
the selected law, and the fold of those weights into the three multipliers the kernel
|
||||
applies.
|
||||
- `filter_saturate` — `softLimit`, the drive stage's shaper. Header-only inline; it sits
|
||||
inside the per-sample recursion.
|
||||
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
|
||||
`process()` is defined in the header.
|
||||
|
||||
### The cutoff is the only control that re-solves per frame, and it re-solves alone
|
||||
|
||||
`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
|
||||
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
|
||||
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
|
||||
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
|
||||
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
|
||||
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.
|
||||
|
||||
Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:
|
||||
|
||||
| per frame | ns |
|
||||
|---|---|
|
||||
| kernel alone, no re-solve | 2.8 |
|
||||
| full `prepare()` | 56.9 |
|
||||
| `setCutoffNorm`, constant logs recomputed | 22.8 |
|
||||
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |
|
||||
|
||||
The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
|
||||
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
|
||||
and the Q parabola are functions of compile-time constants, and recomputing those five
|
||||
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
|
||||
control value to save the solve** — make the solve cheaper instead.
|
||||
|
||||
## Invariants
|
||||
|
||||
### No vtable on the per-sample path
|
||||
|
||||
The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase` → `Filter` →
|
||||
`Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
|
||||
pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
|
||||
chain could wrap it. **None of that came across, and none of it may come back.**
|
||||
`VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
|
||||
seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
|
||||
heuristic 3 names this class of dispatch blowout directly.
|
||||
|
||||
### The rate enters ONLY through `g = tan(pi*fc/sr)`
|
||||
|
||||
There is no reference sample rate, calibration rate, or fallback rate anywhere in this
|
||||
module, and introducing one is the specific regression to guard against. An earlier design
|
||||
carried a `kFilterFeedbackDelaySeconds = 1/48000` tuning constant for a feedback tap; that
|
||||
tap, its ring buffer, and the constant are all deleted. A non-positive rate yields `g == 0`
|
||||
and a bypass mix (signal passes through) — never an invented rate.
|
||||
|
||||
### Why the high-pass feedback tap was right on Q15 hardware and wrong here
|
||||
|
||||
The ported firmware fed a saturated share of an earlier output back into the high-pass
|
||||
input. Its stated rationale — that the HP numerator collapses toward zero at low cutoff,
|
||||
taking the resonance with it — is **inverted**, and the comment asserting it has been
|
||||
removed rather than carried forward. Measurement: the HP `b0` approaches **1** as cutoff
|
||||
falls (0.99987 at 20 Hz); it is the **low-pass** `b0` that collapses (1.7e−06 at 20 Hz).
|
||||
|
||||
The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
|
||||
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
|
||||
another route. float32 survives that cancellation with 7 bits to spare, so on this target
|
||||
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
|
||||
target with the tap disabled, and 25% off with it enabled), and it introduced both level
|
||||
dependence and rate dependence.
|
||||
|
||||
Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
|
||||
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
|
||||
now an explicit user-controlled stage instead of an emergent side effect.
|
||||
|
||||
### The morph is a blend of taps, never a coefficient switch
|
||||
|
||||
An SVF produces high, band, and low from the same state, which is the reason this topology
|
||||
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, LP at 1.0, continuous
|
||||
throughout, and both endpoints are exact under either law — only the centre differs.
|
||||
|
||||
The crossfade is **equal-power** in both laws, and that is forced by the topology rather
|
||||
than picked by ear. At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent
|
||||
taps in exact quadrature and HP/LP in exact antiphase, relationships the bilinear transform
|
||||
preserves exactly at the prewarped corner. A `cos`/`sin` pair therefore holds the crossfaded
|
||||
power at unity across the whole sweep; a linear crossfade of a quadrature pair would sag to
|
||||
`1/sqrt(2)` mid-leg, a 3 dB hole that reads as a defect rather than as character.
|
||||
|
||||
### The two morph laws, and why only one of them has a flat corner
|
||||
|
||||
`MorphLaw` is a two-value selector on `FilterSettings`, **defaulting to `HighBandLow`** —
|
||||
that is the reviewed-and-measured law, and it is enumerator 0 so a zero-initialized or absent
|
||||
persisted field lands on it rather than on the SEM leg.
|
||||
|
||||
- **`HighBandLow` (HP→BP→LP, the default).** Two equal-power legs crossfading **adjacent taps
|
||||
only**, BP at the centre. Because adjacent taps are in quadrature, the corner magnitude is
|
||||
algebraically `Q*sqrt(cos² + sin²) = Q` at every position — measured flat to 4e-6 across 65
|
||||
positions. **That flatness guarantee is specific to this law.** Do not weaken the assertion
|
||||
that pins it in order to accommodate the other law.
|
||||
- **`HighNotchLow` (HP→notch→LP, the Oberheim SEM).** One equal-power crossfade weighting HP
|
||||
and LP **together** across the whole sweep, `bp == 0` throughout. The notch is not tuned in:
|
||||
HP and LP sit at exactly +90° and −90° at the corner, so equal weights cancel there by
|
||||
construction. Here the corner magnitude deliberately goes to **zero** at the centre —
|
||||
measured worst case −88 dB on the shipped `{250, 1000, 4000}` Hz cutoff grid, typically −110 to
|
||||
−145 dB. Over the full control range (20 Hz – 20 kHz, Q 0.1 – 10) the worst residual is
|
||||
shallower — −69.8 dB at 192 kHz / 30 Hz / Q=10 — from float conditioning in the folded
|
||||
`x − k·v1` term as `fc/sr → 1e-4` at high Q; it is Q-dependent (Q=0.1 holds −110 dB everywhere)
|
||||
and still an excellent notch, not a broadband defect. `test_filter.cpp`'s null test covers this
|
||||
full range with a Q-scaled threshold rather than the flat −74 dB the shipped grid alone would
|
||||
justify. The fold makes the centre's cancellation structural rather than a runtime near-miss:
|
||||
`m2 = lp - hp` is **exactly** `0.0f` at the centre, because `cos` and `sin` of π/4 differ by
|
||||
about an ulp of *double*, nine orders below float's spacing there, so they narrow to one float.
|
||||
|
||||
SEM's zero is at the **notch frequency**, not a broadband level sag — off the corner the pair
|
||||
is still equal-power, so neither law's legs dip. Measuring that requires dividing by each
|
||||
tap's own analytic response first: at `Q = 0.1` a 2-pole approaches its passband so slowly
|
||||
that the pure low tap still reads 0.896 at 50 Hz, and a raw reading would report a 20% "sag"
|
||||
that is the Q, not the morph.
|
||||
|
||||
**The toggle is free on the hot path, and must stay that way.** `morphWeights` runs at
|
||||
`prepare()` cadence; the law is consumed there and nowhere else. The kernel, `svfCoeffs`, and
|
||||
`morphMix`'s fold are identical between the laws — all a law selects is three floats the
|
||||
kernel was already multiplying by. Verified at the machine-code level, not by inspection: the
|
||||
same TU compiled `/O2` against the pre-toggle and post-toggle headers emits byte-identical
|
||||
assembly for `process()` and `processFrame()`. `VoiceFilter` gained no member and `process()`
|
||||
gained no branch. A design that puts the law selector inside the per-sample path is wrong —
|
||||
rework it rather than paying for it.
|
||||
|
||||
### Drive is a contraction inside the loop, which is what makes it unconditionally stable
|
||||
|
||||
`softLimit(u, depth) = u / sqrt(1 + (depth*u)²)` shapes the **band-pass integrator state**.
|
||||
Three properties carry the design:
|
||||
|
||||
- `depth == 0` makes it algebraically the identity (`x / sqrt(1) == x`, exact in IEEE), so
|
||||
drive 0 is **bit-exact** linear whether or not `softLimit` is actually called. The test
|
||||
asserts bit-identity against the same kernel with the limiter deleted.
|
||||
- `process()` gates the call on `driven_` (`driveDepth_ != 0`, cached at `prepare()`) rather
|
||||
than calling `softLimit` unconditionally. `sqrt`/div sit on the per-sample recursive
|
||||
dependency chain, so out-of-order execution can't hide their latency, and at drive 0 that
|
||||
cost buys nothing. Measured: 11.2 ns/sample unconditional vs 4.1 ns gated — the gated form
|
||||
lands at the limiter-removed floor. `driven_` only changes at `prepare()`, so the branch
|
||||
predicts perfectly. The gate is a perf optimization on top of the bit-identity above, not a
|
||||
substitute for it — deleting the gate would still be correct, just 2.7x slower at rest.
|
||||
- `|softLimit(u, d)| <= |u|` for every depth, so the state update can only shrink the state.
|
||||
The filter cannot gain energy from the drive stage: stability at any Q and any cutoff is
|
||||
structural, and self-oscillation is impossible. This is why the shaper must keep unit slope
|
||||
at the origin — a shaper with gain above 1 there turns the resonator into an oscillator.
|
||||
- It shapes the **state**, not the zero-delay loop. A nonlinearity inside the loop would
|
||||
break the closed-form `a1`/`a2`/`a3` solve and need per-sample Newton iteration.
|
||||
|
||||
Placement is the resonance path because that is where the firmware's character came from,
|
||||
and because the band-pass state sits at zero in the passband and at DC — so drive colours
|
||||
the resonance and leaves the passband transparent (measured 0.98 at max drive). It is not a
|
||||
distortion box in series with the signal; a caller wanting that has every other plugin.
|
||||
|
||||
**Drive × resonance interact by design.** What reaches the shaper is the resonance state,
|
||||
already multiplied by roughly `2*Q`, so the same drive setting bites harder the more
|
||||
resonance is dialled in — and harder on a hotter input. That level dependence is the
|
||||
*point* of an explicit drive control; what Daniel rejected was level dependence nobody
|
||||
asked for. At drive 0 there is none, to 0.0004% over a 1000:1 level range.
|
||||
|
||||
`kFilterDriveDepthMax` (4.0) was set against measurement, not feel: at max drive, full-scale
|
||||
input and max resonance the resonant peak lands ~10 dB under the passband — plainly
|
||||
crushed, which is the asked-for "extreme". Raising it further inverts the filter's shape
|
||||
(21 dB under passband at depth 64), turning the peak the user dialled in into a notch.
|
||||
There is deliberately **no makeup gain** — any law for it would be invented rather than
|
||||
derived, and drive is due an ear pass against the radial dial.
|
||||
|
||||
### The cutoff control is sample-rate-free; the clamp is not
|
||||
|
||||
`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3 is
|
||||
200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
|
||||
knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
|
||||
96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
|
||||
transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
|
||||
is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
|
||||
live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.
|
||||
|
||||
### Q spans 0.1 → 10 with √2 at the center
|
||||
|
||||
Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
|
||||
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
|
||||
three anchors rather than two spliced log segments — same anchors either way, but no slope
|
||||
kink at the center detent. The quadratic term is nonzero only because √2 is not the
|
||||
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
|
||||
`k = 1/Q`.
|
||||
|
||||
### Denormal flushing: why conjunctive, honestly
|
||||
|
||||
`process()` flushes **both** integrators to exact zero once both are below
|
||||
`kFilterDenormalFloor` (1e-30). The honest reason is narrower than it sounds: `isSilent()`
|
||||
means "both integrators are exactly zero," so both have to reach zero for that check to mean
|
||||
anything, and the conjunctive test is the cheapest way to guarantee it.
|
||||
|
||||
The stronger claim — that a per-variable flush limit-cycles at the floor — does **not**
|
||||
reproduce on this topology. Measured (Q=10, fc=1kHz, 48k): shipped conjunctive goes silent at
|
||||
sample 10783 with 0 subnormals; a per-variable independent flush goes silent ~180 samples
|
||||
earlier and an either-below-zero-both flush ~970 samples earlier, both also 0 subnormals, no
|
||||
limit cycle, and the same excited RMS. That claim WAS real on the retired Direct Form I state,
|
||||
where the flushed variables (`y1`/`y2`) were the actual filter OUTPUT, so zeroing one injected
|
||||
a discontinuity the resonance then amplified. Here `ic1`/`ic2` are integrator STATE, not
|
||||
output: zeroing one only removes energy, a contraction rather than an injection, so the hazard
|
||||
is structurally absent. The only demonstrable hazard is no flush at all, which never reaches
|
||||
exact zero and grinds through subnormals for thousands of samples on a released voice.
|
||||
|
||||
Keep the conjunctive test regardless — it costs nothing extra and is the right guarantee for
|
||||
`isSilent()` — but don't cite the limit-cycle rationale for TPT; it belongs to the retired
|
||||
topology.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **TPT is what fixed the low-cutoff conditioning defect** — this is a topology change, not
|
||||
a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
|
||||
them against each other every sample; at `fc/sr ≈ 1e-4` that ~17-bit cancellation moved the
|
||||
measured 20 Hz / 192 kHz LP peak by **-27% on a true-peak scan, -57% measured at the
|
||||
analytic peak frequency** (the degraded pole itself moves, so the two methods diverge), and
|
||||
the error is non-monotone with rate rather than a fixed percentage (+5% high at 96 kHz).
|
||||
TPT encodes the same proximity in `a1`'s small deviation from 1, which float32 resolves:
|
||||
checked against an exact-double evaluation of the same difference equation (which matches
|
||||
the analytic target to within measurement noise), TPT's float32-narrowed coefficients are
|
||||
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
|
||||
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
|
||||
either way. Do not reintroduce a direct-form kernel.
|
||||
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
|
||||
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
|
||||
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
|
||||
the state at all, so a stale nonzero `ic1`/`ic2` would otherwise latch `isSilent()` false
|
||||
forever with no audible effect either way — `prepare()` clears state on that path only,
|
||||
which costs nothing audibly since bypass ignores it.
|
||||
- **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
|
||||
leg endpoints instead of trusting `cos`/`sin` to land on 0 and 1, which they miss by ~1e-17
|
||||
— enough to leave a -324 dB neighbour tap in what is specified as a pure response.
|
||||
- **A NaN morph position falls back per law, not to one shared value.** Every comparison
|
||||
against NaN is false, so it clamps to neither endpoint: `HighBandLow` lands on pure
|
||||
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
||||
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
||||
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
||||
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
|
||||
It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
|
||||
so the corner glides rather than staircasing.
|
||||
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
||||
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
||||
is ample at 48k and expires mid-decay at 96k and above.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||
reasampler_pure_library(filter SOURCES
|
||||
filter_params.cpp
|
||||
filter_coeffs.cpp
|
||||
filter_morph.cpp
|
||||
voice_filter.cpp)
|
||||
|
||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||
# a measurement reference is a worse defect than a long file.
|
||||
reasampler_test(filter_params LINK filter)
|
||||
reasampler_test(filter_morph LINK filter)
|
||||
reasampler_test(filter_state LINK filter)
|
||||
reasampler_test(filter LINK filter)
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
// M_PI is not standard C++ and is absent on MSVC without _USE_MATH_DEFINES.
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
|
||||
const double qq = clampd(q, kFilterQMin, kFilterQMax);
|
||||
const double k = 1.0 / qq;
|
||||
|
||||
double g = 0.0;
|
||||
if (sampleRate > 0.0) {
|
||||
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
|
||||
g = std::tan(kPi * fc / sampleRate);
|
||||
}
|
||||
|
||||
// Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
|
||||
// pole proximity, so forming it in float would throw away the conditioning TPT just bought.
|
||||
const double a1 = 1.0 / (1.0 + g * (g + k));
|
||||
const double a2 = g * a1;
|
||||
const double a3 = g * a2;
|
||||
|
||||
SvfCoeffs c;
|
||||
c.g = static_cast<float>(g);
|
||||
c.k = static_cast<float>(k);
|
||||
c.a1 = static_cast<float>(a1);
|
||||
c.a2 = static_cast<float>(a2);
|
||||
c.a3 = static_cast<float>(a3);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,42 @@
|
||||
// filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
|
||||
// The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
|
||||
// anywhere in this module, and reintroducing one would restore the rate-dependent resonance
|
||||
// the TPT rewrite exists to remove.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
|
||||
// zero-delay feedback loop, so the kernel needs no iteration.
|
||||
struct SvfCoeffs {
|
||||
float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
|
||||
float k = 1.0f; // 1/Q, the damping term
|
||||
float a1 = 1.0f;
|
||||
float a2 = 0.0f;
|
||||
float a3 = 0.0f;
|
||||
};
|
||||
|
||||
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
|
||||
// as fc approaches sr/2.
|
||||
inline constexpr double kFilterNyquistFraction = 0.48;
|
||||
|
||||
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
|
||||
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
|
||||
// rate rather than assume 44.1k.
|
||||
//
|
||||
// Float storage is safe HERE in a way it was not for the retired Direct Form I path. DF1 encoded
|
||||
// pole proximity in a1 -> -2, a2 -> +1 and cancelled them against each other every sample; at
|
||||
// fc/sr ~ 1e-4 that ~17-bit cancellation moved the measured 20 Hz/192 kHz LP peak by -27%
|
||||
// (true-peak scan) to -57% (point measurement at the analytic peak frequency, since the
|
||||
// degraded pole itself moves) -- and the error is non-monotone with rate, not a fixed percentage
|
||||
// (+5% high at 96 kHz). TPT encodes the same proximity in a1's small DEVIATION from 1, which
|
||||
// float resolves: measured against an exact-double evaluation of the same difference equation
|
||||
// (which matches the analytic target to within measurement noise), TPT's float32-narrowed
|
||||
// coefficients land genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz -- both
|
||||
// comfortably inside the test's 0.4% tolerance.
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "core/instrument/engine/filter/filter_morph.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
struct Pair {
|
||||
double a, b;
|
||||
};
|
||||
|
||||
// Equal-power crossfade, EXACT at both ends by construction rather than by rounding: cos and sin
|
||||
// of the leg's quarter turn are only 1e-17 from 0/1 at the endpoints, and the endpoints have to
|
||||
// be pure taps, not a pure tap plus a -324 dB neighbour.
|
||||
Pair equalPower(double t) {
|
||||
if (!(t > 0.0)) return {1.0, 0.0};
|
||||
if (t >= 1.0) return {0.0, 1.0};
|
||||
const double theta = 0.5 * kPi * t;
|
||||
return {std::cos(theta), std::sin(theta)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MorphWeights morphWeights(float norm, MorphLaw law) {
|
||||
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
|
||||
|
||||
MorphWeights w;
|
||||
if (law == MorphLaw::HighNotchLow) {
|
||||
// ONE crossfade across the whole sweep rather than two legs, so HP and LP carry weight
|
||||
// together everywhere between the endpoints and are equal at the centre.
|
||||
const Pair p = equalPower(n);
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = 0.0f;
|
||||
w.lp = static_cast<float>(p.b);
|
||||
return w;
|
||||
}
|
||||
|
||||
if (n <= 0.5) {
|
||||
const Pair p = equalPower(2.0 * n); // HP -> BP
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = static_cast<float>(p.b);
|
||||
w.lp = 0.0f;
|
||||
} else {
|
||||
const Pair p = equalPower(2.0 * n - 1.0); // BP -> LP
|
||||
w.hp = 0.0f;
|
||||
w.bp = static_cast<float>(p.a);
|
||||
w.lp = static_cast<float>(p.b);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k) {
|
||||
MorphMix m;
|
||||
m.m0 = w.hp;
|
||||
m.m1 = w.bp - w.hp * k;
|
||||
m.m2 = w.lp - w.hp;
|
||||
return m;
|
||||
}
|
||||
|
||||
MorphMix bypassMix() { return MorphMix{1.0f, 0.0f, 0.0f}; }
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,70 @@
|
||||
// filter_morph.h — the continuous morph: normalized position to tap weights under one of two
|
||||
// laws, and the fold of those weights into the three multipliers the kernel actually applies.
|
||||
// An SVF produces all three taps from one state, so the morph is a blend, never a coefficient
|
||||
// switch. Weights are computed at prepare() cadence; the law never reaches the per-sample path.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Which shape the sweep traces between its two fixed endpoints. This selects CHARACTER, not
|
||||
// topology — same SVF, same coefficients, same kernel under either law; only the centre differs.
|
||||
//
|
||||
// HighBandLow is enumerator 0 deliberately: a zero-initialized or absent persisted field then
|
||||
// lands on the default rather than on the SEM leg.
|
||||
enum class MorphLaw {
|
||||
// HP -> BP -> LP. Crossfades ADJACENT taps only, so the corner magnitude is flat at Q the
|
||||
// whole way across. The default.
|
||||
HighBandLow,
|
||||
// HP -> notch -> LP, the Oberheim SEM. One crossfade weighting HP and LP together, bp == 0
|
||||
// throughout; the notch falls out of the antiphase cancellation rather than being tuned in.
|
||||
HighNotchLow,
|
||||
};
|
||||
|
||||
// Weight on each SVF tap. Under HighBandLow exactly one of hp/lp is nonzero at a time — that law
|
||||
// crossfades adjacent taps only, never HP against LP. Under HighNotchLow bp is always zero and
|
||||
// hp/lp carry weight together, which is precisely what cuts the notch.
|
||||
struct MorphWeights {
|
||||
float hp = 0.0f;
|
||||
float bp = 0.0f;
|
||||
float lp = 1.0f;
|
||||
};
|
||||
|
||||
// HP at 0.0, LP at 1.0 under BOTH laws; the centre is a band-pass under HighBandLow and a notch
|
||||
// under HighNotchLow. Out-of-range norm clamps to the endpoints; NaN clamps to neither (every
|
||||
// comparison against it is false) and lands on the law's degenerate — pure band-pass under
|
||||
// HighBandLow, pure high-pass under HighNotchLow, which has no band tap to land on.
|
||||
//
|
||||
// Equal-power (cos/sin) in both laws rather than linear, and that choice is forced by the
|
||||
// topology rather than picked by ear. At the corner frequency the three taps are HP = jQ,
|
||||
// BP = Q, LP = -jQ, so ADJACENT taps are in exact QUADRATURE there (and the bilinear transform
|
||||
// preserves that exactly at the prewarped corner). Under HighBandLow's cos/sin pair the corner
|
||||
// magnitude is therefore Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat
|
||||
// across the whole sweep. A linear crossfade of the same quadrature pair would sag to Q/sqrt(2),
|
||||
// a 3 dB hole mid-leg.
|
||||
//
|
||||
// HP and LP are exactly ANTIPHASE at the corner (+90 and -90 degrees), so a law giving both
|
||||
// simultaneous weight cancels there. HighBandLow avoids that by staying adjacent; HighNotchLow
|
||||
// uses it — one equal-power crossfade of HP against LP over the whole sweep puts equal weights
|
||||
// at the centre and the null is exact by construction, not tuned. That is why the corner-flat-at-Q
|
||||
// guarantee is specific to HighBandLow: on the SEM leg the corner magnitude deliberately goes to
|
||||
// zero at the centre. Equal power still holds off the notch frequency, so neither law's legs sag.
|
||||
MorphWeights morphWeights(float norm, MorphLaw law);
|
||||
|
||||
// The kernel applies out = m0*v0 + m1*v1 + m2*v2, where v0 is the input and v1/v2 are the SVF's
|
||||
// band and low outputs. Folding hp = v0 - k*v1 - v2 into the weights here keeps the per-sample
|
||||
// path at three multiplies and spares it ever forming the high tap.
|
||||
struct MorphMix {
|
||||
float m0 = 0.0f;
|
||||
float m1 = 0.0f;
|
||||
float m2 = 1.0f;
|
||||
};
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k);
|
||||
|
||||
// Passes the input through untouched, whatever the morph position asks for. Reserved for a
|
||||
// sample rate we cannot form a filter from: silencing an instrument is a worse failure than
|
||||
// ignoring the morph, and at g == 0 a low-pass tap is analytically silent.
|
||||
MorphMix bypassMix();
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
// log Q = A + B*n + C*n^2, solved from the three anchor points. C is nonzero precisely
|
||||
// because the center anchor sqrt(2) is not the geometric mean of the endpoints (which is 1);
|
||||
// were they equal the curve would degenerate to a plain log sweep and the inverse below
|
||||
// would divide by zero.
|
||||
struct QCurve {
|
||||
double a, b, c;
|
||||
};
|
||||
|
||||
QCurve solveQCurve() {
|
||||
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
|
||||
}
|
||||
|
||||
// Functions of compile-time constants alone, so they resolve once at static init rather than
|
||||
// per call. Load-bearing rather than tidy: a modulated cutoff re-solves EVERY FRAME, and
|
||||
// recomputing these logarithms of literals cost more than the solve they feed.
|
||||
const double kLogCutoffMin = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||
const double kLogCutoffSpan =
|
||||
std::log(static_cast<double>(kFilterCutoffMaxHz)) - kLogCutoffMin;
|
||||
const QCurve kQCurve = solveQCurve();
|
||||
|
||||
} // namespace
|
||||
|
||||
float filterCutoffHzFromNorm(float norm) {
|
||||
return static_cast<float>(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterNormFromCutoffHz(float hz) {
|
||||
if (!(hz > 0.0f)) return 0.0f;
|
||||
return static_cast<float>(
|
||||
clamp01((std::log(static_cast<double>(hz)) - kLogCutoffMin) / kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterQFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(std::exp(kQCurve.a + n * (kQCurve.b + kQCurve.c * n)));
|
||||
}
|
||||
|
||||
float filterDriveDepthFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
||||
}
|
||||
|
||||
float filterNormFromQ(float q) {
|
||||
if (!(q > kFilterQMin)) return 0.0f;
|
||||
if (q >= kFilterQMax) return 1.0f;
|
||||
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
|
||||
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
|
||||
const QCurve& k = kQCurve;
|
||||
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
|
||||
if (!(d >= 0.0)) return 0.0f;
|
||||
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
|
||||
// vertex sits well above 1 for the settled anchors.
|
||||
return static_cast<float>(clamp01((-k.b + std::sqrt(d)) / (2.0 * k.c)));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,52 @@
|
||||
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
|
||||
// positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
|
||||
// clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
|
||||
// normalized cutoff means the same frequency at every project rate.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
|
||||
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
|
||||
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
|
||||
// be dead against the Nyquist clamp (the ported firmware's 23 kHz endpoint had exactly that
|
||||
// defect). 20 kHz sits under 0.48*sr at 44.1 kHz and above; below that (e.g. 32 kHz, 22.05 kHz)
|
||||
// the clamp still handles it correctly, it just eats the top of the knob travel at those rates.
|
||||
inline constexpr float kFilterCutoffMinHz = 20.0f;
|
||||
inline constexpr float kFilterCutoffMaxHz = 20000.0f;
|
||||
|
||||
// Q spans the full range with Butterworth (sqrt(2)) at the control's center detent.
|
||||
inline constexpr float kFilterQMin = 0.1f;
|
||||
inline constexpr float kFilterQMax = 10.0f;
|
||||
inline constexpr float kFilterQCenter = 1.41421356f;
|
||||
|
||||
// Depth at the top of the drive control. The limiter's knee is at 1/depth, and the resonance
|
||||
// swings the state to roughly 2*Q*level, so this is the range over which drive bites. Chosen
|
||||
// against measurement rather than by feel: at max drive, full-scale input and max resonance the
|
||||
// resonant peak lands ~10 dB under the passband — plainly crushed, which is the asked-for
|
||||
// "extreme". Raising it further inverts the filter's shape (measured 21 dB under passband at
|
||||
// depth 64), turning the peak the user dialled in into a notch.
|
||||
inline constexpr float kFilterDriveDepthMax = 4.0f;
|
||||
|
||||
// Out-of-range norm clamps to the endpoints.
|
||||
float filterCutoffHzFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterCutoffHzFromNorm over the band; out-of-band Hz clamps to 0 or 1.
|
||||
float filterNormFromCutoffHz(float hz);
|
||||
|
||||
// A single smooth curve — quadratic in log Q — through (0, kFilterQMin),
|
||||
// (0.5, kFilterQCenter), (1, kFilterQMax), rather than two spliced log segments. Same three
|
||||
// anchors either way, but the single curve has no slope kink at the center detent.
|
||||
float filterQFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
|
||||
float filterNormFromQ(float q);
|
||||
|
||||
// Drive depth for the in-loop limiter. Square law, not linear: the knee is 1/depth, so a linear
|
||||
// depth would spend most of the audible travel in the first tenth of the knob. Exactly 0 at
|
||||
// norm 0 — the limiter is then algebraically the identity, which is what makes drive=0 bit-exact
|
||||
// linear rather than merely close.
|
||||
float filterDriveDepthFromNorm(float norm);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,34 @@
|
||||
// filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
|
||||
// per-voice per-sample recursion.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
|
||||
//
|
||||
// Three properties are load-bearing and none of them are tuning:
|
||||
// - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
|
||||
// drive = 0 is bit-exact linear whether or not the caller special-cases it. (voice_filter.h
|
||||
// gates the call on drive != 0 anyway, but as a perf optimization, not because correctness
|
||||
// needs it.)
|
||||
// - |softLimit(x, d)| <= |x| for every d, so dropping it into the resonance state update can
|
||||
// only ever shrink the state. The filter therefore cannot gain energy from the drive stage:
|
||||
// stability at any Q and any cutoff is structural, not a tuned margin, and it can never
|
||||
// self-oscillate.
|
||||
// - Unit slope at the origin, so the shaper adds no gain of its own at any depth. What reaches
|
||||
// it is the resonance state, already multiplied by roughly 2*Q, which is why drive and
|
||||
// resonance interact: the same drive setting bites harder the more resonance is dialled in.
|
||||
//
|
||||
// The retired feedbackSaturate() is deliberately not carried forward: it had 0.75 slope at the
|
||||
// origin, a fixed +/-2.0 threshold calibrated for firmware excursion levels, and turned over
|
||||
// (non-monotone) past x = 6. That absolute threshold is the origin of the level-dependent
|
||||
// resonance this rewrite removes — do not reintroduce it.
|
||||
inline float softLimit(float x, float depth) {
|
||||
const float s = depth * x;
|
||||
return x / std::sqrt(1.0f + s * s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
|
||||
q_ = filterQFromNorm(settings.resonanceNorm);
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm), q_, sampleRate);
|
||||
if (sampleRate > 0.0) {
|
||||
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
|
||||
} else {
|
||||
// Bypass: a1=1, a2=a3=0 makes both state updates the exact identity, and bypassMix()
|
||||
// reads only the input, never the state -- so clearing here is audibly free (the state
|
||||
// was already going to be ignored) and prevents a stale nonzero ic1/ic2 from latching
|
||||
// isSilent() false forever, which prepare() otherwise deliberately never does.
|
||||
mix_ = bypassMix();
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
|
||||
driven_ = driveDepth_ != 0.0f;
|
||||
}
|
||||
|
||||
void VoiceFilter::setCutoffNorm(float cutoffNorm, double sampleRate) {
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(cutoffNorm), q_, sampleRate);
|
||||
}
|
||||
|
||||
void VoiceFilter::reset() {
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
|
||||
bool VoiceFilter::isSilent() const {
|
||||
for (const State& s : state_) {
|
||||
if (s.ic1 != 0.0f || s.ic2 != 0.0f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,135 @@
|
||||
// voice_filter.h — per-voice TPT state-variable filter with a continuous HP->BP->LP morph and
|
||||
// an in-loop drive stage. Concrete type, no vtable: this sits on the per-voice per-sample path,
|
||||
// so process() is header-inline. No allocation, no virtual dispatch, no I/O in process().
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
#include "core/instrument/engine/filter/filter_morph.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/filter_saturate.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Normalized control positions, as the editor moves them and the persisted state carries them.
|
||||
// morphLaw is the one discrete control here — a two-value selector, not a normalized position —
|
||||
// because its two values are characters to choose between, not points on a continuum.
|
||||
struct FilterSettings {
|
||||
float cutoffNorm = 1.0f;
|
||||
float resonanceNorm = 0.0f;
|
||||
float morphNorm = 1.0f; // 0 = high-pass, 1 = low-pass; the centre is set by morphLaw
|
||||
float driveNorm = 0.0f;
|
||||
MorphLaw morphLaw = MorphLaw::HighBandLow;
|
||||
};
|
||||
|
||||
// Below this the recursion has decayed past -600 dB. Flushing keeps the state out of the
|
||||
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of
|
||||
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
|
||||
inline constexpr float kFilterDenormalFloor = 1e-30f;
|
||||
|
||||
class VoiceFilter {
|
||||
public:
|
||||
// The instrument's output bus is permanently stereo; one integrator pair per channel.
|
||||
static constexpr int kMaxChannels = 2;
|
||||
|
||||
struct State {
|
||||
float ic1 = 0.0f; // band-pass integrator
|
||||
float ic2 = 0.0f; // low-pass integrator
|
||||
};
|
||||
|
||||
// Recomputes coefficients from the control positions. State is deliberately preserved so a
|
||||
// live parameter move glides instead of clicking; call reset() at note-on.
|
||||
void prepare(const FilterSettings& settings, double sampleRate);
|
||||
|
||||
// Re-solves ONLY the cutoff-dependent coefficients, for a cutoff that moves per frame under
|
||||
// envelope modulation — cheap enough that the corner never needs quantizing (see
|
||||
// filter/CLAUDE.md for the numbers). Neither Q's parabola nor the morph's cos/sin enters
|
||||
// `g`, and the folded mix is a function of the weights and k alone, so a cutoff move
|
||||
// re-derives none of them. State preserved, exactly as prepare(). `sampleRate` must be the
|
||||
// one the last prepare() ran at: prepare() owns the non-positive-rate bypass mix, and this
|
||||
// deliberately leaves that mix alone.
|
||||
void setCutoffNorm(float cutoffNorm, double sampleRate);
|
||||
|
||||
void reset();
|
||||
|
||||
// Hot path. `channel` must be in [0, kMaxChannels).
|
||||
float process(int channel, float x) {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
State& s = state_[channel];
|
||||
|
||||
const float v3 = x - s.ic2;
|
||||
const float v1 = coeffs_.a1 * s.ic1 + coeffs_.a2 * v3;
|
||||
const float v2 = s.ic2 + coeffs_.a2 * s.ic1 + coeffs_.a3 * v3;
|
||||
|
||||
// The drive stage, and the only nonlinearity. It shapes the BAND-PASS integrator state
|
||||
// rather than the input because that state IS the resonance: in the passband and at DC
|
||||
// it sits at zero, so drive colours the resonance and leaves the passband transparent.
|
||||
// Placing it on the state rather than inside the zero-delay loop keeps a1/a2/a3 an exact
|
||||
// algebraic solve — a nonlinearity inside the loop would need per-sample Newton
|
||||
// iteration. softLimit is a contraction, so this cannot destabilize the filter.
|
||||
//
|
||||
// Gated on driven_ rather than called unconditionally: sqrt and div sit on this
|
||||
// recursive dependency chain, so out-of-order execution can't hide them, and at drive 0
|
||||
// (the default) that cost buys nothing — softLimit(x, 0) == x algebraically. Measured:
|
||||
// 11.2 ns/sample unconditional vs 4.1 ns gated, matching the limiter-removed floor.
|
||||
// driven_ only changes at prepare(), so the branch predicts perfectly. Bit-identity at
|
||||
// drive 0 holds either way, by algebra — the gate is a perf optimization, not what makes
|
||||
// it exact.
|
||||
const float u = 2.0f * v1 - s.ic1;
|
||||
s.ic1 = driven_ ? softLimit(u, driveDepth_) : u;
|
||||
s.ic2 = 2.0f * v2 - s.ic2;
|
||||
|
||||
// Snap the state once the whole resonator has decayed past -600 dB. isSilent() means
|
||||
// "both integrators are exactly zero," so both must reach zero for that check to be
|
||||
// meaningful — the conjunctive test is the cheapest guarantee of that, not a defense
|
||||
// against a demonstrated limit cycle on this topology (measured: a per-variable flush
|
||||
// and an either-below-zero-both flush both go silent here too, no limit cycle, no
|
||||
// subnormals). That risk was real on the retired Direct Form I state, where a per-sample
|
||||
// flush zeroed y1/y2 — the actual OUTPUT — injecting a step the resonance then amplified.
|
||||
// ic1/ic2 are integrator STATE, not output; zeroing one only removes energy, a
|
||||
// contraction rather than an injection. The only demonstrable hazard here is no flush at
|
||||
// all, which never reaches exact zero and stalls in subnormals for thousands of samples.
|
||||
if (s.ic1 > -kFilterDenormalFloor && s.ic1 < kFilterDenormalFloor &&
|
||||
s.ic2 > -kFilterDenormalFloor && s.ic2 < kFilterDenormalFloor) {
|
||||
s.ic1 = 0.0f;
|
||||
s.ic2 = 0.0f;
|
||||
}
|
||||
|
||||
return mix_.m0 * x + mix_.m1 * v1 + mix_.m2 * v2;
|
||||
}
|
||||
|
||||
void processFrame(float* samples, int channelCount) {
|
||||
assert(channelCount >= 0 && channelCount <= kMaxChannels);
|
||||
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]);
|
||||
}
|
||||
|
||||
// True once every integrator has flushed to exact zero — the voice's filter has stopped
|
||||
// ringing and cannot contribute further output.
|
||||
bool isSilent() const;
|
||||
|
||||
const State& state(int channel) const {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
return state_[channel];
|
||||
}
|
||||
const SvfCoeffs& coeffs() const { return coeffs_; }
|
||||
const MorphMix& mix() const { return mix_; }
|
||||
|
||||
private:
|
||||
SvfCoeffs coeffs_{};
|
||||
MorphMix mix_{};
|
||||
float q_ = 1.0f; // cached at prepare() so setCutoffNorm need not re-solve Q's parabola
|
||||
float driveDepth_ = 0.0f;
|
||||
bool driven_ = false; // driveDepth_ != 0, cached so process() branches on a bool, not a float compare
|
||||
State state_[kMaxChannels]{};
|
||||
};
|
||||
|
||||
// The port's whole point, enforced by the compiler rather than by review: the source was a
|
||||
// virtual hierarchy dispatching per channel per sample, and this type must never grow one
|
||||
// back. Trivially copyable also means nothing here is heap-owned.
|
||||
static_assert(!std::is_polymorphic_v<VoiceFilter>, "no vtable on the per-sample path");
|
||||
static_assert(std::is_trivially_copyable_v<VoiceFilter>, "state is plain values, never owned");
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
+60
-22
@@ -1,18 +1,21 @@
|
||||
#pragma once
|
||||
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
|
||||
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
|
||||
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
|
||||
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
|
||||
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
||||
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
||||
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
||||
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
||||
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
@@ -25,8 +28,7 @@ enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
|
||||
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
|
||||
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
@@ -45,8 +47,7 @@ struct AdsrParams {
|
||||
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Per-zone; default Gate so an instrument with no params set plays
|
||||
// exactly as before.
|
||||
// honor the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
@@ -69,10 +70,10 @@ inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
// (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
|
||||
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
|
||||
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
|
||||
// tests (an octave up still halves duration with no params set).
|
||||
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
||||
// the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
|
||||
// so "no params == the bare engine" holds for the core's own regression tests (an octave up
|
||||
// still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
@@ -90,20 +91,41 @@ struct PitchEnvParams {
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
|
||||
// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN
|
||||
// normalized control positions verbatim rather than a parallel set, so no control range is
|
||||
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
||||
//
|
||||
// The three modulation depths below land in that same normalized cutoff domain and sum
|
||||
// before a single clamp; all three are zero/neutral by default.
|
||||
struct FilterParams {
|
||||
bool enabled = false;
|
||||
instrument::engine::filter::FilterSettings settings;
|
||||
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
|
||||
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
|
||||
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||
AdsrParams env; // the same staged AHDSR the amp runs; frames
|
||||
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
|
||||
// because a flat curve under a depth control would make every velocity the same offset;
|
||||
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
|
||||
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
|
||||
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
|
||||
// flat() regardless, since that repair has no curve-specific fallback.
|
||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||
};
|
||||
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
|
||||
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct PlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv;
|
||||
FilterParams filter;
|
||||
};
|
||||
|
||||
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||
struct SampleLoop {
|
||||
@@ -112,11 +134,14 @@ struct SampleLoop {
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
//
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
|
||||
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
|
||||
// channels share the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
||||
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
@@ -130,13 +155,26 @@ struct SampleData {
|
||||
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
ZonePlayParams play;
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
PlayParams play;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
||||
// voice on an empty read span.
|
||||
bool playable() const { return !frames.empty(); }
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,956 +0,0 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
|
||||
//
|
||||
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
|
||||
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
|
||||
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
|
||||
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
|
||||
// this file further; the header is split instead (zone_params.h carries the value structs).
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
|
||||
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
|
||||
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
|
||||
// negative fade is treated as 0. When both fades together exceed the play length, shrink
|
||||
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
|
||||
// then the fade-in — never letting either go negative or the sum exceed the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
|
||||
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
|
||||
// The offset is fractional (the read head is fractional under repitch), so the ramps are
|
||||
// smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
bool Voice::sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
|
||||
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
|
||||
// fade-in/out over the % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
|
||||
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
|
||||
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under either
|
||||
// engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
|
||||
// finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant. (An
|
||||
// earlier revision stored the frozen difference (ref − x₀), which could exceed full scale
|
||||
// if outₙ rose while that residue was still large.)
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
|
||||
// once and applied identically to every channel — only the PCM value read differs. The
|
||||
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
|
||||
// non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
|
||||
// "no loop". Under Preserve the loop is over the source read (loop the source, shift the
|
||||
// output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new play
|
||||
// span ends within the ramp). With no declick (the common case) this is byte-identical
|
||||
// to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
||||
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
|
||||
// entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose the
|
||||
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
|
||||
// amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
|
||||
// were primed with that window at start()), under the same sustain-loop wrap rule,
|
||||
// reading integer source frames (nothing to interpolate). Past the last real frame
|
||||
// the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum combing.
|
||||
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
|
||||
// that would advance its heads twice and corrupt the state). Gated on haveR so
|
||||
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
|
||||
// stereo samples, and a stale un-primed ring must not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
|
||||
// this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
|
||||
// the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
|
||||
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [Rev 1 added the frozen difference (ref − x₀) ungated; if outₙ rose while the residue
|
||||
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
|
||||
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
|
||||
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
|
||||
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
|
||||
// sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
|
||||
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
|
||||
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||
// maxVoices — the Poly path sizes the whole pool as before.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||
v.playingSample() == &sample) {
|
||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
|
||||
// The declick opt-in rides every mono restart: start() self-gates it on the voice being
|
||||
// ACTIVE, so a first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||
return;
|
||||
}
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
|
||||
// original velocity. Peer restart site of monoNoteOn's takeover — same declick opt-in
|
||||
// (the fallback also hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
|
||||
// voice being active, so a free-voice start never ramps — only an at-cap steal, which is
|
||||
// the same hard cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,485 +0,0 @@
|
||||
#pragma once
|
||||
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
|
||||
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
|
||||
// repitch/interpolation from a root note with loop-point-aware sustain.
|
||||
//
|
||||
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
|
||||
// enter as plain int/frame-index inputs; the core does no file I/O.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/zone_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
|
||||
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
|
||||
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
|
||||
// zones, each a key range with its own root.
|
||||
//
|
||||
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
|
||||
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
|
||||
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
|
||||
// receives but currently ignores for selection — no signature change needed.
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
|
||||
// note to repitch from (defaults to the sample's own root, overridable per zone).
|
||||
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
|
||||
// velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
|
||||
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
|
||||
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// `matched == false` means the note falls in no zone — a defined no-play result, not an
|
||||
// error and not voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
|
||||
// in order, so an earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
|
||||
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
|
||||
// keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
|
||||
// reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
|
||||
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
|
||||
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
|
||||
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
||||
// one value per output frame.
|
||||
double tick();
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
|
||||
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
|
||||
// frame — a step discontinuity that clicks. When the caller opts in (start()'s
|
||||
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
|
||||
// the first frame after the restart seeds a compensation equal to
|
||||
// (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
|
||||
// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
|
||||
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
|
||||
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
|
||||
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
|
||||
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
|
||||
// inside process(); the warm silence pass settles the OLA taps before the first output
|
||||
// frame. Byte-identical to the bare engine when sample.play is default.
|
||||
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
|
||||
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
|
||||
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
|
||||
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
|
||||
// difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
|
||||
// guarantees the voice is playing the same SampleData the resolved zone names — a
|
||||
// cross-sample takeover must restart the voice instead.
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// Identity only, never mutated through; the engine's mono legato path compares it
|
||||
// against the new note's resolved sample to decide retune vs. restart.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame();
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const;
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and
|
||||
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
|
||||
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
|
||||
// (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed
|
||||
// full scale by several dB.)
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
|
||||
// voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
|
||||
// engine — held by reference, never copies PCM). Play params ride on each zone's
|
||||
// SampleData::play; the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
|
||||
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
|
||||
// leaves them pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
|
||||
// same-sample takeover without a re-attack). The engine's config is immutable — a
|
||||
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
|
||||
// reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
|
||||
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
|
||||
// self-gates on the voice being active, so a fresh start never ramps. Default false
|
||||
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, zone-resolving note in press order (top = most recent = the sounding
|
||||
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
|
||||
// the voice back on a fallback). Re-pressing a held note moves it to the top.
|
||||
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
|
||||
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
|
||||
// velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
|
||||
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
|
||||
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
|
||||
// not applied in mono — a single voice runs at most one shifter, inherently within any
|
||||
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,203 @@
|
||||
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const PlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
|
||||
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
|
||||
// per-frame path, exactly as the amp's velocityGain_ does.
|
||||
filterOn_ = p.filter.enabled;
|
||||
if (filterOn_) {
|
||||
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||
filterModAmount_ = p.filter.modAmount;
|
||||
filterKeyTrack_ = p.filter.keyTrack;
|
||||
filterVelOffset_ =
|
||||
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
filter_.reset();
|
||||
updateFilterCutoffBase(note);
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
|
||||
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
|
||||
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
|
||||
instrument::engine::filter::FilterSettings s = p.filter.settings;
|
||||
s.cutoffNorm = filterBaseCutoff_;
|
||||
filter_.prepare(s, filterRate_);
|
||||
filterSolvedCutoff_ = filterBaseCutoff_;
|
||||
filterSolved_ = true;
|
||||
}
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
||||
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
||||
if (filterOn_) updateFilterCutoffBase(note);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
filterEnv_.noteOff();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,530 @@
|
||||
#pragma once
|
||||
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
|
||||
//
|
||||
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
|
||||
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
|
||||
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/envelopes.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||
// reference-frequency needed.
|
||||
inline double pitchRatio(int note, int rootNote) {
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
|
||||
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
||||
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
||||
inline double filterNormPerOctave() {
|
||||
namespace flt = instrument::engine::filter;
|
||||
return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
|
||||
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
|
||||
}
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
|
||||
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
|
||||
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
|
||||
// instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
|
||||
// AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
|
||||
// seconds at load). Preserve shifters must already be pre-sized
|
||||
// (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
|
||||
// no allocation) since it runs on the audio thread inside process(). Byte-identical to
|
||||
// the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
|
||||
// amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
|
||||
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
|
||||
void retune(int note);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop() { active_ = false; }
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
private:
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under
|
||||
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
|
||||
// advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
|
||||
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
|
||||
// rather than staircasing. State preservation across the solve is voice_filter's own
|
||||
// contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
|
||||
// control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
|
||||
//
|
||||
// Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
|
||||
// note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
|
||||
// held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
|
||||
// compare the value itself, so they can never suppress a move the ear would hear.
|
||||
// filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
|
||||
// through both so a moved base always re-solves.
|
||||
void tickFilterCutoff() {
|
||||
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||
filterModAmount_ * filterEnv_.tick();
|
||||
if (cut < 0.0) cut = 0.0;
|
||||
if (cut > 1.0) cut = 1.0;
|
||||
const float cutNorm = static_cast<float>(cut);
|
||||
if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
|
||||
filterSolvedCutoff_ = cutNorm;
|
||||
filterSolved_ = true;
|
||||
filter_.setCutoffNorm(cutNorm, filterRate_);
|
||||
}
|
||||
|
||||
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
|
||||
// move the note), never per frame.
|
||||
void updateFilterCutoffBase(int note) {
|
||||
double base = filterCutoffNorm_ + filterVelOffset_;
|
||||
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
|
||||
base += filterKeyTrack_ *
|
||||
(static_cast<double>(note - sample_->rootNote) / 12.0) *
|
||||
filterNormPerOctave();
|
||||
}
|
||||
if (base < 0.0) base = 0.0;
|
||||
if (base > 1.0) base = 1.0;
|
||||
filterBaseCutoff_ = static_cast<float>(base);
|
||||
filterSolved_ = false; // forces the next frame to solve
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick() {
|
||||
// The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
|
||||
// exact boundary identity whatever the new envelope's first value. Each subsequent
|
||||
// frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
|
||||
// max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
|
||||
// rises while the weight is still significant. (An earlier revision stored the frozen
|
||||
// difference (ref − x₀), which could exceed full scale if outₙ rose while that
|
||||
// residue was still large.)
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
//
|
||||
// INLINE BY CONSTRAINT — see the file header.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR) {
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
|
||||
// valid, non-zero-length loop wraps the read head back into [start, end); a
|
||||
// zero-length loop is "no loop". Under Preserve the loop is over the source read
|
||||
// (loop the source, shift the output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new
|
||||
// play span ends within the ramp). With no declick (the common case) this is
|
||||
// byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||
// floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
|
||||
// pow entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor =
|
||||
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
// Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
|
||||
// it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
|
||||
// and the amp envelope shapes the filtered result (drive included).
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
|
||||
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
|
||||
// rings were primed with that window at start()), under the same sustain-loop wrap
|
||||
// rule, reading integer source frames (nothing to interpolate). Past the last real
|
||||
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||
// splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||
// (never twice — that would advance its heads twice and corrupt the state).
|
||||
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||
// shiftL_.process again this frame.
|
||||
outRlocal = shiftedL;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||
// the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head.
|
||||
// For the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Skipped whole when disengaged (the default), so an un-filtered render stays
|
||||
// bit-identical to the pre-filter engine.
|
||||
if (filterOn_) {
|
||||
tickFilterCutoff();
|
||||
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
|
||||
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
|
||||
// filtered result is exactly what a second identical filter would produce — one
|
||||
// less kernel pass per frame for the same samples.
|
||||
if (stereo) {
|
||||
outRlocal = haveR
|
||||
? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
|
||||
: outL;
|
||||
}
|
||||
}
|
||||
|
||||
outL *= gain;
|
||||
if (stereo) outRlocal *= gain;
|
||||
|
||||
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
|
||||
// while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
|
||||
// extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
|
||||
// one extra subtract.
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any
|
||||
// running declick) — a future takeover restart seeds its declick from exactly this. In
|
||||
// a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
|
||||
// of a mono sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still
|
||||
// rings: the envelope contributes 0 from here on, so the remaining frames are the bare
|
||||
// ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
|
||||
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
|
||||
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
|
||||
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
|
||||
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
|
||||
instrument::engine::filter::VoiceFilter filter_;
|
||||
AdsrEnvelope filterEnv_;
|
||||
bool filterOn_ = false;
|
||||
double filterRate_ = 0.0;
|
||||
double filterCutoffNorm_ = 1.0;
|
||||
double filterModAmount_ = 0.0;
|
||||
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||
double filterKeyTrack_ = 0.0;
|
||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
|
||||
bool filterSolved_ = false; // false forces the next frame to solve
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,272 @@
|
||||
// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the
|
||||
// block render loops. See voice_engine.h for the contract.
|
||||
//
|
||||
// The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in
|
||||
// voice.h precisely so this TU boundary costs nothing on the per-sample path.
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
sample_(sample),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
// Nothing decoded: a defined no-play, and the note must not join the stack (it cannot
|
||||
// sound, so it must not later take the voice back on a fallback).
|
||||
if (!sample_.playable()) return kNoVoice;
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is
|
||||
// a no-op there, so releasing_ never latches and a one-shot still ringing after the last
|
||||
// key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note
|
||||
// same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so
|
||||
// heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.)
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(note);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger ignores this and plays through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
if (monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(fb.note); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||
// hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play.
|
||||
|
||||
// Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are
|
||||
// unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets
|
||||
// past the cap are refused.
|
||||
if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too: start() self-gates on the voice being
|
||||
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||
// cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap.
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
// voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and
|
||||
// stealing, the mono held-note stack, the two-tier panic, and the block render loops. The
|
||||
// per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its
|
||||
// present inline shape across this seam.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, note-on
|
||||
// allocation with bounded voice stealing, note-off routing, and block rendering (sum of
|
||||
// voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine —
|
||||
// held by reference, never copies PCM). Every playback parameter rides on the sample; the
|
||||
// engine holds no parameters of its own beyond the voice-system config below.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are pre-sized
|
||||
// to at construction (off the audio thread), so note-on never allocates; 0 leaves them
|
||||
// pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes without a
|
||||
// re-attack). The engine's config is immutable — a mode/count change rebuilds the engine
|
||||
// off-thread through the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, poly at-cap steal) seeds the per-voice declick ramp (see
|
||||
// kDeclickDecay) so the hard cut doesn't click. start() self-gates on the voice being
|
||||
// active, so a fresh start never ramps. Default false keeps the bare core byte-identical
|
||||
// to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
|
||||
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
|
||||
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, playable note in press order (top = most recent = the sounding note).
|
||||
// Re-pressing a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) —
|
||||
// no allocation on the audio thread. Velocity is kept per held note so a retrigger
|
||||
// fallback re-strikes at its original velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune, else a fresh start). Returns
|
||||
// 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (rejected before the
|
||||
// stack, which stores uint8). The Preserve cap is not applied in mono — a single voice
|
||||
// runs at most one shifter, inherently within any cap; applying it would wrongly drop a
|
||||
// Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const SampleData& sample_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,33 @@
|
||||
reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
|
||||
reasampler_test(bridge_marshal LINK bridge_marshal)
|
||||
|
||||
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
||||
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
|
||||
# available.
|
||||
reasampler_test(trigger_seam LINK trigger_seam)
|
||||
|
||||
reasampler_pure_library(bank_sync
|
||||
SOURCES bank_sync.cpp
|
||||
LINK PUBLIC assignment_request PRIVATE wire)
|
||||
# Links only bank_sync (+ its assignment_request dep): linking more would break the
|
||||
# plain-data-boundary proof.
|
||||
reasampler_test(bank_sync LINK bank_sync)
|
||||
|
||||
# The state codec is shared with the extension's preset-blob path, so it must link WITHOUT
|
||||
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
|
||||
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
|
||||
# tail -- plain value types, so no filter symbol is linked and this stays true.
|
||||
reasampler_pure_library(component_state_io
|
||||
SOURCES component_state_io.cpp
|
||||
LINK PUBLIC velocity_curve master_gain)
|
||||
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||
reasampler_test(component_state_io LINK component_state_io)
|
||||
|
||||
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
||||
reasampler_pure_library(sample_map
|
||||
SOURCES sample_map.cpp
|
||||
LINK PUBLIC bank_book wav_codec velocity_curve peaks)
|
||||
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
||||
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||
reasampler_test(sample_map LINK sample_map component_state_io)
|
||||
@@ -1,5 +1,5 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
|
||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
@@ -11,7 +11,7 @@
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
@@ -26,98 +26,185 @@ namespace {
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component
|
||||
// blob, so both write zones identically. Always emits the CURRENT payload version (marker +
|
||||
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
|
||||
// marker precedes the zone count so any reader can detect record shape independent of the
|
||||
// envelope version (see sample_map.h).
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(z.loopOverride->start));
|
||||
putLE(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in the header).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
|
||||
// trigger %-length + fades stay source frames/fraction. Order matches the header's
|
||||
// v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(out, doubleToBits(p.amp));
|
||||
}
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
|
||||
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
|
||||
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
|
||||
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
|
||||
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
|
||||
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
putCurve(out, p.velocityCurve);
|
||||
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||
// whole payload stays one numeric shape.
|
||||
const FilterSeconds& f = pp.filter;
|
||||
out.push_back(f.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||
putLE(out, doubleToBits(f.modAmount));
|
||||
putLE(out, doubleToBits(f.velAmount));
|
||||
putLE(out, doubleToBits(f.keyTrack));
|
||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||
putCurve(out, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
|
||||
if (r.ok) {
|
||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||
: engine::filter::MorphLaw::HighBandLow;
|
||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||
double modAmount = bitsToDouble(r.u64());
|
||||
double velAmount = bitsToDouble(r.u64());
|
||||
double keyTrack = bitsToDouble(r.u64());
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||
readCurveTail(r, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
|
||||
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
|
||||
PerformanceZone z;
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
@@ -125,113 +212,90 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
|
||||
// A/D) were written as frames -> divide by `projectRate` to reach seconds.
|
||||
// Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
|
||||
// in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
readSecondsPlayTail(r, p);
|
||||
}
|
||||
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
|
||||
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
|
||||
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
|
||||
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
|
||||
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
|
||||
// read leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p.velocityCurve);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
out.params = std::move(read.params);
|
||||
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Combined component state --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
@@ -268,7 +332,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
// 1 = user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
|
||||
// to here. Wire shape per kSelectionRefsV10Version: entry count, then per entry id
|
||||
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||
// channelCount, displayName (length-prefixed; display-only).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
@@ -286,75 +350,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// v11 envelope addition (usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// Length-prefixed selection id (it precedes the params payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
putParamsPayload(out, state.params);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. See readZonesPayload for the guard.
|
||||
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one
|
||||
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
|
||||
// loaded capture with default parameters.
|
||||
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono
|
||||
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
|
||||
return out; // channelMode stays Mono
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
|
||||
// length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
|
||||
// length + id + payload starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // channelMode stays Mono, marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
|
||||
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
|
||||
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
if (version == kSelectionModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
|
||||
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||
// already-saved instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
if (version == kSelectionModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -363,21 +417,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // previewVelocity stays at the mid default
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||
version != kSelectionRefsV10Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionModeMarkerVelV6Version) {
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
|
||||
// than rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -392,7 +446,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
const std::uint8_t mt = r.u8();
|
||||
@@ -408,7 +462,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||
// than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
// mode/marker/velocity/voice fields from above; unity holds
|
||||
@@ -420,7 +474,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
@@ -428,8 +482,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||
// drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
// drops the rest (the selection/params behind it are unreadable anyway).
|
||||
if (version >= kSelectionRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
SampleRefEntry e;
|
||||
@@ -457,7 +511,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||
// holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
if (version >= kSelectionRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||
@@ -465,7 +519,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,109 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
|
||||
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||
// payload and the instrument's reader can never drift — without the extension having to
|
||||
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
|
||||
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
|
||||
// engine.
|
||||
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
|
||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
|
||||
// payload v1..v7) must be preserved exactly.
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// payload v1..v8) must be preserved exactly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
// --- The instance's parameter payload ----------------------------------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
|
||||
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
|
||||
// of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
// The one parameter set is the instrument's OWN state, serialized to the VST3
|
||||
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
|
||||
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
|
||||
// OWN version, so its record can grow without bumping the envelope version. Payload
|
||||
// extensions and envelope-field additions stay on independent axes that can never collide
|
||||
// on one version number.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
|
||||
// carries its OWN version, so the per-zone record can grow without bumping the envelope
|
||||
// version. Zone-record extensions and envelope-field additions stay on independent axes
|
||||
// that can never collide on one version number.
|
||||
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
|
||||
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
|
||||
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
|
||||
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
|
||||
// case. Their record shapes, in order:
|
||||
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
|
||||
// is v1.
|
||||
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
|
||||
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
|
||||
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
|
||||
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
|
||||
// (int64). The marker lets the reader detect record shape independent of the envelope.
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
|
||||
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
|
||||
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
|
||||
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
|
||||
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
|
||||
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
|
||||
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
|
||||
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
|
||||
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
|
||||
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
|
||||
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
|
||||
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
|
||||
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
|
||||
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
|
||||
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
|
||||
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
|
||||
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
|
||||
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
|
||||
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
|
||||
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
|
||||
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
|
||||
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
|
||||
// sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
|
||||
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
|
||||
// truncated/unknown/empty blob deserializes to an EMPTY map.
|
||||
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
|
||||
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
|
||||
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
|
||||
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
|
||||
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
|
||||
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
|
||||
// threaded into the v3 lift path at read time (a parameter, no baked constant).
|
||||
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
|
||||
// tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
|
||||
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
|
||||
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
|
||||
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
|
||||
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
|
||||
// frames tail) was never shipped and is intentionally not read.
|
||||
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
|
||||
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
|
||||
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
|
||||
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||
// (soft hits play louder than under the old linear velocity/127 map).
|
||||
//
|
||||
// These two functions serialize the ZONES only; the instrument's full component state is
|
||||
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
|
||||
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
|
||||
// with no count, no key range and no sample id (the envelope's selection id is the capture):
|
||||
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
|
||||
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
|
||||
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||
// velocity curve (count + points) as in v7.
|
||||
//
|
||||
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
|
||||
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
|
||||
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
|
||||
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
|
||||
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
|
||||
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
|
||||
// bit-identically.
|
||||
//
|
||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker. serializePerformance and
|
||||
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
|
||||
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
|
||||
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
|
||||
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
|
||||
// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
|
||||
// never shipped and is not read. The marker is a high sentinel no legitimate zone count
|
||||
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
|
||||
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
|
||||
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
|
||||
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
|
||||
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
|
||||
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
|
||||
// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
|
||||
// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
|
||||
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
|
||||
// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
|
||||
// leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
|
||||
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
|
||||
// so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||
// reads through the legacy walk; everything at or above it shares the v8 record shape and
|
||||
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
|
||||
// future bump does not silently push the previous format back into the zone reader.
|
||||
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
|
||||
// self-describing, mirroring the envelope's version constants.
|
||||
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||
// seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3+) -------------
|
||||
//
|
||||
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and an instance with NO pick and NO
|
||||
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
|
||||
// sample #1.
|
||||
// --- Combined component state (VST3 setState/getState) -----------------------
|
||||
//
|
||||
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||
@@ -129,51 +113,53 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
|
||||
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
|
||||
// path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
|
||||
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
|
||||
// minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
|
||||
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||
// version). The instance guid is the only v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
|
||||
// PARALLEL track owns zone-record extension under its own versioning — the two version
|
||||
// numbers are independent axes; do NOT bump the zones-payload version for an envelope
|
||||
// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
|
||||
// corrupt blob) falls back to the field's default rather than silencing the instance.
|
||||
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
|
||||
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
|
||||
//
|
||||
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
|
||||
// blob) falls back to the field's default rather than silencing the instance.
|
||||
//
|
||||
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||
// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
|
||||
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
|
||||
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
|
||||
// re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
|
||||
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
|
||||
// publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
|
||||
// * v7 blob -> unity master gain.
|
||||
// * v6 blob -> voice defaults.
|
||||
// * v5 blob -> no velocity byte.
|
||||
// * v4 blob -> no marker.
|
||||
// * v3 blob -> no channel mode.
|
||||
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
|
||||
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
|
||||
// instance re-applying a stale assign_request the user already got and then manually
|
||||
// changed away from: on re-open the instance re-reads the pending request, and only a
|
||||
// generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
|
||||
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
|
||||
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
|
||||
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
|
||||
//
|
||||
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation stops a
|
||||
// re-opened instance re-applying a stale assign_request the user already got and then
|
||||
// manually changed away from: on re-open the instance re-reads the pending request, and only
|
||||
// a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written
|
||||
// to the bank — the extension owns the assign_request key; the instrument only tracks what
|
||||
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
|
||||
// with no velocity byte lifts to this, audible-but-not-hot.
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
|
||||
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
|
||||
// no velocity byte lifts to this, audible-but-not-hot.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
std::string selectionId; // the loaded capture; "" = no pick
|
||||
InstrumentParams params; // the ONE parameter set governing it
|
||||
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||
@@ -181,26 +167,25 @@ struct ComponentState {
|
||||
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
|
||||
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
|
||||
// user's chosen strike velocity across saves.
|
||||
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
|
||||
// the Sample-view preview button retains the user's chosen strike velocity across saves.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
|
||||
// exactly, so an older blob lifting to these plays byte-identically.
|
||||
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
|
||||
// reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
|
||||
// byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
|
||||
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
|
||||
// unity reproduces pre-master-gain output byte-identically.
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
|
||||
// voice sum — never per voice. Default unity reproduces pre-master-gain output
|
||||
// byte-identically.
|
||||
double masterGainLinear = 1.0;
|
||||
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||
// bank sample this instance plays (see the SampleRefs block above). setState decodes
|
||||
// straight from these; NO bridge/extension read is required for playback. A pre-v10
|
||||
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
|
||||
// once (then re-saves self-contained).
|
||||
// bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||
@@ -214,7 +199,7 @@ inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
|
||||
|
||||
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||
@@ -222,36 +207,36 @@ inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
|
||||
|
||||
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||
// in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||
// {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
|
||||
|
||||
// Selection + zones + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
// Selection + params + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
|
||||
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
|
||||
|
||||
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, params}.
|
||||
inline constexpr std::uint32_t kSelectionV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
@@ -266,16 +251,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
|
||||
// the instrument, never written back to the bank) — a single string id. serialize/
|
||||
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
|
||||
// without breaking already-saved instances.
|
||||
// The original v1 instance state was which bank sample it plays — a single string id.
|
||||
//
|
||||
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||
// for the v1->v3 back-compat lift in deserializeComponentState.
|
||||
// for the v1 back-compat lift in deserializeComponentState.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
@@ -286,5 +268,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// note_entry.cpp — see note_entry.h.
|
||||
|
||||
#include "core/instrument/map/note_entry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
namespace {
|
||||
char asciiUpper(char c) {
|
||||
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
std::size_t a = 0;
|
||||
std::size_t b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
int clampNote(long long n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > 127) return 127;
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
||||
int letterSemitone(char up) {
|
||||
switch (up) {
|
||||
case 'C': return 0;
|
||||
case 'D': return 2;
|
||||
case 'E': return 4;
|
||||
case 'F': return 5;
|
||||
case 'G': return 7;
|
||||
case 'A': return 9;
|
||||
case 'B': return 11;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
|
||||
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
|
||||
std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
const int base = letterSemitone(asciiUpper(s[i]));
|
||||
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||
++i;
|
||||
int semitone = base;
|
||||
// Optional accidental(s): # / b only (not 's'/'f').
|
||||
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||
if (s[i] == '#') ++semitone;
|
||||
else --semitone;
|
||||
++i;
|
||||
}
|
||||
// The octave: an optional sign then digits, running to the end.
|
||||
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
int octave = 0;
|
||||
bool anyDigit = false;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
octave = octave * 10 + (s[i] - '0');
|
||||
anyDigit = true;
|
||||
}
|
||||
if (!anyDigit) return std::nullopt;
|
||||
if (neg) octave = -octave;
|
||||
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
||||
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
std::optional<int> parseInteger(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
long long v = 0;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
v = v * 10 + (s[i] - '0');
|
||||
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
||||
}
|
||||
if (neg) v = -v;
|
||||
return clampNote(v);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<int> parseNoteEntry(const std::string& text) {
|
||||
const std::string s = trim(text);
|
||||
if (s.empty()) return std::nullopt;
|
||||
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
||||
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
||||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
||||
if (auto n = parseInteger(s)) return n;
|
||||
}
|
||||
return parseNoteName(s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -1,18 +0,0 @@
|
||||
// note_entry — parse + clamp for direct numeric/note-name entry of a zone's low/high/root
|
||||
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
|
||||
//
|
||||
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
|
||||
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
|
||||
// rejecting; unparseable input returns nullopt (shell keeps the old value).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
|
||||
std::optional<int> parseNoteEntry(const std::string& text);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <algorithm> // std::remove_if
|
||||
#include <cassert> // assert
|
||||
#include <utility> // std::move
|
||||
|
||||
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
|
||||
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
|
||||
// (start). The bank is never mutated.
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
|
||||
return rz;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map) {
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
|
||||
std::vector<std::string> ids;
|
||||
const auto addUnique = [&ids](const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
for (const std::string& have : ids) {
|
||||
if (have == id) return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
};
|
||||
addUnique(selectionId);
|
||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||
if (!selectionId.empty()) ids.push_back(selectionId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
DecodedPcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
@@ -230,7 +202,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
@@ -240,7 +212,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
PlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
@@ -253,133 +225,77 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
// Filter: the control positions are already rate-free and carry through untouched; only
|
||||
// its envelope resolves to frames.
|
||||
out.filter.enabled = stored.filter.enabled;
|
||||
out.filter.settings = stored.filter.settings;
|
||||
out.filter.modAmount = stored.filter.modAmount;
|
||||
out.filter.velAmount = stored.filter.velAmount;
|
||||
out.filter.keyTrack = stored.filter.keyTrack;
|
||||
out.filter.velocityCurve = stored.filter.velocityCurve;
|
||||
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
||||
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
|
||||
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
|
||||
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
|
||||
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
// --- The one parameter set ----------------------------------------------------
|
||||
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
|
||||
ResolvedCapture rs;
|
||||
rs.relativePath = ref.relativePath;
|
||||
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rs.keyTrack = params.keyTrack;
|
||||
rs.velocityCurve = params.velocityCurve;
|
||||
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
|
||||
// The bank is never mutated.
|
||||
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
|
||||
rs.startFrame = params.startPoint ? *params.startPoint : 0;
|
||||
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
|
||||
return rs;
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
|
||||
if (!sel) return std::nullopt;
|
||||
return resolveCapture(*sel, params);
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const SelectedSample* ref = findRef(refs, selectionId);
|
||||
if (ref == nullptr) return std::nullopt;
|
||||
return resolveCapture(*ref, params);
|
||||
}
|
||||
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
|
||||
assert(decoded.sampleRate > 0 &&
|
||||
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
|
||||
data.frames = std::move(decoded.monoFrames);
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(decoded.framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
data.sampleRate = decoded.sampleRate;
|
||||
data.rootNote = resolved.rootNote;
|
||||
data.loop = resolved.loop;
|
||||
data.startFrame = resolved.startFrame;
|
||||
data.keyTrack = resolved.keyTrack;
|
||||
data.velocityCurve = resolved.velocityCurve;
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(resolved.play, data.sampleRate);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// A sample lives in exactly one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
|
||||
continue;
|
||||
}
|
||||
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
|
||||
// fold — so the bank path and refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||
if (selectedId.empty() || map.zones.empty()) return false;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||
// there, so the map is left exactly as authored.
|
||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||
}
|
||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||
}
|
||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||
map.zones.resize(keptCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#pragma once
|
||||
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
|
||||
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
|
||||
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
|
||||
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
|
||||
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
|
||||
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
|
||||
// here. Links bank_book, wav_codec, and sampler_core (all pure).
|
||||
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
|
||||
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
|
||||
// NOT the voice engine: the build's product is plain SampleData.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -12,7 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
@@ -29,7 +30,7 @@ struct SelectedSample {
|
||||
int rootNote = 60; // defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
|
||||
// the GA channel-mode auto-default skips it
|
||||
// the channel-mode auto-default skips it
|
||||
};
|
||||
|
||||
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
|
||||
@@ -60,8 +61,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
||||
// Consequence: a sample deleted from the bank no longer silences an instance that carries
|
||||
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
|
||||
// the file yields the defined no-play).
|
||||
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
|
||||
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
@@ -74,10 +73,9 @@ using SampleRefs = std::vector<SampleRefEntry>;
|
||||
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||
|
||||
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
||||
// sampleId, de-duplicated, selection first then map order.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
// Every bank sample id this instance plays. One capture = at most one id; the list form is
|
||||
// kept because the refs-table helpers below are id-set operations.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
|
||||
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
|
||||
@@ -123,10 +121,10 @@ struct BankChoice {
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
|
||||
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
|
||||
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
|
||||
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
|
||||
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
|
||||
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
|
||||
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
// --- Stored (wall-clock SECONDS) play params ----------------------------------
|
||||
//
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
// keymap build. Quantities anchored to the source file's timeline (start point, loop
|
||||
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
|
||||
// unchanged (TriggerParams reused verbatim).
|
||||
// build. Quantities anchored to the source file's timeline (start point, loop points,
|
||||
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||
// (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
@@ -162,57 +160,55 @@ struct PitchEnvSeconds {
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
|
||||
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
|
||||
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
|
||||
struct ZonePlaySeconds {
|
||||
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||
// MEANS). Only the envelope differs between the two: the control positions and depths are
|
||||
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
|
||||
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
|
||||
// loaded blob and the pre-filter sound.
|
||||
struct FilterSeconds {
|
||||
bool enabled = false;
|
||||
engine::filter::FilterSettings settings;
|
||||
double modAmount = 0.0;
|
||||
double velAmount = 0.0;
|
||||
double keyTrack = 0.0;
|
||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
|
||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||
};
|
||||
|
||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||
// from the engine-facing PlayParams (frames).
|
||||
struct PlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
FilterSeconds filter; // per-voice filter, off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
|
||||
// unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
|
||||
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
|
||||
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
|
||||
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
|
||||
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
|
||||
// picked single capture plays under the same default engine as a zone would. Resolves the
|
||||
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (the instrument's OWN state) ---------------
|
||||
// --- The instrument's ONE parameter set (its OWN state) -----------------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. A performance
|
||||
// choice, so it lives in the instrument (VST3 component state), never written back to the
|
||||
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
|
||||
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
|
||||
// + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
|
||||
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
|
||||
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them per zone
|
||||
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
|
||||
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
|
||||
// the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
// One loaded capture, one set of playback parameters governing it across the whole
|
||||
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
|
||||
// never written back to the bank. Pure value type: names no sample (the ComponentState's
|
||||
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
|
||||
// the pure build stitches the decoded frames + this set into one SampleData.
|
||||
//
|
||||
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
|
||||
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them without
|
||||
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
|
||||
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
|
||||
// ResolvedCapture.
|
||||
struct InstrumentParams {
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
|
||||
@@ -223,116 +219,80 @@ struct PerformanceZone {
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
|
||||
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
|
||||
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
|
||||
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
|
||||
// in Voice::start.
|
||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
|
||||
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
|
||||
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
|
||||
// Voice::start.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
|
||||
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
|
||||
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
|
||||
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
|
||||
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
|
||||
// to exactly these defaults on read.
|
||||
ZonePlaySeconds play;
|
||||
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
|
||||
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
|
||||
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
|
||||
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
||||
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||
PlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution — first zone in order wins (mirrors the core's first-match
|
||||
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
|
||||
//
|
||||
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||
// control edit. Loading a different sample used to change only the selection id, leaving
|
||||
// the previous sample's full-range zone in the map — and since zone resolution is
|
||||
// first-match in order, that stale zone shadowed every later one forever: the engine kept
|
||||
// playing the old sample while the editor drew the new one's zone. This function is called
|
||||
// at every selection-change site so the zone the editor draws is the zone the engine plays.
|
||||
//
|
||||
// Rules (order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
|
||||
// first-match order is load-bearing there — untouched, false (the Sample face never
|
||||
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
|
||||
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
|
||||
// (params preserved); drop the rest. A selection with no zone yet empties the map.
|
||||
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
|
||||
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
|
||||
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
|
||||
// an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
|
||||
// (which holds optional overrides) — this is the parameter set folded against the capture.
|
||||
struct ResolvedCapture {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
|
||||
double keyTrack = 1.0;
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
PlaySeconds play; // stored SECONDS; resolved to frames at build
|
||||
};
|
||||
|
||||
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
|
||||
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
|
||||
// cleanly — not an error, not silence for the whole map — and reported here so the editor
|
||||
// can flag/prune it.
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
|
||||
// drift.
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
|
||||
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
|
||||
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
|
||||
// -> empty result.
|
||||
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
|
||||
// empty selection, or a stale id -> nullopt.
|
||||
//
|
||||
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
|
||||
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
|
||||
// (both share foldZone, so the drift test keeps the shared fold honest).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
|
||||
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
|
||||
// resolveCapture, so the drift test keeps the shared fold honest).
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
|
||||
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
|
||||
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map);
|
||||
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
|
||||
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
|
||||
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
|
||||
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
|
||||
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
|
||||
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
|
||||
struct DecodedZonePcm {
|
||||
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
|
||||
struct DecodedPcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
|
||||
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
|
||||
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
|
||||
// `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (average all source channels).
|
||||
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
|
||||
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
// Empty/zero-channel input -> empty frames (caller plays silence).
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
|
||||
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
|
||||
// plays across the whole keyboard, repitched from the effective root. A second channel is
|
||||
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
|
||||
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
|
||||
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
|
||||
// unplayable SampleData (silence, never a crash).
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
|
||||
|
||||
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
|
||||
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
|
||||
// so both artifacts share the codec while only the VST links the voice engine.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# The geometry vocabulary every instrument UI module speaks (the Rect alias + contains())
|
||||
# is header-only, hence INTERFACE.
|
||||
add_library(editor_geometry INTERFACE)
|
||||
target_include_directories(editor_geometry INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
|
||||
reasampler_pure_library(sample_bands SOURCES sample_bands.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(sample_bands LINK sample_bands)
|
||||
|
||||
reasampler_pure_library(sample_chrome SOURCES sample_chrome.cpp LINK PUBLIC sample_bands)
|
||||
reasampler_test(sample_chrome LINK sample_chrome)
|
||||
|
||||
reasampler_pure_library(embed_strip SOURCES embed_strip.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(embed_strip LINK embed_strip)
|
||||
|
||||
reasampler_pure_library(capture_browser SOURCES capture_browser.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(capture_browser LINK capture_browser)
|
||||
|
||||
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
||||
|
||||
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
|
||||
# header needs it.
|
||||
reasampler_pure_library(waveform_view
|
||||
SOURCES waveform_view.cpp
|
||||
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
|
||||
# sample_bands is linked directly here because the test exercises the lane metrics that
|
||||
# waveform_view does not re-export.
|
||||
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
||||
|
||||
reasampler_pure_library(browser_scroll
|
||||
SOURCES browser_scroll.cpp
|
||||
LINK PUBLIC capture_browser sample_chrome)
|
||||
reasampler_test(browser_scroll LINK browser_scroll)
|
||||
|
||||
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(param_slider LINK param_slider)
|
||||
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||
|
||||
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||
reasampler_test(envelope_edit LINK envelope_edit)
|
||||
|
||||
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(knob_deck LINK knob_deck)
|
||||
|
||||
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
|
||||
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
|
||||
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
|
||||
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
|
||||
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
|
||||
reasampler_pure_library(deck_groups
|
||||
SOURCES deck_groups.cpp
|
||||
LINK PUBLIC knob_deck velocity_curve peaks)
|
||||
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
|
||||
# needs the band allocator deck_groups itself has no reason to depend on.
|
||||
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
||||
|
||||
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(curve_popup LINK curve_popup)
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
|
||||
// The Browse modal is a full-window sheet drawn over the Sample face, so it reuses that
|
||||
// face's chrome metrics rather than minting its own — a divergent title height or pad would
|
||||
// make the sheet visibly not line up with what it covers.
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight
|
||||
#include "core/instrument/ui/sample_chrome.h" // kNavButtonWidth (the Back button's slot)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
|
||||
// window within min/max bounds. A title row sits over the curve box. The curve box rect
|
||||
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
|
||||
// formula, so the popup editor and the Zone-panel inline editor share coordinates.
|
||||
// formula.
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// deck_groups.cpp — see deck_groups.h. Pure data; no host types.
|
||||
|
||||
#include "core/instrument/ui/deck_groups.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
int id(DeckParam p) { return static_cast<int>(p); }
|
||||
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
} // namespace
|
||||
|
||||
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
|
||||
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc pitch;
|
||||
pitch.id = kGroupPitch;
|
||||
pitch.captionWidth = 38;
|
||||
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
|
||||
pitch.cellIds = {id(DeckParam::kKeyTrack)};
|
||||
out.push_back(std::move(pitch));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||
id(DeckParam::kPitchEnvDecay),
|
||||
id(DeckParam::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
{
|
||||
// Tone shaping left-to-right, then the three modulation depths that all target cutoff.
|
||||
DeckGroupDesc filter;
|
||||
filter.id = kGroupFilter;
|
||||
filter.captionWidth = 46;
|
||||
filter.captionToggle = {id(DeckParam::kFilterEnable), 32};
|
||||
filter.cellIds = {id(DeckParam::kFilterMorph),
|
||||
id(DeckParam::kFilterCutoff),
|
||||
id(DeckParam::kFilterQ),
|
||||
id(DeckParam::kFilterDrive),
|
||||
id(DeckParam::kFilterModAmt),
|
||||
id(DeckParam::kFilterVel),
|
||||
id(DeckParam::kFilterKeyTrack)};
|
||||
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
||||
out.push_back(std::move(filter));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc fenv;
|
||||
fenv.id = kGroupFilterEnv;
|
||||
fenv.captionWidth = 66;
|
||||
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
out.push_back(std::move(fenv));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||
if (playMode == PlayMode::Gate) {
|
||||
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
|
||||
id(DeckParam::kDecay), id(DeckParam::kSustain),
|
||||
id(DeckParam::kRelease)};
|
||||
} else {
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
|
||||
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
|
||||
id(DeckParam::kTrigFadeOut), -1, -1};
|
||||
}
|
||||
out.push_back(std::move(amp));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
voice.captionWidth = 38;
|
||||
voice.captionToggle = {id(DeckParam::kVoiceMode), 40};
|
||||
voice.cellIds = {id(DeckParam::kVoiceCount)};
|
||||
voice.rowToggle = {id(DeckParam::kMonoTrigger), 44};
|
||||
out.push_back(std::move(voice));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc master;
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.cellIds = {id(DeckParam::kMasterGain)};
|
||||
out.push_back(std::move(master));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,81 @@
|
||||
// deck_groups.h — WHICH groups the Sample face's knob deck carries and in what order, plus
|
||||
// the control-id space they are built from. Pure data: knob_deck lays out whatever descriptors
|
||||
// it is handed, and this module decides what those descriptors are, so the deck's signal-flow
|
||||
// ordering is provable without a host.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Deck control ids. Opaque to knob_deck, resolved by the shell's hit-test and value binding.
|
||||
// Runtime-only — nothing persists them, so the ordering here is free to change.
|
||||
enum class DeckParam {
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
kPitchEngine, // Varispeed | Preserve toggle
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length
|
||||
kTrigFadeIn, // Trigger fade-in
|
||||
kTrigFadeOut, // Trigger fade-out
|
||||
kPitchEnvEnable, // AD pitch envelope on|off
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||
// Filter. The four control positions map through filter_params' own laws; the three
|
||||
// depths are bipolar and centred at zero.
|
||||
kFilterEnable, // filter on|off caption toggle
|
||||
kFilterMorph, // morph position: high-pass .. low-pass
|
||||
kFilterCutoff, // cutoff, log across the audio band
|
||||
kFilterQ, // resonance
|
||||
kFilterDrive, // in-loop drive depth
|
||||
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
||||
kFilterVel, // velocity -> cutoff, +/-100%
|
||||
kFilterKeyTrack, // note -> cutoff, 0..200%
|
||||
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
|
||||
kFilterEnvAttack,
|
||||
kFilterEnvHold,
|
||||
kFilterEnvDecay,
|
||||
kFilterEnvSustain,
|
||||
kFilterEnvRelease,
|
||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||
// setters, never through the parameter set.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kCount
|
||||
};
|
||||
|
||||
// Deck group ids. Unscoped so the shell's caption switch reads against the plain `id` int
|
||||
// knob_deck carries.
|
||||
enum DeckGroupId {
|
||||
kGroupPitch = 0,
|
||||
kGroupPitchEnv,
|
||||
kGroupFilter,
|
||||
kGroupFilterEnv,
|
||||
kGroupAmpEnv,
|
||||
kGroupVoice,
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
|
||||
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
|
||||
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||
|
||||
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
||||
double deckBipolarFromNorm(double norm);
|
||||
double deckNormFromBipolar(double value);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -1,273 +0,0 @@
|
||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp to non-negative extents so a degenerate view can't produce inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
|
||||
out.canvas = Rect::ltrb(0, titleH, cw, ch);
|
||||
|
||||
// Button inset from the canvas top-left, clamped so it never overhangs a small view.
|
||||
const int bx = out.canvas.x + kButtonMargin;
|
||||
const int by = out.canvas.y + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
|
||||
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
|
||||
if (y < layout.canvas.y) return -1;
|
||||
if (y >= layout.canvas.bottom()) return -1;
|
||||
const int index = (y - layout.canvas.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
const int canvasW = std::max(0, canvas.width);
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.x, canvas.right() - splitW);
|
||||
|
||||
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
|
||||
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
|
||||
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
|
||||
out.addZoneButton =
|
||||
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
|
||||
out.zonePanel.y + addH);
|
||||
|
||||
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
|
||||
out.zonePanel.right(), out.zonePanel.bottom());
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
|
||||
top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.x || x >= list.right()) return -1;
|
||||
if (y < list.y || y >= list.bottom()) return -1;
|
||||
const int index = (y - list.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
|
||||
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
|
||||
top + kZoneRowHeight);
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.x || x >= area.right()) return ZoneHit{};
|
||||
if (y < area.y || y >= area.bottom()) return ZoneHit{};
|
||||
const int index = (y - area.y) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom()) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
|
||||
// order 0..6; a click left of the leftmost is the label ("select").
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kHeroMinHeight = 150; // elastic hero's floor
|
||||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
||||
constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip)
|
||||
|
||||
// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
|
||||
} // namespace
|
||||
|
||||
// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
|
||||
// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
|
||||
// window too short for the floor keeps the hero at its floor and clips lower bands.
|
||||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
||||
SampleBands b;
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
b.title = Rect::ltrb(0, 0, w, titleH);
|
||||
// Two nav buttons right-anchored in the title band (Browse then Zone).
|
||||
const int navTop = 2;
|
||||
const int navBot = (std::max)(navTop, titleH - 2);
|
||||
const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot);
|
||||
const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot);
|
||||
b.navBrowse = browse;
|
||||
b.navZone = zone;
|
||||
|
||||
int deckTop = h - kPad - deckH;
|
||||
int clusterTop = deckTop - kClusterHeight - 4;
|
||||
int heroBottom = clusterTop - 4;
|
||||
if (heroBottom - titleH < kHeroMinHeight) {
|
||||
heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below
|
||||
clusterTop = heroBottom + 4;
|
||||
deckTop = clusterTop + kClusterHeight + 4;
|
||||
}
|
||||
b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom);
|
||||
b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight);
|
||||
b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
|
||||
ClusterRects r;
|
||||
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
|
||||
const int stripBot = stripTop + kStripBandHeight;
|
||||
const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2;
|
||||
r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop,
|
||||
chanMono.x - kPad, curveTop + kCurveBtnSize);
|
||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
||||
r.curveBtn.x - kPad, stripBot);
|
||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + knobSize);
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom());
|
||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
||||
r.velCell.x - kPad, stripBot);
|
||||
r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot);
|
||||
return r;
|
||||
}
|
||||
|
||||
ChannelToggleRects channelToggleRects(const Rect& area) {
|
||||
const int top = area.y + (area.height - kChanSegH) / 2;
|
||||
const int right = area.right() - kPad;
|
||||
const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH);
|
||||
const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH);
|
||||
return {mono, stereo};
|
||||
}
|
||||
|
||||
Rect zoneContentArea(int w, int h) {
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
return Rect::ltrb(0, titleH, w, h);
|
||||
}
|
||||
|
||||
Rect zoneBackRect(int w, int h) {
|
||||
return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad,
|
||||
(std::max)(2, (std::min)(kTitleHeight, h) - 2));
|
||||
}
|
||||
|
||||
Rect zoneAddRect(const Rect& content) {
|
||||
return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96,
|
||||
content.y + 4 + 20);
|
||||
}
|
||||
|
||||
Rect zoneDeleteRect(const Rect& addR) {
|
||||
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
|
||||
}
|
||||
|
||||
// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
|
||||
Rect zonesStripArea(const Rect& content) {
|
||||
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
|
||||
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
|
||||
stripTop + kStripBandHeight);
|
||||
}
|
||||
|
||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
|
||||
Rect noteEntryFieldsArea(const Rect& content) {
|
||||
const int stripBottom = zonesStripArea(content).bottom();
|
||||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
|
||||
return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18);
|
||||
}
|
||||
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
||||
if (f < 0 || f > 2 || fields.width <= 0) return Rect{};
|
||||
const int segW = fields.width / 3;
|
||||
const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
||||
const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW;
|
||||
return Rect::ltrb(left, fields.y, right, fields.bottom());
|
||||
}
|
||||
|
||||
Rect zonesControlPanel(const Rect& content) {
|
||||
const Rect strip = zonesStripArea(content);
|
||||
const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||||
return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad,
|
||||
content.bottom() - 4);
|
||||
}
|
||||
|
||||
// Top-anchored; reserves a column at the panel's right for the curve-preview button so
|
||||
// no deck row starts inside it.
|
||||
Rect zonesDeckArea(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
|
||||
}
|
||||
|
||||
Rect zonesCurveButton(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -1,8 +1,10 @@
|
||||
// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
|
||||
// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
|
||||
// live here so they can be unit-tested outside the DAW.
|
||||
|
||||
#pragma once
|
||||
// editor_geometry.h — the shared geometry vocabulary for the VST3 editor's pure modules:
|
||||
// the one concrete `Rect` (aliased from core/ui), its half-open `contains()`, and
|
||||
// `OverlayArea` (the waveform band's shared overlay rect — see the contract in
|
||||
// waveform_view.h). Every instrument UI module speaks these types, so they live in one
|
||||
// place rather than each module reaching into core/ui separately. The Sample face's own
|
||||
// layout lives in sample_bands (the band-stack allocator) and the per-band modules.
|
||||
|
||||
#include "core/ui/rect.h"
|
||||
|
||||
@@ -11,174 +13,13 @@ namespace reasampler::instrument::ui {
|
||||
using Rect = ::reasampler::ui::Rect;
|
||||
using ::reasampler::ui::contains;
|
||||
|
||||
// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
|
||||
// never yields a region spilling outside the surface.
|
||||
struct EditorLayout {
|
||||
Rect titleBar;
|
||||
Rect button;
|
||||
Rect canvas;
|
||||
// Distinct from Rect on purpose (no implicit Rect->OverlayArea conversion): only
|
||||
// waveformOverlayArea/WaveformSurface::overlay construct one, so an overlay-only API can
|
||||
// require this type and reject a lane rect at compile time instead of silently accepting it.
|
||||
struct OverlayArea {
|
||||
Rect rect;
|
||||
bool operator==(const OverlayArea& o) const { return rect == o.rect; }
|
||||
bool operator!=(const OverlayArea& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the editor's top-level regions. Pure.
|
||||
EditorLayout layoutEditor(int w, int h);
|
||||
|
||||
enum class HitTarget {
|
||||
kNone,
|
||||
kButton,
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against a layout.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list ---------------------------------------------------
|
||||
//
|
||||
// A vertical stack of fixed-height rows below the title bar; clicking a row selects that
|
||||
// sample. Pure geometry only — the shell draws names and routes the click.
|
||||
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
|
||||
// beyond what the canvas can show are still computed (the shell clips at paint time); a
|
||||
// negative index yields an empty rect.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
|
||||
// the list.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor ------------------------------------------------------------
|
||||
//
|
||||
// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
|
||||
// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
|
||||
// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
|
||||
// carries nudge/delete mini-buttons (LICE has no native numeric entry field).
|
||||
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
|
||||
|
||||
// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base;
|
||||
Rect sampleList; // LEFT column
|
||||
Rect zonePanel; // RIGHT column
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// Bank-sample row a click lands on inside the left list, or -1 outside it.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields: a label on the left, then seven fixed-width
|
||||
// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
|
||||
// means the click missed a control (e.g. the label) — the shell may still treat that as
|
||||
// "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
kLowUp,
|
||||
kHighDown,
|
||||
kHighUp,
|
||||
kRootDown,
|
||||
kRootUp,
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// Which zone row (or -1) and which field within it a click landed on. A click on
|
||||
// "Add Zone" is reported separately by addZoneHitTest.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
|
||||
// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
|
||||
// click left of those slots is {index, kZoneNone} (the label area — "select").
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample / Zone face layout ------------------------------------------------
|
||||
//
|
||||
// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
|
||||
// both derive every rect from these formulas so they can never drift; the shell only
|
||||
// draws + routes. The Browse-modal layout lives in browser_scroll (its search box
|
||||
// height feeds it).
|
||||
|
||||
inline constexpr int kPad = 8;
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||||
|
||||
// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
|
||||
// HERO (absorbs all height left after the fixed bands, floored), the root+preview
|
||||
// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
|
||||
// window shorter than the hero floor clips the lower bands past the window bottom.
|
||||
struct SampleBands {
|
||||
Rect title;
|
||||
Rect navBrowse;
|
||||
Rect navZone;
|
||||
Rect hero; // waveform + envelope overlay
|
||||
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
|
||||
Rect deck;
|
||||
};
|
||||
SampleBands computeSampleBands(int w, int h, int deckH);
|
||||
|
||||
// Cluster sub-rects: the root strip keeps the left side at remainder width; the right
|
||||
// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
|
||||
// Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
|
||||
// depend on knob_deck.
|
||||
struct ClusterRects {
|
||||
Rect rootStrip;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the curve-preview popup
|
||||
};
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
|
||||
|
||||
// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
|
||||
struct ChannelToggleRects {
|
||||
Rect mono;
|
||||
Rect stereo;
|
||||
};
|
||||
ChannelToggleRects channelToggleRects(const Rect& area);
|
||||
|
||||
// Zone-view content area: the whole window below the title band.
|
||||
Rect zoneContentArea(int w, int h);
|
||||
|
||||
// Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
|
||||
Rect zoneBackRect(int w, int h);
|
||||
|
||||
// "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
|
||||
// when a zone is selected).
|
||||
Rect zoneAddRect(const Rect& content);
|
||||
Rect zoneDeleteRect(const Rect& addR);
|
||||
|
||||
// Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
|
||||
// horizontally.
|
||||
Rect zonesStripArea(const Rect& content);
|
||||
|
||||
// Numeric-entry field row area inside the Zones legend, and the rect of field `f`
|
||||
// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
|
||||
// index yields an empty rect.
|
||||
Rect noteEntryFieldsArea(const Rect& content);
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f);
|
||||
|
||||
// Per-zone parameter panel below the strip + legend, running to the content bottom; the
|
||||
// knob-deck area within it (a right column reserved for the curve-preview button); and
|
||||
// that button's rect (right-anchored at the panel top).
|
||||
Rect zonesControlPanel(const Rect& content);
|
||||
Rect zonesDeckArea(const Rect& content);
|
||||
Rect zonesCurveButton(const Rect& content);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -15,7 +15,7 @@ int clampNote(int n) {
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
|
||||
// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
|
||||
// span's left uses floor(low) and its right uses floor(high+1), tiling adjacent spans
|
||||
// without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
@@ -44,31 +44,19 @@ EmbedLayout layoutEmbed(int w, int h) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
const Rect& band = layout.keymap;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
if (lo > hi) lo = hi; // defensive: a malformed span collapses rather than inverts
|
||||
|
||||
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
|
||||
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
|
||||
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
|
||||
}
|
||||
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y) {
|
||||
if (zoneCount <= 0 || zones == nullptr) return -1;
|
||||
if (!contains(layout.keymap, x, y)) return -1;
|
||||
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
|
||||
for (int i = 0; i < zoneCount; ++i) {
|
||||
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
|
||||
if (contains(r, x, y)) return i;
|
||||
}
|
||||
return -1; // on the band but on an uncovered key
|
||||
}
|
||||
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level) {
|
||||
const Rect& band = layout.levelBand;
|
||||
if (band.width <= 0 || band.height <= 0) return Rect{};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// bitmap + mouse coords) into these functions.
|
||||
//
|
||||
// A single compact band REAPER draws inline in the track/mixer control panel via the
|
||||
// Cockos embedded-UI surface: each performance zone as a horizontal segment across the
|
||||
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
|
||||
// at the bottom. Interaction is zone selection only — no editing.
|
||||
// Cockos embedded-UI surface: the loaded capture across the keyboard span (MIDI 0..127
|
||||
// mapped to the strip width) with its root marked, plus a thin activity level band at the
|
||||
// bottom. Read-only — the strip displays, it never edits.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -19,18 +19,10 @@ inline constexpr int kEmbedKeyCount = 128;
|
||||
inline constexpr int kEmbedLevelBandHeight = 4;
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6;
|
||||
|
||||
// One zone rendered on the strip: its inclusive MIDI key range — the minimal projection
|
||||
// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
|
||||
// low <= high; layout clamps defensively regardless.
|
||||
struct EmbedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
};
|
||||
|
||||
// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
|
||||
// the surface.
|
||||
struct EmbedLayout {
|
||||
Rect keymap; // top: zone-segment band
|
||||
Rect keymap; // top: keyboard-span band
|
||||
Rect levelBand; // bottom: level/activity indicator
|
||||
};
|
||||
|
||||
@@ -39,16 +31,11 @@ struct EmbedLayout {
|
||||
// kEmbedKeymapMinHeight); the keymap takes the rest.
|
||||
EmbedLayout layoutEmbed(int w, int h);
|
||||
|
||||
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
|
||||
// overlap. Notes clamp to [0,127] and low clamps to <= high.
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
|
||||
// zones overlap on a key, the first covering zone in order wins — mirroring the sampler
|
||||
// core's first-match Keymap::resolve, so selection agrees with playback.
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y);
|
||||
// Horizontal sub-rect of the keymap band for the inclusive key span [lowNote, highNote].
|
||||
// Spans the half-open pixel range so adjacent spans tile without a gap or overlap. Notes
|
||||
// clamp to [0,127] and low clamps to <= high. The loaded capture uses the full span; a
|
||||
// single-key span (low == high) is the root marker.
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
|
||||
// whose width is level * band width.
|
||||
|
||||
@@ -63,7 +63,8 @@ bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
|
||||
@@ -81,16 +82,17 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSecond
|
||||
return best;
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
|
||||
|
||||
const double secPerPx = secondsPerPixel(area, totalSeconds);
|
||||
const Rect& rect = area.rect;
|
||||
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
|
||||
|
||||
switch (node) {
|
||||
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
@@ -106,7 +108,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(area);
|
||||
const double lvlPerPx = levelPerPixel(rect);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
|
||||
//
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
|
||||
// every paint), so a node drag and a slider edit are two views on one source of truth.
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
|
||||
// parameter set every paint), so a node drag and a slider edit are two views on one source of
|
||||
// truth.
|
||||
//
|
||||
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
|
||||
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
|
||||
@@ -53,7 +54,9 @@ struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
|
||||
// Takes the waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y);
|
||||
|
||||
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
|
||||
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
|
||||
@@ -64,7 +67,7 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSecond
|
||||
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// `grabEnv` unchanged.
|
||||
// Only the dragged node's param(s) change. Pure.
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
|
||||
@@ -149,15 +149,16 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds) {
|
||||
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
|
||||
const Rect& rect = area.rect;
|
||||
if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) {
|
||||
// Degenerate surface: flat two-point baseline so the shell always has a line.
|
||||
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
|
||||
return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
|
||||
: triggerPolyline(env, area, totalSeconds);
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, rect)
|
||||
: triggerPolyline(env, rect, totalSeconds);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
|
||||
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
|
||||
// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
|
||||
// the polyline plus a handle at each node (envelope_edit does the hit-test).
|
||||
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
|
||||
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
|
||||
// hit-test).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -92,8 +93,9 @@ double gatePxPerSecond(const Rect& area);
|
||||
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
|
||||
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
|
||||
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
|
||||
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
|
||||
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
|
||||
|
||||
@@ -8,18 +8,29 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Pitch class of each natural, and the count of naturals strictly below each pitch class.
|
||||
constexpr int kNaturalPitchClass[7] = {0, 2, 4, 5, 7, 9, 11};
|
||||
constexpr int kNaturalsBelowPc[12] = {0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6};
|
||||
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kStripKeyCount - 1) return kStripKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
return bandLeft + (keyEdge * bandWidth) / kStripKeyCount;
|
||||
// The MIDI note of white key `index` (0..74).
|
||||
int whiteNoteAt(int index) {
|
||||
const int i = index < 0 ? 0 : (index > kStripWhiteKeyCount - 1 ? kStripWhiteKeyCount - 1
|
||||
: index);
|
||||
return clampNote((i / 7) * 12 + kNaturalPitchClass[i % 7]);
|
||||
}
|
||||
|
||||
// The black key straddling white-key boundary `b`, or -1 where the scale has none (E-F and
|
||||
// B-C are adjacent naturals).
|
||||
int blackNoteAtBoundary(int b) {
|
||||
if (b <= 0 || b >= kStripWhiteKeyCount) return -1;
|
||||
const int below = whiteNoteAt(b) - 1;
|
||||
return (below >= 0 && !isNaturalKey(below)) ? below : -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -28,77 +39,22 @@ StripLayout layoutStrip(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
StripLayout out;
|
||||
out.keys = Rect::ltrb(0, 0, cw, ch);
|
||||
out.band = Rect::ltrb(0, 0, cw, ch);
|
||||
if (ch <= 0) return out;
|
||||
|
||||
out.whiteWidth = cw / kStripWhiteKeyCount;
|
||||
if (out.whiteWidth <= 0) return out; // narrower than one pixel per white key
|
||||
|
||||
const int keysW = out.whiteWidth * kStripWhiteKeyCount;
|
||||
const int left = (cw - keysW) / 2; // residue split evenly into the two end margins
|
||||
out.keys = Rect::ltrb(left, 0, left + keysW, ch);
|
||||
out.blackWidth = std::max(1, (out.whiteWidth * 3) / 5);
|
||||
out.blackHeight = std::max(1, (ch * 3) / 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
int keyLeftX(const StripLayout& layout, int note) {
|
||||
const Rect& band = layout.keys;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
// note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
|
||||
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
|
||||
return keyEdgeToX(band.x, bandWidth, edge);
|
||||
}
|
||||
|
||||
Rect keyRect(const StripLayout& layout, int note) {
|
||||
const int n = clampNote(note);
|
||||
const int leftX = keyLeftX(layout, n);
|
||||
const int rightX = keyLeftX(layout, n + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
|
||||
return keyRect(layout, rootNote);
|
||||
}
|
||||
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
const Rect& band = layout.keys;
|
||||
if (!contains(band, x, y)) return -1;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
if (bandWidth <= 0) return -1;
|
||||
// Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
|
||||
const int offset = x - band.x;
|
||||
int note = (offset * kStripKeyCount) / bandWidth;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
|
||||
const Rect bar = zoneBarRect(layout, lowNote, highNote);
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width;
|
||||
// A narrow bar has no body: split at the midpoint, low edge wins the tie.
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.x + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
}
|
||||
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
|
||||
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
|
||||
return ZoneGrab::kBody;
|
||||
}
|
||||
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
|
||||
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
|
||||
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
|
||||
}
|
||||
return ZoneBarHit{}; // on the band but on no bar
|
||||
}
|
||||
|
||||
bool isNaturalKey(int note) {
|
||||
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
|
||||
const int n = clampNote(note);
|
||||
static constexpr bool kNatural[12] = {
|
||||
true, // 0 C
|
||||
false, // 1 C#
|
||||
@@ -116,20 +72,56 @@ bool isNaturalKey(int note) {
|
||||
return kNatural[n % 12];
|
||||
}
|
||||
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
|
||||
if (dxPixels == 0) return clampNote(startNote);
|
||||
const int bandWidth = std::max(0, layout.keys.width);
|
||||
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
|
||||
// Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
|
||||
// bandWidth/128 key width — that drifted at the far end of the strip.
|
||||
const int half = bandWidth / 2;
|
||||
int shift;
|
||||
if (dxPixels > 0) {
|
||||
shift = (dxPixels * kStripKeyCount + half) / bandWidth;
|
||||
} else {
|
||||
shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth);
|
||||
int whiteIndexOf(int note) {
|
||||
const int n = clampNote(note);
|
||||
return (n / 12) * 7 + kNaturalsBelowPc[n % 12];
|
||||
}
|
||||
|
||||
Rect keyRect(const StripLayout& layout, int note) {
|
||||
if (layout.keys.empty()) return Rect{};
|
||||
const int n = clampNote(note);
|
||||
const int wi = whiteIndexOf(n);
|
||||
if (isNaturalKey(n)) {
|
||||
const int left = layout.keys.x + wi * layout.whiteWidth;
|
||||
return Rect::ltrb(left, layout.keys.y, left + layout.whiteWidth, layout.keys.bottom());
|
||||
}
|
||||
return clampNote(startNote + shift);
|
||||
const int centre = layout.keys.x + wi * layout.whiteWidth;
|
||||
const int left = centre - layout.blackWidth / 2;
|
||||
return Rect::ltrb(left, layout.keys.y, left + layout.blackWidth,
|
||||
layout.keys.y + layout.blackHeight);
|
||||
}
|
||||
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
|
||||
return keyRect(layout, rootNote);
|
||||
}
|
||||
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
if (!contains(layout.keys, x, y)) return -1;
|
||||
const int offset = x - layout.keys.x;
|
||||
const int wi = std::min(offset / layout.whiteWidth, kStripWhiteKeyCount - 1);
|
||||
if (y < layout.keys.y + layout.blackHeight) {
|
||||
// Only the two boundaries flanking this white key can carry an overlapping black.
|
||||
const int candidates[2] = {wi, wi + 1};
|
||||
for (const int b : candidates) {
|
||||
const int note = blackNoteAtBoundary(b);
|
||||
if (note >= 0 && contains(keyRect(layout, note), x, y)) return note;
|
||||
}
|
||||
}
|
||||
return whiteNoteAt(wi);
|
||||
}
|
||||
|
||||
int resolveDragNote(const StripLayout& layout, int x, int y) {
|
||||
if (layout.keys.empty()) return -1;
|
||||
const int cx = std::clamp(x, layout.keys.x, layout.keys.right() - 1);
|
||||
const int cy = std::clamp(y, layout.keys.y, layout.keys.bottom() - 1);
|
||||
return keyAtPoint(layout, cx, cy);
|
||||
}
|
||||
|
||||
std::string noteName(int note) {
|
||||
static constexpr const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
|
||||
"F#", "G", "G#", "A", "A#", "B"};
|
||||
const int n = clampNote(note);
|
||||
return std::string(kNames[n % 12]) + std::to_string(n / 12 - 1);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
|
||||
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
|
||||
// and marshals mouse events into these functions.
|
||||
// keyboard_strip.h — piano-keyboard geometry for the editor's root strip: per-class key
|
||||
// rects, hit-test, the root marker, and the note name a hovered key reports.
|
||||
//
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
|
||||
// embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
|
||||
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
|
||||
// bar with edge-grab resize handles + a body move-handle).
|
||||
// See src/core/instrument/CLAUDE.md for the uniform-width-vs-edge-to-edge-tiling tradeoff.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -17,68 +15,48 @@ namespace reasampler::instrument::ui {
|
||||
// stay independent.
|
||||
inline constexpr int kStripKeyCount = 128;
|
||||
|
||||
// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
|
||||
// body move-handle (both edges win their halves).
|
||||
inline constexpr int kStripEdgeGrabWidth = 6;
|
||||
// Naturals in MIDI 0..127 (C-1 .. G9): ten full octaves of seven, plus C D E F G.
|
||||
inline constexpr int kStripWhiteKeyCount = 75;
|
||||
|
||||
// The keys band takes the whole strip area today; clamped so a degenerate size never
|
||||
// yields an inverted rect.
|
||||
struct StripLayout {
|
||||
Rect keys;
|
||||
Rect band; // the surface handed in, edge to edge (no src/ consumer; kept as the tests'
|
||||
// reference bound instead of recomputing Rect::ltrb(0, 0, w, h) at each call site)
|
||||
Rect keys; // the tiled key area, kStripWhiteKeyCount * whiteWidth, centred in band
|
||||
int whiteWidth = 0;
|
||||
int blackWidth = 0;
|
||||
int blackHeight = 0; // black keys are short; below them the white key answers
|
||||
};
|
||||
|
||||
// Divide a (w x h) strip area into its regions. Pure.
|
||||
// Divide a (w x h) strip area into its key geometry. Pure. A band too narrow for one pixel
|
||||
// per white key yields empty `keys` — nothing draws and nothing hit-tests.
|
||||
StripLayout layoutStrip(int w, int h);
|
||||
|
||||
// x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
|
||||
// occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
|
||||
int keyLeftX(const StripLayout& layout, int note);
|
||||
|
||||
// Half-open rect of a single key `note`, clamped to [0,127].
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// Root-marker rect for the single-capture fast path; equivalent to
|
||||
// keyRect(layout, rootNote) but named so the intent reads at the call site.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// MIDI note a point (x, y) lands on, or -1 outside the keys band.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
|
||||
// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
|
||||
// moves the whole span, kNone means the grab missed the bar.
|
||||
enum class ZoneGrab {
|
||||
kNone,
|
||||
kLowEdge,
|
||||
kHighEdge,
|
||||
kBody,
|
||||
};
|
||||
|
||||
// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body); the low edge wins a tie at the exact
|
||||
// midpoint.
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
|
||||
|
||||
// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
|
||||
// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
|
||||
// order wins.
|
||||
struct ZoneBarHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneGrab grab = ZoneGrab::kNone;
|
||||
};
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
|
||||
// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
|
||||
// edges with the same delta to preserve span), and root-marker drag.
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
|
||||
|
||||
// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
|
||||
// temperament; false for an accidental (black) key.
|
||||
bool isNaturalKey(int note);
|
||||
|
||||
// Naturals strictly below `note`. For a white key that is its own ordinal; for a black key
|
||||
// it is the white-key boundary the accidental straddles.
|
||||
int whiteIndexOf(int note);
|
||||
|
||||
// Rect of a single key, clamped to [0,127]. Whites are full band height and whiteWidth
|
||||
// wide; blacks are blackHeight tall and blackWidth wide, centred on their boundary.
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// Root-marker rect; equivalent to keyRect(layout, rootNote) but named so the intent reads
|
||||
// at the call site.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// MIDI note a point (x, y) lands on, or -1 outside the tiled keys. A black key wins inside
|
||||
// its own short zone; anywhere else the white key beneath answers.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// The note a live drag resolves to: keyAtPoint with the point clamped into the key area, so
|
||||
// a drag that wanders off the strip keeps tracking rather than dropping the edit. -1 only
|
||||
// when there is no key area at all.
|
||||
int resolveDragNote(const StripLayout& layout, int x, int y);
|
||||
|
||||
// DAW convention (the one REAPER uses): MIDI 60 is C4, so MIDI 0 is C-1.
|
||||
std::string noteName(int note);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// sample_bands.cpp — see sample_bands.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
const int deckH = std::max(0, deckHeight);
|
||||
|
||||
SampleBands b;
|
||||
const int chromeH = std::min(kTitleHeight + kChromeRowHeight, ch);
|
||||
b.chrome = Rect::ltrb(0, 0, cw, chromeH);
|
||||
|
||||
// Decks are bottom-anchored so the deck row sits on the window edge at any height; the
|
||||
// waveform absorbs whatever is left. When that leaves less than the two-lane floor the
|
||||
// FLOOR WINS and the deck band is pushed past the window bottom (clipped) rather than
|
||||
// squeezing the waveform into an unreadable sliver.
|
||||
int deckTop = ch - kPad - deckH;
|
||||
int waveTop = chromeH + kBandGap;
|
||||
int waveBottom = deckTop - kBandGap;
|
||||
if (waveBottom - waveTop < kWaveformMinHeight) {
|
||||
waveBottom = waveTop + kWaveformMinHeight;
|
||||
deckTop = waveBottom + kBandGap;
|
||||
}
|
||||
|
||||
b.waveform = Rect::ltrb(kPad, waveTop, std::max(kPad, cw - kPad), waveBottom);
|
||||
b.decks = Rect::ltrb(kPad, deckTop, std::max(kPad, cw - kPad), deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
WaveformLanes waveformLanes(const Rect& waveform, LaneSplit split) {
|
||||
WaveformLanes lanes;
|
||||
if (waveform.empty()) return lanes;
|
||||
if (split == LaneSplit::Single) {
|
||||
lanes.upper = waveform; // one lane; `lower` stays empty
|
||||
return lanes;
|
||||
}
|
||||
// Split the usable height evenly, giving the seam to the gap. An odd remainder goes to
|
||||
// the upper (left) lane so the two lanes never disagree about the seam row.
|
||||
const int usable = std::max(0, waveform.height - kLaneGap);
|
||||
const int lowerH = usable / 2;
|
||||
const int upperH = usable - lowerH;
|
||||
const int upperBottom = waveform.y + upperH;
|
||||
lanes.upper = Rect::ltrb(waveform.x, waveform.y, waveform.right(), upperBottom);
|
||||
lanes.lower = Rect::ltrb(waveform.x, upperBottom + kLaneGap, waveform.right(),
|
||||
waveform.bottom());
|
||||
return lanes;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// sample_bands.h — THE band-stack allocator for the Sample face: the one module that owns
|
||||
// the editor's vertical inventory. Three bands, top to bottom — CHROME (toolbar + control
|
||||
// row), WAVEFORM (elastic, sized to hold two stacked channel lanes), DECKS (the knob-deck
|
||||
// row). Everything else in the editor fills a band it is handed; nothing else allocates
|
||||
// vertical space, so a band's owner can re-lay its interior without moving its neighbours.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Shared outer inset every band honours horizontally.
|
||||
inline constexpr int kPad = 8;
|
||||
|
||||
// The editor's client-area floor, which IS its default size: the band stack is laid out for
|
||||
// exactly this, and there is no scroll, so anything smaller pushes the deck band off the
|
||||
// window bottom (computeSampleBands' waveform-floor-wins degrade). Growing is fine — the
|
||||
// waveform band is the elastic one. Both the enforced minimum and the opening rect read this.
|
||||
inline constexpr int kEditorMinWidth = 840;
|
||||
inline constexpr int kEditorMinHeight = 620;
|
||||
|
||||
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
||||
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kChromeRowHeight = 52;
|
||||
|
||||
// Waveform band floor: two stacked lanes plus the seam between them. The band never shrinks
|
||||
// below this — a window too short for it clips the bands beneath instead, so the waveform
|
||||
// stays a usable two-lane surface at every size.
|
||||
inline constexpr int kLaneMinHeight = 74;
|
||||
inline constexpr int kLaneGap = 2;
|
||||
inline constexpr int kWaveformMinHeight = 2 * kLaneMinHeight + kLaneGap;
|
||||
|
||||
// Vertical seam between adjacent bands.
|
||||
inline constexpr int kBandGap = 4;
|
||||
|
||||
// The vertical inventory. Bands never overlap and are returned top-to-bottom; a band may be
|
||||
// empty() on a degenerate window, in which case its owner draws and hit-tests nothing.
|
||||
struct SampleBands {
|
||||
Rect chrome; // full width: toolbar row + control row
|
||||
Rect waveform; // kPad-inset, elastic, >= kWaveformMinHeight
|
||||
Rect decks; // kPad-inset, bottom-anchored, height `deckHeight`
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
||||
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||
// the deck band is exactly as tall as its content. Pure.
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
||||
|
||||
// The waveform band's two channel lanes: L above R, separated by kLaneGap. In mono only
|
||||
// `upper` is populated (it takes the whole band) and `lower` is empty — a mono capture has
|
||||
// no second lane to draw, and overlays that ride the waveform draw ONCE across the whole
|
||||
// band in either mode, never per lane.
|
||||
struct WaveformLanes {
|
||||
Rect upper;
|
||||
Rect lower; // empty() in mono
|
||||
};
|
||||
|
||||
// A RESOLVED lane-split decision, not "is the instrument in stereo mode" — a mono source
|
||||
// stays Single even in stereo mode (dual-mono, no second channel to draw). Only
|
||||
// waveformSurface (waveform_view) folds the source channel count in; a bare bool here would
|
||||
// let a caller pass isStereoMode straight through and skip that check.
|
||||
enum class LaneSplit { Single, Stereo };
|
||||
WaveformLanes waveformLanes(const Rect& waveform, LaneSplit split);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,92 @@
|
||||
// sample_chrome.cpp — see sample_chrome.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_chrome.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The toolbar row carries the whole control run, so it is taller than the Browse modal's
|
||||
// plain kTitleHeight bar — the velocity knob cell (knob over label) sets the floor. Both
|
||||
// rows still fit the band the allocator hands out (kTitleHeight + kChromeRowHeight).
|
||||
constexpr int kToolbarHeight = 44;
|
||||
constexpr int kStripBandHeight = 30;
|
||||
|
||||
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
constexpr int kCurveBtnSize = 24;
|
||||
constexpr int kVelCellW = 44;
|
||||
constexpr int kVelLabelH = 12;
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kRunButtonH = 24; // Browse and Preview
|
||||
|
||||
} // namespace
|
||||
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
ChromeRects r;
|
||||
if (chrome.empty()) return r;
|
||||
|
||||
const int toolbarH = std::min(kToolbarHeight, chrome.height);
|
||||
r.toolbar = Rect::ltrb(chrome.x, chrome.y, chrome.right(), chrome.y + toolbarH);
|
||||
r.controls = Rect::ltrb(chrome.x, r.toolbar.bottom(), chrome.right(), chrome.bottom());
|
||||
|
||||
const Rect& row = r.toolbar;
|
||||
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
||||
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
||||
|
||||
// The fixed run, right to left: Browse, Mono|Stereo, curve, velocity cell, preview.
|
||||
const int navH = std::min(kRunButtonH, row.height);
|
||||
const int navTop = topFor(navH);
|
||||
const int navRight = std::max(row.x, row.right() - kPad);
|
||||
r.navBrowse = Rect::ltrb(leftOf(navRight, kNavButtonWidth), navTop, navRight,
|
||||
navTop + navH);
|
||||
|
||||
const int chanTop = topFor(kChanSegH);
|
||||
const int chanRight = leftOf(r.navBrowse.x, kRunGap);
|
||||
r.chanStereo = Rect::ltrb(leftOf(chanRight, kChanSegW), chanTop, chanRight,
|
||||
chanTop + kChanSegH);
|
||||
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
|
||||
chanTop + kChanSegH);
|
||||
|
||||
const int curveTop = topFor(kCurveBtnSize);
|
||||
const int curveRight = leftOf(r.chanMono.x, kRunGap);
|
||||
r.curveBtn = Rect::ltrb(leftOf(curveRight, kCurveBtnSize), curveTop, curveRight,
|
||||
curveTop + kCurveBtnSize);
|
||||
|
||||
const int cellH = std::min(row.height, knobSize + kVelLabelH);
|
||||
const int cellTop = topFor(cellH);
|
||||
const int cellRight = leftOf(r.curveBtn.x, kRunGap);
|
||||
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
|
||||
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + std::min(knobSize, cellH));
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(),
|
||||
r.velCell.bottom());
|
||||
|
||||
const int prevTop = topFor(std::min(kRunButtonH, row.height));
|
||||
const int prevRight = leftOf(r.velCell.x, kRunGap);
|
||||
r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight,
|
||||
prevTop + std::min(kRunButtonH, row.height));
|
||||
|
||||
// The title takes what the run leaves; clamped so a narrow window collapses it rather
|
||||
// than inverting it.
|
||||
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.preview.x - kRunGap),
|
||||
row.bottom());
|
||||
|
||||
if (r.controls.empty()) return r;
|
||||
// The strip row belongs to the strip alone — inset only by the shared band pad, so it
|
||||
// lines up with the waveform band directly beneath it.
|
||||
const int stripH = std::min(kStripBandHeight, r.controls.height);
|
||||
const int stripTop = r.controls.y + (r.controls.height - stripH) / 2;
|
||||
r.rootStrip = Rect::ltrb(r.controls.x + kPad, stripTop,
|
||||
std::max(r.controls.x + kPad, r.controls.right() - kPad),
|
||||
stripTop + stripH);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
// sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row
|
||||
// (title + the whole right-anchored control run + Browse) over the strip row, which the
|
||||
// piano strip has to itself. Reads the band rect the allocator hands it (sample_bands) and
|
||||
// never allocates vertical space of its own.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
|
||||
|
||||
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
|
||||
// derive them differently. The toolbar's fixed run is right-anchored and the title takes
|
||||
// what is left of that row; the strip row carries nothing but the strip, so the strip grows
|
||||
// with the window in both directions.
|
||||
struct ChromeRects {
|
||||
Rect toolbar; // full-width top row
|
||||
Rect title; // the title text slot: the toolbar left of the control run
|
||||
Rect preview; // ---- the right-anchored run, left to right ----
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the velocity-curve popup
|
||||
Rect chanMono;
|
||||
Rect chanStereo;
|
||||
Rect navBrowse;
|
||||
Rect controls; // full-width second row
|
||||
Rect rootStrip; // the piano strip: the whole row, inset only by the shared band pad
|
||||
};
|
||||
|
||||
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -3,8 +3,11 @@
|
||||
#include "core/instrument/ui/waveform_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdlib> // std::abs (int overload)
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
@@ -17,30 +20,57 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
||||
|
||||
} // namespace
|
||||
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (frameCount <= 0 || w <= 0) return area.x;
|
||||
OverlayArea waveformOverlayArea(const Rect& band) {
|
||||
return OverlayArea{band.empty() ? Rect{} : band};
|
||||
}
|
||||
|
||||
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
|
||||
WaveformSurface s;
|
||||
if (band.empty()) return s;
|
||||
s.overlay = waveformOverlayArea(band);
|
||||
const bool twoLanes = stereoMode && sourceChannels >= 2;
|
||||
const WaveformLanes lanes =
|
||||
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
|
||||
s.upper = lanes.upper;
|
||||
s.lower = lanes.lower;
|
||||
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
|
||||
// rounds the lower lane to empty for a band this thin (height <= 3), far below the
|
||||
// allocator's kWaveformMinHeight floor but reachable if this is called directly with an
|
||||
// arbitrary rect (as tests do).
|
||||
s.laneCount = lanes.lower.empty() ? 1 : 2;
|
||||
return s;
|
||||
}
|
||||
|
||||
audio::Envelope laneEnvelope(const audio::Envelope& env, int lane) {
|
||||
if (lane < 0 || static_cast<std::size_t>(lane) >= env.size()) return {};
|
||||
return audio::Envelope{env[static_cast<std::size_t>(lane)]};
|
||||
}
|
||||
|
||||
int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.rect.width);
|
||||
if (frameCount <= 0 || w <= 0) return area.rect.x;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// x = left + round(f * w / frameCount); multiply before divide to keep this exact.
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.x + static_cast<int>(num / frameCount);
|
||||
return area.rect.x + static_cast<int>(num / frameCount);
|
||||
}
|
||||
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
|
||||
const int w = std::max(0, area.width);
|
||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
||||
const Rect& r = area.rect;
|
||||
const int w = std::max(0, r.width);
|
||||
if (frameCount <= 0 || w <= 0) return 0;
|
||||
if (x <= area.x) return 0;
|
||||
if (x >= area.right()) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
|
||||
if (x <= r.x) return 0;
|
||||
if (x >= r.right()) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - r.x);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w).
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || frames == nullptr) return -1;
|
||||
if (!contains(area, x, y)) return -1;
|
||||
if (!contains(area.rect, x, y)) return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int mx = frameToX(area, frameCount, frames[i]);
|
||||
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
|
||||
@@ -48,11 +78,11 @@ int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t*
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels) {
|
||||
std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
||||
std::int64_t startFrame, int dxPixels) {
|
||||
const std::int64_t start = clampFrame(startFrame, frameCount);
|
||||
if (dxPixels == 0) return start;
|
||||
const int w = std::max(0, area.width);
|
||||
const int w = std::max(0, area.rect.width);
|
||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
||||
const std::int64_t magnitude =
|
||||
|
||||
@@ -1,46 +1,81 @@
|
||||
// waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
|
||||
// editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
|
||||
// outside the DAW; the shell draws and marshals mouse events into it.
|
||||
// waveform_view.h — the WAVEFORM band's interior: the drawn lane/overlay surface, plus
|
||||
// frame<->pixel mapping, marker hit-test and zero-crossing snap. Unit-tested outside the
|
||||
// DAW; the shell draws and marshals mouse events into it.
|
||||
//
|
||||
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
|
||||
// waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
|
||||
// different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
|
||||
// The band maps a sample's full frame span [0, frameCount] linearly across its width.
|
||||
// Markers are a generic N-named-marker set (not hardcoded specials), so a different mode
|
||||
// (e.g. start + %-length end + fades) can repurpose the same machinery.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains
|
||||
#include "core/audio/peaks.h" // AudioSample (float), Envelope
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// What the waveform band actually draws: the channel lane(s), and THE rect every overlay
|
||||
// riding the waveform occupies.
|
||||
//
|
||||
// OVERLAY CONTRACT — `overlay` is the whole band in BOTH modes, never a lane. The amp
|
||||
// envelope trace and its node handles, the start/loop markers, and the loop region draw
|
||||
// ONCE into `overlay`, spanning both stacked lanes in stereo. Hit-testing reads the same
|
||||
// rect, so a grab in the lower lane resolves to the same overlay item as one in the upper.
|
||||
// Anything that draws per lane is a duplicate and a defect.
|
||||
struct WaveformSurface {
|
||||
Rect upper; // lane 0 -> channel 0 (LEFT); the whole band when single-lane
|
||||
Rect lower; // lane 1 -> channel 1 (RIGHT); empty() when single-lane
|
||||
OverlayArea overlay; // the full band, both modes
|
||||
int laneCount = 0; // 0 on a degenerate band, else 1 or 2 — matches `lower`'s emptiness
|
||||
// (2 iff lower non-empty). For a non-empty band <= 2px tall, `upper`
|
||||
// can be empty too while this still reports 1 — unreachable through
|
||||
// the band-stack allocator's kWaveformMinHeight floor.
|
||||
};
|
||||
|
||||
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source
|
||||
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a
|
||||
// second lane would be the redundant duplicate single-lane mode exists to avoid.
|
||||
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
|
||||
|
||||
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
|
||||
// paths that have no channel count to hand. An overlay's rect never depends on the lane
|
||||
// split, which is exactly the contract.
|
||||
OverlayArea waveformOverlayArea(const Rect& band);
|
||||
|
||||
// The single-channel envelope lane `lane` draws, taken from a multi-channel envelope
|
||||
// computed in ONE computeEnvelope pass (it already envelopes channels independently, so a
|
||||
// second lane costs no second scan of the PCM). Lane 0 is the upper lane and takes channel
|
||||
// 0, lane 1 the lower and channel 1 — the L-above-R order. An out-of-range lane yields an
|
||||
// empty envelope, which draws as a bare midline.
|
||||
audio::Envelope laneEnvelope(const audio::Envelope& env, int lane);
|
||||
|
||||
// Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
|
||||
// edge-grab idiom.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
|
||||
// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
|
||||
// every frame to area.x.
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
|
||||
// every frame to area.x. Takes the overlay (not a lane) — see the OVERLAY CONTRACT above.
|
||||
int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
|
||||
// area.x yields 0; right of area.right() yields frameCount.
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
|
||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
|
||||
|
||||
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
|
||||
// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
|
||||
// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
|
||||
// start <= loopEnd) after this per-marker resolve.
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels);
|
||||
std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
||||
std::int64_t startFrame, int dxPixels);
|
||||
|
||||
// Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
|
||||
// frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
reasampler_pure_library(json SOURCES json.cpp)
|
||||
reasampler_test(json LINK json)
|
||||
@@ -0,0 +1,17 @@
|
||||
reasampler_pure_library(bank_model SOURCES bank_model.cpp LINK PRIVATE json)
|
||||
reasampler_test(bank_model LINK bank_model)
|
||||
|
||||
reasampler_pure_library(slot_map SOURCES slot_map.cpp LINK PRIVATE json)
|
||||
reasampler_test(slot_map LINK slot_map json)
|
||||
|
||||
reasampler_pure_library(bank_book
|
||||
SOURCES bank_book.cpp bank_book_json.cpp
|
||||
LINK PUBLIC bank_model slot_map PRIVATE json)
|
||||
reasampler_test(bank_book LINK bank_book)
|
||||
|
||||
reasampler_pure_library(owned_manifest SOURCES owned_manifest.cpp LINK PRIVATE json)
|
||||
reasampler_test(owned_manifest LINK owned_manifest)
|
||||
|
||||
reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire)
|
||||
# bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip.
|
||||
reasampler_test(provenance LINK provenance bank_model)
|
||||
@@ -0,0 +1,3 @@
|
||||
reasampler_pure_library(prune_reconcile SOURCES prune_reconcile.cpp)
|
||||
# bank_book supplies the cross-bank referenced-file union the orphan set is computed against.
|
||||
reasampler_test(prune_reconcile LINK prune_reconcile bank_book)
|
||||
@@ -0,0 +1,40 @@
|
||||
reasampler_pure_library(bank_grid SOURCES bank_grid.cpp)
|
||||
reasampler_test(bank_grid LINK bank_grid)
|
||||
|
||||
reasampler_pure_library(tab_strip SOURCES tab_strip.cpp)
|
||||
reasampler_test(tab_strip LINK tab_strip)
|
||||
|
||||
reasampler_pure_library(prune_button SOURCES prune_button.cpp)
|
||||
reasampler_test(prune_button LINK prune_button)
|
||||
|
||||
reasampler_pure_library(drag_out SOURCES drag_out.cpp)
|
||||
reasampler_test(drag_out LINK drag_out)
|
||||
|
||||
reasampler_pure_library(theme SOURCES theme.cpp)
|
||||
reasampler_test(theme LINK theme)
|
||||
|
||||
reasampler_pure_library(component_geometry SOURCES component_geometry.cpp)
|
||||
reasampler_test(component_geometry LINK component_geometry)
|
||||
|
||||
reasampler_pure_library(action_bar SOURCES action_bar.cpp)
|
||||
reasampler_test(action_bar LINK action_bar)
|
||||
|
||||
# prune_button owns the FooterRect input type the footer layout reuses.
|
||||
reasampler_pure_library(footer_bar SOURCES footer_bar.cpp LINK PUBLIC prune_button)
|
||||
reasampler_test(footer_bar LINK footer_bar)
|
||||
|
||||
reasampler_pure_library(overflow_menu SOURCES overflow_menu.cpp)
|
||||
reasampler_test(overflow_menu LINK overflow_menu)
|
||||
|
||||
# view_mode_model owns the seed mode ids the predicate compares against.
|
||||
reasampler_pure_library(mode_enable SOURCES mode_enable.cpp LINK PUBLIC view_mode_model)
|
||||
reasampler_test(mode_enable LINK mode_enable)
|
||||
|
||||
reasampler_pure_library(tooltip SOURCES tooltip.cpp)
|
||||
reasampler_test(tooltip LINK tooltip)
|
||||
|
||||
reasampler_pure_library(card_meta SOURCES card_meta.cpp)
|
||||
reasampler_test(card_meta LINK card_meta)
|
||||
|
||||
reasampler_pure_library(card_drag SOURCES card_drag.cpp LINK PUBLIC drag_out bank_grid)
|
||||
reasampler_test(card_drag LINK card_drag)
|
||||
@@ -48,4 +48,10 @@ PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||
return out;
|
||||
}
|
||||
|
||||
OsHandoff decideOsHandoff(const std::vector<std::string>& paths) {
|
||||
OsHandoff out;
|
||||
out.handOffToOs = !paths.empty();
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -69,4 +69,21 @@ struct PathList {
|
||||
// exact-string — the shell normalizes case/slashes upstream if it wants Windows-style dedup.
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
|
||||
|
||||
// --- OS hand-off ordering -----------------------------------------------------
|
||||
|
||||
// Whether an empty/unresolvable payload should hand off to the OS at all. This decides ONLY
|
||||
// the empty-payload third of the failure space — an unresolvable payload must leave the
|
||||
// internal drag live rather than winding it down (release capture, clear drag state) for a
|
||||
// hand-off that then never happens, which reads to the user as "the drag did nothing, try
|
||||
// again". A resolved-but-OS-not-ready hand-off (OLE unavailable, HDROP build failure) is a
|
||||
// separate, shell-side readiness gate (drag_out_win::canInitiateDragOut) checked BEFORE the
|
||||
// shell tears down internal drag state — this struct does not model that path.
|
||||
struct OsHandoff {
|
||||
bool handOffToOs = false; // true: wind down internal drag state, then start the OS drag
|
||||
};
|
||||
|
||||
// Decides the hand-off from the assembled path list. Empty (everything stale/unresolvable)
|
||||
// means the internal drag stays live rather than dying half-torn-down.
|
||||
OsHandoff decideOsHandoff(const std::vector<std::string>& paths);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
reasampler_pure_library(file_bytes SOURCES file_bytes.cpp)
|
||||
reasampler_test(file_bytes LINK file_bytes)
|
||||
@@ -0,0 +1,42 @@
|
||||
reasampler_pure_library(app_version SOURCES app_version.cpp)
|
||||
# version_generated.h, configured at the root from the one REASAMPLER_VERSION string.
|
||||
# PUBLIC so every consumer of the channel bit sees it.
|
||||
target_include_directories(app_version PUBLIC ${PROJECT_BINARY_DIR}/generated)
|
||||
reasampler_test(app_version LINK app_version)
|
||||
|
||||
# Anti-normalization padding canary (rationale in tests/test_app_version_padding.cpp). The
|
||||
# live-version assertions in app_version_tests are version-shape-blind — relative checks
|
||||
# such as appVersion() == stampVersion() hold whether the string was threaded verbatim or
|
||||
# reconstructed from numeric components — so that suite cannot detect a
|
||||
# reconstruct-from-components regression at all. So: run the SAME template through
|
||||
# configure_file a second time with a SYNTHETIC padded version, and compile the SAME
|
||||
# app_version.cpp against it.
|
||||
#
|
||||
# "0.9.01" is a permanent test fixture, NOT the shipped version. It must match the literals
|
||||
# in test_app_version_padding.cpp and must NEVER be bumped on release.
|
||||
#
|
||||
# Coverage boundary: this catches reconstruct-from-components inside app_version.cpp. It
|
||||
# cannot see the live source-of-truth line becoming a CMake variable derivation — the
|
||||
# comment block at the top of the root CMakeLists.txt guards that half.
|
||||
function(_configure_padding_canary)
|
||||
# set() inside a function is function-scoped, so this clobber cannot leak to the parent
|
||||
# scope — no save/restore dance needed. REASAMPLER_CHANNEL_IS_BETA and the directory
|
||||
# variables are inherited read-only.
|
||||
set(REASAMPLER_VERSION "0.9.01")
|
||||
configure_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/version_generated.h.in
|
||||
${PROJECT_BINARY_DIR}/generated_padding_canary/version_generated.h
|
||||
@ONLY)
|
||||
endfunction()
|
||||
_configure_padding_canary()
|
||||
|
||||
# Longhand rather than reasampler_test: the canary RECOMPILES app_version.cpp instead of
|
||||
# linking the library, which is what lets the include-dir substitution work — it must see
|
||||
# generated_padding_canary/ in place of the live generated/ dir. If app_version ever gains
|
||||
# a link dependency, mirror it here.
|
||||
add_executable(app_version_padding_tests
|
||||
${REASAMPLER_TESTS_DIR}/test_app_version_padding.cpp
|
||||
app_version.cpp)
|
||||
target_include_directories(app_version_padding_tests PRIVATE
|
||||
${PROJECT_BINARY_DIR}/generated_padding_canary ${REASAMPLER_SRC_DIR})
|
||||
add_test(NAME app_version_padding_tests COMMAND app_version_padding_tests)
|
||||
@@ -0,0 +1,18 @@
|
||||
reasampler_pure_library(lane_keys SOURCES lane_keys.cpp)
|
||||
reasampler_test(lane_keys LINK lane_keys)
|
||||
|
||||
# lane_keys is PUBLIC: the lane-minting plan names managed lanes through the one durable-key
|
||||
# convention, so every consumer has to resolve that symbol too.
|
||||
reasampler_pure_library(view_mode_model
|
||||
SOURCES view_mode_model.cpp
|
||||
LINK PRIVATE json PUBLIC lane_keys)
|
||||
reasampler_test(view_mode_model LINK view_mode_model)
|
||||
|
||||
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
|
||||
reasampler_test(view_tree LINK view_tree)
|
||||
|
||||
reasampler_pure_library(guid_diff SOURCES guid_diff.cpp)
|
||||
reasampler_test(guid_diff LINK guid_diff)
|
||||
|
||||
reasampler_pure_library(mode_switch SOURCES mode_switch.cpp)
|
||||
reasampler_test(mode_switch LINK mode_switch)
|
||||
@@ -0,0 +1,18 @@
|
||||
reasampler_pure_library(wire SOURCES wire.cpp)
|
||||
reasampler_test(wire LINK wire)
|
||||
|
||||
reasampler_pure_library(assignment_request SOURCES assignment_request.cpp LINK PRIVATE wire)
|
||||
reasampler_test(assignment_request LINK assignment_request)
|
||||
|
||||
reasampler_pure_library(sample_usage SOURCES sample_usage.cpp LINK PRIVATE wire)
|
||||
# prune_reconcile composes the protection proof at the pure layer: a capture held by a live
|
||||
# instance lands in the referenced union, so pruneOrphans can never emit it.
|
||||
reasampler_test(sample_usage LINK sample_usage prune_reconcile)
|
||||
|
||||
# The drop payload is built through the instrument's OWN state serializer rather than a
|
||||
# parallel byte writer, so the cross-artifact contract cannot drift — hence the link to
|
||||
# component_state_io, which stays engine-free. The class-ID string derives from the frozen
|
||||
# UID macros, channel-selected via the generated version header, hence its include dir.
|
||||
reasampler_pure_library(instrument_drop SOURCES instrument_drop.cpp LINK PUBLIC component_state_io)
|
||||
target_include_directories(instrument_drop PUBLIC ${PROJECT_BINARY_DIR}/generated)
|
||||
reasampler_test(instrument_drop LINK instrument_drop)
|
||||
@@ -76,6 +76,17 @@ std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId)
|
||||
return buildVstPresetBytes(vstClassIdHex(), instrumentDropStateBytes(sampleId));
|
||||
}
|
||||
|
||||
DropOutcome decideDropOutcome(const DropAttempt& attempt) {
|
||||
DropOutcome out;
|
||||
if (attempt.addedFxIndex < 0) return out; // add failed — nothing exists to roll back
|
||||
if (!attempt.presetApplied) {
|
||||
out.rollbackFxIndex = attempt.addedFxIndex;
|
||||
return out;
|
||||
}
|
||||
out.loaded = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool infoNamesFxHotspot(const std::string& info) {
|
||||
// See the header contract for the prefix rule and the embed-strip exclusion.
|
||||
auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; };
|
||||
|
||||
@@ -68,4 +68,25 @@ bool infoNamesFxHotspot(const std::string& info);
|
||||
// assert the capture is selected. Not called by the shell.
|
||||
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
|
||||
|
||||
// --- All-or-nothing rollback --------------------------------------------------
|
||||
|
||||
// What the shell observed while executing one drop, reduced to the two REAPER
|
||||
// results the contract turns on.
|
||||
struct DropAttempt {
|
||||
int addedFxIndex = -1; // TrackFX_AddByName's return; < 0 = nothing was created
|
||||
bool presetApplied = false; // TrackFX_SetPreset's return
|
||||
};
|
||||
|
||||
// The verdict. `rollbackFxIndex >= 0` obliges the caller to TrackFX_Delete it before
|
||||
// returning — an instance whose capture never landed must not survive the drop.
|
||||
struct DropOutcome {
|
||||
bool loaded = false;
|
||||
int rollbackFxIndex = -1;
|
||||
};
|
||||
|
||||
// Pure so the contract is provable without a DAW: the rollback obligation is decided
|
||||
// here, not inline in the shell, and holds identically for every drop surface (FX
|
||||
// button, FX chain/container, Media-Explorer import).
|
||||
DropOutcome decideDropOutcome(const DropAttempt& attempt);
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -37,13 +37,11 @@ is owned by other directories and only skinned here.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Structural wart, not yet fixed:** `ingest.cpp` / `ingest.h`, plus `ext_keys.h`
|
||||
and `resource.h`, physically live at `src/` root rather than under
|
||||
`shell/actions/` — Phase Q's reorg did not re-home these files into
|
||||
`core/`/`shell/`/`app/`. `ingest` is documented here as its nearest sibling by
|
||||
role, but the files themselves are not in this directory. This is a code
|
||||
organization issue, not a documentation one — see Open questions in the
|
||||
originating dispatch report.
|
||||
- **Structural wart, partly closed:** `ingest.cpp` / `ingest.h` now live in this
|
||||
directory. `ext_keys.h` and `resource.h` still sit at `src/` root: `ext_keys.h`
|
||||
is consumed mostly from `shell/instrument/`, so it is not this directory's to
|
||||
claim, and `resource.h` is a build input paired with `src/resource.rc` (the SWELL
|
||||
resgen step) rather than a shell module.
|
||||
- Media-Explorer import is single-file, pull-on-action (`OpenMediaExplorer` +
|
||||
`MediaExplorerGetLastPlayedFileInfo`) — there is no enumerate-selected-files or
|
||||
register-a-drop-handler API on the Media Explorer surface.
|
||||
|
||||
@@ -62,6 +62,30 @@ HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
|
||||
return h;
|
||||
}
|
||||
|
||||
// COM reference counts MUST be interlocked. A CF_HDROP target is free to marshal the data
|
||||
// object into another apartment and finish the copy on a background thread AFTER DoDragDrop
|
||||
// has returned; Explorer's async file copy is suspected to do exactly this, though that
|
||||
// specific behavior is not confirmed by experiment. A plain ++/-- there races the source
|
||||
// thread's post-DoDragDrop Release: one lost increment destroys the object — and with it the
|
||||
// source HGLOBAL — before the target reads it, and the drop lands with no file. That race is
|
||||
// intermittent and a retry usually wins it; do not "simplify" these back.
|
||||
inline ULONG comAddRef(volatile LONG& refs) {
|
||||
return static_cast<ULONG>(InterlockedIncrement(&refs));
|
||||
}
|
||||
|
||||
// DoDragDrop requires the calling thread to be OLE-initialized — CoInitialize alone is not
|
||||
// enough, and an uninitialized thread fails the call outright, so the drag never starts.
|
||||
// Relying on REAPER having done it is a first-use hazard: whether it has depends on what else
|
||||
// ran first in the session. OleInitialize is per-thread refcounted, so this is additive to
|
||||
// whatever the host did; we deliberately never OleUninitialize — the extension lives for the
|
||||
// process, and unbalancing a REAPER-owned apartment is the hazard worth avoiding, not this.
|
||||
// RPC_E_CHANGED_MODE means the thread joined an MTA, where OLE drag-drop is unavailable.
|
||||
bool ensureOleForThisThread() {
|
||||
static thread_local int state = 0; // 0 untried, 1 ready, -1 unavailable
|
||||
if (state == 0) state = SUCCEEDED(OleInitialize(nullptr)) ? 1 : -1;
|
||||
return state > 0;
|
||||
}
|
||||
|
||||
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always
|
||||
// request the copy cursor. This is the standard textbook drop source — no custom feedback.
|
||||
class DropSource final : public IDropSource {
|
||||
@@ -76,11 +100,11 @@ public:
|
||||
*ppv = nullptr;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return comAddRef(refs_); }
|
||||
ULONG STDMETHODCALLTYPE Release() override {
|
||||
const ULONG r = --refs_;
|
||||
const LONG r = InterlockedDecrement(&refs_);
|
||||
if (r == 0) delete this;
|
||||
return r;
|
||||
return static_cast<ULONG>(r);
|
||||
}
|
||||
// IDropSource
|
||||
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
|
||||
@@ -92,7 +116,7 @@ public:
|
||||
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
|
||||
}
|
||||
private:
|
||||
ULONG refs_ = 1;
|
||||
volatile LONG refs_ = 1;
|
||||
};
|
||||
|
||||
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
|
||||
@@ -112,11 +136,11 @@ public:
|
||||
*ppv = nullptr;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return comAddRef(refs_); }
|
||||
ULONG STDMETHODCALLTYPE Release() override {
|
||||
const ULONG r = --refs_;
|
||||
const LONG r = InterlockedDecrement(&refs_);
|
||||
if (r == 0) delete this;
|
||||
return r;
|
||||
return static_cast<ULONG>(r);
|
||||
}
|
||||
|
||||
// IDataObject — the two that matter for a drag source.
|
||||
@@ -184,7 +208,7 @@ private:
|
||||
(fe.tymed & TYMED_HGLOBAL) &&
|
||||
fe.dwAspect == DVASPECT_CONTENT;
|
||||
}
|
||||
ULONG refs_ = 1;
|
||||
volatile LONG refs_ = 1;
|
||||
HGLOBAL hdrop_ = nullptr;
|
||||
};
|
||||
|
||||
@@ -192,10 +216,8 @@ private:
|
||||
|
||||
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
|
||||
if (absolutePaths.empty()) return false;
|
||||
if (!ensureOleForThisThread()) return false;
|
||||
|
||||
// REAPER's main thread is already OLE-initialized (it hosts OLE drag targets); we
|
||||
// deliberately do NOT call OleInitialize — pairing OleUninitialize across a
|
||||
// REAPER-owned apartment is the kind of thing that bites.
|
||||
HGLOBAL hdrop = buildHDrop(absolutePaths);
|
||||
if (!hdrop) return false;
|
||||
|
||||
@@ -213,6 +235,15 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& abso
|
||||
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
|
||||
}
|
||||
|
||||
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
|
||||
if (absolutePaths.empty()) return false;
|
||||
if (!ensureOleForThisThread()) return false;
|
||||
HGLOBAL hdrop = buildHDrop(absolutePaths);
|
||||
if (!hdrop) return false;
|
||||
GlobalFree(hdrop); // probe only — initiateDragOut builds its own on the real attempt
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
|
||||
@@ -239,6 +270,13 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
|
||||
return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return)
|
||||
}
|
||||
|
||||
// SWELL exposes no readiness probe ahead of SWELL_InitiateDragDropOfFileList (which itself
|
||||
// reports no accept/cancel outcome) — the non-empty check is the only thing knowable in
|
||||
// advance on this platform.
|
||||
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
|
||||
return !absolutePaths.empty();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
#endif
|
||||
|
||||
@@ -26,4 +26,10 @@ namespace reasampler {
|
||||
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error.
|
||||
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
|
||||
|
||||
// Side-effect-free readiness probe for initiateDragOut. Call BEFORE tearing down internal drag
|
||||
// state — an OS that isn't ready (OLE unavailable, HDROP build failure) must not consume the
|
||||
// gesture like an empty payload would. Windows re-runs the OLE-init + HDROP checks and frees the
|
||||
// probe HGLOBAL immediately; SWELL exposes no probe, so macOS/Linux reduces to the non-empty check.
|
||||
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// REAPER-facing, DAW-verified; the pure serialization it drives (assignment_request)
|
||||
// is CTest-tested.
|
||||
|
||||
#include "ingest.h"
|
||||
#include "shell/actions/ingest.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -20,6 +20,7 @@
|
||||
#define REAPERAPI_WANT_GetThingFromPoint
|
||||
#define REAPERAPI_WANT_TrackFX_AddByName
|
||||
#define REAPERAPI_WANT_TrackFX_Delete
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_SetPreset
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
@@ -29,6 +30,9 @@ namespace reasampler {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using version::vstPluginName;
|
||||
using wire::decideDropOutcome;
|
||||
using wire::DropAttempt;
|
||||
using wire::DropOutcome;
|
||||
using wire::infoNamesFxHotspot;
|
||||
|
||||
namespace {
|
||||
@@ -99,25 +103,39 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
|
||||
// (beta extension <-> beta VST) has no literal to drift.
|
||||
const std::string fxName = "VST3:" + vstPluginName();
|
||||
|
||||
// Negative `instantiate` => always create a NEW instance. recFX = false: a
|
||||
// normal track FX chain instance, not a record/monitoring FX.
|
||||
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
|
||||
/*instantiate=*/-1);
|
||||
bool ok = fxIndex >= 0;
|
||||
// An EXPLICIT top-level insertion position (instantiate <= -1000 IS the position, -1000
|
||||
// = first in chain), not the bare -1 — this form is documented in the SDK header. Both
|
||||
// always create a new instance; the bare form additionally leaves placement to REAPER's
|
||||
// ambient FX-chain insert point, which a drop onto an FX container/chain-window is
|
||||
// suspected (unconfirmed by experiment) to move — if so, the index handed to
|
||||
// TrackFX_SetPreset and the instance just created would stop denoting the same FX and the
|
||||
// capture would never land. The bare-form retry keeps the reference path alive if the
|
||||
// positional form is ever refused.
|
||||
// recFX = false: a normal track FX chain instance, not a record/monitoring FX.
|
||||
const int insertPos = TrackFX_GetCount(track);
|
||||
int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
|
||||
/*instantiate=*/-1000 - insertPos);
|
||||
// Only retry with the bare form when the chain is PROVABLY unchanged (count still
|
||||
// insertPos): a negative return with the count grown means the positional add DID create
|
||||
// an instance and just reported -1 — retrying then would add a SECOND instance, leaving
|
||||
// the first orphaned (no preset applied, unreachable for rollback), which is exactly the
|
||||
// all-or-nothing violation this contract forbids.
|
||||
if (fxIndex < 0 && TrackFX_GetCount(track) == insertPos)
|
||||
fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false, /*instantiate=*/-1);
|
||||
|
||||
// u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under
|
||||
// an accented or CJK user-name is handled correctly by REAPER's path APIs.
|
||||
if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
|
||||
DropAttempt attempt;
|
||||
attempt.addedFxIndex = fxIndex;
|
||||
if (fxIndex >= 0)
|
||||
attempt.presetApplied = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(presetPath, ec); // transient regardless of outcome
|
||||
|
||||
// All-or-nothing: if the preset apply fails, remove the FX instance we just
|
||||
// added so the track is left exactly as it was.
|
||||
if (!ok && fxIndex >= 0) {
|
||||
TrackFX_Delete(track, fxIndex);
|
||||
}
|
||||
return ok;
|
||||
const DropOutcome outcome = decideDropOutcome(attempt);
|
||||
if (outcome.rollbackFxIndex >= 0) TrackFX_Delete(track, outcome.rollbackFxIndex);
|
||||
return outcome.loaded;
|
||||
}
|
||||
|
||||
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "shell/panel/panel_input.h" // bankPanelTailSetting / bankPanelRefresh
|
||||
#include "core/capture/tail_control.h" // TailSetting
|
||||
#include "core/model/provenance.h" // model::Provenance
|
||||
#include "ingest.h" // ingestAssignActiveInstance
|
||||
#include "shell/actions/ingest.h" // ingestAssignActiveInstance
|
||||
#include "shell/persist/session.h" // ReaSamplerSession
|
||||
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
||||
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
||||
|
||||
@@ -8,10 +8,10 @@ two small identity/helper headers this directory owns outright
|
||||
(`reasampler_vst.h`, `editor_internal.h`).
|
||||
|
||||
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
||||
`sample_map`, `component_state_io`, `zone_params.h`, `editor_geometry`,
|
||||
`keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `note_entry`,
|
||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||
|
||||
## Invariants
|
||||
@@ -78,7 +78,7 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
- No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not
|
||||
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||||
- The pure core stays REAPER-free *and* VST3-free — the voice engine / envelope /
|
||||
keymap / repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
marshals. Any VST3 or REAPER type leaking into `core/instrument` is a bug.
|
||||
- Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
|
||||
headers before use.
|
||||
@@ -86,17 +86,17 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
## Modules
|
||||
|
||||
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family (Q-W2v split), included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the former god-TU's anonymous-namespace helpers that more than one split TU needs. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/editor_internal.h`'s own header comment and body.)*
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `editor_internal.h` is include-only — it has no TU of its own and must never become
|
||||
a public seam; only the eight `reasampler_editor` face-axis TUs include it.
|
||||
a public seam; only the `reasampler_editor` band-axis TUs include it.
|
||||
- The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
|
||||
`reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID.
|
||||
- The UID selection `#ifdef` in `reasampler_vst.h` is the one deliberate exception to
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# ReaSampler 9000 — the second build artifact: a Windows-only VST3 instrument the user
|
||||
# instantiates on an instrument track. Additive; the extension builds unchanged.
|
||||
#
|
||||
# The nested vst3sdk slice (pluginterfaces / base / public.sdk) is a one-time submodule
|
||||
# step documented in CLAUDE.md. When it is absent — a fresh clone that ran only the
|
||||
# top-level init — skip this module rather than fail configure on missing sources: the pure
|
||||
# libraries and their CTest targets still build and test without the SDK. Probe one
|
||||
# representative source file.
|
||||
if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
|
||||
# The bounded slice of the Steinberg VST3 SDK this instrument needs. Enumerated rather
|
||||
# than add_subdirectory of the whole SDK to keep the build hermetic and lean: no VSTGUI,
|
||||
# no examples, no SDK-global CMake helpers or install machinery. The submodule is pinned
|
||||
# to tag v3.7.9_build_61, so the list is fixed — re-verify this set if the tag is bumped.
|
||||
add_library(vst3_sdk STATIC
|
||||
${VST3_SDK}/pluginterfaces/base/funknown.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/coreiids.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/conststringtable.cpp
|
||||
${VST3_SDK}/pluginterfaces/base/ustring.cpp
|
||||
${VST3_SDK}/base/source/fobject.cpp
|
||||
${VST3_SDK}/base/source/fstring.cpp
|
||||
${VST3_SDK}/base/source/fbuffer.cpp
|
||||
${VST3_SDK}/base/source/fstreamer.cpp
|
||||
${VST3_SDK}/base/source/fdebug.cpp
|
||||
${VST3_SDK}/base/source/baseiids.cpp
|
||||
${VST3_SDK}/base/source/updatehandler.cpp
|
||||
${VST3_SDK}/base/thread/source/flock.cpp
|
||||
# vstsinglecomponenteffect.cpp #includes vsteditcontroller.cpp unity-style, so
|
||||
# vsteditcontroller.cpp must NOT be listed separately (double definition).
|
||||
${VST3_SDK}/public.sdk/source/vst/vstsinglecomponenteffect.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstcomponentbase.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstbus.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstparameters.cpp
|
||||
${VST3_SDK}/public.sdk/source/vst/vstinitiids.cpp
|
||||
${VST3_SDK}/public.sdk/source/common/pluginview.cpp
|
||||
${VST3_SDK}/public.sdk/source/common/commoniids.cpp
|
||||
# dllmain.cpp + moduleinit.cpp are NOT here — see the module target below.
|
||||
${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp
|
||||
)
|
||||
target_include_directories(vst3_sdk PUBLIC ${VST3_SDK})
|
||||
# The SDK requires exactly one of RELEASE / DEVELOPMENT (fdebug.cpp keys off it).
|
||||
target_compile_definitions(vst3_sdk PUBLIC $<IF:$<CONFIG:Debug>,DEVELOPMENT=1,RELEASE=1>)
|
||||
|
||||
add_library(reasampler_vst MODULE
|
||||
vst_entry.cpp
|
||||
reasampler_processor.cpp
|
||||
processor_state.cpp
|
||||
processor_reload.cpp
|
||||
# The editor family is split on the Sample face's band axis: session/bridge state,
|
||||
# param plumbing plus the shared band-layout resolve, then paint and input in
|
||||
# matching sets, plus the two band-independent surfaces and the platform TU.
|
||||
editor_session.cpp
|
||||
editor_controls.cpp
|
||||
editor_paint.cpp
|
||||
editor_paint_chrome.cpp
|
||||
editor_paint_waveform.cpp
|
||||
editor_paint_deck.cpp
|
||||
editor_paint_browse.cpp
|
||||
editor_paint_curve.cpp
|
||||
editor_input.cpp
|
||||
editor_input_chrome.cpp
|
||||
editor_input_waveform.cpp
|
||||
editor_input_deck.cpp
|
||||
editor_input_browse.cpp
|
||||
editor_input_curve.cpp
|
||||
editor_platform.cpp
|
||||
reasampler_embed.cpp
|
||||
reaper_bridge.cpp
|
||||
# draw_kit is compiled into each module rather than being a static library — see root
|
||||
# CMakeLists.txt's LICE_SRC comment for why.
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp
|
||||
# Compiled into the module, NOT into vst3_sdk: their SMTG_EXPORT_SYMBOL functions
|
||||
# (InitDll/ExitDll) have no internal referrer, so the linker strips them from a
|
||||
# static library. Compiling them here keeps the exports.
|
||||
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
|
||||
${VST3_SDK}/public.sdk/source/main/moduleinit.cpp
|
||||
${LICE_SRC}
|
||||
)
|
||||
# sample_map's build yields plain SampleData, so it does NOT pull the voice engine —
|
||||
# sampler_core is linked directly. app_version's PUBLIC include dir carries the
|
||||
# generated version header, so the instrument reads the SAME channel-derived ext-state
|
||||
# namespace the extension writes.
|
||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||
sampler_core sample_map component_state_io capture_paths embed_strip app_version
|
||||
capture_browser keyboard_strip sample_bands sample_chrome
|
||||
waveform_view bank_sync browser_scroll param_slider tooltip
|
||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||
knob_deck deck_groups curve_popup master_gain sample_usage file_bytes)
|
||||
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
||||
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
||||
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
||||
|
||||
# A .vst3 is a DLL with a .vst3 extension and no lib prefix. OUTPUT_NAME is channel-
|
||||
# forked from the one channel decision at the root and must match
|
||||
# app_version::vstOutputName(); the two channels install side-by-side, and the
|
||||
# per-channel VST3 class UID keeps a saved instance bound to its own channel.
|
||||
# LIBRARY_OUTPUT_DIRECTORY / ARCHIVE_OUTPUT_DIRECTORY pin the module to the top of the
|
||||
# build tree even though this target is declared in a subdirectory — see src/app/CMakeLists.txt's
|
||||
# set_target_properties comment for why both properties are needed.
|
||||
set_target_properties(reasampler_vst PROPERTIES
|
||||
PREFIX ""
|
||||
SUFFIX ".vst3"
|
||||
OUTPUT_NAME "${REASAMPLER_VST_OUTPUT_NAME}"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
|
||||
endif()
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
|
||||
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
|
||||
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
|
||||
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// Value logic only — no painting, no window plumbing.
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
|
||||
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
|
||||
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (the
|
||||
// trigger-seam converter). Value logic only — no painting, no window plumbing.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -12,25 +12,40 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
|
||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
||||
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||
#include "core/util/clamp01.h"
|
||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
|
||||
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum
|
||||
using instrument::ui::computeSampleBands;
|
||||
using instrument::ui::chromeRects;
|
||||
using instrument::ui::deckHeight;
|
||||
using instrument::ui::kDeckKnobSize;
|
||||
using instrument::ui::kPad;
|
||||
using instrument::ui::deckBipolarFromNorm;
|
||||
using instrument::ui::deckNormFromBipolar;
|
||||
using instrument::ui::sampleDeckGroups;
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
using instrument::engine::filter::MorphLaw;
|
||||
using instrument::engine::filter::filterCutoffHzFromNorm;
|
||||
using instrument::engine::filter::filterDriveDepthFromNorm;
|
||||
using instrument::engine::filter::filterQFromNorm;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
|
||||
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
|
||||
// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames
|
||||
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
|
||||
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
|
||||
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
|
||||
@@ -42,7 +57,18 @@ constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling
|
||||
|
||||
} // namespace
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
||||
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
|
||||
// chrome interior, and the deck descriptors can never be derived three different ways.
|
||||
// The deck's own wrapped height is the only interior measurement the allocator needs.
|
||||
FaceLayout fl;
|
||||
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
|
||||
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
|
||||
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
||||
return fl;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
|
||||
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
|
||||
@@ -70,11 +96,28 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||
// Filter. The four tone controls ARE the module's normalized positions — stored and
|
||||
// shown as-is, so the knob travel is exactly filter_params' own law.
|
||||
case ParamControl::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
||||
case ParamControl::kFilterLaw:
|
||||
return play.filter.settings.morphLaw == MorphLaw::HighNotchLow ? 1.0 : 0.0;
|
||||
case ParamControl::kFilterMorph: return clamp01(play.filter.settings.morphNorm);
|
||||
case ParamControl::kFilterCutoff: return clamp01(play.filter.settings.cutoffNorm);
|
||||
case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
|
||||
case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||
case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||
case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||
case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||
case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
||||
case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
||||
case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
|
||||
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
||||
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
|
||||
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||
@@ -109,6 +152,33 @@ void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||
break;
|
||||
case ParamControl::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||
case ParamControl::kFilterLaw:
|
||||
play.filter.settings.morphLaw =
|
||||
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||
break;
|
||||
case ParamControl::kFilterMorph:
|
||||
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
||||
case ParamControl::kFilterCutoff:
|
||||
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
||||
case ParamControl::kFilterQ:
|
||||
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
||||
case ParamControl::kFilterDrive:
|
||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||
case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||
case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||
case ParamControl::kFilterKeyTrack:
|
||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||
case ParamControl::kFilterEnvAttack:
|
||||
play.filter.env.attackSeconds = normToSec(value); break;
|
||||
case ParamControl::kFilterEnvHold:
|
||||
play.filter.env.holdSeconds = normToSec(value); break;
|
||||
case ParamControl::kFilterEnvDecay:
|
||||
play.filter.env.decaySeconds = normToSec(value); break;
|
||||
case ParamControl::kFilterEnvSustain:
|
||||
play.filter.env.sustainLevel = clamp01(value); break;
|
||||
case ParamControl::kFilterEnvRelease:
|
||||
play.filter.env.releaseSeconds = normToSec(value); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -134,94 +204,22 @@ double ReaSamplerEditor::previewVelocity01() const {
|
||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
|
||||
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
|
||||
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
|
||||
// neighbouring groups.
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
|
||||
if (play.playMode == PlayMode::Gate) {
|
||||
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
|
||||
static_cast<int>(ParamControl::kHold),
|
||||
static_cast<int>(ParamControl::kDecay),
|
||||
static_cast<int>(ParamControl::kSustain),
|
||||
static_cast<int>(ParamControl::kRelease)};
|
||||
} else {
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus the two reserved blanks.
|
||||
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
|
||||
static_cast<int>(ParamControl::kTrigLength),
|
||||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
||||
}
|
||||
out.push_back(std::move(amp));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc pitch;
|
||||
pitch.id = kGroupPitch;
|
||||
pitch.captionWidth = 38;
|
||||
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
|
||||
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
|
||||
out.push_back(std::move(pitch));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
|
||||
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
|
||||
static_cast<int>(ParamControl::kPitchEnvDecay),
|
||||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
|
||||
// appended here, not in zoneDeckGroupDescs.
|
||||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
voice.captionWidth = 38;
|
||||
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
|
||||
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
|
||||
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
|
||||
out.push_back(std::move(voice));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc master;
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.cellIds = {static_cast<int>(ParamControl::kMasterGain)};
|
||||
out.push_back(std::move(master));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
|
||||
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
|
||||
double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(zone.keyTrack / kKeyTrackMax);
|
||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
case ParamControl::kMasterGain:
|
||||
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
||||
default:
|
||||
return controlValue(id, zone.play);
|
||||
return controlValue(id, params_.play);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
||||
if (!processor_) return;
|
||||
norm = clamp01(norm);
|
||||
if (id == -2) {
|
||||
@@ -247,15 +245,15 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
|
||||
return;
|
||||
default:
|
||||
applyZoneControl(zoneIndex, id, norm, 0);
|
||||
applyParamControl(id, norm, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||
char buf[24];
|
||||
buf[0] = '\0';
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const PlaySeconds& play = params_.play;
|
||||
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kAttack:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
|
||||
@@ -282,13 +280,50 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
|
||||
case ParamControl::kKeyTrack:
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
|
||||
case ParamControl::kVoiceCount:
|
||||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
||||
case ParamControl::kMasterGain:
|
||||
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
|
||||
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
||||
// Filter readouts run the stored normalized positions back through the module's OWN
|
||||
// laws, so what the label says is what the kernel is solved for.
|
||||
case ParamControl::kFilterMorph: {
|
||||
const double m = play.filter.settings.morphNorm;
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
|
||||
break;
|
||||
}
|
||||
case ParamControl::kFilterCutoff: {
|
||||
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
|
||||
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
|
||||
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
|
||||
break;
|
||||
}
|
||||
case ParamControl::kFilterQ:
|
||||
snprintf(buf, sizeof(buf), "%.2f",
|
||||
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
|
||||
break;
|
||||
case ParamControl::kFilterDrive:
|
||||
snprintf(buf, sizeof(buf), "%.2f",
|
||||
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
|
||||
break;
|
||||
case ParamControl::kFilterModAmt:
|
||||
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
|
||||
case ParamControl::kFilterVel:
|
||||
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
|
||||
case ParamControl::kFilterKeyTrack:
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
|
||||
case ParamControl::kFilterEnvAttack:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.attackSeconds); break;
|
||||
case ParamControl::kFilterEnvHold:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.holdSeconds); break;
|
||||
case ParamControl::kFilterEnvDecay:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.decaySeconds); break;
|
||||
case ParamControl::kFilterEnvSustain:
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
|
||||
case ParamControl::kFilterEnvRelease:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break;
|
||||
default:
|
||||
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
|
||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
||||
break;
|
||||
}
|
||||
return std::string(buf);
|
||||
@@ -309,7 +344,7 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
return b;
|
||||
}
|
||||
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const {
|
||||
AmpEnvelope env;
|
||||
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
||||
@@ -320,7 +355,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
env.sustainLevel = play.adsr.sustainLevel;
|
||||
env.releaseSeconds = play.adsr.releaseSeconds;
|
||||
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the zone's effective start
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the effective start
|
||||
// point so the fraction denominator matches the voice's actual post-start span. A zero play
|
||||
// length yields 0 fractions.
|
||||
env.lengthFraction = play.trigger.lengthFraction;
|
||||
@@ -332,7 +367,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
|
||||
std::int64_t startFrame, ZonePlaySeconds& play) const {
|
||||
std::int64_t startFrame, PlaySeconds& play) const {
|
||||
if (env.mode == EnvMode::Gate) {
|
||||
play.adsr.attackSeconds = env.attackSeconds;
|
||||
play.adsr.holdSeconds = env.holdSeconds;
|
||||
@@ -342,7 +377,7 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
} else {
|
||||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
||||
// the played span (the trigger-seam converter, unpack direction). startFrame is the
|
||||
// zone's effective start point so the frame denominator matches the voice's actual
|
||||
// effective start point so the frame denominator matches the voice's actual
|
||||
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
|
||||
// so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||||
@@ -353,39 +388,13 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
}
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||||
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
|
||||
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
}
|
||||
return effectiveSampleZone();
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::popupZoneIndex() {
|
||||
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
|
||||
// Zone surface never materializes (the button only shows for an explicit selection); the
|
||||
// Sample face finds-or-materializes the picked id's one-zone site.
|
||||
if (view_ == View::kZone) {
|
||||
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
|
||||
? selectedZone_
|
||||
: -1;
|
||||
}
|
||||
return ensureSampleZone();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1.
|
||||
z.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||
params_.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
} else {
|
||||
applyControl(id, z.play, value, segment);
|
||||
applyControl(id, params_.play, value, segment);
|
||||
}
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// editor_input.cpp — the ReaSamplerEditor's input dispatch and drag-state machine: the
|
||||
// mouse-down routing (curve popup first, then Browse or the three bands in order), the
|
||||
// onMouseMove drag router, the release commit, and the hover resolver. The per-band
|
||||
// branches live in the editor_input_<band> TUs; this TU only sequences them.
|
||||
// Windows-only. All hit-test math is pure; this family routes and mutates editor state.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// The curve popup is modal over the face — while open it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
// Band order matters only where bands can overlap on a degenerate window; each branch
|
||||
// reports whether it consumed the click so the next band gets a clean shot.
|
||||
const FaceLayout fl = faceLayout(w, h);
|
||||
if (mouseDownChrome(fl, x, y)) return;
|
||||
if (selectedId_.empty()) return; // empty state — chrome nav only
|
||||
if (mouseDownDeck(fl, x, y)) return;
|
||||
mouseDownWaveform(fl, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
|
||||
dragCurY_ = y;
|
||||
|
||||
// The three band-free drags resolve without a layout pass at all.
|
||||
switch (drag_) {
|
||||
case DragKind::kDeckKnob: dragDeck(x, y); return;
|
||||
case DragKind::kScrollThumb: dragBrowse(x, y); return;
|
||||
case DragKind::kCurveNode: dragCurve(x, y); return;
|
||||
default: break;
|
||||
}
|
||||
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
dragChrome(fl, x, y);
|
||||
} else {
|
||||
dragWaveform(fl, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = -1;
|
||||
invalidate();
|
||||
}
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
// hover_ is deliberately not re-resolved during a drag (see resolveHover's caller), so it
|
||||
// still names wherever the drag started. Re-resolve now against the release position, for
|
||||
// every drag kind — otherwise the next paint latches a stale hover (wrong note name/tooltip,
|
||||
// wrong control outline) until the next WM_MOUSEMOVE. This resolve runs before the release
|
||||
// branches below can delete or relocate the hovered element; a future branch that does so
|
||||
// must clear hover_ itself afterwards (as the curve drag-off delete does below) rather than
|
||||
// rely on this resolve, which reflects pre-mutation state.
|
||||
resolveHover(x, y);
|
||||
// A scrollbar drag is transient UI (no parameter change), and the processor-side knobs
|
||||
// (the preview-velocity -2 sentinel, voice count, master gain) are per-instance settings
|
||||
// that don't reload the instrument. Master gain is an atomic the audio thread reads
|
||||
// directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||||
processor_->setVoiceCount(voiceCount_);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
x > curveRect.right() + kCurveDragOffMargin ||
|
||||
y < curveRect.y - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
params_.velocityCurve.deletePoint(static_cast<std::size_t>(curveIdx));
|
||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's routing order, but read-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int hgt = cr.bottom - cr.top;
|
||||
|
||||
HoverTarget h; // kNone by default
|
||||
if (view_ == View::kBrowse) {
|
||||
h = hoverBrowse(w, hgt, x, y);
|
||||
} else if (curvePopupOpen_) { // modal over the face
|
||||
h = hoverCurvePopup(w, hgt, x, y);
|
||||
} else {
|
||||
const FaceLayout fl = faceLayout(w, hgt);
|
||||
h = hoverChrome(fl, x, y);
|
||||
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y);
|
||||
}
|
||||
|
||||
if (h != hover_) {
|
||||
hover_ = h;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,174 @@
|
||||
// editor_input_browse.cpp — the Browse modal's input: the click branch (tabs, cards,
|
||||
// select-then-confirm, scroll-thumb grab, search focus), the thumb drag, the wheel scroll,
|
||||
// the type-to-filter keystrokes, and the modal's hover. Also carries the degraded
|
||||
// drop affordance (an OS drop is never ingested). Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
// Cancel/Back: discard the pending pick, return to Sample unchanged.
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.confirm, x, y)) {
|
||||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||||
if (!browsePendingId_.empty()) loadSelection(browsePendingId_);
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
|
||||
searchFocused_ = false;
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) {
|
||||
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height > 0 &&
|
||||
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
|
||||
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||
loadSelection(id);
|
||||
browsePendingId_.clear();
|
||||
lastBrowseClickCard_ = -1;
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
} else {
|
||||
browsePendingId_ = id;
|
||||
lastBrowseClickCard_ = card;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastBrowseClickCard_ = -1;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragBrowse(int x, int y) {
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
|
||||
// window recomputes at paint from scrollOffset_.
|
||||
(void)x;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const BrowseModal bm = computeBrowseModal(rc.right - rc.left, rc.bottom - rc.top);
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||
dragStartScrollOffset_, y - dragStartY_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
|
||||
int y) const {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
|
||||
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
|
||||
if (contains(bm.confirm, x, y)) return {HoverKind::kBrowseConfirm, -1};
|
||||
if (contains(bm.search, x, y)) return {HoverKind::kSearchBox, -1};
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) return {HoverKind::kFilterTab, tab};
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) return {HoverKind::kCard, card};
|
||||
return {};
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the face, and
|
||||
// the Browse search cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Type-to-filter search. Only when the search box has focus (a click focuses it).
|
||||
// Backspace deletes; a printable ASCII char appends; the visible list recomposes (bank
|
||||
// filter, then search).
|
||||
if (view_ != View::kBrowse || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
} else if (ch == 27) { // escape clears + defocuses
|
||||
searchQuery_.clear();
|
||||
searchFocused_ = false;
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
searchQuery_.push_back(static_cast<char>(ch));
|
||||
} else {
|
||||
return; // ignore other control chars
|
||||
}
|
||||
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,437 +0,0 @@
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// input + the hover resolver: hover resolution across all three faces, the Browse picker's
|
||||
// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the
|
||||
// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck
|
||||
// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes,
|
||||
// and the degraded drop affordance. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry)
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
HoverTarget h; // kNone by default
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int hgt = cr.bottom - cr.top;
|
||||
|
||||
if (view_ == View::kBrowse) {
|
||||
const BrowseModal bm = computeBrowseModal(w, hgt);
|
||||
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
|
||||
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
|
||||
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
|
||||
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
|
||||
else {
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
const int card = (tab >= 0)
|
||||
? -1
|
||||
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
|
||||
else if (card >= 0) h = {HoverKind::kCard, card};
|
||||
}
|
||||
} else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
|
||||
if (contains(pl.close, x, y)) {
|
||||
h = {HoverKind::kPopupClose, -1};
|
||||
} else if (contains(pl.curveBox, x, y)) {
|
||||
// A curve node under the pointer lights accent-hot.
|
||||
const int idx =
|
||||
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||||
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
|
||||
}
|
||||
} else if (view_ == View::kZone) {
|
||||
const Rect back = zoneBackRect(w, hgt);
|
||||
const Rect content = zoneContentArea(w, hgt);
|
||||
Rect addR = zoneAddRect(content);
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (contains(back, x, y)) {
|
||||
h = {HoverKind::kBack, -1};
|
||||
} else if (contains(addR, x, y)) {
|
||||
h = {HoverKind::kAddZone, -1};
|
||||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
h = {HoverKind::kDeleteZone, -1};
|
||||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
// The per-zone knob deck + the mini curve-preview button (the Sample deck's hover
|
||||
// grammar — knobs light + swap label->value).
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
h = {HoverKind::kCurveButton, -1};
|
||||
} else {
|
||||
const ZonePlaySeconds& play =
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x,
|
||||
deckArea.y, deckArea.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home)
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
h = {HoverKind::kNavBrowse, -1};
|
||||
} else if (contains(bands.navZone, x, y)) {
|
||||
h = {HoverKind::kNavZone, -1};
|
||||
} else if (selectedId_.empty() && map_.zones.empty()) {
|
||||
// Empty state — no interactive surfaces beyond the nav.
|
||||
} else {
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
|
||||
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
|
||||
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
|
||||
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
|
||||
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
|
||||
else if (contains(bands.deck, x, y)) {
|
||||
// A deck knob/toggle under the pointer: knobs light + swap label->value.
|
||||
const DeckLayout dl =
|
||||
layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h != hover_) {
|
||||
hover_ = h;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the
|
||||
// dispatch).
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
// Cancel/Back: discard the pending pick, return to Sample unchanged.
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.confirm, x, y)) {
|
||||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||||
if (!browsePendingId_.empty()) {
|
||||
loadSelection(browsePendingId_);
|
||||
}
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
|
||||
searchFocused_ = false;
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) {
|
||||
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height > 0 &&
|
||||
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
|
||||
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||
loadSelection(id);
|
||||
browsePendingId_.clear();
|
||||
lastBrowseClickCard_ = -1;
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
} else {
|
||||
browsePendingId_ = id;
|
||||
lastBrowseClickCard_ = card;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastBrowseClickCard_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the
|
||||
// Zone surface too).
|
||||
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
|
||||
const Rect content = zoneContentArea(w, h);
|
||||
Rect addR = zoneAddRect(content);
|
||||
if (contains(addR, x, y)) {
|
||||
// Add a narrow default zone for the picked capture (or the first visible sample as a
|
||||
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
|
||||
// already exists, select it rather than appending a duplicate (mirrors the upsert the
|
||||
// root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one
|
||||
// octave centred on the bank root, clamped to [0,127]) so the new zone is immediately
|
||||
// "authored" (narrow) and survives reconcileSingleCaptureZones without being treated
|
||||
// as a Sample-face full-range zone.
|
||||
std::string seed = !selectedId_.empty() ? selectedId_
|
||||
: (!visible_.empty() ? visible_.front().id : std::string());
|
||||
if (seed.empty()) return;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
|
||||
selectedZone_ = i;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Look up the seed's root note from the browser list (absent root defaults to 60).
|
||||
int seedRoot = 60;
|
||||
for (const SampleChoice& sc : samples_) {
|
||||
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
|
||||
}
|
||||
const int lo = (std::max)(0, seedRoot - 6);
|
||||
const int hi = (std::min)(127, seedRoot + 5);
|
||||
PerformanceZone z;
|
||||
z.sampleId = seed;
|
||||
z.lowNote = lo;
|
||||
z.highNote = hi;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
map_.zones.erase(map_.zones.begin() + selectedZone_);
|
||||
selectedZone_ = -1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
|
||||
// selected zone's root.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int lx = x - stripArea.x;
|
||||
const int ly = y - stripArea.y;
|
||||
|
||||
std::vector<int> lows, highs;
|
||||
lows.reserve(map_.zones.size());
|
||||
highs.reserve(map_.zones.size());
|
||||
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
|
||||
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
|
||||
highs.empty() ? nullptr : highs.data(),
|
||||
static_cast<int>(map_.zones.size()), lx, ly);
|
||||
if (hit.zoneIndex >= 0) {
|
||||
selectedZone_ = hit.zoneIndex;
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
|
||||
dragStartX_ = x;
|
||||
dragStartLow_ = z.lowNote;
|
||||
dragStartHigh_ = z.highNote;
|
||||
dragStartMap_ = map_;
|
||||
switch (hit.grab) {
|
||||
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
|
||||
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
|
||||
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
|
||||
default: drag_ = DragKind::kNone; break;
|
||||
}
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// A bare key-click inside the strip sets the selected zone's root override.
|
||||
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const int note = keyAtPoint(sl, lx, ly);
|
||||
if (note >= 0) {
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
if (contains(noteEntryFieldRect(fields, f), x, y)) {
|
||||
entryField_ = f;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
|
||||
|
||||
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
|
||||
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
|
||||
// Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y,
|
||||
deckArea.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
|
||||
// final edit committed at once (the deck precedent). No per-instance ids reach
|
||||
// here — VOICE/MASTER are not in the zone group set.
|
||||
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
|
||||
// Sample deck's guard, mirrored.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||||
// Grab-anchored vertical drag: live-drag the map, commit on release.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = selectedZone_;
|
||||
dragStartMap_ = map_;
|
||||
dragKnobStartValue_ = deckControlNorm(
|
||||
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface; opening it clears any note-entry focus, and the Browse search
|
||||
// cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||
// focused, owns the keystrokes).
|
||||
if (view_ == View::kZone && entryField_ >= 0) {
|
||||
if (ch == 13) { // Enter: parse + commit
|
||||
if (auto note = parseNoteEntry(entryText_)) {
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
|
||||
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
|
||||
else z.rootOverride = *note;
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 27) { // Escape cancels
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 8) { // backspace
|
||||
if (!entryText_.empty()) entryText_.pop_back();
|
||||
invalidate();
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
entryText_.push_back(static_cast<char>(ch));
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then
|
||||
// search).
|
||||
if (view_ != View::kBrowse || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
} else if (ch == 27) { // escape clears + defocuses
|
||||
searchQuery_.clear();
|
||||
searchFocused_ = false;
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
searchQuery_.push_back(static_cast<char>(ch));
|
||||
} else {
|
||||
return; // ignore other control chars
|
||||
}
|
||||
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
#ifdef _WIN32
|
||||
invalidate();
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,120 @@
|
||||
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
|
||||
// the preview-velocity knob grab, the curve-button summon, the channel toggle, and the
|
||||
// piano strip's root grab plus its live drag. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root key)
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
|
||||
if (contains(cr.navBrowse, x, y)) {
|
||||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||||
// capture reads as pre-selected.
|
||||
browsePendingId_ = selectedId_;
|
||||
lastBrowseClickCard_ = -1;
|
||||
view_ = View::kBrowse;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (selectedId_.empty()) return false; // empty state — nav only
|
||||
|
||||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||||
// (momentary — note-on on press, note-off on release).
|
||||
if (contains(cr.preview, x, y)) {
|
||||
const int note = effectiveRoot();
|
||||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = note;
|
||||
processor_->previewNoteOn(note);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||
if (contains(cr.velCell, x, y)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
dragKnobStartValue_ = previewVelocity01();
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
// The mini curve-preview button: summon the popup editor.
|
||||
if (contains(cr.curveBtn, x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.chanMono, x, y)) {
|
||||
channelMode_ = ChannelMode::Mono;
|
||||
processor_->setChannelMode(ChannelMode::Mono);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.chanStereo, x, y)) {
|
||||
channelMode_ = ChannelMode::Stereo;
|
||||
processor_->setChannelMode(ChannelMode::Stereo);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The piano strip: clicking a key sets the root, and holding tracks the pointer. The
|
||||
// click itself lands below as the first (unmoved) drag resolve.
|
||||
if (!cr.rootStrip.empty()) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
if (keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y) >= 0) {
|
||||
drag_ = DragKind::kRootMarker;
|
||||
dragStartParams_ = params_;
|
||||
onMouseMove(x, y);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A click on the strip row's background is consumed so it can't fall through to a
|
||||
// band the user cannot see under the chrome.
|
||||
return contains(cr.controls, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
|
||||
const Rect& stripArea = fl.chrome.rootStrip;
|
||||
if (stripArea.empty()) return;
|
||||
// Absolute tracking, not a pixel delta: with black keys overlaying whites there is no
|
||||
// one pixels-per-semitone rate a delta could use.
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int note = resolveDragNote(sl, x - stripArea.x, y - stripArea.y);
|
||||
if (note < 0) return;
|
||||
params_.rootOverride = note;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
|
||||
int y) const {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
|
||||
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
||||
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
||||
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
||||
if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1};
|
||||
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
|
||||
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
|
||||
if (!cr.rootStrip.empty()) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
if (note >= 0) return {HoverKind::kStripKey, note};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,130 @@
|
||||
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
|
||||
// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click
|
||||
// delete, and the popup's hover. Band-independent (the sheet floats over the whole face).
|
||||
// Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// While open the sheet is modal over the face — it owns every left-click. Close click /
|
||||
// outside-wash click dismiss (outside only when no drag is in flight); in-box clicks
|
||||
// route to the curve machinery; anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(pl.curveBox, x, y)) {
|
||||
handleCurveMouseDown(pl.curveBox, x, y);
|
||||
return true;
|
||||
}
|
||||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
|
||||
int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
|
||||
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
|
||||
dragStartParams_ = params_;
|
||||
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
|
||||
// endpoint. A ring click can still grab an existing node (handled above).
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
if (inBox) {
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||||
idx = static_cast<int>(params_.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = params_.velocityCurve; // AFTER the add — resolvePointDrag's delta base
|
||||
dragCurveRect_ = r;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragCurve(int x, int y) {
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute
|
||||
// delta — the mirror of the envelope-node drag). Live feedback only; commit on release.
|
||||
if (curvePointIndex_ < 0) return;
|
||||
params_.velocityCurve = VelocityCurve::resolvePointDrag(
|
||||
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (!contains(pl.curveBox, x, y)) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||||
const int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
||||
if (idx < 0) return;
|
||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
|
||||
int y) const {
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
|
||||
if (!contains(pl.curveBox, x, y)) return {};
|
||||
// A curve node under the pointer lights accent-hot.
|
||||
const int idx =
|
||||
params_.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||||
if (idx < 0) return {};
|
||||
return {HoverKind::kCurveNode, idx};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,125 @@
|
||||
// editor_input_deck.cpp — the DECKS band's input: toggles (committed at once, a discrete
|
||||
// final edit), knob grabs (grab-anchored vertical drag, committed on release), the live
|
||||
// knob-drag resolution, and the band's hover. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kPitchEnvAttack:
|
||||
case ParamControl::kPitchEnvDecay:
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
return !params_.play.pitchEnv.enabled;
|
||||
case ParamControl::kFilterMorph:
|
||||
case ParamControl::kFilterCutoff:
|
||||
case ParamControl::kFilterQ:
|
||||
case ParamControl::kFilterDrive:
|
||||
case ParamControl::kFilterModAmt:
|
||||
case ParamControl::kFilterVel:
|
||||
case ParamControl::kFilterKeyTrack:
|
||||
case ParamControl::kFilterEnvAttack:
|
||||
case ParamControl::kFilterEnvHold:
|
||||
case ParamControl::kFilterEnvDecay:
|
||||
case ParamControl::kFilterEnvSustain:
|
||||
case ParamControl::kFilterEnvRelease:
|
||||
return !params_.play.filter.enabled;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
const Rect& band = fl.bands.decks;
|
||||
if (!contains(band, x, y)) return false;
|
||||
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
switch (static_cast<ParamControl>(hit.id)) {
|
||||
case ParamControl::kVoiceMode: {
|
||||
// Processor-side per-instance param: live setter (engine rebuild via the
|
||||
// drain-slot swap — tails survive), local snapshot in step.
|
||||
const VoiceMode m = (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
if (m != voiceMode_) {
|
||||
voiceMode_ = m;
|
||||
processor_->setVoiceMode(m);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kMonoTrigger: {
|
||||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||||
const MonoTrigger t =
|
||||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
if (t != monoTrigger_) {
|
||||
monoTrigger_ = t;
|
||||
processor_->setMonoTrigger(t);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kFilterLaw:
|
||||
// Inert while the filter is off, matching its Disabled paint.
|
||||
if (!params_.play.filter.enabled) break;
|
||||
applyParamControl(hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
break;
|
||||
default:
|
||||
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable).
|
||||
applyParamControl(hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// Knobs of a disabled group are drawn but inert.
|
||||
if (deckKnobDisabled(hit.id)) return true;
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragKnobStartValue_ = deckControlNorm(hit.id);
|
||||
// Processor-side knobs (voice count / master gain) are transient live writes with no
|
||||
// parameter-set mutation, so they need no rollback snapshot.
|
||||
dragStartParams_ = params_;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
// The deck band swallows its own clicks either way — no fall-through to the waveform.
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragDeck(int x, int y) {
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps on
|
||||
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
|
||||
(void)x;
|
||||
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
|
||||
invalidate();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
|
||||
int y) const {
|
||||
const Rect& band = fl.bands.decks;
|
||||
if (!contains(band, x, y)) return {};
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind == DeckHitKind::None) return {};
|
||||
return {HoverKind::kControl, dh.id};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,583 +0,0 @@
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state
|
||||
// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches
|
||||
// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the
|
||||
// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve
|
||||
// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the
|
||||
// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and
|
||||
// mutates editor state only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// The curve popup: while open the sheet is modal over its host face — the Sample home or
|
||||
// the Zone surface — it owns every left-click. Close click / outside-wash click dismiss
|
||||
// (outside only when no drag is in flight); in-box clicks route to the shared curve
|
||||
// machinery against popupZoneIndex(); anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(pl.curveBox, x, y)) {
|
||||
const int zi = popupZoneIndex();
|
||||
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
|
||||
return true;
|
||||
}
|
||||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
|
||||
int idx = z.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zoneIndex;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint.
|
||||
// A ring click can still grab an existing node (handled above); only add is box-gated.
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
if (inBox) {
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||||
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
|
||||
dragCurveRect_ = r;
|
||||
dragCurveZone_ = zoneIndex;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
selectedZone_ = zoneIndex;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
// --- Input: the drag-state machine -------------------------------------------
|
||||
|
||||
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
// Browse modal: the face branch lives in editor_input_browse_zone.
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample home.
|
||||
if (view_ == View::kSample) {
|
||||
// The curve popup: while open the sheet is modal — it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
const PerformanceZone probeZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||||
// capture reads as pre-selected.
|
||||
browsePendingId_ = selectedId_;
|
||||
lastBrowseClickCard_ = -1;
|
||||
view_ = View::kBrowse;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
|
||||
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
|
||||
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
|
||||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||||
// (momentary — note-on on press, note-off on release).
|
||||
if (contains(cr.preview, x, y)) {
|
||||
const int note = effectiveRoot();
|
||||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = note;
|
||||
processor_->previewNoteOn(note);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||
if (contains(cr.velCell, x, y)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
dragParamZone_ = -1;
|
||||
dragKnobStartValue_ = previewVelocity01();
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// The mini curve-preview button: summon the popup editor.
|
||||
if (contains(cr.curveBtn, x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Channel toggle.
|
||||
if (contains(chan.mono, x, y)) {
|
||||
channelMode_ = ChannelMode::Mono;
|
||||
processor_->setChannelMode(ChannelMode::Mono);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(chan.stereo, x, y)) {
|
||||
channelMode_ = ChannelMode::Stereo;
|
||||
processor_->setChannelMode(ChannelMode::Stereo);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// The knob deck: toggles commit at once (a discrete, final edit); knobs start a
|
||||
// grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to
|
||||
// the hero/markers).
|
||||
if (contains(bands.deck, x, y)) {
|
||||
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
|
||||
bands.deck.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
switch (static_cast<ParamControl>(hit.id)) {
|
||||
case ParamControl::kVoiceMode: {
|
||||
// Processor-side per-instance param: live setter (engine rebuild via
|
||||
// the drain-slot swap — tails survive), local snapshot in step.
|
||||
const VoiceMode m =
|
||||
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
if (m != voiceMode_) {
|
||||
voiceMode_ = m;
|
||||
processor_->setVoiceMode(m);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kMonoTrigger: {
|
||||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||||
const MonoTrigger t =
|
||||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
if (t != monoTrigger_) {
|
||||
monoTrigger_ = t;
|
||||
processor_->setMonoTrigger(t);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
|
||||
// materialize the one-zone site, apply, commit.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
applyZoneControl(zi, hit.id, 0.0, hit.segment);
|
||||
selectedZone_ = zi;
|
||||
commitAndReload();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
|
||||
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
|
||||
// Processor-side knobs: transient live writes, no map edit, no reload.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = -1;
|
||||
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
|
||||
} else {
|
||||
// Zone-param knobs: live-drag the map, commit on release.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi < 0) return;
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = zi;
|
||||
selectedZone_ = zi;
|
||||
dragStartMap_ = map_;
|
||||
dragKnobStartValue_ =
|
||||
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
|
||||
}
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes first, then the wave markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
if (frames > 0) {
|
||||
const double rate = liveSampleRate();
|
||||
if (rate > 0.0) {
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
||||
if (nh.hit) {
|
||||
drag_ = DragKind::kEnvNode;
|
||||
envNode_ = nh.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartFrame_ = startFrame;
|
||||
dragStartMap_ = map_;
|
||||
return; // node moves once the cursor drags
|
||||
}
|
||||
}
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
|
||||
if (hit >= 0) {
|
||||
drag_ = DragKind::kWaveMarker;
|
||||
waveMarker_ = static_cast<WaveMarker>(hit);
|
||||
dragStartX_ = x;
|
||||
dragStartMarkers_ = m;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartMap_ = map_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced root strip: grab the root marker (remainder-width).
|
||||
if (cr.rootStrip.width > 0) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
if (note >= 0) {
|
||||
drag_ = DragKind::kRootMarker;
|
||||
dragStartX_ = x;
|
||||
dragStartRoot_ = note;
|
||||
dragStartMap_ = map_;
|
||||
onMouseMove(x, y); // apply the click as the first delta==0 set
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone surface: the face branch lives in editor_input_browse_zone.
|
||||
mouseDownZone(w, h, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
|
||||
dragCurY_ = y;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kDeckKnob) {
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps
|
||||
// on grab. Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
const int dy = y - dragStartY_;
|
||||
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// The Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||||
// full-keyboard zone carrying the override on the picked id — upsert by id so a
|
||||
// repeated drag edits the same zone rather than stacking duplicates.
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int note = resolveDragNote(sl, dragStartRoot_, dx);
|
||||
bool found = false;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == selectedId_) {
|
||||
z.rootOverride = note;
|
||||
selectedZone_ = i;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.rootOverride = note;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab
|
||||
// (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of
|
||||
// the marker path).
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const int dy = y - dragStartY_;
|
||||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
|
||||
totalSeconds, envClampBounds(), dx, dy);
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
unpackEnvelope(edited, frames, dragStartFrame_,
|
||||
map_.zones[static_cast<std::size_t>(zi)].play);
|
||||
selectedZone_ = zi;
|
||||
}
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box
|
||||
// (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// commit lands on WM_LBUTTONUP.
|
||||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
const int dy = y - dragStartY_;
|
||||
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
|
||||
VelocityCurve::resolvePointDrag(dragStartCurve_,
|
||||
static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), dx, dy);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// Resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap it
|
||||
// against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
const Rect waveArea = bands.hero;
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
if (frames <= 0) return;
|
||||
|
||||
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
|
||||
const int idx = static_cast<int>(waveMarker_);
|
||||
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
|
||||
// frames — no host types, no file I/O.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty()) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
|
||||
newFrame);
|
||||
}
|
||||
|
||||
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
|
||||
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
|
||||
SetupMarkers m = dragStartMarkers_;
|
||||
if (waveMarker_ == WaveMarker::kStart) {
|
||||
m.start = newFrame;
|
||||
} else if (waveMarker_ == WaveMarker::kLoopStart) {
|
||||
m.loopStart = (std::min)(newFrame, m.loopEnd);
|
||||
m.hasLoop = true;
|
||||
} else { // kLoopEnd
|
||||
m.loopEnd = (std::max)(newFrame, m.loopStart);
|
||||
m.hasLoop = true;
|
||||
}
|
||||
if (m.start < 0) m.start = 0;
|
||||
if (m.start > frames - 1) m.start = frames - 1;
|
||||
|
||||
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
|
||||
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
|
||||
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
|
||||
// map hides controls after the first marker drag on the single-capture face).
|
||||
selectedZone_ = upsertPickedOverride(m);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kScrollThumb) {
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only
|
||||
// happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// at paint from scrollOffset_.
|
||||
const int dyThumb = y - dragStartY_;
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||
dragStartScrollOffset_, dyThumb);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
|
||||
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (drag_ == DragKind::kZoneLow) {
|
||||
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
|
||||
} else if (drag_ == DragKind::kZoneHigh) {
|
||||
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
|
||||
} else if (drag_ == DragKind::kZoneBody) {
|
||||
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
|
||||
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
|
||||
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
|
||||
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
|
||||
const int span = dragStartHigh_ - dragStartLow_;
|
||||
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
|
||||
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
|
||||
else { z.lowNote = newLow; z.highNote = newHigh; }
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = -1;
|
||||
invalidate();
|
||||
}
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const int curveZone = dragCurveZone_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
dragParamZone_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
dragCurveZone_ = -1;
|
||||
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
|
||||
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
|
||||
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
|
||||
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||||
processor_->setVoiceCount(voiceCount_);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||||
curveZone < static_cast<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
x > curveRect.right() + kCurveDragOffMargin ||
|
||||
y < curveRect.y - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
|
||||
static_cast<std::size_t>(curveIdx));
|
||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open — over the Sample face or the Zone
|
||||
// surface (nothing else in the editor consumes them) — and never during an in-flight left
|
||||
// drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (!contains(pl.curveBox, x, y)) return;
|
||||
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
|
||||
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
|
||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||||
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
|
||||
if (idx < 0) return;
|
||||
const int zi = popupZoneIndex();
|
||||
if (zi < 0) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zi;
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,126 @@
|
||||
// editor_input_waveform.cpp — the WAVEFORM band's input: grabbing an envelope node or a
|
||||
// start/loop marker, and resolving both drags live against the pure inverse maps
|
||||
// (envelope_edit, waveform_view). Windows-only.
|
||||
//
|
||||
// Overlay contract: see waveform_view.h's WaveformSurface.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
if (frames <= 0) return false;
|
||||
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
||||
|
||||
// Envelope nodes first (they sit on top of the markers), then the wave markers.
|
||||
const double rate = liveSampleRate();
|
||||
if (rate > 0.0) {
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const NodeHit nh = nodeAtPoint(env, overlay, totalSeconds, x, y);
|
||||
if (nh.hit) {
|
||||
drag_ = DragKind::kEnvNode;
|
||||
envNode_ = nh.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartFrame_ = startFrame;
|
||||
dragStartParams_ = params_;
|
||||
return true; // node moves once the cursor drags
|
||||
}
|
||||
}
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
|
||||
if (hit >= 0) {
|
||||
drag_ = DragKind::kWaveMarker;
|
||||
waveMarker_ = static_cast<WaveMarker>(hit);
|
||||
dragStartX_ = x;
|
||||
dragStartMarkers_ = m;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartParams_ = params_;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
||||
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto
|
||||
// the parameter set. The AmpEnvelope was snapshotted at grab (dragStartEnv_) so the
|
||||
// delta is absolute.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds,
|
||||
envClampBounds(), dx, y - dragStartY_);
|
||||
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
// kWaveMarker: resolve the grabbed marker's new frame from the pixel delta,
|
||||
// zero-crossing-snap it against the decoded PCM, apply the inter-marker clamps, and write
|
||||
// the override live.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
if (frames <= 0) return;
|
||||
|
||||
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
|
||||
const int idx = static_cast<int>(waveMarker_);
|
||||
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
|
||||
// frames — no host types, no file I/O.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty()) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
|
||||
newFrame);
|
||||
}
|
||||
|
||||
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
|
||||
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
|
||||
SetupMarkers m = dragStartMarkers_;
|
||||
if (waveMarker_ == WaveMarker::kStart) {
|
||||
m.start = newFrame;
|
||||
} else if (waveMarker_ == WaveMarker::kLoopStart) {
|
||||
m.loopStart = (std::min)(newFrame, m.loopEnd);
|
||||
m.hasLoop = true;
|
||||
} else { // kLoopEnd
|
||||
m.loopEnd = (std::max)(newFrame, m.loopStart);
|
||||
m.hasLoop = true;
|
||||
}
|
||||
if (m.start < 0) m.start = 0;
|
||||
if (m.start > frames - 1) m.start = frames - 1;
|
||||
|
||||
applyMarkers(m);
|
||||
invalidate(); // live feedback; the commit lands on release
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by
|
||||
// the editor's own shell TUs (editor_session / editor_controls / editor_paint_* /
|
||||
// editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters,
|
||||
// small draw primitives (knob face / spectral strip / root marker / title band), label
|
||||
// helpers, deck group ids, and the velocity-curve box derivation. All inline.
|
||||
// small draw primitives (knob face / title band), label helpers, and the velocity-curve box
|
||||
// derivation. All inline.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type)
|
||||
#include "core/instrument/ui/keyboard_strip.h" // noteName (the one note-naming source)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include "wdltypes.h"
|
||||
@@ -22,7 +23,6 @@
|
||||
#include "core/audio/peaks.h" // Envelope (drawEnvelope)
|
||||
#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins)
|
||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace)
|
||||
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip)
|
||||
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
|
||||
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
|
||||
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice
|
||||
@@ -30,15 +30,6 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order.
|
||||
enum DeckGroup {
|
||||
kGroupAmpEnv = 0,
|
||||
kGroupPitch,
|
||||
kGroupPitchEnv,
|
||||
kGroupVoice,
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius
|
||||
// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond
|
||||
// box+margin deletes the dragged node.
|
||||
@@ -55,15 +46,10 @@ inline instrument::engine::VelocityCurve::Box curveBoxFromRect(
|
||||
(std::max)(0, r.height - 2 * kVelCurveInset)};
|
||||
}
|
||||
|
||||
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
|
||||
// common DAW convention REAPER uses).
|
||||
// A short MIDI-note label ("C4", "F#3"). The naming itself is the pure strip module's, so
|
||||
// a browser badge and a strip tooltip can never disagree about what a note is called.
|
||||
inline std::string noteLabel(int note) {
|
||||
static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
|
||||
"F#", "G", "G#", "A", "A#", "B"};
|
||||
if (note < 0) note = 0;
|
||||
if (note > 127) note = 127;
|
||||
const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4
|
||||
return std::string(kNames[note % 12]) + std::to_string(octave);
|
||||
return instrument::ui::noteName(note);
|
||||
}
|
||||
|
||||
// A display name for a bank sample id: the snapshotted bank list first, then the
|
||||
@@ -115,7 +101,7 @@ inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
|
||||
instrument::ui::cardThumbnailRect(layout, 0))));
|
||||
}
|
||||
|
||||
// Draws the title band with the live readout. Browse/Zone draw their own back button in
|
||||
// Draws the title band with the live readout. The Browse modal draws its own back button in
|
||||
// place of the nav.
|
||||
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
const std::string& readout) {
|
||||
@@ -170,53 +156,6 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
|
||||
}
|
||||
|
||||
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
|
||||
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
|
||||
// a keyboard at a glance. Shared by the setup face + the Zones strip.
|
||||
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
|
||||
using instrument::ui::StripLayout;
|
||||
if (stripArea.width <= 0 || stripArea.height <= 0) return;
|
||||
const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height);
|
||||
const int sx = stripArea.x;
|
||||
const int sy = stripArea.y;
|
||||
const int h = stripArea.height;
|
||||
const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase));
|
||||
for (int n = 0; n <= 127; ++n) {
|
||||
const instrument::ui::Rect k = instrument::ui::keyRect(sl, n);
|
||||
const int x0 = k.x + sx;
|
||||
const int x1 =
|
||||
(n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right();
|
||||
const int cw = (std::max)(1, x1 - x0);
|
||||
const ui::KitColor hue = ui::spectralColor(static_cast<double>(n) / 127.0);
|
||||
LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
|
||||
if (!instrument::ui::isNaturalKey(n)) {
|
||||
LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0);
|
||||
}
|
||||
}
|
||||
// Faint per-octave key ticks (hairline role) for orientation.
|
||||
const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline));
|
||||
for (int n = 0; n <= 127; n += 12) {
|
||||
const instrument::ui::Rect k = instrument::ui::keyRect(sl, n);
|
||||
LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Draws the single-capture root marker: an accent-primary bar with a soft static glow —
|
||||
// the "this is live" mark.
|
||||
inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea,
|
||||
const instrument::ui::StripLayout& sl, int root) {
|
||||
const int sx = stripArea.x;
|
||||
const int sy = stripArea.y;
|
||||
const int h = stripArea.height;
|
||||
const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root);
|
||||
const int mw = (std::max)(2, marker.width);
|
||||
const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary));
|
||||
const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot));
|
||||
// Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
|
||||
LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
|
||||
LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0);
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
|
||||
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
|
||||
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
|
||||
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
||||
// face draws first and the modal reads as a sheet layered on top.
|
||||
paintSample(&bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
||||
// hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(&bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
const FaceLayout fl = faceLayout(w, h);
|
||||
const bool empty = selectedId_.empty();
|
||||
|
||||
paintChrome(bmp, fl, empty);
|
||||
|
||||
// Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse
|
||||
// (which the chrome lit above), and there is nothing to deck.
|
||||
if (empty) {
|
||||
Rect body = Rect::ltrb(fl.bands.waveform.x, fl.bands.waveform.y,
|
||||
fl.bands.waveform.right(), fl.bands.decks.bottom());
|
||||
paintEmptyState(bmp, body);
|
||||
return;
|
||||
}
|
||||
|
||||
paintWaveform(bmp, fl.bands.waveform);
|
||||
paintDeck(bmp, fl);
|
||||
|
||||
// The curve popup: a centered sheet over the whole face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
|
||||
// The piano strip's note-name chip overhangs its band, so it goes on top of everything.
|
||||
paintChromeTooltip(bmp, fl, w, h);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
kitTextCentered(bmp, hint,
|
||||
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
|
||||
Font::Micro, Role::TextDim);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
+7
-124
@@ -1,9 +1,7 @@
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
|
||||
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
|
||||
// Shares the Sample face's painters (title band / empty state / deck / curve button /
|
||||
// popup) via the class + editor_internal.h.
|
||||
// editor_paint_browse.cpp — the Browse modal's painter: the full-window
|
||||
// select-then-confirm picker (wash, search box, filter tabs, card grid, scrollbar, footer).
|
||||
// Windows-only. Shares the Sample face's title-band + empty-state painters via the class +
|
||||
// editor_internal.h.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,15 +13,14 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + labels
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
|
||||
using namespace reasampler::instrument::ui; // browser geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice
|
||||
|
||||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
|
||||
@@ -157,120 +154,6 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
|
||||
// to the Sample home on close.
|
||||
const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h));
|
||||
drawTitleBand(bmp, title, "Zone - keyboard map");
|
||||
{
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
const KitButtonBox box{toKitBox(back)};
|
||||
const InteractionState st =
|
||||
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "Back", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
const Rect content = zoneContentArea(w, h);
|
||||
|
||||
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
|
||||
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
|
||||
Rect addR = zoneAddRect(content);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(addR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0) {
|
||||
const KitButtonBox box{toKitBox(delR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
|
||||
// read-only here), so it is a normal button, not `warn`.
|
||||
drawButton(bmp, box, "Delete", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
|
||||
// zone is live"); the rest take the categorical secondary hue at low alpha.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
drawSpectralStrip(bmp, stripArea);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int sx = stripArea.x;
|
||||
const int sy = stripArea.y;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
|
||||
const int bw = (std::max)(2, bar.width);
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo behind the live zone, then the crisp accent-primary bar.
|
||||
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
|
||||
// text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom() + 8;
|
||||
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
|
||||
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
|
||||
z.sampleId)
|
||||
.c_str(),
|
||||
Font::Label, Role::TextPrimary);
|
||||
// Three fields laid out left-to-right after the sample label. A focused field lifts to
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
const char* names[3] = {"Low", "High", "Root"};
|
||||
const std::string vals[3] = {
|
||||
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
const Rect fr = noteEntryFieldRect(fields, f);
|
||||
const bool editing = (entryField_ == f);
|
||||
fillSurface(bmp, toKitBox(fr), Role::BgCell,
|
||||
editing ? InteractionState::Focus : InteractionState::Rest);
|
||||
const KitColor border =
|
||||
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
|
||||
toLice(border), 1.0f, 0);
|
||||
std::string cap = std::string(names[f]) + ": " +
|
||||
(editing ? (entryText_ + "_") : vals[f]);
|
||||
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
|
||||
Font::ValueMono, Role::TextPrimary);
|
||||
}
|
||||
} else if (map_.zones.empty()) {
|
||||
kitText(bmp, infoR,
|
||||
"No zones. Add Zone maps the picked capture across the keyboard.",
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone: the same knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the
|
||||
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
|
||||
// (ComponentState) and live on the Sample deck only.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
|
||||
// modal grammar as the Sample face.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,224 @@
|
||||
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
||||
// live readout, then the control run — preview, preview-velocity knob, curve button,
|
||||
// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only;
|
||||
// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / noteName
|
||||
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
|
||||
#include "core/ui/tooltip.h" // computeTooltip (shared placement math)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // chrome geometry + keyboard strip
|
||||
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
|
||||
|
||||
namespace {
|
||||
|
||||
// Every text element on the toolbar row draws at this one size/weight — including the
|
||||
// product title, which used to be the row's odd one out.
|
||||
constexpr Font kToolbarFont = Font::Label;
|
||||
|
||||
// Kit font is proportional, so the char width is a generous estimate (pads, never clips).
|
||||
constexpr int kTooltipCharPx = 7;
|
||||
constexpr int kTooltipTextH = 14;
|
||||
|
||||
constexpr int kRootBadgeW = 38;
|
||||
constexpr int kRootBadgeH = 13;
|
||||
|
||||
// The piano strip: white keys tiled at one width, black keys overlaid at one width, each
|
||||
// tinted with its spectral hue so pitch position reads at a glance. `hoverNote` is outlined
|
||||
// (-1 for none). All rects are strip-local; `area` supplies the origin.
|
||||
void drawKeyboard(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int hoverNote) {
|
||||
fillSurface(bmp, toKitBox(area), Role::BgBase, InteractionState::Rest);
|
||||
if (sl.keys.empty()) return;
|
||||
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
const LICE_pixel shadow = toLice(roleColor(Role::BgBase));
|
||||
const auto hueOf = [](int n) {
|
||||
return toLice(spectralColor(static_cast<double>(n) / (kStripKeyCount - 1)));
|
||||
};
|
||||
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
if (!isNaturalKey(n)) continue;
|
||||
const Rect k = keyRect(sl, n);
|
||||
LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, hueOf(n), 0.55f, 0);
|
||||
LICE_Line(bmp, area.x + k.right() - 1, area.y + k.y, area.x + k.right() - 1,
|
||||
area.y + k.bottom() - 1, hairline, 0.6f, 0, false);
|
||||
}
|
||||
// Blacks last: they overlap the whites they straddle.
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
if (isNaturalKey(n)) continue;
|
||||
const Rect k = keyRect(sl, n);
|
||||
LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, shadow, 1.0f, 0);
|
||||
LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, hueOf(n), 0.35f, 0);
|
||||
}
|
||||
if (hoverNote >= 0) {
|
||||
const Rect k = keyRect(sl, hoverNote);
|
||||
LICE_DrawRect(bmp, area.x + k.x, area.y + k.y, k.width - 1, k.height - 1,
|
||||
toLice(roleColor(Role::TextPrimary)), 0.8f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The root affordance: the root key lit accent-primary with a static glow, plus a name badge
|
||||
// (a key is far too narrow to carry text itself). The badge is clamped inside the strip.
|
||||
void drawRootKey(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int root) {
|
||||
if (sl.keys.empty()) return;
|
||||
const Rect k = rootMarkerRect(sl, root);
|
||||
const int kx = area.x + k.x;
|
||||
const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel glow = toLice(roleColor(Role::AccentHot));
|
||||
// Static glow: a wider low-alpha halo behind the lit key (a drawn state, not a pulse).
|
||||
LICE_FillRect(bmp, kx - 3, area.y + k.y, k.width + 6, k.height, glow, 0.30f, 0);
|
||||
LICE_FillRect(bmp, kx, area.y + k.y, k.width, k.height, accent, 1.0f, 0);
|
||||
|
||||
const int badgeH = (std::min)(kRootBadgeH, area.height);
|
||||
const int badgeW = (std::min)(kRootBadgeW, area.width);
|
||||
int bx = kx + (k.width - badgeW) / 2;
|
||||
bx = (std::max)(area.x, (std::min)(bx, area.right() - badgeW));
|
||||
const Rect badge = Rect::ltrb(bx, area.bottom() - badgeH, bx + badgeW, area.bottom());
|
||||
LICE_FillRect(bmp, badge.x, badge.y, badge.width, badge.height, accent, 0.92f, 0);
|
||||
kitTextCentered(bmp, badge, noteName(root).c_str(), Font::Micro, Role::BgBase);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
|
||||
fillSurface(bmp, toKitBox(cr.toolbar), Role::BgPanel, InteractionState::Rest);
|
||||
|
||||
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary);
|
||||
|
||||
// Browse: the picker. When nothing is loaded it is the empty state's dominant
|
||||
// call-to-action — draw it Active (accent-primary) so it reads as "start here".
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.navBrowse)};
|
||||
const InteractionState st = empty ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The rest of the run and the strip row draw only once a capture is loaded — with
|
||||
// nothing picked there is no root, no preview and no channel decision to make.
|
||||
if (empty) return;
|
||||
|
||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.preview)};
|
||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||||
const InteractionState st = dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||||
if (dragging || hov) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d",
|
||||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
|
||||
// The mini curve-preview button: opens the popup editor.
|
||||
paintCurveButton(bmp, cr.curveBtn);
|
||||
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, cr.chanMono, "Mono", kToolbarFont,
|
||||
!isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, cr.chanStereo, "Stereo", kToolbarFont,
|
||||
isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// The strip row: the full 128-key piano with the root lit. The loaded capture responds
|
||||
// across the whole strip, repitched from that root.
|
||||
if (cr.controls.empty() || cr.rootStrip.empty()) return;
|
||||
fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest);
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
// Same staleness guard as the tooltip: a latched hover note outlives a drag it started.
|
||||
const int hoverNote = (drag_ == DragKind::kNone && hover_.kind == HoverKind::kStripKey)
|
||||
? hover_.index : -1;
|
||||
drawKeyboard(bmp, cr.rootStrip, sl, hoverNote);
|
||||
drawRootKey(bmp, cr.rootStrip, sl, effectiveRoot());
|
||||
}
|
||||
|
||||
// Drawn after every band so the chip is never painted over. No hover delay: the strip is a
|
||||
// continuous readout you sweep, and a delay there reads as a dead surface — unlike the bank
|
||||
// panel's buttons, where the delay stops tooltips firing on every traverse.
|
||||
void ReaSamplerEditor::paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w,
|
||||
int h) {
|
||||
if (hover_.kind != HoverKind::kStripKey || hover_.index < 0) return;
|
||||
// Hover is deliberately not re-resolved mid-drag, so the latched note would go stale
|
||||
// under a root drag — the root badge is the live readout there.
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
const Rect& area = fl.chrome.rootStrip;
|
||||
if (area.empty()) return;
|
||||
const StripLayout sl = layoutStrip(area.width, area.height);
|
||||
const Rect key = keyRect(sl, hover_.index);
|
||||
if (key.empty()) return;
|
||||
|
||||
const std::string label = noteName(hover_.index);
|
||||
const int textW = static_cast<int>(label.size()) * kTooltipCharPx;
|
||||
// Anchor y/h to the whole strip row, not the hovered key: a black key (18px) is shorter
|
||||
// than a white key (30px), and anchoring to the key rect placed the chip 12px higher for
|
||||
// black keys — landing on the keyboard's bottom edge and, near the root, on the badge.
|
||||
const TooltipBox tb = computeTooltip(area.x + key.x, area.y, key.width, area.height,
|
||||
textW, kTooltipTextH, w, h, TooltipSpec{});
|
||||
if (tb.empty()) return;
|
||||
|
||||
const Rect box = Rect::ltrb(tb.x, tb.y, tb.x + tb.width, tb.y + tb.height);
|
||||
fillSurface(bmp, toKitBox(box), Role::BgCell, InteractionState::Hover);
|
||||
LICE_DrawRect(bmp, box.x, box.y, box.width - 1, box.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
kitTextCentered(bmp, box, label.c_str(), Font::Label, Role::TextPrimary);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,125 @@
|
||||
// editor_paint_curve.cpp — the velocity->amp curve surfaces: the chrome band's mini
|
||||
// preview button and the modal popup sheet that hosts the full editor. Band-independent
|
||||
// (the popup floats over the whole face). Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + curveBoxFromRect
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // popup geometry
|
||||
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// A hairline-bordered bg/cell square with the live velocity curve traced in miniature
|
||||
// (no node markers at this scale). Hover lifts it; it draws Active (accent-primary
|
||||
// border) while its popup is open, and re-renders live as the popup edits the curve.
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
||||
: roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
||||
const VelocityCurve& curve = params_.velocityCurve;
|
||||
const int inset = 3;
|
||||
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
||||
r.height - 2 * inset};
|
||||
if (mini.width > 1 && mini.height > 1) {
|
||||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= mini.width; ++px) {
|
||||
const int mx = mini.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
||||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||||
prevX = mx;
|
||||
prevY = my;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
|
||||
// face stays legible behind it), then the centered sheet.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
||||
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(pl.close)};
|
||||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||||
? InteractionState::Hover
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift from the hit-test.
|
||||
paintVelocityCurve(bmp, pl.curveBox);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
const VelocityCurve& curve = params_.velocityCurve;
|
||||
|
||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
||||
// velocity and amp -> y mappings both go through the pure module so the trace, the node
|
||||
// handles, and the hit-test all share one coordinate system.
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= box.width; ++px) {
|
||||
const int cx = box.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
}
|
||||
|
||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||||
// has passed kCurveDragOffMargin outside the box — release will delete the node).
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
|
||||
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
|
||||
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
|
||||
dragCurveRect_.y == r.y) &&
|
||||
(dragCurX_ < r.x - kCurveDragOffMargin ||
|
||||
dragCurX_ > r.right() + kCurveDragOffMargin ||
|
||||
dragCurY_ < r.y - kCurveDragOffMargin ||
|
||||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||
curvePointIndex_ == static_cast<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||||
: (hot ? handleHot : handle);
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,164 @@
|
||||
// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups, their
|
||||
// captions, the compact caption and row toggles, and the radial knobs with the label<->value
|
||||
// swap on hover/drag. Windows-only; the deck's cell geometry is the pure knob_deck layout and
|
||||
// its group composition the pure deck_groups list.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // deck geometry
|
||||
|
||||
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
const Rect& deckArea = fl.bands.decks;
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, deckArea.x, deckArea.y, deckArea.width);
|
||||
const PlaySeconds& play = params_.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||||
bool seg1Active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st0 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (!seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
const InteractionState st1 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
case ParamControl::kHold: return "Hold";
|
||||
case ParamControl::kDecay: return "Decay";
|
||||
case ParamControl::kSustain: return "Sustain";
|
||||
case ParamControl::kRelease: return "Release";
|
||||
case ParamControl::kTrigFadeIn: return "Fade In";
|
||||
case ParamControl::kTrigLength: return "Len %";
|
||||
case ParamControl::kTrigFadeOut: return "Fade Out";
|
||||
case ParamControl::kKeyTrack: return "Key Trk";
|
||||
case ParamControl::kPitchEnvAttack: return "P.Att";
|
||||
case ParamControl::kPitchEnvDecay: return "P.Dec";
|
||||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||||
case ParamControl::kVoiceCount: return "Voices";
|
||||
case ParamControl::kMasterGain: return "Gain";
|
||||
case ParamControl::kFilterMorph: return "Mode";
|
||||
case ParamControl::kFilterCutoff: return "Cutoff";
|
||||
case ParamControl::kFilterQ: return "Res";
|
||||
case ParamControl::kFilterDrive: return "Drive";
|
||||
case ParamControl::kFilterModAmt: return "Mod";
|
||||
case ParamControl::kFilterVel: return "Vel";
|
||||
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
||||
case ParamControl::kFilterEnvAttack: return "F.Att";
|
||||
case ParamControl::kFilterEnvHold: return "F.Hold";
|
||||
case ParamControl::kFilterEnvDecay: return "F.Dec";
|
||||
case ParamControl::kFilterEnvSustain: return "F.Sus";
|
||||
case ParamControl::kFilterEnvRelease: return "F.Rel";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const DeckGroupLayout& g : dl.groups) {
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
|
||||
hairline, 1.0f, 0);
|
||||
const char* caption = "";
|
||||
switch (g.id) {
|
||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||
case kGroupPitch: caption = "PITCH"; break;
|
||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||
case kGroupFilter: caption = "FILTER"; break;
|
||||
case kGroupFilterEnv: caption = "FILTER ENV"; break;
|
||||
case kGroupVoice: caption = "VOICE"; break;
|
||||
case kGroupMaster: caption = "MASTER"; break;
|
||||
default: break;
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||||
play.playMode == PlayMode::Trigger, false);
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
case ParamControl::kFilterEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (static_cast<ParamControl>(g.rowToggle.id) == ParamControl::kFilterLaw) {
|
||||
drawToggle(g.rowToggle, "Band", "Notch",
|
||||
play.filter.settings.morphLaw ==
|
||||
instrument::engine::filter::MorphLaw::HighNotchLow,
|
||||
!play.filter.enabled);
|
||||
} else {
|
||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
}
|
||||
|
||||
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
|
||||
// The predicate is the input side's, so the drawn state and the inert grab agree.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = deckKnobDisabled(c.id);
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
drawKnobFace(bmp, c.knob, deckControlNorm(c.id), st);
|
||||
const std::string label = (dragging || hov)
|
||||
? deckValueLabel(c.id)
|
||||
: std::string(knobName(static_cast<ParamControl>(c.id)));
|
||||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,516 +0,0 @@
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT
|
||||
// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster
|
||||
// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini
|
||||
// preview button + popup sheet (shared painters the Zone surface reuses), and the empty
|
||||
// state. Windows-only; draws through the shared kit by palette role. All layout math is
|
||||
// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||
using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip)
|
||||
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over
|
||||
// Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face.
|
||||
if (view_ == View::kZone) {
|
||||
paintZone(&bmp, w, h);
|
||||
} else {
|
||||
paintSample(&bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
}
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
||||
// hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(&bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE
|
||||
// group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
const PerformanceZone deckZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
// Title: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
drawTitleBand(bmp, bands.title, title);
|
||||
|
||||
// Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap
|
||||
// surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw
|
||||
// it Active (accent-primary) so it reads as "start here".
|
||||
const bool empty = selectedId_.empty() && map_.zones.empty();
|
||||
{
|
||||
const KitButtonBox box{toKitBox(bands.navBrowse)};
|
||||
const InteractionState st = empty ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||||
}
|
||||
{
|
||||
const KitButtonBox box{toKitBox(bands.navZone)};
|
||||
const InteractionState st =
|
||||
isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "Zone", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing
|
||||
// at Browse (which is lit above). No hero waveform / controls to draw.
|
||||
if (empty) {
|
||||
Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom());
|
||||
paintEmptyState(bmp, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
|
||||
// else the product-default play params (the single capture is a one-zone map). This is the
|
||||
// one storage site both Sample and Zone edit.
|
||||
const PerformanceZone& zone = deckZone;
|
||||
|
||||
// Hero waveform band: envelope + markers + envelope overlay.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
|
||||
if (frames > 0 && waveArea.width > 0) {
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
const std::int64_t wantBins =
|
||||
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
|
||||
kWaveformOversample;
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
|
||||
drawEnvelope(bmp, waveArea, env);
|
||||
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
||||
const int lx = frameToX(waveArea, frames, m.loopStart);
|
||||
const int rx = frameToX(waveArea, frames, m.loopEnd);
|
||||
if (rx > lx) {
|
||||
LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height,
|
||||
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
|
||||
}
|
||||
}
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int mx = frameToX(waveArea, frames, markerFrames[i]);
|
||||
const bool loopMarker = (i != 0);
|
||||
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
|
||||
LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height,
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
// Trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
|
||||
} else {
|
||||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// Root + preview cluster: remainder-width root strip, preview button, radial velocity
|
||||
// knob, mini curve-preview button, channel toggle.
|
||||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
int root = effectiveRoot();
|
||||
if (cr.rootStrip.width > 0) {
|
||||
drawSpectralStrip(bmp, cr.rootStrip);
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
drawRootMarker(bmp, cr.rootStrip, sl, root);
|
||||
}
|
||||
|
||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.preview)};
|
||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||||
const InteractionState st = dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||||
if (dragging || hov) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d",
|
||||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
// The mini curve-preview button: opens the popup editor. Shared painter with the Zone
|
||||
// panel's button — one grammar on both surfaces.
|
||||
paintCurveButton(bmp, cr.curveBtn, zone);
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// The knob deck: the fenced control groups, bottom-anchored.
|
||||
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
|
||||
|
||||
// The curve popup: a centered sheet over the whole Sample face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
|
||||
const PerformanceZone& zone, std::int64_t frames) {
|
||||
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
|
||||
const double rate = liveSampleRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||||
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the hero rect so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
|
||||
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
|
||||
const int r = 3;
|
||||
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
|
||||
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
|
||||
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
const VelocityCurve& curve = zone.velocityCurve;
|
||||
|
||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
|
||||
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
|
||||
// the hit-test all share one coordinate system.
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= box.width; ++px) {
|
||||
const int cx = box.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
}
|
||||
|
||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||||
// has passed kCurveDragOffMargin outside the box — release will delete the node).
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
|
||||
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
|
||||
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
|
||||
dragCurveRect_.y == r.y) &&
|
||||
(dragCurX_ < r.x - kCurveDragOffMargin ||
|
||||
dragCurX_ > r.right() + kCurveDragOffMargin ||
|
||||
dragCurY_ < r.y - kCurveDragOffMargin ||
|
||||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||
curvePointIndex_ == static_cast<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||||
: (hot ? handleHot : handle);
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs) {
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width);
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||||
bool seg1Active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st0 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (!seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
const InteractionState st1 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
case ParamControl::kHold: return "Hold";
|
||||
case ParamControl::kDecay: return "Decay";
|
||||
case ParamControl::kSustain: return "Sustain";
|
||||
case ParamControl::kRelease: return "Release";
|
||||
case ParamControl::kTrigFadeIn: return "Fade In";
|
||||
case ParamControl::kTrigLength: return "Len %";
|
||||
case ParamControl::kTrigFadeOut: return "Fade Out";
|
||||
case ParamControl::kKeyTrack: return "Key Trk";
|
||||
case ParamControl::kPitchEnvAttack: return "P.Att";
|
||||
case ParamControl::kPitchEnvDecay: return "P.Dec";
|
||||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||||
case ParamControl::kVoiceCount: return "Voices";
|
||||
case ParamControl::kMasterGain: return "Gain";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const DeckGroupLayout& g : dl.groups) {
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
|
||||
hairline, 1.0f, 0);
|
||||
const char* caption = "";
|
||||
switch (g.id) {
|
||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||
case kGroupPitch: caption = "PITCH"; break;
|
||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||
case kGroupVoice: caption = "VOICE"; break;
|
||||
case kGroupMaster: caption = "MASTER"; break;
|
||||
default: break;
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||||
play.playMode == PlayMode::Trigger, false);
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st);
|
||||
const std::string label = (dragging || hov)
|
||||
? deckValueLabel(c.id, zone)
|
||||
: std::string(knobName(static_cast<ParamControl>(c.id)));
|
||||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// The mini curve-preview button (shared by the Sample cluster and the Zone panel): a
|
||||
// hairline-bordered bg/cell square with the zone's live velocity curve traced in
|
||||
// miniature (no node markers at this scale). Hover lifts it; it draws Active
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup edits
|
||||
// the curve (same zone, re-read each paint).
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
||||
: roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
||||
const VelocityCurve& curve = zone.velocityCurve;
|
||||
const int inset = 3;
|
||||
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
||||
r.height - 2 * inset};
|
||||
if (mini.width > 1 && mini.height > 1) {
|
||||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= mini.width; ++px) {
|
||||
const int mx = mini.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
||||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||||
prevX = mx;
|
||||
prevY = my;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
|
||||
// Sample face stays legible behind it), then the centered sheet.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
||||
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(pl.close)};
|
||||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||||
? InteractionState::Hover
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
|
||||
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
|
||||
// surface.
|
||||
paintVelocityCurve(bmp, pl.curveBox, popupZone());
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
kitTextCentered(bmp, hint,
|
||||
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
|
||||
Font::Micro, Role::TextDim);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,139 @@
|
||||
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop
|
||||
// span + start/loop markers, and the amp-envelope overlay. Windows-only.
|
||||
//
|
||||
// Overlay contract: see waveform_view.h's WaveformSurface.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
||||
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // lanes + waveform geometry
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
|
||||
fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest);
|
||||
if (band.empty()) return;
|
||||
|
||||
const std::vector<AudioSample>& mono = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(mono.size());
|
||||
if (frames <= 0) {
|
||||
kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim);
|
||||
return;
|
||||
}
|
||||
|
||||
const ChannelPcm& src = channelPcmFor(selectedId_);
|
||||
const WaveformSurface surface = waveformSurface(
|
||||
band, channelMode_ == ChannelMode::Stereo, src.channelCount);
|
||||
|
||||
if (!surface.upper.empty()) {
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
// Both lanes share a width, so one bin count serves both.
|
||||
const std::int64_t wantBins =
|
||||
static_cast<std::int64_t>(
|
||||
(std::max)(1, waveformColumnCount(toKitBox(surface.upper)))) *
|
||||
kWaveformOversample;
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
|
||||
if (surface.laneCount == 2) {
|
||||
// ONE pass over the interleaved source: computeEnvelope already envelopes each
|
||||
// channel independently, so the second lane costs no second scan of the PCM.
|
||||
const Envelope env =
|
||||
computeEnvelope(src.interleaved, static_cast<std::size_t>(src.channelCount),
|
||||
static_cast<std::size_t>(src.frameCount()), bins);
|
||||
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
|
||||
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
|
||||
} else {
|
||||
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
|
||||
// source.
|
||||
drawEnvelope(bmp, surface.upper, computeEnvelope(mono, 1, mono.size(), bins));
|
||||
}
|
||||
}
|
||||
|
||||
// Markers and the loop span are overlays: ONE draw across the full stacked height, so a
|
||||
// stereo view reads one loop region rather than two.
|
||||
const OverlayArea& overlay = surface.overlay;
|
||||
const Rect& overlayRect = overlay.rect;
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
||||
const int lx = frameToX(overlay, frames, m.loopStart);
|
||||
const int rx = frameToX(overlay, frames, m.loopEnd);
|
||||
if (rx > lx) {
|
||||
LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height,
|
||||
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
|
||||
}
|
||||
}
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int mx = frameToX(overlay, frames, markerFrames[i]);
|
||||
const bool loopMarker = (i != 0);
|
||||
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
|
||||
LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height,
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
paintEnvelopeOverlay(bmp, overlay, frames);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
|
||||
std::int64_t frames) {
|
||||
const Rect& area = waveArea.rect;
|
||||
if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
|
||||
const double rate = liveSampleRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||||
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i - 1].x));
|
||||
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the band so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
|
||||
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
|
||||
const int r = 3;
|
||||
const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x));
|
||||
const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
|
||||
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user