Эх сурвалжийг харах

audio now captures on 44.1 kHz like the original wallpaper engine

UwU 4 өдөр өмнө
parent
commit
86d49f1054

+ 5 - 0
CMakeLists.txt

@@ -352,6 +352,10 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.cpp
     src/WallpaperEngine/Audio/SpectrumNormalizer.cpp
     src/WallpaperEngine/Audio/SpectrumNormalizer.h
+    src/WallpaperEngine/Audio/SpectrumAnalyzer.cpp
+    src/WallpaperEngine/Audio/SpectrumAnalyzer.h
+    src/WallpaperEngine/Audio/SpectrumProcessor.cpp
+    src/WallpaperEngine/Audio/SpectrumProcessor.h
     src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h
 
     src/WallpaperEngine/Audio/Drivers/Detectors/PulseAudioPlayingDetector.cpp
@@ -603,6 +607,7 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
         src/WallpaperEngine/Testing/Cases/SpectrumListeners.cpp
         src/WallpaperEngine/Testing/Cases/SpectrumNormalizer.cpp
+        src/WallpaperEngine/Testing/Cases/SpectrumPipeline.cpp
         src/WallpaperEngine/Testing/Cases/Localization.cpp
         src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
         src/WallpaperEngine/Testing/Cases/CornerColor.cpp

+ 6 - 4
docs/OS-Waves-rendering-issues.md

@@ -4,7 +4,7 @@ The earlier investigation of this wallpaper listed six differences from the nati
 Windows Wallpaper Engine. Script-driven user settings, text objects, the orthographic
 "auto" camera and `autosize` are implemented now, and skipping passthrough images
 without effects matches the real engine (`CImage.cpp`, `sub_140175830`). One item is
-still open.
+left to confirm on real hardware.
 
 ## Audio spectrum response may differ
 
@@ -12,9 +12,11 @@ The pulse effect on the three OS logos (objects 173, 57 and 61) uses:
 - `AUDIOPROCESSING=3`
 - `audioamount=1.0`, `audiobounds=0.5-1.0`, `audioexponent=0.35`
 
-Nobody has compared the logo pulse against Windows side by side, so whether our
-spectrum analysis (`Audio/SpectrumNormalizer`) drives these parameters the same way is
-unknown. Checking it needs the same audio playing on both engines at once.
+The spectrum these read now follows the real engine's pipeline, taken from `wallpaper64.exe`
+(`Audio/SpectrumAnalyzer` and `Audio/SpectrumProcessor`): same FFT size, band layout, per-group level
+tracking, smoothing and separate left/right channels. Still unverified: a side-by-side comparison with
+Windows playing the same audio, and whether WE's loopback capture and the Linux monitor source both see
+the signal before or after the system volume.
 
 ## Reproduction
 

+ 1 - 1
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -2193,7 +2193,7 @@ void WallpaperApplication::render () {
 	    g_Time += rawDelta * this->m_context.settings.render.playbackSpeed;
 	}
 	g_RealTime = fixedTimestep > 0.0f ? g_RealTime + fixedTimestep : rawTimeNow;
-	m_audioDriver->update ();
+	m_audioDriver->update (rawDelta * this->m_context.settings.render.playbackSpeed);
 	m_mediaSource->update ();
 	m_videoDriver->getInputContext ().update ();
 	m_videoDriver->dispatchEventQueue ();

+ 4 - 4
src/WallpaperEngine/Audio/Drivers/AudioDriver.cpp

@@ -6,12 +6,12 @@ AudioDriver::AudioDriver (
     Recorders::PlaybackRecorder& recorder
 ) : m_applicationContext (applicationContext), m_detector (detector), m_recorder (&recorder) {
     // perform a few update cycles to ensure data is ready before anything actually uses the audio
-    this->AudioDriver::update ();
-    this->AudioDriver::update ();
+    this->AudioDriver::update (0.0f);
+    this->AudioDriver::update (0.0f);
 }
 
