243 lines
12 KiB
C++
243 lines
12 KiB
C++
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop
|
|
// span + start/loop markers, and the amp-envelope overlay. Windows-only.
|
|
//
|
|
// Overlay contract: see waveform_view.h's WaveformSurface.
|
|
|
|
#include "shell/instrument/reasampler_editor.h"
|
|
|
|
#ifdef _WIN32
|
|
|
|
#include <algorithm>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
|
#include "core/instrument/ui/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<AudioSample>& mono = monoPcmFor(selectedId_);
|
|
const std::int64_t frames = static_cast<std::int64_t>(mono.size());
|
|
if (frames <= 0) {
|
|
kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim);
|
|
return;
|
|
}
|
|
|
|
const ChannelPcm& src = channelPcmFor(selectedId_);
|
|
const WaveformSurface surface = waveformSurface(
|
|
band, channelMode_ == ChannelMode::Stereo, src.channelCount);
|
|
|
|
if (!surface.upper.empty()) {
|
|
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
|
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
|
// partition — extra bins produce no visible change. Clamped to frame count below.
|
|
// Both lanes share a width, so one bin count serves both.
|
|
const std::int64_t wantBins =
|
|
static_cast<std::int64_t>(
|
|
(std::max)(1, waveformColumnCount(toKitBox(surface.upper)))) *
|
|
kWaveformOversample;
|
|
const std::size_t bins =
|
|
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
|
|
|
if (surface.laneCount == 2) {
|
|
// ONE pass over the interleaved source: computeEnvelope already envelopes each
|
|
// channel independently, so the second lane costs no second scan of the PCM.
|
|
const Envelope env =
|
|
computeEnvelope(src.interleaved, static_cast<std::size_t>(src.channelCount),
|
|
static_cast<std::size_t>(src.frameCount()), bins);
|
|
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
|
|
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
|
|
} else {
|
|
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
|
|
// source.
|
|
drawEnvelope(bmp, surface.upper, computeEnvelope(mono, 1, mono.size(), bins));
|
|
}
|
|
}
|
|
|
|
// Markers and the loop span are overlays: ONE draw across the full stacked height, so a
|
|
// stereo view reads one loop region rather than two.
|
|
const OverlayArea& overlay = surface.overlay;
|
|
const Rect& overlayRect = overlay.rect;
|
|
const SetupMarkers m = pickedMarkers(frames);
|
|
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
|
const int lx = frameToX(overlay, frames, m.loopStart);
|
|
const int rx = frameToX(overlay, frames, m.loopEnd);
|
|
if (rx > lx) {
|
|
LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height,
|
|
toLice(roleColor(kRoleLoopMarker)),
|
|
static_cast<float>(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<float>(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<instrument::engine::VelocityPoint>& 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<int>(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<double>(frames) / rate;
|
|
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
|
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
|
const std::vector<EnvVertex> 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<float>(hx), static_cast<float>(hy),
|
|
static_cast<float>(r), handle, 1.0f, 0, true);
|
|
if (grabbed)
|
|
LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
|
|
static_cast<float>(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
|