diff --git a/CMakeLists.txt b/CMakeLists.txt index d372965..e0c7bbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -659,6 +659,15 @@ add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) target_include_directories(keyboard_strip PUBLIC src/vst) 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. +add_library(waveform_view STATIC src/vst/waveform_view.cpp) +target_include_directories(waveform_view PUBLIC src/vst src) +target_link_libraries(waveform_view PUBLIC editor_geometry peaks) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -686,6 +695,12 @@ add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp) target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip) add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests) +# waveform_view (S11): the pure marker geometry + zero-crossing snap. Links ONLY waveform_view +# (+ its pure editor_geometry/peaks deps) — NEITHER SDK — the same plain-data-boundary proof. +add_executable(waveform_view_tests tests/test_waveform_view.cpp) +target_link_libraries(waveform_view_tests PRIVATE waveform_view) +add_test(NAME waveform_view_tests COMMAND waveform_view_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -865,8 +880,12 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip # geometry the capture-first editor draws + hit-tests against; both link editor_geometry # transitively (shared Rect). + # waveform_view (S11): the pure frame<->pixel marker geometry + zero-crossing snap the + # editor's waveform surface draws + hit-tests against; links editor_geometry + peaks + # transitively (shared Rect + AudioSample). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version capture_browser keyboard_strip) + sample_map capture_paths embed_strip app_version capture_browser keyboard_strip + waveform_view) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 6bbebf3..18f311f 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -21,6 +21,7 @@ #include "reasampler_vst.h" // kPluginName (the editor title band) #include "sample_map.h" #include "wav_trim.h" // parseWavLayout, extractFloatFrames +#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM @@ -43,8 +44,9 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; // capture face) hosts the keyboard strip + level readout under the browser. constexpr int kTitleHeight = 24; constexpr int kToggleHeight = 22; -constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels) +constexpr int kSetupHeight = 176; // the single-capture setup surface (labels + waveform + strip) constexpr int kStripBandHeight = 40; +constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface // Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as // the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.) @@ -60,6 +62,11 @@ const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255); const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255); +const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber) +const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue) +const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint) const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255); const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); @@ -132,6 +139,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) 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 S11 waveform + snap source) if (!processor_) { samples_.clear(); banks_.clear(); @@ -176,17 +184,85 @@ void ReaSamplerEditor::commitAndReload() { #endif } -const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - const std::string key = sampleId + "|" + std::to_string(binCount); - auto it = thumbCache_.find(key); - if (it != thumbCache_.end()) return it->second; +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic + // from the live bank blob (the same path selectSample uses); the override lives in map_. + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, selectedId_)) { + if (sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + m.loopEnd = sel->loop.end; + } + } + } + } + // The override (loop + start) on a zone for the picked id supersedes the intrinsic. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId != selectedId_) continue; + if (z.loopOverride) { + m.hasLoop = z.loopOverride->hasLoop; + m.loopStart = z.loopOverride->start; + m.loopEnd = z.loopOverride->end; + } + if (z.startPoint) m.start = *z.startPoint; + break; + } + // Default an unset loop's end to the sample length so the loop markers have somewhere sane + // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; + // the markers are still drawn (drag one to CREATE a loop). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + return m; +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + } + commitAndReload(); +} + +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 — the mirror of the processor's decodeRelative. Every failure path caches an - // empty envelope so a broken/missing file is not re-decoded on every paint. + // parse — the mirror of the processor's decodeRelative. 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; - Envelope env; + std::vector mono; if (processor_) { auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); @@ -210,13 +286,27 @@ const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int if (layout.valid) { std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); - std::vector mono = - downmixToMono(interleaved, layout.channelCount); - env = computeEnvelope(mono, 1, mono.size(), - static_cast((std::max)(1, binCount))); + mono = downmixToMono(interleaved, layout.channelCount); } } } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + const std::string key = sampleId + "|" + std::to_string(binCount); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + env = computeEnvelope(mono, 1, mono.size(), + static_cast((std::max)(1, binCount))); + } auto ins = thumbCache_.emplace(key, std::move(env)); return ins.first->second; } @@ -324,6 +414,17 @@ Rect setupStripArea(const Rect& area) { return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; } +// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the +// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and +// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this +// single formula so the draw and the hit-test never drift. +Rect setupWaveformArea(const Rect& area) { + constexpr int pad = 8; + const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip + const int waveTop = waveBottom - kWaveformHeight; + return Rect{area.left + pad, waveTop, area.right - pad, waveBottom}; +} + // The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the // mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) // with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, @@ -482,7 +583,48 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { drawText(bmp, headerR, header.c_str(), kRgbText); Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; - drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); + drawText(bmp, hintR, + "Drag the waveform markers to set start + loop; drag the keyboard to set root.", + kRgbDim); + + // --- S11 waveform surface: the picked capture's envelope + draggable markers ---------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = setupWaveformArea(area); + LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(), + kColWaveBg, 1.0f, 0); + if (frames > 0 && waveArea.width() > 0) { + // Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view + // reused). computeEnvelope over the cached mono frames — no new decode. + const int bins = (std::max)(1, waveArea.width()); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(bins)); + drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb) + + const SetupMarkers m = pickedMarkers(frames); + // Faint loop-region fill between the loop markers (only when a loop is set). + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), + kColLoopRegion, 1.0f, 0); + } + } + // The three markers: start (amber), loop start + loop end (blue). Drawn as 2px vertical + // lines the full waveform height. Loop markers dim when no loop is set (the "no loop" + // state — draggable to CREATE one). + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), markerCols[i], + alpha, 0); + } + } else { + drawTextCentered(bmp, waveArea, "(decoding…)", kRgbDim); + } // Keyboard strip with the root marker. const Rect stripArea = setupStripArea(area); @@ -597,9 +739,30 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { commitAndReload(); // publishes the pick + reloads; process() plays it repitched return; } - // The setup strip: grab the root marker (drag to set the picked capture's root). if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + + // S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the + // waveform band first (it sits above the keyboard strip). markerAtPoint resolves + // which marker under the grab; a miss falls through to the keyboard strip. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + if (frames > 0) { + const Rect waveArea = setupWaveformArea(area); + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + return; // no immediate set — the marker only moves once the cursor drags + } + } + + // The setup strip: grab the root marker (drag to set the picked capture's root). const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); @@ -731,6 +894,72 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } + if (drag_ == DragKind::kWaveMarker) { + // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap + // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect waveArea = setupWaveformArea(area); + const std::int64_t frames = dragSampleFrames_; + if (frames <= 0) return; + + // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). + const int idx = static_cast(waveMarker_); + const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd}; + std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); + + // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware + // requirement). Pure over the cached mono frames — no host types, no file I/O. + const std::vector& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), + newFrame); + } + + // Build the edited marker set from the snapshot, moving only the grabbed marker, then + // clamp: loopStart <= loopEnd, start in [0, frames]. Dragging a loop marker MAKES a loop. + SetupMarkers m = dragStartMarkers_; + if (waveMarker_ == WaveMarker::kStart) { + m.start = newFrame; + } else if (waveMarker_ == WaveMarker::kLoopStart) { + m.loopStart = (std::min)(newFrame, m.loopEnd); + m.hasLoop = true; + } else { // kLoopEnd + m.loopEnd = (std::max)(newFrame, m.loopStart); + m.hasLoop = true; + } + if (m.start < 0) m.start = 0; + if (m.start > frames) m.start = frames; + + // Upsert the override on the picked id (mirror of the root-marker path); commit lands on + // release, this is live feedback. + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + } + invalidate(); + return; + } + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; const Rect stripArea = zonesStripArea(bands); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index e492869..a34f704 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -68,8 +68,14 @@ private: // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the - // single-capture root drag on the setup strip. - enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody }; + // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop + // marker on the S11 waveform surface (which marker is in waveMarker_). + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker }; + + // The waveform markers on the single-capture setup surface (S11). Order is the draw + hit + // order (start first). Named generically per the spec so S15 can repurpose the surface with + // a different marker set; here it is start-point + the sustain loop's two ends. + enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; #ifdef _WIN32 void paint(HDC hdc); @@ -105,6 +111,30 @@ private: // an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). const Envelope& thumbnailFor(const std::string& sampleId, int binCount); + // The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id. + // Feeds the S11 waveform surface: the full-res envelope binned at view width AND the + // zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty + // vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same + // decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width- + // independent). Cleared with the thumbnail cache on refresh. + const std::vector& monoPcmFor(const std::string& sampleId); + + // The effective loop + start markers for the picked single capture (S11): the per-zone + // OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic + // (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state). + // frames is the decoded length (for defaulting loopEnd when the bank left the loop empty). + struct SetupMarkers { + std::int64_t start = 0; + std::int64_t loopStart = 0; + std::int64_t loopEnd = 0; + bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance) + }; + SetupMarkers pickedMarkers(std::int64_t frames) const; + + // Commit an edited marker set for the picked capture as a per-zone loop/start override + // (upsert on the picked id — mirror of the root-marker path), then reload off-thread. + void commitPickedMarkers(const SetupMarkers& m); + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -126,10 +156,23 @@ private: int dragStartHigh_ = 0; int dragStartRoot_ = 60; + // S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the + // pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker + // clamps use the sibling markers). + WaveMarker waveMarker_ = WaveMarker::kStart; + SetupMarkers dragStartMarkers_; + std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. std::unordered_map thumbCache_; + + // --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------ + // Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning + // + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or + // deleted sample does not show/snap against stale PCM. + std::unordered_map> pcmCache_; }; } // namespace reasampler::vst diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index cb6d8b9..2505597 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -143,7 +143,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // Effective root: override beats bank intrinsic beats middle-C default. rz.rootNote = z.rootOverride ? *z.rootOverride : (found->rootNote ? *found->rootNote : 60); - rz.loop = loopFromSample(*found); + // Effective loop / start (S11): the instrument's per-zone override wins over the + // bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is + // never mutated — this only shapes what the core plays for THIS instance (D-B). + rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); + rz.startFrame = z.startPoint ? *z.startPoint : 0; out.zones.push_back(std::move(rz)); } return out; @@ -161,6 +165,7 @@ Keymap buildZonedKeymap(const std::vector& zones, data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; + data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -184,6 +189,14 @@ void putU32le(std::vector& out, std::uint32_t v) { out.push_back(static_cast((v >> 24) & 0xFF)); } +// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as +// two's-complement u64, mirroring the u32 signed-int idiom above). +void putU64le(std::vector& out, std::uint64_t v) { + for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); +} + +std::uint64_t asU64(std::int64_t v) { return static_cast(v); } + // A bounded little-endian reader over a byte blob. Every read is length-checked; once a // read runs past the end the reader latches `ok=false` and yields zeros, so a truncated // blob degrades to a partial/empty parse rather than reading out of bounds. @@ -215,11 +228,38 @@ struct ByteReader { } // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). int i32() { return static_cast(static_cast(u32())); } + + std::uint64_t u64() { + if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } + std::uint64_t v = 0; + for (int b = 0; b < 8; ++b) + v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); + pos += 8; + return v; + } + // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). + std::int64_t i64() { return static_cast(u64()); } + + // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields + // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob + // as "no marker" and falls through to the (also-guarded) v1 count read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } }; -// Append the zones payload (zone count + per-zone records) — the shared body of the v2 -// performance blob and the v3 component blob, so both write zones identically. +// Append the zones payload — the shared body of the v2 performance blob and the v3 component +// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing +// marker + version + EXTENDED records): the marker precedes the zone count so any reader can +// detect the record shape independently of the envelope version (see sample_map.h). The S11 +// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump. void putZonesPayload(std::vector& out, const PerformanceMap& map) { + putU32le(out, kZonesFormatMarker); + putU32le(out, kZonesPayloadVersion); putU32le(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { putU32le(out, static_cast(z.sampleId.size())); @@ -231,14 +271,30 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU32le(out, static_cast(static_cast(*z.rootOverride))); } + // S11 extension: loop override (hasLoop flag + start/end), then start point. + out.push_back(z.loopOverride ? 1 : 0); + if (z.loopOverride) { + out.push_back(z.loopOverride->hasLoop ? 1 : 0); + putU64le(out, asU64(z.loopOverride->start)); + putU64le(out, asU64(z.loopOverride->end)); + } + out.push_back(z.startPoint ? 1 : 0); + if (z.startPoint) putU64le(out, asU64(*z.startPoint)); } } -// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the -// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones -// that parsed cleanly and drops the rest; the reader position is left after the last byte -// read successfully. +// Read a zones payload from `r` into `map`. Shared by the performance parse and the component +// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the +// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — +// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read +// keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { + bool extended = false; + if (r.peekU32() == kZonesFormatMarker) { + r.u32(); // consume the marker + const std::uint32_t pv = r.u32(); // payload version + extended = (pv >= 2); // v2+ carries the loop/start tail + } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { PerformanceZone z; @@ -248,6 +304,18 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { z.highNote = r.i32(); const std::uint8_t hasOverride = r.u8(); if (hasOverride) z.rootOverride = r.i32(); + if (extended) { + const std::uint8_t hasLoop = r.u8(); + if (hasLoop) { + SampleLoop lp; + lp.hasLoop = (r.u8() != 0); + lp.start = r.i64(); + lp.end = r.i64(); + z.loopOverride = lp; + } + const std::uint8_t hasStart = r.u8(); + if (hasStart) z.startPoint = r.i64(); + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index ad2b696..9806158 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -122,11 +122,20 @@ Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, // One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, // with an optional root-note override. rootOverride absent -> repitch from the bank // sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). +// +// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain +// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the +// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins +// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame +// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here; +// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone. struct PerformanceZone { std::string sampleId; // bank sample id this zone plays int lowNote = 0; // inclusive int highNote = 127; // inclusive std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic + std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic + std::optional startPoint; // instrument-owned initial read frame; absent -> 0 }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -148,7 +157,8 @@ struct ResolvedZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 - SampleLoop loop; // bank intrinsic + SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) + std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -191,21 +201,48 @@ Keymap buildZonedKeymap(const std::vector& zones, // instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of // truncation/wrong-version by design (bounded reads, never throws across the host). // -// Format (v2): 4-byte LE version tag (== 2), then a 4-byte LE zone count, then per zone: -// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, -// 1 byte hasOverride (0/1), 4-byte LE rootOverride (present only when hasOverride==1). -// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance -// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob -// deserializes to an EMPTY map. +// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the +// ZONES PAYLOAD. +// +// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones +// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) +// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState +// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the +// key composition property: the zone-record extension is versioned inside the map blob, not on +// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not +// collide on a single version number. +// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: +// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, +// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). +// A payload starting with a small u32 (the zone count) is v1 — there is no marker. +// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone +// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended +// to each zone record after rootOverride: +// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, +// 8-byte LE loop.end (both two's-complement int64); +// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). +// The reader detects the marker to know the record shape — a v1 payload (no marker) reads +// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved +// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes +// to an EMPTY map. // // These two functions serialize the ZONES only. Since S10 the instrument's full component // state is {single-capture selection id, zones} — see ComponentState / serializeComponentState // below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift. +// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. inline constexpr std::uint32_t kPerformanceStateVersion = 2; +// The zones-payload format version and its detection marker (S11). serializePerformance and +// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the +// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload +// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count +// (bounded by 128 MIDI zones in practice, always tiny) can never collide with. +inline constexpr std::uint32_t kZonesPayloadVersion = 2; +inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; + // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 57a7748..bc6c174 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -151,7 +151,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; ratio_ = pitchRatio(note, rootNote); - readPos_ = 0.0; + // Initial read position honors the sample's start-point offset (S11). Clamp into + // [0, frames): a start at or past the end degrades to 0 (play from the top) rather + // than starting a voice already off the end. A negative start (shouldn't occur — + // the map clamps) is likewise pinned to 0. + const std::int64_t frameCount = static_cast(sample.frames.size()); + std::int64_t start = sample.startFrame; + if (start < 0 || start >= frameCount) start = 0; + readPos_ = static_cast(start); sample_ = &sample; env_.configure(adsr); env_.noteOn(); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 5efa837..cd4afad 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -50,6 +50,12 @@ struct SampleData { // note-relative, so rate cancels for repitch) int rootNote = 60; // MIDI note recorded at (plays at unity here) SampleLoop loop; // sustain loop, if any + // Initial read position (frame offset) a voice starts playback at — frame 0 by + // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this + // an instrument-side per-zone override (the "start point" marker); S15 builds on it + // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: + // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::int64_t startFrame = 0; }; // --------------------------------------------------------------------------- diff --git a/src/vst/waveform_view.cpp b/src/vst/waveform_view.cpp new file mode 100644 index 0000000..2b6d70c --- /dev/null +++ b/src/vst/waveform_view.cpp @@ -0,0 +1,99 @@ +// waveform_view.cpp — see waveform_view.h. Pure math; no host types. + +#include "waveform_view.h" + +#include +#include // std::abs (int overload) + +namespace reasampler::vst { + +namespace { + +std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { + if (f < 0) return 0; + if (f > frameCount) return frameCount; + return f; +} + +} // namespace + +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.left; + const std::int64_t f = clampFrame(frame, frameCount); + // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line + // visually centered on its frame; the divide is exact rational (multiply first). + const std::int64_t num = f * static_cast(w) + frameCount / 2; + return area.left + static_cast(num / frameCount); +} + +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return 0; + if (x <= area.left) return 0; + if (x >= area.right) return frameCount; + const std::int64_t dx = static_cast(x - area.left); + // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw + // agree at bin granularity. + const std::int64_t num = dx * frameCount + static_cast(w) / 2; + return clampFrame(num / static_cast(w), frameCount); +} + +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y) { + if (count <= 0 || frames == nullptr) return -1; + if (!contains(area, x, y)) return -1; + for (int i = 0; i < count; ++i) { + const int mx = frameToX(area, frameCount, frames[i]); + if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i; + } + return -1; +} + +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels) { + const std::int64_t start = clampFrame(startFrame, frameCount); + if (dxPixels == 0) return start; + const int w = std::max(0, area.width()); + if (frameCount <= 0 || w <= 0) return start; // no room to move + // Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame). + const std::int64_t magnitude = + (static_cast(std::abs(dxPixels)) * frameCount + + static_cast(w) / 2) / + static_cast(w); + const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude; + return clampFrame(start + shift, frameCount); +} + +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target) { + if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0); + // Clamp target into a valid sample index [0, frames). + std::int64_t t = target; + if (t < 0) t = 0; + if (t > frames - 1) t = frames - 1; + + // A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR + // pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we + // probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic). + auto isCrossing = [&](std::int64_t i) -> bool { + if (i < 1 || i >= frames) return false; + const AudioSample a = pcm[i - 1]; + const AudioSample b = pcm[i]; + if (b == 0.0f) return true; // a sample on zero is its own crossing + return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i + }; + + if (isCrossing(t)) return t; + for (std::int64_t d = 1; d < frames; ++d) { + const std::int64_t lo = t - d; + if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie + const std::int64_t hi = t + d; + if (hi < frames && isCrossing(hi)) return hi; + // Stop once both probes have run off both ends — no crossing anywhere. + if (lo < 1 && hi >= frames) break; + } + return t; // no sign change in the whole buffer -> keep the raw (clamped) target +} + +} // namespace reasampler::vst diff --git a/src/vst/waveform_view.h b/src/vst/waveform_view.h new file mode 100644 index 0000000..4831cbf --- /dev/null +++ b/src/vst/waveform_view.h @@ -0,0 +1,83 @@ +// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 +// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror +// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap +// arithmetic lives here, unit-tested outside the DAW, while the editor shell +// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it. +// +// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal +// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start, +// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately +// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a +// different marker set (start + %-length end + fades) without reworking the machinery. +// +// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab +// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's +// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps), +// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame. +// +// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so +// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio +// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do +// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded. + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "peaks.h" // AudioSample (float), the mono PCM the snap scans + +namespace reasampler::vst { + +// The width (px) of a marker's grab region either side of its x line: a grab within this many +// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab +// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay +// distinguishable. +inline constexpr int kMarkerGrabWidth = 5; + +// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left, +// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an +// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a +// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure. +int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); + +// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to +// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount. +// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips +// to the same frame at bin granularity). +std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); + +// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at +// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is +// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker +// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring +// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or +// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like +// keyboard_strip::zoneBarAtPoint. +int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, + int count, int x, int y); + +// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time +// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the +// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), +// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to +// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag; +// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this +// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame +// for dxPixels == 0. +std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, + int dxPixels); + +// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the +// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) +// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing +// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and +// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to +// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or +// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller +// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the +// decoded PCM the shell already holds; no host types, no file I/O. +std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, + std::int64_t target); + +} // namespace reasampler::vst diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index da645ef..6a89ebe 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -479,6 +479,40 @@ static void testResolveLoopThreaded() { CHECK(r.zones.size() == 1); CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); + // No loop override + no startPoint -> effective start is 0 (S11 default). + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0); +} + +static void testResolveLoopOverrideWins() { + // S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the + // startPoint feeds the effective startFrame — without mutating the bank. + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; // bank intrinsic + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000; + z.loopOverride = over; // instrument override + z.startPoint = 512; // start offset + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); + CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000); + CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512); +} + +static void testResolveLoopOverrideDisablesLoop() { + // A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance + // (override present-but-empty wins over the intrinsic — a deliberate "no loop here"). + Sample s = makeSample("a", "Pad", "b/a.wav", 60); + s.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0 + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop); } // --- performance map: buildZonedKeymap ---------------------------------------- @@ -506,6 +540,24 @@ static void testBuildZonedKeymapMultiZone() { CHECK(!km.resolve(24, 100).matched); } +static void testBuildZonedKeymapThreadsLoopAndStart() { + // S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the + // voice honors them at note-on. + std::vector zones; + ResolvedZone z0; + z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; + z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7; + z0.startFrame = 2; + zones.push_back(z0); + std::vector decoded; + decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100}); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && + km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7); + CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2); +} + static void testBuildZonedKeymapDropsEmptyPcm() { // A zone whose decoded WAV is empty is dropped; the other zone survives, and the // survivor's sampleIndex points at ITS sample (not the dropped one's slot). @@ -563,11 +615,79 @@ static void testPerformanceStateRoundTrip() { static void testPerformanceStateEmpty() { const std::vector bytes = serializePerformance(PerformanceMap{}); - // Just the version + zero-count header. - CHECK(bytes.size() == 8); + // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). + CHECK(bytes.size() == 16); CHECK(deserializePerformance(bytes).zones.empty()); } +static void testPerformanceStateLoopStartRoundTrip() { + // S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip. + PerformanceMap m; + PerformanceZone z = zone("pad", 24, 96, /*override=*/64); + SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890; + z.loopOverride = lp; + z.startPoint = 4096; + m.zones.push_back(z); + // A second zone with NO overrides proves the optional tail is per-record. + m.zones.push_back(zone("kick", 0, 23)); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 2); + CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && + *back.zones[0].rootOverride == 64); + CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() && + back.zones[0].loopOverride->hasLoop && + back.zones[0].loopOverride->start == 12345 && + back.zones[0].loopOverride->end == 67890); + CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() && + *back.zones[0].startPoint == 4096); + // Zone 1: no overrides -> all optionals absent after round trip. + CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value()); + CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value()); +} + +static void testPerformanceStateV1PayloadBackCompat() { + // A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records) + // parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact + // shipped shape to prove the reader still accepts the marker-less payload. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + u32(2); // envelope version 2 + u32(1); // zone count 1 (NOT the marker -> payload v1) + const std::string id = "legacy"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(40); // highNote + b.push_back(0); // hasRootOverride = 0 (record ends here in v1) + const PerformanceMap back = deserializePerformance(b); + CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy"); + CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40); + CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); + CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value()); +} + +static void testComponentStateLoopStartRoundTrip() { + // The overrides also round-trip through the v3 ComponentState envelope (zones nest inside + // it), so the processor's live getState/setState preserves them — the composition property. + ComponentState s; + s.selectionId = "pick"; + PerformanceZone z = zone("pick", 0, 127); + SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000; + z.loopOverride = lp; + z.startPoint = 128; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); +} + static void testPerformanceStateV1BackCompat() { // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. const std::vector v1 = serializeSelection("legacy-sample-id"); @@ -711,16 +831,22 @@ int main() { testResolveStaleIdDropsZone(); testResolveRootPrecedence(); testResolveLoopThreaded(); + testResolveLoopOverrideWins(); + testResolveLoopOverrideDisablesLoop(); testBuildZonedKeymapMultiZone(); + testBuildZonedKeymapThreadsLoopAndStart(); testBuildZonedKeymapDropsEmptyPcm(); testBuildZonedKeymapOverlapFirstWins(); testBuildZonedKeymapEmpty(); testPerformanceStateRoundTrip(); testPerformanceStateEmpty(); + testPerformanceStateLoopStartRoundTrip(); + testPerformanceStateV1PayloadBackCompat(); testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); testComponentStateRoundTrip(); + testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); testComponentStateEmptyIsEmpty(); testComponentStateV1BackCompat(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4a3a5a7..700e9a0 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -520,6 +520,78 @@ static void testAbsentLoopGoesSilent() { for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); } +// --------------------------------------------------------------------------- +// start point (S11): the voice's initial read position is SampleData::startFrame. +// --------------------------------------------------------------------------- + +static void testStartFrameOffsetsInitialRead() { + // A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the + // read position. startFrame = 30 -> the first output frame reads frame 30 (0.30). + SampleData s; + s.frames.resize(100); + for (int i = 0; i < 100; ++i) s.frames[i] = static_cast(i) * 0.01f; + s.rootNote = 60; + s.startFrame = 30; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + std::vector out; + eng.render(out, 3); + CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0 + CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio + CHECK(approx(out[2], 0.32, 1e-4)); +} + +static void testStartFrameZeroIsUnchanged() { + // startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0. + SampleData s; + s.frames.resize(20); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) * 0.05f; + s.rootNote = 60; // startFrame stays 0 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 0.0, 1e-6)); // frame 0 +} + +static void testStartFrameOutOfRangeClampsToZero() { + // A start point at/past the sample end degrades to frame 0 (play from the top), never an + // out-of-bounds read that would start the voice already exhausted. + SampleData s = dcSample(10, 60); // 10 frames of 1.0 + s.startFrame = 10; // == frameCount: out of range + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle. + CHECK(eng.activeVoiceCount() == 1); + CHECK(approx(out[0], 1.0, 1e-4)); +} + +static void testStartFrameWithLoop() { + // Start point and loop compose: begin reading mid-sample, then sustain the loop region. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 40; ++i) s.frames[i] = static_cast(i) * 0.01f; + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.startFrame = 10; // begin at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 200); + CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10 + CHECK(eng.activeVoiceCount() == 1); // loop sustains it + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4)); +} + // --------------------------------------------------------------------------- // velocity -> volume. // --------------------------------------------------------------------------- @@ -580,6 +652,10 @@ int main() { testZeroLengthLoopGoesSilent(); testSingleFrameLoop(); testAbsentLoopGoesSilent(); + testStartFrameOffsetsInitialRead(); + testStartFrameZeroIsUnchanged(); + testStartFrameOutOfRangeClampsToZero(); + testStartFrameWithLoop(); testVelocityToVolume(); testPolyphonyMixesAdditively(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp new file mode 100644 index 0000000..2969cd9 --- /dev/null +++ b/tests/test_waveform_view.cpp @@ -0,0 +1,223 @@ +// Standalone tests for reasampler::vst::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. +// +// 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). + +#include "../src/vst/waveform_view.h" + +#include +#include + +using namespace reasampler::vst; +using reasampler::AudioSample; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs). +static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000 + +// --- frameToX / xToFrame ------------------------------------------------------ + +static void testFrameToXEndpoints() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge + CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge + CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here) +} + +static void testFrameToXClampsOutOfRange() { + const Rect a = wideArea(); + CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left + CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right +} + +static void testFrameToXDegenerate() { + const Rect a = wideArea(); + CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left + const Rect z = Rect{5, 5, 5, 45}; // zero width + CHECK(frameToX(z, 1000, 500) == z.left); +} + +static void testXToFrameInverse() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left) == 0); + CHECK(xToFrame(a, 1000, a.right) == 1000); + CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here +} + +static void testXToFrameClampsOutside() { + const Rect a = wideArea(); + CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0 + CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount + CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0 +} + +static void testFrameToXRoundTrip() { + // Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then + // xToFrame should land within a couple frames (rounding both directions). + const Rect a = Rect{0, 0, 800, 60}; + for (std::int64_t f = 0; f <= 2000; f += 137) { + const int x = frameToX(a, 2000, f); + const std::int64_t back = xToFrame(a, 2000, x); + CHECK(back >= f - 3 && back <= f + 3); + } +} + +// --- markerAtPoint ------------------------------------------------------------ + +static void testMarkerAtPointGrabsWithinBand() { + const Rect a = wideArea(); + // Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900. + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2); + // Within the grab band on either side of the line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1); +} + +static void testMarkerAtPointMissesBetween() { + const Rect a = wideArea(); + const std::int64_t frames[3] = {100, 500, 900}; + const int midY = a.top + a.height() / 2; + // Well away from any marker line. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1); + // Off the area vertically. + CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1); +} + +static void testMarkerAtPointFirstMatchOnOverlap() { + const Rect a = wideArea(); + // Two markers at the same frame -> first in order wins. + const std::int64_t frames[2] = {400, 400}; + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0); +} + +static void testMarkerAtPointRejectsNullEmpty() { + const Rect a = wideArea(); + const int midY = a.top + a.height() / 2; + CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1); + const std::int64_t frames[1] = {100}; + CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1); +} + +// --- resolveDragFrame --------------------------------------------------------- + +static void testResolveDragFrameShift() { + const Rect a = wideArea(); // 1:1 (1000px / 1000 frames) + CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged + CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames + CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames +} + +static void testResolveDragFrameClamps() { + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low + CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount) +} + +static void testResolveDragFrameRounds() { + // 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is + // at the frame centre. Use a scale where a fractional result appears. + const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px + // +3px -> 3*1000/300 = 10.0 -> 10 frames. + CHECK(resolveDragFrame(a, 1000, 100, 3) == 110); + // +1px -> 1000/300 = 3.33 -> rounds to 3. + CHECK(resolveDragFrame(a, 1000, 100, 1) == 103); +} + +static void testResolveDragFrameDegenerate() { + const Rect z = Rect{0, 0, 0, 60}; // zero width + CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start + const Rect a = wideArea(); + CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0 + // startFrame out of range is clamped first. + CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000); +} + +// --- nearestZeroCrossing ------------------------------------------------------ + +static void testZeroCrossingNearest() { + // Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1). + std::vector pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1}; + // Target 4 is itself a crossing -> 4. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4); + // Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5); + // Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10); +} + +static void testZeroCrossingSampleOnZero() { + // A sample exactly 0 is its own crossing (frame index of the zero sample). + std::vector pcm = {1, 1, 0, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2); +} + +static void testZeroCrossingEquidistantTieToLower() { + // Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2). + std::vector pcm = {1, 1, -1, -1, -1, -1, 1, 1}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2); +} + +static void testZeroCrossingNoneKeepsTarget() { + // All one sign -> no crossing -> the (clamped) target comes back unchanged. + std::vector pcm = {0.5f, 0.6f, 0.7f, 0.8f}; + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2); +} + +static void testZeroCrossingClampsTarget() { + std::vector pcm = {1, -1, 1, -1}; // crossings at 1,2,3 + // Target beyond the end clamps to frames-1 (3) then finds crossing at 3. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3); + // Negative target clamps to 0; nearest crossing is 1. + CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1); +} + +static void testZeroCrossingDegenerate() { + CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0); + std::vector one = {1}; + CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target +} + +int main() { + testFrameToXEndpoints(); + testFrameToXClampsOutOfRange(); + testFrameToXDegenerate(); + testXToFrameInverse(); + testXToFrameClampsOutside(); + testFrameToXRoundTrip(); + + testMarkerAtPointGrabsWithinBand(); + testMarkerAtPointMissesBetween(); + testMarkerAtPointFirstMatchOnOverlap(); + testMarkerAtPointRejectsNullEmpty(); + + testResolveDragFrameShift(); + testResolveDragFrameClamps(); + testResolveDragFrameRounds(); + testResolveDragFrameDegenerate(); + + testZeroCrossingNearest(); + testZeroCrossingSampleOnZero(); + testZeroCrossingEquidistantTieToLower(); + testZeroCrossingNoneKeepsTarget(); + testZeroCrossingClampsTarget(); + testZeroCrossingDegenerate(); + + 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; +}