From 2a0d10fab515837f91874dfbcbdac40fd474e62d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 30 Jul 2026 09:04:57 -0400 Subject: [PATCH] Stack L/R waveform lanes in stereo mode, with overlays drawn once at full band height --- CMakeLists.txt | 11 +- src/core/instrument/CLAUDE.md | 3 +- src/core/instrument/ui/waveform_view.cpp | 20 +++ src/core/instrument/ui/waveform_view.h | 47 +++++- .../instrument/editor_input_waveform.cpp | 17 +- .../instrument/editor_paint_waveform.cpp | 59 ++++--- src/shell/instrument/editor_session.cpp | 84 ++++++---- src/shell/instrument/reasampler_editor.h | 27 ++++ tests/test_waveform_view.cpp | 150 +++++++++++++++++- 9 files changed, 341 insertions(+), 77 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 91d5899..722c7d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -905,13 +905,14 @@ target_include_directories(keyboard_strip PUBLIC src) target_link_libraries(keyboard_strip PUBLIC editor_geometry) # waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta -# frame resolver, and the zero-crossing snap for the capture-first editor's waveform surface -# (draggable start + loop markers over the picked capture's decoded PCM). The mirror of -# keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample -# alias the snap scans. NEITHER SDK. +# frame resolver, the zero-crossing snap, and the WAVEFORM band's drawn surface (channel +# lane(s) + the one full-height overlay rect) for the capture-first editor. The mirror of +# keyboard_strip; links editor_geometry for the shared Rect, peaks for the AudioSample alias +# the snap scans + the per-lane Envelope split, and sample_bands (READ-ONLY, as a band +# interior) for the lane inventory. NEITHER SDK. add_library(waveform_view STATIC src/core/instrument/ui/waveform_view.cpp) target_include_directories(waveform_view PUBLIC src) -target_link_libraries(waveform_view PUBLIC editor_geometry peaks) +target_link_libraries(waveform_view PUBLIC editor_geometry peaks sample_bands) # bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread # poll: parse/compare the S9 bank-generation stamp, and the S8 assignment-request CONSUME diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 3ae371f..b8446c5 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -220,7 +220,8 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma - `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: 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. 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 + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder. - `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver. -- `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. +- `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 rect, 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 rect so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect. - `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. diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index 324a1a6..5aa5162 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -3,6 +3,7 @@ #include "core/instrument/ui/waveform_view.h" #include +#include #include // std::abs (int overload) namespace reasampler::instrument::ui { @@ -17,6 +18,25 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { } // namespace +Rect waveformOverlayArea(const Rect& band) { return 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); + s.upper = lanes.upper; + s.lower = lanes.lower; + s.laneCount = twoLanes ? 2 : 1; + return s; +} + +audio::Envelope laneEnvelope(const audio::Envelope& env, int lane) { + if (lane < 0 || static_cast(lane) >= env.size()) return {}; + return audio::Envelope{env[static_cast(lane)]}; +} + 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; diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index 3dc58a9..b314dce 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -1,22 +1,55 @@ -// 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 #include "core/instrument/ui/editor_geometry.h" // Rect, contains -#include "core/audio/peaks.h" // AudioSample (float) +#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory) +#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 + Rect overlay; // the full band, both modes + int laneCount = 0; // 0 on a degenerate band, else 1 or 2 +}; + +// 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 rect, 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. +Rect 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; diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 1918dc1..d7a320e 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -1,6 +1,9 @@ // 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. +// +// Hit-test and drag both resolve against WaveformSurface::overlay — the same full-band rect +// the overlays draw into — so a grab in the lower stereo lane reaches them. #include "shell/instrument/reasampler_editor.h" @@ -11,7 +14,7 @@ #include #include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag -#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap +#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap #include "shell/instrument/editor_internal.h" #include "shell/instrument/reasampler_processor.h" @@ -21,10 +24,10 @@ using namespace reasampler::ui; using namespace reasampler::instrument::ui; bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { - const Rect& band = fl.bands.waveform; const std::vector& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(pcm.size()); if (frames <= 0) return false; + const Rect overlay = waveformOverlayArea(fl.bands.waveform); // Envelope nodes first (they sit on top of the markers), then the wave markers. const double rate = liveSampleRate(); @@ -32,7 +35,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { const std::int64_t startFrame = params_.startPoint.value_or(0); const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame); const double totalSeconds = static_cast(frames) / rate; - const NodeHit nh = nodeAtPoint(env, band, totalSeconds, x, y); + const NodeHit nh = nodeAtPoint(env, overlay, totalSeconds, x, y); if (nh.hit) { drag_ = DragKind::kEnvNode; envNode_ = nh.node; @@ -47,7 +50,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } const SetupMarkers m = pickedMarkers(frames); const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const int hit = markerAtPoint(band, frames, markerFrames, 3, x, y); + const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y); if (hit >= 0) { drag_ = DragKind::kWaveMarker; waveMarker_ = static_cast(hit); @@ -61,7 +64,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { - const Rect& band = fl.bands.waveform; + const Rect overlay = waveformOverlayArea(fl.bands.waveform); const int dx = x - dragStartX_; if (drag_ == DragKind::kEnvNode) { @@ -73,7 +76,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const double rate = liveSampleRate(); if (frames <= 0 || rate <= 0.0) return; const double totalSeconds = static_cast(frames) / rate; - const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, band, totalSeconds, + 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 @@ -90,7 +93,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const int idx = static_cast(waveMarker_); const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, dragStartMarkers_.loopEnd}; - std::int64_t newFrame = resolveDragFrame(band, frames, startVals[idx], dx); + 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. diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 7b48fc0..58e54ad 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -2,20 +2,20 @@ // span + start/loop markers, and the amp-envelope overlay. Windows-only. // // Overlays that ride the waveform (the envelope trace, its node handles, the markers) draw -// ONCE across the full band height, never per lane — the landed contract the stacked-lane -// work consumes. +// ONCE into WaveformSurface::overlay — the full band, spanning both stacked lanes in +// stereo. Never per lane; see waveform_view.h's overlay contract. #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 #include +#include #include #include #include "core/audio/peaks.h" // computeEnvelope (waveform binning) -#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory) -#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers) +#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX #include "shell/instrument/editor_internal.h" // kit adapters #include "shell/instrument/reasampler_processor.h" @@ -36,52 +36,67 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest); if (band.empty()) return; - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); + const std::vector& mono = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(mono.size()); if (frames <= 0) { kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim); return; } - // One lane today: the cached PCM is a mono downmix, so there is no second channel to - // draw. The band is already sized for two, and the second lane lights up when the - // per-channel decode lands. - const WaveformLanes lanes = waveformLanes(band, /*stereo=*/false); - const Rect& lane = lanes.upper; - if (lane.width > 0) { + 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::max)(1, waveformColumnCount(toKitBox(lane)))) * + static_cast( + (std::max)(1, waveformColumnCount(toKitBox(surface.upper)))) * kWaveformOversample; const std::size_t bins = static_cast(wantBins < frames ? wantBins : frames); - drawEnvelope(bmp, lane, computeEnvelope(pcm, 1, pcm.size(), bins)); + + 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(src.channelCount), + static_cast(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 run the FULL band height (both lanes), so a stacked view - // reads one loop region rather than two. + // 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 Rect& overlay = surface.overlay; const SetupMarkers m = pickedMarkers(frames); if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(band, frames, m.loopStart); - const int rx = frameToX(band, frames, m.loopEnd); + const int lx = frameToX(overlay, frames, m.loopStart); + const int rx = frameToX(overlay, frames, m.loopEnd); if (rx > lx) { - LICE_FillRect(bmp, lx, band.y, rx - lx, band.height, + LICE_FillRect(bmp, lx, overlay.y, rx - lx, overlay.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(band, frames, markerFrames[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, band.y, 2, band.height, + LICE_FillRect(bmp, mx - 1, overlay.y, 2, overlay.height, toLice(roleColor(markerRoles[i])), alpha, 0); } - paintEnvelopeOverlay(bmp, band, frames); + paintEnvelopeOverlay(bmp, overlay, frames); } void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 2f32519..99737d3 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -48,6 +48,8 @@ void ReaSamplerEditor::refreshFromBank() { // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks pcmCache_.clear(); // and its decoded PCM (the waveform + snap source) + channelPcmId_.clear(); + channelPcm_ = ChannelPcm{}; if (!processor_) { samples_.clear(); banks_.clear(); @@ -202,42 +204,62 @@ int ReaSamplerEditor::effectiveRoot() const { return 60; } +std::string ReaSamplerEditor::samplePathFor(const std::string& sampleId) const { + // SampleChoice is the browser's metadata projection and does not carry the WAV path, so + // resolve it from the live bank blob (selectSample). + if (!processor_) return {}; + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) return sel->relativePath; + } + // Fallback: the bank blob is not readable (extension absent / not yet parsed) or the id + // went stale there — the instance-owned ref still carries the path, so a self-contained + // instance draws its loaded sound's waveform regardless. + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, sampleId)) return r->relativePath; + return {}; +} + +const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor( + const std::string& sampleId) { + if (channelPcmId_ == sampleId && !sampleId.empty()) return channelPcm_; + + // A failed decode is still cached (channelCount stays 0) so a broken/missing file is not + // re-read on every paint. + channelPcmId_ = sampleId; + channelPcm_ = ChannelPcm{}; + const std::string relativePath = samplePathFor(sampleId); + if (relativePath.empty()) return channelPcm_; + + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::vector bytes = + readFileBytes(resolveBankFile(projectDir, relativePath)); // empty on any failure + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + channelPcm_.interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); + channelPcm_.channelCount = static_cast(layout.channelCount); + } + return channelPcm_; +} + const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { auto it = pcmCache_.find(sampleId); if (it != pcmCache_.end()) return it->second; - // SampleChoice is the browser's metadata projection and does not carry the WAV path, so - // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse. Every failure path caches an empty vector so a broken/missing file is not - // re-decoded on every paint. Keyed by id (width-independent) — the thumbnail bins this at - // whatever width, the snap scans it directly. - std::string relativePath; + // Every failure path caches an empty vector so a broken/missing file is not re-decoded on + // every paint. Keyed by id (width-independent) — the thumbnail bins this at whatever + // width, the snap scans it directly. std::vector mono; - if (processor_) { - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) { - if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; - } - if (relativePath.empty()) { - // Fallback: the bank blob is not readable (extension absent / not yet parsed) or - // the id went stale there — the instance-owned ref still carries the path, so a - // self-contained instance draws its loaded sound's waveform regardless. - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, sampleId)) { - relativePath = r->relativePath; - } - } - if (!relativePath.empty()) { - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::string abs = resolveBankFile(projectDir, relativePath); - const std::vector bytes = readFileBytes(abs); // empty on any failure - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - mono = downmixToMono(interleaved, layout.channelCount); - } + const std::string relativePath = samplePathFor(sampleId); + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + const std::vector bytes = readFileBytes(abs); // empty on any failure + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + mono = downmixToMono(interleaved, layout.channelCount); } } auto ins = pcmCache_.emplace(sampleId, std::move(mono)); diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index ef7360b..bafd9b8 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -262,6 +262,28 @@ private: // thread only (file I/O); cleared with the thumbnail cache on refresh. const std::vector& monoPcmFor(const std::string& sampleId); + // The interleaved source PCM behind the stereo waveform lanes. + struct ChannelPcm { + std::vector interleaved; // frame-interleaved source frames + int channelCount = 0; // 0 = nothing decoded + std::int64_t frameCount() const { + return channelCount > 0 + ? static_cast(interleaved.size()) / channelCount + : 0; + } + }; + + // The interleaved PCM + channel count for a bank sample id. SINGLE-SLOT by design: the + // waveform band draws one capture at a time, while monoPcmFor's cache spans every + // browsed card — holding interleaved PCM there would pin a whole bank at multi-channel + // size. A miss re-decodes (only on selection change or a bank refresh; an edit commit + // does not clear it). UI thread only (file I/O). + const ChannelPcm& channelPcmFor(const std::string& sampleId); + + // The project-relative WAV path for a bank sample id: the live bank blob first, the + // instance's own SampleRefs as the self-contained fallback. "" when unresolvable. + std::string samplePathFor(const std::string& sampleId) const; + // The effective loop + start markers for the loaded capture: the parameter set's // override when one is set, else the bank's loop intrinsic / frame 0. Absent loop -> // loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty. @@ -434,6 +456,11 @@ private: // Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope // binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh. std::unordered_map> pcmCache_; + + // The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this + // is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh. + std::string channelPcmId_; + ChannelPcm channelPcm_; }; } // namespace reasampler::vst diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index d5cfd60..425ec66 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -1,16 +1,19 @@ // Standalone tests for reasampler::instrument::ui::waveform_view — no VST3, no REAPER, no framework. -// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's -// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and -// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers. +// Same fast assert loop as the sibling pure tests. Assert the waveform band's drawn surface +// (lane split + the full-height overlay contract) and its frame<->pixel mapping, marker grab +// regions, drag-delta frame resolver (with clamps), and zero-crossing snap. // // Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width); // markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection); // resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, -// no-crossing keeps target, target clamp, degenerate buffers). +// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked +// lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full +// stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split). #include "../src/core/instrument/ui/waveform_view.h" +#include #include #include @@ -193,6 +196,133 @@ static void testZeroCrossingDegenerate() { CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target } +// --- waveformSurface: the lane split + the overlay contract -------------------- + +// A realistic waveform band: full-width, taller than the two-lane floor. +static Rect band() { return Rect::ltrb(8, 90, 832, 90 + kWaveformMinHeight); } + +static void testSurfaceStereoStacksTwoLanes() { + const Rect b = band(); + const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/2); + CHECK(s.laneCount == 2); + CHECK(!s.upper.empty() && !s.lower.empty()); + CHECK(s.upper.y == b.y); // L on top + CHECK(s.lower.y > s.upper.bottom()); // R below, seam between them + CHECK(s.lower.bottom() == b.bottom()); // together they reach the band's floor + CHECK(s.upper.x == b.x && s.upper.width == b.width); + CHECK(s.lower.x == b.x && s.lower.width == b.width); + // Non-overlapping, and the band is exactly lanes + the one seam gap. + CHECK(s.lower.y - s.upper.bottom() == kLaneGap); + CHECK(s.upper.height + kLaneGap + s.lower.height == b.height); +} + +static void testSurfaceMonoIsOneLane() { + const Rect b = band(); + const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, /*sourceChannels=*/2); + CHECK(s.laneCount == 1); + CHECK(s.upper == b); // the single lane spans the whole band + CHECK(s.lower.empty()); // no second lane to draw +} + +static void testSurfaceMonoSourceInStereoModeStaysOneLane() { + // Dual-mono: a mono source under stereo mode has no second channel, so a second lane + // would be a redundant duplicate. + const Rect b = band(); + const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/1); + CHECK(s.laneCount == 1); + CHECK(s.upper == b); + CHECK(s.lower.empty()); +} + +static void testSurfaceOverlayIsFullStackedHeightInBothModes() { + const Rect b = band(); + const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2); + const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2); + // Stereo: ONE overlay rect spanning both lanes, not either lane. + CHECK(st.overlay == b); + CHECK(st.overlay.height == st.upper.height + kLaneGap + st.lower.height); + CHECK(st.overlay != st.upper && st.overlay != st.lower); + // Mono: the same rect, which is also the single lane. + CHECK(mo.overlay == b); + CHECK(mo.overlay == mo.upper); + // The standalone accessor the hit-test paths use agrees with the resolved surface. + CHECK(waveformOverlayArea(b) == st.overlay); + CHECK(waveformOverlayArea(b) == mo.overlay); +} + +static void testSurfaceDegenerateBandDrawsNothing() { + const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2); + CHECK(s.laneCount == 0); + CHECK(s.upper.empty() && s.lower.empty() && s.overlay.empty()); + CHECK(waveformOverlayArea(Rect{10, 10, 0, 0}).empty()); +} + +// --- Hit-testing across the stacked lanes ------------------------------------- + +static void testMarkerGrabReachesTheLowerStereoLane() { + const Rect b = band(); + const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, 2); + const std::int64_t frames = 1000; + const std::int64_t markers[1] = {500}; + const int mx = frameToX(s.overlay, frames, 500); + // The same marker answers a grab in either lane — overlays span the full stack. + const int upperY = s.upper.y + s.upper.height / 2; + const int lowerY = s.lower.y + s.lower.height / 2; + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, upperY) == 0); + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, lowerY) == 0); + // A lower-lane grab hit-tested against the UPPER LANE would be lost — the miss this + // contract exists to prevent. + CHECK(markerAtPoint(s.upper, frames, markers, 1, mx, lowerY) == -1); + // Off the marker's x is still a miss at either height. + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx + 40, lowerY) == -1); +} + +static void testMarkerGrabInMonoSpansTheBand() { + const Rect b = band(); + const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, 2); + const std::int64_t frames = 1000; + const std::int64_t markers[1] = {250}; + const int mx = frameToX(s.overlay, frames, 250); + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.y) == 0); + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() - 1) == 0); + CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1); +} + +// --- Per-lane envelope content ------------------------------------------------- + +static void testAsymmetricStereoLanesCarryDifferentContent() { + // Left is full-scale, right is a tenth of it — the lanes must look materially different. + const std::size_t frames = 400; + std::vector interleaved(frames * 2); + for (std::size_t f = 0; f < frames; ++f) { + const AudioSample v = (f % 2 == 0) ? 1.0f : -1.0f; + interleaved[f * 2 + 0] = v; + interleaved[f * 2 + 1] = v * 0.1f; + } + // ONE pass over the interleaved source, split per lane — what the painter does. + const reasampler::audio::Envelope env = + reasampler::audio::computeEnvelope(interleaved, 2, frames, 20); + const reasampler::audio::Envelope upper = laneEnvelope(env, 0); + const reasampler::audio::Envelope lower = laneEnvelope(env, 1); + CHECK(upper.size() == 1 && lower.size() == 1); + CHECK(upper[0].size() == 20 && lower[0].size() == 20); + for (std::size_t i = 0; i < 20; ++i) { + CHECK(upper[0][i].max > 0.9f); // left near full scale + CHECK(lower[0][i].max < 0.2f); // right an order of magnitude down + CHECK(!(upper[0][i] == lower[0][i])); // and materially different, bin for bin + } +} + +static void testLaneEnvelopeRejectsOutOfRangeLane() { + const std::size_t frames = 16; + std::vector mono(frames, 0.5f); + const reasampler::audio::Envelope env = + reasampler::audio::computeEnvelope(mono, 1, frames, 4); + CHECK(laneEnvelope(env, 0).size() == 1); + CHECK(laneEnvelope(env, 1).empty()); // a mono source has no lower lane + CHECK(laneEnvelope(env, -1).empty()); +} + int main() { testFrameToXEndpoints(); testFrameToXClampsOutOfRange(); @@ -218,6 +348,18 @@ int main() { testZeroCrossingClampsTarget(); testZeroCrossingDegenerate(); + testSurfaceStereoStacksTwoLanes(); + testSurfaceMonoIsOneLane(); + testSurfaceMonoSourceInStereoModeStaysOneLane(); + testSurfaceOverlayIsFullStackedHeightInBothModes(); + testSurfaceDegenerateBandDrawsNothing(); + + testMarkerGrabReachesTheLowerStereoLane(); + testMarkerGrabInMonoSpansTheBand(); + + testAsymmetricStereoLanesCarryDifferentContent(); + testLaneEnvelopeRejectsOutOfRangeLane(); + if (g_fail == 0) std::printf("waveform_view: all tests passed\n"); else std::printf("waveform_view: %d FAILED\n", g_fail); return g_fail == 0 ? 0 : 1;