-void AudioDriver::update () {
-    this->m_recorder->update ();
+void AudioDriver::update (float dt) {
+    this->m_recorder->update (dt);
     this->m_detector.update ();
 }
 

+ 3 - 1
src/WallpaperEngine/Audio/Drivers/AudioDriver.h

@@ -58,8 +58,10 @@ namespace Audio {
 
 	    /**
 	     * Updates status of the different audio settings
+	     *
+	     * @param dt Seconds since the previous frame, scaled by the playback speed
 	     */
-	    virtual void update ();
+	    virtual void update (float dt);
 
 	    /**
 	     * TODO: MAYBE THIS SHOULD BE OUR OWN DEFINITIONS INSTEAD OF LIBRARY SPECIFIC ONES?

+ 10 - 1
src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.cpp

@@ -1,9 +1,18 @@
 #include "PlaybackRecorder.h"
 
+#include <algorithm>
 #include <ranges>
 
 namespace WallpaperEngine::Audio::Drivers::Recorders {
-void PlaybackRecorder::update () { }
+void PlaybackRecorder::update (float dt) {
+    float captured[128];
+
+    this->lock ();
+    std::copy_n (this->m_captured, 128, captured);
+    this->unlock ();
+
+    this->m_processor.update (captured, dt);
+}
 
 int PlaybackRecorder::addSpectrumListener (SpectrumListener listener) {
     std::lock_guard guard (this->m_listenersMutex);

+ 19 - 5
src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h

@@ -4,17 +4,27 @@
 #include <map>
 #include <mutex>
 
+#include "WallpaperEngine/Audio/SpectrumProcessor.h"
+
 namespace WallpaperEngine::Audio::Drivers::Recorders {
 class PlaybackRecorder {
+    // declared first, audio16/32/64 below point into it
+    Audio::SpectrumProcessor m_processor;
+
 public:
     /** Called with the 64 band spectrum every time a fresh one has been computed, from the recorder's capture thread */
     using SpectrumListener = std::function<void (const float* audio64)>;
 
     virtual ~PlaybackRecorder () = default;
 
-    virtual void update ();
+    /**
+     * Runs once per rendered frame, turns the latest captured spectrum into audio16/32/64
+     *
+     * @param dt Seconds since the previous frame, scaled by the playback speed
+     */
+    virtual void update (float dt);
 
-    /** Guards audio16/32/64 for recorders that fill them from a background capture thread; no-op by default */
+    /** Guards the captured spectrum for recorders that fill it from a background capture thread; no-op by default */
     virtual void lock () const { }
     virtual void unlock () const { }
 
@@ -27,13 +37,17 @@ public:
     int addSpectrumListener (SpectrumListener listener);
     void removeSpectrumListener (int id);
 
-    float audio16[16] = { 0 };
-    float audio32[32] = { 0 };
-    float audio64[64] = { 0 };
+    // [left | right | average], only written from update() on the render thread
+    const float* audio16 = m_processor.audio16;
+    const float* audio32 = m_processor.audio32;
+    const float* audio64 = m_processor.audio64;
 
 protected:
     void notifySpectrumListeners (const float* audio64);
 
+    /** [left 64 | right 64] linear band levels from the capture thread, guarded by lock()/unlock() */
+    float m_captured[128] = { 0 };
+
 private:
     std::mutex m_listenersMutex;
     std::map<int, SpectrumListener> m_listeners;

+ 78 - 122
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -1,6 +1,7 @@
 #include "PulseAudioPlaybackRecorder.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include <pulse/rtclock.h>
+#include <algorithm>
 #include <chrono>
 #include <cmath>
 #include <cstring>
@@ -10,6 +11,10 @@
 
 namespace WallpaperEngine::Audio::Drivers::Recorders {
 namespace {
+constexpr int CAPTURE_RATE = 44100;
+constexpr int CAPTURE_CHANNELS = 2;
+constexpr auto CAPTURE_TIMEOUT = std::chrono::milliseconds (1000);
+
 // Timestamp helper backing the debug-only capture markers below - useful for tracking down
 // audio-to-visual delay regressions in the future.
 std::string wallClockTimestamp () {
@@ -86,9 +91,9 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 
     // Careful when to pa_stream_peek() and pa_stream_drop()!
     // c.f. https://www.freedesktop.org/software/pulseaudio/doxygen/stream_8h.html#ac2838c449cde56e169224d7fe3d00824
-    const uint8_t* data = nullptr;
+    const void* data = nullptr;
     size_t currentSize;
-    if (pa_stream_peek (stream, reinterpret_cast<const void**> (&data), &currentSize) != 0) {
+    if (pa_stream_peek (stream, &data, &currentSize) != 0) {
 	sLog.error ("Failed to peek at stream data...");
 	return;
     }
@@ -100,44 +105,20 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 
     if (data == nullptr && currentSize > 0) {
 	// Hole in the buffer. We must drop it.
+	recorder->owner->dropBlock ();
+
 	if (pa_stream_drop (stream) != 0) {
 	    sLog.error ("Failed to drop a hole while capturing!");
 	    return;
 	}
-    } else if (currentSize > 0 && data) {
-	const size_t dataToCopy = std::min (currentSize, WAVE_BUFFER_SIZE - recorder->currentWritePointer);
-
-	// depending on the amount of data available, we might want to read one or multiple frames
-	const size_t end = recorder->currentWritePointer + dataToCopy;
-
-	// this packet will fill the buffer, perform some extra checks for extra full buffers and get the latest one
-	if (end == WAVE_BUFFER_SIZE) {
-	    if (const size_t numberOfFullBuffers = (currentSize - dataToCopy) / WAVE_BUFFER_SIZE;
-		numberOfFullBuffers > 0) {
-		// calculate the start of the last block (we need the end of the previous block, hence the - 1)
-		const size_t startOfLastBuffer = std::max (
-		    dataToCopy + (numberOfFullBuffers - 1) * WAVE_BUFFER_SIZE, currentSize - WAVE_BUFFER_SIZE
-		);
-		memcpy (recorder->audioBuffer, &data[startOfLastBuffer], WAVE_BUFFER_SIZE * sizeof (uint8_t));
-		recorder->currentWritePointer = currentSize - startOfLastBuffer - WAVE_BUFFER_SIZE;
-		memcpy (
-		    recorder->audioBufferTmp, &data[startOfLastBuffer + WAVE_BUFFER_SIZE],
-		    recorder->currentWritePointer * sizeof (uint8_t)
-		);
-	    } else {
-		// okay, no full extra packets available, copy the rest of the data and flip the buffers
-		memcpy (&recorder->audioBufferTmp[recorder->currentWritePointer], data, dataToCopy * sizeof (uint8_t));
-		uint8_t* tmp = recorder->audioBuffer;
-		recorder->audioBuffer = recorder->audioBufferTmp;
-		recorder->audioBufferTmp = tmp;
-		recorder->currentWritePointer = 0;
-	    }
 
-	    recorder->fullFrameReady = true;
-	} else {
-	    memcpy (&recorder->audioBufferTmp[recorder->currentWritePointer], data, dataToCopy * sizeof (uint8_t));
-	    recorder->currentWritePointer += dataToCopy;
-	}
+	return;
+    }
+
+    if (currentSize > 0 && data) {
+	recorder->owner->consumeSamples (
+	    reinterpret_cast<const float*> (data), currentSize / (sizeof (float) * CAPTURE_CHANNELS)
+	);
     }
 
     if (pa_stream_drop (stream) != 0) {
@@ -173,9 +154,10 @@ void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userd
     recorder->monitorName = monitor_name;
 
     pa_sample_spec spec;
-    spec.format = PA_SAMPLE_U8;
-    spec.rate = 44100;
-    spec.channels = 1;
+    // WE's loopback capture is float stereo, 44.1kHz gives exactly its FFT size (it scales with the rate)
+    spec.format = PA_SAMPLE_FLOAT32NE;
+    spec.rate = CAPTURE_RATE;
+    spec.channels = CAPTURE_CHANNELS;
 
     recorder->captureStream = pa_stream_new (ctx, "output monitor", &spec, nullptr);
 
@@ -240,11 +222,9 @@ void pa_context_notify_cb (pa_context* ctx, void* userdata) {
 }
 
 PulseAudioPlaybackRecorder::PulseAudioPlaybackRecorder () :
-    m_captureData (
-	{ .kisscfg = kiss_fftr_alloc (WAVE_BUFFER_SIZE, 0, nullptr, nullptr),
-	  .audioBuffer = new uint8_t[WAVE_BUFFER_SIZE],
-	  .audioBufferTmp = new uint8_t[WAVE_BUFFER_SIZE] }
-    ) {
+    m_captureData ({ .owner = this, .captureStream = nullptr, .captureLost = false }),
+    m_analyzer (CAPTURE_RATE),
+    m_webFFT (kiss_fftr_alloc (WAVE_BUFFER_SIZE, 0, nullptr, nullptr)) {
     this->m_dataMutex = SDL_CreateMutex ();
     this->m_mainloop = pa_mainloop_new ();
     this->m_mainloopApi = pa_mainloop_get_api (this->m_mainloop);
@@ -279,7 +259,7 @@ PulseAudioPlaybackRecorder::PulseAudioPlaybackRecorder () :
 PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
     this->m_running.store (false, std::memory_order_relaxed);
     if (this->m_mainloop) {
-	// unblocks a pa_mainloop_iterate() the capture thread may be blocked in
+	// unblocks the pa_mainloop_poll() the capture thread may be waiting in
 	pa_mainloop_wakeup (this->m_mainloop);
     }
     if (this->m_captureThread) {
@@ -292,9 +272,7 @@ PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
 	pa_stream_unref (m_captureData.captureStream);
     }
 
-    delete[] this->m_captureData.audioBufferTmp;
-    delete[] this->m_captureData.audioBuffer;
-    free (this->m_captureData.kisscfg);
+    free (this->m_webFFT);
 
     pa_context_disconnect (this->m_context);
     pa_context_unref (this->m_context);
@@ -305,11 +283,6 @@ PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
     }
 }
 
-void PulseAudioPlaybackRecorder::update () {
-    // capture now runs on its own thread (see the constructor and captureLoop()) - nothing to do
-    // here anymore, kept as a no-op override since AudioDriver still calls this once per frame.
-}
-
 void PulseAudioPlaybackRecorder::lock () const { SDL_LockMutex (this->m_dataMutex); }
 void PulseAudioPlaybackRecorder::unlock () const { SDL_UnlockMutex (this->m_dataMutex); }
 
@@ -319,102 +292,85 @@ int PulseAudioPlaybackRecorder::captureThreadEntry (void* userdata) {
 }
 
 void PulseAudioPlaybackRecorder::captureLoop () {
+    bool cleared = false;
+
     while (this->m_running.load (std::memory_order_relaxed)) {
-	// blocks until there's data, a state change, or pa_mainloop_wakeup() from the destructor -
-	// this thread has nothing else to do, so there's no reason to poll instead of blocking
-	pa_mainloop_iterate (this->m_mainloop, 1, nullptr);
+	// time out so a stream that stopped delivering (suspended sink) is noticed
+	if (pa_mainloop_prepare (this->m_mainloop, 100 * 1000) < 0 || pa_mainloop_poll (this->m_mainloop) < 0
+	    || pa_mainloop_dispatch (this->m_mainloop) < 0) {
+	    break;
+	}
+
+	const bool stale = std::chrono::steady_clock::now () - this->m_lastSamples > CAPTURE_TIMEOUT;
 
-	if (!this->m_captureData.fullFrameReady) {
-	    continue;
+	if (stale && !cleared) {
+	    this->m_analyzer.reset ();
+	    this->clearCaptured ();
 	}
 
-	this->m_captureData.fullFrameReady = false;
-	this->processFrame ();
+	cleared = stale;
     }
 }
 
-void PulseAudioPlaybackRecorder::processFrame () {
-    // convert audio data to deltas so the fft library can properly handle it
-    for (int i = 0; i < WAVE_BUFFER_SIZE; i++) {
-	this->m_audioFFTbuffer[i] = (this->m_captureData.audioBuffer[i] - 128) / 128.0f;
+void PulseAudioPlaybackRecorder::clearCaptured () {
+    this->lock ();
+    std::fill_n (this->m_captured, 128, 0.0f);
+    this->unlock ();
+}
+
+void PulseAudioPlaybackRecorder::dropBlock () { this->m_analyzer.reset (); }
+
+void PulseAudioPlaybackRecorder::consumeSamples (const float* samples, std::size_t frames) {
+    this->m_lastSamples = std::chrono::steady_clock::now ();
+
+    float bands[SpectrumAnalyzer::BANDS * 2];
+
+    if (this->m_analyzer.feed (samples, frames, CAPTURE_CHANNELS, bands)) {
+	this->lock ();
+	std::copy_n (bands, 128, this->m_captured);
+	this->unlock ();
+    }
+
+    for (std::size_t i = 0; i < frames; i++) {
+	this->m_webSamples[this->m_webSampleCount++]
+	    = (samples[i * CAPTURE_CHANNELS] + samples[i * CAPTURE_CHANNELS + 1]) * 0.5f;
+
+	if (this->m_webSampleCount == WAVE_BUFFER_SIZE) {
+	    this->m_webSampleCount = 0;
+	    this->processWebFrame ();
+	}
     }
+}
 
-    kiss_fftr (this->m_captureData.kisscfg, this->m_audioFFTbuffer, this->m_FFTinfo);
+void PulseAudioPlaybackRecorder::processWebFrame () {
+    kiss_fftr (this->m_webFFT, this->m_webSamples, this->m_FFTinfo);
 
-    // computed into locals first so the lock only needs to be held for the final copy, not the
-    // whole FFT pass
     float bands64[64];
-    float bands32[32];
-    float bands16[16];
 
-    // one loop produces all 3 band resolutions
     for (int band = 0; band < 64; band++) {
-	int index = band * 2;
-	float f1 = this->m_FFTinfo[index].r;
-	float f2 = this->m_FFTinfo[index].i;
-	f2 = f1 * f1 + f2 * f2; // magnitude
-	f1 = 0.0f;
-
-	if (f2 > 0.0f) {
-	    // log10(magnitude) is unbounded and usually negative at ordinary listening volumes, but
-	    // scripts/shaders consuming this expect roughly a 0 (quiet) - 1 (loud) range; empirically
-	    // chosen from real capture logs, may need retuning for very quiet/loud setups.
-	    constexpr float kLoudnessOffset = 1.0f;
-	    f1 = 0.35f * log10 (f2) + kLoudnessOffset;
+	const int index = band * 2;
+	const float power = this->m_FFTinfo[index].r * this->m_FFTinfo[index].r
+	    + this->m_FFTinfo[index].i * this->m_FFTinfo[index].i;
+	float level = 0.0f;
+
+	if (power > 0.0f) {
+	    level = 0.35f * log10 (power) + 1.0f;
 	}
 
-	// Written directly (no smoothing here) - the wallpaper's own script already smooths this
-	// via its "smoothing" scriptproperty; an extra pass here would just double up on that.
-	bands64[band] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)));
-	bands32[band >> 1] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 31.0f) * 1.0f - 0.5f)));
-	bands16[band >> 2] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 15.0f) * 1.0f - 0.5f)));
+	bands64[band] = fmax (0.0f, level * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)));
     }
 
