Skip to content

Commit 25d04ee

Browse files
committed
Cache the send effect snapshot in the audio callback
1 parent f335cff commit 25d04ee

9 files changed

Lines changed: 113 additions & 1 deletion

File tree

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ New features:
4040

4141
Bug fixes:
4242

43+
* Avoid copying the send effect list on every audio callback
44+
- AudioEngine caches a snapshot of the send effect rack and refreshes it only
45+
when the rack changes (tracked by a version counter), instead of copying the
46+
effect vector under a lock every buffer
47+
4348
* Fix per-buffer heap allocation in the Sampler audio path
4449
- processAudio() now reuses member scratch buffers (main mix and per-pad
4550
sub-mixes) instead of allocating on every audio callback

src/domain/effects/effect_rack.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ void EffectRack::setEffect(size_t index, EffectS effect)
4040
std::lock_guard<std::recursive_mutex> lock { m_mutex };
4141
if (index < m_effects.size()) {
4242
m_effects[index] = std::move(effect);
43+
markChanged();
4344
}
4445
}
4546

@@ -48,6 +49,7 @@ void EffectRack::swapEffects(size_t indexA, size_t indexB)
4849
std::lock_guard<std::recursive_mutex> lock { m_mutex };
4950
if (indexA < m_effects.size() && indexB < m_effects.size()) {
5051
std::swap(m_effects[indexA], m_effects[indexB]);
52+
markChanged();
5153
}
5254
}
5355

@@ -56,9 +58,20 @@ void EffectRack::removeEffect(size_t index)
5658
std::lock_guard<std::recursive_mutex> lock { m_mutex };
5759
if (index < m_effects.size()) {
5860
m_effects.erase(m_effects.begin() + index);
61+
markChanged();
5962
}
6063
}
6164

