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:
@@ -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{};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user