diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-14 18:06:24 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-14 18:06:24 +0100 |
| commit | e9dde3cea39e69d6188a7f49034f6d95e4c8b6b4 (patch) | |
| tree | b3b383eff5f0d1d18bdbb781c331e6a31fcb25a7 | |
| parent | b4c901700de0d9e867b9fd0c0c6a6586578f8480 (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | data/test_humanize.track | 11 | ||||
| -rw-r--r-- | doc/TRACKER.md | 41 | ||||
| -rw-r--r-- | src/audio/gen.h | 1 | ||||
| -rw-r--r-- | src/audio/tracker.cc | 33 | ||||
| -rw-r--r-- | src/audio/tracker.h | 3 | ||||
| -rw-r--r-- | tools/tracker_compiler.cc | 65 |
6 files changed, 118 insertions, 36 deletions
diff --git a/data/test_humanize.track b/data/test_humanize.track new file mode 100644 index 0000000..1da75e6 --- /dev/null +++ b/data/test_humanize.track @@ -0,0 +1,11 @@ +BPM 120 +SAMPLE kick, 80, 0.3, 1.0, 0.01, 3, 0.6 OFFSET 0.01 +SAMPLE snare, 200, 0.2, 1.0, 0.01, 2, 0.5 +HUMANIZE SEED 42 TIMING 5 VOLUME 10 + +PATTERN main LENGTH 1.0 + 0.0, kick, 1.0, 0.0 + 0.5, snare, 1.0, 0.0 + +SCORE + 0.0, main diff --git a/doc/TRACKER.md b/doc/TRACKER.md index 5cb59de..dc77976 100644 --- a/doc/TRACKER.md +++ b/doc/TRACKER.md @@ -18,16 +18,22 @@ Patterns are BPM-independent. Changing BPM only affects playback speed. ``` # Comments start with # -BPM <tempo> # Optional, defaults to 120 BPM +BPM <tempo> # Optional, defaults to 120 BPM +HUMANIZE SEED <int> TIMING <pct> VOLUME <pct> # Optional humanization -SAMPLE <name> [OFFSET <sec>] [VOL <volume>] # Define sample with optional offset/volume +# Generated samples: +SAMPLE <name>, <freq>, <dur>, <amp>, <attack>, <harmonics>, <decay> [OFFSET <sec>] -PATTERN <name> LENGTH <duration> # Define pattern with unit-less duration - <unit_time>, <sample>, <volume>, <pan> # Pattern events +# Asset samples: +SAMPLE <asset_id> [OFFSET <sec>] # ASSET_* from assets.txt -HUMANIZE SEED <int> TIMING <pct> VOLUME <pct> # Optional humanization params +# Auto-generated notes (no SAMPLE declaration needed): +# NOTE_C4, NOTE_A#3, NOTE_Eb2, etc. -SCORE # Score section (pattern triggers) +PATTERN <name> [LENGTH <duration>] # Defaults to LENGTH 1.0 + <unit_time>, <sample>, <volume>, <pan> + +SCORE <unit_time>, <pattern_name> ``` @@ -74,26 +80,27 @@ PATTERN short_fill LENGTH 0.5 # 2 beats = 1 second at 120 BPM ### Sample Offset -Samples can specify an intrinsic offset (time-shift left): +Intrinsic timing offset per sample (compile-time): ``` -SAMPLE ASSET_KICK_1 OFFSET 0.05 VOL 1.2 +SAMPLE ASSET_KICK_1 OFFSET 0.01 # Trigger 10ms earlier (attack compensation) +SAMPLE bass, 80, 0.5, 1.0, 0.02, 3, 0.6 OFFSET 0.005 ``` -- **OFFSET**: Seconds to shift trigger earlier (preserves beat sync) -- **VOL**: Default volume multiplier for this sample +- Shifts sample trigger earlier while preserving beat sync +- Applied at compile-time (zero runtime cost) +- Use for attack-heavy samples to align perceived beat ### Humanization -Add per-note timing/volume variation for realistic playback: +Per-note timing/volume variation (runtime, deterministic): ``` -HUMANIZE SEED 42 TIMING 2.0 VOLUME 5.0 +HUMANIZE SEED 42 TIMING 5.0 VOLUME 10.0 ``` -- **SEED**: Random seed for reproducibility -- **TIMING**: Timing variation (% of beat duration) -- **VOLUME**: Volume variation (% of event volume) - -Applied per-note, baked into WAV export. +- **SEED**: Deterministic RNG seed (reproducible across runs) +- **TIMING**: ±% jitter of beat duration +- **VOLUME**: ±% variation of event volume +- Identical in real-time playback and WAV export --- diff --git a/src/audio/gen.h b/src/audio/gen.h index 94f4ee3..370971b 100644 --- a/src/audio/gen.h +++ b/src/audio/gen.h @@ -17,6 +17,7 @@ struct NoteParams { float harmonic_decay; float pitch_randomness; float amp_randomness; + float offset_sec = 0.0f; }; // Generates a single note into a new spectrogram buffer diff --git a/src/audio/tracker.cc b/src/audio/tracker.cc index 67c197f..1c0a9b2 100644 --- a/src/audio/tracker.cc +++ b/src/audio/tracker.cc @@ -5,6 +5,7 @@ #include "util/debug.h" #include "util/fatal_error.h" #include <cstring> +#include <random> #include <vector> static uint32_t g_last_trigger_idx = 0; @@ -190,8 +191,9 @@ static int get_free_pattern_slot() { // Helper to trigger a single note event (OPTIMIZED with caching) // start_offset_samples: How many samples into the future to trigger (for // sample-accurate timing) +// volume_mult: Additional volume multiplier (for humanization) static void trigger_note_event(const TrackerEvent& event, - int start_offset_samples) { + int start_offset_samples, float volume_mult = 1.0f) { #if defined(DEBUG_LOG_TRACKER) // VALIDATION: Check sample_id bounds if (event.sample_id >= g_tracker_samples_count) { @@ -227,7 +229,7 @@ static void trigger_note_event(const TrackerEvent& event, } // Trigger voice with sample-accurate offset - synth_trigger_voice(cached_synth_id, event.volume, event.pan, + synth_trigger_voice(cached_synth_id, event.volume * volume_mult, event.pan, start_offset_samples); } @@ -287,7 +289,32 @@ void tracker_update(float music_time_sec, float dt_music_sec) { (int)((event_music_time - music_time_sec) / tempo_scale * 32000.0f); } - trigger_note_event(event, sample_offset); + // Apply humanization if enabled + float volume_mult = 1.0f; + if (g_tracker_score.humanize_seed != 0) { + // Deterministic per-event RNG: hash seed with pattern/event indices + uint32_t event_hash = g_tracker_score.humanize_seed ^ + (active.pattern_id << 16) ^ active.next_event_idx; + std::minstd_rand rng(event_hash); + std::uniform_real_distribution<float> dist(-1.0f, 1.0f); + + // Timing variation: jitter by % of beat duration + if (g_tracker_score.timing_variation_pct > 0.0f) { + float beat_sec = 60.0f / g_tracker_score.bpm; + float jitter = dist(rng) * + (g_tracker_score.timing_variation_pct / 100.0f) * + beat_sec; + sample_offset += (int)(jitter / tempo_scale * 32000.0f); + } + + // Volume variation: vary by % + if (g_tracker_score.volume_variation_pct > 0.0f) { + volume_mult += + dist(rng) * (g_tracker_score.volume_variation_pct / 100.0f); + } + } + + trigger_note_event(event, sample_offset, volume_mult); active.next_event_idx++; } diff --git a/src/audio/tracker.h b/src/audio/tracker.h index 8e7a63f..8e89a4e 100644 --- a/src/audio/tracker.h +++ b/src/audio/tracker.h @@ -31,6 +31,9 @@ struct TrackerScore { const TrackerPatternTrigger* triggers; uint32_t num_triggers; float bpm; // BPM is used only for playback scaling (1 unit = 4 beats) + uint32_t humanize_seed = 0; + float timing_variation_pct = 0.0f; + float volume_variation_pct = 0.0f; }; // Global music data generated by tracker_compiler 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<Sample> samples; std::map<std::string, int> sample_map; std::vector<Pattern> 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 |
