fix: restore waveform symmetry about the midline, cut deck_values' link to the bank model, and unit-test the column arithmetic

The waveform column's vertical extents move to pure component_geometry so the shared
primitive stops being untested; PlaySeconds hoists into a header-only play_seconds target.
This commit is contained in:
2026-08-01 09:47:07 -04:00
parent 7f74b11dce
commit ca464397b2
17 changed files with 310 additions and 138 deletions
+6 -3
View File
@@ -291,6 +291,7 @@ anything for a trigger shape.
### `map/`
- `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`.
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v13), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `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.
@@ -318,9 +319,11 @@ anything for a trigger shape.
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
two stage times are comparable at a glance. A display-unit decision only — nothing about the
stored representation changes. Links the map layer because `PlaySeconds` is what a deck edits.
The shell keeps only the controls the parameter set does not carry (key-track, voice count,
master gain, preview velocity) and the labels for them.
stored representation changes. Links the header-only `play_seconds`, deliberately not
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); 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 velocity/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. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `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.
+11 -1
View File
@@ -26,10 +26,20 @@ reasampler_pure_library(component_state_io
# 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 stored seconds value layer, header-only (hence INTERFACE) — PlaySeconds and the four
# stage-time structs it composes. Split from sample_map so a consumer that only edits those
# values reaches them WITHOUT the bank model and the WAV codec: the editor's deck_values
# binding is exactly that consumer, and linking sample_map for one value struct would put
# bank_book + wav_codec into a test whose subject is a knob. Links the same value-layer set
# play_params.h needs (velocity_curve's out-of-line zero() is a default member initializer).
add_library(play_seconds INTERFACE)
target_include_directories(play_seconds INTERFACE ${REASAMPLER_SRC_DIR})
target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
# 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 curve_law)
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law)
# 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)
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// play_seconds — the STORED, wall-clock-SECONDS value layer the instrument edits and
// serializes: PlaySeconds and the four stage-time structs it composes. Header-only, and split
// from sample_map so a consumer that only edits these values (the editor's deck binding) does
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
namespace reasampler::instrument::map {
using instrument::engine::VelocityCurve;
// 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
// 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; the
// three curve exponents are dimensionless too (curve_law.h owns their domain).
struct AdsrSeconds {
double attackSeconds = 0.003; // tier-0 default
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060; // tier-0 default
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the
// span left after them (AhdParams owns why a fraction, not a time).
struct AhdSeconds {
double attackSeconds = 0.0;
double decaySeconds = 0.0;
double holdFraction = 1.0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
struct PitchEnvSeconds {
bool enabled = false;
double peakSemitones = 0.0; // signed depth at the peak
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
};
// 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}; // Gate
AhdSeconds trigEnv; // Trigger
VelocityCurve velocityCurve = VelocityCurve::zero();
};
// 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 amp: AHDSR (seconds)
TriggerParams trigger; // Trigger play span (%-length)
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
// owns why they sit beside the envelopes rather than inside them). Normalized over the
// sample's own length, so resolvePlay needs no rate for them.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
};
} // namespace reasampler::instrument::map
+2 -75
View File
@@ -14,6 +14,7 @@
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map {
@@ -134,81 +135,7 @@ 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) 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
// 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; the
// three curve exponents are dimensionless too (curve_law.h owns their domain).
struct AdsrSeconds {
double attackSeconds = 0.003; // tier-0 default
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060; // tier-0 default
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the
// span left after them (AhdParams owns why a fraction, not a time).
struct AhdSeconds {
double attackSeconds = 0.0;
double decaySeconds = 0.0;
double holdFraction = 1.0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
struct PitchEnvSeconds {
bool enabled = false;
double peakSemitones = 0.0; // signed depth at the peak
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
};
// 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}; // Gate
AhdSeconds trigEnv; // Trigger
VelocityCurve velocityCurve = VelocityCurve::zero();
};
// 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 amp: AHDSR (seconds)
TriggerParams trigger; // Trigger play span (%-length)
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
// owns why they sit beside the envelopes rather than inside them). Normalized over the
// sample's own length, so resolvePlay needs no rate for them.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
};
// --- Seconds -> frames --------------------------------------------------------
// 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
+7 -5
View File
@@ -7,7 +7,9 @@ reasampler_pure_library(sample_bands SOURCES sample_bands.cpp LINK PUBLIC editor
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)
# knob_deck is linked for the test only: the knob size the shell hands chromeRects is
# kDeckKnobSize, and the test reads the real constant rather than copying its value.
reasampler_test(sample_chrome LINK sample_chrome knob_deck)
reasampler_pure_library(embed_strip SOURCES embed_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(embed_strip LINK embed_strip)
@@ -69,12 +71,12 @@ reasampler_pure_library(spline_edit
reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
# The deck's VALUE binding, split from its composition on the same axis deck_groups was split
# from knob_deck. Links sample_map because PlaySeconds — the thing a deck knob edits — is
# declared there; no sample_map symbol is called, only its value types. Same for the filter's
# MorphLaw — an enum, so no filter symbol is linked.
# from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck
# knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for
# the filter's MorphLaw — an enum, so no filter symbol is linked.
reasampler_pure_library(deck_values
SOURCES deck_values.cpp
LINK PUBLIC deck_groups sample_map envelope_overlay)
LINK PUBLIC deck_groups play_seconds envelope_overlay)
reasampler_test(deck_values LINK deck_values)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
+7 -5
View File
@@ -2,13 +2,13 @@
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
// what each one's value MEANS. Links the map layer because PlaySeconds is what a deck edits.
// what each one's value MEANS.
#pragma once
#include <cstddef>
#include "core/instrument/map/sample_map.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
@@ -39,9 +39,11 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play);
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
// second table of defaults to drift from the real one. Exact for every shipped default: they
// are all 0, 1, or the curve neutral, and the seconds ceiling is a power of two, so the
// norm round trip loses nothing. For knob-valued controls — a toggle has no reset gesture.
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
// For knob-valued controls — a toggle has no reset gesture.
void resetDeckParam(DeckParam id, PlaySeconds& play);
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
+7 -12
View File
@@ -190,24 +190,19 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {};
}
namespace {
// Squared distance from a rect's centre, in the rect's own pixel units.
double distSqFromCentre(const Rect& r, int x, int y) {
const double dx = x - (r.x + r.width / 2.0);
const double dy = y - (r.y + r.height / 2.0);
return dx * dx + dy * dy;
bool inKnobFace(const Rect& knob, int x, int y) {
const double dx = x - (knob.x + knob.width / 2.0);
const double dy = y - (knob.y + knob.height / 2.0);
const double r = knob.width / 2.0;
return dx * dx + dy * dy < r * r;
}
} // namespace
DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
for (const DeckCellLayout& c : g.cells) {
const double rOuter = c.knob.width / 2.0;
if (distSqFromCentre(c.knob, x, y) >= rOuter * rOuter) continue;
const double rInner = c.inner.width / 2.0;
const bool inner = distSqFromCentre(c.inner, x, y) < rInner * rInner;
return {c.id, inner};
if (!inKnobFace(c.knob, x, y)) continue;
return {c.id, inKnobFace(c.inner, x, y)};
}
return {}; // inside the group but off every dial
}
+11 -4
View File
@@ -153,11 +153,18 @@ struct DeckFaceHit {
bool inner = false; // inside the concentric inner disc
};
// A point inside the circle inscribed in `knob`, boundary-EXCLUSIVE. THE target rule for the
// reset gesture wherever a radial knob is drawn — the deck's own faces and the chrome's
// preview-velocity dial both resolve through it, so one gesture cannot grow two target rules.
bool inKnobFace(const Rect& knob, int x, int y);
// Resolved against the drawn CIRCLES, not the cell: a reset is aimed at a dial, so the label
// band and the cell margins must miss where a drag grab deliberately does not. Both radii are
// boundary-EXCLUSIVE, one rule for both rings — a point exactly on the inner radius is an
// outer-ring hit, one exactly on the outer radius is a miss. Whether a cell actually carries an
// inner value is deck_groups' call, exactly as with DeckHit::inner.
// band and the cell margins must miss where a drag grab deliberately does not. One rule for
// both rings — a point exactly on the inner radius is an outer-ring hit, one exactly on the
// outer radius is a miss. Whether a cell actually carries an inner value is deck_groups' call,
// exactly as with DeckHit::inner. Unlike hitTestDeck this runs NO toggle/radio precedence pass
// first, which is only correct while no toggle rect overlaps a knob circle — a layout change
// that lets them overlap has to give this the same precedence order.
DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+27
View File
@@ -97,4 +97,31 @@ int waveformColumnCount(const KitBox& box) {
return w > 0 ? w : 0;
}
WaveformBand waveformBand(int bandTop, int bandHeight) {
WaveformBand b;
b.top = bandTop;
b.height = bandHeight;
b.midY = bandTop + bandHeight / 2;
b.halfSpan = (bandHeight / 2) - 2; // matches drawWaveform's 2px vertical breathing room
return b;
}
WaveformColumnSpan waveformColumnSpan(const WaveformBand& band,
double compressedMax, double compressedMin) {
const int lo = band.top;
const int hi = band.top + band.height - 1;
WaveformColumnSpan s;
s.top = band.midY - static_cast<int>(compressedMax * band.halfSpan);
s.bottom = band.midY - static_cast<int>(compressedMin * band.halfSpan);
s.topF = band.midY - compressedMax * band.halfSpan;
s.bottomF = band.midY - compressedMin * band.halfSpan;
if (s.top < lo) s.top = lo;
if (s.bottom > hi) s.bottom = hi;
if (s.topF < lo) s.topF = lo;
if (s.bottomF > hi) s.bottomF = hi;
return s;
}
} // namespace reasampler::ui
+28 -1
View File
@@ -75,11 +75,38 @@ ListRowBox computeListRow(const KitBox& list, int index, int rowHeight);
// `rowCount` rows). rowCount bounds the hit so blank space past the last row is a clean miss.
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount);
// --- Waveform column count ---------------------------------------------------
// --- Waveform columns --------------------------------------------------------
// Pixel columns drawWaveform renders inside `box` (its fixed 2px side insets), never negative.
// Pass directly as peaks::computeEnvelope's binCount — one bin per column is correct resolution;
// overbinning doesn't improve render quality and wastes memory/CPU.
int waveformColumnCount(const KitBox& box);
// One channel band's shared vertical metrics: the zero line every column mirrors about, and the
// pixel height a full-scale amplitude reaches (inset so a peak keeps the band's breathing room).
struct WaveformBand {
int top = 0;
int height = 0;
int midY = 0;
double halfSpan = 0.0;
};
WaveformBand waveformBand(int bandTop, int bandHeight);
// The vertical extents of one column: the integer edges of the filled span, and the same edges
// at sub-pixel precision for the antialiased outline stroke that joins a column's extremes to
// its neighbour's. Both take the band clamp, so the stroke cannot leave the band the fill is
// confined to. Amplitudes arrive already through the display curve.
struct WaveformColumnSpan {
int top = 0;
int bottom = 0;
double topF = 0.0;
double bottomF = 0.0;
};
// Truncation is applied to the SCALED AMPLITUDE and then mirrored about midY — never to the
// resulting y, which would round the two edges in opposite directions and leave a column with
// |max| == |min| a pixel taller above the zero line than below.
WaveformColumnSpan waveformColumnSpan(const WaveformBand& band,
double compressedMax, double compressedMin);
} // namespace reasampler::ui
+4 -4
View File
@@ -7,6 +7,7 @@
#ifdef _WIN32
#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root key)
#include "core/instrument/ui/knob_deck.h" // inKnobFace (the shared reset target rule)
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
@@ -82,11 +83,10 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
bool ReaSamplerEditor::doubleClickChrome(const FaceLayout& fl, int x, int y) {
// The preview-velocity dial answers the same reset gesture the deck's knobs do — it is a
// radial knob drawn by the same primitive, so a user who learns the gesture there expects
// it here. Resolved against the whole cell, not the circle: the cell IS the knob's target
// on this surface (there is no neighbouring control to steal from).
// radial knob drawn by the same primitive, so it resolves through the same target rule
// (inKnobFace), against the drawn circle rather than the cell's label band.
if (selectedId_.empty() || !processor_) return false;
if (!contains(fl.chrome.velCell, x, y)) return false;
if (!inKnobFace(fl.chrome.velKnob, x, y)) return false;
processor_->setPreviewVelocity(kPreviewVelocityDefault);
invalidate();
return true;
+1 -1
View File
@@ -56,7 +56,7 @@ live in `shell/bank_ops`, a sibling directory, not here.
- `panel_thumbnails` — the PCM→envelope thumbnail cache + the bank-change fingerprint pass.
- `panel_audition` — the preview-playback engine.
- `panel_bank_ops` — the menu/prompt UX skin over the promptless `shell/bank_ops` verbs.
- `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`.
- `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`. Its antialiasing contract — which surfaces alias, which cannot, and the LICE primitive each answers with — is the disposition table in `docs/product/visual-design-language.md` §8, which governs `drawWaveform` as much as this doc does. `drawWaveform`'s per-column vertical arithmetic is NOT here: it is the pure, unit-tested `component_geometry::waveformBand`/`waveformColumnSpan` pair, because this TU is DAW-verified and not unit-tested.
## Gotchas
+15 -19
View File
@@ -27,6 +27,10 @@ using ui::compressAmplitudeForDisplay;
using ui::roleColor;
using ui::roleColorState;
using ui::spectralColor;
using ui::WaveformBand;
using ui::waveformBand;
using ui::WaveformColumnSpan;
using ui::waveformColumnSpan;
// The one place a pure KitColor becomes a LICE_pixel (LICE_RGBA(r,g,b,a), verified against
// lice.h). The theme owns the color; the shell owns the packing.
@@ -289,11 +293,9 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
for (int ch = 0; ch < channels; ++ch) {
const ChannelEnvelope& bins = env[static_cast<std::size_t>(ch)];
const int bandTop = box.y + ch * bandH;
const int midY = bandTop + bandH / 2;
const double halfSpan = (bandH / 2) - 2;
const WaveformBand band = waveformBand(box.y + ch * bandH, bandH);
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
LICE_Line(bmp, box.x + 2, band.midY, box.x + box.width - 2, band.midY,
midCol, 1.0f, 0, false);
if (bins.empty() || innerW <= 0) continue;
@@ -303,28 +305,22 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
// alone leaves the outline stepped — a vertical span has no aa to apply — and where two
// adjacent columns differ sharply it reads as a comb rather than one envelope. The
// stroke is the SAME ink as the fill it edges, so it can only soften the boundary.
double prevTop = 0.0;
double prevBottom = 0.0;
WaveformColumnSpan prev;
for (int col = 0; col < innerW; ++col) {
const MinMax mm = columnMinMax(bins, innerW, col);
const int x = box.x + 2 + col;
const double topF = midY - compressAmplitudeForDisplay(mm.max) * halfSpan;
const double bottomF = midY - compressAmplitudeForDisplay(mm.min) * halfSpan;
int yMax = static_cast<int>(topF);
int yMin = static_cast<int>(bottomF);
if (yMax < bandTop) yMax = bandTop;
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
const WaveformColumnSpan s = waveformColumnSpan(
band, compressAmplitudeForDisplay(mm.max), compressAmplitudeForDisplay(mm.min));
LICE_Line(bmp, x, s.bottom, x, s.top, waveCol, 1.0f, 0, false);
if (col > 0) {
const float xPrev = static_cast<float>(x - 1);
const float xF = static_cast<float>(x);
LICE_FLine(bmp, xPrev, static_cast<float>(prevTop), xF,
static_cast<float>(topF), waveCol, 1.0f, 0, true);
LICE_FLine(bmp, xPrev, static_cast<float>(prevBottom), xF,
static_cast<float>(bottomF), waveCol, 1.0f, 0, true);
LICE_FLine(bmp, xPrev, static_cast<float>(prev.topF), xF,
static_cast<float>(s.topF), waveCol, 1.0f, 0, true);
LICE_FLine(bmp, xPrev, static_cast<float>(prev.bottomF), xF,
static_cast<float>(s.bottomF), waveCol, 1.0f, 0, true);
}
prevTop = topF;
prevBottom = bottomF;
prev = s;
}
}
}