Ω-W1-T3: run the master meter on its own 60 FPS timer, and make a meter frame cost one rect

This commit is contained in:
2026-08-03 12:39:36 -04:00
parent 0eb2c67875
commit 4320bfdb08
7 changed files with 248 additions and 88 deletions
+22 -12
View File
@@ -260,21 +260,31 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick.
Narrowing it further means a second deferral mechanism (a posted window message) rather than
the tick — deliberately not built.
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
**The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames
~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain
across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting
store displayed one block in ~47 and lost the rest — the specified "a peak displays on the
first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING,
so exactly one caller may hold it; the embed strip reads its own non-consuming
- **The MASTER meter has its OWN 16 ms timer, beside the 500 ms sync poll.** It runs outside
that poll's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus
keeps sounding. Elapsed time is measured (`steady_clock`, whose resolution the rate needs —
`GetTickCount64`'s ~15.6 ms quantized a frame's delta to 0 or 16), never assumed from the
timer's period, a zero delta skips the advance, and the tick repaints only when
`meterDrawEqual` says the picture changed. **The published block state is ACCUMULATED, not
sampled**: the processor folds a per-channel max and a min limiter gain across every block
since the last read and `masterBusMeter()` clears the accumulators as it reads. A plain
overwriting store displayed one block per window and lost the rest — the specified "a peak
displays on the first UI frame after it occurs" is what the fold restores. `masterBusMeter()`
is CONSUMING, so exactly one caller may hold it — which is why the meter block MOVED to the
meter tick rather than being copied there; the embed strip reads its own non-consuming
`embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the
only consumer: with no editor open the accumulators hold everything since the instance was
created, and advancing off them would open the meter at the session's loudest peak. The clip
latch is not discarded with them — it is a latch the user clears. A meter-rate timer remains a
separate change and is not in.
latch is not discarded with them — it is a latch the user clears.
- **A meter frame repaints the bar FIELD, not the client area**, which is what makes the rate
affordable: `paint` honours `ps.rcPaint` against the field rect `paintDeck` cached, composes
into a RETAINED back buffer (so the rest of the face is still there from the last full
compose) and blits the dirty region alone. The fast path is chosen GEOMETRICALLY, never by a
flag — Windows unions a meter invalidate with any other pending one into a single rcPaint, so
a flag would paint a meter frame over a face that had really changed. What stays outside the
field stays on the full path: the static numeral gutter (AA text re-blended onto itself every
frame thickens) and the GR lamp (drawn straight onto the deck group's gradient, which a
sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+64 -25
View File
@@ -1,7 +1,8 @@
// 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.
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry over a retained
// back buffer (with the meter's dirty-rect fast path), 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"
@@ -18,37 +19,75 @@ 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) {
LICE_IBitmap* ReaSamplerEditor::ensureBackBuffer(int w, int h) {
if (w <= 0 || h <= 0) return nullptr;
// resize() is a no-op at the current size, so the steady-state paint allocates nothing.
if (!backBuffer_) backBuffer_ = new LICE_SysBitmap(w, h);
else backBuffer_->resize(w, h);
return backBuffer_;
}
void ReaSamplerEditor::releaseBackBuffer() {
delete backBuffer_;
backBuffer_ = nullptr;
}
void ReaSamplerEditor::paint(HDC hdc, const RECT& dirty) {
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)));
LICE_IBitmap* bmp = ensureBackBuffer(w, h);
if (!bmp) return;
// 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);
// The host's update rect, clipped to the client area.
const int dx = (std::max)(0, static_cast<int>(dirty.left));
const int dy = (std::max)(0, static_cast<int>(dirty.top));
const int dr = (std::min)(w, static_cast<int>(dirty.right));
const int db = (std::min)(h, static_cast<int>(dirty.bottom));
if (dr <= dx || db <= dy) return;
// 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);
// A meter frame's dirty rect lies wholly inside the bar field, and nothing else is drawn
// there — so the field is all that needs redrawing, and the rest of the face is still in the
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag,
// because Windows coalesces a meter invalidate with any other pending one into a single
// union rect: a flag would then paint a meter frame over a face that had really changed.
const Rect& mf = meterRects_.field;
if (!mf.empty() && dx >= mf.x && dy >= mf.y && dr <= mf.right() && db <= mf.bottom()) {
paintMeterField(bmp);
} else {
// Repopulated by paintDeck when the meter is on screen; left empty by the empty state.
meterRects_ = {};
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);
}
// A sheet layered over the face covers the meter, so the fast path must not paint
// through it — dropping the rect routes the next meter tick down this path instead.
if (view_ == View::kBrowse || curvePopup_ != CurveTarget::kNone) meterRects_ = {};
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
BitBlt(hdc, dx, dy, dr - dx, db - dy, bmp->getDC(), dx, dy, SRCCOPY);
}
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
+30 -12
View File
@@ -36,12 +36,25 @@ std::string tickLabel(int db) {
return std::string(buf);
}
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
// the latched clip cap. `split` is the RESOLVED lane decision — see master_meter.h.
void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state,
LaneSplit split) {
if (column.width <= 0 || column.height <= 0) return;
const MeterRects m = meterRects(column, split);
// The numeral gutter, left of the bars: static content, so it is drawn on a full paint only —
// re-blending AA text onto itself every meter frame would thicken it.
void drawMeterNumerals(LICE_IBitmap* bmp, const MeterRects& m) {
if (m.field.empty()) return;
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
db >= static_cast<int>(instrument::engine::kMeterFloorDb);
db -= static_cast<int>(kMeterTickStepDb)) {
if (!meterTickNumeralled(db)) continue;
kitText(bmp, meterNumeralRect(m.labels, meterDbToY(m.field, db)), tickLabel(db).c_str(),
Font::Micro, Role::TextDim, Align::Right);
}
}
// The bar field: the dB rules, one or two bars, the held peak tick and the latched clip cap —
// everything in the column that moves at meter rate. SELF-CONTAINED on purpose: the BgCell fill
// covers every pixel the rest of it then draws, so a meter frame can redraw this rect alone.
// Two bars or one is read off the rects (barB is empty exactly when the split is Single), so
// the lane decision has one representation here rather than two.
void drawMeterField(LICE_IBitmap* bmp, const MeterRects& m, const MasterMeterUi& state) {
// A column narrower than the interior needs yields all-empty rects, which under rect.h's
// contract means suppressed — not a zero-height field to fill, tick twelve times and cap.
if (m.field.empty()) return;
@@ -57,10 +70,6 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
const bool zero = (db == 0);
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
if (meterTickNumeralled(db)) {
kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro,
Role::TextDim, Align::Right);
}
}
// The bars. A single-lane surface shows ONE bar folding both channels per field
@@ -80,7 +89,7 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0);
}
};
if (split == LaneSplit::Single) {
if (m.barB.empty()) {
drawBar(m.barA, meterSingleLaneState(state));
} else {
drawBar(m.barA, state.left);
@@ -96,6 +105,10 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
} // namespace
void ReaSamplerEditor::paintMeterField(LICE_IBitmap* bmp) {
drawMeterField(bmp, meterRects_, masterMeter_);
}
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return;
@@ -279,7 +292,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit);
if (g.column.id >= 0) {
// Cached so the meter's own tick repaints the field without re-laying out the deck.
meterRects_ = meterRects(g.column.box, meterSplit);
drawMeterNumerals(bmp, meterRects_);
drawMeterField(bmp, meterRects_, masterMeter_);
}
// 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.
@@ -13,7 +13,7 @@
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
#include "core/audio/peaks.h" // Envelope (the binned waveform)
#include "core/instrument/ui/curve_tessellate.h" // buildEnvelopeTrace (the staged trace)
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
@@ -24,7 +24,6 @@ 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. The loop family is teal
@@ -198,18 +197,18 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
// Binned once per (capture, bin count) rather than once per paint — a full scan of the
// decoded PCM is what a frame-rate repaint cannot afford. See heroEnvelope.
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);
const Envelope& env = heroEnvelope(bins, 2);
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));
drawEnvelope(bmp, surface.upper, heroEnvelope(bins, 1));
}
}
+34 -2
View File
@@ -31,6 +31,15 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
// The meter's own clock on the same child window, because the bus meter is the one surface
// whose value changes every block. Raising the poll above to frame rate instead is the REJECTED
// alternative: its body costs a bridge read plus a bank parse, and its two tick-counted banners
// (the bake message, the drop hint) are calibrated in ticks, so they would silently shorten by
// the same factor. 16 ms is 60 FPS; what a frame costs is the meter's bar field, not the client
// area — the whole-client invalidate is what made this rate unaffordable before.
constexpr UINT_PTR kMeterTimerId = 2;
constexpr UINT kMeterTimerIntervalMs = 16;
} // namespace
#endif
@@ -66,6 +75,17 @@ void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::invalidateMeter() {
if (!childHwnd_) return;
const Rect& f = meterRects_.field;
if (f.empty()) { // nothing cached (see meterRects_) — the whole face has to answer
invalidate();
return;
}
RECT r{f.x, f.y, f.right(), f.bottom()};
InvalidateRect(childHwnd_, &r, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
@@ -111,6 +131,9 @@ void ReaSamplerEditor::attachedToParent() {
// created here, killed in removedFromParent — so an instance whose editor is closed
// does not poll.
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
// Bound to the same window for the same reason: an instance with no editor open runs
// neither clock, and the meter's accumulators simply pile up until one opens.
SetTimer(childHwnd_, kMeterTimerId, kMeterTimerIntervalMs, nullptr);
// Poll once immediately so a pending assignment (an ingest fired while this editor was
// closed) or a bank change applies the instant the editor opens, rather than waiting up
// to one timer interval. refreshFromBank above already primed the view; this folds in
@@ -127,9 +150,11 @@ void ReaSamplerEditor::removedFromParent() {
if (processor_) processor_->endParamGesture();
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
KillTimer(childHwnd_, kMeterTimerId);
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
releaseBackBuffer(); // a client-area bitmap outlives nothing here
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
@@ -137,6 +162,10 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
if (childHwnd_ && newSize) {
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
ensureBackBuffer(newSize->getWidth(), newSize->getHeight());
// The cached meter rects name the OLD layout; drop them so the meter tick takes the
// whole-client path until the resize's own full paint resolves them again.
meterRects_ = {};
}
return res;
}
@@ -149,7 +178,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
case WM_PAINT: {
PAINTSTRUCT ps{};
HDC hdc = BeginPaint(hwnd, &ps);
if (self) self->paint(hdc);
if (self) self->paint(hdc, ps.rcPaint);
EndPaint(hwnd, &ps);
return 0;
}
@@ -293,7 +322,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
return 0;
}
case WM_TIMER:
if (self && wParam == kSyncTimerId) self->onSyncTimer();
if (self) {
if (wParam == kSyncTimerId) self->onSyncTimer();
else if (wParam == kMeterTimerId) self->onMeterTimer();
}
return 0;
case WM_ERASEBKGND:
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
+65 -26
View File
@@ -1,5 +1,5 @@
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync and meter ticks, the
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
// thread via the processor's reloadInstrument.
@@ -7,6 +7,7 @@
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
@@ -108,31 +109,6 @@ void ReaSamplerEditor::onSyncTimer() {
// tick picks up the change after release.
if (!processor_) return;
// Ahead of the drag guard on purpose: a drag suppresses the reload poll below, but the bus
// keeps sounding and a frozen bar would misreport it.
{
const unsigned long long now = GetTickCount64();
const unsigned long long previous = meterTickMs_;
meterTickMs_ = now;
const MasterBusMeter bus = processor_->masterBusMeter();
// The accumulators have exactly one consumer — this tick — so with no editor open they
// hold everything since the instance was created. The first read is therefore session
// history, not a window: showing it would put the bar at the loudest peak of the
// session (instantaneous rise, then a 1.5 s hold) and light the GR lamp off a catch
// minutes old, with the limiter possibly off since. Discard it and start the window
// here. The CLIP survives, because it is a latch the user clears rather than a window —
// it is still set in the processor and the next tick reports it.
if (previous != 0) {
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
masterMeter_,
{bus.peakL, bus.peakR, bus.minGain, bus.clip},
static_cast<double>(now - previous) / 1000.0);
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
masterMeter_ = advanced;
if (changed) invalidate();
}
}
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// A parameter commit that flipped the limiter already changed the sound; what waits for this
@@ -202,6 +178,49 @@ void ReaSamplerEditor::onSyncTimer() {
invalidate();
}
}
void ReaSamplerEditor::onMeterTimer() {
// UI thread (WM_TIMER). Deliberately outside the sync tick's in-flight-drag guard rather
// than merely ahead of it: a drag suppresses the reload poll, but the bus keeps sounding
// and a frozen bar would misreport it.
if (!processor_) return;
const auto now = std::chrono::steady_clock::now();
const bool first = meterTick_ == std::chrono::steady_clock::time_point{};
const double elapsed =
first ? 0.0 : std::chrono::duration<double>(now - meterTick_).count();
// A zero delta advances nothing, and the drain below is CONSUMING — returning ahead of it
// keeps the window intact for the next tick instead of spending it on a frame that would
// move no pixel. GetTickCount64's ~15.6 ms granularity made that the common case at this
// rate; a steady clock makes it rare, not impossible.
if (!first && elapsed <= 0.0) return;
meterTick_ = now;
// THE one call site. masterBusMeter() exchanges the accumulators to identity as it reads,
// so a second clock reading it would steal windows from this one.
const MasterBusMeter bus = processor_->masterBusMeter();
// The accumulators have exactly one consumer — this tick — so with no editor open they
// hold everything since the instance was created. The first read is therefore session
// history, not a window: showing it would put the bar at the loudest peak of the session
// (instantaneous rise, then a 1.5 s hold) and light the GR lamp off a catch minutes old,
// with the limiter possibly off since. Discard it and start the window here. The CLIP
// survives, because it is a latch the user clears rather than a window — it is still set in
// the processor and the next tick reports it.
if (first) return;
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
masterMeter_, {bus.peakL, bus.peakR, bus.minGain, bus.clip}, elapsed);
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
// The GR lamp sits in the deck group's caption row, outside the bar field a meter frame
// repaints, and is drawn straight onto the group's own gradient — so its rare transitions
// take the whole-client repaint rather than growing the fast path a second rect.
const bool lampMoved =
instrument::ui::grLampLit(advanced) != instrument::ui::grLampLit(masterMeter_);
masterMeter_ = advanced;
if (!changed) return;
if (lampMoved) invalidate();
else invalidateMeter();
}
#endif // _WIN32
void ReaSamplerEditor::commitAndReload() {
@@ -437,6 +456,25 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
return ins.first->second;
}
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
// One lane draws the downmix binned at the band's width, which is exactly what the
// thumbnail cache already answers — so the single-lane hero costs no second slot and
// inherits that cache's busting (refreshFromBank, and a resize's clear).
if (laneCount != 2) return thumbnailFor(selectedId_, static_cast<int>(bins));
// Two lanes bin the INTERLEAVED source instead, which no thumbnail ever asks for. A resize
// changes `bins` and misses the memo; a capture change drops the decode it rides in.
const int channels = channelPcmFor(selectedId_).channelCount;
if (channelPcm_.heroBins != bins) {
channelPcm_.heroBins = bins;
channelPcm_.heroEnv = computeEnvelope(channelPcm_.interleaved,
static_cast<std::size_t>(channels),
static_cast<std::size_t>(channelPcm_.frameCount()),
bins);
}
return channelPcm_.heroEnv;
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
@@ -468,6 +506,7 @@ ReaSamplerEditor::~ReaSamplerEditor() {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
releaseBackBuffer(); // removedFromParent normally did; this is the un-detached path
#endif
}
+28 -5
View File
@@ -7,6 +7,7 @@
#pragma once
#include <chrono>
#include <optional>
#include <string>
#include <unordered_map>
@@ -118,7 +119,13 @@ private:
FaceLayout faceLayout(int w, int h) const;
#ifdef _WIN32
void paint(HDC hdc);
// `dirty` is the host's WM_PAINT update rect: a region inside the meter's bar field redraws
// that field alone, anything else re-composes the face, and either way only the dirty region
// is blitted out of the retained back buffer (a per-paint client-area LICE_SysBitmap is what
// the meter's rate would otherwise cost).
void paint(HDC hdc, const RECT& dirty);
LICE_IBitmap* ensureBackBuffer(int w, int h);
void releaseBackBuffer();
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
@@ -137,6 +144,7 @@ private:
// Decks: the group fence + caption + compact caption toggles + radial knobs with
// label<->value swap on hover/drag.
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
void paintMeterField(LICE_IBitmap* bmp); // the bar field alone, off meterRects_
// A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
@@ -231,10 +239,16 @@ private:
// never yanks the edit surface.
void onSyncTimer();
// The meter tick (its own WM_TIMER id, UI thread only): drains the bus, advances the
// ballistics, repaints the bar field. Its own clock — see editor_platform.cpp's timer ids.
void onMeterTimer();
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
void invalidate();
void invalidateMeter(); // the bar field only; falls back to invalidate() before first paint
HWND childHwnd_ = nullptr;
LICE_IBitmap* backBuffer_ = nullptr; // owned; see ensureBackBuffer
#endif
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
@@ -280,10 +294,16 @@ private:
// thread only (file I/O); cleared with the thumbnail cache on refresh.
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
// The hero waveform's binned envelope: the band's most expensive draw, and a function of the
// capture and the bin count alone. UI thread only (may decode).
const Envelope& heroEnvelope(std::size_t bins, int laneCount);
// The interleaved source PCM behind the stereo waveform lanes.
struct ChannelPcm {
std::vector<AudioSample> interleaved; // frame-interleaved source frames
int channelCount = 0; // 0 = nothing decoded
Envelope heroEnv; // binned from `interleaved`, so every path that drops the
std::size_t heroBins = 0; // decode drops it too. 0 = not computed
std::int64_t frameCount() const {
return channelCount > 0
? static_cast<std::int64_t>(interleaved.size()) / channelCount
@@ -471,11 +491,14 @@ private:
std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus
// The MASTER deck's meter, advanced from the published block magnitudes on the sync tick
// (see onSyncTimer for why it runs mid-drag too, and why the first read is discarded).
// meterTickMs_ 0 = never ticked.
// The MASTER deck's meter, advanced from the published block magnitudes on its own timer
// (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
// A default-constructed meterTick_ means never ticked. meterRects_ is the column interior
// resolved at the last FULL paint; an empty field means "not on screen" (no layout yet, the
// empty state, a modal sheet over it) and sends the tick down the whole-client path.
instrument::ui::MasterMeterUi masterMeter_;
unsigned long long meterTickMs_ = 0;
std::chrono::steady_clock::time_point meterTick_{};
instrument::ui::MeterRects meterRects_;
// Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer