From e9dde3cea39e69d6188a7f49034f6d95e4c8b6b4 Mon Sep 17 00:00:00 2001 From: skal Date: Sat, 14 Feb 2026 18:06:24 +0100 Subject: feat(tracker): add sample offset and humanization Implements two tracker realism features: 1. Sample Offset (compile-time): - Add offset_sec field to NoteParams and Sample structs - Parse OFFSET parameter in SAMPLE directive - Apply timing shift during compilation (zero runtime cost) - Use for attack-heavy samples to align perceived beat 2. Humanization (runtime, deterministic): - Add humanize_seed, timing_variation_pct, volume_variation_pct to TrackerScore - Parse HUMANIZE directive with SEED/TIMING/VOLUME params - Apply per-event RNG jitter using std::minstd_rand - Deterministic: same seed produces identical output Both features work in real-time playback and WAV export. Test file: data/test_humanize.track Co-Authored-By: Claude Sonnet 4.5 --- tools/tracker_compiler.cc | 65 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 16 deletions(-) (limited to 'tools/tracker_compiler.cc') diff --git a/tools/tracker_compiler.cc b/tools/tracker_compiler.cc index de635cd..6bc22be 100644 --- a/tools/tracker_compiler.cc +++ b/tools/tracker_compiler.cc @@ -98,6 +98,7 @@ struct Sample { // Parameters for generated samples float freq, dur, amp, attack, harmonic_decay; int harmonics; + float offset_sec = 0.0f; }; struct Event { @@ -409,6 +410,9 @@ int main(int argc, char** argv) { } float bpm = 120.0f; + uint32_t humanize_seed = 0; + float timing_variation_pct = 0.0f; + float volume_variation_pct = 0.0f; std::vector samples; std::map sample_map; std::vector patterns; @@ -432,6 +436,17 @@ int main(int argc, char** argv) { if (cmd == "BPM") { ss >> bpm; + } else if (cmd == "HUMANIZE") { + std::string key; + while (ss >> key) { + if (key == "SEED") { + ss >> humanize_seed; + } else if (key == "TIMING") { + ss >> timing_variation_pct; + } else if (key == "VOLUME") { + ss >> volume_variation_pct; + } + } } else if (cmd == "SAMPLE") { Sample s; std::string name; @@ -443,12 +458,13 @@ int main(int argc, char** argv) { if (name.rfind("ASSET_", 0) == 0) { s.type = ASSET; s.asset_id_name = name; - // Parameters for asset samples are ignored, so we don't parse them - // here. However, we must consume the rest of the line to avoid issues - // if a comma is present. - std::string dummy; - while (ss >> dummy) { - } // Consume rest of line + // Parse optional KEY-VALUE parameters for asset samples + std::string token; + while (ss >> token) { + if (token == "OFFSET") { + ss >> s.offset_sec; + } + } } else { s.type = GENERATED; // Very simple parsing: freq, dur, amp, attack, harmonics, @@ -456,6 +472,14 @@ int main(int argc, char** argv) { char comma; ss >> s.freq >> comma >> s.dur >> comma >> s.amp >> comma >> s.attack >> comma >> s.harmonics >> comma >> s.harmonic_decay; + + // Parse optional KEY-VALUE parameters after the standard params + std::string token; + while (ss >> token) { + if (token == "OFFSET") { + ss >> s.offset_sec; + } + } } sample_map[s.name] = samples.size(); @@ -565,9 +589,9 @@ int main(int argc, char** argv) { if (s.type == GENERATED) { fprintf(out_file, " { %.1ff, %.2ff, %.1ff, %.2ff, 0.0f, 0.0f, 0.0f, %d, %.1ff, " - "0.0f, 0.0f }, // %s\n", + "0.0f, 0.0f, %.3ff }, // %s\n", s.freq, s.dur, s.amp, s.attack, s.harmonics, s.harmonic_decay, - s.name.c_str()); + s.offset_sec, s.name.c_str()); } else { fprintf(out_file, " { 0 }, // %s (ASSET)\n", s.name.c_str()); } @@ -594,17 +618,24 @@ int main(int argc, char** argv) { }); } + const float BEATS_PER_UNIT = 4.0f; + const float unit_duration_sec = (BEATS_PER_UNIT / bpm) * 60.0f; + for (const auto& p : patterns) { fprintf(out_file, "static const TrackerEvent PATTERN_EVENTS_%s[] = {\n", p.name.c_str()); for (const auto& e : p.events) { - // When referencing a sample, we need to get its index or synth_id. - // If it's an asset, the name starts with ASSET_. - // For now, assume sample_map is used for both generated and asset - // samples. This will need refinement if asset samples are not in - // sample_map directly. - fprintf(out_file, " { %.2ff, %d, %.1ff, %.1ff },\n", e.unit_time, - sample_map[e.sample_name], e.volume, e.pan); + // Apply compile-time sample offset + int sample_id = sample_map[e.sample_name]; + float adjusted_time = e.unit_time; + if (sample_id >= 0 && sample_id < (int)samples.size()) { + const Sample& sample = samples[sample_id]; + if (sample.offset_sec != 0.0f) { + adjusted_time = e.unit_time - (sample.offset_sec / unit_duration_sec); + } + } + fprintf(out_file, " { %.2ff, %d, %.1ff, %.1ff },\n", adjusted_time, + sample_id, e.volume, e.pan); } fprintf(out_file, "};\n"); } @@ -628,7 +659,9 @@ int main(int argc, char** argv) { fprintf(out_file, "};\n\n"); fprintf(out_file, "const TrackerScore g_tracker_score = {\n"); - fprintf(out_file, " SCORE_TRIGGERS, %zu, %.1ff\n", score.size(), bpm); + fprintf(out_file, " SCORE_TRIGGERS, %zu, %.1ff, %u, %.1ff, %.1ff\n", + score.size(), bpm, humanize_seed, timing_variation_pct, + volume_variation_pct); fprintf(out_file, "};\n\n"); // Analyze resource requirements -- cgit v1.2.3