Skip to content

Commit 67fbbfc

Browse files
authored
Merge pull request #16066 from Swarnadip-Kar/key-comparison-effect
Add KeyComparisonEffect builtin effect
2 parents 9fea37e + 941a9b6 commit 67fbbfc

6 files changed

Lines changed: 530 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,8 @@ add_library(
12651265
src/effects/backends/builtin/loudnesscontoureffect.cpp
12661266
src/effects/backends/builtin/metronomeeffect.cpp
12671267
src/effects/backends/builtin/metronomeclick.cpp
1268+
src/effects/backends/builtin/keycomparisoneffect.cpp
1269+
src/effects/backends/builtin/pianosample.cpp
12681270
src/effects/backends/builtin/moogladder4filtereffect.cpp
12691271
src/effects/backends/builtin/compressoreffect.cpp
12701272
src/effects/backends/builtin/autogaincontroleffect.cpp

src/effects/backends/builtin/builtinbackend.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "effects/backends/builtin/distortioneffect.h"
2323
#include "effects/backends/builtin/echoeffect.h"
2424
#include "effects/backends/builtin/glitcheffect.h"
25+
#include "effects/backends/builtin/keycomparisoneffect.h"
2526
#include "effects/backends/builtin/loudnesscontoureffect.h"
2627
#include "effects/backends/builtin/metronomeeffect.h"
2728
#include "effects/backends/builtin/phasereffect.h"
@@ -58,6 +59,7 @@ BuiltInBackend::BuiltInBackend() {
5859
#endif
5960
registerEffect<PhaserEffect>();
6061
registerEffect<MetronomeEffect>();
62+
registerEffect<KeyComparisonEffect>();
6163
registerEffect<TremoloEffect>();
6264
#ifdef __RUBBERBAND__
6365
registerEffect<PitchShiftEffect>();
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
#include "effects/backends/builtin/keycomparisoneffect.h"
2+
3+
#include <algorithm>
4+
#include <array>
5+
#include <cmath>
6+
#include <cstddef>
7+
#include <optional>
8+
#include <span>
9+
10+
#include "audio/types.h"
11+
#include "effects/backends/effectmanifest.h"
12+
#include "effects/backends/effectmanifestparameter.h"
13+
#include "engine/effects/engineeffectparameter.h"
14+
#include "engine/engine.h"
15+
#include "util/math.h"
16+
#include "util/sample.h"
17+
#include "util/types.h"
18+
19+
namespace {
20+
21+
// Semitone offsets from A4 (440 Hz) for each chromatic key.
22+
// Index 9 (A) has offset 0 — the sample is synthesised at A4.
23+
// Index: 0=C 1=C# 2=D 3=D# 4=E 5=F 6=F# 7=G 8=G# 9=A 10=A# 11=B 12=C
24+
constexpr std::array<int, 13> kKeySemitoneOffset = {
25+
-9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3};
26+
27+
// Resamples monoSource into monoDest using linear interpolation at pitchRatio.
28+
// Returns the number of source frames consumed so the caller can resume
29+
// from the correct position in the next buffer.
30+
// Kept as a separate loop so the stereo mix step (SampleUtil::addMonoToStereoWithGain)
31+
// can be vectorized independently by the compiler.
32+
std::size_t resampleMono(
33+
std::span<const CSAMPLE> monoSource,
34+
std::span<CSAMPLE> monoDest,
35+
double pitchRatio) {
36+
std::size_t framesConsumed = 0;
37+
for (std::size_t i = 0; i < monoDest.size(); ++i) {
38+
const double srcPos = static_cast<double>(i) * pitchRatio;
39+
const auto srcIdx = static_cast<std::size_t>(srcPos);
40+
41+
if (srcIdx >= monoSource.size()) {
42+
std::fill(monoDest.begin() + static_cast<std::ptrdiff_t>(i),
43+
monoDest.end(),
44+
0.0f);
45+
break;
46+
}
47+
48+
if (srcIdx + 1 < monoSource.size()) {
49+
const double frac = srcPos - static_cast<double>(srcIdx);
50+
monoDest[i] = static_cast<CSAMPLE>(
51+
monoSource[srcIdx] * (1.0 - frac) +
52+
monoSource[srcIdx + 1] * frac);
53+
} else {
54+
monoDest[i] = monoSource[srcIdx];
55+
}
56+
57+
framesConsumed = srcIdx + 1;
58+
}
59+
return framesConsumed;
60+
}
61+
62+
template<class T>
63+
std::span<T> subspanClamped(
64+
std::span<T> in, typename std::span<T>::size_type offset) {
65+
return in.subspan(std::min(offset, in.size()));
66+
}
67+
68+
// Returns the subspan of output starting at the beat position.
69+
// Mirrors metronomeeffect's syncedClickOutput exactly.
70+
std::span<CSAMPLE> syncedNoteOutput(
71+
double beatFractionBufferEnd,
72+
std::optional<GroupFeatureBeatLength> beatLengthAndScratch,
73+
const mixxx::EngineParameters& engineParameters,
74+
std::span<CSAMPLE> output) {
75+
if (!beatLengthAndScratch.has_value() ||
76+
beatLengthAndScratch->scratch_rate == 0.0) {
77+
return {};
78+
}
79+
double beatLength = beatLengthAndScratch->seconds *
80+
engineParameters.sampleRate() / beatLengthAndScratch->scratch_rate;
81+
82+
const bool needsPreviousBeat = beatLength < 0;
83+
double beatToBufferEndFrames = std::abs(beatLength) *
84+
(needsPreviousBeat ? (1 - beatFractionBufferEnd)
85+
: beatFractionBufferEnd);
86+
std::size_t beatToBufferEndSamples =
87+
static_cast<std::size_t>(beatToBufferEndFrames) *
88+
mixxx::kEngineChannelOutputCount;
89+
90+
if (beatToBufferEndSamples <= output.size()) {
91+
return output.last(beatToBufferEndSamples);
92+
}
93+
return {};
94+
}
95+
96+
// Returns where in the output buffer the next note starts using the internal
97+
// BPM counter. Mirrors metronomeeffect's unsyncedClickOutput.
98+
std::span<CSAMPLE> unsyncedNoteOutput(
99+
mixxx::audio::SampleRate framesPerSecond,
100+
std::size_t m_framesSinceLastNote,
101+
double bpm,
102+
double periodMultiplier,
103+
std::span<CSAMPLE> output) {
104+
const std::size_t period = static_cast<std::size_t>(
105+
framesPerSecond * 60.0 / bpm * periodMultiplier);
106+
if (period == 0) {
107+
return {};
108+
}
109+
const std::size_t offset = m_framesSinceLastNote % period;
110+
return subspanClamped(output, offset * mixxx::kEngineChannelOutputCount);
111+
}
112+
113+
// Returns the number of beats between each note onset.
114+
// 1 fires on every beat, 3 suits 3/4 time, 4 suits 4/4 time, and so on.
115+
double periodMultiplierFromMeasure(int measure) {
116+
return static_cast<double>(std::max(1, measure));
117+
}
118+
119+
} // namespace
120+
121+
void KeyComparisonGroupState::audioParametersChanged(
122+
const mixxx::EngineParameters& engineParameters) {
123+
m_sampleRate = engineParameters.sampleRate();
124+
m_pianoSample = generatePianoSample(m_sampleRate);
125+
m_tempMono.resize(engineParameters.framesPerBuffer());
126+
}
127+
128+
// static
129+
QString KeyComparisonEffect::getId() {
130+
return QStringLiteral("org.mixxx.effects.keycomparison");
131+
}
132+
133+
// static
134+
EffectManifestPointer KeyComparisonEffect::getManifest() {
135+
auto pManifest = EffectManifestPointer::create();
136+
pManifest->setId(getId());
137+
pManifest->setName(QObject::tr("Key Comparison"));
138+
pManifest->setAuthor(QObject::tr("The Mixxx Team"));
139+
pManifest->setVersion(QStringLiteral("1.0"));
140+
pManifest->setDescription(QObject::tr(
141+
"Plays a piano note at a configurable beat interval so you "
142+
"can match it by ear to identify or verify the musical key."));
143+
pManifest->setEffectRampsFromDry(true);
144+
pManifest->setMetaknobDefault(24.0 / 27.0);
145+
146+
EffectManifestParameterPointer key = pManifest->addParameter();
147+
key->setId(QStringLiteral("key"));
148+
key->setName(QObject::tr("Key"));
149+
key->setShortName(QObject::tr("Key"));
150+
key->setDescription(QObject::tr(
151+
"Musical key of the piano note (C to C, 13 chromatic steps).\n"
152+
"0=C 1=C\u266f/D\u266d 2=D 3=D\u266f/E\u266d "
153+
"4=E 5=F\n"
154+
"6=F\u266f/G\u266d 7=G 8=G\u266f/A\u266d "
155+
"9=A 10=A\u266f/B\u266d 11=B 12=C"));
156+
key->setValueScaler(EffectManifestParameter::ValueScaler::Integral);
157+
key->setUnitsHint(EffectManifestParameter::UnitsHint::Unknown);
158+
key->setDefaultLinkType(EffectManifestParameter::LinkType::None);
159+
key->setRange(0.0, 9.0, 12.0);
160+
161+
EffectManifestParameterPointer tuning = pManifest->addParameter();
162+
tuning->setId(QStringLiteral("tuning"));
163+
tuning->setName(QObject::tr("Tuning"));
164+
tuning->setShortName(QObject::tr("Tune"));
165+
tuning->setDescription(QObject::tr(
166+
"Reference pitch of A4 in Hz. Adjust this when the track was\n"
167+
"recorded to a non-standard tuning such as 432 Hz, 442 Hz,\n"
168+
"or 415 Hz (Baroque pitch)."));
169+
tuning->setValueScaler(EffectManifestParameter::ValueScaler::Linear);
170+
tuning->setUnitsHint(EffectManifestParameter::UnitsHint::Unknown);
171+
tuning->setDefaultLinkType(EffectManifestParameter::LinkType::None);
172+
tuning->setNeutralPointOnScale(25.0 / 51.0);
173+
tuning->setRange(415.0, 440.0, 466.0);
174+
175+
EffectManifestParameterPointer bpm = pManifest->addParameter();
176+
bpm->setId(QStringLiteral("bpm"));
177+
bpm->setName(QObject::tr("BPM"));
178+
bpm->setShortName(QObject::tr("BPM"));
179+
bpm->setDescription(QObject::tr(
180+
"Note repeat rate when Sync is off."));
181+
bpm->setValueScaler(EffectManifestParameter::ValueScaler::Linear);
182+
bpm->setUnitsHint(EffectManifestParameter::UnitsHint::Unknown);
183+
bpm->setDefaultLinkType(EffectManifestParameter::LinkType::None);
184+
bpm->setRange(60.0, 120.0, 200.0);
185+
186+
EffectManifestParameterPointer measure = pManifest->addParameter();
187+
measure->setId(QStringLiteral("measure"));
188+
measure->setName(QObject::tr("Measure"));
189+
measure->setShortName(QObject::tr("Meas"));
190+
measure->setDescription(QObject::tr(
191+
"How many beats between each note.\n"
192+
"1 = every beat | 3 = 3/4 time | "
193+
"4 = 4/4 time | 5 = 5/4 time"));
194+
measure->setValueScaler(EffectManifestParameter::ValueScaler::Integral);
195+
measure->setUnitsHint(EffectManifestParameter::UnitsHint::Unknown);
196+
measure->setDefaultLinkType(EffectManifestParameter::LinkType::None);
197+
measure->setRange(1.0, 4.0, 8.0);
198+
199+
EffectManifestParameterPointer sync = pManifest->addParameter();
200+
sync->setId(QStringLiteral("sync"));
201+
sync->setName(QObject::tr("Sync"));
202+
sync->setShortName(QObject::tr("Sync"));
203+
sync->setDescription(QObject::tr(
204+
"Lock note timing to the detected beat grid (recommended).\n"
205+
"Disable to set a manual rate with the BPM knob."));
206+
sync->setValueScaler(EffectManifestParameter::ValueScaler::Toggle);
207+
sync->setUnitsHint(EffectManifestParameter::UnitsHint::Unknown);
208+
sync->setRange(0.0, 1.0, 1.0);
209+
210+
EffectManifestParameterPointer gain = pManifest->addParameter();
211+
gain->setId(QStringLiteral("gain"));
212+
gain->setName(QObject::tr("Gain"));
213+
gain->setShortName(QObject::tr("Gain"));
214+
gain->setDescription(QObject::tr(
215+
"Volume of the piano note."));
216+
gain->setValueScaler(EffectManifestParameter::ValueScaler::Linear);
217+
gain->setUnitsHint(EffectManifestParameter::UnitsHint::Decibel);
218+
gain->setDefaultLinkType(EffectManifestParameter::LinkType::Linked);
219+
// 0 dB sits at (0 - (-24)) / (3 - (-24)) = 24/27 on the -24..+3 dB scale.
220+
gain->setNeutralPointOnScale(24.0 / 27.0);
221+
gain->setRange(-24.0, -5.0, 3.0);
222+
223+
return pManifest;
224+
}
225+
226+
void KeyComparisonEffect::loadEngineEffectParameters(
227+
const QMap<QString, EngineEffectParameterPointer>& parameters) {
228+
m_pKeyParameter = parameters.value(QStringLiteral("key"));
229+
m_pTuningParameter = parameters.value(QStringLiteral("tuning"));
230+
m_pBpmParameter = parameters.value(QStringLiteral("bpm"));
231+
m_pMeasureParameter = parameters.value(QStringLiteral("measure"));
232+
m_pSyncParameter = parameters.value(QStringLiteral("sync"));
233+
m_pGainParameter = parameters.value(QStringLiteral("gain"));
234+
}
235+
236+
void KeyComparisonEffect::processChannel(
237+
KeyComparisonGroupState* pGroupState,
238+
const CSAMPLE* pInput,
239+
CSAMPLE* pOutput,
240+
const mixxx::EngineParameters& engineParameters,
241+
const EffectEnableState enableState,
242+
const GroupFeatureState& groupFeatures) {
243+
if (enableState == EffectEnableState::Disabled) {
244+
return;
245+
}
246+
247+
if (pOutput != pInput) {
248+
SampleUtil::copy(pOutput, pInput, engineParameters.samplesPerBuffer());
249+
}
250+
251+
const std::span<CSAMPLE> output(pOutput, engineParameters.samplesPerBuffer());
252+
const std::span<const CSAMPLE> m_pianoSample(pGroupState->m_pianoSample);
253+
254+
const bool shouldSync = m_pSyncParameter->toBool();
255+
const bool hasBeatInfo = groupFeatures.beat_length.has_value() &&
256+
groupFeatures.beat_fraction_buffer_end.has_value();
257+
258+
const int measure = std::clamp(
259+
static_cast<int>(std::round(m_pMeasureParameter->value())),
260+
1,
261+
8);
262+
const double periodMultiplier = periodMultiplierFromMeasure(measure);
263+
264+
if (enableState == EffectEnableState::Enabling) {
265+
if (shouldSync && hasBeatInfo) {
266+
// If the user enabled within the first quarter of a beat they
267+
// were just a split second late — fire immediately so it feels
268+
// like it started on the downbeat. Otherwise wait for the next beat.
269+
const bool justMissedBeat =
270+
*groupFeatures.beat_fraction_buffer_end < 0.25;
271+
pGroupState->m_fireImmediately = justMissedBeat;
272+
pGroupState->m_srcFramePos =
273+
static_cast<double>(m_pianoSample.size());
274+
pGroupState->m_framesSinceLastNote = m_pianoSample.size();
275+
} else {
276+
// In unsynced mode, fire the first note immediately via
277+
// m_fireImmediately. Silence the tail path so the note
278+
// does not play twice on the first buffer.
279+
pGroupState->m_srcFramePos =
280+
static_cast<double>(m_pianoSample.size());
281+
pGroupState->m_framesSinceLastNote = 0;
282+
pGroupState->m_fireImmediately = true;
283+
}
284+
// Initialise to measure - 1 so the first synced beat fires immediately
285+
// rather than waiting a full measure.
286+
pGroupState->m_beatCount = measure - 1;
287+
}
288+
289+
const int keyIndex = std::clamp(
290+
static_cast<int>(std::round(m_pKeyParameter->value())),
291+
0,
292+
static_cast<int>(kKeySemitoneOffset.size()) - 1);
293+
// pitchRatio = 2^(semitones/12) * (tuningHz / 440) * (generatedRate / engineRate)
294+
// The rate correction compensates for the sample being generated at a
295+
// different rate than the engine (e.g. 96000 Hz default vs 48000 Hz actual).
296+
const double rateCorrection =
297+
static_cast<double>(pGroupState->m_sampleRate.value()) /
298+
static_cast<double>(engineParameters.sampleRate().value());
299+
const double pitchRatio =
300+
std::pow(2.0, kKeySemitoneOffset[keyIndex] / 12.0) *
301+
(m_pTuningParameter->value() / 440.0) *
302+
rateCorrection;
303+
304+
const CSAMPLE_GAIN gain =
305+
db2ratio(static_cast<float>(m_pGainParameter->value()));
306+
307+
// Continue the note tail from the previous buffer.
308+
// m_srcFramePos tracks the source position directly so that changing
309+
// pitchRatio mid-note does not cause a position jump and crack.
310+
{
311+
const std::size_t tailFrames = output.size() / mixxx::kEngineChannelOutputCount;
312+
std::span<CSAMPLE> tailMono(pGroupState->m_tempMono.data(), tailFrames);
313+
const auto srcOffset = static_cast<std::size_t>(pGroupState->m_srcFramePos);
314+
resampleMono(subspanClamped(m_pianoSample, srcOffset), tailMono, pitchRatio);
315+
SampleUtil::addMonoToStereoWithGain(gain, pOutput, tailMono.data(), tailFrames);
316+
pGroupState->m_srcFramePos +=
317+
static_cast<double>(engineParameters.framesPerBuffer()) * pitchRatio;
318+
}
319+
pGroupState->m_framesSinceLastNote += engineParameters.framesPerBuffer();
320+
321+
std::span<CSAMPLE> noteStart;
322+
if (pGroupState->m_fireImmediately) {
323+
// Fire at the start of this buffer immediately, then revert to normal
324+
// unsynced timing.
325+
noteStart = output;
326+
pGroupState->m_fireImmediately = false;
327+
} else if (shouldSync && hasBeatInfo) {
328+
noteStart = syncedNoteOutput(
329+
*groupFeatures.beat_fraction_buffer_end,
330+
groupFeatures.beat_length,
331+
engineParameters,
332+
output);
333+
} else {
334+
noteStart = unsyncedNoteOutput(
335+
engineParameters.sampleRate(),
336+
pGroupState->m_framesSinceLastNote,
337+
m_pBpmParameter->value(),
338+
periodMultiplier,
339+
output);
340+
}
341+
342+
if (noteStart.empty()) {
343+
return;
344+
}
345+
346+
// In sync mode, count beats and only fire every `measure` beats.
347+
if (shouldSync && hasBeatInfo) {
348+
pGroupState->m_beatCount++;
349+
if (pGroupState->m_beatCount < measure) {
350+
return;
351+
}
352+
pGroupState->m_beatCount = 0;
353+
}
354+
355+
{
356+
const std::size_t onsetFrames = noteStart.size() / mixxx::kEngineChannelOutputCount;
357+
std::span<CSAMPLE> onsetMono(pGroupState->m_tempMono.data(), onsetFrames);
358+
const std::size_t srcConsumed =
359+
resampleMono(m_pianoSample, onsetMono, pitchRatio);
360+
SampleUtil::addMonoToStereoWithGain(
361+
gain, noteStart.data(), onsetMono.data(), onsetFrames);
362+
// Reset source position and output counter from the onset playback.
363+
pGroupState->m_srcFramePos = static_cast<double>(srcConsumed);
364+
pGroupState->m_framesSinceLastNote = srcConsumed;
365+
}
366+
}

0 commit comments

Comments
 (0)