// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop // span + start/loop markers, and the amp-envelope overlay. Windows-only. // // Overlay contract: see waveform_view.h's WaveformSurface. #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 #include #include #include #include #include "core/audio/peaks.h" // computeEnvelope (waveform binning) #include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box) #include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX #include "shell/instrument/editor_internal.h" // kit adapters #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { using namespace reasampler::ui; // kit vocabulary using namespace reasampler::instrument::ui; // lanes + waveform geometry using audio::computeEnvelope; namespace { // Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both // = teal (secondary). Markers are 2px bars and a translucent span fill, not the 1px trace, so // they live with 1.92:1 against the waveform; the trace, which cannot, has its own role. // Do not collapse the two back onto one role — they overlap in this rect. The trace crossing // the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no // trace value fixes it — see the two-neighbour rule in core/ui/CLAUDE.md. If it is ever // resolved, the FILL is what changes; do not nudge a color to chase it. constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleLoopMarker = Role::AccentSecondary; // Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit) // is the PICK radius and is unrelated — a handle may draw larger than it without widening any // hit region. constexpr int kEnvHandleRadius = 3; constexpr int kEnvHandleGrabbedRadius = 5; constexpr int kEnvHandleRingPx = 2; } // namespace void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest); if (band.empty()) return; const std::vector& mono = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(mono.size()); if (frames <= 0) { kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim); return; } const ChannelPcm& src = channelPcmFor(selectedId_); const WaveformSurface surface = waveformSurface( band, channelMode_ == ChannelMode::Stereo, src.channelCount); if (!surface.upper.empty()) { // Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact // partition — extra bins produce no visible change. Clamped to frame count below. // Both lanes share a width, so one bin count serves both. const std::int64_t wantBins = static_cast( (std::max)(1, waveformColumnCount(toKitBox(surface.upper)))) * kWaveformOversample; const std::size_t bins = static_cast(wantBins < frames ? wantBins : frames); if (surface.laneCount == 2) { // ONE pass over the interleaved source: computeEnvelope already envelopes each // channel independently, so the second lane costs no second scan of the PCM. const Envelope env = computeEnvelope(src.interleaved, static_cast(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 are overlays: ONE draw across the full stacked height, so a // stereo view reads one loop region rather than two. const OverlayArea& overlay = surface.overlay; const Rect& overlayRect = overlay.rect; const SetupMarkers m = pickedMarkers(frames); if (m.hasLoop && m.loopEnd > m.loopStart) { const int lx = frameToX(overlay, frames, m.loopStart); const int rx = frameToX(overlay, frames, m.loopEnd); if (rx > lx) { LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, toLice(roleColor(kRoleLoopMarker)), static_cast(kLoopSpanFillAlpha), 0); } } // The crossfade region, at half the loop span's weight so the two read as nested rather // than as a second loop. Drawn before the marker bars so the bars stay on top. if (m.hasLoop && m.crossfade > 0) { const int fx = frameToX(overlay, frames, m.loopStart - m.crossfade); const int lx = frameToX(overlay, frames, m.loopStart); if (lx > fx) { LICE_FillRect(bmp, fx, overlayRect.y, lx - fx, overlayRect.height, toLice(roleColor(kRoleLoopMarker)), static_cast(kLoopSpanFillAlpha) * 0.5f, 0); } } const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; for (int i = 0; i < 3; ++i) { const int mx = frameToX(overlay, frames, markerFrames[i]); const bool loopMarker = (i != 0); const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, toLice(roleColor(markerRoles[i])), alpha, 0); } // The crossfade's grab tab. Only offered with a loop set, matching the hit-test, and it // is the whole affordance for a zero-length fade — nothing else marks where it sits. if (m.hasLoop) { const Rect tab = markerHandleRect(overlay, frames, m.loopStart - m.crossfade); if (!tab.empty()) { LICE_FillRect(bmp, tab.x, tab.y, tab.width, tab.height, toLice(roleColor(kRoleLoopMarker)), 1.0f, 0); } } paintEnvelopeOverlay(bmp, overlay, frames); } void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) { const VelocityCurve::Box box = splineOverlayBox(waveArea); if (box.width <= 0 || box.height <= 1) return; const VelocityCurve& curve = splineFor(overlayEnv_); // One eval per drawn column, through the curve's own pixel maps, so the trace and the // handles share the coordinate system the hit-test resolves against. const LICE_pixel line = toLice(roleColor(Role::OverlayTrace)); int prevX = 0, prevY = 0; for (int px = 0; px <= box.width; ++px) { const int cx = box.left + px; const double t = curve.pointFromPixel(box, cx, box.top).velocity; const int cy = curve.pixelFromPoint(box, {t, curve.eval(t)}).y; if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); prevX = cx; prevY = cy; } // Handles carry two independent states on the same mark, so they use two independent // channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower // contrast over the lime, see its note), and HOLLOW is hard. The corner itself is the // primary hard cue, but a corner between two near-collinear segments has none to show. const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace)); const LICE_pixel core = toLice(roleColor(Role::BgBase)); const std::vector& pts = curve.points(); for (std::size_t i = 0; i < pts.size(); ++i) { const auto np = curve.pixelFromPoint(box, pts[i]); const bool grabbed = (drag_ == DragKind::kSplineNode && curvePointIndex_ == static_cast(i)); const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius; const int ir = r - kEnvHandleRingPx; const int hx = (std::max)(box.left + r, (std::min)(box.left + box.width - 1 - r, np.x)); const int hy = (std::max)(box.top + r, (std::min)(box.top + box.height - 1 - r, np.y)); LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0); if (pts[i].hard && ir > 0) { LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0); } } } void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames) { if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state const Rect& area = waveArea.rect; if (frames <= 0 || area.width <= 0 || area.height <= 0) return; if (overlayIsSpline()) { // The contour is a pure function of normalized position, so it needs neither the frame // count nor the live rate the staged path below resolves its seconds against. paintSplineOverlay(bmp, waveArea); return; } const double rate = liveSampleRate(); if (rate <= 0.0) return; const double totalSeconds = static_cast(frames) / rate; const std::int64_t startFrame = params_.startPoint.value_or(0); const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); // Clip x to the wave rect. Knots are handles, not line vertices. const LICE_pixel line = toLice(roleColor(Role::OverlayTrace)); const EnvVertex* prev = nullptr; for (const EnvVertex& v : poly) { if (v.knot) continue; if (prev != nullptr) { const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, prev->x)); const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, v.x)); LICE_Line(bmp, x0, prev->y, x1, v.y, line, 1.0f, 0, true); } prev = &v; } // Handles: a square per draggable stage node, a ROUND knot per curvable segment. Every // vertex is guaranteed in-bounds; the handle is additionally clamped inside the band so one // on an edge node never overhangs into the neighbouring bands. // // GRAB is signalled by SIZE + a punched-out core, NOT by a hotter hue — the one place the // kit's "brighter = hotter" convention is deliberately inverted, because a brighter tint is // a LOWER-contrast tint here: accent/hot sits at 1.15:1 against the lime it is drawn over. // The two-neighbour ceiling (core/ui/CLAUDE.md) leaves at most 1.05:1 between ANY two values // that both clear the floor over lime and bg/base, so no color can carry this state. The // grabbed mark stays overlay/trace and reads by its 3.07:1 ring against the lime plus a // 3.06:1 bg/base core inside it. const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace)); const LICE_pixel core = toLice(roleColor(Role::BgBase)); for (const EnvVertex& v : poly) { if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue; const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius; const int ir = r - kEnvHandleRingPx; // core radius; > 0 only when grabbed const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x)); const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y)); if (v.knot) { LICE_FillCircle(bmp, static_cast(hx), static_cast(hy), static_cast(r), handle, 1.0f, 0, true); if (grabbed) LICE_FillCircle(bmp, static_cast(hx), static_cast(hy), static_cast(ir), core, 1.0f, 0, true); } else { LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0); if (grabbed) LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0); } } } } // namespace reasampler::vst #endif // _WIN32