|
| 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