Skip to content

Commit b07fb2e

Browse files
committed
Add the Stereo Field Meter rack effect
- A QPainter goniometer drawn over marked mono, side and channel axes, with amplitude rings to read distance from the centre by - Correlation broadband and per band, so a mix that will not survive mono says where it is failing, plus mid and side levels and balance - Analysis is gated on the dialog, so a meter left in a rack is free
1 parent a24a28c commit b07fb2e

27 files changed

Lines changed: 1944 additions & 0 deletions

CHANGELOG

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ Release date:
55

66
New features:
77

8+
* Add the Stereo Field Meter rack effect
9+
- A goniometer drawn over marked mono, side and channel axes, with
10+
amplitude rings to read distance from the centre by
11+
- Correlation broadband and per band, so a mix that will not survive
12+
mono says where it is failing, plus mid and side levels and balance
13+
- Names the field Mono, Narrow, Medium, Wide, Very Wide or Phase Risk,
14+
latched so the word does not chatter on a boundary
15+
- Costs nothing while its dialog is closed
16+
817
* Add the Stereo Widener rack effect
918
- Widens or narrows the low, mid and high bands on their own terms, so the
1019
top can be opened up without the bottom smearing

src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ set(QML_SOURCE_FILES
116116
${QML_BASE_DIR}/Dialogs/PianoSynthV2Dialog_Global.qml
117117
${QML_BASE_DIR}/Dialogs/PianoSynthV2Dialog_Hammer.qml
118118
${QML_BASE_DIR}/Dialogs/PianoSynthV2Dialog_String.qml
119+
${QML_BASE_DIR}/Dialogs/StereoFieldMeterDialog.qml
119120
${QML_BASE_DIR}/Dialogs/StereoEnhancerDialog.qml
120121
${QML_BASE_DIR}/Dialogs/StereoExciterDialog.qml
121122
${QML_BASE_DIR}/Dialogs/StereoWidenerDialog.qml

src/application/application.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
#include "../view/controllers/wavetable_synth_controller.hpp"
5656
#include "../view/qml/Components/oscilloscope_renderer.hpp"
5757
#include "../view/qml/Dialogs/rta_renderer.hpp"
58+
#include "../view/qml/Dialogs/stereo_field_renderer.hpp"
5859
#include "../view/qml/Editor/line_number_renderer.hpp"
5960
#include "../view/qml/Editor/note_column_renderer.hpp"
6061
#include "models/audio_settings_model.hpp"
@@ -223,6 +224,7 @@ void Application::registerTypes()
223224
qmlRegisterType<NoteColumnLineContainerHelper>("Noteahead", majorVersion, minorVersion, "NoteColumnLineContainerHelper");
224225
qmlRegisterType<NoteColumnRenderer>("Noteahead", majorVersion, minorVersion, "NoteColumnRenderer");
225226
qmlRegisterType<RtaRenderer>("Noteahead", majorVersion, minorVersion, "RtaRenderer");
227+
qmlRegisterType<StereoFieldRenderer>("Noteahead", majorVersion, minorVersion, "StereoFieldRenderer");
226228
qmlRegisterType<OscilloscopeRenderer>("Noteahead", majorVersion, minorVersion, "OscilloscopeRenderer");
227229
qmlRegisterType<PitchBendAutomationsModel>("Noteahead", majorVersion, minorVersion, "PitchBendAutomationsModel");
228230
qmlRegisterType<PropertyService>("Noteahead", majorVersion, minorVersion, "PropertyService");

src/common/constants.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ QString stereoWidener()
256256
return "stereoWidener";
257257
}
258258

259+
QString stereoFieldMeter()
260+
{
261+
return "stereoFieldMeter";
262+
}
263+
259264
QString limiter()
260265
{
261266
return "limiter";
@@ -2357,6 +2362,16 @@ QString xmlKeySpeed()
23572362
return "speed";
23582363
}
23592364