-    // The levels above are log scaled with no upper bound, and clamping them to 1 made every band of anything but
-    // quiet music sit at the top. Fit them to the loudest recent band instead, the same way for all resolutions
-    // so they stay consistent with each other.
     const auto now = std::chrono::steady_clock::now ();
-    const float dt = std::chrono::duration<float> (now - this->m_lastFrame).count ();
-    this->m_lastFrame = now;
+    const float dt = std::chrono::duration<float> (now - this->m_lastWebFrame).count ();
+    this->m_lastWebFrame = now;
 
     this->m_normalizer.update (bands64, 64, dt);
 
     for (float& band : bands64) {
 	band = this->m_normalizer.apply (band);
     }
-    for (float& band : bands32) {
-	band = this->m_normalizer.apply (band);
-    }
-    for (float& band : bands16) {
-	band = this->m_normalizer.apply (band);
-    }
-
-    this->lock ();
-    memcpy (this->audio64, bands64, sizeof (bands64));
-    memcpy (this->audio32, bands32, sizeof (bands32));
-    memcpy (this->audio16, bands16, sizeof (bands16));
-    this->unlock ();
 
     this->notifySpectrumListeners (bands64);
-
-    static int diagnosticCounter = 0;
-    if (++diagnosticCounter >= 100) {
-	diagnosticCounter = 0;
-	sLog.debug ("Audio processing: audio16[0..3] = ", bands16[0], ", ", bands16[1], ", ", bands16[2], ", ", bands16[3]);
-    }
-
-    // Edge-triggered marker for a loud transient (e.g. a clap) reaching the capture layer,
-    // timestamped to isolate whether a future audio-to-visual delay regression is in capture or
-    // downstream of it.
-    static bool wasLoud = false;
-    float peak = 0.0f;
-    for (float band : bands16) {
-	peak = fmax (peak, band);
-    }
-    if (peak > 0.5f && !wasLoud) {
-	sLog.debug ("[", wallClockTimestamp (), "] Audio processing: TRANSIENT detected, peak=", peak);
-    }
-    wasLoud = peak > 0.5f;
 }
 
 } // namespace WallpaperEngine::Audio::Drivers::Recorders

