Skip to content

Commit 20ed11b

Browse files
latency
1 parent 2b30b37 commit 20ed11b

5 files changed

Lines changed: 182 additions & 9 deletions

File tree

source/CodecControllers/OpusController.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ bool OpusController::init (int sampleRate, int, int)
9595
return true;
9696
}
9797

98+
int OpusController::computeLatencySamples(double sampleRate)
99+
{
100+
auto validatedSamplerate = getClosest(static_cast<int>(std::lround(sampleRate)), allowed_samplerates);
101+
return validatedSamplerate * 20 / 1000;
102+
}
103+
98104
void OpusController::deInit()
99105
{
100106
if (opusEncoder != nullptr) {

source/CodecControllers/OpusController.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ class OpusController : public CodecController, public juce::AudioProcessorValueT
3333
void processBlock(juce::AudioBuffer<float>& buffer);
3434

3535
int getBitrate() override { return bitrate; }
36+
37+
// The number of host samples of buffering delay Opus encoding/decoding introduces at
38+
// sampleRate: one full 20ms frame, at whichever Opus-native rate sampleRate snaps to (see
39+
// validate_samplerate()). Usable without an initialized instance, so PluginProcessor can
40+
// call it to report latency to the host before/without an OpusController existing.
41+
static int computeLatencySamples(double sampleRate);
42+
3643
void setError(float) override {}
3744
void setButterflyBends(float, float, float, float) override {}
3845
void setMDCTbandstepBends(bool, int) override {}
@@ -111,7 +118,7 @@ class OpusController : public CodecController, public juce::AudioProcessorValueT
111118
};
112119
float turbo{};
113120

114-
const std::vector<int> allowed_samplerates {
121+
static inline const std::vector<int> allowed_samplerates {
115122
8000, 12000, 16000, 24000, 48000
116123
};
117124

source/PluginProcessor.cpp

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ void MaimAudioProcessor::prepareToPlay (double fs, int samplesPerBlock)
264264
{
265265
PresetManager::createPresetDirectories();
266266

267-
setLatencySamples(currentLatencySamples());
267+
setLatencySamples(currentLatencySamples(fs));
268268
dryWetMixer.prepare({fs, static_cast<uint32_t>(samplesPerBlock), 2});
269269
sampleRate = fs;
270270
estimatedSamplesPerBlock = samplesPerBlock;
@@ -334,7 +334,7 @@ void MaimAudioProcessor::updateParameters()
334334
postGain = juce::Decibels::decibelsToGain(makeupDB);
335335

336336
parametersNeedUpdating = false;
337-
setLatencySamples(currentLatencySamples());
337+
setLatencySamples(currentLatencySamples(sampleRate));
338338
}
339339

340340
void MaimAudioProcessor::processBlockStereo (juce::AudioBuffer<float>& buffer)
@@ -430,29 +430,33 @@ void MaimAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
430430
}
431431
}
432432
}
433-
int MaimAudioProcessor::currentLatencySamples()
433+
int MaimAudioProcessor::currentLatencySamples(double hostSampleRate)
434434
{
435-
/* Currently, the number of samples of latency is determined experimentally,
435+
/* For Blade and Lame, the number of samples of latency is determined experimentally,
436436
* using pluginval and an impulse response testing program. These experiments showed that
437437
* the impulse response held consistent across samplerates, bitrates, and block sizes; however,
438438
* Blade had an impulse response of silence at low bitrates, so some other response may be
439439
* needed for a more reliable test. I would like to try to get these numbers lower, which
440440
* I suspect may be possible, and also to have an actual proof of the latency, rather than
441441
* determining it experimentally.
442+
*
443+
* Opus's latency is instead computed directly: OpusController buffers a full 20ms frame
444+
* of host samples before it can encode/decode it (see OpusController::processBlock), so
445+
* that framing delay - at whichever Opus-native sample rate hostSampleRate snaps to -
446+
* is the dominant, exactly-known component of its latency.
442447
*/
443448
auto encoder = (Encoder)((juce::AudioParameterChoice*)
444449
parameters.getParameter(ENCODER_PARAM_ID))->getIndex();
445450
int computedLatencySamples;
446451
if (encoder == 0) {
447452
// Blade
448453
computedLatencySamples = BLADELATENCYSAMPLES;
449-
}
450-
if (encoder == 1) {
454+
} else if (encoder == 1) {
451455
// lame
452456
computedLatencySamples = LAMELATENCYSAMPLES;
453457
} else {
454458
// opus
455-
computedLatencySamples = 0; // TEST
459+
computedLatencySamples = OpusController::computeLatencySamples(hostSampleRate);
456460
}
457461
dryWetMixer.setWetLatency(computedLatencySamples);
458462

source/PluginProcessor.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class MaimAudioProcessor : public juce::AudioProcessor,
7777
void processBlockStereo(juce::AudioBuffer<float>& buffer);
7878
void addPsychoanalStateToParameters();
7979
void addMdctSamplesToParameters();
80-
int currentLatencySamples();
80+
int currentLatencySamples(double hostSampleRate);
8181
float preGain;
8282
float postGain;
8383

tests/OpusLatency.cpp

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#include <PluginProcessor.h>
2+
#include <catch2/catch_test_macros.hpp>
3+
4+
#include "CodecControllers/OpusController.h"
5+
#include "parameterIds.h"
6+
7+
#include <algorithm>
8+
#include <cmath>
9+
#include <vector>
10+
11+
// OpusController buffers one full 20ms frame of host samples before it can encode/decode it
12+
// (see OpusController::processBlock), so that framing delay is the dominant, exactly-known
13+
// component of its reported latency. These tests cover both the pure frame-size math and the
14+
// end-to-end value MaimAudioProcessor reports to the host via getLatencySamples().
15+
16+
TEST_CASE ("opus latency is one 20ms frame at the nearest Opus-native rate", "[opuslatency]")
17+
{
18+
// Exact matches to Opus's five native rates: 20ms of that exact rate.
19+
REQUIRE (OpusController::computeLatencySamples (8000.0) == 160);
20+
REQUIRE (OpusController::computeLatencySamples (12000.0) == 240);
21+
REQUIRE (OpusController::computeLatencySamples (16000.0) == 320);
22+
REQUIRE (OpusController::computeLatencySamples (24000.0) == 480);
23+
REQUIRE (OpusController::computeLatencySamples (48000.0) == 960);
24+
25+
// 44.1kHz isn't an Opus-native rate, so it should snap to the nearest one (48000) before
26+
// computing the frame size - matching what OpusController::validate_samplerate() does.
27+
REQUIRE (OpusController::computeLatencySamples (44100.0) == 960);
28+
}
29+
30+
TEST_CASE ("plugin reports opus's real latency instead of a hardcoded zero", "[opuslatency]")
31+
{
32+
auto gui = juce::ScopedJuceInitialiser_GUI {};
33+
34+
MaimAudioProcessor testPlugin;
35+
auto& apvts = testPlugin.getValueTreeState();
36+
*((juce::AudioParameterChoice*) apvts.getParameter (ENCODER_PARAM_ID)) = 2; // opus
37+
38+
testPlugin.prepareToPlay (44100.0, 512);
39+
40+
REQUIRE (testPlugin.getLatencySamples() == OpusController::computeLatencySamples (44100.0));
41+
REQUIRE (testPlugin.getLatencySamples() > 0);
42+
}
43+
44+
TEST_CASE ("plugin still reports blade/lame's latency correctly", "[opuslatency]")
45+
{
46+
// Regression test: currentLatencySamples() used to have an if/if-else chain where the
47+
// second branch's else clause (meant only for opus) unconditionally overwrote whatever
48+
// the first branch had just set, so blade always reported 0 too. Guards against that.
49+
auto gui = juce::ScopedJuceInitialiser_GUI {};
50+
51+
MaimAudioProcessor bladePlugin;
52+
auto& bladeApvts = bladePlugin.getValueTreeState();
53+
*((juce::AudioParameterChoice*) bladeApvts.getParameter (ENCODER_PARAM_ID)) = 0; // blade
54+
bladePlugin.prepareToPlay (44100.0, 512);
55+
REQUIRE (bladePlugin.getLatencySamples() == 2209);
56+
57+
MaimAudioProcessor lamePlugin;
58+
auto& lameApvts = lamePlugin.getValueTreeState();
59+
*((juce::AudioParameterChoice*) lameApvts.getParameter (ENCODER_PARAM_ID)) = 1; // lame
60+
lamePlugin.prepareToPlay (44100.0, 512);
61+
REQUIRE (lamePlugin.getLatencySamples() == 2880);
62+
}
63+
64+
namespace {
65+
66+
void processSilence (MaimAudioProcessor& plugin, int numSamples, int blockSize)
67+
{
68+
int remaining = numSamples;
69+
while (remaining > 0) {
70+
int thisBlock = std::min (blockSize, remaining);
71+
juce::AudioBuffer<float> buffer (2, thisBlock);
72+
buffer.clear();
73+
auto midi = juce::MidiBuffer();
74+
plugin.processBlock (buffer, midi);
75+
remaining -= thisBlock;
76+
}
77+
}
78+
79+
} // namespace
80+
81+
TEST_CASE ("measured latency roughly matches reported latency across encoders and samplerates", "[opuslatency]")
82+
{
83+
auto gui = juce::ScopedJuceInitialiser_GUI {};
84+
85+
constexpr int blockSize = 512;
86+
constexpr int flushSamples = 5000; // a few thousand samples to let an encoder switch settle
87+
constexpr int silenceLeadIn = 2000;
88+
constexpr int burstLength = 200;
89+
constexpr int silenceTrailOut = 5000;
90+
constexpr int totalSamples = silenceLeadIn + burstLength + silenceTrailOut;
91+
constexpr float burstFrequency = 1000.0f;
92+
constexpr float burstAmplitude = 0.8f;
93+
constexpr float onsetThreshold = 0.05f;
94+
95+
const std::vector<double> sampleRates {44100.0, 48000.0, 96000.0};
96+
const std::vector<int> encoders {0, 1, 2}; // blade, lame, opus
97+
98+
MaimAudioProcessor testPlugin;
99+
auto& apvts = testPlugin.getValueTreeState();
100+
101+
for (double sampleRate : sampleRates) {
102+
testPlugin.prepareToPlay (sampleRate, blockSize);
103+
104+
std::vector<float> inputSignal (static_cast<size_t> (totalSamples), 0.0f);
105+
for (int i = 0; i < burstLength; ++i) {
106+
inputSignal[static_cast<size_t> (silenceLeadIn + i)] =
107+
burstAmplitude * std::sin (2.0f * juce::MathConstants<float>::pi * burstFrequency
108+
* static_cast<float> (i) / static_cast<float> (sampleRate));
109+
}
110+
111+
for (int encoder : encoders) {
112+
// Switching to a new encoder (mid-stream, on the same live plugin instance) can
113+
// trigger an internal crossfade/settle period - flush it out before measuring.
114+
*((juce::AudioParameterChoice*) apvts.getParameter (ENCODER_PARAM_ID)) = encoder;
115+
processSilence (testPlugin, flushSamples, blockSize);
116+
117+
auto reportedLatency = testPlugin.getLatencySamples();
118+
REQUIRE (reportedLatency > 0);
119+
120+
std::vector<float> outputSignal;
121+
outputSignal.reserve (static_cast<size_t> (totalSamples));
122+
123+
int sampleIndex = 0;
124+
while (sampleIndex < totalSamples) {
125+
int thisBlock = std::min (blockSize, totalSamples - sampleIndex);
126+
juce::AudioBuffer<float> buffer (2, thisBlock);
127+
for (int s = 0; s < thisBlock; ++s) {
128+
float sample = inputSignal[static_cast<size_t> (sampleIndex + s)];
129+
buffer.setSample (0, s, sample);
130+
buffer.setSample (1, s, sample);
131+
}
132+
auto midi = juce::MidiBuffer();
133+
testPlugin.processBlock (buffer, midi);
134+
for (int s = 0; s < thisBlock; ++s) {
135+
outputSignal.push_back (buffer.getSample (0, s));
136+
}
137+
sampleIndex += thisBlock;
138+
}
139+
140+
// Measured latency = where the burst's onset actually shows up in the output, minus
141+
// where it started in the input.
142+
int measuredOnset = -1;
143+
for (int i = 0; i < totalSamples; ++i) {
144+
if (std::abs (outputSignal[static_cast<size_t> (i)]) > onsetThreshold) {
145+
measuredOnset = i;
146+
break;
147+
}
148+
}
149+
REQUIRE (measuredOnset >= 0);
150+
151+
int measuredLatency = measuredOnset - silenceLeadIn;
152+
int tolerance = std::max (300, static_cast<int> (reportedLatency * 0.5));
153+
REQUIRE (std::abs (measuredLatency - reportedLatency) <= tolerance);
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)