65+
void EffectRack::markChanged()
66+
{
67+
m_version.fetch_add(1, std::memory_order_release);
68+
}
69+
70+
uint64_t EffectRack::version() const
71+
{
72+
return m_version.load(std::memory_order_acquire);
73+
}
74+
6275
EffectRack::EffectS EffectRack::effect(size_t index) const
6376
{
6477
std::lock_guard<std::recursive_mutex> lock { m_mutex };
@@ -172,6 +185,7 @@ void EffectRack::clear()
172185
{
173186
std::lock_guard<std::recursive_mutex> lock { m_mutex };
174187
std::fill(m_effects.begin(), m_effects.end(), nullptr);
188+
markChanged();
175189
}
176190

177191
void EffectRack::serializeEffectsToXml(ProjectWriter & writer) const
@@ -195,6 +209,7 @@ void EffectRack::deserializeEffectsFromXml(ProjectReader & reader)
195209
{
196210
std::lock_guard<std::recursive_mutex> lock { m_mutex };
197211
std::fill(m_effects.begin(), m_effects.end(), nullptr);
212+
markChanged();
198213

199214
while (reader.readNextStartElement()) {
200215
if (reader.name() == Constants::NahdXml::xmlKeyEffect()) {
@@ -224,6 +239,7 @@ void EffectRack::deserializeEffect(ProjectReader & reader)
224239
m_effects.resize(targetIndex + 1, nullptr);
225240
}
226241
m_effects[targetIndex] = std::move(effect);
242+
markChanged();
227243
} else {
228244
reader.skipCurrentElement();
229245
}
@@ -280,6 +296,7 @@ bool EffectRack::importEffectSettings(size_t index, ProjectReader & reader)
280296
effect->deserializeParametersFromXml(reader);
281297
effect->sync();
282298
m_effects[index] = std::move(effect);
299+
markChanged();
283300
return true;
284301
} else {
285302
reader.skipCurrentElement();
@@ -297,6 +314,7 @@ bool EffectRack::importEffectSettings(size_t index, ProjectReader & reader)
297314
effect->deserializeParametersFromXml(reader);
298315
effect->sync();
299316
m_effects[index] = std::move(effect);
317+
markChanged();
300318
return true;
301319
} else {
302320
reader.skipCurrentElement();

src/domain/effects/effect_rack.hpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
#include "../dsp/audio_context.hpp"
2020
#include "effect.hpp"
21+
#include <atomic>
22+
#include <cstdint>
2123
#include <memory>
2224
#include <mutex>
2325
#include <vector>
@@ -43,6 +45,11 @@ class EffectRack
4345
size_t effectCount() const;
4446
bool hasEffects() const;
4547

48+
//! Monotonically increasing counter bumped whenever the effect list changes (effects added,
49+
//! removed, swapped, cleared or deserialized). Lets callers cache a snapshot of effects() and
50+
//! only refresh it when this changes, avoiding a per-audio-callback copy. Cheap atomic read.
51+
uint64_t version() const;
52+
4653
void process(AudioContext & outputContext, const double * sendBus, size_t effectIndex);
4754
void processInPlace(AudioContext & context);
4855
std::vector<size_t> sidechainDependencies() const;
@@ -61,7 +68,10 @@ class EffectRack
6168
bool importEffectSettings(size_t index, ProjectReader & reader);
6269

6370
private:
71+
void markChanged();
72+
6473
std::vector<EffectS> m_effects;
74+
std::atomic<uint64_t> m_version { 0 };
6575
mutable std::recursive_mutex m_mutex;
6676
};
6777

src/infra/audio/audio_engine.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,13 @@ void AudioEngine::process(AudioContext & context)
350350
if (!bufferSize) {
351351
return;
352352
}
353-
auto effects = m_sendEffectRack->effects();
353+
// Refresh the cached send-effects snapshot only when the rack actually changed, so the common
354+
// case avoids copying the vector (and bumping shared_ptr refcounts) under a lock every callback.
355+
if (const auto version = m_sendEffectRack->version(); version != m_sendEffectsVersion) {
356+
m_sendEffectsSnapshot = m_sendEffectRack->effects();
357+
m_sendEffectsVersion = version;
358+
}
359+
auto & effects = m_sendEffectsSnapshot;
354360
const size_t sendCount = effects.size();
355361
const size_t laneCount = m_workerPool->laneCount();
356362

src/infra/audio/audio_engine.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
#include "../../domain/devices/device.hpp"
2020

21+
#include <cstdint>
22+
#include <limits>
2123
#include <map>
2224
#include <memory>
2325
#include <mutex>
@@ -81,6 +83,11 @@ class AudioEngine
8183
std::map<size_t, DeviceS> m_devices;
8284
std::unique_ptr<EffectRack> m_sendEffectRack;
8385
std::unique_ptr<EffectRack> m_insertEffectRack;
86+
87+
// Cached snapshot of the send effect rack, refreshed only when the rack changes so that process()
88+
// does not copy the effect vector (and bump shared_ptr refcounts) on every audio callback.
89+
std::vector<std::shared_ptr<Effect>> m_sendEffectsSnapshot;
90+
uint64_t m_sendEffectsVersion = std::numeric_limits<uint64_t>::max();
8491
std::unique_ptr<RealTimeWorkerPool> m_workerPool;
8592
std::vector<AudioEngineWorkBuffer> m_workBuffers;
8693
std::vector<DeviceS> m_deviceSnapshot;

src/unit_tests/effect_rack_test/effect_rack_test.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,40 @@ void EffectRackTest::test_addRemove_shouldAddAndRemoveEffects()
5050
QCOMPARE(rack.effect(0), nullptr);
5151
}
5252