+ 18 - 11
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "PlaybackRecorder.h"
+#include "WallpaperEngine/Audio/SpectrumAnalyzer.h"
 #include "WallpaperEngine/Audio/SpectrumNormalizer.h"
 #include "kiss_fftr.h"
 #include <SDL.h>
@@ -20,11 +21,7 @@ public:
      * Struct that contains all the required data for the PulseAudio callbacks
      */
     struct PulseAudioData {
-	kiss_fftr_cfg kisscfg;
-	uint8_t* audioBuffer;
-	uint8_t* audioBufferTmp;
-	size_t currentWritePointer;
-	bool fullFrameReady;
+	PulseAudioPlaybackRecorder* owner;
 	pa_stream* captureStream;
 	std::string monitorName;
 	bool captureLost;
@@ -33,14 +30,18 @@ public:
     PulseAudioPlaybackRecorder ();
     ~PulseAudioPlaybackRecorder () override;
 
-    void update () override;
     void lock () const override;
     void unlock () const override;
 
+    void consumeSamples (const float* samples, std::size_t frames);
+    /** A gap in the capture, the block being collected is thrown away like WE does on a silent packet */
+    void dropBlock ();
+
 private:
     static int captureThreadEntry (void* userdata);
     void captureLoop ();
-    void processFrame ();
+    void processWebFrame ();
+    void clearCaptured ();
 
     pa_mainloop* m_mainloop;
     pa_mainloop_api* m_mainloopApi;
@@ -48,14 +49,20 @@ private:
     PulseAudioData m_captureData;
 
     // only ever touched from the capture thread
-    WallpaperEngine::Audio::SpectrumNormalizer m_normalizer;
-    std::chrono::steady_clock::time_point m_lastFrame = std::chrono::steady_clock::now ();
+    WallpaperEngine::Audio::SpectrumAnalyzer m_analyzer;
+    std::chrono::steady_clock::time_point m_lastSamples = std::chrono::steady_clock::now ();
 
-    float m_audioFFTbuffer[WAVE_BUFFER_SIZE] = { 0.0f };
+    // web wallpapers get their own spectrum through the listeners, WE computes that one in its web process with
+    // different rules, so it keeps the older mono FFT and normalizer
+    kiss_fftr_cfg m_webFFT;
+    WallpaperEngine::Audio::SpectrumNormalizer m_normalizer;
+    std::chrono::steady_clock::time_point m_lastWebFrame = std::chrono::steady_clock::now ();
+    float m_webSamples[WAVE_BUFFER_SIZE] = { 0.0f };
+    std::size_t m_webSampleCount = 0;
     kiss_fft_cpx m_FFTinfo[WAVE_BUFFER_SIZE / 2 + 1] = { { .r = 0.0f, .i = 0.0f } };
 
     // Capture runs on its own thread (see the constructor) so it keeps draining PulseAudio
-    // regardless of how long a render frame takes - see processFrame()'s comment for why.
+    // regardless of how long a render frame takes
     SDL_Thread* m_captureThread = nullptr;
     mutable SDL_mutex* m_dataMutex = nullptr;
     std::atomic<bool> m_running { true };

+ 110 - 0
src/WallpaperEngine/Audio/SpectrumAnalyzer.cpp

@@ -0,0 +1,110 @@
+#include "SpectrumAnalyzer.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstdlib>
+
+using namespace WallpaperEngine::Audio;
+
+namespace {
+// wallpaper64.exe's capture thread settings (processor constructor sub_1400A9130)
+constexpr float BAND_EXPONENT = 0.25f;
+constexpr float TILT = 0.5f;
+constexpr float SIZE_FACTOR = 30.0f;
+constexpr float BIN_FACTOR = 10.0f;
+// "audioinputvolume" (default 50) * 0.02
+constexpr float GAIN = 1.0f;
+} // namespace
+
+SpectrumAnalyzer::SpectrumAnalyzer (int sampleRate) {
+    const float rateScale = std::max (static_cast<float> (sampleRate) / 44100.0f, 1.0f);
+
+    this->m_size = static_cast<int> (rateScale * 64.0f * SIZE_FACTOR);
+    this->m_binCount = static_cast<int> (BIN_FACTOR * 64.0f);
+    this->m_blockSize = static_cast<int> (
+	static_cast<float> (this->m_size)
+	- (BIN_FACTOR / SIZE_FACTOR) * static_cast<float> (this->m_size)
+    );
+    this->m_config = kiss_fft_alloc (this->m_size, 0, nullptr, nullptr);
+
+    // samples are stored as s * 127 + 127 with 1 / that in the imaginary part, the part of the buffer past the
+    // block is never written and keeps the silence value
+    for (auto& input : this->m_input) {
+	input.assign (this->m_size, kiss_fft_cpx { 127.0f, 1.0f / 127.0f });
+    }
+
+    this->m_output.resize (this->m_size);
+}
+
+SpectrumAnalyzer::~SpectrumAnalyzer () { kiss_fft_free (this->m_config); }
+
+void SpectrumAnalyzer::reset () { this->m_filled = 0; }
+
+bool SpectrumAnalyzer::feed (const float* samples, std::size_t frames, int channels, float* bands) {
+    channels = std::clamp (channels, 1, 2);
+
+    const int end = static_cast<int> (std::min<std::size_t> (this->m_blockSize, this->m_filled + frames));
+
+    for (int i = this->m_filled; i < end; i++) {
+	const float* frame = samples + static_cast<std::size_t> (i - this->m_filled) * channels;
+
+	for (int c = 0; c < channels; c++) {
+	    const float value = frame[c] * 127.0f + 127.0f;
+
+	    this->m_input[c][i] = { value, 1.0f / value };
+	}
+    }
+
+    this->m_filled = end;
+
+    if (this->m_filled < this->m_blockSize) {
+	return false;
+    }
+
+    this->m_filled = 0;
+    this->analyze (channels, bands);
+
+    return true;
+}
+
+void SpectrumAnalyzer::analyze (int channels, float* bands) {
+    std::fill_n (bands, BANDS * 2, 0.0f);
+
+    const float lastBin = static_cast<float> (this->m_binCount - 1);
+
+    for (int c = 0; c < channels; c++) {
+	kiss_fft (this->m_config, this->m_input[c].data (), this->m_output.data ());
+
+	float* out = bands + c * BANDS;
+	int band = 0;
+
+	for (int bin = 1; bin < this->m_binCount; bin++) {
+	    const kiss_fft_cpx& value = this->m_output[bin];
+	    float power = value.r * value.r + value.i * value.i;
+
+	    if (!std::isfinite (power)) {
+		power = 0.0f;
+	    }
+
+	    const float x = static_cast<float> (bin - 1);
+	    const float weight = TILT - std::cos (x * 3.1415927f / lastBin) * (1.0f - TILT);
+	    const float magnitude = std::sqrt (weight * power);
+	    const int target = static_cast<int> (std::pow (x / lastBin, BAND_EXPONENT) * 64.0f) % 64;
+
+	    // the low bins would all land in the first few bands, each takes the next band until the curve catches up
+	    band = std::min (band + 1, target);
+	    out[band] = std::max (out[band], magnitude);
+	}
+    }
+
+    if (channels < 2) {
+	std::copy_n (bands, BANDS, bands + BANDS);
+    }
+
+    const float scale = GAIN * 0.001f
+	* (static_cast<float> (this->m_binCount) / (static_cast<float> (this->m_size) * 0.5f));
+
+    for (int i = 0; i < BANDS * 2; i++) {
+	bands[i] *= scale;
+    }
+}

+ 51 - 0
src/WallpaperEngine/Audio/SpectrumAnalyzer.h

@@ -0,0 +1,51 @@
+#pragma once
+
+#include <cstddef>
+#include <vector>
+
+#include "kiss_fft.h"
+
+namespace WallpaperEngine::Audio {
+/**
+ * Turns captured samples into the 64 band left/right spectrum the way wallpaper64.exe's capture thread does
+ * (sub_1400B2850). Samples are collected into blocks of two thirds of the FFT size, every block is transformed
+ * on its own (no overlap) and the bins are folded into 64 bands on a pow(x, 0.25) curve. The result is linear
+ * magnitude, SpectrumProcessor turns it into what shaders and scripts see.
+ */
+class SpectrumAnalyzer {
+public:
+    static constexpr int BANDS = 64;
+
+    explicit SpectrumAnalyzer (int sampleRate);
+    ~SpectrumAnalyzer ();
+
+    SpectrumAnalyzer (const SpectrumAnalyzer&) = delete;
+    SpectrumAnalyzer& operator= (const SpectrumAnalyzer&) = delete;
+
+    /**
+     * Feeds one packet of interleaved samples (1 or 2 channels, -1..1). WE takes a packet only up to the end of
+     * the block it is filling and drops the rest of it, so does this.
+     *
+     * @param bands Receives [left 64 | right 64] when a block completed
+     * @return If a block completed and bands was written
+     */
+    bool feed (const float* samples, std::size_t frames, int channels, float* bands);
+
+    /** Drops the partially filled block, WE does this on silent packets and capture errors */
+    void reset ();
+
+    [[nodiscard]] int getFFTSize () const { return this->m_size; }
+    [[nodiscard]] int getBlockSize () const { return this->m_blockSize; }
+
+private:
+    void analyze (int channels, float* bands);
+
+    int m_size;
+    int m_blockSize;
+    int m_binCount;
+    int m_filled = 0;
+    kiss_fft_cfg m_config;
+    std::vector<kiss_fft_cpx> m_input[2];
+    std::vector<kiss_fft_cpx> m_output;
+};
+} // namespace WallpaperEngine::Audio

+ 77 - 0
src/WallpaperEngine/Audio/SpectrumProcessor.cpp

@@ -0,0 +1,77 @@
+#include "SpectrumProcessor.h"
+
+#include <algorithm>
+#include <cmath>
+
+using namespace WallpaperEngine::Audio;
+
+namespace {
+constexpr float SILENCE = 0.0001f;
+} // namespace
+
+void SpectrumProcessor::update (const float* raw, float dt) {
+    dt = std::clamp (dt, SILENCE, 0.25f);
+
+    float groupPeak[GROUPS];
+    float peak = 0.0f;
+
+    for (int g = 0; g < GROUPS; g++) {
+	groupPeak[g] = *std::max_element (raw + g * 8, raw + g * 8 + 8);
+	peak = std::max (peak, groupPeak[g]);
+    }
+
+    // a quiet group is measured against a third of the loudest one, so it doesn't get stretched to full range
+    for (float& value : groupPeak) {
+	value = std::max (value, peak * 0.333f);
+    }
+
+    if (!(this->m_level[0] > SILENCE) && peak >= SILENCE) {
+	std::fill_n (this->m_level, GROUPS, 1.0f);
+    }
+
+    // the level walks towards the group's peak linearly, up at 1 per second and down at half that
+    const float step = std::min (dt, 1.0f);
+
+    for (int g = 0; g < GROUPS; g++) {
+	const float diff = groupPeak[g] - this->m_level[g];
+
+	if (std::fabs (diff) <= SILENCE) {
+	    this->m_level[g] = groupPeak[g];
+	} else {
+	    this->m_level[g] += std::min (step, std::fabs (diff)) * (diff > 0.0f ? 1.0f : -0.5f);
+	}
+    }
+
+    float* left = this->audio64;
+    float* right = this->audio64 + 64;
+    float* average = this->audio64 + 128;
+
+    if (peak >= SILENCE) {
+	const float follow = std::min (dt * 20.0f, 1.0f);
+	const float maxStep = std::min (dt * 40.0f, 1.0f);
+
+	for (int i = 0; i < 128; i++) {
+	    const float scaled = raw[i] / std::max (this->m_level[i / 8], 0.001f);
+
+	    this->m_smoothed[i] += (scaled - this->m_smoothed[i]) * follow;
+	    this->m_output[i] += std::clamp (this->m_smoothed[i] - this->m_output[i], -maxStep, maxStep);
+	}
+
+	std::copy_n (this->m_output, 128, this->audio64);
+    } else {
+	// silence drops straight to zero, the smoothing state is kept for when sound comes back
+	std::fill_n (this->audio64, 128, 0.0f);
+    }
+
+    for (int i = 0; i < 64; i++) {
+	average[i] = (left[i] + right[i]) * 0.5f;
+    }
+
+    for (int i = 0; i < 32 * 3; i++) {
+	this->audio32[i] = std::max (this->audio64[i * 2], this->audio64[i * 2 + 1]);
+    }
+
+    for (int i = 0; i < 16 * 3; i++) {
+	this->audio16[i] = std::max (this->audio32[i * 2], this->audio32[i * 2 + 1]);
+    }
+}

+ 30 - 0
src/WallpaperEngine/Audio/SpectrumProcessor.h

@@ -0,0 +1,30 @@
+#pragma once
+
+namespace WallpaperEngine::Audio {
+/**
+ * The per-frame half of wallpaper64.exe's audio pipeline (main loop sub_1400E7240): scales each group of 8 bands
+ * by a slowly tracking level of that group, smooths the result and limits how fast it may move, then builds the
+ * 64/32/16 band buffers shaders, scripts and particles read. Each buffer is [left | right | average].
+ */
+class SpectrumProcessor {
+public:
+    static constexpr int GROUPS = 16;
+
+    /**
+     * @param raw [left 64 | right 64] from SpectrumAnalyzer
+     * @param dt Seconds since the previous frame (already scaled by playback speed)
+     */
+    void update (const float* raw, float dt);
+
+    float audio16[16 * 3] = { 0 };
+    float audio32[32 * 3] = { 0 };
+    float audio64[64 * 3] = { 0 };
+
+    [[nodiscard]] const float* getGroupLevels () const { return this->m_level; }
+
+private:
+    float m_level[GROUPS] = { 0 };
+    float m_smoothed[128] = { 0 };
+    float m_output[128] = { 0 };
+};
+} // namespace WallpaperEngine::Audio

+ 11 - 4
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -405,7 +405,7 @@ void CParticle::setupEmitters () {
 float CParticle::sampleAudio (
     int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
 ) const {
-    // same curve as wallpaper64.exe. Modes 1/2/3 pick left/right/averaged channels there, the recorder is mono
+    // same curve as wallpaper64.exe: modes 1/2/3 read left, right or (left + right) / 2 of the 16 band buffer
     if (mode == 0) {
 	return 1.0f;
     }
@@ -420,11 +420,18 @@ float CParticle::sampleAudio (
     const auto& recorder = this->getScene ().getAudioContext ().getRecorder ();
     float peak = 0.0f;
 
-    recorder.lock ();
+    const float* left = recorder.audio16;
+    const float* right = recorder.audio16 + 16;
+
     for (int i = first; i <= last; i++) {
-	peak = std::max (peak, recorder.audio16[i]);
+	if (mode == 1) {
+	    peak = std::max (peak, left[i]);
+	} else if (mode == 2) {
+	    peak = std::max (peak, right[i]);
+	} else if (mode == 3) {
+	    peak = std::max (peak, (left[i] + right[i]) * 0.5f);
+	}
     }
-    recorder.unlock ();
 
     float t = (peak - bounds.x) / (bounds.y - bounds.x);
     // NaN from equal bounds ends up as 0 like the original

+ 3 - 3
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -1142,11 +1142,11 @@ void CPass::setupUniforms () {
     this->addUniform ("g_TexelSize", glm::vec2 (1.0 / scene.getWidth (), 1.0 / scene.getHeight ()));
     this->addUniform ("g_TexelSizeHalf", glm::vec2 (0.5 / scene.getWidth (), 0.5 / scene.getHeight ()));
     this->addUniform ("g_AudioSpectrum16Left", recorder.audio16, 16);
-    this->addUniform ("g_AudioSpectrum16Right", recorder.audio16, 16);
+    this->addUniform ("g_AudioSpectrum16Right", recorder.audio16 + 16, 16);
     this->addUniform ("g_AudioSpectrum32Left", recorder.audio32, 32);
-    this->addUniform ("g_AudioSpectrum32Right", recorder.audio32, 32);
+    this->addUniform ("g_AudioSpectrum32Right", recorder.audio32 + 32, 32);
     this->addUniform ("g_AudioSpectrum64Left", recorder.audio64, 64);
-    this->addUniform ("g_AudioSpectrum64Right", recorder.audio64, 64);
+    this->addUniform ("g_AudioSpectrum64Right", recorder.audio64 + 64, 64);
 }
 
 void CPass::addAttribute (const std::string& name, GLint type, GLint elements, const GLuint* value) {

+ 12 - 21
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -213,18 +213,18 @@ JSValue engine_set_interval (JSContext* ctx, JSValueConst this_val, int argc, JS
     return JS_NewCFunctionData (ctx, engine_stop_interval, 2, magic, 1, args);
 }
 
-// Backs the "average"/"left"/"right" getters on the object returned by registerAudioBuffers().
-// The recorder only ever produces one (mono) spectrum - see PulseAudioPlaybackRecorder - so all
-// three read the same data, matching how CPass already binds it to both the Left and Right
-// g_AudioSpectrum shader uniforms.
+// Backs the "left"/"right"/"average" getters on the object returned by registerAudioBuffers(). WE hands out
+// Float32Array views into its [left | right | average] buffer, these copy the same part on every read.
 JSValue audio_buffer_get_values (
     JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
 ) {
     int engineInstanceId = 0;
     int resolution = 32;
+    int section = 0;
 
     JS_ToInt32 (ctx, &engineInstanceId, func_data[0]);
     JS_ToInt32 (ctx, &resolution, func_data[1]);
+    JS_ToInt32 (ctx, &section, func_data[2]);
 
     JSValue result = JS_NewArray (ctx);
 
@@ -244,28 +244,18 @@ JSValue audio_buffer_get_values (
 	data = recorder.audio64;
     }
 
-    // audio16/32/64 are written from the recorder's own capture thread (see
-    // PulseAudioPlaybackRecorder), so reading them here (the script thread) needs the same lock.
-    recorder.lock ();
-
-    static int diagnosticCounter = 0;
-    if (++diagnosticCounter >= 500) {
-	diagnosticCounter = 0;
-	sLog.debug ("registerAudioBuffers: average[0..3] = ", data[0], ", ", data[1], ", ", data[2], ", ", data[3]);
-    }
+    data += section * resolution;
 
     for (int i = 0; i < resolution; i++) {
 	JS_SetPropertyUint32 (ctx, result, i, JS_NewFloat64 (ctx, data[i]));
     }
 
-    recorder.unlock ();
-
     return result;
 }
 
 // engine.registerAudioBuffers(resolution): resolution must be 16, 32 or 64 (falls back to 32
 // otherwise), matching the AUDIO_RESOLUTION_* constants below. Returns an object whose
-// average/left/right properties are re-read from the live FFT spectrum every access, so scripts
+// left/right/average properties are re-read from the live spectrum every access, so scripts
 // that poll them from an update() callback see current values each frame.
 JSValue engine_register_audio_buffers (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     int resolution = 32;
@@ -279,14 +269,15 @@ JSValue engine_register_audio_buffers (JSContext* ctx, JSValueConst this_val, in
     }
 
     JSValue result = JS_NewObject (ctx);
-    static constexpr const char* properties[] = { "average", "left", "right" };
+    // same order as the sections of the recorder's buffers
+    static constexpr const char* properties[] = { "left", "right", "average" };
 
-    for (const char* property : properties) {
-	JSValue closureData[] = { JS_NewInt32 (ctx, magic), JS_NewInt32 (ctx, resolution) };
+    for (int section = 0; section < 3; section++) {
+	JSValue closureData[] = { JS_NewInt32 (ctx, magic), JS_NewInt32 (ctx, resolution), JS_NewInt32 (ctx, section) };
 
 	JS_DefinePropertyGetSet (
-	    ctx, result, JS_NewAtom (ctx, property),
-	    JS_NewCFunctionData (ctx, audio_buffer_get_values, 0, 0, 2, closureData),
+	    ctx, result, JS_NewAtom (ctx, properties[section]),
+	    JS_NewCFunctionData (ctx, audio_buffer_get_values, 0, 0, 3, closureData),
 	    JS_NewCFunction (ctx, engine_set_value, "set", 1), JS_PROP_ENUMERABLE
 	);
     }

+ 1 - 1
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -1246,7 +1246,7 @@ void ScriptEngine::tick () {
 	    }
 
 	    // Edge-triggered marker for when a pulse lands visually, timestamped so it can be
-	    // correlated against the capture-layer TRANSIENT marker in PulseAudioPlaybackRecorder.
+	    // correlated against when the sound was played.
 	    static std::map<std::string, bool> wasPulsing;
 	    const bool pulsingNow = std::abs (module.value.getVec3 ().x - 1.0f) > 0.03f;
 	    if (pulsingNow && !wasPulsing[key]) {

+ 154 - 0
src/WallpaperEngine/Testing/Cases/SpectrumPipeline.cpp

@@ -0,0 +1,154 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include <algorithm>
+#include <cmath>
+#include <vector>
+
+#include "WallpaperEngine/Audio/SpectrumAnalyzer.h"
+#include "WallpaperEngine/Audio/SpectrumProcessor.h"
+
+using Catch::Approx;
+using WallpaperEngine::Audio::SpectrumAnalyzer;
+using WallpaperEngine::Audio::SpectrumProcessor;
+
+namespace {
+std::vector<float> stereoTone (int frames, float frequency, int rate, float leftAmplitude, float rightAmplitude) {
+    std::vector<float> samples (frames * 2);
+
+    for (int i = 0; i < frames; i++) {
+	const float value = std::sin (2.0f * 3.1415927f * frequency * static_cast<float> (i) / static_cast<float> (rate));
+
+	samples[i * 2] = value * leftAmplitude;
+	samples[i * 2 + 1] = value * rightAmplitude;
+    }
+
+    return samples;
+}
+} // namespace
+
+TEST_CASE ("Spectrum analyzer uses WE's FFT and block sizes") {
+    SpectrumAnalyzer at44 (44100);
+    SpectrumAnalyzer at48 (48000);
+
+    CHECK (at44.getFFTSize () == 1920);
+    CHECK (at44.getBlockSize () == 1280);
+    CHECK (at48.getFFTSize () == 2089);
+    CHECK (at48.getBlockSize () == 1392);
+}
+
+TEST_CASE ("A low tone lands in the band of its bin, one bin per band at the bottom") {
+    SpectrumAnalyzer analyzer (44100);
+    float bands[128];
+    // bin 10 of a 1920 point FFT at 44.1kHz, bins 1-30 get a band each (bin - 1)
+    const auto samples = stereoTone (1280, 10.0f * 44100.0f / 1920.0f, 44100, 0.5f, 0.0f);
+
+    REQUIRE (analyzer.feed (samples.data (), 1280, 2, bands));
+
+    const auto loudest = std::max_element (bands, bands + 64) - bands;
+
+    CHECK (loudest == 9);
+    CHECK (bands[loudest] > 0.0f);
+    CHECK (*std::max_element (bands + 64, bands + 128) < bands[loudest] * 0.001f);
+}
+
+TEST_CASE ("The packet completing a block is not carried into the next one") {
+    SpectrumAnalyzer analyzer (44100);
+    float bands[128];
+    const auto samples = stereoTone (2000, 440.0f, 44100, 0.5f, 0.5f);
+
+    CHECK (analyzer.feed (samples.data (), 2000, 2, bands));
+    CHECK_FALSE (analyzer.feed (samples.data (), 1279, 2, bands));
+    CHECK (analyzer.feed (samples.data (), 1, 2, bands));
+}
+
+TEST_CASE ("Mono input is copied to the right channel") {
+    SpectrumAnalyzer analyzer (44100);
+    float bands[128];
+    std::vector<float> samples (1280);
+
+    for (int i = 0; i < 1280; i++) {
+	samples[i] = 0.5f * std::sin (2.0f * 3.1415927f * 1000.0f * static_cast<float> (i) / 44100.0f);
+    }
+
+    REQUIRE (analyzer.feed (samples.data (), 1280, 1, bands));
+
+    for (int i = 0; i < 64; i++) {
+	CHECK (bands[i] == bands[i + 64]);
+    }
+}
+
+TEST_CASE ("Steady input settles at the level of its group") {
+    SpectrumProcessor processor;
+    float raw[128];
+
+    std::fill_n (raw, 64, 0.5f);
+    std::fill_n (raw + 64, 64, 0.25f);
+
+    for (int frame = 0; frame < 600; frame++) {
+	processor.update (raw, 1.0f / 60.0f);
+    }
+
+    CHECK (processor.getGroupLevels ()[0] == Approx (0.5f));
+    CHECK (processor.getGroupLevels ()[8] == Approx (0.25f));
+    CHECK (processor.audio64[0] == Approx (1.0f).margin (0.01));
+    CHECK (processor.audio64[64] == Approx (1.0f).margin (0.01));
+    CHECK (processor.audio64[128] == Approx (1.0f).margin (0.01));
+}
+
+TEST_CASE ("Buffers are left, right and average, lower resolutions take pairwise maxima") {
+    SpectrumProcessor processor;
+    float raw[128] = {};
+
+    for (int i = 0; i < 128; i++) {
+	raw[i] = 0.1f + 0.005f * static_cast<float> (i);
+    }
+
+    for (int frame = 0; frame < 120; frame++) {
+	processor.update (raw, 1.0f / 60.0f);
+    }
+
+    for (int i = 0; i < 64; i++) {
+	CHECK (processor.audio64[128 + i] == Approx ((processor.audio64[i] + processor.audio64[64 + i]) * 0.5f));
+    }
+
+    for (int i = 0; i < 96; i++) {
+	CHECK (processor.audio32[i] == std::max (processor.audio64[i * 2], processor.audio64[i * 2 + 1]));
+    }
+
+    for (int i = 0; i < 48; i++) {
+	CHECK (processor.audio16[i] == std::max (processor.audio32[i * 2], processor.audio32[i * 2 + 1]));
+    }
+}
+
+TEST_CASE ("Silence drops the spectrum to zero at once") {
+    SpectrumProcessor processor;
+    float raw[128];
+    const float silence[128] = {};
+
+    std::fill_n (raw, 128, 0.5f);
+
+    for (int frame = 0; frame < 120; frame++) {
+	processor.update (raw, 1.0f / 60.0f);
+    }
+
+    REQUIRE (processor.audio16[0] > 0.5f);
+
+    processor.update (silence, 1.0f / 60.0f);
+
+    CHECK (processor.audio16[0] == 0.0f);
+    CHECK (processor.audio64[127] == 0.0f);
+}
+
+TEST_CASE ("The first frame only moves part of the way") {
+    SpectrumProcessor processor;
+    float raw[128];
+
+    std::fill_n (raw, 128, 0.5f);
+    processor.update (raw, 1.0f / 60.0f);
+
+    // the level starts at 1 and has moved down by dt / 2, then 20 * dt of the way to raw / level
+    const float level = 1.0f - 0.5f / 60.0f;
+
+    CHECK (processor.audio64[0] == Approx (0.5f / level * (20.0f / 60.0f)));
+}