summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-14 18:06:24 +0100
committerskal <pascal.massimino@gmail.com>2026-02-14 18:06:24 +0100
commite9dde3cea39e69d6188a7f49034f6d95e4c8b6b4 (patch)
treeb3b383eff5f0d1d18bdbb781c331e6a31fcb25a7
parentb4c901700de0d9e867b9fd0c0c6a6586578f8480 (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.track11
-rw-r--r--doc/TRACKER.md41
-rw-r--r--src/audio/gen.h1
-rw-r--r--src/audio/tracker.cc33
-rw-r--r--src/audio/tracker.h3
-rw-r--r--tools/tracker_compiler.cc65
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