summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-15 23:56:43 +0100
committerskal <pascal.massimino@gmail.com>2026-02-15 23:56:43 +0100
commit5c7feffd3749ce4b355d0db6334cf39ca94d8d82 (patch)
tree7105aea12d0367208a37777cf53b348f35a66dad /src
parente21127a3fc4797805d49ae2d95fc7ed6f94ac456 (diff)
perf(audio): smooth playback time and RMS-based peak at 60Hz
Interpolates audio playback time between callbacks using CLOCK_MONOTONIC for smooth 60Hz updates instead of coarse 8-10Hz steps. Replaces artificial peak decay with true RMS calculation over 50ms window. Ring buffer computes RMS directly on internal buffer without copies for efficiency. All backends updated with get_callback_state() interface for time interpolation. Tests passing (34/34). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/main.cc17
-rw-r--r--src/audio/audio.cc32
-rw-r--r--src/audio/audio.h1
-rw-r--r--src/audio/audio_backend.h6
-rw-r--r--src/audio/backend/jittered_audio_backend.cc6
-rw-r--r--src/audio/backend/jittered_audio_backend.h1
-rw-r--r--src/audio/backend/miniaudio_backend.cc34
-rw-r--r--src/audio/backend/miniaudio_backend.h5
-rw-r--r--src/audio/backend/mock_audio_backend.cc6
-rw-r--r--src/audio/backend/mock_audio_backend.h1
-rw-r--r--src/audio/backend/silent_backend.cc5
-rw-r--r--src/audio/backend/silent_backend.h1
-rw-r--r--src/audio/backend/wav_dump_backend.cc6
-rw-r--r--src/audio/backend/wav_dump_backend.h1
-rw-r--r--src/audio/ring_buffer.cc39
-rw-r--r--src/audio/ring_buffer.h5
16 files changed, 138 insertions, 28 deletions
diff --git a/src/app/main.cc b/src/app/main.cc
index 3c80520..9bbbaaf 100644
--- a/src/app/main.cc
+++ b/src/app/main.cc
@@ -168,7 +168,7 @@ int main(int argc, char** argv) {
#if !defined(STRIP_ALL)
// Debug output when tempo changes significantly
if (fabsf(g_tempo_scale - prev_tempo) > 0.05f) {
- printf("[Tempo] t=%.2fs, tempo=%.3fx, music_time=%.3fs\n",
+ printf("[Tempo] t=%.3fs, tempo=%.3fx, music_time=%.3fs\n",
(float)physical_time, g_tempo_scale, g_music_time);
}
#endif
@@ -191,7 +191,7 @@ int main(int argc, char** argv) {
#if !defined(STRIP_ALL)
if (seek_time > 0.0) {
- printf("Seeking to %.2f seconds...\n", seek_time);
+ printf("Seeking to %.3f seconds...\n", seek_time);
// Simulate audio/game logic
// We step at ~60hz
@@ -213,7 +213,7 @@ int main(int argc, char** argv) {
}
audio_start();
- g_last_audio_time = audio_get_playback_time(); // Initialize after start
+ g_last_audio_time = 0.0f; // Initialize to zero (will use smooth interpolation)
#if !defined(STRIP_ALL)
// Hot-reload setup
@@ -351,7 +351,7 @@ int main(int argc, char** argv) {
// Graphics frame time - derived from platform's clock
const double current_physical_time = platform_state.time + seek_time;
const double graphics_frame_time = current_physical_time - last_frame_time;
- // Audio playback time - master clock for audio events
+ // Audio playback time - smoothly interpolated at 60Hz
const float current_audio_time = audio_get_playback_time();
// Delta time for audio processing, based on audio clock
const float audio_dt = current_audio_time - g_last_audio_time;
@@ -398,17 +398,18 @@ int main(int argc, char** argv) {
// Use graphics time for the print interval to avoid excessive output if
// audio clock is slow
static float last_graphics_print_time = -1.0f;
- if (current_physical_time - last_graphics_print_time >=
+ if (true ||
+ current_physical_time - last_graphics_print_time >=
0.5f) { // Print every 0.5 seconds
if (tempo_test_enabled) {
printf(
- "[GraphicsT=%.2f, AudioT=%.2f, MusicT=%.2f, Beat=%d, Phase=%.2f, "
- "Peak=%.2f, Tempo=%.2fx]\n",
+ "[GraphicsT=%.3f, AudioT=%.3f, MusicT=%.3f, Beat=%d, Phase=%.3f, "
+ "Peak=%.3f, Tempo=%.3fx]\n",
current_physical_time, current_audio_time, g_music_time,
beat_number, beat_phase, visual_peak, g_tempo_scale);
} else {
printf(
- "[GraphicsT=%.2f, AudioT=%.2f, Beat=%d, Phase=%.2f, Peak=%.2f]\n",
+ "[GraphicsT=%.3f, AudioT=%.3f, Beat=%d, Phase=%.3f, Peak=%.3f]\n",
current_physical_time, current_audio_time, beat_number, beat_phase,
visual_peak);
}
diff --git a/src/audio/audio.cc b/src/audio/audio.cc
index a220fbb..780691a 100644
--- a/src/audio/audio.cc
+++ b/src/audio/audio.cc
@@ -12,6 +12,7 @@
#define MINIAUDIO_IMPLEMENTATION
#include "miniaudio.h"
+#include <cmath>
#include <stdio.h>
// Global ring buffer for audio streaming
@@ -228,6 +229,27 @@ void audio_render_ahead(float music_time, float dt, float target_fill) {
}
float audio_get_playback_time() {
+ // Smooth interpolation: use backend callback state if available
+ if (g_audio_backend != nullptr) {
+ double last_callback_time = 0.0;
+ int64_t last_callback_samples = 0;
+ g_audio_backend->get_callback_state(&last_callback_time,
+ &last_callback_samples);
+
+ // If callback has fired, interpolate for smooth 60Hz updates
+ if (last_callback_time != 0.0) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ const double current_time = ts.tv_sec + ts.tv_nsec / 1e9;
+ const double elapsed = current_time - last_callback_time;
+ const float interpolated_samples =
+ (float)(elapsed * RING_BUFFER_SAMPLE_RATE * RING_BUFFER_CHANNELS);
+ const float total_samples = (float)last_callback_samples + interpolated_samples;
+ return total_samples / (RING_BUFFER_SAMPLE_RATE * RING_BUFFER_CHANNELS);
+ }
+ }
+
+ // Fallback: coarse ring buffer time (before first callback or no backend)
const int64_t total_samples = g_ring_buffer.get_total_read();
return (float)total_samples /
(RING_BUFFER_SAMPLE_RATE * RING_BUFFER_CHANNELS);
@@ -240,10 +262,12 @@ float audio_get_render_time() {
}
float audio_get_realtime_peak() {
- if (g_audio_backend == nullptr) {
- return 0.0f;
- }
- return g_audio_backend->get_realtime_peak();
+ // Calculate RMS over recent time window (50ms)
+ const int window_ms = 50;
+ const int window_samples =
+ (window_ms * RING_BUFFER_SAMPLE_RATE * RING_BUFFER_CHANNELS) / 1000;
+
+ return g_ring_buffer.get_past_rms(window_samples);
}
// Expose ring buffer for backends
diff --git a/src/audio/audio.h b/src/audio/audio.h
index beb994f..97a8c99 100644
--- a/src/audio/audio.h
+++ b/src/audio/audio.h
@@ -36,6 +36,7 @@ void audio_render_ahead(float music_time, float dt, float target_fill = -1.0f);
// Get current playback time (in seconds) based on samples consumed
// This is the ring buffer READ position (what's being played NOW)
+// Smoothly interpolated between audio callbacks for 60Hz updates
float audio_get_playback_time();
// Get current render time (in seconds) based on samples written
diff --git a/src/audio/audio_backend.h b/src/audio/audio_backend.h
index d9c4690..980ee6e 100644
--- a/src/audio/audio_backend.h
+++ b/src/audio/audio_backend.h
@@ -3,6 +3,7 @@
// Enables testing without hardware by abstracting audio output.
#pragma once
+#include <cstdint>
// AudioBackend interface for audio output abstraction
// Production uses MiniaudioBackend, tests use MockAudioBackend
@@ -25,6 +26,11 @@ class AudioBackend {
// Note: This should measure peak at actual playback time, not pre-buffer time
virtual float get_realtime_peak() = 0;
+ // Get callback state for smooth time interpolation
+ // out_time: Timestamp of last audio callback (seconds)
+ // out_samples: Total samples read at last callback
+ virtual void get_callback_state(double* out_time, int64_t* out_samples) = 0;
+
#if !defined(STRIP_ALL)
// Hook called when a voice is triggered (test-only)
// timestamp: Time in seconds when voice was triggered
diff --git a/src/audio/backend/jittered_audio_backend.cc b/src/audio/backend/jittered_audio_backend.cc
index 0c1c4a6..973a474 100644
--- a/src/audio/backend/jittered_audio_backend.cc
+++ b/src/audio/backend/jittered_audio_backend.cc
@@ -115,4 +115,10 @@ float JitteredAudioBackend::get_realtime_peak() {
return 0.0f;
}
+void JitteredAudioBackend::get_callback_state(double* out_time,
+ int64_t* out_samples) {
+ *out_time = 0.0;
+ *out_samples = 0;
+}
+
#endif /* !defined(STRIP_ALL) */
diff --git a/src/audio/backend/jittered_audio_backend.h b/src/audio/backend/jittered_audio_backend.h
index 8bda4c7..13feee0 100644
--- a/src/audio/backend/jittered_audio_backend.h
+++ b/src/audio/backend/jittered_audio_backend.h
@@ -20,6 +20,7 @@ class JitteredAudioBackend : public AudioBackend {
void start() override;
void shutdown() override;
float get_realtime_peak() override;
+ void get_callback_state(double* out_time, int64_t* out_samples) override;
// Control simulation
void set_jitter_amount(float jitter_ms); // Random jitter in ms (default: 5ms)
diff --git a/src/audio/backend/miniaudio_backend.cc b/src/audio/backend/miniaudio_backend.cc
index ffa0852..26194c9 100644
--- a/src/audio/backend/miniaudio_backend.cc
+++ b/src/audio/backend/miniaudio_backend.cc
@@ -13,6 +13,10 @@
// Updated in audio_callback when samples are read from ring buffer
volatile float MiniaudioBackend::realtime_peak_ = 0.0f;
+// Smooth playback time interpolation
+volatile double MiniaudioBackend::last_callback_time_ = 0.0;
+volatile int64_t MiniaudioBackend::last_callback_samples_ = 0;
+
// Static callback for miniaudio (C API requirement)
void MiniaudioBackend::audio_callback(ma_device* pDevice, void* pOutput,
const void* pInput,
@@ -140,6 +144,12 @@ void MiniaudioBackend::audio_callback(ma_device* pDevice, void* pOutput,
const int actually_read = ring_buffer->read(fOutput, samples_to_read);
+ // Update smooth playback time tracking (absolute time, no epoch needed)
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ last_callback_time_ = ts.tv_sec + ts.tv_nsec / 1e9;
+ last_callback_samples_ = ring_buffer->get_total_read();
+
#if defined(DEBUG_LOG_RING_BUFFER)
if (actually_read < samples_to_read) {
DEBUG_RING_BUFFER(
@@ -150,22 +160,8 @@ void MiniaudioBackend::audio_callback(ma_device* pDevice, void* pOutput,
}
#endif /* defined(DEBUG_LOG_RING_BUFFER) */
- // Measure peak of samples being played RIGHT NOW (real-time sync)
- // This ensures visual effects trigger at the same moment audio is heard
- float frame_peak = 0.0f;
- for (int i = 0; i < actually_read; ++i) {
- frame_peak = fmaxf(frame_peak, fabsf(fOutput[i]));
- }
-
- // Exponential averaging: instant attack, fast decay
- // Decay rate of 0.5 gives ~500ms decay time for 120 BPM music
- // (At 128ms callbacks: 0.5^3.9 ≈ 0.07 after ~500ms = 1 beat)
- // TODO: Make decay rate configurable based on BPM from tracker/MainSequence
- if (frame_peak > realtime_peak_) {
- realtime_peak_ = frame_peak; // Attack: instant
- } else {
- realtime_peak_ *= 0.5f; // Decay: 50% per callback
- }
+ // Peak calculation moved to audio_get_realtime_peak() for RMS-based measurement
+ // (uses ring buffer peek for accurate time-windowed RMS)
}
#if defined(DEBUG_LOG_AUDIO)
@@ -285,3 +281,9 @@ void MiniaudioBackend::shutdown() {
float MiniaudioBackend::get_realtime_peak() {
return realtime_peak_;
}
+
+void MiniaudioBackend::get_callback_state(double* out_time,
+ int64_t* out_samples) {
+ *out_time = last_callback_time_;
+ *out_samples = last_callback_samples_;
+}
diff --git a/src/audio/backend/miniaudio_backend.h b/src/audio/backend/miniaudio_backend.h
index eb9019c..435496c 100644
--- a/src/audio/backend/miniaudio_backend.h
+++ b/src/audio/backend/miniaudio_backend.h
@@ -18,6 +18,7 @@ class MiniaudioBackend : public AudioBackend {
void start() override;
void shutdown() override;
float get_realtime_peak() override;
+ void get_callback_state(double* out_time, int64_t* out_samples) override;
// Get the underlying miniaudio device (for internal use)
ma_device* get_device() {
@@ -32,6 +33,10 @@ class MiniaudioBackend : public AudioBackend {
// Updated in audio_callback when samples are read from ring buffer
static volatile float realtime_peak_;
+ // Smooth playback time interpolation (updated in callback)
+ static volatile double last_callback_time_; // Absolute CLOCK_MONOTONIC time
+ static volatile int64_t last_callback_samples_;
+
// Static callback required by miniaudio C API
static void audio_callback(ma_device* pDevice, void* pOutput,
const void* pInput, ma_uint32 frameCount);
diff --git a/src/audio/backend/mock_audio_backend.cc b/src/audio/backend/mock_audio_backend.cc
index 068d8a3..60be429 100644
--- a/src/audio/backend/mock_audio_backend.cc
+++ b/src/audio/backend/mock_audio_backend.cc
@@ -30,6 +30,12 @@ float MockAudioBackend::get_realtime_peak() {
return 0.5f;
}
+void MockAudioBackend::get_callback_state(double* out_time,
+ int64_t* out_samples) {
+ *out_time = (double)current_time_sec_;
+ *out_samples = (int64_t)(current_time_sec_ * kSampleRate * 2);
+}
+
void MockAudioBackend::on_voice_triggered(float timestamp, int spectrogram_id,
float volume, float pan) {
// Record the event with the timestamp provided by synth
diff --git a/src/audio/backend/mock_audio_backend.h b/src/audio/backend/mock_audio_backend.h
index 55e89c5..e998909 100644
--- a/src/audio/backend/mock_audio_backend.h
+++ b/src/audio/backend/mock_audio_backend.h
@@ -29,6 +29,7 @@ class MockAudioBackend : public AudioBackend {
void start() override;
void shutdown() override;
float get_realtime_peak() override;
+ void get_callback_state(double* out_time, int64_t* out_samples) override;
// Event recording hooks
void on_voice_triggered(float timestamp, int spectrogram_id, float volume,
diff --git a/src/audio/backend/silent_backend.cc b/src/audio/backend/silent_backend.cc
index 6615eff..d6e1b19 100644
--- a/src/audio/backend/silent_backend.cc
+++ b/src/audio/backend/silent_backend.cc
@@ -32,6 +32,11 @@ float SilentBackend::get_realtime_peak() {
return test_peak_;
}
+void SilentBackend::get_callback_state(double* out_time, int64_t* out_samples) {
+ *out_time = 0.0;
+ *out_samples = 0;
+}
+
void SilentBackend::on_voice_triggered(float timestamp, int spectrogram_id,
float volume, float pan) {
// Track voice triggers for testing
diff --git a/src/audio/backend/silent_backend.h b/src/audio/backend/silent_backend.h
index 2d52858..c3942da 100644
--- a/src/audio/backend/silent_backend.h
+++ b/src/audio/backend/silent_backend.h
@@ -22,6 +22,7 @@ class SilentBackend : public AudioBackend {
void start() override;
void shutdown() override;
float get_realtime_peak() override;
+ void get_callback_state(double* out_time, int64_t* out_samples) override;
// Test inspection interface
bool is_initialized() const {
diff --git a/src/audio/backend/wav_dump_backend.cc b/src/audio/backend/wav_dump_backend.cc
index 7427fa9..67f639f 100644
--- a/src/audio/backend/wav_dump_backend.cc
+++ b/src/audio/backend/wav_dump_backend.cc
@@ -114,6 +114,12 @@ float WavDumpBackend::get_realtime_peak() {
return 0.0f;
}
+void WavDumpBackend::get_callback_state(double* out_time,
+ int64_t* out_samples) {
+ *out_time = 0.0;
+ *out_samples = 0;
+}
+
void WavDumpBackend::write_wav_header(FILE* file, uint32_t num_samples) {
// WAV file header structure
// Reference: http://soundfile.sapp.org/doc/WaveFormat/
diff --git a/src/audio/backend/wav_dump_backend.h b/src/audio/backend/wav_dump_backend.h
index de445d6..b59d406 100644
--- a/src/audio/backend/wav_dump_backend.h
+++ b/src/audio/backend/wav_dump_backend.h
@@ -23,6 +23,7 @@ class WavDumpBackend : public AudioBackend {
void start() override;
void shutdown() override;
float get_realtime_peak() override;
+ void get_callback_state(double* out_time, int64_t* out_samples) override;
// Set output filename (call before init())
void set_output_file(const char* filename);
diff --git a/src/audio/ring_buffer.cc b/src/audio/ring_buffer.cc
index 30566c9..54dd25b 100644
--- a/src/audio/ring_buffer.cc
+++ b/src/audio/ring_buffer.cc
@@ -5,6 +5,7 @@
#include "util/debug.h"
#include "util/fatal_error.h"
#include <algorithm>
+#include <cmath>
#include <cstring>
AudioRingBuffer::AudioRingBuffer()
@@ -178,3 +179,41 @@ void AudioRingBuffer::commit_write(int num_samples) {
std::memory_order_release);
total_written_.fetch_add(num_samples, std::memory_order_release);
}
+
+float AudioRingBuffer::get_past_rms(int window_samples) const {
+ const int avail = available_read();
+ const int to_read = std::min(window_samples, avail);
+
+ if (to_read <= 0) {
+ return 0.0f;
+ }
+
+ // Calculate start position (backward from read position)
+ int start = read_pos_.load(std::memory_order_acquire);
+ start = (start - to_read + capacity_) % capacity_;
+
+ // Calculate RMS directly from ring buffer
+ float sum_squares = 0.0f;
+ const int space_to_end = capacity_ - start;
+
+ if (to_read <= space_to_end) {
+ // One contiguous chunk
+ for (int i = 0; i < to_read; ++i) {
+ const float sample = buffer_[start + i];
+ sum_squares += sample * sample;
+ }
+ } else {
+ // Two chunks (wrap around)
+ for (int i = 0; i < space_to_end; ++i) {
+ const float sample = buffer_[start + i];
+ sum_squares += sample * sample;
+ }
+ const int remainder = to_read - space_to_end;
+ for (int i = 0; i < remainder; ++i) {
+ const float sample = buffer_[i];
+ sum_squares += sample * sample;
+ }
+ }
+
+ return sqrtf(sum_squares / (float)to_read);
+}
diff --git a/src/audio/ring_buffer.h b/src/audio/ring_buffer.h
index 524cb29..1a21542 100644
--- a/src/audio/ring_buffer.h
+++ b/src/audio/ring_buffer.h
@@ -60,6 +60,11 @@ class AudioRingBuffer {
// FATAL ERROR if num_samples exceeds region from get_write_region()
void commit_write(int num_samples);
+ // Get RMS of recent samples without consuming
+ // window_samples: Number of samples to include in RMS calculation
+ // Returns: RMS value (root mean square) over the window
+ float get_past_rms(int window_samples) const;
+
private:
float buffer_[RING_BUFFER_CAPACITY_SAMPLES];
int capacity_; // Total capacity in samples