2365+
QString xmlKeyZoom()
2366+
{
2367+
return "zoom";
2368+
}
2369+
2370+
QString xmlKeyShowGuides()
2371+
{
2372+
return "showGuides";
2373+
}
2374+
23602375
QString xmlKeyFftRate()
23612376
{
23622377
return "fftRate";

src/common/constants.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ QString waveDesigner();
174174
QString stereoEnhancer();
175175
QString stereoExciter();
176176
QString stereoWidener();
177+
QString stereoFieldMeter();
177178
QString limiter();
178179
QString endless();
179180
QString panner();
@@ -648,6 +649,8 @@ QString xmlKeyDbRange();
648649
QString xmlKeyShowPinkNoise();
649650
QString xmlKeyPinkNoiseLevel();
650651
QString xmlKeySpeed();
652+
QString xmlKeyZoom();
653+
QString xmlKeyShowGuides();
651654
QString xmlKeyFftRate();
652655

653656
QString embeddedDataPathPrefix();

src/domain/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ set(HEADER_FILES
120120
utility/loudness_analyzer.hpp
121121
utility/lufs_meter.hpp
122122
utility/rta.hpp
123+
utility/stereo_field_meter.hpp
123124
)
124125

125126
set(SOURCE_FILES
@@ -245,6 +246,7 @@ set(SOURCE_FILES
245246
utility/loudness_analyzer.cpp
246247
utility/lufs_meter.cpp
247248
utility/rta.cpp
249+
utility/stereo_field_meter.cpp
248250
)
249251

250252
add_library(${DomainLibName} STATIC ${HEADER_FILES} ${SOURCE_FILES})

src/domain/effects/effect_factory.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "../utility/dbtp_meter.hpp"
2020
#include "../utility/lufs_meter.hpp"
2121
#include "../utility/rta.hpp"
22+
#include "../utility/stereo_field_meter.hpp"
2223
#include "air_band_eq.hpp"
2324
#include "all_pass_filter.hpp"
2425
#include "auto_ducker.hpp"
@@ -116,6 +117,7 @@ void EffectFactory::init()
116117
registerEffect(Panner::typeIdString(), []() { return std::make_shared<Panner>(); });
117118
registerEffect(Phaser::typeIdString(), []() { return std::make_shared<Phaser>(); });
118119
registerEffect(Rta::typeIdString(), []() { return std::make_shared<Rta>(); });
120+
registerEffect(StereoFieldMeter::typeIdString(), []() { return std::make_shared<StereoFieldMeter>(); });
119121
registerEffect(Reverb::typeIdString(), []() { return std::make_shared<Reverb>(); });
120122
registerEffect(Saturator::typeIdString(), []() { return std::make_shared<Saturator>(); });
121123
registerEffect(TubeStage::typeIdString(), []() { return std::make_shared<TubeStage>(); });
@@ -139,6 +141,7 @@ void EffectFactory::init()
139141
registerEffect(Constants::RackEffectType::phaser().toStdString(), []() { return std::make_shared<Phaser>(); });
140142
registerEffect(Constants::RackEffectType::reverb().toStdString(), []() { return std::make_shared<Reverb>(); });
141143
registerEffect(Constants::RackEffectType::rta().toStdString(), []() { return std::make_shared<Rta>(); });
144+
registerEffect(Constants::RackEffectType::stereoFieldMeter().toStdString(), []() { return std::make_shared<StereoFieldMeter>(); });
142145
registerEffect(Constants::RackEffectType::saturator().toStdString(), []() { return std::make_shared<Saturator>(); });
143146
registerEffect(Constants::RackEffectType::vintagePassiveEq().toStdString(), []() { return std::make_shared<VintagePassiveEq>(); });
144147
registerEffect(Constants::RackEffectType::airBandEq().toStdString(), []() { return std::make_shared<AirBandEq>(); });
@@ -158,6 +161,7 @@ void EffectFactory::init()
158161
registerLegacyEffect("auto_filter", []() { return std::make_shared<AutoFilter>(); });
159162
registerLegacyEffect("multiband_compressor", []() { return std::make_shared<MultibandCompressor>(); });
160163
registerLegacyEffect("stereo_widener", []() { return std::make_shared<StereoWidener>(); });
164+
registerLegacyEffect("stereo_field_meter", []() { return std::make_shared<StereoFieldMeter>(); });
161165
registerLegacyEffect("multibandStereoWidth", []() { return std::make_shared<StereoWidener>(); });
162166
registerLegacyEffect("multiband_stereo_width", []() { return std::make_shared<StereoWidener>(); });
163167
registerLegacyEffect("bass_grinder", []() { return std::make_shared<BassGrinder>(); });
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// This file is part of Noteahead.
2+
// Copyright (C) 2026 Jussi Lind <jussi.lind@iki.fi>
3+
//
4+
// Noteahead is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
// Noteahead is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Noteahead. If not, see <http://www.gnu.org/licenses/>.
15+
16+
#include "stereo_field_meter.hpp"
17+
18+
#include "../../common/constants.hpp"
19+
#include "../../common/utils.hpp"
20+
#include "../dsp/audio_context.hpp"
21+
22+
#include <algorithm>
23+
#include <cmath>
24+
25+
namespace noteahead {
26+
27+
namespace {
28+
29+
//! Where the three measured bands are divided. Fixed rather than adjustable: these are the regions
30+
//! width behaves differently in, not a crossover anything is summed back across.
31+
constexpr double lowerCrossoverHz = 250.0;
32+
constexpr double upperCrossoverHz = 3000.0;
33+
34+
//! What the Speed control selects, in milliseconds. Fast follows a phrase, Slow follows a mix.
35+
constexpr std::array<double, 3> meterTimeConstantsMs { 80.0, 300.0, 1000.0 };
36+
37+
//! Anything quieter than this is silence as far as the readings are concerned.
38+
constexpr double silenceFloor = 1.0e-9;
39+
40+
//! Floor the level readings rest on, so a silent meter does not report minus infinity.
41+
constexpr float floorDb = -100.0f;
42+
43+
} // namespace
44+
45+
StereoFieldMeter::StereoFieldMeter()
46+
{
47+
addParameter(Parameter { Constants::NahdXml::xmlKeySpeed().toStdString(), 1.0f, 0, 2, 1, 1, Parameter::Type::Discrete });
48+
addParameter(Parameter { Constants::NahdXml::xmlKeyZoom().toStdString(), 0.5f, 0, 10000, 5000, 100, Parameter::Type::Continuous });
49+
addParameter(Parameter { Constants::NahdXml::xmlKeyShowGuides().toStdString(), 1.0f, 0, 1, 1, 1, Parameter::Type::Boolean });
50+
51+
syncParameters();
52+
}
53+
54+
void StereoFieldMeter::PairMeter::update(double a, double b, double coefficient)
55+
{
56+
productAb += (a * b - productAb) * coefficient;
57+
squareA += (a * a - squareA) * coefficient;
58+
squareB += (b * b - squareB) * coefficient;
59+
}
60+
61+
float StereoFieldMeter::PairMeter::correlation() const
62+
{
63+
const double energy = std::sqrt(squareA * squareB);
64+
65+
// Silence has no correlation to report. Reading it as centred is what keeps the meter still
66+
// between notes instead of wandering on the noise floor.
67+
if (energy < silenceFloor) {
68+
return 1.0f;
69+
}
70+
71+
return static_cast<float>(std::clamp(productAb / energy, -1.0, 1.0));
72+
}
73+
74+
double StereoFieldMeter::PairMeter::rmsA() const
75+
{
76+
return std::sqrt(std::max(squareA, 0.0));
77+
}
78+
79+
double StereoFieldMeter::PairMeter::rmsB() const
80+
{
81+
return std::sqrt(std::max(squareB, 0.0));
82+
}
83+
84+
void StereoFieldMeter::PairMeter::reset()
85+
{
86+
productAb = 0.0;
87+
squareA = 0.0;
88+
squareB = 0.0;
89+
}
90+
91+
void StereoFieldMeter::Splitter::setCutoffs(double lowerFrequency, double upperFrequency, double sampleRate)
92+
{
93+
lowSplit.setSampleRate(sampleRate);
94+
highSplit.setSampleRate(sampleRate);
95+
lowSplit.setCutoff(lowerFrequency);
96+
highSplit.setCutoff(upperFrequency);
97+
}
98+
99+
void StereoFieldMeter::Splitter::split(double input, std::array<double, NumBands> & bands)
100+
{
101+
double low = 0.0;
102+
double aboveLow = 0.0;
103+
lowSplit.process(input, low, aboveLow);
104+
105+
double mid = 0.0;
106+
double high = 0.0;
107+
highSplit.process(aboveLow, mid, high);
108+
109+
bands[0] = low;
110+
bands[1] = mid;
111+
bands[2] = high;
112+
}
113+
114+
void StereoFieldMeter::Splitter::reset()
115+
{
116+
lowSplit.reset();
117+
highSplit.reset();
118+
}
119+
120+
std::string StereoFieldMeter::typeIdString()
121+
{
122+
return "7e1c94b6-2fa8-4d53-9c6b-05e8d3417a92";
123+
}
124+
125+
std::string StereoFieldMeter::type() const
126+
{
127+
return Constants::RackEffectType::stereoFieldMeter().toStdString();
128+
}
129+
130+
std::string StereoFieldMeter::typeId() const
131+
{
132+
return typeIdString();
133+
}
134+
135+
void StereoFieldMeter::setAnalysisEnabled(bool enabled)
136+
{
137+
m_analysisEnabled.store(enabled, std::memory_order_relaxed);
138+
m_scope.setActive(enabled);
139+
}
140+
141+
void StereoFieldMeter::analyse(double left, double right)
142+
{
143+
m_broadband.update(left, right, m_meterCoefficient);
144+
145+
const double mid = (left + right) * 0.5;
146+
const double side = (left - right) * 0.5;
147+
m_midSide.update(mid, side, m_meterCoefficient);
148+
149+
std::array<double, NumBands> bandsLeft {};
150+
std::array<double, NumBands> bandsRight {};
151+
m_splitterL.split(left, bandsLeft);
152+
m_splitterR.split(right, bandsRight);
153+
154+
for (size_t i = 0; i < NumBands; i++) {
155+
m_bands[i].update(bandsLeft[i], bandsRight[i], m_meterCoefficient);
156+
}
157+
}
158+
159+
void StereoFieldMeter::processSample(double & left, double & right)
160+
{
161+
if (!m_analysisEnabled.load(std::memory_order_relaxed) || m_sampleRate <= 0) {
162+
return;
163+
}
164+
165+
updateState();
166+
analyse(left, right);
167+
}
168+
169+
void StereoFieldMeter::processBlock(AudioContext & context)
170+
{
171+
if (!m_analysisEnabled.load(std::memory_order_relaxed)) {
172+
return;
173+
}
174+
175+
if (context.sampleRate != m_lastSampleRate) {
176+
m_lastSampleRate = context.sampleRate;
177+
m_shouldSyncParameters = true;
178+
}
179+
if (m_shouldSyncParameters) {
180+
syncParameters();
181+
m_shouldSyncParameters = false;
182+
}
183+
184+
for (uint32_t i = 0; i < context.frameCount; i++) {
185+
analyse(context.buffer[i * 2], context.buffer[i * 2 + 1]);
186+
}
187+
188+
m_scope.write(context.buffer.data(), context.frameCount, context.sampleRate);
189+
190+
// Published once per block rather than per sample: the dialog reads it thirty times a second,
191+
// and taking the lock for every frame would be the only expensive thing this effect does.
192+
Reading reading;
193+
reading.correlation = m_broadband.correlation();
194+
for (size_t i = 0; i < NumBands; i++) {
195+
reading.bandCorrelation[i] = m_bands[i].correlation();
196+
}
197+
reading.midDb = std::max(Utils::Dsp::linearToDb(static_cast<float>(m_midSide.rmsA())), floorDb);
198+
reading.sideDb = std::max(Utils::Dsp::linearToDb(static_cast<float>(m_midSide.rmsB())), floorDb);
199+
200+
const double leftRms = m_broadband.rmsA();
201+
const double rightRms = m_broadband.rmsB();
202+
const double total = leftRms + rightRms;
203+
reading.balance = total > silenceFloor ? static_cast<float>((rightRms - leftRms) / total) : 0.0f;
204+
205+
{
206+
const std::lock_guard<std::mutex> lock { m_readingMutex };
207+
m_reading = reading;
208+
}
209+
}
210+
211+
StereoFieldMeter::Reading StereoFieldMeter::reading() const
212+
{
213+
const std::lock_guard<std::mutex> lock { m_readingMutex };
214+
return m_reading;
215+
}
216+
217+
AudioScope::Snapshot StereoFieldMeter::trace(size_t maxPoints) const
218+
{
219+
return m_scope.snapshot(maxPoints);
220+
}
221+
222+
void StereoFieldMeter::updateState()
223+
{
224+
if (static_cast<uint32_t>(m_sampleRate) != m_lastSampleRate || m_shouldSyncParameters) {
225+
syncParameters();
226+
m_lastSampleRate = static_cast<uint32_t>(m_sampleRate);
227+
m_shouldSyncParameters = false;
228+
}
229+
}
230+
231+
void StereoFieldMeter::syncParameters()
232+
{
233+
const double sampleRate = m_sampleRate > 0 ? m_sampleRate : (m_lastSampleRate > 0 ? m_lastSampleRate : 48000.0);
234+
235+
m_splitterL.setCutoffs(lowerCrossoverHz, upperCrossoverHz, sampleRate);
236+
m_splitterR.setCutoffs(lowerCrossoverHz, upperCrossoverHz, sampleRate);
237+
238+
size_t speedIndex = 1;
239+
if (const auto p = parameter(Constants::NahdXml::xmlKeySpeed().toStdString()); p) {
240+
speedIndex = std::min(static_cast<size_t>(std::lround(p->get().value())), meterTimeConstantsMs.size() - 1);
241+
}
242+
243+
m_meterCoefficient = 1.0 - std::exp(-1.0 / (meterTimeConstantsMs[speedIndex] * 0.001 * sampleRate));
244+
}
245+
246+
void StereoFieldMeter::reset()
247+
{
248+
m_broadband.reset();
249+
m_midSide.reset();
250+
for (auto && band : m_bands) {
251+
band.reset();
252+
}
253+
m_splitterL.reset();
254+
m_splitterR.reset();
255+
256+
const std::lock_guard<std::mutex> lock { m_readingMutex };
257+
m_reading = Reading {};
258+
}
259+
260+
void StereoFieldMeter::sync()
261+
{
262+
m_shouldSyncParameters = true;
263+
}
264+
265+
} // namespace noteahead

0 commit comments

Comments
 (0)