DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
PitchCorrector.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
226#include "../Analysis/PitchDetector.h"
227#include "../Core/AudioBuffer.h"
228#include "../Core/AudioSpec.h"
229#include "../Core/DenormalGuard.h"
230#include "../Core/DspMath.h"
231#include "../Core/StateBlob.h"
232#include "../Music/HarmonyConstants.h"
233#include "PitchShifter.h"
234
235#include <algorithm>
236#include <atomic>
237#include <cmath>
238#include <cstdint>
239#include <span>
240#include <vector>
241
242namespace dspark {
243
251template <FloatType T>
252class PitchCorrector final
253{
254public:
255 // The published words are read on the audio thread every control interval,
256 // and a word that is not lock-free would take a mutex inside the callback.
257 static_assert(std::atomic<T>::is_always_lock_free,
258 "audio-thread stores must not lock");
259 static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
260 "audio-thread stores must not lock");
261
262 // -- Lifecycle -------------------------------------------------------------
263
284 void prepare(const AudioSpec& spec, int fftSize = 0)
285 {
286 if (!std::isfinite(spec.sampleRate) || !spec.isValid()) return;
287
288 prepared_ = false;
289
290 const int frame = fftSize > 0 ? sanitizeFrame(fftSize)
291 : automaticFrame(spec.sampleRate);
292
293 detector_.prepare(spec.sampleRate);
294 // The corrector keeps the Standard engine: its retune dynamics and the
295 // 2 * frame latency are tuned to it, and it moves the shift on every
296 // control tick, where Studio's strike anchoring buys nothing.
297 shifter_.setQuality(PitchShifter<T>::Quality::Standard);
298 shifter_.prepare(spec, frame);
299
300 sampleRate_ = spec.sampleRate;
301 frameSize_ = frame;
302 latency_ = shifter_.getLatency();
303
304 prepared_ = true;
305 reset();
306 }
307
314 void reset() noexcept
315 {
316 if (!prepared_) return;
317
318 detector_.reset();
319 target_ = 0.0;
320 correction_ = 0.0;
321 publishedCorrection_ = 0.0;
322 shifter_.setSemitones(T(0));
323 shifter_.reset();
324 controlPhase_ = 0;
325 smoothingForMs_ = -1.0; // rebuild the glide coefficient on next block
326 warmupRemaining_ = detector_.getWindowSize();
327 }
328
329 // -- Parameters (any thread) -------------------------------------------------
330
341 void setScale(std::uint16_t scaleBitmask, int rootPitchClass) noexcept
342 {
343 const auto mask = static_cast<std::uint32_t>(scaleBitmask & 0x0FFFu);
344 const auto root = static_cast<std::uint32_t>((rootPitchClass % 12 + 12) % 12);
345 const auto absolute = static_cast<std::uint32_t>(harmony::scaleAtRoot(
346 static_cast<harmony::NoteSet>(mask), static_cast<int>(root)));
347 // One packed word: the audio thread must never pair a fresh mask with a
348 // stale root, so the mask, the root and the pre-rotated absolute
349 // pitch-class set travel together.
350 scaleWord_.store(absolute | (root << 12) | (mask << 16),
351 std::memory_order_relaxed);
352 }
353
362 void setRetuneSpeedMs(T ms) noexcept
363 {
364 if (!std::isfinite(ms)) return;
365 retuneSpeedMs_.store(std::clamp(ms, T(0), T(kMaxRetuneMs)),
366 std::memory_order_relaxed);
367 }
368
371 void setFormantPreserve(bool on) noexcept
372 {
373 formantPreserve_.store(on, std::memory_order_relaxed);
374 }
375
377 [[nodiscard]] std::uint16_t getScaleMask() const noexcept
378 {
379 return static_cast<std::uint16_t>(
380 (scaleWord_.load(std::memory_order_relaxed) >> 16) & 0x0FFFu);
381 }
382
384 [[nodiscard]] int getRootPitchClass() const noexcept
385 {
386 return static_cast<int>(
387 (scaleWord_.load(std::memory_order_relaxed) >> 12) & 0x0Fu);
388 }
389
391 [[nodiscard]] T getRetuneSpeedMs() const noexcept
392 {
393 return retuneSpeedMs_.load(std::memory_order_relaxed);
394 }
395
397 [[nodiscard]] bool getFormantPreserve() const noexcept
398 {
399 return formantPreserve_.load(std::memory_order_relaxed);
400 }
401
403 [[nodiscard]] std::vector<uint8_t> getState() const
404 {
405 StateWriter w(stateId("PCOR"), 1);
406 w.write("scaleMask", static_cast<int32_t>(getScaleMask()));
407 w.write("root", static_cast<int32_t>(getRootPitchClass()));
408 w.write("retuneMs", static_cast<float>(getRetuneSpeedMs()));
409 w.write("formant", getFormantPreserve());
410 return w.blob();
411 }
412
416 bool setState(const uint8_t* data, size_t size)
417 {
418 StateReader r(data, size);
419 if (!r.isValid() || r.processorId() != stateId("PCOR")) return false;
420 setScale(static_cast<std::uint16_t>(r.read("scaleMask", int32_t(0x0FFF)) & 0x0FFF),
421 r.read("root", int32_t(0)));
422 setRetuneSpeedMs(static_cast<T>(r.read("retuneMs", 0.0f)));
423 setFormantPreserve(r.read("formant", false));
424 return true;
425 }
426
430 [[nodiscard]] int getLatency() const noexcept
431 {
432 return prepared_ ? latency_ : 0;
433 }
434
437 [[nodiscard]] int getFrameSize() const noexcept
438 {
439 return prepared_ ? frameSize_ : 0;
440 }
441
442 // -- Processing --------------------------------------------------------------
443
453 void processBlock(AudioBufferView<T> buffer) noexcept
454 {
455 if (!prepared_) return;
456 const int numSamples = buffer.getNumSamples();
457 if (numSamples <= 0) return;
458
459 DenormalGuard guard;
460
461 // One load per block for each independent word. The scale word is
462 // internally consistent by packing; the retune speed and the formant
463 // flag stand alone.
464 const std::uint32_t absoluteMask =
465 scaleWord_.load(std::memory_order_relaxed) & 0x0FFFu;
466
467 const double retuneMs = static_cast<double>(
468 retuneSpeedMs_.load(std::memory_order_relaxed));
469 if (retuneMs != smoothingForMs_) rebuildSmoothing(retuneMs);
470
471 const bool formant = formantPreserve_.load(std::memory_order_relaxed);
472 if (formant != publishedFormant_)
473 {
474 // Forwarded by the stream owner, which is the shifter's only
475 // control writer here (see the file documentation).
476 shifter_.setFormantPreserve(formant);
477 publishedFormant_ = formant;
478 }
479
480 const T* const detectionInput =
481 buffer.getNumChannels() > 0 ? buffer.getChannel(0) : nullptr;
482
483 int offset = 0;
484 while (offset < numSamples)
485 {
486 // The grid is absolute (controlPhase_ persists across blocks): the
487 // correction advances once, at the START of each whole interval,
488 // and every sample of that interval is then rendered with it in
489 // force. The shifter's engine adopts a new value at its own
490 // analysis hops, which do not land on this grid once the ratio
491 // leaves unity, so publishing at the start of the interval that
492 // contains a hop - rather than at whichever chunk boundary the
493 // host block size happens to create - is what makes the rendered
494 // output identical however the stream is chopped into blocks.
495 if (controlPhase_ == 0) advanceCorrection(absoluteMask);
496
497 const int chunk =
498 std::min(numSamples - offset, kControlInterval - controlPhase_);
499
500 if (detectionInput != nullptr)
501 detector_.pushSamples(std::span<const T>(
502 detectionInput + offset, static_cast<std::size_t>(chunk)));
503
504 if (warmupRemaining_ > 0)
505 warmupRemaining_ = std::max(0, warmupRemaining_ - chunk);
506
507 shifter_.processBlock(buffer.getSubView(offset, chunk));
508
509 controlPhase_ += chunk;
510 if (controlPhase_ >= kControlInterval) controlPhase_ = 0;
511 offset += chunk;
512 }
513 }
514
515private:
520 static constexpr double kAutoSpanRef = 2048.0;
521 static constexpr double kAutoSpanRate = 48000.0;
522 static constexpr int kAutoMinFrame = 512;
523 static constexpr int kAutoMaxFrame = 32768;
524
526 [[nodiscard]] static int automaticFrame(double sampleRate) noexcept
527 {
528 const double target = sampleRate * (kAutoSpanRef / kAutoSpanRate);
529 int frame = kAutoMinFrame;
530 while (frame < kAutoMaxFrame && static_cast<double>(frame) < target)
531 frame <<= 1;
532 return frame;
533 }
534
539 [[nodiscard]] static int sanitizeFrame(int requested) noexcept
540 {
541 constexpr int kMax = 1 << 20;
542 int frame = 256;
543 while (frame < requested && frame < kMax) frame <<= 1;
544 return frame;
545 }
546
550 static constexpr int kControlInterval = 64;
551
555 static constexpr double kLandingSemitones = 0.001;
556
558 static constexpr double kMaxRetuneMs = 10000.0;
559
565 void rebuildSmoothing(double retuneMs) noexcept
566 {
567 smoothingForMs_ = retuneMs;
568 if (retuneMs <= 0.0 || sampleRate_ <= 0.0)
569 {
570 smoothing_ = 1.0; // hard snap
571 return;
572 }
573 const double tauSamples = retuneMs * 0.001 * sampleRate_;
574 smoothing_ = 1.0 - std::exp(-static_cast<double>(kControlInterval) / tauSamples);
575 }
576
581 void advanceCorrection(std::uint32_t absoluteMask) noexcept
582 {
583 if (absoluteMask == 0u)
584 {
585 target_ = 0.0; // no scale to snap to: correction disabled
586 }
587 else if (warmupRemaining_ == 0)
588 {
589 // Two gates before a report may establish a held correction: the
590 // analysis window must be full (its first, mostly-zero window
591 // reports the shortest lag the search allows, at full confidence),
592 // and the fundamental must be one this effect could act on - above
593 // a quarter of the sample rate not even its second harmonic fits
594 // below Nyquist. Unvoiced, or refused: keep the previous target.
595 const double frequency = static_cast<double>(detector_.getFrequencyHz());
596 if (frequency > 0.0 && frequency <= 0.25 * sampleRate_)
597 {
598 const double midi = 69.0 + 12.0 * std::log2(frequency / 440.0);
599 if (std::isfinite(midi))
600 target_ = std::clamp(nearestInScale(midi, absoluteMask) - midi,
601 -12.0, 12.0);
602 }
603 }
604
605 if (smoothing_ >= 1.0)
606 {
607 correction_ = target_;
608 }
609 else
610 {
611 correction_ += (target_ - correction_) * smoothing_;
612 if (std::abs(target_ - correction_) < kLandingSemitones)
613 correction_ = target_; // exact landing
614 }
615
616 if (correction_ != publishedCorrection_)
617 {
618 shifter_.setSemitones(static_cast<T>(correction_));
619 publishedCorrection_ = correction_;
620 }
621 }
622
631 [[nodiscard]] static double nearestInScale(double midi,
632 std::uint32_t absoluteMask) noexcept
633 {
634 const int base = static_cast<int>(std::floor(midi));
635 int bestNote = base;
636 double bestDistance = 1.0e9;
637 for (int note = base - 12; note <= base + 13; ++note)
638 {
639 const int pitchClass = ((note % 12) + 12) % 12;
640 if ((absoluteMask & (1u << pitchClass)) == 0u) continue;
641 const double distance = std::abs(static_cast<double>(note) - midi);
642 if (distance < bestDistance)
643 {
644 bestDistance = distance;
645 bestNote = note;
646 }
647 }
648 return static_cast<double>(bestNote);
649 }
650
651 // -- Members -----------------------------------------------------------------
652
653 PitchDetector<T> detector_;
654 PitchShifter<T> shifter_;
655
656 double sampleRate_ = 48000.0;
657 int frameSize_ = 0;
658 int latency_ = 0;
659 bool prepared_ = false;
660
661 // Stream-owner state (audio thread only).
662 double target_ = 0.0;
663 double correction_ = 0.0;
664 double publishedCorrection_ = 0.0;
665 double smoothing_ = 1.0;
666 double smoothingForMs_ = -1.0;
667 bool publishedFormant_ = false;
668 int controlPhase_ = 0;
669 int warmupRemaining_ = 0;
670
673 std::atomic<std::uint32_t> scaleWord_ { 0x0FFF0FFFu };
674 std::atomic<T> retuneSpeedMs_ { T(0) };
675 std::atomic<bool> formantPreserve_ { false };
676};
677
678} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Scale-aware monophonic retune over the framework's YIN detector and phase-vocoder shifter.
T getRetuneSpeedMs() const noexcept
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
void setFormantPreserve(bool on) noexcept
Enables the shifter's cepstral formant preservation (the anti-chipmunk envelope pre-warp)....
int getFrameSize() const noexcept
The analysis frame in effect, in samples: the automatic choice for this rate, or the rounded explicit...
void processBlock(AudioBufferView< T > buffer) noexcept
Processes audio in-place. Pass-through until prepare() succeeds.
bool getFormantPreserve() const noexcept
int getLatency() const noexcept
Reports the signal latency in samples: twice the analysis frame, which is 4096 samples at 44....
void setScale(std::uint16_t scaleBitmask, int rootPitchClass) noexcept
Selects the scale the output snaps to.
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids). A missing field restores its default...
int getRootPitchClass() const noexcept
std::uint16_t getScaleMask() const noexcept
void prepare(const AudioSpec &spec, int fftSize=0)
Allocates the detector and shifter state (setup thread).
void setRetuneSpeedMs(T ms) noexcept
Sets the retune speed: the time constant, in milliseconds, of the glide from the sung pitch to the ta...
void reset() noexcept
Clears all signal state and keeps the parameters (stream owner).
Real-time phase-vocoder pitch shifter (+-12 semitones, stereo-linked).
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:161
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:204
bool isValid() const noexcept
Definition StateBlob.h:199
uint32_t processorId() const noexcept
Definition StateBlob.h:200
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:53
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:105
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:71
std::uint16_t NoteSet
A 12-bit bitmask representing the 12 pitch-classes of the chromatic scale.
constexpr NoteSet scaleAtRoot(NoteSet base, int root) noexcept
Circularly rotate a NoteSet so it becomes rooted at a specific key.
Main namespace for the DSPark framework.
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:71
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45