feat: run the per-voice filter between the pitch and amp stages, with its own deck

Params ride the one parameter set; payload v8 -> v9, off by default.
Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+66 -18
View File
@@ -1,5 +1,5 @@
// component_state_io — the ComponentState envelope + params-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
// Every wire format is FROZEN — byte-identical across revisions.
#include "core/instrument/map/component_state_io.h"
@@ -51,6 +51,17 @@ void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
}
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
@@ -79,14 +90,27 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(p.keyTrack));
// The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
// per point velocity + amp as doubles (endpoints included, so N >= 2).
const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
// velocity + amp as doubles (endpoints included, so N >= 2).
putCurve(out, p.velocityCurve);
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
// whole payload stays one numeric shape.
const FilterSeconds& f = pp.filter;
out.push_back(f.enabled ? 1 : 0);
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
putLE(out, doubleToBits(f.env.decaySeconds));
putLE(out, doubleToBits(f.env.sustainLevel));
putLE(out, doubleToBits(f.env.releaseSeconds));
putCurve(out, f.velocityCurve);
}
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
@@ -108,9 +132,9 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint
// invariant defensively; a truncated read leaves the flat default.
void readCurveTail(ByteReader& r, InstrumentParams& p) {
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
@@ -123,10 +147,32 @@ void readCurveTail(ByteReader& r, InstrumentParams& p) {
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) {
p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
f.modAmount = bitsToDouble(r.u64());
f.velAmount = bitsToDouble(r.u64());
f.keyTrack = bitsToDouble(r.u64());
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
@@ -188,7 +234,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p);
if (curveTail) readCurveTail(r, p.velocityCurve);
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
@@ -201,15 +247,16 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
return out;
}
// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
// zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
std::uint32_t pv = 0; // 0 = v1, no marker
if (r.peekU32() == kParamsFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
}
if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
PayloadRead out;
InstrumentParams& p = out.params;
@@ -227,7 +274,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p);
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+25 -7
View File
@@ -63,12 +63,20 @@ namespace reasampler::instrument::map {
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
// (soft hits play louder than under the old linear velocity/127 map).
//
// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
// SINGLE record with no count, no key range and no sample id (the envelope's selection id is
// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte
// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set);
// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim
// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7.
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
// with no count, no key range and no sample id (the envelope's selection id is the capture):
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
// velocity curve (count + points) as in v7.
//
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
// bit-identically.
//
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
@@ -77,9 +85,19 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
// reads through the legacy walk; everything at or above it shares the v8 record shape and
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
// future bump does not silently push the previous format back into the zone reader.
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
// self-describing, mirroring the envelope's version constants.
inline constexpr std::uint32_t kParamsFilterVersion = 9;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+13
View File
@@ -225,6 +225,19 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
// Filter: the control positions are already rate-free and carry through untouched; only
// its envelope resolves to frames.
out.filter.enabled = stored.filter.enabled;
out.filter.settings = stored.filter.settings;
out.filter.modAmount = stored.filter.modAmount;
out.filter.velAmount = stored.filter.velAmount;
out.filter.keyTrack = stored.filter.keyTrack;
out.filter.velocityCurve = stored.filter.velocityCurve;
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
return out;
}
+16
View File
@@ -160,6 +160,21 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak
};
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
// MEANS). Only the envelope differs between the two: the control positions and depths are
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
// loaded blob and the pre-filter sound.
struct FilterSeconds {
bool enabled = false;
engine::filter::FilterSettings settings;
double modAmount = 0.0;
double velAmount = 0.0;
double keyTrack = 0.0;
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
VelocityCurve velocityCurve = VelocityCurve::linear();
};
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
// from the engine-facing PlayParams (frames).
@@ -169,6 +184,7 @@ struct PlaySeconds {
TriggerParams trigger; // Trigger: %-length + fades (source frames)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
FilterSeconds filter; // per-voice filter, off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live