53+
void EffectRackTest::test_version_shouldChangeOnlyWhenEffectsChange()
54+
{
55+
EffectRack rack;
56+
57+
// Adding, removing, swapping and clearing effects must bump the version so cached snapshots of
58+
// effects() (e.g. in AudioEngine::process) get refreshed. Read-only access must not.
59+
const auto v0 = rack.version();
60+
61+
rack.setEffect(0, std::make_shared<Volume>());
62+
const auto v1 = rack.version();
63+
QVERIFY(v1 != v0);
64+
65+
// Read-only queries leave the version unchanged.
66+
(void)rack.effects();
67+
(void)rack.effect(0);
68+
(void)rack.effectCount();
69+
QCOMPARE(rack.version(), v1);
70+
71+
rack.setEffect(1, std::make_shared<Volume>());
72+
const auto v2 = rack.version();
73+
QVERIFY(v2 != v1);
74+
75+
rack.swapEffects(0, 1);
76+
const auto v3 = rack.version();
77+
QVERIFY(v3 != v2);
78+
79+
rack.removeEffect(0);
80+
const auto v4 = rack.version();
81+
QVERIFY(v4 != v3);
82+
83+
rack.clear();
84+
QVERIFY(rack.version() != v4);
85+
}
86+
5387
void EffectRackTest::test_process_shouldProcessAudio()
5488
{
5589
EffectRack rack;

src/unit_tests/effect_rack_test/effect_rack_test.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ private slots:
2828
void initTestCase();
2929
void cleanupTestCase();
3030
void test_addRemove_shouldAddAndRemoveEffects();
31+
void test_version_shouldChangeOnlyWhenEffectsChange();
3132
void test_process_shouldProcessAudio();
3233
void test_processInPlace_shouldApplyEffectToBuffer();
3334
void test_serialization_shouldSerializeAndDeserializeEffects();

src/unit_tests/side_chain_audio_test/side_chain_audio_test.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
#include "../../common/constants.hpp"
1919
#include "../../domain/devices/device.hpp"
20+
#include "../../domain/dsp/volume.hpp"
2021
#include "../../domain/effects/compressor.hpp"
2122
#include "../../infra/audio/audio_engine.hpp"
2223
#include "../../infra/xml/nahd_xml_reader.hpp"
@@ -426,6 +427,35 @@ void SideChainAudioTest::test_audioEngine_serialAndExclusive_shouldProduceIdenti
426427
QVERIFY(std::abs(serialOut[0] - 5.0) < 1e-9);
427428
}
428429

430+
void SideChainAudioTest::test_audioEngine_sendEffectAddedAfterProcess_shouldBeApplied()
431+
{
432+
// AudioEngine caches a snapshot of the send effect rack and only refreshes it when the rack's
433+
// version changes. A send effect added *after* the first process() must still be applied.
434+
AudioEngine engine;
435+
const auto device = std::make_shared<MockDevice>("Source");
436+
device->setGenerateSignal(true); // Emits 1.0
437+
device->setReverbSend(0, 1.0f); // Route fully to send bus 0
438+
engine.setDevice(0, device);
439+
440+
std::vector<double> buffer(128, 0.0);
441+
AudioContext context { std::span(buffer.data(), 128), 64, 44100 };
442+
443+
// No send effect yet: output is just the device's dry signal.
444+
engine.process(context);
445+
QVERIFY(std::abs(buffer[0] - 1.0) < 1e-9);
446+
447+
// Add a send effect after the first process(); the cached snapshot must refresh.
448+
const auto volume = std::make_shared<Volume>();
449+
volume->setVolume(0.5f);
450+
engine.sendEffectRack().setEffect(0, volume);
451+
452+
std::fill(buffer.begin(), buffer.end(), 0.0);
453+
engine.process(context);
454+
455+
// Output = dry device (1.0) + wet send (0.5 * 1.0 - 1.0 = -0.5) = 0.5.
456+
QVERIFY(std::abs(buffer[0] - 0.5) < 1e-6);
457+
}
458+
429459
} // namespace noteahead
430460

431461
QTEST_GUILESS_MAIN(noteahead::SideChainAudioTest)

src/unit_tests/side_chain_audio_test/side_chain_audio_test.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ private slots:
3030
void test_audioEngine_process_runtimeSidechainChange_shouldRebuildGraph();
3131
void test_audioEngine_rebuildProcessingGraph_shouldHandleCircularDependencyGracefully();
3232
void test_audioEngine_serialAndExclusive_shouldProduceIdenticalOutput();
33+
void test_audioEngine_sendEffectAddedAfterProcess_shouldBeApplied();
3334
void test_compressorEffect_process_shouldApplySidechainGainReduction();
3435
void test_compressorEffect_sideChainLpf_bypass_shouldPreserveGainReduction();
3536
void test_compressorEffect_sideChainLpf_lowCutoff_shouldAttenuateAcDetectorSignal();

0 commit comments

Comments
 (0)