From 185c10594580267bf1b82532c1227939abfae779 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sun, 20 Sep 2026 15:25:29 +0200 Subject: [PATCH 01/37] Hard sync oscillators and fourier series --- CHANGELOG.md | 4 + docs/dsp/index.md | 6 +- docs/dsp/oscillators.md | 142 +++++ .../oscillators/yup_AdditiveOscillator.h | 258 +++++++++ .../yup_dsp/oscillators/yup_FourierSeries.h | 383 ++++++++++++ .../yup_dsp/oscillators/yup_SyncOscillator.h | 349 +++++++++++ .../oscillators/yup_SyncSpectralResampler.h | 418 ++++++++++++++ .../oscillators/yup_WavetableOscillator.h | 345 +++++++++++ modules/yup_dsp/utilities/yup_DspMath.h | 51 ++ modules/yup_dsp/yup_dsp.h | 7 + tests/yup_dsp.cpp | 5 + tests/yup_dsp/yup_AdditiveOscillator.cpp | 224 +++++++ tests/yup_dsp/yup_FourierSeries.cpp | 360 ++++++++++++ tests/yup_dsp/yup_SyncOscillator.cpp | 305 ++++++++++ tests/yup_dsp/yup_SyncSpectralResampler.cpp | 546 ++++++++++++++++++ tests/yup_dsp/yup_WavetableOscillator.cpp | 221 +++++++ 16 files changed, 3623 insertions(+), 1 deletion(-) create mode 100644 docs/dsp/oscillators.md create mode 100644 modules/yup_dsp/oscillators/yup_AdditiveOscillator.h create mode 100644 modules/yup_dsp/oscillators/yup_FourierSeries.h create mode 100644 modules/yup_dsp/oscillators/yup_SyncOscillator.h create mode 100644 modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h create mode 100644 modules/yup_dsp/oscillators/yup_WavetableOscillator.h create mode 100644 tests/yup_dsp/yup_AdditiveOscillator.cpp create mode 100644 tests/yup_dsp/yup_FourierSeries.cpp create mode 100644 tests/yup_dsp/yup_SyncOscillator.cpp create mode 100644 tests/yup_dsp/yup_SyncSpectralResampler.cpp create mode 100644 tests/yup_dsp/yup_WavetableOscillator.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c51fd7a0c..b0556d06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Audio +- Fixed the pulsar spectral resampler's alternating coefficient sign and corrected oscillator regression tests for fixed-size copies, startup crossfades, and spectral window leakage. + - Added a `TuningMap` class (`midi/yup_TuningMap.h`): maps MIDI note numbers to frequencies under an arbitrary scale and key map, loading Scala `.scl` scale files and `.kbm` key map files via `loadScale()` / `loadKeyMap()` (which return a `yup::Result` and keep the previous tuning when a file fails to parse). `isNoteActive()` reports the notes a key map asks to retune, taken from the range in its header unless the file carries `< first last` lines, which declare it instead - `FFTProcessor` is now templated on the sample type - `FFTProcessor` (the default) or `FFTProcessor` - and every backend (PFFFT, Apple vDSP, Intel IPP, FFTW3 and the Ooura fallback, which now ships both a `float` and a `double` implementation) gained a native double-precision path. References to the nested scaling enum need qualifying, e.g. `FFTProcessor::FFTScaling::asymmetric` @@ -46,6 +48,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Fixed the double-precision Ooura FFT translation unit only building on GCC/Clang: it declared every internal helper (`makewt`, `cftfsub`, `bitrv2`, ...) inside the body of the functions that call them, and a block-scope declaration inside `namespace yup` declares a *global* function, so `yup::cdft` referenced a `::makewt` that no one defined and the Windows link failed with 30 unresolved externals. The declarations now sit at namespace scope, matching `yup_OouraFFT8g_float.cpp` +- Added the `yup_dsp` oscillator classes (`oscillators/yup_FourierSeries.h`, `oscillators/yup_SyncSpectralResampler.h`, `oscillators/yup_AdditiveOscillator.h`, `oscillators/yup_WavetableOscillator.h`, `oscillators/yup_SyncOscillator.h`), an alias-free synchronizing oscillator following Roth, Keller, Castaneda and Studer, "Alias-Free Oscillator Synchronization via Additive Synthesis" (DAFx26, paper 49): `FourierSeries` holds the coefficients and the waveform presets, `SyncSpectralResampler` rewrites them from a follower period ratio into hard, mirrored or pulsar sync, `AdditiveOscillator` and `WavetableOscillator` synthesize the result using only the harmonics below Nyquist, and the `SyncOscillator` facade offers both backends and refreshes them from a once-per-block `update()`. `utilities/yup_DspMath.h` also gained `fillHarmonicPhasors` + ### Graphics - SVG drawables now prepare text layout, fitted image bounds, gradients, local dashed paths, marker placements and path bounds while parsing, avoiding repeated CPU work and failed image-resolution retries during rendering. diff --git a/docs/dsp/index.md b/docs/dsp/index.md index ee0593abb..ea16f2e62 100644 --- a/docs/dsp/index.md +++ b/docs/dsp/index.md @@ -3,7 +3,7 @@ The `yup_dsp` module provides the real-time audio processing building blocks of the framework: mathematical utilities, windowing, noise, FFTs and spectral analysis, filter design, filter implementations, crossovers, dynamics -processing, metering, convolution, delay lines, resampling, and +processing, metering, convolution, delay lines, resampling, oscillators, and time-stretching / pitch-shifting. **Modules covered:** `yup_dsp`. @@ -45,6 +45,9 @@ available in the build. the `CircularBuffer` helper. - [Time-stretching & pitch-shifting](time-stretching.md) - the `TimeStretchProcessor` with its time-domain and Bungee backends. +- [Oscillators](oscillators.md) - `FourierSeries`, the alias-free + `SyncSpectralResampler`, the `AdditiveOscillator` / `WavetableOscillator` + synthesis backends, and the synth-ready `SyncOscillator` facade. ## Key building blocks @@ -99,4 +102,5 @@ onsets convolution-and-delay resampling time-stretching +oscillators ``` diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md new file mode 100644 index 000000000..9d6481bbc --- /dev/null +++ b/docs/dsp/oscillators.md @@ -0,0 +1,142 @@ +# Oscillators + +The `yup_dsp` oscillator classes synthesize bandlimited periodic waveforms from +their Fourier coefficients, and synchronize one oscillator to another without +aliasing: the output spectrum is built from scratch for every period ratio, so no +harmonic ever folds back below Nyquist. + +The implementation follows Roth, Keller, Castaneda and Studer, *"Alias-Free +Oscillator Synchronization via Additive Synthesis"* (DAFx26, paper 49). + +**Headers:** `yup_dsp/oscillators/` - `yup_FourierSeries.h`, +`yup_SyncSpectralResampler.h`, `yup_AdditiveOscillator.h`, +`yup_WavetableOscillator.h`, `yup_SyncOscillator.h`. + +## The idea + +A free-running *follower* oscillator is described by its Fourier series +`{a0, a_k, b_k}`, with `t` in units of the follower period `T_follow`. A *leader* +runs at a period ratio `P = T_lead / T_follow`. When the follower is restarted, +mirrored or windowed on every leader period, the result is periodic again, and its +Fourier coefficients turn out to be a **linear transform** of the follower's: the +sinc and versinc kernels of the paper resample the follower's spectrum onto the +leader's harmonic grid. + +Synthesizing that transformed series with only the harmonics below Nyquist is +alias-free *by construction* - nothing has to be filtered out afterwards. + +| mode | construction | pre-rotation | output fundamental | +|---|---|---|---| +| `hard` | `s (t) = r (t + P / 2)` on `[-P/2, P/2)`, period `P` | `-P/2` | `f_lead` | +| `mirrored` | `s (t) = r (P - abs (t))` on `[-P, P)`, period `2 P` | `-P` | `f_lead / 2` | +| `pulsar` | one follower period in a window of width 1, period `P` | `-1/2` | `f_lead` | + +Two details of the published method are worth knowing before using it: + +- **Mirrored sync sounds an octave below the leader.** Its period is `2 T_lead`, + which is exactly how the paper defines it; `SyncOscillator::getOutputFrequency()` + reports the fundamental that is actually synthesized. +- **The absolute phase follows the paper's pre-rotation.** Relative to the raw + time-domain constructions above, the output is the same waveform delayed by half + a period of the output fundamental, i.e. its coefficients carry a factor + `(-1)^n`. Only the phase is affected, never the magnitude spectrum. +- **Bandlimiting the follower caps the output.** The transform cannot create + harmonics the follower does not have, so a follower with `N` harmonics produces + an output whose top harmonic sits at `N * P` times the leader frequency. + `getRecommendedOutputHarmonics()` reports the harmonic count needed to keep all + of them; the facade clamps it to the configured maximum. + +## The classes + +| class | role | +|---|---| +| `FourierSeries` | Coefficient container: `dc`, `std::vector` cosine and sine coefficients, waveform presets (`sine`, `cosine`, `sawtooth`, `square`, `triangle`, `pulse`), a direct DFT (`setFromCycle`) and the pre-rotation (`timeShift`). | +| `SyncSpectralResampler` | The synchronization transform itself: follower coefficients plus a period ratio and a `SyncMode` in, synchronized coefficients out. | +| `AdditiveOscillator` | Exact additive synthesis of a series at a fundamental, Nyquist limited, evaluated a SIMD width of harmonics at a time. | +| `WavetableOscillator` | Renders a series into a single-cycle table with an inverse FFT and plays it back with 4-point Hermite interpolation, crossfading between renders. | +| `SyncOscillator` | The synth-ready facade: a follower series, a mode, a ratio and a pitch in, audio out, with both synthesis backends selectable at runtime. | + +Both oscillators are usable on their own as plain bandlimited oscillators; the +resampler is usable on its own for analysis or visualization, and the series can be +inspected through `SyncOscillator::getSyncedSeries()`. + +## Real-time contract + +```cpp +yup::SyncOscillator osc; + +void prepare (double sampleRate, int maxHarmonics) +{ + osc.prepare (sampleRate, maxHarmonics); // allocates, renders the first table + osc.setWaveform (yup::Waveform::sawtooth); + osc.setSyncMode (yup::SyncMode::hard); +} + +void setPitch (double leaderHz) +{ + osc.setFrequency (leaderHz); // phase continuous, no update needed +} + +void setSync (double ratio, yup::SyncMode mode) +{ + osc.setFollowerRatio (ratio); // marks the oscillator dirty + osc.setSyncMode (mode); +} + +void processBlock (double* output, int numSamples) +{ + osc.update(); // resample + render, once per block + osc.processBlock (output, numSamples); +} +``` + +- `prepare()` is the only method that allocates. It must run outside the audio + callback. +- Parameter setters only store values and mark the oscillator dirty; nothing heavy + happens inside `update()`, `processSample()` or `processBlock()` beyond the work + described below. `update()` is a no-op when nothing is dirty, so it is safe to + call unconditionally once per block. +- `update()` is allocation-free, so it may be called from the audio callback. +- Pitch changes are phase-continuous and need no `update()`; synchronization + parameter changes need exactly one. + +| operation | cost | +|---|---| +| `SyncSpectralResampler::transform` | `O (N_in * N_out)` multiply-accumulates, `O (N_in)` transcendental calls. About 10 us for 128 harmonics, 100 us for 512. | +| `AdditiveOscillator::processSample` | One multiply accumulate per active harmonic, plus 2 transcendental calls. | +| `WavetableOscillator::render` | One inverse FFT of the oversampled table, 10 to 50 us for the default table. | +| `WavetableOscillator::processSample` | Two Hermite table reads (two while crossfading). | + +## Choosing a synthesis backend + +- **Wavetable** (the default) renders the synchronized series into a single-cycle + table and reads it back with interpolation. The per-sample cost is independent of + the harmonic count, at the price of interpolation images (the table is + oversampled 8x, which pushes them below roughly -70 dB) and of a table that is + only refreshed on `update()`. Pitch changes between updates can push the top + rendered harmonic slightly past Nyquist, which is why a synth should call + `update()` once per block. +- **Additive** synthesizes the harmonics directly, so it is exact and reacts to + every change immediately, but its per-sample cost grows with the harmonic count. + +Both honor the Nyquist limit, so both are alias free. + +The wavetable backend crossfades from silence on its first render, using the +crossfade length passed to `prepare()` (64 samples by default). When comparing +it with additive synthesis, let this ramp finish and align the playback phases +before measuring. Subsequent renders crossfade from the current table. + +## Related areas + +- [Frequency domain](frequency.md) - `FFTProcessor`, which the wavetable renderer + uses for its inverse transform. +- [Math, windowing & noise](math.md) - `DspMath`, which provides the harmonic + phasor table used by the transform and the pre-rotation. +- [Resampling](resampling.md) - `Oversampler` and `Resampler` for sample-rate + conversion, as opposed to the spectral resampling done here. + +## Reference + +Roth, J., Keller, D., Castaneda, L., Studer, C. *Alias-Free Oscillator +Synchronization via Additive Synthesis*. DAFx26, paper 49. Reference Python +implementation: `github.com/IIP-Group/hasy-python`. diff --git a/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h new file mode 100644 index 000000000..b28218949 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h @@ -0,0 +1,258 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Exact additive oscillator. + + Synthesizes a FourierSeries at a given fundamental frequency, using only the + harmonics that stay strictly below Nyquist, so the output never aliases. It is + the reference renderer of this folder: whatever the wavetable oscillator + produces should match it, and a synchronizing transform can be verified + against it as well. + + Synthesis is band-unlimited on purpose - the caller decides how many harmonics + the series holds - and each sample costs a phase increment plus one multiply + accumulate per active harmonic, evaluated laneCount harmonics at a time in + SIMDRegister lanes. Two transcendental calls per sample are needed to rotate + the harmonic phasors, and no per-harmonic state is kept, so there is no + long-term drift. + + @tparam SampleType Type for the synthesized samples (float or double). + @tparam CoeffType Type for the coefficients and phase math (default double). + + @see FourierSeries, WavetableOscillator, SyncOscillator +*/ +template +class AdditiveOscillator +{ +public: + //============================================================================== + /** Number of harmonics evaluated per SIMD step. */ + static constexpr int laneCount = std::is_same_v ? 8 : 4; + + /** SIMD register type used for the harmonic accumulation. */ + using Register = SIMDRegister; + + //============================================================================== + /** Default constructor. Call prepare() before processing. */ + AdditiveOscillator() = default; + + //============================================================================== + /** + Allocates the internal series and recomputes the harmonic limit. + + @param sampleRate Sample rate in Hz. + @param maxHarmonics Maximum number of harmonics to store and synthesize. + */ + void prepare (double sampleRate, int maxHarmonics) + { + jassert (sampleRate > 0.0); + jassert (maxHarmonics >= 0); + + this->sampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + series.resize (maxHarmonics); + + reset(); + } + + /** Resets the oscillator phase to zero. */ + void reset() noexcept + { + phase = 0.0; + } + + //============================================================================== + /** Sets the fundamental frequency in Hz. */ + void setFrequency (CoeffType newFrequency) noexcept + { + const auto sanitized = jmax (newFrequency, static_cast (0)); + + if (! approximatelyEqual (frequency, sanitized)) + { + frequency = sanitized; + updateActiveHarmonics(); + } + } + + /** Returns the fundamental frequency in Hz. */ + CoeffType getFrequency() const noexcept { return frequency; } + + /** Sets the phase, normalized to one period. */ + void setPhase (CoeffType newPhase) noexcept + { + phase = static_cast (newPhase - std::floor (newPhase)); + } + + /** Returns the phase, normalized to one period. */ + CoeffType getPhase() const noexcept { return static_cast (phase); } + + //============================================================================== + /** Copies a series into the oscillator, without rendering anything yet. */ + void setSeries (const FourierSeries& newSeries) noexcept + { + series.copyFrom (newSeries); + updateActiveHarmonics(); + } + + /** Fills the series with one of the convenient presets. */ + void setWaveform (Waveform waveform) noexcept + { + series.setWaveform (waveform); + updateActiveHarmonics(); + } + + /** Returns the series being synthesized. */ + const FourierSeries& getSeries() const noexcept { return series; } + + /** Selects whether the series' DC coefficient is synthesized (off by default). */ + void setIncludeDC (bool shouldIncludeDC) noexcept { includeDC = shouldIncludeDC; } + + /** Returns whether the DC coefficient is synthesized. */ + bool getIncludeDC() const noexcept { return includeDC; } + + /** Returns the number of harmonics currently synthesized. */ + int getNumActiveHarmonics() const noexcept { return activeHarmonics; } + + /** Returns the number of harmonics the oscillator can synthesize. */ + int getNumHarmonics() const noexcept { return series.getNumHarmonics(); } + + //============================================================================== + /** Synthesizes one sample. */ + SampleType processSample() noexcept + { + const auto value = activeHarmonics > 0 ? synthesizeSample() : CoeffType (0); + + phase += static_cast (frequency) / sampleRate; + phase -= std::floor (phase); + + return static_cast (value); + } + + /** Synthesizes a block of samples. */ + void processBlock (SampleType* output, int numSamples) noexcept + { + if (output == nullptr) + return; + + for (int i = 0; i < numSamples; ++i) + output[i] = processSample(); + } + +private: + //============================================================================== + void updateActiveHarmonics() noexcept + { + activeHarmonics = getNyquistHarmonicLimit (frequency, sampleRate, series.getNumHarmonics()); + } + + CoeffType synthesizeSample() noexcept + { + const auto theta = MathConstants::twoPi * static_cast (phase); + const auto stepCosine = std::cos (theta); + const auto stepSine = std::sin (theta); + + CoeffType laneCosine[laneCount] = {}; + CoeffType laneSine[laneCount] = {}; + + auto cosine = stepCosine; + auto sine = stepSine; + + for (int lane = 0; lane < laneCount; ++lane) + { + laneCosine[lane] = cosine; + laneSine[lane] = sine; + + if (lane + 1 < laneCount) + { + const auto nextCosine = cosine * stepCosine - sine * stepSine; + const auto nextSine = sine * stepCosine + cosine * stepSine; + + cosine = nextCosine; + sine = nextSine; + } + } + + auto laneCosines = Register::loadUnaligned (laneCosine); + auto laneSines = Register::loadUnaligned (laneSine); + const auto blockCosines = Register::broadcast (cosine); + const auto blockSines = Register::broadcast (sine); + + const auto* cosineCoefficients = series.getCosineCoefficients().data(); + const auto* sineCoefficients = series.getSineCoefficients().data(); + + auto accumulator = Register::zero(); + + int harmonic = 1; + + while (harmonic + laneCount - 1 <= activeHarmonics) + { + const auto index = static_cast (harmonic - 1); + + accumulator = accumulator + .mulAdd (Register::loadUnaligned (cosineCoefficients + index), laneCosines) + .mulAdd (Register::loadUnaligned (sineCoefficients + index), laneSines); + + const auto nextCosines = laneCosines * blockCosines - laneSines * blockSines; + const auto nextSines = laneSines * blockCosines + laneCosines * blockSines; + + laneCosines = nextCosines; + laneSines = nextSines; + + harmonic += laneCount; + } + + auto value = accumulator.sum(); + + for (auto remaining = activeHarmonics - harmonic + 1, lane = 0; remaining > 0; --remaining, ++lane) + { + const auto index = static_cast (harmonic - 1 + lane); + + value += cosineCoefficients[index] * laneCosines[lane] + + sineCoefficients[index] * laneSines[lane]; + } + + if (includeDC) + value += series.getDC(); + + return value; + } + + //============================================================================== + FourierSeries series; + double sampleRate = 44100.0; + double phase = 0.0; + CoeffType frequency = static_cast (440); + int activeHarmonics = 0; + bool includeDC = false; +}; + +//============================================================================== +/** @name Convenience type aliases */ +using AdditiveOscillatorFloat = AdditiveOscillator; /**< float samples, double coefficients */ +using AdditiveOscillatorDouble = AdditiveOscillator; /**< double samples and coefficients */ + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_FourierSeries.h b/modules/yup_dsp/oscillators/yup_FourierSeries.h new file mode 100644 index 000000000..f2b055a1d --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_FourierSeries.h @@ -0,0 +1,383 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Waveforms produced by the convenient series presets. + + @see FourierSeries::setWaveform +*/ +enum class Waveform +{ + sine, /**< Single sine harmonic, b1 = 1 */ + cosine, /**< Single cosine harmonic, a1 = 1 */ + sawtooth, /**< Bandlimited sawtooth, b_n = -2 (-1)^n / (pi n) */ + square, /**< Bandlimited square, b_n = 4 / (pi n) for odd n */ + triangle, /**< Bandlimited triangle, b_n = -8 (-1)^k / (pi n)^2 for n = 2k - 1 */ + pulse /**< Bandlimited pulse, a_n = 1 / N */ +}; + +//============================================================================== +/** + Number of harmonics of a fundamental that fit strictly below the Nyquist + frequency. + + A harmonic n is audible when n * frequency < sampleRate / 2, so the returned + limit is ceil (sampleRate / (2 * frequency)) - 1, clamped to + [0, maxHarmonics]. + + @param frequency Oscillator fundamental frequency in Hz. + @param sampleRate Sample rate in Hz. + @param maxHarmonics Number of harmonics the caller is able to synthesize. +*/ +template +int getNyquistHarmonicLimit (FloatType frequency, double sampleRate, int maxHarmonics) noexcept +{ + if (maxHarmonics <= 0 || sampleRate <= 0.0) + return 0; + + const auto fundamental = static_cast (frequency); + + if (! (fundamental > 0.0)) + return maxHarmonics; + + const auto limit = sampleRate / (2.0 * fundamental); + + if (limit >= static_cast (maxHarmonics)) + return maxHarmonics; + + return jlimit (0, maxHarmonics, static_cast (std::ceil (limit)) - 1); +} + +//============================================================================== +/** + Fourier series of a periodic waveform. + + The series stores the real coefficients of + @code + r (t) = dc + sum (n = 1..N) (cosine[n] * cos (2 pi n t) + sine[n] * sin (2 pi n t)) + @endcode + where t runs over one period. It is the coefficient container shared by every + oscillator in this folder: additive synthesis reads the coefficients + directly, wavetable synthesis renders them with an inverse FFT, and the + synchronizing transform rewrites them. + + Coefficients are stored contiguously (index 0 is harmonic 1) so the + oscillator classes can load them with SIMD loads. + + @tparam CoeffType Precision used for the coefficients (default double). + + @see AdditiveOscillator, WavetableOscillator, SyncSpectralResampler +*/ +template +class FourierSeries +{ +public: + //============================================================================== + /** Creates an empty series with no harmonics. */ + FourierSeries() = default; + + /** Creates a series with a fixed number of harmonics, all zero. */ + explicit FourierSeries (int numHarmonics) + { + resize (numHarmonics); + } + + /** Resizes the series, zero-filling every coefficient. */ + void resize (int numHarmonics) + { + jassert (numHarmonics >= 0); + + const auto count = static_cast (jmax (0, numHarmonics)); + + cosine.assign (count, CoeffType (0)); + sine.assign (count, CoeffType (0)); + phasorCosine.assign (count, CoeffType (0)); + phasorSine.assign (count, CoeffType (0)); + dc = CoeffType (0); + } + + //============================================================================== + /** Returns the number of stored harmonics. */ + int getNumHarmonics() const noexcept { return static_cast (cosine.size()); } + + /** Sets every coefficient to zero, keeping the current size. */ + void clear() noexcept + { + dc = CoeffType (0); + + FloatVectorOperations::clear (cosine.data(), getNumHarmonics()); + FloatVectorOperations::clear (sine.data(), getNumHarmonics()); + } + + //============================================================================== + /** Returns the DC (harmonic 0) coefficient. */ + CoeffType getDC() const noexcept { return dc; } + + /** Sets the DC (harmonic 0) coefficient. */ + void setDC (CoeffType newDC) noexcept { dc = newDC; } + + /** Returns the cosine coefficient of a 1-based harmonic. */ + CoeffType getCosine (int harmonic) const noexcept + { + jassert (isPositiveAndBelow (harmonic - 1, getNumHarmonics())); + + return cosine[static_cast (harmonic - 1)]; + } + + /** Returns the sine coefficient of a 1-based harmonic. */ + CoeffType getSine (int harmonic) const noexcept + { + jassert (isPositiveAndBelow (harmonic - 1, getNumHarmonics())); + + return sine[static_cast (harmonic - 1)]; + } + + /** Sets both coefficients of a 1-based harmonic. */ + void setHarmonic (int harmonic, CoeffType newCosine, CoeffType newSine) noexcept + { + jassert (isPositiveAndBelow (harmonic - 1, getNumHarmonics())); + + cosine[static_cast (harmonic - 1)] = newCosine; + sine[static_cast (harmonic - 1)] = newSine; + } + + /** Returns the magnitude of a 1-based harmonic. */ + CoeffType getMagnitude (int harmonic) const noexcept + { + const auto a = getCosine (harmonic); + const auto b = getSine (harmonic); + + return std::sqrt (a * a + b * b); + } + + /** Returns the contiguous cosine coefficients, index 0 being harmonic 1. */ + Span getCosineCoefficients() const noexcept + { + return { cosine.data(), cosine.size() }; + } + + /** Returns the contiguous sine coefficients, index 0 being harmonic 1. */ + Span getSineCoefficients() const noexcept + { + return { sine.data(), sine.size() }; + } + + //============================================================================== + /** + Fills the existing harmonics with one of the convenient presets. + + The number of harmonics is not changed, so resize() first to pick the + bandwidth of the preset. + */ + void setWaveform (Waveform waveform) noexcept + { + clear(); + + const auto numHarmonics = getNumHarmonics(); + if (numHarmonics <= 0) + return; + + const auto pi = MathConstants::pi; + + switch (waveform) + { + case Waveform::sine: + setHarmonic (1, CoeffType (0), CoeffType (1)); + break; + + case Waveform::cosine: + setHarmonic (1, CoeffType (1), CoeffType (0)); + break; + + case Waveform::sawtooth: + for (int n = 1; n <= numHarmonics; ++n) + sine[static_cast (n - 1)] = static_cast ((n % 2 == 0) ? -2 : 2) / (pi * static_cast (n)); + break; + + case Waveform::square: + for (int n = 1; n <= numHarmonics; n += 2) + sine[static_cast (n - 1)] = static_cast (4) / (pi * static_cast (n)); + break; + + case Waveform::triangle: + for (int k = 1; 2 * k - 1 <= numHarmonics; ++k) + { + const auto n = 2 * k - 1; + const auto sign = (k % 2 == 0) ? static_cast (-1) : static_cast (1); + + sine[static_cast (n - 1)] = static_cast (-8) * sign / (pi * pi * static_cast (n * n)); + } + break; + + case Waveform::pulse: + for (int n = 1; n <= numHarmonics; ++n) + cosine[static_cast (n - 1)] = CoeffType (1) / static_cast (numHarmonics); + break; + } + } + + //============================================================================== + /** + Analyses one cycle of a waveform into the existing harmonics. + + The cycle is expected to cover exactly one period of the waveform, sampled + at evenly spaced points in [0, 1). This is an offline transform: it costs + O (numSamples * numHarmonics) and is not meant to run on the audio thread, + although it does not allocate. + + @param oneCycle Evenly spaced samples of a single waveform period. + */ + template + void setFromCycle (Span oneCycle) noexcept + { + clear(); + + const auto numSamples = static_cast (oneCycle.size()); + const auto numHarmonics = getNumHarmonics(); + + if (numSamples <= 0 || numHarmonics <= 0) + return; + + const auto measure = static_cast (numSamples); + CoeffType sum = CoeffType (0); + + for (int m = 0; m < numSamples; ++m) + sum += static_cast (oneCycle[static_cast (m)]); + + dc = sum / measure; + + for (int n = 1; n <= numHarmonics; ++n) + { + const auto angle = MathConstants::twoPi * static_cast (n) / measure; + const auto stepCosine = std::cos (angle); + const auto stepSine = std::sin (angle); + + CoeffType phasorCosine = CoeffType (1); + CoeffType phasorSine = CoeffType (0); + CoeffType accumulatedCosine = CoeffType (0); + CoeffType accumulatedSine = CoeffType (0); + + for (int m = 0; m < numSamples; ++m) + { + const auto sample = static_cast (oneCycle[static_cast (m)]); + + accumulatedCosine += sample * phasorCosine; + accumulatedSine += sample * phasorSine; + + if ((m + 1) % harmonicPhasorReseedInterval == 0) + { + const auto seedAngle = angle * static_cast (m + 1); + phasorCosine = std::cos (seedAngle); + phasorSine = std::sin (seedAngle); + } + else + { + const auto nextCosine = phasorCosine * stepCosine - phasorSine * stepSine; + const auto nextSine = phasorSine * stepCosine + phasorCosine * stepSine; + + phasorCosine = nextCosine; + phasorSine = nextSine; + } + } + + cosine[static_cast (n - 1)] = CoeffType (2) * accumulatedCosine / measure; + sine[static_cast (n - 1)] = CoeffType (2) * accumulatedSine / measure; + } + } + + //============================================================================== + /** + Rotates the phase of the whole series, so that it describes r (t - shift). + + Writing the series as r (t), the shifted series is r (t - normalizedShift), + with the shift expressed as a fraction of the period. For a sine series + this is the "pre-rotation" step of the alias-free synchronization method, + where it moves the leader's period boundary onto the follower's zero phase. + + @param normalizedShift Time shift in periods (negative values shift forward). + */ + void timeShift (CoeffType normalizedShift) noexcept + { + const auto numHarmonics = getNumHarmonics(); + if (numHarmonics <= 0) + return; + + fillHarmonicPhasors (phasorCosine.data(), phasorSine.data(), numHarmonics, MathConstants::twoPi * normalizedShift); + + for (int n = 1; n <= numHarmonics; ++n) + { + const auto index = static_cast (n - 1); + const auto a = cosine[index]; + const auto b = sine[index]; + const auto c = phasorCosine[index]; + const auto s = phasorSine[index]; + + cosine[index] = a * c - b * s; + sine[index] = a * s + b * c; + } + } + + //============================================================================== + /** + Copies another series into this one, keeping the current storage. + + Harmonics beyond the source size are zeroed, so the copy never leaves + stale coefficients behind and never allocates. + */ + void copyFrom (const FourierSeries& other) noexcept + { + const auto numHarmonics = getNumHarmonics(); + const auto numToCopy = jmin (numHarmonics, other.getNumHarmonics()); + + FloatVectorOperations::copy (cosine.data(), other.cosine.data(), numToCopy); + FloatVectorOperations::copy (sine.data(), other.sine.data(), numToCopy); + FloatVectorOperations::clear (cosine.data() + numToCopy, numHarmonics - numToCopy); + FloatVectorOperations::clear (sine.data() + numToCopy, numHarmonics - numToCopy); + + dc = other.dc; + } + + //============================================================================== + /** Creates a series of a given size filled with one of the presets. */ + static FourierSeries create (Waveform waveform, int numHarmonics) + { + FourierSeries result (numHarmonics); + result.setWaveform (waveform); + + return result; + } + +private: + //============================================================================== + CoeffType dc = CoeffType (0); + std::vector cosine; + std::vector sine; + std::vector phasorCosine; + std::vector phasorSine; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_SyncOscillator.h b/modules/yup_dsp/oscillators/yup_SyncOscillator.h new file mode 100644 index 000000000..b65cdfc77 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_SyncOscillator.h @@ -0,0 +1,349 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Alias-free synchronizing oscillator, ready to drop into a synthesizer voice. + + Owns a follower FourierSeries, the synced series the spectral resampler + produces from it, and both synthesis backends, so a voice only has to set a + frequency, a follower ratio and a sync mode. The output never aliases, + because the series being synthesized only ever holds harmonics below Nyquist. + + ``` + yup::SyncOscillator osc; + osc.prepare (44100.0); + osc.setWaveform (yup::Waveform::sawtooth); + osc.setSyncMode (yup::SyncMode::hard); + osc.setFrequency (220.0); + osc.setFollowerRatio (1.375); + osc.update(); // resample + render, once per block + osc.processBlock (buffer, numSamples); + ``` + + Pitch changes are phase-continuous and need no update(); changing the + synchronization parameters marks the oscillator dirty and needs one update() + before the change is heard. update() runs the O (N^2) spectral transform plus, + for the wavetable backend, one inverse FFT, so it belongs in the block loop, + not in the sample loop. + + Note that mirrored sync physically has a period of 2 * T_lead, so it sounds an + octave below the leader, exactly as the reference method describes: + getOutputFrequency() reports the fundamental that is actually synthesized. + + @tparam SampleType Type for the synthesized samples (float or double). + @tparam CoeffType Type for the coefficients and phase math (default double). + + @see FourierSeries, SyncSpectralResampler, AdditiveOscillator, WavetableOscillator +*/ +template +class SyncOscillator +{ +public: + //============================================================================== + /** Synthesis backend. */ + enum class Synthesis + { + wavetable, /**< Inverse FFT into a table, cheap per sample */ + additive /**< Exact additive synthesis, one multiply accumulate per harmonic and sample */ + }; + + //============================================================================== + /** Default constructor. Call prepare() before processing. */ + SyncOscillator() = default; + + //============================================================================== + /** + Allocates every backend and starts from a sine follower. + + @param sampleRate Sample rate in Hz. + @param maxHarmonics Maximum number of follower and output harmonics. + @param crossfadeLengthInSamples Length of the wavetable crossfade between renders. + */ + void prepare (double sampleRate, int maxHarmonics = 128, int crossfadeLengthInSamples = 64) + { + jassert (sampleRate > 0.0); + jassert (maxHarmonics > 0); + + this->sampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + this->maxHarmonics = jmax (1, maxHarmonics); + + follower.resize (this->maxHarmonics); + follower.setWaveform (Waveform::sine); + synced.resize (this->maxHarmonics); + + resampler.prepare (this->maxHarmonics); + additive.prepare (sampleRate, this->maxHarmonics); + wavetable.prepare (sampleRate, this->maxHarmonics, crossfadeLengthInSamples); + + followerRatio = CoeffType (1); + dirty = true; + + setFrequency (leaderFrequency); + setPhase (CoeffType (0)); + + additive.setIncludeDC (includeDC); + wavetable.setIncludeDC (includeDC); + + update(); + } + + /** Resets the playback phase of both backends. */ + void reset() noexcept + { + additive.reset(); + wavetable.reset(); + } + + //============================================================================== + /** Selects the synthesis backend, keeping the phase continuous. */ + void setSynthesis (Synthesis newSynthesis) noexcept + { + if (synthesis == newSynthesis) + return; + + const auto currentPhase = getPhase(); + + synthesis = newSynthesis; + + setPhase (currentPhase); + } + + /** Returns the active synthesis backend. */ + Synthesis getSynthesis() const noexcept { return synthesis; } + + //============================================================================== + /** + Sets the leader (note) pitch in Hz. + + The follower's pitch follows immediately as + frequency * getFundamentalScale (syncMode), which needs no update() because + both backends are phase-continuous in frequency. + */ + void setFrequency (CoeffType leaderHz) noexcept + { + leaderFrequency = jmax (leaderHz, CoeffType (0)); + + const auto outputFrequency = getOutputFrequency(); + + additive.setFrequency (outputFrequency); + wavetable.setFrequency (outputFrequency); + } + + /** Returns the leader pitch in Hz. */ + CoeffType getFrequency() const noexcept { return leaderFrequency; } + + /** Returns the fundamental that is actually synthesized, in Hz. */ + CoeffType getOutputFrequency() const noexcept + { + return leaderFrequency * SyncSpectralResampler::getFundamentalScale (syncMode); + } + + //============================================================================== + /** + Sets the follower ratio P = T_lead / T_follow = f_follower / f_lead. + + Changing the ratio marks the oscillator dirty; call update() to run the + spectral transform. Because the ratio is a factor rather than an absolute + frequency, it stays constant when the pitch changes. + */ + void setFollowerRatio (CoeffType ratio) noexcept + { + const auto sanitized = jmax (ratio, static_cast (1e-6)); + + if (! approximatelyEqual (followerRatio, sanitized)) + { + followerRatio = sanitized; + dirty = true; + } + } + + /** Returns the follower ratio. */ + CoeffType getFollowerRatio() const noexcept { return followerRatio; } + + /** + Sets the follower frequency in Hz, converting it to a period ratio. + + The conversion uses the current leader pitch, so call setFrequency() first. + The ratio then stays constant across later pitch changes, which is what a + keyboard-tracking sync oscillator wants. + */ + void setFollowerFrequency (CoeffType followerHz) noexcept + { + jassert (leaderFrequency > CoeffType (0)); + + if (leaderFrequency <= CoeffType (0)) + return; + + setFollowerRatio (followerHz / leaderFrequency); + } + + //============================================================================== + /** Sets the synchronization mode, marking the oscillator dirty. */ + void setSyncMode (SyncMode newSyncMode) noexcept + { + if (syncMode == newSyncMode) + return; + + syncMode = newSyncMode; + dirty = true; + + setFrequency (leaderFrequency); + } + + /** Returns the synchronization mode. */ + SyncMode getSyncMode() const noexcept { return syncMode; } + + //============================================================================== + /** Fills the follower with one of the convenient presets, marking it dirty. */ + void setWaveform (Waveform waveform) noexcept + { + follower.setWaveform (waveform); + dirty = true; + } + + /** Copies a follower series in, marking the oscillator dirty. */ + void setFollowerSeries (const FourierSeries& newFollower) noexcept + { + follower.copyFrom (newFollower); + dirty = true; + } + + /** Returns the follower series. */ + const FourierSeries& getFollowerSeries() const noexcept { return follower; } + + /** Returns the synchronized series, useful for visualization. */ + const FourierSeries& getSyncedSeries() const noexcept { return synced; } + + //============================================================================== + /** Sets the phase of both backends, normalized to one period. */ + void setPhase (CoeffType newPhase) noexcept + { + additive.setPhase (newPhase); + wavetable.setPhase (newPhase); + } + + /** Returns the phase of the active backend, normalized to one period. */ + CoeffType getPhase() const noexcept + { + return synthesis == Synthesis::additive ? additive.getPhase() : wavetable.getPhase(); + } + + /** Selects whether the synthesized series includes its DC coefficient. */ + void setIncludeDC (bool shouldIncludeDC) noexcept + { + if (includeDC == shouldIncludeDC) + return; + + includeDC = shouldIncludeDC; + + additive.setIncludeDC (shouldIncludeDC); + wavetable.setIncludeDC (shouldIncludeDC); + } + + /** Returns whether the synthesized series includes its DC coefficient. */ + bool getIncludeDC() const noexcept { return includeDC; } + + //============================================================================== + /** Returns true when update() would change the output. */ + bool needsUpdate() const noexcept + { + return dirty || wavetable.needsRender(); + } + + /** + Runs the spectral transform and refreshes the wavetable, if needed. + + Costs a worst case O (N^2) transform plus one inverse FFT, and allocates + nothing, so it can be called from the audio thread once per block. When + nothing is dirty and the wavetable is up to date it does no work at all, + which makes it safe to call unconditionally. + */ + void update() noexcept + { + if (dirty) + { + const auto numOutputHarmonics = jmin (maxHarmonics, + SyncSpectralResampler::getRecommendedOutputHarmonics (follower.getNumHarmonics(), + followerRatio, + syncMode)); + + resampler.transform (follower, followerRatio, syncMode, synced, numOutputHarmonics); + + additive.setSeries (synced); + wavetable.setSeries (synced); + + dirty = false; + } + + if (wavetable.needsRender()) + wavetable.render(); + } + + //============================================================================== + /** Produces one sample with the active backend. */ + SampleType processSample() noexcept + { + return synthesis == Synthesis::additive ? additive.processSample() + : wavetable.processSample(); + } + + /** Produces a block of samples with the active backend. */ + void processBlock (SampleType* output, int numSamples) noexcept + { + if (output == nullptr) + return; + + if (synthesis == Synthesis::additive) + additive.processBlock (output, numSamples); + else + wavetable.processBlock (output, numSamples); + } + + //============================================================================== +private: + //============================================================================== + SyncSpectralResampler resampler; + AdditiveOscillator additive; + WavetableOscillator wavetable; + FourierSeries follower; + FourierSeries synced; + double sampleRate = 44100.0; + CoeffType leaderFrequency = static_cast (440); + CoeffType followerRatio = CoeffType (1); + SyncMode syncMode = SyncMode::none; + Synthesis synthesis = Synthesis::wavetable; + int maxHarmonics = 128; + bool includeDC = false; + bool dirty = true; +}; + +//============================================================================== +/** @name Convenience type aliases */ +using SyncOscillatorFloat = SyncOscillator; /**< float samples, double coefficients */ +using SyncOscillatorDouble = SyncOscillator; /**< double samples and coefficients */ + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h new file mode 100644 index 000000000..131547e12 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h @@ -0,0 +1,418 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Synchronization flavors of the alias-free oscillator method. + + Each flavor corresponds to one of the three time-domain constructions in + Roth, Keller, Castaneda and Studer, "Alias-Free Oscillator Synchronization + via Additive Synthesis" (DAFx26). + + @see SyncSpectralResampler +*/ +enum class SyncMode +{ + none, /**< No synchronization, the follower is passed through untouched */ + hard, /**< Hard sync: s (t) = r (t + P / 2) on [-P / 2, P / 2), period P */ + mirrored, /**< Mirrored sync: s (t) = r (P - |t|) on [-P, P), period 2 P */ + pulsar /**< Pulsar sync: one follower period in a window of width 1, period P */ +}; + +//============================================================================== +/** + Rewrites the Fourier coefficients of a free-running follower into the ones of + a synchronized oscillator. + + For a follower with period ratio P = T_lead / T_follow, the synchronized + waveform is periodic again and its Fourier coefficients are a linear + transform of the follower's: this class applies that transform (a "spectral + resampling" of the follower's spectrum) so the result can be synthesized + additively. Because only harmonics below Nyquist are synthesized, the output + is alias-free by construction. + + Both the follower and the output are FourierSeries objects, so the caller + controls the follower bandwidth and how many output harmonics are wanted + (Remark 6 of the paper allows N_out != N_in). The transform cannot create + harmonics that the follower does not have: bandlimiting the follower to N + harmonics caps the spectrum of the synchronized waveform at N * P (Remark 13). + + The transform costs O (N_in * N_out) multiply-accumulates with O (N_in) + transcendental calls, using FloatVectorOperations and, where helpful, + SIMDRegister. It is allocation-free once prepare() has been called, so it can + run on the audio thread, but a synthesizer normally calls it once per block + instead of once per sample. + + The absolute phase of the output follows the paper's pre-rotation: relative to + the raw time-domain definitions above, the output is the same waveform delayed + by half a period of the output fundamental, i.e. its coefficients carry an + extra factor (-1)^n. + + @tparam CoeffType Precision used for the transform (default double). + + @see FourierSeries, SyncOscillator +*/ +template +class SyncSpectralResampler +{ +public: + //============================================================================== + /** Creates an unprepared resampler. Call prepare() before transform(). */ + SyncSpectralResampler() = default; + + //============================================================================== + /** + Allocates the scratch storage for series of up to maxHarmonics harmonics. + + This is the only allocating method, so it must be called outside of the + audio callback. + */ + void prepare (int maxHarmonics) + { + jassert (maxHarmonics > 0); + + const auto count = static_cast (jmax (1, maxHarmonics)); + + firstWeight.assign (count, CoeffType (0)); + secondWeight.assign (count, CoeffType (0)); + argumentSquared.assign (count, CoeffType (0)); + triggerSine.assign (count, CoeffType (0)); + triggerCosine.assign (count, CoeffType (0)); + inverseArgument.assign (count, CoeffType (0)); + weight.assign (count, CoeffType (0)); + + resonantIndices.reserve (count); + resonantHarmonics.reserve (count); + + rotated.resize (jmax (1, maxHarmonics)); + } + + //============================================================================== + /** + Runs the synchronization transform. + + @param follower Follower coefficients, resized to the follower bandwidth. + @param periodRatio P = T_lead / T_follow, must be positive. + @param mode Synchronization flavor to apply. + @param output Destination series, already sized to the wanted + number of harmonics and no larger than the + maxHarmonics given to prepare(). + @param numOutputHarmonics Number of output harmonics to compute (-1 for + all of them). The remaining ones are zeroed + without reallocating, which lets a caller + follow a changing Nyquist limit. + */ + void transform (const FourierSeries& follower, + CoeffType periodRatio, + SyncMode mode, + FourierSeries& output, + int numOutputHarmonics = -1) noexcept + { + jassert (periodRatio > CoeffType (0)); + jassert (follower.getNumHarmonics() <= rotated.getNumHarmonics()); + jassert (output.getNumHarmonics() <= rotated.getNumHarmonics()); + + if (mode == SyncMode::none) + { + output.copyFrom (follower); + return; + } + + const auto maxHarmonics = rotated.getNumHarmonics(); + if (maxHarmonics <= 0) + return; + + const auto ratio = jmax (periodRatio, static_cast (1e-9)); + const auto numOut = numOutputHarmonics < 0 ? output.getNumHarmonics() + : jlimit (0, output.getNumHarmonics(), numOutputHarmonics); + const auto numFollower = jmin (follower.getNumHarmonics(), maxHarmonics); + + output.clear(); + + rotated.copyFrom (follower); + rotated.timeShift (getPreRotation (mode, ratio)); + + if (numFollower <= 0) + return; + + switch (mode) + { + case SyncMode::hard: transformHard (ratio, numFollower, output, numOut); break; + case SyncMode::mirrored: transformMirrored (ratio, numFollower, output, numOut); break; + case SyncMode::pulsar: transformPulsar (ratio, numFollower, output, numOut); break; + case SyncMode::none: break; + } + } + + //============================================================================== + /** Returns the ratio between the output fundamental and the leader frequency. + + Mirrored sync produces a waveform with period 2 * T_lead, so it sounds an + octave below the leader; every other mode has the leader's period. + */ + static constexpr CoeffType getFundamentalScale (SyncMode mode) noexcept + { + return mode == SyncMode::mirrored ? static_cast (0.5) : static_cast (1); + } + + /** Returns the pre-rotation time shift applied to the follower, in follower periods. */ + static constexpr CoeffType getPreRotation (SyncMode mode, CoeffType periodRatio) noexcept + { + return mode == SyncMode::hard ? -periodRatio / static_cast (2) + : mode == SyncMode::mirrored ? -periodRatio + : static_cast (-0.5); + } + + /** Returns how many output harmonics are needed to keep the follower's bandwidth. + + The follower's top harmonic sits at N * P times the leader frequency, and + the output fundamental is the leader frequency scaled by + getFundamentalScale(), hence ceil (N * P / scale). The caller is expected + to clamp the result to its own harmonic budget, in which case the upper + harmonics of the follower are truncated. + */ + static int getRecommendedOutputHarmonics (int numFollowerHarmonics, CoeffType periodRatio, SyncMode mode) noexcept + { + const auto scale = static_cast (getFundamentalScale (mode)); + const auto count = static_cast (jmax (0, numFollowerHarmonics)) * static_cast (periodRatio) / scale; + + return static_cast (std::ceil (count)); + } + + /** Returns the tolerance used to detect the removable singularities of the transform. */ + static constexpr CoeffType getResonanceEpsilon() noexcept + { + return std::is_same_v ? static_cast (1e-3) + : static_cast (1e-6); + } + +private: + //============================================================================== + void transformHard (CoeffType ratio, int numFollower, FourierSeries& output, int numOut) noexcept + { + const auto* followerCosine = rotated.getCosineCoefficients().data(); + const auto* followerSine = rotated.getSineCoefficients().data(); + const auto pi = MathConstants::pi; + + resonantIndices.clear(); + resonantHarmonics.clear(); + + for (int k = 1; k <= numFollower; ++k) + { + const auto index = static_cast (k - 1); + const auto argument = ratio * static_cast (k); + const auto trigger = std::sin (pi * argument); + const auto rounded = std::round (argument); + + argumentSquared[index] = argument * argument; + triggerSine[index] = trigger; + firstWeight[index] = followerCosine[index] * trigger * argument; + secondWeight[index] = followerSine[index] * trigger; + inverseArgument[index] = CoeffType (1) / (pi * argument); + + if (std::abs (argument - rounded) < getResonanceEpsilon()) + { + resonantIndices.push_back (static_cast (index)); + resonantHarmonics.push_back (static_cast (rounded)); + } + } + + FloatVectorOperations::multiply (weight.data(), followerCosine, triggerSine.data(), numFollower); + output.setDC (rotated.getDC() + FloatVectorOperations::dotProduct (weight.data(), inverseArgument.data(), numFollower)); + + const auto numResonant = static_cast (resonantIndices.size()); + + for (int n = 1; n <= numOut; ++n) + { + const auto nSquared = static_cast (n) * static_cast (n); + + FloatVectorOperations::fill (weight.data(), nSquared, numFollower); + FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); + FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); + + for (int i = 0; i < numResonant; ++i) + weight[static_cast (resonantIndices[static_cast (i)])] = CoeffType (0); + + const auto sign = (n % 2 == 0) ? static_cast (-1) : static_cast (1); + + auto a = sign * (CoeffType (2) / pi) * FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower); + auto b = sign * (CoeffType (2 * n) / pi) * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower); + + for (int i = 0; i < numResonant; ++i) + { + if (n != resonantHarmonics[static_cast (i)]) + continue; + + const auto index = static_cast (resonantIndices[static_cast (i)]); + a += followerCosine[index]; + b += followerSine[index]; + } + + output.setHarmonic (n, a, b); + } + } + + //============================================================================== + void transformMirrored (CoeffType ratio, int numFollower, FourierSeries& output, int numOut) noexcept + { + const auto* followerCosine = rotated.getCosineCoefficients().data(); + const auto* followerSine = rotated.getSineCoefficients().data(); + const auto pi = MathConstants::pi; + + resonantIndices.clear(); + resonantHarmonics.clear(); + + for (int k = 1; k <= numFollower; ++k) + { + const auto index = static_cast (k - 1); + const auto argument = static_cast (2) * ratio * static_cast (k); + const auto rounded = std::round (argument); + + argumentSquared[index] = argument * argument; + triggerSine[index] = std::sin (pi * argument); + triggerCosine[index] = std::cos (pi * argument); + firstWeight[index] = followerSine[index] * argument; + secondWeight[index] = -(followerCosine[index] * triggerSine[index] + followerSine[index] * triggerCosine[index]) * argument; + inverseArgument[index] = CoeffType (1) / (pi * argument); + weight[index] = followerCosine[index] * triggerSine[index] - followerSine[index] * (CoeffType (1) - triggerCosine[index]); + + if (std::abs (argument - rounded) < getResonanceEpsilon()) + { + resonantIndices.push_back (static_cast (index)); + resonantHarmonics.push_back (static_cast (rounded)); + } + } + + output.setDC (rotated.getDC() + FloatVectorOperations::dotProduct (weight.data(), inverseArgument.data(), numFollower)); + + const auto numResonant = static_cast (resonantIndices.size()); + + for (int n = 1; n <= numOut; ++n) + { + const auto nSquared = static_cast (n) * static_cast (n); + + FloatVectorOperations::fill (weight.data(), nSquared, numFollower); + FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); + FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); + + for (int i = 0; i < numResonant; ++i) + weight[static_cast (resonantIndices[static_cast (i)])] = CoeffType (0); + + const auto alternating = (n % 2 == 0) ? static_cast (1) : static_cast (-1); + + auto a = (CoeffType (2) / pi) + * (FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower) + + alternating * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower)); + + for (int i = 0; i < numResonant; ++i) + { + const auto harmonic = resonantHarmonics[static_cast (i)]; + const auto index = static_cast (resonantIndices[static_cast (i)]); + + if (n == harmonic) + { + a += followerCosine[index]; + } + else if (((n + harmonic) % 2) != 0) + { + // The mirrored weight vector has a removable pole at |n| = 2 k P whose + // numerator only vanishes in the n == harmonic case, so the contribution + // of a resonant harmonic has to be added back explicitly here. + const auto m = static_cast (harmonic); + + a += static_cast (4) * m * followerSine[index] / (pi * (nSquared - m * m)); + } + } + + output.setHarmonic (n, a, CoeffType (0)); + } + } + + //============================================================================== + void transformPulsar (CoeffType ratio, int numFollower, FourierSeries& output, int numOut) noexcept + { + const auto* followerCosine = rotated.getCosineCoefficients().data(); + const auto* followerSine = rotated.getSineCoefficients().data(); + const auto pi = MathConstants::pi; + + for (int k = 1; k <= numFollower; ++k) + { + const auto index = static_cast (k - 1); + const auto sign = (k % 2 == 0) ? static_cast (1) : static_cast (-1); + + argumentSquared[index] = static_cast (k * k); + firstWeight[index] = sign * followerCosine[index]; + secondWeight[index] = sign * static_cast (k) * followerSine[index]; + } + + // The paper's pulsar transform drops the follower's DC term. + output.setDC (CoeffType (0)); + + for (int n = 1; n <= numOut; ++n) + { + const auto q = static_cast (n) / ratio; + const auto rounded = std::round (q); + const auto isResonant = std::abs (q - rounded) < getResonanceEpsilon() + && rounded >= CoeffType (1) + && rounded <= static_cast (numFollower); + const auto resonantIndex = isResonant ? static_cast (rounded) - 1 : 0; + + FloatVectorOperations::fill (weight.data(), q * q, numFollower); + FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); + FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); + + if (isResonant) + weight[resonantIndex] = CoeffType (0); + + const auto sine = std::sin (pi * q); + + auto a = (CoeffType (2) * q * sine / (pi * ratio)) * FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower); + auto b = (CoeffType (2) * sine / (pi * ratio)) * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower); + + if (isResonant) + { + a += followerCosine[resonantIndex] / ratio; + b += followerSine[resonantIndex] / ratio; + } + + output.setHarmonic (n, a, b); + } + } + + //============================================================================== + FourierSeries rotated; + std::vector firstWeight; + std::vector secondWeight; + std::vector argumentSquared; + std::vector triggerSine; + std::vector triggerCosine; + std::vector inverseArgument; + std::vector weight; + std::vector resonantIndices; + std::vector resonantHarmonics; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_WavetableOscillator.h b/modules/yup_dsp/oscillators/yup_WavetableOscillator.h new file mode 100644 index 000000000..039f90a02 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_WavetableOscillator.h @@ -0,0 +1,345 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Wavetable oscillator rendering a FourierSeries with an inverse FFT. + + A FourierSeries is rendered once into a single-cycle table and then played + back with 4-point Hermite interpolation, which makes the per-sample cost a + couple of table reads instead of one multiply accumulate per harmonic. The + table is oversampled 8x with respect to the series bandwidth, which keeps the + interpolation images below roughly -70 dB. + + Renders are crossfaded: rendering a new table while the previous one is still + playing ramps between them over a configurable number of samples, so a + synchronization parameter change does not click. This is the cheap backend of + the two the oscillators in this folder offer, at the price of a coarser + control over when the harmonic content is refreshed. + + Rendering costs one inverse FFT (about 10 to 50 us for the default table + size), so it belongs in an update() called once per block, not in the audio + callback. Everything else, including processSample(), is allocation-free. + + @tparam SampleType Type for the synthesized samples (float or double). + @tparam CoeffType Type for the coefficients and phase math (default double). + + @see FourierSeries, AdditiveOscillator, SyncOscillator +*/ +template +class WavetableOscillator +{ +public: + //============================================================================== + /** Smallest table the renderer will use. */ + static constexpr int minimumTableSize = 64; + + /** Largest table the renderer will use. */ + static constexpr int maximumTableSize = 32768; + + /** Table oversampling factor relative to the series bandwidth. */ + static constexpr int tableOversampling = 8; + + //============================================================================== + /** Default constructor. Call prepare() before processing. */ + WavetableOscillator() = default; + + //============================================================================== + /** + Allocates the render buffers and the FFT plan. + + @param sampleRate Sample rate in Hz. + @param maxHarmonics Maximum number of harmonics to render. + @param crossfadeLengthInSamples Length of the crossfade between two renders. + */ + void prepare (double sampleRate, int maxHarmonics, int crossfadeLengthInSamples = 64) + { + jassert (sampleRate > 0.0); + jassert (maxHarmonics >= 0); + jassert (maxHarmonics <= 4096); + + this->sampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + crossfadeLength = jmax (1, crossfadeLengthInSamples); + crossfadeStep = CoeffType (1) / static_cast (crossfadeLength); + + series.resize (jmax (0, maxHarmonics)); + + tableSize = jlimit (minimumTableSize, + maximumTableSize, + nextPowerOfTwo (jmax (1, tableOversampling * maxHarmonics))); + tableMask = tableSize - 1; + + fft = std::make_unique> (tableSize); + fft->setScaling (FFTProcessor::FFTScaling::none); + + spectrum.assign (static_cast (tableSize) * 2, 0.0f); + renderBuffer.assign (static_cast (tableSize), 0.0f); + current.assign (static_cast (tableSize), SampleType (0)); + next.assign (static_cast (tableSize), SampleType (0)); + + renderedHarmonics = 0; + seriesChanged = true; + crossfadePosition = CoeffType (1); + + reset(); + } + + /** Resets the playback phase. The rendered tables are kept. */ + void reset() noexcept + { + phase = 0.0; + } + + //============================================================================== + /** Sets the playback frequency in Hz. */ + void setFrequency (CoeffType newFrequency) noexcept + { + const auto sanitized = jmax (newFrequency, static_cast (0)); + + if (! approximatelyEqual (frequency, sanitized)) + frequency = sanitized; + } + + /** Returns the playback frequency in Hz. */ + CoeffType getFrequency() const noexcept { return frequency; } + + /** Sets the phase, normalized to one period. */ + void setPhase (CoeffType newPhase) noexcept + { + phase = static_cast (newPhase - std::floor (newPhase)); + } + + /** Returns the phase, normalized to one period. */ + CoeffType getPhase() const noexcept { return static_cast (phase); } + + //============================================================================== + /** Copies a series into the oscillator. The table is not rendered until render(). */ + void setSeries (const FourierSeries& newSeries) noexcept + { + series.copyFrom (newSeries); + seriesChanged = true; + } + + /** Fills the series with one of the convenient presets, pending a render. */ + void setWaveform (Waveform waveform) noexcept + { + series.setWaveform (waveform); + seriesChanged = true; + } + + /** Returns the series being rendered. */ + const FourierSeries& getSeries() const noexcept { return series; } + + /** Selects whether the series' DC coefficient is rendered (off by default). */ + void setIncludeDC (bool shouldIncludeDC) noexcept + { + if (includeDC != shouldIncludeDC) + { + includeDC = shouldIncludeDC; + seriesChanged = true; + } + } + + /** Returns whether the DC coefficient is rendered. */ + bool getIncludeDC() const noexcept { return includeDC; } + + //============================================================================== + /** Returns true when the rendered table no longer matches the series and pitch. + + A render is needed when the series changed, when the pitch rose far enough + that the rendered harmonics would alias, or when the pitch dropped far + enough that more harmonics than one eighth of the rendered ones became + available again - the latter keeps a vibrato from triggering an inverse FFT + on every block. + */ + bool needsRender() const noexcept + { + if (seriesChanged) + return true; + + const auto limit = jmin (getNyquistHarmonicLimit (frequency, sampleRate, series.getNumHarmonics()), + tableSize / 2 - 1); + + if (limit < renderedHarmonics) + return true; + + return limit > renderedHarmonics + jmax (1, renderedHarmonics / 8); + } + + //============================================================================== + /** + Renders the series into the next table and starts crossfading towards it. + + An in-progress crossfade is baked into the current table first, so no + rendition is lost. The inverse FFT is unnormalized, and the bins are filled + with half the coefficients, which makes the result the series itself: the + complex bin n of a signal a cos (2 pi n t) + b sin (2 pi n t) is a / 2 for + the real part and -b / 2 for the imaginary one. + */ + void render() noexcept + { + if (fft == nullptr || tableSize <= 0) + return; + + if (crossfadePosition < CoeffType (1)) + { + FloatVectorOperations::subtract (next.data(), current.data(), tableSize); + FloatVectorOperations::addWithMultiply (current.data(), next.data(), static_cast (crossfadePosition), tableSize); + } + + FloatVectorOperations::clear (spectrum.data(), tableSize * 2); + + const auto limit = jmin (getNyquistHarmonicLimit (frequency, sampleRate, series.getNumHarmonics()), + tableSize / 2 - 1); + + const auto* cosineCoefficients = series.getCosineCoefficients().data(); + const auto* sineCoefficients = series.getSineCoefficients().data(); + const auto half = static_cast (0.5); + + for (int n = 1; n <= limit; ++n) + { + const auto index = static_cast (n - 1); + + spectrum[static_cast (2 * n)] = static_cast (cosineCoefficients[index]) * half; + spectrum[static_cast (2 * n + 1)] = static_cast (-sineCoefficients[index]) * half; + } + + if (includeDC) + spectrum[0] = static_cast (series.getDC()); + + fft->performRealFFTInverse (spectrum.data(), renderBuffer.data()); + + if constexpr (std::is_same_v) + FloatVectorOperations::copy (next.data(), renderBuffer.data(), tableSize); + else + FloatVectorOperations::convertFloatToDouble (next.data(), renderBuffer.data(), tableSize); + + renderedHarmonics = limit; + seriesChanged = false; + crossfadePosition = CoeffType (0); + } + + //============================================================================== + /** Returns the rendered table size in samples. */ + int getTableSize() const noexcept { return tableSize; } + + /** Returns the number of harmonics present in the rendered table. */ + int getNumRenderedHarmonics() const noexcept { return renderedHarmonics; } + + /** Returns the number of harmonics the oscillator can render. */ + int getNumHarmonics() const noexcept { return series.getNumHarmonics(); } + + //============================================================================== + /** Produces one sample. + + While a pitch is rising between two update() calls the top rendered harmonic + can drift slightly past Nyquist until the next render, so callers are + expected to refresh once per block with update() on the owning facade. + */ + SampleType processSample() noexcept + { + auto value = readTable (current); + + if (crossfadePosition < CoeffType (1)) + { + const auto target = readTable (next); + + value += (target - value) * static_cast (crossfadePosition); + + crossfadePosition += crossfadeStep; + + if (crossfadePosition >= CoeffType (1)) + { + crossfadePosition = CoeffType (1); + std::swap (current, next); + } + } + + const auto increment = static_cast (frequency) / sampleRate; + + phase += increment; + phase -= std::floor (phase); + + return value; + } + + /** Produces a block of samples. */ + void processBlock (SampleType* output, int numSamples) noexcept + { + if (output == nullptr) + return; + + for (int i = 0; i < numSamples; ++i) + output[i] = processSample(); + } + +private: + //============================================================================== + SampleType readTable (const std::vector& table) const noexcept + { + const auto position = phase * static_cast (tableSize); + const auto index = static_cast (position) & tableMask; + const auto fraction = static_cast (position - std::floor (position)); + + const auto y0 = static_cast (table[static_cast ((index - 1) & tableMask)]); + const auto y1 = static_cast (table[static_cast (index)]); + const auto y2 = static_cast (table[static_cast ((index + 1) & tableMask)]); + const auto y3 = static_cast (table[static_cast ((index + 2) & tableMask)]); + + const auto c0 = y1; + const auto c1 = static_cast (0.5) * (y2 - y0); + const auto c2 = y0 - static_cast (2.5) * y1 + static_cast (2) * y2 - static_cast (0.5) * y3; + const auto c3 = static_cast (0.5) * (y3 - y0) + static_cast (1.5) * (y1 - y2); + + return static_cast (((c3 * fraction + c2) * fraction + c1) * fraction + c0); + } + + //============================================================================== + std::unique_ptr> fft; + FourierSeries series; + std::vector spectrum; + std::vector renderBuffer; + std::vector current; + std::vector next; + double sampleRate = 44100.0; + double phase = 0.0; + CoeffType frequency = static_cast (440); + CoeffType crossfadeStep = CoeffType (0); + CoeffType crossfadePosition = CoeffType (1); + int tableSize = 0; + int tableMask = 0; + int crossfadeLength = 64; + int renderedHarmonics = 0; + bool seriesChanged = true; + bool includeDC = false; +}; + +//============================================================================== +/** @name Convenience type aliases */ +using WavetableOscillatorFloat = WavetableOscillator; /**< float samples, double coefficients */ +using WavetableOscillatorDouble = WavetableOscillator; /**< double samples and coefficients */ + +} // namespace yup diff --git a/modules/yup_dsp/utilities/yup_DspMath.h b/modules/yup_dsp/utilities/yup_DspMath.h index 27dce5e93..eb43c9d47 100644 --- a/modules/yup_dsp/utilities/yup_DspMath.h +++ b/modules/yup_dsp/utilities/yup_DspMath.h @@ -60,6 +60,57 @@ constexpr FloatType angularToFrequency (FloatType omega, FloatType sampleRate) n //============================================================================== +/** Number of harmonics generated between two exact trigonometric re-seedings. */ +inline constexpr int harmonicPhasorReseedInterval = 64; + +/** Fills two arrays with the cosines and sines of the harmonic angles k * angle. + + Entry i holds the phasor of harmonic i + 1, so cosines[i] is + cos ((i + 1) * angle) and sines[i] is sin ((i + 1) * angle). The values are + generated with a rotating-phasor recurrence that is re-seeded from + std::cos / std::sin every harmonicPhasorReseedInterval harmonics, which costs + a couple of transcendental calls per block instead of one per harmonic while + keeping the accumulated error bounded. + + @param cosines Destination array for the cosines, must hold count entries. + @param sines Destination array for the sines, must hold count entries. + @param count Number of harmonics to generate. + @param angle Fundamental angle in radians. +*/ +template +void fillHarmonicPhasors (FloatType* cosines, FloatType* sines, int count, FloatType angle) noexcept +{ + if (count <= 0) + return; + + const auto stepCosine = std::cos (angle); + const auto stepSine = std::sin (angle); + + FloatType cosine = stepCosine; + FloatType sine = stepSine; + + for (int harmonic = 1; harmonic <= count; ++harmonic) + { + if ((harmonic - 1) % harmonicPhasorReseedInterval == 0) + { + const auto seedAngle = angle * static_cast (harmonic); + cosine = std::cos (seedAngle); + sine = std::sin (seedAngle); + } + + cosines[harmonic - 1] = cosine; + sines[harmonic - 1] = sine; + + const auto nextCosine = cosine * stepCosine - sine * stepSine; + const auto nextSine = sine * stepCosine + cosine * stepSine; + + cosine = nextCosine; + sine = nextSine; + } +} + +//============================================================================== + /** Converts Q factor to bandwidth (octaves) */ template constexpr FloatType qToBandwidth (FloatType q) noexcept diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 720518cbf..ea0c21ebe 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -140,6 +140,13 @@ #include "frequency/yup_FFTProcessor.h" #include "frequency/yup_SpectrumAnalyzerState.h" +// Oscillators (need FFTProcessor for the wavetable renderer) +#include "oscillators/yup_FourierSeries.h" +#include "oscillators/yup_SyncSpectralResampler.h" +#include "oscillators/yup_AdditiveOscillator.h" +#include "oscillators/yup_WavetableOscillator.h" +#include "oscillators/yup_SyncOscillator.h" + // Onset detection #include "onsets/yup_FilterBank.h" #include "onsets/yup_Spectrogram.h" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index 0ce49e06a..4fed482f0 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -20,6 +20,7 @@ */ #include "yup_dsp/yup_AaIirAntialiaser.cpp" +#include "yup_dsp/yup_AdditiveOscillator.cpp" #include "yup_dsp/yup_AnalogFilters.cpp" #include "yup_dsp/yup_BiquadCascade.cpp" #include "yup_dsp/yup_BiquadFilter.cpp" @@ -31,6 +32,7 @@ #include "yup_dsp/yup_FFTProcessor.cpp" #include "yup_dsp/yup_FilterDesigner.cpp" #include "yup_dsp/yup_FirstOrderFilter.cpp" +#include "yup_dsp/yup_FourierSeries.cpp" #include "yup_dsp/yup_FractionallyAddressedDelay.cpp" #include "yup_dsp/yup_KMeterState.cpp" #include "yup_dsp/yup_LevelProcessor.cpp" @@ -46,5 +48,8 @@ #include "yup_dsp/yup_SoftClipper.cpp" #include "yup_dsp/yup_SpectrumAnalyzerState.cpp" #include "yup_dsp/yup_StateVariableFilter.cpp" +#include "yup_dsp/yup_SyncOscillator.cpp" +#include "yup_dsp/yup_SyncSpectralResampler.cpp" #include "yup_dsp/yup_TimeStretchProcessor.cpp" +#include "yup_dsp/yup_WavetableOscillator.cpp" #include "yup_dsp/yup_WindowFunctions.cpp" diff --git a/tests/yup_dsp/yup_AdditiveOscillator.cpp b/tests/yup_dsp/yup_AdditiveOscillator.cpp new file mode 100644 index 000000000..327511c38 --- /dev/null +++ b/tests/yup_dsp/yup_AdditiveOscillator.cpp @@ -0,0 +1,224 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== +class AdditiveOscillatorTests : public ::testing::Test +{ +protected: + /** Scalar evaluation of the harmonics the oscillator is expected to synthesize. */ + static double harmonicReference (const FourierSeries& series, int numHarmonics, double frequency, double phase) + { + double value = 0.0; + + for (int n = 1; n <= numHarmonics; ++n) + { + if (n * frequency >= testSampleRate / 2) + break; + + const auto angle = MathConstants::twoPi * n * phase; + + value += series.getCosine (n) * std::cos (angle) + + series.getSine (n) * std::sin (angle); + } + + return value; + } + + static AdditiveOscillator makeOscillator (int numHarmonics, Waveform waveform, double frequency) + { + AdditiveOscillator oscillator; + + oscillator.prepare (testSampleRate, numHarmonics); + oscillator.setWaveform (waveform); + oscillator.setFrequency (frequency); + + return oscillator; + } + + static constexpr double testSampleRate = 48000.0; +}; + +TEST_F (AdditiveOscillatorTests, SineMatchesAnalyticSine) +{ + auto oscillator = makeOscillator (64, Waveform::sine, 1000.0); + + const auto increment = 1000.0 / testSampleRate; + + for (int i = 0; i < 480; ++i) + EXPECT_NEAR (std::sin (MathConstants::twoPi * increment * i), oscillator.processSample(), 1e-9) << i; +} + +TEST_F (AdditiveOscillatorTests, HarmonicLimitFollowsNyquist) +{ + auto oscillator = makeOscillator (512, Waveform::sawtooth, 10000.0); + + EXPECT_EQ (2, oscillator.getNumActiveHarmonics()); + + oscillator.setFrequency (30000.0); + EXPECT_EQ (0, oscillator.getNumActiveHarmonics()); + EXPECT_EQ (0.0, oscillator.processSample()); + + oscillator.setFrequency (1000.0); + EXPECT_EQ (23, oscillator.getNumActiveHarmonics()); + + oscillator.setFrequency (1.0e-6); + EXPECT_EQ (512, oscillator.getNumActiveHarmonics()); +} + +TEST_F (AdditiveOscillatorTests, BlockMatchesSampleBySample) +{ + auto blockOscillator = makeOscillator (32, Waveform::sawtooth, 1500.0); + auto sampleOscillator = makeOscillator (32, Waveform::sawtooth, 1500.0); + + std::vector buffer (256); + + blockOscillator.processBlock (buffer.data(), 256); + + for (int i = 0; i < 256; ++i) + EXPECT_EQ (buffer[static_cast (i)], sampleOscillator.processSample()) << i; +} + +TEST_F (AdditiveOscillatorTests, PhaseWrapsAndCanBeSet) +{ + auto oscillator = makeOscillator (8, Waveform::sine, 1000.0); + + oscillator.setPhase (-0.25); + EXPECT_NEAR (0.75, oscillator.getPhase(), 1e-15); + + oscillator.setPhase (1.25); + EXPECT_NEAR (0.25, oscillator.getPhase(), 1e-15); + + auto reference = makeOscillator (8, Waveform::sine, 1000.0); + + reference.setPhase (0.25); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (reference.processSample(), oscillator.processSample(), 1e-15) << i; + + EXPECT_TRUE (oscillator.getPhase() >= 0.0 && oscillator.getPhase() < 1.0); +} + +TEST_F (AdditiveOscillatorTests, SimdLanesMatchScalarReference) +{ + for (const auto numHarmonics : { 13, 37 }) + { + const auto series = FourierSeries::create (Waveform::sawtooth, numHarmonics); + auto oscillator = makeOscillator (numHarmonics, Waveform::sawtooth, 1000.0); + + for (int i = 0; i < 64; ++i) + { + const auto phase = std::fmod (1000.0 / testSampleRate * i, 1.0); + + EXPECT_NEAR (harmonicReference (series, numHarmonics, 1000.0, phase), oscillator.processSample(), 1e-9) << i; + } + } +} + +TEST_F (AdditiveOscillatorTests, ManyHarmonicsStayAccurate) +{ + constexpr int numHarmonics = 512; + + const auto series = FourierSeries::create (Waveform::sawtooth, numHarmonics); + auto oscillator = makeOscillator (numHarmonics, Waveform::sawtooth, 50.0); + + EXPECT_EQ (479, oscillator.getNumActiveHarmonics()); + + for (int i = 0; i < 32; ++i) + { + const auto phase = std::fmod (50.0 / testSampleRate * i, 1.0); + + EXPECT_NEAR (harmonicReference (series, numHarmonics, 50.0, phase), oscillator.processSample(), 1e-9) << i; + } +} + +TEST_F (AdditiveOscillatorTests, DCCoefficientIsOptional) +{ + FourierSeries series (8); + + series.setDC (0.5); + + AdditiveOscillator oscillator; + oscillator.prepare (testSampleRate, 8); + oscillator.setSeries (series); + oscillator.setFrequency (0.0); + + EXPECT_EQ (0.0, oscillator.processSample()); + + oscillator.setIncludeDC (true); + EXPECT_EQ (0.5, oscillator.processSample()); +} + +TEST_F (AdditiveOscillatorTests, SeriesChangesTakeEffectImmediately) +{ + const auto sawtooth = FourierSeries::create (Waveform::sawtooth, 16); + const auto square = FourierSeries::create (Waveform::square, 16); + + AdditiveOscillator oscillator; + oscillator.prepare (testSampleRate, 16); + oscillator.setSeries (sawtooth); + oscillator.setFrequency (500.0); + oscillator.setPhase (0.25); + + const auto sawtoothSample = oscillator.processSample(); + + oscillator.setPhase (0.25); + oscillator.setSeries (square); + + const auto squareSample = oscillator.processSample(); + + EXPECT_NEAR (harmonicReference (sawtooth, 16, 500.0, 0.25), sawtoothSample, 1e-12); + EXPECT_NEAR (harmonicReference (square, 16, 500.0, 0.25), squareSample, 1e-12); + EXPECT_NE (sawtoothSample, squareSample); +} + +TEST_F (AdditiveOscillatorTests, FloatInstantiationBehavesLikeDouble) +{ + AdditiveOscillator oscillator; + + oscillator.prepare (testSampleRate, 32); + oscillator.setWaveform (Waveform::sawtooth); + oscillator.setFrequency (1000.0); + + const auto series = FourierSeries::create (Waveform::sawtooth, 32); + + for (int i = 0; i < 64; ++i) + { + double expected = 0.0; + + for (int n = 1; n <= 32; ++n) + { + if (n * 1000.0 >= testSampleRate / 2) + break; + + const auto angle = MathConstants::twoPi * n * static_cast (std::fmod (1000.0 / testSampleRate * i, 1.0)); + + expected += series.getCosine (n) * std::cos (angle) + series.getSine (n) * std::sin (angle); + } + + EXPECT_NEAR (expected, oscillator.processSample(), 1e-3) << i; + } +} diff --git a/tests/yup_dsp/yup_FourierSeries.cpp b/tests/yup_dsp/yup_FourierSeries.cpp new file mode 100644 index 000000000..7a4870d39 --- /dev/null +++ b/tests/yup_dsp/yup_FourierSeries.cpp @@ -0,0 +1,360 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== +class FourierSeriesTests : public ::testing::Test +{ +protected: + static double sawtoothCoefficient (int harmonic) + { + return -2.0 * (harmonic % 2 == 0 ? 1.0 : -1.0) / (MathConstants::pi * harmonic); + } + + static double triangleCoefficient (int harmonic) + { + const auto order = (harmonic + 1) / 2; + const auto sign = (order % 2 == 0) ? -1.0 : 1.0; + + return -8.0 * sign / (MathConstants::pi * MathConstants::pi * harmonic * harmonic); + } + + static constexpr int presetHarmonics = 16; + + FourierSeries series { presetHarmonics }; +}; + +TEST_F (FourierSeriesTests, ConstructorSizesAndZeroes) +{ + EXPECT_EQ (presetHarmonics, series.getNumHarmonics()); + EXPECT_EQ (0.0, series.getDC()); + + for (int n = 1; n <= presetHarmonics; ++n) + { + EXPECT_EQ (0.0, series.getCosine (n)); + EXPECT_EQ (0.0, series.getSine (n)); + } +} + +TEST_F (FourierSeriesTests, ResizeZeroFills) +{ + series.setHarmonic (1, 1.0, 2.0); + series.setDC (0.5); + + series.resize (32); + + EXPECT_EQ (32, series.getNumHarmonics()); + EXPECT_EQ (0.0, series.getDC()); + EXPECT_EQ (0.0, series.getCosine (1)); + EXPECT_EQ (0.0, series.getSine (1)); +} + +TEST_F (FourierSeriesTests, SineAndCosinePresets) +{ + series.setWaveform (Waveform::sine); + EXPECT_EQ (0.0, series.getCosine (1)); + EXPECT_EQ (1.0, series.getSine (1)); + + for (int n = 2; n <= presetHarmonics; ++n) + EXPECT_EQ (0.0, series.getSine (n)); + + series.setWaveform (Waveform::cosine); + EXPECT_EQ (1.0, series.getCosine (1)); + EXPECT_EQ (0.0, series.getSine (1)); +} + +TEST_F (FourierSeriesTests, SawtoothPresetMatchesAnalyticFormula) +{ + series.setWaveform (Waveform::sawtooth); + + for (int n = 1; n <= presetHarmonics; ++n) + { + EXPECT_NEAR (sawtoothCoefficient (n), series.getSine (n), 1e-15); + EXPECT_EQ (0.0, series.getCosine (n)); + } +} + +TEST_F (FourierSeriesTests, SquarePresetMatchesAnalyticFormula) +{ + series.setWaveform (Waveform::square); + + for (int n = 1; n <= presetHarmonics; ++n) + { + const auto expected = (n % 2 == 1) ? 4.0 / (MathConstants::pi * n) : 0.0; + + EXPECT_NEAR (expected, series.getSine (n), 1e-15); + } +} + +TEST_F (FourierSeriesTests, TrianglePresetMatchesAnalyticFormula) +{ + series.setWaveform (Waveform::triangle); + + for (int n = 1; n <= presetHarmonics; ++n) + { + const auto expected = (n % 2 == 1) ? triangleCoefficient (n) : 0.0; + + EXPECT_NEAR (expected, series.getSine (n), 1e-15); + } +} + +TEST_F (FourierSeriesTests, PulsePresetMatchesAnalyticFormula) +{ + series.setWaveform (Waveform::pulse); + + for (int n = 1; n <= presetHarmonics; ++n) + EXPECT_NEAR (1.0 / presetHarmonics, series.getCosine (n), 1e-15); +} + +TEST_F (FourierSeriesTests, CreateReturnsSizedPresets) +{ + const auto created = FourierSeries::create (Waveform::sine, 8); + + EXPECT_EQ (8, created.getNumHarmonics()); + EXPECT_EQ (1.0, created.getSine (1)); +} + +TEST_F (FourierSeriesTests, MagnitudeCombinesBothCoefficients) +{ + series.setHarmonic (3, 3.0, 4.0); + + EXPECT_NEAR (5.0, series.getMagnitude (3), 1e-15); + EXPECT_EQ (0.0, series.getMagnitude (4)); +} + +TEST_F (FourierSeriesTests, CopyFromZeroesUnusedHarmonics) +{ + FourierSeries destination (32); + + destination.setWaveform (Waveform::square); + series.setWaveform (Waveform::sawtooth); + destination.copyFrom (series); + + for (int n = 1; n <= presetHarmonics; ++n) + { + EXPECT_NEAR (sawtoothCoefficient (n), destination.getSine (n), 1e-15); + EXPECT_EQ (0.0, destination.getCosine (n)); + } + + for (int n = presetHarmonics + 1; n <= 32; ++n) + EXPECT_EQ (0.0, destination.getSine (n)); + + EXPECT_EQ (32, destination.getNumHarmonics()); +} + +TEST_F (FourierSeriesTests, TimeShiftTurnsSineIntoCosine) +{ + series.resize (1); + series.setWaveform (Waveform::sine); + + // r (t + 0.25) = sin (2 pi (t + 0.25)) = cos (2 pi t) + series.timeShift (-0.25); + + EXPECT_NEAR (1.0, series.getCosine (1), 1e-15); + EXPECT_NEAR (0.0, series.getSine (1), 1e-15); +} + +TEST_F (FourierSeriesTests, TimeShiftByHalfPeriodInvertsTheSeries) +{ + series.resize (4); + series.setWaveform (Waveform::sawtooth); + + const auto before = series.getSine (1); + + series.timeShift (0.5); + + EXPECT_NEAR (-before, series.getSine (1), 1e-14); +} + +TEST_F (FourierSeriesTests, TimeShiftKeepsMagnitudesAndIsReversible) +{ + series.setWaveform (Waveform::sawtooth); + + const auto cosineBefore = series.getCosine (3); + const auto sineBefore = series.getSine (3); + + series.timeShift (-0.125); + EXPECT_NEAR (std::sqrt (cosineBefore * cosineBefore + sineBefore * sineBefore), series.getMagnitude (3), 1e-14); + + series.timeShift (0.125); + EXPECT_NEAR (cosineBefore, series.getCosine (3), 1e-14); + EXPECT_NEAR (sineBefore, series.getSine (3), 1e-14); +} + +TEST_F (FourierSeriesTests, SetFromCycleRecoversSine) +{ + constexpr int numSamples = 64; + + std::vector cycle (numSamples); + + for (int m = 0; m < numSamples; ++m) + cycle[static_cast (m)] = std::sin (MathConstants::twoPi * m / numSamples); + + series.setFromCycle (Span (cycle.data(), cycle.size())); + + EXPECT_NEAR (1.0, series.getSine (1), 1e-12); + EXPECT_NEAR (0.0, series.getCosine (1), 1e-12); + EXPECT_NEAR (0.0, series.getDC(), 1e-12); + + for (int n = 2; n <= presetHarmonics; ++n) + { + EXPECT_NEAR (0.0, series.getCosine (n), 1e-12); + EXPECT_NEAR (0.0, series.getSine (n), 1e-12); + } +} + +TEST_F (FourierSeriesTests, SetFromCycleRecoversDCAndFirstHarmonic) +{ + constexpr int numSamples = 32; + + std::vector cycle (numSamples); + + for (int m = 0; m < numSamples; ++m) + { + const auto angle = MathConstants::twoPi * m / numSamples; + + cycle[static_cast (m)] = 0.25 + 0.5 * std::cos (angle) + 0.75 * std::sin (angle); + } + + series.setFromCycle (Span (cycle.data(), cycle.size())); + + EXPECT_NEAR (0.25, series.getDC(), 1e-12); + EXPECT_NEAR (0.5, series.getCosine (1), 1e-12); + EXPECT_NEAR (0.75, series.getSine (1), 1e-12); +} + +TEST_F (FourierSeriesTests, SetFromCycleHandlesFloatCycles) +{ + constexpr int numSamples = 16; + + std::vector cycle (numSamples); + + for (int m = 0; m < numSamples; ++m) + cycle[static_cast (m)] = std::cos (static_cast (MathConstants::twoPi * m / numSamples)); + + series.setFromCycle (Span (cycle.data(), cycle.size())); + + EXPECT_NEAR (1.0, series.getCosine (1), 1e-6); + EXPECT_NEAR (0.0, series.getSine (1), 1e-6); +} + +//============================================================================== +class NyquistHarmonicLimitTests : public ::testing::Test +{ +}; + +TEST_F (NyquistHarmonicLimitTests, FollowsSampleRateAndFrequency) +{ + EXPECT_EQ (2, getNyquistHarmonicLimit (10000.0, 48000.0, 512)); + EXPECT_EQ (0, getNyquistHarmonicLimit (30000.0, 48000.0, 512)); + EXPECT_EQ (23, getNyquistHarmonicLimit (1000.0, 48000.0, 512)); + EXPECT_EQ (7, getNyquistHarmonicLimit (1000.0, 48000.0, 7)); + EXPECT_EQ (0, getNyquistHarmonicLimit (1000.0, 48000.0, 0)); +} + +TEST_F (NyquistHarmonicLimitTests, UsesStrictlyBelowNyquistHarmonics) +{ + // Exactly 24 kHz at 48 kHz is Nyquist, so harmonic 23 is the last audible one. + EXPECT_EQ (22, getNyquistHarmonicLimit (1000.0, 48000.0, 4096) - 1); +} + +//============================================================================== +class HarmonicPhasorTests : public ::testing::Test +{ +protected: + static void compareWithTrigonometry (const std::vector& cosines, const std::vector& sines, double angle, double tolerance) + { + for (int k = 1; k <= static_cast (cosines.size()); ++k) + { + const auto index = static_cast (k - 1); + + EXPECT_NEAR (std::cos (angle * k), cosines[index], tolerance); + EXPECT_NEAR (std::sin (angle * k), sines[index], tolerance); + } + } + + static void compareWithTrigonometry (const std::vector& cosines, const std::vector& sines, double angle, double tolerance) + { + for (int k = 1; k <= static_cast (cosines.size()); ++k) + { + const auto index = static_cast (k - 1); + + EXPECT_NEAR (std::cos (angle * k), cosines[index], tolerance); + EXPECT_NEAR (std::sin (angle * k), sines[index], tolerance); + } + } + + static constexpr int count = 4096; +}; + +TEST_F (HarmonicPhasorTests, MatchesTrigonometryInDouble) +{ + constexpr double angle = 0.12217304763960307; // 7 degrees + + std::vector cosines (count); + std::vector sines (count); + + fillHarmonicPhasors (cosines.data(), sines.data(), count, angle); + + compareWithTrigonometry (cosines, sines, angle, 1e-12); +} + +TEST_F (HarmonicPhasorTests, MatchesTrigonometryAcrossReseedBoundaries) +{ + constexpr int reseededCount = 8 * harmonicPhasorReseedInterval; + + std::vector cosines (reseededCount); + std::vector sines (reseededCount); + + const auto angle = MathConstants::twoPi / reseededCount; + + fillHarmonicPhasors (cosines.data(), sines.data(), reseededCount, angle); + + compareWithTrigonometry (cosines, sines, angle, 1e-12); +} + +TEST_F (HarmonicPhasorTests, MatchesTrigonometryInFloat) +{ + const auto angle = static_cast (MathConstants::twoPi / count); + + std::vector cosines (count); + std::vector sines (count); + + fillHarmonicPhasors (cosines.data(), sines.data(), count, angle); + + compareWithTrigonometry (cosines, sines, static_cast (angle), 2e-5); +} + +TEST_F (HarmonicPhasorTests, IgnoresEmptyRanges) +{ + std::vector cosines (1, -1.0); + std::vector sines (1, -1.0); + + fillHarmonicPhasors (cosines.data(), sines.data(), 0, 1.0); + + EXPECT_EQ (-1.0, cosines[0]); + EXPECT_EQ (-1.0, sines[0]); +} diff --git a/tests/yup_dsp/yup_SyncOscillator.cpp b/tests/yup_dsp/yup_SyncOscillator.cpp new file mode 100644 index 000000000..482b4f058 --- /dev/null +++ b/tests/yup_dsp/yup_SyncOscillator.cpp @@ -0,0 +1,305 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== +class SyncOscillatorTests : public ::testing::Test +{ +protected: + /** + Level, in dB below the strongest bin, of every spectral bin that is not + within guardBins of a multiple of harmonicBin. + + A signal whose only components sit on the harmonic grid of harmonicBin + measures far below 0 dB, while a naive oscillator whose aliases fold onto + other bins measures tens of dB higher. + */ + static double offHarmonicEnergyDb (const std::vector& signal, int harmonicBin, int guardBins) + { + const auto size = static_cast (signal.size()); + + std::vector window (static_cast (size)); + WindowFunctions::generate (WindowType::blackmanHarris, window); + + std::vector input (static_cast (size)); + + for (int i = 0; i < size; ++i) + input[static_cast (i)] = static_cast (signal[static_cast (i)] * window[static_cast (i)]); + + FFTProcessor fft (size); + + std::vector spectrum (static_cast (size) * 2); + fft.performRealFFTForward (input.data(), spectrum.data()); + + std::vector magnitude (static_cast (size) / 2 + 1, 0.0); + + for (int k = 0; k <= size / 2; ++k) + magnitude[static_cast (k)] = std::hypot (static_cast (spectrum[static_cast (2 * k)]), + static_cast (spectrum[static_cast (2 * k + 1)])); + + const auto peak = *std::max_element (magnitude.begin(), magnitude.end()); + + double worst = 0.0; + + for (int k = 1; k <= size / 2; ++k) + { + const auto remainder = k % harmonicBin; + + if (jmin (remainder, harmonicBin - remainder) <= guardBins) + continue; + + worst = jmax (worst, magnitude[static_cast (k)]); + } + + worst = jmax (worst, 1e-12 * peak); + + return 20.0 * std::log10 (worst / peak); + } + + static void configureSyncedSawtooth (SyncOscillator& oscillator, SyncMode mode, double ratio) + { + oscillator.prepare (testSampleRate, testMaxHarmonics); + oscillator.setWaveform (Waveform::sawtooth); + oscillator.setSyncMode (mode); + oscillator.setFollowerRatio (ratio); + oscillator.setFrequency (440.0); + oscillator.update(); + } + + static constexpr double testSampleRate = 48000.0; + static constexpr int testMaxHarmonics = 256; +}; + +TEST_F (SyncOscillatorTests, AdditiveBackendMatchesManualPipeline) +{ + SyncOscillator oscillator; + configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); + oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + + FourierSeries follower (testMaxHarmonics); + follower.setWaveform (Waveform::sawtooth); + + const auto numOutputHarmonics = jmin (testMaxHarmonics, + SyncSpectralResampler::getRecommendedOutputHarmonics (follower.getNumHarmonics(), + 1.375, + SyncMode::hard)); + + SyncSpectralResampler resampler; + resampler.prepare (testMaxHarmonics); + + FourierSeries synced (testMaxHarmonics); + resampler.transform (follower, 1.375, SyncMode::hard, synced, numOutputHarmonics); + + AdditiveOscillator reference; + reference.prepare (testSampleRate, testMaxHarmonics); + reference.setSeries (synced); + reference.setFrequency (440.0); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (reference.processSample(), oscillator.processSample(), 1e-12) << i; +} + +TEST_F (SyncOscillatorTests, WavetableBackendApproximatesTheAdditiveOne) +{ + SyncOscillator oscillator; + configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); + + std::vector wavetableBuffer (512); + oscillator.processBlock (wavetableBuffer.data(), 512); + oscillator.setPhase (0.0); + oscillator.processBlock (wavetableBuffer.data(), 512); + + oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + oscillator.setPhase (0.0); + + std::vector additiveBuffer (512); + oscillator.processBlock (additiveBuffer.data(), 512); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (additiveBuffer[static_cast (i)], wavetableBuffer[static_cast (i)], 1e-3) << i; +} + +TEST_F (SyncOscillatorTests, MirroredRunsAtHalfTheLeaderFrequency) +{ + SyncOscillator oscillator; + configureSyncedSawtooth (oscillator, SyncMode::mirrored, 1.375); + + EXPECT_NEAR (220.0, oscillator.getOutputFrequency(), 1e-12); + EXPECT_EQ (440.0, oscillator.getFrequency()); + + SyncOscillator reference; + reference.prepare (testSampleRate, testMaxHarmonics); + reference.setWaveform (Waveform::sawtooth); + reference.setSyncMode (SyncMode::mirrored); + reference.setFollowerRatio (1.375); + reference.setFrequency (440.0); + reference.setSynthesis (SyncOscillator::Synthesis::additive); + reference.update(); + + // The mirrored output is periodic with twice the leader period, so it equals an + // additive oscillator running the synced series at half the leader pitch. + const auto numOutputHarmonics = jmin (testMaxHarmonics, + SyncSpectralResampler::getRecommendedOutputHarmonics (testMaxHarmonics, + 1.375, + SyncMode::mirrored)); + + FourierSeries follower (testMaxHarmonics); + follower.setWaveform (Waveform::sawtooth); + + SyncSpectralResampler resampler; + resampler.prepare (testMaxHarmonics); + + FourierSeries synced (testMaxHarmonics); + resampler.transform (follower, 1.375, SyncMode::mirrored, synced, numOutputHarmonics); + + AdditiveOscillator additive; + additive.prepare (testSampleRate, testMaxHarmonics); + additive.setSeries (synced); + additive.setFrequency (220.0); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (additive.processSample(), reference.processSample(), 1e-12) << i; +} + +TEST_F (SyncOscillatorTests, UpdateWithNothingDirtyLeavesTheOutputUnchanged) +{ + SyncOscillator updatedOnce; + SyncOscillator updatedTwice; + + configureSyncedSawtooth (updatedOnce, SyncMode::hard, 1.375); + configureSyncedSawtooth (updatedTwice, SyncMode::hard, 1.375); + + updatedTwice.update(); + + EXPECT_FALSE (updatedTwice.needsUpdate()); + + for (int i = 0; i < 256; ++i) + EXPECT_EQ (updatedOnce.processSample(), updatedTwice.processSample()) << i; +} + +TEST_F (SyncOscillatorTests, SwitchingSynthesisKeepsThePhase) +{ + SyncOscillator oscillator; + configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); + + for (int i = 0; i < 100; ++i) + oscillator.processSample(); + + const auto phase = oscillator.getPhase(); + + oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + EXPECT_NEAR (phase, oscillator.getPhase(), 1e-12); + + oscillator.processSample(); + + const auto additivePhase = oscillator.getPhase(); + + oscillator.setSynthesis (SyncOscillator::Synthesis::wavetable); + EXPECT_NEAR (additivePhase, oscillator.getPhase(), 1e-12); +} + +TEST_F (SyncOscillatorTests, FollowerFrequencySetsTheRatio) +{ + SyncOscillator oscillator; + oscillator.prepare (testSampleRate, 64); + oscillator.setFrequency (440.0); + oscillator.setFollowerFrequency (605.0); + + EXPECT_NEAR (1.375, oscillator.getFollowerRatio(), 1e-12); + + // The ratio is a factor, so it survives a pitch change. + oscillator.setFrequency (880.0); + EXPECT_NEAR (1.375, oscillator.getFollowerRatio(), 1e-12); + EXPECT_NEAR (880.0, oscillator.getOutputFrequency(), 1e-12); +} + +TEST_F (SyncOscillatorTests, NoneModePlaysTheFollowerAtTheLeaderPitch) +{ + SyncOscillator oscillator; + oscillator.prepare (testSampleRate, 64); + oscillator.setWaveform (Waveform::square); + oscillator.setFollowerRatio (1.375); + oscillator.setFrequency (440.0); + oscillator.update(); + + EXPECT_EQ (SyncMode::none, oscillator.getSyncMode()); + EXPECT_NEAR (440.0, oscillator.getOutputFrequency(), 1e-12); + EXPECT_EQ (64, oscillator.getSyncedSeries().getNumHarmonics()); + + for (int n = 1; n <= 64; ++n) + EXPECT_EQ (oscillator.getFollowerSeries().getSine (n), oscillator.getSyncedSeries().getSine (n)) << n; +} + +//============================================================================== +TEST_F (SyncOscillatorTests, HardSyncedSawtoothIsAliasFree) +{ + constexpr int fftSize = 4096; + constexpr int harmonicBin = 63; + constexpr int guardBins = 4; // Exclude the Blackman-Harris main lobe. + + // Bin exact leader pitch, with 63 coprime with the FFT size so that folded + // aliases of a naive oscillator cannot land on a harmonic by accident. + const auto leaderFrequency = testSampleRate * harmonicBin / fftSize; + + SyncOscillator oscillator; + oscillator.prepare (testSampleRate, testMaxHarmonics); + oscillator.setWaveform (Waveform::sawtooth); + oscillator.setSyncMode (SyncMode::hard); + oscillator.setFollowerRatio (1.375); + oscillator.setFrequency (leaderFrequency); + oscillator.update(); + + std::vector buffer (fftSize); + + oscillator.processBlock (buffer.data(), fftSize); + oscillator.setPhase (0.0); + oscillator.processBlock (buffer.data(), fftSize); + const auto wavetableEnergy = offHarmonicEnergyDb (buffer, harmonicBin, guardBins); + + oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + oscillator.setPhase (0.0); + oscillator.processBlock (buffer.data(), fftSize); + const auto additiveEnergy = offHarmonicEnergyDb (buffer, harmonicBin, guardBins); + + // A naive phase-reset sawtooth at the follower pitch is not alias free, and the + // same measurement catches it. + std::vector naive (fftSize); + auto phase = 0.0; + + for (int i = 0; i < fftSize; ++i) + { + naive[static_cast (i)] = 2.0 * phase - 1.0; + + phase += leaderFrequency * 1.375 / testSampleRate; + phase -= std::floor (phase); + } + + const auto naiveEnergy = offHarmonicEnergyDb (naive, harmonicBin, guardBins); + + EXPECT_LT (additiveEnergy, -80.0) << "additive backend"; + EXPECT_LT (wavetableEnergy, -60.0) << "wavetable backend"; + EXPECT_GT (naiveEnergy, -40.0) << "naive reference"; +} diff --git a/tests/yup_dsp/yup_SyncSpectralResampler.cpp b/tests/yup_dsp/yup_SyncSpectralResampler.cpp new file mode 100644 index 000000000..f87eaee43 --- /dev/null +++ b/tests/yup_dsp/yup_SyncSpectralResampler.cpp @@ -0,0 +1,546 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +//============================================================================== +class SyncSpectralResamplerTests : public ::testing::Test +{ +protected: + // Number of midpoint samples used to integrate the coefficients of one period + // of a waveform. The constructions are made of at most a handful of harmonics, + // so the integration error stays below 1e-7 except for the pulsar + // discontinuity, which is below 1e-4. + static constexpr int referenceSamples = 1 << 15; + static constexpr int followerHarmonics = 8; + + static double evaluateSeries (const FourierSeries& series, double t) + { + auto value = series.getDC(); + + for (int n = 1; n <= series.getNumHarmonics(); ++n) + value += series.getCosine (n) * std::cos (MathConstants::twoPi * n * t) + + series.getSine (n) * std::sin (MathConstants::twoPi * n * t); + + return value; + } + + static double normalizedSinc (double x) + { + return x == 0.0 ? 1.0 : std::sin (MathConstants::pi * x) / (MathConstants::pi * x); + } + + static double normalizedVersinc (double x) + { + return x == 0.0 ? 0.0 : (1.0 - std::cos (MathConstants::pi * x)) / (MathConstants::pi * x); + } + + static FourierSeries makeFollower (Waveform waveform = Waveform::sawtooth) + { + return FourierSeries::create (waveform, followerHarmonics); + } + + //============================================================================== + /** Pre-rotation of the paper: the coefficients of r (t - shift). */ + struct RotatedCoefficients + { + std::vector cosine; + std::vector sine; + }; + + static RotatedCoefficients preRotate (const FourierSeries& follower, double shift) + { + const auto count = follower.getNumHarmonics(); + + RotatedCoefficients rotated { std::vector (static_cast (count) + 1, 0.0), + std::vector (static_cast (count) + 1, 0.0) }; + + for (int n = 1; n <= count; ++n) + { + const auto angle = MathConstants::twoPi * shift * n; + const auto index = static_cast (n); + + rotated.cosine[index] = follower.getCosine (n) * std::cos (angle) - follower.getSine (n) * std::sin (angle); + rotated.sine[index] = follower.getCosine (n) * std::sin (angle) + follower.getSine (n) * std::cos (angle); + } + + return rotated; + } + + /** + Independent scalar implementation of the paper's transform, written with the + sinc and versinc kernels as published. It is the reference the vectorized, + dot-product implementation is checked against, so it deliberately repeats the + pre-rotation instead of calling into the class. + */ + static FourierSeries scalarReferenceTransform (const FourierSeries& follower, double ratio, SyncMode mode, int numOutputHarmonics) + { + FourierSeries output (numOutputHarmonics); + + if (mode == SyncMode::none) + { + output.copyFrom (follower); + return output; + } + + const auto shift = mode == SyncMode::hard ? -ratio / 2.0 + : mode == SyncMode::mirrored ? -ratio + : -0.5; + const auto rotated = preRotate (follower, shift); + + double dc = 0.0; + + if (mode == SyncMode::hard) + { + for (int k = 1; k <= followerHarmonics; ++k) + dc += rotated.cosine[static_cast (k)] * normalizedSinc (k * ratio); + } + else if (mode == SyncMode::mirrored) + { + for (int k = 1; k <= followerHarmonics; ++k) + dc += rotated.cosine[static_cast (k)] * normalizedSinc (2.0 * k * ratio) + - rotated.sine[static_cast (k)] * normalizedVersinc (2.0 * k * ratio); + } + + output.setDC (dc); + + for (int n = 1; n <= numOutputHarmonics; ++n) + { + double a = 0.0; + double b = 0.0; + + for (int k = 1; k <= followerHarmonics; ++k) + { + const auto index = static_cast (k); + const auto a_k = rotated.cosine[index]; + const auto b_k = rotated.sine[index]; + + if (mode == SyncMode::hard) + { + a += a_k * (normalizedSinc (n - k * ratio) + normalizedSinc (n + k * ratio)); + b += b_k * (normalizedSinc (n - k * ratio) - normalizedSinc (n + k * ratio)); + } + else if (mode == SyncMode::mirrored) + { + a += a_k * (normalizedSinc (n - 2.0 * k * ratio) + normalizedSinc (n + 2.0 * k * ratio)) + + b_k * (normalizedVersinc (n - 2.0 * k * ratio) - normalizedVersinc (n + 2.0 * k * ratio)); + } + else + { + const auto q = n / ratio; + + a += (a_k / ratio) * (normalizedSinc (q - k) + normalizedSinc (q + k)); + b += (b_k / ratio) * (normalizedSinc (q - k) - normalizedSinc (q + k)); + } + } + + output.setHarmonic (n, a, b); + } + + return output; + } + + //============================================================================== + /** One of the three time-domain constructions of the paper. */ + struct TimeDomainConstruction + { + std::function waveform; + double start = 0.0; + double period = 1.0; + }; + + static TimeDomainConstruction makeConstruction (SyncMode mode, double ratio, const FourierSeries& follower) + { + const auto value = [&follower] (double t) { return evaluateSeries (follower, t); }; + + if (mode == SyncMode::hard) + return { [value, ratio] (double t) { return value (t + ratio / 2); }, -ratio / 2, ratio }; + + if (mode == SyncMode::mirrored) + return { [value, ratio] (double t) { return value (ratio - std::abs (t)); }, -ratio, 2 * ratio }; + + return { [value] (double t) { return std::abs (t) < 0.5 ? value (t + 0.5) : 0.0; }, -ratio / 2, ratio }; + } + + /** + Integrates the Fourier coefficients of one period of a time-domain construction. + + The integral is taken over the period the construction is defined on, and the + result carries the (-1)^n factor of the paper's phase frame: relative to the + raw construction the transform fixes the phase of the output at half an output + period, which is where the sinc kernels come from. + */ + static FourierSeries integrateConstruction (const TimeDomainConstruction& construction, int numHarmonics) + { + std::vector cosine (static_cast (numHarmonics) + 1, 0.0); + std::vector sine (static_cast (numHarmonics) + 1, 0.0); + std::vector phasorCosine (static_cast (numHarmonics), 0.0); + std::vector phasorSine (static_cast (numHarmonics), 0.0); + + const auto step = construction.period / referenceSamples; + + for (int m = 0; m < referenceSamples; ++m) + { + const auto t = construction.start + (m + 0.5) * step; + const auto value = construction.waveform (t); + + cosine[0] += value; + + fillHarmonicPhasors (phasorCosine.data(), phasorSine.data(), numHarmonics, MathConstants::twoPi * (t - construction.start) / construction.period); + + for (int n = 1; n <= numHarmonics; ++n) + { + const auto index = static_cast (n - 1); + + cosine[static_cast (n)] += value * phasorCosine[index]; + sine[static_cast (n)] += value * phasorSine[index]; + } + } + + FourierSeries integrated (numHarmonics); + + integrated.setDC (cosine[0] / referenceSamples); + + for (int n = 1; n <= numHarmonics; ++n) + { + const auto index = static_cast (n); + const auto frame = (n % 2 == 0) ? 1.0 : -1.0; + + integrated.setHarmonic (n, + frame * 2.0 * cosine[index] / referenceSamples, + frame * 2.0 * sine[index] / referenceSamples); + } + + return integrated; + } + + static FourierSeries runTransform (const FourierSeries& follower, double ratio, SyncMode mode, int numOutputHarmonics) + { + SyncSpectralResampler resampler; + resampler.prepare (jmax (numOutputHarmonics, follower.getNumHarmonics())); + + FourierSeries output (numOutputHarmonics); + resampler.transform (follower, ratio, mode, output, numOutputHarmonics); + + return output; + } + + static void expectMatchesTimeDomain (SyncMode mode, double ratio, const FourierSeries& follower, int numOutputHarmonics, double tolerance) + { + const auto reference = integrateConstruction (makeConstruction (mode, ratio, follower), numOutputHarmonics); + const auto transformed = runTransform (follower, ratio, mode, numOutputHarmonics); + + EXPECT_NEAR (reference.getDC(), transformed.getDC(), tolerance) << "mode " << (int) mode << " P " << ratio; + + for (int n = 1; n <= numOutputHarmonics; ++n) + { + EXPECT_NEAR (reference.getCosine (n), transformed.getCosine (n), tolerance) << "cosine " << n << " mode " << (int) mode << " P " << ratio; + EXPECT_NEAR (reference.getSine (n), transformed.getSine (n), tolerance) << "sine " << n << " mode " << (int) mode << " P " << ratio; + } + } +}; + +//============================================================================== +TEST_F (SyncSpectralResamplerTests, NoneModePassesTheFollowerThrough) +{ + auto follower = makeFollower(); + follower.setDC (0.25); + + FourierSeries output (32); + output.setWaveform (Waveform::pulse); + SyncSpectralResampler resampler; + resampler.prepare (64); + + resampler.transform (follower, 1.375, SyncMode::none, output, 32); + + EXPECT_EQ (32, output.getNumHarmonics()); + EXPECT_EQ (follower.getDC(), output.getDC()); + + for (int n = 1; n <= follower.getNumHarmonics(); ++n) + { + EXPECT_EQ (follower.getCosine (n), output.getCosine (n)); + EXPECT_EQ (follower.getSine (n), output.getSine (n)); + } + + for (int n = follower.getNumHarmonics() + 1; n <= 32; ++n) + { + EXPECT_EQ (0.0, output.getCosine (n)); + EXPECT_EQ (0.0, output.getSine (n)); + } +} + +TEST_F (SyncSpectralResamplerTests, HardSyncOfSineWithIntegerRatioRemapsTheHarmonic) +{ + const auto follower = makeFollower (Waveform::sine); + + // With P = 2 the hard sync of a sine repeats it every two follower periods, + // which is the second harmonic of the leader frequency. + const auto output = runTransform (follower, 2.0, SyncMode::hard, 16); + + EXPECT_NEAR (1.0, output.getSine (2), 1e-12); + EXPECT_NEAR (0.0, output.getDC(), 1e-12); + + for (int n = 1; n <= 16; ++n) + { + if (n == 2) + continue; + + EXPECT_NEAR (0.0, output.getCosine (n), 1e-12); + EXPECT_NEAR (0.0, output.getSine (n), 1e-12); + } +} + +TEST_F (SyncSpectralResamplerTests, HardSyncOfSineWithFractionalRatioStaysFinite) +{ + for (const auto ratio : { 0.75, 1.375, 2.5, 1.5, 2.0 / 3.0 }) + { + const auto output = runTransform (makeFollower (Waveform::sine), ratio, SyncMode::hard, 16); + + for (int n = 1; n <= 16; ++n) + { + EXPECT_TRUE (std::isfinite (output.getCosine (n))); + EXPECT_TRUE (std::isfinite (output.getSine (n))); + } + } +} + +TEST_F (SyncSpectralResamplerTests, MirroredOutputOnlyHasCosineCoefficients) +{ + const auto follower = makeFollower(); + + for (const auto ratio : { 0.75, 1.375, 2.5, 1.5, 2.0 }) + { + const auto output = runTransform (follower, ratio, SyncMode::mirrored, 24); + + for (int n = 1; n <= 24; ++n) + EXPECT_EQ (0.0, output.getSine (n)) << "harmonic " << n << " ratio " << ratio; + } +} + +TEST_F (SyncSpectralResamplerTests, PulsarOutputHasNoDC) +{ + auto follower = makeFollower(); + + follower.setDC (0.5); + + EXPECT_EQ (0.0, runTransform (follower, 1.375, SyncMode::pulsar, 16).getDC()); +} + +//============================================================================== +TEST_F (SyncSpectralResamplerTests, MatchesTheTimeDomainConstruction) +{ + const auto follower = makeFollower(); + + for (const auto ratio : { 0.75, 1.375, 2.5 }) + { + expectMatchesTimeDomain (SyncMode::hard, ratio, follower, 8, 1e-3); + expectMatchesTimeDomain (SyncMode::mirrored, ratio, follower, 8, 1e-3); + } + + // The pulsar construction needs P >= 1: below that the follower period is + // shorter than the pulse width, a case the paper leaves unanalysed. + for (const auto ratio : { 1.375, 2.5, 3.0 }) + expectMatchesTimeDomain (SyncMode::pulsar, ratio, follower, 8, 1e-3); +} + +TEST_F (SyncSpectralResamplerTests, MatchesTheTimeDomainConstructionForMixedSpectra) +{ + FourierSeries follower (followerHarmonics); + + follower.setDC (0.2); + follower.setHarmonic (1, 0.7, 0.9); + follower.setHarmonic (2, -0.3, 0.4); + follower.setHarmonic (3, 0.15, -0.25); + follower.setHarmonic (5, 0.0, 0.35); + + for (const auto ratio : { 1.375, 2.0 }) + { + expectMatchesTimeDomain (SyncMode::hard, ratio, follower, 8, 1e-3); + expectMatchesTimeDomain (SyncMode::mirrored, ratio, follower, 8, 1e-3); + } + + // The pulsar transform drops the follower's DC term, so its construction has + // to be DC free to be comparable. + follower.setDC (0.0); + + for (const auto ratio : { 1.375, 2.0 }) + expectMatchesTimeDomain (SyncMode::pulsar, ratio, follower, 8, 1e-3); +} + +TEST_F (SyncSpectralResamplerTests, MatchesTheTimeDomainConstructionAtResonantRatios) +{ + // These ratios put 2 k P or k P right on top of an output harmonic, which is + // where the published kernels have removable singularities. + const auto follower = makeFollower(); + + for (const auto ratio : { 1.0, 2.0, 3.0, 1.5, 2.0 / 3.0 }) + { + expectMatchesTimeDomain (SyncMode::hard, ratio, follower, 8, 1e-3); + expectMatchesTimeDomain (SyncMode::mirrored, ratio, follower, 8, 1e-3); + } + + for (const auto ratio : { 1.0, 2.0, 3.0, 1.5, 7.0 / 3.0 }) + expectMatchesTimeDomain (SyncMode::pulsar, ratio, follower, 8, 1e-3); +} + +TEST_F (SyncSpectralResamplerTests, MatchesTheScalarSincReference) +{ + // Guards the vectorized dot-product rewrite against the published kernels, for + // a ratio that does not touch any removable singularity. + for (const auto mode : { SyncMode::hard, SyncMode::mirrored, SyncMode::pulsar }) + { + for (const auto waveform : { Waveform::sawtooth, Waveform::square, Waveform::triangle }) + { + const auto follower = makeFollower (waveform); + const auto reference = scalarReferenceTransform (follower, 1.375, mode, 24); + const auto transformed = runTransform (follower, 1.375, mode, 24); + + EXPECT_NEAR (reference.getDC(), transformed.getDC(), 1e-12); + + for (int n = 1; n <= 24; ++n) + { + EXPECT_NEAR (reference.getCosine (n), transformed.getCosine (n), 1e-12) << "cosine " << n << " mode " << (int) mode; + EXPECT_NEAR (reference.getSine (n), transformed.getSine (n), 1e-12) << "sine " << n << " mode " << (int) mode; + } + } + } +} + +TEST_F (SyncSpectralResamplerTests, MatchesTheScalarSincReferenceAtResonances) +{ + for (const auto mode : { SyncMode::hard, SyncMode::mirrored, SyncMode::pulsar }) + { + for (const auto ratio : { 1.0, 2.0, 1.5, 0.5, 2.0 / 3.0 }) + { + const auto follower = makeFollower(); + const auto reference = scalarReferenceTransform (follower, ratio, mode, 24); + const auto transformed = runTransform (follower, ratio, mode, 24); + + for (int n = 1; n <= 24; ++n) + { + EXPECT_NEAR (reference.getCosine (n), transformed.getCosine (n), 1e-9) << "cosine " << n << " mode " << (int) mode << " P " << ratio; + EXPECT_NEAR (reference.getSine (n), transformed.getSine (n), 1e-9) << "sine " << n << " mode " << (int) mode << " P " << ratio; + } + } + } +} + +//============================================================================== +TEST_F (SyncSpectralResamplerTests, NumOutputHarmonicsZeroesTheTail) +{ + const auto follower = makeFollower(); + + FourierSeries output (32); + SyncSpectralResampler resampler; + resampler.prepare (64); + + resampler.transform (follower, 1.375, SyncMode::hard, output, 8); + + for (int n = 9; n <= 32; ++n) + { + EXPECT_EQ (0.0, output.getCosine (n)); + EXPECT_EQ (0.0, output.getSine (n)); + } + + for (int n = 1; n <= 8; ++n) + EXPECT_TRUE (std::isfinite (output.getSine (n))); +} + +TEST_F (SyncSpectralResamplerTests, RecommendedOutputHarmonicsCoversTheFollowerBandwidth) +{ + EXPECT_EQ (22, SyncSpectralResampler::getRecommendedOutputHarmonics (16, 1.375, SyncMode::hard)); + EXPECT_EQ (22, SyncSpectralResampler::getRecommendedOutputHarmonics (16, 1.375, SyncMode::pulsar)); + EXPECT_EQ (44, SyncSpectralResampler::getRecommendedOutputHarmonics (16, 1.375, SyncMode::mirrored)); + EXPECT_EQ (64, SyncSpectralResampler::getRecommendedOutputHarmonics (16, 4.0, SyncMode::hard)); + EXPECT_EQ (16, SyncSpectralResampler::getRecommendedOutputHarmonics (16, 1.0, SyncMode::hard)); + EXPECT_EQ (0, SyncSpectralResampler::getRecommendedOutputHarmonics (0, 1.0, SyncMode::hard)); +} + +TEST_F (SyncSpectralResamplerTests, FundamentalScaleIsHalvedForMirroring) +{ + EXPECT_EQ (1.0, SyncSpectralResampler::getFundamentalScale (SyncMode::none)); + EXPECT_EQ (1.0, SyncSpectralResampler::getFundamentalScale (SyncMode::hard)); + EXPECT_EQ (0.5, SyncSpectralResampler::getFundamentalScale (SyncMode::mirrored)); + EXPECT_EQ (1.0, SyncSpectralResampler::getFundamentalScale (SyncMode::pulsar)); +} + +//============================================================================== +TEST_F (SyncSpectralResamplerTests, FloatCoefficientsAgreeWithDouble) +{ + const auto followerDouble = makeFollower (Waveform::sawtooth); + + FourierSeries followerFloat (followerHarmonics); + + for (int n = 1; n <= followerHarmonics; ++n) + followerFloat.setHarmonic (n, static_cast (followerDouble.getCosine (n)), static_cast (followerDouble.getSine (n))); + + SyncSpectralResampler resamplerFloat; + resamplerFloat.prepare (32); + + FourierSeries outputFloat (32); + resamplerFloat.transform (followerFloat, 1.375f, SyncMode::mirrored, outputFloat, 24); + + const auto outputDouble = runTransform (followerDouble, 1.375, SyncMode::mirrored, 24); + + for (int n = 1; n <= 24; ++n) + { + EXPECT_NEAR (outputDouble.getCosine (n), outputFloat.getCosine (n), 1e-4) << "harmonic " << n; + EXPECT_EQ (0.0f, outputFloat.getSine (n)); + } +} + +TEST_F (SyncSpectralResamplerTests, LargeFollowerStaysFinite) +{ + const auto follower = FourierSeries::create (Waveform::sawtooth, 512); + + const auto output = runTransform (follower, 1.375, SyncMode::hard, 512); + + for (int n = 1; n <= 512; ++n) + { + EXPECT_TRUE (std::isfinite (output.getCosine (n))); + EXPECT_TRUE (std::isfinite (output.getSine (n))); + EXPECT_LT (std::abs (output.getSine (n)), 10.0); + } +} + +TEST_F (SyncSpectralResamplerTests, RepeatedTransformsDoNotAccumulateState) +{ + const auto follower = makeFollower(); + + FourierSeries output (32); + SyncSpectralResampler resampler; + resampler.prepare (64); + + resampler.transform (follower, 1.375, SyncMode::hard, output, 32); + + const auto first = output.getSine (3); + + for (int i = 0; i < 8; ++i) + resampler.transform (follower, 1.375, SyncMode::hard, output, 32); + + EXPECT_EQ (first, output.getSine (3)); +} diff --git a/tests/yup_dsp/yup_WavetableOscillator.cpp b/tests/yup_dsp/yup_WavetableOscillator.cpp new file mode 100644 index 000000000..607a008ab --- /dev/null +++ b/tests/yup_dsp/yup_WavetableOscillator.cpp @@ -0,0 +1,221 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== +class WavetableOscillatorTests : public ::testing::Test +{ +protected: + static double peakMagnitude (WavetableOscillator& oscillator, int numSamples) + { + double peak = 0.0; + + for (int i = 0; i < numSamples; ++i) + peak = jmax (peak, std::abs (oscillator.processSample())); + + return peak; + } + + static constexpr double testSampleRate = 48000.0; +}; + +TEST_F (WavetableOscillatorTests, TableSizeIsOversizedButBounded) +{ + WavetableOscillator oscillator; + + oscillator.prepare (testSampleRate, 128); + EXPECT_EQ (1024, oscillator.getTableSize()); + + oscillator.prepare (testSampleRate, 600); + EXPECT_EQ (8192, oscillator.getTableSize()); + + oscillator.prepare (testSampleRate, 4096); + EXPECT_EQ (32768, oscillator.getTableSize()); + + oscillator.prepare (testSampleRate, 8); + EXPECT_EQ (64, oscillator.getTableSize()); + + oscillator.prepare (testSampleRate, 1); + EXPECT_EQ (64, oscillator.getTableSize()); +} + +TEST_F (WavetableOscillatorTests, MatchesTheAdditiveOscillator) +{ + constexpr int numHarmonics = 32; + constexpr int crossfadeLength = 64; + + const auto series = FourierSeries::create (Waveform::sawtooth, numHarmonics); + + WavetableOscillator wavetable; + wavetable.prepare (testSampleRate, numHarmonics, crossfadeLength); + wavetable.setSeries (series); + wavetable.setFrequency (1000.0); + wavetable.render(); + + for (int i = 0; i < crossfadeLength; ++i) + wavetable.processSample(); + + wavetable.reset(); + + AdditiveOscillator additive; + additive.prepare (testSampleRate, numHarmonics); + additive.setSeries (series); + additive.setFrequency (1000.0); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (additive.processSample(), wavetable.processSample(), 1e-3) << i; +} + +TEST_F (WavetableOscillatorTests, NeedsRenderTracksTheHarmonicLimit) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 64); + oscillator.setWaveform (Waveform::sawtooth); + oscillator.setFrequency (1000.0); + + EXPECT_TRUE (oscillator.needsRender()); + + oscillator.render(); + EXPECT_FALSE (oscillator.needsRender()); + EXPECT_EQ (23, oscillator.getNumRenderedHarmonics()); + + // Raising the pitch far enough that the rendered harmonics would alias + // forces an immediate render. + oscillator.setFrequency (20000.0); + EXPECT_TRUE (oscillator.needsRender()); + + oscillator.render(); + EXPECT_EQ (1, oscillator.getNumRenderedHarmonics()); + + oscillator.setFrequency (15000.0); + EXPECT_FALSE (oscillator.needsRender()); + + // Dropping the pitch regains brightness lazily, so a small change is ignored. + oscillator.setFrequency (2000.0); + EXPECT_TRUE (oscillator.needsRender()); +} + +TEST_F (WavetableOscillatorTests, CrossfadeBetweenRendersIsContinuous) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 64, 64); + oscillator.setSeries (FourierSeries::create (Waveform::sawtooth, 16)); + oscillator.setFrequency (220.0); + oscillator.render(); + + for (int i = 0; i < 128; ++i) + oscillator.processSample(); + + oscillator.setSeries (FourierSeries::create (Waveform::square, 16)); + oscillator.render(); + + auto previous = oscillator.processSample(); + auto maxJump = 0.0; + + for (int i = 1; i < 4096; ++i) + { + const auto value = oscillator.processSample(); + + maxJump = jmax (maxJump, std::abs (value - previous)); + previous = value; + } + + // Swapping the table without a crossfade would jump by up to the full swing of + // the two waveforms. + EXPECT_LT (maxJump, 0.5); +} + +TEST_F (WavetableOscillatorTests, KeepsPlayingUntilRenderedAgain) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 16); + oscillator.setWaveform (Waveform::sine); + oscillator.setFrequency (1000.0); + oscillator.render(); + + EXPECT_NEAR (1.0, peakMagnitude (oscillator, 480), 0.01); + + // Changing the series marks the table stale, but the previous rendition has to + // keep playing until the next render. + oscillator.setWaveform (Waveform::square); + + EXPECT_TRUE (oscillator.needsRender()); + EXPECT_NEAR (1.0, peakMagnitude (oscillator, 480), 0.01); +} + +TEST_F (WavetableOscillatorTests, BlockMatchesSampleBySample) +{ + WavetableOscillator block; + WavetableOscillator sample; + + block.prepare (testSampleRate, 32); + sample.prepare (testSampleRate, 32); + + block.setSeries (FourierSeries::create (Waveform::triangle, 32)); + sample.setSeries (FourierSeries::create (Waveform::triangle, 32)); + + block.setFrequency (660.0); + sample.setFrequency (660.0); + + block.render(); + sample.render(); + + std::vector buffer (256); + block.processBlock (buffer.data(), 256); + + for (int i = 0; i < 256; ++i) + EXPECT_EQ (buffer[static_cast (i)], sample.processSample()) << i; +} + +TEST_F (WavetableOscillatorTests, FloatInstantiationRuns) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 64); + oscillator.setWaveform (Waveform::sine); + oscillator.setFrequency (440.0); + oscillator.render(); + + float peak = 0.0f; + + for (int i = 0; i < 1024; ++i) + peak = jmax (peak, std::abs (oscillator.processSample())); + + EXPECT_NEAR (1.0f, peak, 0.02f); +} + +TEST_F (WavetableOscillatorTests, SilenceWithoutRenderedHarmonics) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 16); + oscillator.setWaveform (Waveform::sine); + + // Above twice Nyquist every harmonic is out of range, so the table is silent. + oscillator.setFrequency (30000.0); + oscillator.render(); + + EXPECT_EQ (0, oscillator.getNumRenderedHarmonics()); + EXPECT_EQ (0.0, peakMagnitude (oscillator, 64)); +} From bde79d074d69afa1d65c232b69f1d2291338419b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sun, 20 Sep 2026 16:28:24 +0200 Subject: [PATCH 02/37] Improved oscillators --- CHANGELOG.md | 2 + docs/dsp/index.md | 3 +- docs/dsp/oscillators.md | 184 ++++++++++-- .../oscillators/yup_AdditiveOscillator.h | 45 ++- .../yup_dsp/oscillators/yup_FourierSeries.h | 2 +- .../oscillators/yup_ModulatedOscillator.h | 265 +++++++++++++++++ .../oscillators/yup_MorphingOscillator.h | 160 ++++++++++ .../yup_dsp/oscillators/yup_SyncOscillator.h | 36 ++- .../oscillators/yup_SyncSpectralResampler.h | 91 ++++-- .../yup_dsp/oscillators/yup_WaveformBank.h | 169 +++++++++++ .../oscillators/yup_WavetableOscillator.h | 59 +++- modules/yup_dsp/resampling/yup_Oversampler.h | 48 ++- modules/yup_dsp/yup_dsp.h | 5 + tests/yup_dsp.cpp | 3 + tests/yup_dsp/yup_AdditiveOscillator.cpp | 26 ++ tests/yup_dsp/yup_FourierSeries.cpp | 7 + tests/yup_dsp/yup_ModulatedOscillator.cpp | 279 ++++++++++++++++++ tests/yup_dsp/yup_MorphingOscillator.cpp | 80 +++++ tests/yup_dsp/yup_Oversampler.cpp | 42 +++ tests/yup_dsp/yup_SyncOscillator.cpp | 21 ++ tests/yup_dsp/yup_SyncSpectralResampler.cpp | 31 +- tests/yup_dsp/yup_WaveformBank.cpp | 89 ++++++ tests/yup_dsp/yup_WavetableOscillator.cpp | 12 + 23 files changed, 1575 insertions(+), 84 deletions(-) create mode 100644 modules/yup_dsp/oscillators/yup_ModulatedOscillator.h create mode 100644 modules/yup_dsp/oscillators/yup_MorphingOscillator.h create mode 100644 modules/yup_dsp/oscillators/yup_WaveformBank.h create mode 100644 tests/yup_dsp/yup_ModulatedOscillator.cpp create mode 100644 tests/yup_dsp/yup_MorphingOscillator.cpp create mode 100644 tests/yup_dsp/yup_WaveformBank.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index b0556d06f..e1740ec80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Audio +- Added shared `WaveformBank`, spectral `MorphingOscillator`, and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `Oversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. + - Fixed the pulsar spectral resampler's alternating coefficient sign and corrected oscillator regression tests for fixed-size copies, startup crossfades, and spectral window leakage. - Added a `TuningMap` class (`midi/yup_TuningMap.h`): maps MIDI note numbers to frequencies under an arbitrary scale and key map, loading Scala `.scl` scale files and `.kbm` key map files via `loadScale()` / `loadKeyMap()` (which return a `yup::Result` and keep the previous tuning when a file fails to parse). `isNoteActive()` reports the notes a key map asks to retune, taken from the range in its header unless the file carries `< first last` lines, which declare it instead diff --git a/docs/dsp/index.md b/docs/dsp/index.md index ea16f2e62..7a4f12304 100644 --- a/docs/dsp/index.md +++ b/docs/dsp/index.md @@ -47,7 +47,8 @@ available in the build. `TimeStretchProcessor` with its time-domain and Bungee backends. - [Oscillators](oscillators.md) - `FourierSeries`, the alias-free `SyncSpectralResampler`, the `AdditiveOscillator` / `WavetableOscillator` - synthesis backends, and the synth-ready `SyncOscillator` facade. + synthesis backends, the `SyncOscillator` facade, shared `WaveformBank` frames, + and `MorphingOscillator` / `ModulatedOscillator` for morphing, FM, PM and sync. ## Key building blocks diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 9d6481bbc..66d387bf7 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -1,16 +1,17 @@ # Oscillators The `yup_dsp` oscillator classes synthesize bandlimited periodic waveforms from -their Fourier coefficients, and synchronize one oscillator to another without -aliasing: the output spectrum is built from scratch for every period ratio, so no -harmonic ever folds back below Nyquist. +their Fourier coefficients. Stationary additive synthesis excludes harmonics at +or above Nyquist. Wavetable interpolation, parameter modulation and time-domain +synchronization have finite antialiasing accuracy, described below. The implementation follows Roth, Keller, Castaneda and Studer, *"Alias-Free Oscillator Synchronization via Additive Synthesis"* (DAFx26, paper 49). **Headers:** `yup_dsp/oscillators/` - `yup_FourierSeries.h`, `yup_SyncSpectralResampler.h`, `yup_AdditiveOscillator.h`, -`yup_WavetableOscillator.h`, `yup_SyncOscillator.h`. +`yup_WavetableOscillator.h`, `yup_SyncOscillator.h`, `yup_WaveformBank.h`, +`yup_MorphingOscillator.h`, `yup_ModulatedOscillator.h`. ## The idea @@ -31,7 +32,7 @@ alias-free *by construction* - nothing has to be filtered out afterwards. | `mirrored` | `s (t) = r (P - abs (t))` on `[-P, P)`, period `2 P` | `-P` | `f_lead / 2` | | `pulsar` | one follower period in a window of width 1, period `P` | `-1/2` | `f_lead` | -Two details of the published method are worth knowing before using it: +Three details of the published method are worth knowing before using it: - **Mirrored sync sounds an octave below the leader.** Its period is `2 T_lead`, which is exactly how the paper defines it; `SyncOscillator::getOutputFrequency()` @@ -40,11 +41,13 @@ Two details of the published method are worth knowing before using it: time-domain constructions above, the output is the same waveform delayed by half a period of the output fundamental, i.e. its coefficients carry a factor `(-1)^n`. Only the phase is affected, never the magnitude spectrum. -- **Bandlimiting the follower caps the output.** The transform cannot create - harmonics the follower does not have, so a follower with `N` harmonics produces - an output whose top harmonic sits at `N * P` times the leader frequency. - `getRecommendedOutputHarmonics()` reports the harmonic count needed to keep all - of them; the facade clamps it to the configured maximum. +- **Input and output bandwidth are independent.** Even a single sine can produce + an infinite harmonic tail after a fractional hard-sync reset. The follower's + finite series limits how accurately it represents the input waveform, not how + many output harmonics the transform can produce. The facade computes output + coefficients up to its harmonic budget and current Nyquist limit. + `getRecommendedOutputHarmonics()` remains available as a brightness heuristic, + not as a bound on the synchronized spectrum. ## The classes @@ -74,7 +77,7 @@ void prepare (double sampleRate, int maxHarmonics) void setPitch (double leaderHz) { - osc.setFrequency (leaderHz); // phase continuous, no update needed + osc.setFrequency (leaderHz); // phase continuous; update refreshes bandwidth } void setSync (double ratio, yup::SyncMode mode) @@ -97,15 +100,17 @@ void processBlock (double* output, int numSamples) described below. `update()` is a no-op when nothing is dirty, so it is safe to call unconditionally once per block. - `update()` is allocation-free, so it may be called from the audio callback. -- Pitch changes are phase-continuous and need no `update()`; synchronization - parameter changes need exactly one. +- Pitch changes are phase-continuous. Call `update()` after pitch changes to + refresh table bandwidth and restore output harmonics after lowering the pitch. +- In additive mode, `update()` skips wavetable rendering. After switching to the + wavetable backend, call `update()` before processing to refresh its stale table. | operation | cost | |---|---| -| `SyncSpectralResampler::transform` | `O (N_in * N_out)` multiply-accumulates, `O (N_in)` transcendental calls. About 10 us for 128 harmonics, 100 us for 512. | -| `AdditiveOscillator::processSample` | One multiply accumulate per active harmonic, plus 2 transcendental calls. | -| `WavetableOscillator::render` | One inverse FFT of the oversampled table, 10 to 50 us for the default table. | -| `WavetableOscillator::processSample` | Two Hermite table reads (two while crossfading). | +| `SyncSpectralResampler::transform` | `O (N_in * N_out)` SIMD multiply-accumulates, `O (N_in)` transcendental calls. | +| `AdditiveOscillator::processSample` | SIMD harmonic accumulation; two fundamental-phasor trig calls every 64 samples and two rotation trig calls per frequency change. | +| `WavetableOscillator::render` | One inverse FFT of the oversampled table. | +| `WavetableOscillator::processSample` | One Hermite table read (two while crossfading). | ## Choosing a synthesis backend @@ -119,13 +124,147 @@ void processBlock (double* output, int numSamples) - **Additive** synthesizes the harmonics directly, so it is exact and reacts to every change immediately, but its per-sample cost grows with the harmonic count. -Both honor the Nyquist limit, so both are alias free. +The additive backend provides a stationary bandlimited reference. Wavetable +interpolation introduces small images, and moving parameters can create sidebands +even when both endpoint waveforms are bandlimited. The wavetable backend crossfades from silence on its first render, using the crossfade length passed to `prepare()` (64 samples by default). When comparing it with additive synthesis, let this ramp finish and align the playback phases before measuring. Subsequent renders crossfade from the current table. +## Endpoint morphing with spectral synchronization + +`MorphingOscillator` composes two `SyncOscillator`s with +identical phase, pitch, mode and ratio. Set endpoint series once and call `update()` +after changing the sync parameters. The morph position is supplied per sample or +as a block of values; changing it never runs a transform or FFT. + +```cpp +auto first = yup::FourierSeries::create (yup::Waveform::sawtooth, 128); +auto second = yup::FourierSeries::create (yup::Waveform::square, 128); +yup::MorphingOscillator oscillator; +oscillator.prepare (48000.0, 128); +oscillator.setSeries (first, second); +oscillator.setSyncMode (yup::SyncMode::hard); +oscillator.setFollowerRatio (1.375); +oscillator.update(); +auto sample = oscillator.processSample (0.25); +``` + +At a fixed ratio the transform is linear: transforming a coefficient blend equals +blending the transformed endpoints. Morphing uses linear amplitude interpolation, +not magnitude/phase interpolation or loudness normalization. Align endpoint phases +when cancellations are undesirable. Table-replacement crossfades remain separate +from the morph control. Morph changes create amplitude-modulation sidebands, so +smooth control-rate automation and reserve bandwidth. For audio-rate morphing use +the oversampled path below. + +## Prepared waveform banks + +`WaveformBank` renders any number of Fourier-series frames +at progressively smaller harmonic counts. Preparation allocates tables and FFT +storage and must happen outside the audio callback. A prepared bank is immutable +during playback and can be shared by multiple voices and threads; its owner must +keep it alive until all readers have stopped. + +Reads interpolate adjacent frames at a common phase. Bandwidth selection blends +two levels strictly below the requested harmonic bound, avoiding abrupt level +switches. This conservative transition can darken the sound before Nyquist. A +bound of twice `getNumHarmonics()` selects the full spectrum; a zero bound returns +only DC. Tables retain each frame's DC coefficient. + +Banks intentionally reuse `WavetableOscillator` rendering and interpolation. Each +frame/level currently retains its FFT scratch storage as well as its table; share +banks across voices to amortize preparation and memory. No bank rebuild is needed +for pitch, morph, FM, PM or phase-distortion changes. + +## Oversampled audio-rate modulation + +`ModulatedOscillator` +reads a shared bank and provides signed linear FM, exponential pitch modulation, +PM, frame morphing, breakpoint phase distortion and fractional hard sync. + +```cpp +std::array, 2> frames { + yup::FourierSeries::create (yup::Waveform::sawtooth, 128), + yup::FourierSeries::create (yup::Waveform::square, 128) +}; +yup::WaveformBank bank; +bank.prepare ({ frames.data(), frames.size() }); + +using Voice = yup::ModulatedOscillator; +Voice voice; +voice.prepare (48000.0, 512, bank); +Voice::Parameters controls; +controls.frequency = 220.0; +controls.syncFrequency = 110.0; +controls.morph = 0.3; +controls.phaseDistortion = 0.4; +// voice.processBlock (output, numSamples, controls); +``` + +For modulation, `processModulatedBlock(output, numSamples, callback)` invokes the +callback once per **internal-rate** sample. It returns a `Parameters` value. +Maintain modulator phase in caller-owned state across calls: callback indices +restart at zero in each block. Generate coupled modulators at this rate or +interpolate external controls to it. Callbacks must not allocate, block or throw. + +| Parameter | Meaning | +|---|---| +| `frequency` | Signed carrier frequency in Hz. | +| `linearFM` | Signed Hz added to the carrier; crossing zero reverses phase. | +| `exponentialFM` | Octave offset, clamped to +/-16; scales carrier plus linear FM. | +| `phaseModulation` | Read-phase offset in periods; leaves accumulated phase unchanged. | +| `morph` | Normalized bank position, clamped to [0, 1]. | +| `phaseDistortion` | Input phase that maps to half a waveform cycle; 0.5 is identity, clamped to [0.01, 0.99]. | +| `syncFrequency` | Nonnegative leader frequency; zero disables hard sync. | + +Carrier and leader increments are limited to half an internal sample-rate cycle. +Leader wraps reset the follower at the fractional event time, preserving its +post-reset phase remainder. Two-sample polynomial BLEP/BLAMP residuals correct +value and slope discontinuities at resets and phase-map corners. The bandwidth +estimate includes carrier speed, PM differences and the maximum phase-map slope. +It is a conservative table selection heuristic, not a bound on modulation sidebands. + +The entire synthesis/modulation path is generated at the elevated rate, low-pass +filtered, then decimated. Latency is `SincRadius` output samples; report +`getLatencyInSamples()` to the owning audio processor. `reset()` clears phase, +residuals and filter history. Processing is allocation-free within the block size +passed to `prepare()`. Invalid block sizes or null output return false without +advancing state. All parameter values must be finite. + +These are **antialiased**, not unconditionally alias-free, modulation algorithms. +Finite correction kernels do not correct all higher derivatives of an arbitrary +waveform. Parameters are treated as constant within each internal sample interval; +abrupt control changes are not automatically smoothed. Higher oversampling and +filter radius improve different error sources at increased CPU cost. Extreme +modulation needs explicit bandwidth/depth constraints. Do not upsample an already +aliased base-rate oscillator and expect its aliases to disappear. + +`Oversampler::beginGeneration()` exposes the same allocation-free generation path +for other sources. Fill its high-rate buffer directly and call `downsample()`; +`getGenerationLatencyInSamples()` reports decimation-only latency, while the +existing `getLatencyInSamples()` still describes the complete up/down path. + +## Verification and performance + +The tests include scalar spectral references, exact Nyquist boundaries, phasor +reseeding, endpoint-transform linearity, safe bandwidth transitions, through-zero +frequency, PM, fractional resets, and block-partition independence. A continuous +analytic sync/phase-distortion waveform rendered at 64x provides an independent +reference for the time-domain path, with a 32x/64x convergence check and a comparison +against naive base-rate sampling. This is a regression target for the tested +settings, not a quality guarantee for every modulation depth or waveform. + +The fused resampler computes both weighted sums in one SIMD pass and masks +resonances before division. Additive synthesis advances a double-precision +fundamental phasor by recurrence and reseeds it every 64 samples. These changes +remove work, but speedups must be measured on each target architecture. Benchmark +128/512 harmonics, float/double coefficients, both backends, and 1/16/64 voices, +including worst-case parameter updates. No timing claims are implied by the API. + + ## Related areas - [Frequency domain](frequency.md) - `FFTProcessor`, which the wavetable renderer @@ -140,3 +279,12 @@ before measuring. Subsequent renders crossfade from the current table. Roth, J., Keller, D., Castaneda, L., Studer, C. *Alias-Free Oscillator Synchronization via Additive Synthesis*. DAFx26, paper 49. Reference Python implementation: `github.com/IIP-Group/hasy-python`. + +Additional algorithm references: + +- [A General Antialiasing Method for Sine Hard Sync](https://dafx.de/paper-archive/2022/papers/DAFx20in22_paper_3.pdf) + discusses why correcting only value and slope jumps remains approximate for sine sync. +- [Vector Phaseshaping Synthesis](https://www.dafx.de/paper-archive/2011/Papers/55_e.pdf) + describes breakpoint phase maps and modulation. +- [Practical Linear and Exponential Frequency Modulation for Digital Music Synthesis](https://www.dafx.de/paper-archive/2020/proceedings/papers/DAFx2020_paper_61.pdf) + discusses FM semantics, sideband bandwidth and oversampling. diff --git a/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h index b28218949..95d4e70fb 100644 --- a/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h +++ b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h @@ -37,9 +37,10 @@ namespace yup Synthesis is band-unlimited on purpose - the caller decides how many harmonics the series holds - and each sample costs a phase increment plus one multiply accumulate per active harmonic, evaluated laneCount harmonics at a time in - SIMDRegister lanes. Two transcendental calls per sample are needed to rotate - the harmonic phasors, and no per-harmonic state is kept, so there is no - long-term drift. + SIMDRegister lanes. The fundamental phasor advances by recurrence and is + reseeded every 64 samples and after phase changes to bound drift. Its rotation + is recalculated only when the frequency or sample rate changes. No persistent + per-harmonic state is kept. @tparam SampleType Type for the synthesized samples (float or double). @tparam CoeffType Type for the coefficients and phase math (default double). @@ -75,6 +76,8 @@ class AdditiveOscillator this->sampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; series.resize (maxHarmonics); + updateActiveHarmonics(); + updatePhaseRotation(); reset(); } @@ -83,6 +86,7 @@ class AdditiveOscillator void reset() noexcept { phase = 0.0; + samplesUntilReseed = 0; } //============================================================================== @@ -95,6 +99,7 @@ class AdditiveOscillator { frequency = sanitized; updateActiveHarmonics(); + updatePhaseRotation(); } } @@ -105,6 +110,7 @@ class AdditiveOscillator void setPhase (CoeffType newPhase) noexcept { phase = static_cast (newPhase - std::floor (newPhase)); + samplesUntilReseed = 0; } /** Returns the phase, normalized to one period. */ @@ -144,11 +150,25 @@ class AdditiveOscillator /** Synthesizes one sample. */ SampleType processSample() noexcept { - const auto value = activeHarmonics > 0 ? synthesizeSample() : CoeffType (0); + if (samplesUntilReseed == 0) + { + const auto angle = MathConstants::twoPi * phase; + fundamentalCosine = std::cos (angle); + fundamentalSine = std::sin (angle); + samplesUntilReseed = 64; + } + + const auto value = activeHarmonics > 0 ? synthesizeSample() + : (includeDC ? series.getDC() : CoeffType (0)); phase += static_cast (frequency) / sampleRate; phase -= std::floor (phase); + const auto nextCosine = fundamentalCosine * rotationCosine - fundamentalSine * rotationSine; + fundamentalSine = fundamentalSine * rotationCosine + fundamentalCosine * rotationSine; + fundamentalCosine = nextCosine; + --samplesUntilReseed; + return static_cast (value); } @@ -169,11 +189,17 @@ class AdditiveOscillator activeHarmonics = getNyquistHarmonicLimit (frequency, sampleRate, series.getNumHarmonics()); } + void updatePhaseRotation() noexcept + { + const auto angle = MathConstants::twoPi * static_cast (frequency) / sampleRate; + rotationCosine = std::cos (angle); + rotationSine = std::sin (angle); + } + CoeffType synthesizeSample() noexcept { - const auto theta = MathConstants::twoPi * static_cast (phase); - const auto stepCosine = std::cos (theta); - const auto stepSine = std::sin (theta); + const auto stepCosine = static_cast (fundamentalCosine); + const auto stepSine = static_cast (fundamentalSine); CoeffType laneCosine[laneCount] = {}; CoeffType laneSine[laneCount] = {}; @@ -245,8 +271,13 @@ class AdditiveOscillator FourierSeries series; double sampleRate = 44100.0; double phase = 0.0; + double fundamentalCosine = 1.0; + double fundamentalSine = 0.0; + double rotationCosine = 1.0; + double rotationSine = 0.0; CoeffType frequency = static_cast (440); int activeHarmonics = 0; + int samplesUntilReseed = 0; bool includeDC = false; }; diff --git a/modules/yup_dsp/oscillators/yup_FourierSeries.h b/modules/yup_dsp/oscillators/yup_FourierSeries.h index f2b055a1d..400b192ef 100644 --- a/modules/yup_dsp/oscillators/yup_FourierSeries.h +++ b/modules/yup_dsp/oscillators/yup_FourierSeries.h @@ -66,7 +66,7 @@ int getNyquistHarmonicLimit (FloatType frequency, double sampleRate, int maxHarm const auto limit = sampleRate / (2.0 * fundamental); - if (limit >= static_cast (maxHarmonics)) + if (limit > static_cast (maxHarmonics)) return maxHarmonics; return jlimit (0, maxHarmonics, static_cast (std::ceil (limit)) - 1); diff --git a/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h new file mode 100644 index 000000000..67eb370d4 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h @@ -0,0 +1,265 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Oversampled waveform morphing, through-zero FM, PM, phase distortion and hard sync. + + Reads a shared, immutable WaveformBank. The phase accumulator is signed and + independent of phase modulation. A two-segment phase map places half a waveform + cycle at phaseDistortion (0.5 is the identity). Positive leader wraps reset the + follower at their fractional internal-sample position. Two-sample polynomial + BLEP/BLAMP residuals correct value/slope jumps at resets and phase-map corners. + + The whole modulation path runs at OversampleFactor times the output rate and + reuses Oversampler's decimation filter. This reduces aliasing; finite kernels, + table interpolation and oversampling do not guarantee alias-free arbitrary + modulation. Higher waveform derivatives and abrupt parameter changes remain + approximate. Choose bandwidth, modulation depth and oversampling accordingly. + + prepare() allocates; processing and reset() do not. A bank must outlive this + oscillator and must not be modified during playback. Each voice owns its phase, + residual and decimator state; voices can share the bank. + + @tparam SampleType Output precision. + @tparam OversampleFactor Internal sample-rate multiplier (at least 2). + @tparam SincRadius Decimator radius in output samples. + @tparam CoeffType Waveform coefficient precision. +*/ +template +class ModulatedOscillator +{ +public: + /** Controls for one internal sample interval. All fields must be finite. */ + struct Parameters + { + double frequency = 440.0; /**< Signed carrier frequency in Hz. */ + double linearFM = 0.0; /**< Signed frequency deviation in Hz. */ + double exponentialFM = 0.0; /**< Pitch offset in octaves, clamped to +/-16. */ + double phaseModulation = 0.0; /**< Read-phase offset in periods; does not reset phase. */ + double morph = 0.0; /**< Normalized waveform-bank position, clamped to [0, 1]. */ + double phaseDistortion = 0.5; /**< Phase-map breakpoint, clamped to [0.01, 0.99]. */ + double syncFrequency = 0.0; /**< Leader frequency in Hz; zero disables hard sync. */ + }; + + /** Allocates decimator storage and attaches a prepared waveform bank. + + @param sampleRate Output rate in Hz, positive. + @param maxBlockSize Maximum output block size, positive. + @param waveforms Immutable bank; must remain alive throughout playback. + */ + void prepare (double sampleRate, int maxBlockSize, const WaveformBank& waveforms) + { + jassert (sampleRate > 0.0 && maxBlockSize > 0); + internalSampleRate = jmax (1.0, sampleRate) * OversampleFactor; + bank = &waveforms; + oversampler.prepare (jmax (1.0, sampleRate), 1, jmax (1, maxBlockSize)); + reset(); + } + + /** Resets phase, sync leader, residuals and decimator history. + + @param initialPhase Initial follower phase in periods, wrapped internally. + */ + void reset (double initialPhase = 0.0) noexcept + { + phase = wrap (initialPhase); + leaderPhase = 0.0; + nextCorrection = 0.0; + previousPhaseModulation = 0.0; + hasPreviousParameters = false; + oversampler.reset(); + } + + /** Returns the unmodulated follower accumulator in periods. */ + double getPhase() const noexcept { return phase; } + + /** Returns the internal rate at which the modulation callback is invoked. */ + double getInternalSampleRate() const noexcept { return internalSampleRate; } + + /** Returns the output latency in samples, including the decimation filter. */ + static constexpr int getLatencyInSamples() noexcept { return SincRadius; } + + /** Produces a block with constant controls. Returns false for invalid block sizes. + + A rejected block leaves output and oscillator state unchanged. Carrier and + leader frequencies are limited to half the internal rate, bounding the + number of fractional events per interval. Negative carriers run backward. + */ + bool processBlock (SampleType* output, int numSamples, const Parameters& parameters) noexcept + { + return processModulatedBlock (output, numSamples, [&] (int) { return parameters; }); + } + + /** Produces a block with controls evaluated at the internal sample rate. + + The callback is invoked as Parameters(int internalSampleIndex), in order, + for numSamples * OversampleFactor samples. Its index restarts at zero for + each call; keep modulator phase in caller-owned state across blocks. Run + coupled modulators here or interpolate external control signals to this + rate. The callback must not allocate, block or throw. Controls describe + the following internal sample interval; event interpolation assumes they + remain constant over that interval. + + Returns false without invoking the callback for null output, unprepared + state, or nonpositive/oversized blocks. Abrupt control changes are not + automatically smoothed and can create audible transients. + */ + template + bool processModulatedBlock (SampleType* output, int numSamples, Modulation&& modulation) noexcept + { + if (output == nullptr || bank == nullptr || ! oversampler.beginGeneration (1, numSamples)) + return false; + + auto* internal = oversampler.getOversampledChannelData (0); + for (int i = 0; i < oversampler.getOversampledNumSamples(); ++i) + internal[i] = static_cast (processInternal (modulation (i))); + + SampleType* channels[] = { output }; + oversampler.downsample (channels, 1, numSamples); + return true; + } + +private: + static double wrap (double value) noexcept { return value - std::floor (value); } + + static double warp (double phase, double breakpoint) noexcept + { + const auto p = wrap (phase); + return p < breakpoint ? 0.5 * p / breakpoint + : 0.5 + 0.5 * (p - breakpoint) / (1.0 - breakpoint); + } + + static double warpSlope (double phase, double breakpoint) noexcept + { + return wrap (phase) < breakpoint ? 0.5 / breakpoint : 0.5 / (1.0 - breakpoint); + } + + double value (double p, const Parameters& controls, double bandwidth) const noexcept + { + return bank->getValue (warp (p + controls.phaseModulation, controls.phaseDistortion), + static_cast (controls.morph), bandwidth); + } + + double slope (double p, double increment, const Parameters& controls, double bandwidth) const noexcept + { + const auto position = p + controls.phaseModulation; + auto derivative = warpSlope (position, controls.phaseDistortion); + if (increment < 0.0) + { + if (wrap (position) == 0.0) + derivative = 0.5 / (1.0 - controls.phaseDistortion); + else if (wrap (position) == controls.phaseDistortion) + derivative = 0.5 / controls.phaseDistortion; + } + return bank->getSlope (warp (position, controls.phaseDistortion), + static_cast (controls.morph), bandwidth) + * derivative; + } + + void correctEvent (double fraction, double jump, double slopeJump, double& current) noexcept + { + const auto before = 1.0 - fraction; + current += 0.5 * jump * before * before + slopeJump * before * before * before / 6.0; + nextCorrection += -0.5 * jump * fraction * fraction + slopeJump * fraction * fraction * fraction / 6.0; + } + + void correctCorners (double start, double increment, double duration, double offset, + const Parameters& controls, double bandwidth, double& current) noexcept + { + if (increment == 0.0 || duration <= 0.0 || controls.phaseDistortion == 0.5) + return; + + const auto position = start + controls.phaseModulation; + const auto end = position + increment * duration; + const auto left = 0.5 / controls.phaseDistortion; + const auto right = 0.5 / (1.0 - controls.phaseDistortion); + + for (const auto corner : { 0.0, controls.phaseDistortion }) + { + const auto boundary = increment > 0.0 ? std::floor (position - corner) + 1.0 + corner + : std::ceil (position - corner) - 1.0 + corner; + if ((increment > 0.0 && boundary > end) || (increment < 0.0 && boundary < end)) + continue; + + const auto fraction = offset + (boundary - position) / increment; + const auto mappedPhase = corner == 0.0 ? 0.0 : 0.5; + const auto derivative = bank->getSlope (mappedPhase, static_cast (controls.morph), bandwidth); + const auto change = corner == 0.0 ? left - right : right - left; + correctEvent (fraction, 0.0, derivative * change * std::abs (increment), current); + } + } + + double processInternal (Parameters controls) noexcept + { + controls.morph = jlimit (0.0, 1.0, controls.morph); + controls.phaseDistortion = jlimit (0.01, 0.99, controls.phaseDistortion); + const auto pitchScale = controls.exponentialFM == 0.0 ? 1.0 : std::exp2 (jlimit (-16.0, 16.0, controls.exponentialFM)); + const auto frequency = (controls.frequency + controls.linearFM) * pitchScale; + const auto increment = jlimit (-0.5, 0.5, frequency / internalSampleRate); + const auto leaderIncrement = jlimit (0.0, 0.5, controls.syncFrequency / internalSampleRate); + const auto phaseModulationSpeed = hasPreviousParameters ? (controls.phaseModulation - previousPhaseModulation) * internalSampleRate : 0.0; + const auto maximumWarpSlope = 0.5 / jmin (controls.phaseDistortion, 1.0 - controls.phaseDistortion); + const auto phaseSpeed = (std::abs (increment) * internalSampleRate + std::abs (phaseModulationSpeed)) * maximumWarpSlope; + const auto bandwidth = phaseSpeed > 0.0 ? 0.45 * internalSampleRate / phaseSpeed + : 2.0 * bank->getNumHarmonics(); + previousPhaseModulation = controls.phaseModulation; + hasPreviousParameters = true; + + auto current = value (phase, controls, bandwidth) + nextCorrection; + nextCorrection = 0.0; + + if (leaderIncrement > 0.0 && leaderPhase + leaderIncrement >= 1.0) + { + const auto fraction = (1.0 - leaderPhase) / leaderIncrement; + const auto beforeReset = phase + increment * fraction; + correctCorners (phase, increment, fraction, 0.0, controls, bandwidth, current); + const auto jump = value (0.0, controls, bandwidth) - value (beforeReset, controls, bandwidth); + const auto slopeJump = (slope (0.0, increment, controls, bandwidth) - slope (beforeReset, increment, controls, bandwidth)) * increment; + correctEvent (fraction, jump, slopeJump, current); + correctCorners (0.0, increment, 1.0 - fraction, fraction, controls, bandwidth, current); + phase = wrap (increment * (1.0 - fraction)); + } + else + { + correctCorners (phase, increment, 1.0, 0.0, controls, bandwidth, current); + phase = wrap (phase + increment); + } + + leaderPhase = wrap (leaderPhase + leaderIncrement); + return current; + } + + const WaveformBank* bank = nullptr; + Oversampler oversampler; + double internalSampleRate = 1.0; + double phase = 0.0; + double leaderPhase = 0.0; + double nextCorrection = 0.0; + double previousPhaseModulation = 0.0; + bool hasPreviousParameters = false; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_MorphingOscillator.h b/modules/yup_dsp/oscillators/yup_MorphingOscillator.h new file mode 100644 index 000000000..0994578c6 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_MorphingOscillator.h @@ -0,0 +1,160 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Morphs two spectral oscillators without rebuilding spectra for morph changes. + + The endpoints share pitch, phase, sync mode and follower ratio. update() applies + the synchronization transform independently to each endpoint; because that + transform is linear, blending their output is equivalent to transforming the + blended input at a fixed ratio. No transform or FFT runs in processSample(). + + Morph is linear amplitude interpolation, clamped to [0, 1]. Moving it creates + modulation sidebands. Smooth control-rate changes and leave bandwidth headroom; + use ModulatedOscillator for oversampled audio-rate morphing. Endpoint render + crossfades remain independent of the morph control. + + @tparam SampleType Output sample precision. + @tparam CoeffType Fourier coefficient precision. + @see SyncOscillator, ModulatedOscillator +*/ +template +class MorphingOscillator +{ +public: + /** Runtime synthesis backend, shared by both endpoints. */ + using Synthesis = typename SyncOscillator::Synthesis; + + /** Allocates both endpoints outside the audio callback. */ + void prepare (double sampleRate, int maxHarmonics = 128, int crossfadeLengthInSamples = 64) + { + first.prepare (sampleRate, maxHarmonics, crossfadeLengthInSamples); + second.prepare (sampleRate, maxHarmonics, crossfadeLengthInSamples); + } + + /** Copies two endpoint spectra into prepared storage. Call update() afterward. */ + void setSeries (const FourierSeries& a, const FourierSeries& b) noexcept + { + first.setFollowerSeries (a); + second.setFollowerSeries (b); + } + + /** Sets the common leader frequency in Hz. Call update() to refresh bandwidth. */ + void setFrequency (CoeffType frequency) noexcept + { + first.setFrequency (frequency); + second.setFrequency (frequency); + } + + /** Sets the common synchronization mode, pending update(). */ + void setSyncMode (SyncMode mode) noexcept + { + first.setSyncMode (mode); + second.setSyncMode (mode); + } + + /** Sets the common follower/leader frequency ratio, pending update(). */ + void setFollowerRatio (CoeffType ratio) noexcept + { + first.setFollowerRatio (ratio); + second.setFollowerRatio (ratio); + } + + /** Selects both synthesis backends, pending update(). */ + void setSynthesis (Synthesis synthesis) noexcept + { + first.setSynthesis (synthesis); + second.setSynthesis (synthesis); + } + + /** Sets the common phase in periods. */ + void setPhase (CoeffType phase) noexcept + { + first.setPhase (phase); + second.setPhase (phase); + } + + /** Returns the common playback phase in periods. */ + CoeffType getPhase() const noexcept { return first.getPhase(); } + + /** Resets both phases, keeping the prepared spectra and tables. */ + void reset() noexcept + { + first.reset(); + second.reset(); + } + + /** Selects whether endpoint DC coefficients are synthesized, pending update(). */ + void setIncludeDC (bool include) noexcept + { + first.setIncludeDC (include); + second.setIncludeDC (include); + } + + /** Returns whether either endpoint needs a control-rate refresh. */ + bool needsUpdate() const noexcept { return first.needsUpdate() || second.needsUpdate(); } + + /** Refreshes endpoint spectra/tables without allocating. Call once per block. */ + void update() noexcept + { + first.update(); + second.update(); + } + + /** Produces a sample, with morph 0 selecting the first endpoint and 1 the second. */ + SampleType processSample (CoeffType morph) noexcept + { + const auto a = first.processSample(); + const auto b = second.processSample(); + return a + (b - a) * static_cast (jlimit (CoeffType (0), CoeffType (1), morph)); + } + + /** Produces a block with a fixed morph position. */ + void processBlock (SampleType* output, int numSamples, CoeffType morph) noexcept + { + if (output == nullptr) + return; + + for (int i = 0; i < numSamples; ++i) + output[i] = processSample (morph); + } + + /** Produces a block with one morph position per output sample. */ + void processBlock (SampleType* output, Span morph) noexcept + { + if (output == nullptr) + return; + + for (std::size_t i = 0; i < morph.size(); ++i) + output[i] = processSample (morph[i]); + } + +private: + SyncOscillator first; + SyncOscillator second; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_SyncOscillator.h b/modules/yup_dsp/oscillators/yup_SyncOscillator.h index b65cdfc77..8994b84d7 100644 --- a/modules/yup_dsp/oscillators/yup_SyncOscillator.h +++ b/modules/yup_dsp/oscillators/yup_SyncOscillator.h @@ -30,8 +30,9 @@ namespace yup Owns a follower FourierSeries, the synced series the spectral resampler produces from it, and both synthesis backends, so a voice only has to set a - frequency, a follower ratio and a sync mode. The output never aliases, - because the series being synthesized only ever holds harmonics below Nyquist. + frequency, a follower ratio and a sync mode. Stationary additive output is + bandlimited. Parameter modulation and wavetable interpolation can introduce + additional spectral components. ``` yup::SyncOscillator osc; @@ -44,7 +45,7 @@ namespace yup osc.processBlock (buffer, numSamples); ``` - Pitch changes are phase-continuous and need no update(); changing the + Pitch changes are phase-continuous; call update() to refresh bandwidth. Changing the synchronization parameters marks the oscillator dirty and needs one update() before the change is heard. update() runs the O (N^2) spectral transform plus, for the wavetable backend, one inverse FFT, so it belongs in the block loop, @@ -119,7 +120,10 @@ class SyncOscillator } //============================================================================== - /** Selects the synthesis backend, keeping the phase continuous. */ + /** Selects the synthesis backend, keeping the phase continuous. + + Call update() before processing to refresh a previously inactive wavetable. + */ void setSynthesis (Synthesis newSynthesis) noexcept { if (synthesis == newSynthesis) @@ -140,8 +144,8 @@ class SyncOscillator Sets the leader (note) pitch in Hz. The follower's pitch follows immediately as - frequency * getFundamentalScale (syncMode), which needs no update() because - both backends are phase-continuous in frequency. + frequency * getFundamentalScale (syncMode). Both backends are phase + continuous; call update() to restore harmonics when lowering the pitch. */ void setFrequency (CoeffType leaderHz) noexcept { @@ -271,7 +275,9 @@ class SyncOscillator /** Returns true when update() would change the output. */ bool needsUpdate() const noexcept { - return dirty || wavetable.needsRender(); + return dirty + || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics) + || (synthesis == Synthesis::wavetable && wavetable.needsRender()); } /** @@ -284,22 +290,20 @@ class SyncOscillator */ void update() noexcept { - if (dirty) + if (dirty || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics)) { - const auto numOutputHarmonics = jmin (maxHarmonics, - SyncSpectralResampler::getRecommendedOutputHarmonics (follower.getNumHarmonics(), - followerRatio, - syncMode)); + const auto numOutputHarmonics = getOutputHarmonicLimit(); resampler.transform (follower, followerRatio, syncMode, synced, numOutputHarmonics); additive.setSeries (synced); wavetable.setSeries (synced); + computedHarmonics = numOutputHarmonics; dirty = false; } - if (wavetable.needsRender()) + if (synthesis == Synthesis::wavetable && wavetable.needsRender()) wavetable.render(); } @@ -326,6 +330,11 @@ class SyncOscillator //============================================================================== private: //============================================================================== + int getOutputHarmonicLimit() const noexcept + { + return getNyquistHarmonicLimit (getOutputFrequency(), sampleRate, maxHarmonics); + } + SyncSpectralResampler resampler; AdditiveOscillator additive; WavetableOscillator wavetable; @@ -337,6 +346,7 @@ class SyncOscillator SyncMode syncMode = SyncMode::none; Synthesis synthesis = Synthesis::wavetable; int maxHarmonics = 128; + int computedHarmonics = 0; bool includeDC = false; bool dirty = true; }; diff --git a/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h index 131547e12..66d217c7c 100644 --- a/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h +++ b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h @@ -56,9 +56,9 @@ enum class SyncMode Both the follower and the output are FourierSeries objects, so the caller controls the follower bandwidth and how many output harmonics are wanted - (Remark 6 of the paper allows N_out != N_in). The transform cannot create - harmonics that the follower does not have: bandlimiting the follower to N - harmonics caps the spectrum of the synchronized waveform at N * P (Remark 13). + (Remark 6 of the paper allows N_out != N_in). Synchronization can create an + infinite output harmonic tail even from a finite follower series. Output + bandwidth must therefore be chosen independently of the follower bandwidth. The transform costs O (N_in * N_out) multiply-accumulates with O (N_in) transcendental calls, using FloatVectorOperations and, where helpful, @@ -103,6 +103,7 @@ class SyncSpectralResampler triggerCosine.assign (count, CoeffType (0)); inverseArgument.assign (count, CoeffType (0)); weight.assign (count, CoeffType (0)); + nonResonant.assign (count, CoeffType (1)); resonantIndices.reserve (count); resonantHarmonics.reserve (count); @@ -186,13 +187,15 @@ class SyncSpectralResampler : static_cast (-0.5); } - /** Returns how many output harmonics are needed to keep the follower's bandwidth. + /** Returns a heuristic output harmonic count based on the follower bandwidth. The follower's top harmonic sits at N * P times the leader frequency, and the output fundamental is the leader frequency scaled by getFundamentalScale(), hence ceil (N * P / scale). The caller is expected to clamp the result to its own harmonic budget, in which case the upper - harmonics of the follower are truncated. + harmonics of the follower are truncated. This is not a bound on the + synchronized spectrum: reset discontinuities can produce harmonics beyond + this count. For full output bandwidth, use the Nyquist harmonic limit. */ static int getRecommendedOutputHarmonics (int numFollowerHarmonics, CoeffType periodRatio, SyncMode mode) noexcept { @@ -219,6 +222,7 @@ class SyncSpectralResampler resonantIndices.clear(); resonantHarmonics.clear(); + FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); for (int k = 1; k <= numFollower; ++k) { @@ -235,6 +239,7 @@ class SyncSpectralResampler if (std::abs (argument - rounded) < getResonanceEpsilon()) { + nonResonant[index] = CoeffType (0); resonantIndices.push_back (static_cast (index)); resonantHarmonics.push_back (static_cast (rounded)); } @@ -249,17 +254,12 @@ class SyncSpectralResampler { const auto nSquared = static_cast (n) * static_cast (n); - FloatVectorOperations::fill (weight.data(), nSquared, numFollower); - FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); - FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); - - for (int i = 0; i < numResonant; ++i) - weight[static_cast (resonantIndices[static_cast (i)])] = CoeffType (0); + const auto sums = accumulateWeights (nSquared, numFollower); const auto sign = (n % 2 == 0) ? static_cast (-1) : static_cast (1); - auto a = sign * (CoeffType (2) / pi) * FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower); - auto b = sign * (CoeffType (2 * n) / pi) * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower); + auto a = sign * (CoeffType (2) / pi) * sums[0]; + auto b = sign * (CoeffType (2 * n) / pi) * sums[1]; for (int i = 0; i < numResonant; ++i) { @@ -284,6 +284,7 @@ class SyncSpectralResampler resonantIndices.clear(); resonantHarmonics.clear(); + FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); for (int k = 1; k <= numFollower; ++k) { @@ -301,6 +302,7 @@ class SyncSpectralResampler if (std::abs (argument - rounded) < getResonanceEpsilon()) { + nonResonant[index] = CoeffType (0); resonantIndices.push_back (static_cast (index)); resonantHarmonics.push_back (static_cast (rounded)); } @@ -314,18 +316,11 @@ class SyncSpectralResampler { const auto nSquared = static_cast (n) * static_cast (n); - FloatVectorOperations::fill (weight.data(), nSquared, numFollower); - FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); - FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); - - for (int i = 0; i < numResonant; ++i) - weight[static_cast (resonantIndices[static_cast (i)])] = CoeffType (0); + const auto sums = accumulateWeights (nSquared, numFollower); const auto alternating = (n % 2 == 0) ? static_cast (1) : static_cast (-1); - auto a = (CoeffType (2) / pi) - * (FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower) - + alternating * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower)); + auto a = (CoeffType (2) / pi) * (sums[0] + alternating * sums[1]); for (int i = 0; i < numResonant; ++i) { @@ -368,6 +363,8 @@ class SyncSpectralResampler secondWeight[index] = sign * static_cast (k) * followerSine[index]; } + FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); + // The paper's pulsar transform drops the follower's DC term. output.setDC (CoeffType (0)); @@ -380,17 +377,18 @@ class SyncSpectralResampler && rounded <= static_cast (numFollower); const auto resonantIndex = isResonant ? static_cast (rounded) - 1 : 0; - FloatVectorOperations::fill (weight.data(), q * q, numFollower); - FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), numFollower); - FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), numFollower); + if (isResonant) + nonResonant[resonantIndex] = CoeffType (0); + + const auto sums = accumulateWeights (q * q, numFollower); if (isResonant) - weight[resonantIndex] = CoeffType (0); + nonResonant[resonantIndex] = CoeffType (1); const auto sine = std::sin (pi * q); - auto a = (CoeffType (2) * q * sine / (pi * ratio)) * FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), numFollower); - auto b = (CoeffType (2) * sine / (pi * ratio)) * FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), numFollower); + auto a = (CoeffType (2) * q * sine / (pi * ratio)) * sums[0]; + auto b = (CoeffType (2) * sine / (pi * ratio)) * sums[1]; if (isResonant) { @@ -402,6 +400,42 @@ class SyncSpectralResampler } } + std::array accumulateWeights (CoeffType squared, int count) const noexcept + { + constexpr int lanes = std::is_same_v ? 8 : 4; + using Register = SIMDRegister; + + const auto one = Register::broadcast (CoeffType (1)); + const auto argument = Register::broadcast (squared); + auto first = Register::zero(); + auto second = Register::zero(); + int k = 0; + + for (; k + lanes <= count; k += lanes) + { + const auto mask = Register::loadUnaligned (nonResonant.data() + k); + const auto denominator = (argument - Register::loadUnaligned (argumentSquared.data() + k)) * mask + (one - mask); + const auto reciprocal = mask / denominator; + first = first.mulAdd (Register::loadUnaligned (firstWeight.data() + k), reciprocal); + second = second.mulAdd (Register::loadUnaligned (secondWeight.data() + k), reciprocal); + } + + std::array result { first.sum(), second.sum() }; + + for (; k < count; ++k) + { + const auto index = static_cast (k); + if (nonResonant[index] == CoeffType (0)) + continue; + + const auto reciprocal = CoeffType (1) / (squared - argumentSquared[index]); + result[0] += firstWeight[index] * reciprocal; + result[1] += secondWeight[index] * reciprocal; + } + + return result; + } + //============================================================================== FourierSeries rotated; std::vector firstWeight; @@ -411,6 +445,7 @@ class SyncSpectralResampler std::vector triggerCosine; std::vector inverseArgument; std::vector weight; + std::vector nonResonant; std::vector resonantIndices; std::vector resonantHarmonics; }; diff --git a/modules/yup_dsp/oscillators/yup_WaveformBank.h b/modules/yup_dsp/oscillators/yup_WaveformBank.h new file mode 100644 index 000000000..3803952aa --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_WaveformBank.h @@ -0,0 +1,169 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Prepared, shareable waveform frames with progressively reduced bandwidth. + + prepare() renders each Fourier series at harmonic limits N, N/2, ... 1 and + zero (DC only). All frames use the same table size and phase convention. + Playback interpolates adjacent frames linearly using Hermite table reads. + Bandwidth transitions blend two safe levels. Align the phases of input frames + when cancellation during morphing is undesirable. + + Preparation allocates FFT plans and tables and must happen off the audio thread. + After preparation, const reads are allocation-free and may be shared by voices + and threads. Do not prepare or destroy a bank while a voice is reading it. + Morphing and phase modulation introduce sidebands; table bandlimiting alone + does not make arbitrary modulation alias-free. + + @tparam SampleType Table sample precision. + @tparam CoeffType Fourier coefficient precision. + @see MorphingOscillator, ModulatedOscillator +*/ +template +class WaveformBank +{ +public: + /** Renders the frames and their bandwidth levels, including each frame's DC. + + @param frames Series collection with at most 4096 harmonics per + frame. Empty collections produce a silent bank. + */ + void prepare (Span> frames) + { + tables.clear(); + harmonicLimits.clear(); + frameCount = static_cast (frames.size()); + int maxHarmonics = 0; + + for (const auto& frame : frames) + maxHarmonics = jmax (maxHarmonics, frame.getNumHarmonics()); + + jassert (maxHarmonics <= 4096); + maxHarmonics = jlimit (0, 4096, maxHarmonics); + + for (int limit = maxHarmonics; limit > 0; limit /= 2) + harmonicLimits.push_back (limit); + harmonicLimits.push_back (0); + + tables.reserve (frames.size() * harmonicLimits.size()); + + for (const auto& frame : frames) + { + for (const auto limit : harmonicLimits) + { + auto& table = tables.emplace_back(); + table.prepare (1.0, maxHarmonics); + table.setSeries (frame); + table.setIncludeDC (true); + table.setFrequency (static_cast (0.5 / (limit + 0.5))); + table.render (false); + } + } + } + + /** Returns the number of prepared frames. */ + int getNumFrames() const noexcept { return frameCount; } + + /** Returns the largest prepared harmonic count. */ + int getNumHarmonics() const noexcept + { + return harmonicLimits.empty() ? 0 : harmonicLimits.front(); + } + + /** Reads a waveform value without modifying the bank. + + @param phase Phase in periods, wrapped internally. + @param position Frame position in [0, 1], clamped at the endpoints. + @param maxHarmonics Exclusive harmonic bandwidth in harmonics. Two levels + below this bound are blended continuously. Use twice + getNumHarmonics() for the full spectrum; zero reads DC. + */ + SampleType getValue (double phase, CoeffType position, double maxHarmonics) const noexcept + { + return read (phase, position, maxHarmonics, false); + } + + /** Reads the derivative per phase period with the same frame/bandwidth selection. + + This differentiates the Hermite interpolant analytically, not the phase + trajectory or the morph signal. + */ + SampleType getSlope (double phase, CoeffType position, double maxHarmonics) const noexcept + { + return read (phase, position, maxHarmonics, true); + } + +private: + SampleType read (double phase, CoeffType position, double maxHarmonics, bool derivative) const noexcept + { + if (frameCount == 0 || ! std::isfinite (position)) + return SampleType (0); + + const auto framePosition = jlimit (CoeffType (0), CoeffType (1), position) * static_cast (frameCount - 1); + const auto firstFrame = static_cast (framePosition); + const auto secondFrame = jmin (firstFrame + 1, frameCount - 1); + const auto fraction = static_cast (framePosition - static_cast (firstFrame)); + std::size_t level = 0; + + while (level + 1 < harmonicLimits.size() && harmonicLimits[level] >= maxHarmonics) + ++level; + + const auto readFrame = [&] (int frame) + { + const auto base = static_cast (frame) * harmonicLimits.size(); + const auto readLevel = [&] (std::size_t index) + { + const auto& table = tables[base + index]; + return derivative ? table.getSlopeAtPhase (phase) : table.getValueAtPhase (phase); + }; + + const auto value = readLevel (level); + if (level + 1 == harmonicLimits.size()) + return value; + + const auto upper = level == 0 ? 2.0 * harmonicLimits[0] : static_cast (harmonicLimits[level - 1]); + const auto blend = static_cast (jlimit (0.0, 1.0, (maxHarmonics - harmonicLimits[level]) / (upper - harmonicLimits[level]))); + if (blend == SampleType (1)) + return value; + + const auto darker = readLevel (level + 1); + return darker + (value - darker) * blend; + }; + + const auto first = readFrame (firstFrame); + if (fraction == SampleType (0)) + return first; + + return first + (readFrame (secondFrame) - first) * fraction; + } + + std::vector> tables; + std::vector harmonicLimits; + int frameCount = 0; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_WavetableOscillator.h b/modules/yup_dsp/oscillators/yup_WavetableOscillator.h index 039f90a02..a2a523acd 100644 --- a/modules/yup_dsp/oscillators/yup_WavetableOscillator.h +++ b/modules/yup_dsp/oscillators/yup_WavetableOscillator.h @@ -198,8 +198,12 @@ class WavetableOscillator with half the coefficients, which makes the result the series itself: the complex bin n of a signal a cos (2 pi n t) + b sin (2 pi n t) is a / 2 for the real part and -b / 2 for the imaginary one. + + @param crossfade When false, installs the table immediately. This is useful + for preparing tables before playback. Replacing an audible + table immediately may click. */ - void render() noexcept + void render (bool crossfade = true) noexcept { if (fft == nullptr || tableSize <= 0) return; @@ -240,6 +244,34 @@ class WavetableOscillator renderedHarmonics = limit; seriesChanged = false; crossfadePosition = CoeffType (0); + + if (! crossfade) + { + std::swap (current, next); + crossfadePosition = CoeffType (1); + } + } + + /** Reads the rendered waveform at a phase in periods without advancing state. + + Includes the current render crossfade. Negative phases wrap periodically. + For externally driven phases, the caller is responsible for bandwidth; + phase modulation can create frequencies beyond the rendered harmonics. + */ + SampleType getValueAtPhase (double normalizedPhase) const noexcept + { + return readAtPhase (normalizedPhase, false); + } + + /** Returns the derivative of the interpolated waveform per phase period. + + Includes the current render crossfade without advancing it. This is the + analytic derivative of the Hermite interpolant, useful for calculating + slope jumps at fractional sync events. + */ + SampleType getSlopeAtPhase (double normalizedPhase) const noexcept + { + return readAtPhase (normalizedPhase, true); } //============================================================================== @@ -261,11 +293,11 @@ class WavetableOscillator */ SampleType processSample() noexcept { - auto value = readTable (current); + auto value = readTable (current, phase, false); if (crossfadePosition < CoeffType (1)) { - const auto target = readTable (next); + const auto target = readTable (next, phase, false); value += (target - value) * static_cast (crossfadePosition); @@ -298,9 +330,23 @@ class WavetableOscillator private: //============================================================================== - SampleType readTable (const std::vector& table) const noexcept + SampleType readAtPhase (double normalizedPhase, bool derivative) const noexcept { - const auto position = phase * static_cast (tableSize); + if (tableSize <= 0 || ! std::isfinite (normalizedPhase)) + return SampleType (0); + + normalizedPhase -= std::floor (normalizedPhase); + auto value = readTable (current, normalizedPhase, derivative); + + if (crossfadePosition < CoeffType (1)) + value += (readTable (next, normalizedPhase, derivative) - value) * static_cast (crossfadePosition); + + return value; + } + + SampleType readTable (const std::vector& table, double normalizedPhase, bool derivative) const noexcept + { + const auto position = normalizedPhase * static_cast (tableSize); const auto index = static_cast (position) & tableMask; const auto fraction = static_cast (position - std::floor (position)); @@ -314,6 +360,9 @@ class WavetableOscillator const auto c2 = y0 - static_cast (2.5) * y1 + static_cast (2) * y2 - static_cast (0.5) * y3; const auto c3 = static_cast (0.5) * (y3 - y0) + static_cast (1.5) * (y1 - y2); + if (derivative) + return static_cast ((CoeffType (3) * c3 * fraction * fraction + CoeffType (2) * c2 * fraction + c1) * static_cast (tableSize)); + return static_cast (((c3 * fraction + c2) * fraction + c1) * fraction + c0); } diff --git a/modules/yup_dsp/resampling/yup_Oversampler.h b/modules/yup_dsp/resampling/yup_Oversampler.h index e813117a3..fdbc5baa6 100644 --- a/modules/yup_dsp/resampling/yup_Oversampler.h +++ b/modules/yup_dsp/resampling/yup_Oversampler.h @@ -215,6 +215,37 @@ class Oversampler } } + /** Starts a block generated directly at the oversampled rate. + + Call after prepare(), fill every sample obtained through + getOversampledChannelData(), then call downsample(). No input upsampling + is performed and no memory is allocated. Returns false for nonpositive + sizes or sizes exceeding the prepared channel/block capacity; a pending + block is left unchanged on failure. The buffer contents are unspecified. + + @param numChannels Number of generated channels. + @param numSamples Number of samples per channel at the output rate. + @see getGenerationLatencyInSamples + */ + bool beginGeneration (int numChannels, int numSamples) noexcept + { + if (numChannels <= 0 || numSamples <= 0 + || numChannels > xInterp.getNumChannels() + || numSamples > xInterp.getNumSamples() - SincRadius) + return false; + + currentOversampledSize = numSamples * OversampleFactor; + currentNumChannels = numChannels; + oversampledBuffer.setSize (numChannels, currentOversampledSize, false, false, true); + return true; + } + + /** Returns the latency of generation followed by downsample(), in output samples. + + Unlike getLatencyInSamples(), this excludes the input interpolation stage. + */ + static constexpr int getGenerationLatencyInSamples() noexcept { return SincRadius; } + /** Downsample the internal oversampled buffer into an output block. @@ -224,9 +255,9 @@ class Oversampler @param output Array of write pointers, one per channel. @param numChannels Number of channels to write (must match the numChannels - passed to the preceding upsample() call). + passed to the preceding upsample() or beginGeneration() call). @param numSamples Number of output samples per channel (must match the numSamples - passed to the preceding upsample() call). + passed to the preceding upsample() or beginGeneration() call). */ void downsample (SampleType* const* output, int numChannels, int numSamples) noexcept { @@ -293,7 +324,8 @@ class Oversampler Invokes a callback with the internal oversampled multi-channel buffer. The callback receives a reference to the internal `AudioBuffer`. - The buffer has the same channel count as the most recent upsample() call, + The buffer has the channel count of the most recent upsample() or + beginGeneration() call, and getOversampledNumSamples() samples per channel. Use this to apply processing at the elevated sample rate. If there is no pending oversampled block, the callback receives an empty buffer. @@ -321,7 +353,7 @@ class Oversampler @return Pointer to getOversampledNumSamples() contiguous samples, or nullptr if the channel index is out of range, prepare() has not been called, or the channel was not processed by - the most recent upsample() call. + the most recent upsample() or beginGeneration() call. */ forcedinline SampleType* getOversampledChannelData (int channel) noexcept { @@ -338,7 +370,7 @@ class Oversampler @return Pointer to getOversampledNumSamples() contiguous samples, or nullptr if the channel index is out of range or the channel was not processed by the most recent upsample() - call. + or beginGeneration() call. */ const forcedinline SampleType* getOversampledChannelData (int channel) const noexcept { @@ -351,9 +383,9 @@ class Oversampler /** Returns the number of samples currently in each oversampled channel. - Equal to the numSamples argument of the most recent pending upsample() - call multiplied by OversampleFactor. Returns 0 before the first - upsample() call, after downsample(), or after reset(). + Equal to the numSamples argument of the pending upsample() or + beginGeneration() call multiplied by OversampleFactor. Returns 0 before + either call, after downsample(), or after reset(). */ forcedinline int getOversampledNumSamples() const noexcept { diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index ea0c21ebe..30a4f5181 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -146,6 +146,8 @@ #include "oscillators/yup_AdditiveOscillator.h" #include "oscillators/yup_WavetableOscillator.h" #include "oscillators/yup_SyncOscillator.h" +#include "oscillators/yup_WaveformBank.h" +#include "oscillators/yup_MorphingOscillator.h" // Onset detection #include "onsets/yup_FilterBank.h" @@ -213,3 +215,6 @@ #include "resampling/yup_SincTable.h" #include "resampling/yup_Oversampler.h" #include "resampling/yup_Resampler.h" + +// Audio-rate oscillator modulation (needs Oversampler) +#include "oscillators/yup_ModulatedOscillator.h" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index 4fed482f0..9d9f2d061 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -38,6 +38,8 @@ #include "yup_dsp/yup_LevelProcessor.cpp" #include "yup_dsp/yup_LinkwitzRileyFilter.cpp" #include "yup_dsp/yup_LoudnessFilter.cpp" +#include "yup_dsp/yup_ModulatedOscillator.cpp" +#include "yup_dsp/yup_MorphingOscillator.cpp" #include "yup_dsp/yup_NoiseGenerators.cpp" #include "yup_dsp/yup_OnsetDetector.cpp" #include "yup_dsp/yup_Oversampler.cpp" @@ -51,5 +53,6 @@ #include "yup_dsp/yup_SyncOscillator.cpp" #include "yup_dsp/yup_SyncSpectralResampler.cpp" #include "yup_dsp/yup_TimeStretchProcessor.cpp" +#include "yup_dsp/yup_WaveformBank.cpp" #include "yup_dsp/yup_WavetableOscillator.cpp" #include "yup_dsp/yup_WindowFunctions.cpp" diff --git a/tests/yup_dsp/yup_AdditiveOscillator.cpp b/tests/yup_dsp/yup_AdditiveOscillator.cpp index 327511c38..43884b054 100644 --- a/tests/yup_dsp/yup_AdditiveOscillator.cpp +++ b/tests/yup_dsp/yup_AdditiveOscillator.cpp @@ -222,3 +222,29 @@ TEST_F (AdditiveOscillatorTests, FloatInstantiationBehavesLikeDouble) EXPECT_NEAR (expected, oscillator.processSample(), 1e-3) << i; } } + +TEST_F (AdditiveOscillatorTests, FrequencyAndPhaseChangesRefreshThePhasorRecurrence) +{ + auto oscillator = makeOscillator (13, Waveform::sawtooth, 440.0); + for (int i = 0; i < 4096; ++i) + { + if (i % 127 == 0) + oscillator.setFrequency (220.0 + (i % 7) * 113.0); + if (i % 191 == 0) + oscillator.setPhase (0.173); + + const auto expected = harmonicReference (oscillator.getSeries(), 13, oscillator.getFrequency(), oscillator.getPhase()); + EXPECT_NEAR (expected, oscillator.processSample(), 1e-10); + } +} + +TEST_F (AdditiveOscillatorTests, DCSurvivesWhenNoHarmonicFitsBelowNyquist) +{ + auto oscillator = makeOscillator (1, Waveform::sine, 24000.0); + FourierSeries series (1); + series.setDC (0.25); + oscillator.setSeries (series); + oscillator.setIncludeDC (true); + EXPECT_EQ (0, oscillator.getNumActiveHarmonics()); + EXPECT_EQ (0.25, oscillator.processSample()); +} diff --git a/tests/yup_dsp/yup_FourierSeries.cpp b/tests/yup_dsp/yup_FourierSeries.cpp index 7a4870d39..9bd11ca80 100644 --- a/tests/yup_dsp/yup_FourierSeries.cpp +++ b/tests/yup_dsp/yup_FourierSeries.cpp @@ -266,6 +266,13 @@ class NyquistHarmonicLimitTests : public ::testing::Test { }; +TEST_F (NyquistHarmonicLimitTests, ExcludesTheExactNyquistBoundaryAtCapacity) +{ + EXPECT_EQ (0, getNyquistHarmonicLimit (24000.0, 48000.0, 1)); + EXPECT_EQ (7, getNyquistHarmonicLimit (3000.0, 48000.0, 8)); + EXPECT_EQ (8, getNyquistHarmonicLimit (2999.0, 48000.0, 8)); +} + TEST_F (NyquistHarmonicLimitTests, FollowsSampleRateAndFrequency) { EXPECT_EQ (2, getNyquistHarmonicLimit (10000.0, 48000.0, 512)); diff --git a/tests/yup_dsp/yup_ModulatedOscillator.cpp b/tests/yup_dsp/yup_ModulatedOscillator.cpp new file mode 100644 index 000000000..faefc06e1 --- /dev/null +++ b/tests/yup_dsp/yup_ModulatedOscillator.cpp @@ -0,0 +1,279 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +using namespace yup; + +class ModulatedOscillatorTests : public ::testing::Test +{ +protected: + using Oscillator = ModulatedOscillator; + std::array, 2> frames { + FourierSeries::create (Waveform::sine, 16), + FourierSeries::create (Waveform::cosine, 16) + }; + WaveformBank bank; + static constexpr double sampleRate = 48000.0; + + void SetUp() override { bank.prepare ({ frames.data(), frames.size() }); } + + static Oscillator::Parameters modulation (int index) + { + const auto t = index / (4.0 * sampleRate); + Oscillator::Parameters p; + p.frequency = 1100.0; + p.linearFM = 1700.0 * std::sin (MathConstants::twoPi * 137.0 * t); + p.phaseModulation = 0.1 * std::sin (MathConstants::twoPi * 251.0 * t); + p.morph = 0.5 + 0.5 * std::sin (MathConstants::twoPi * 89.0 * t); + p.phaseDistortion = 0.5 + 0.3 * std::sin (MathConstants::twoPi * 73.0 * t); + p.syncFrequency = 731.0; + return p; + } +}; + +TEST_F (ModulatedOscillatorTests, SignedFrequencyMatchesAnalyticSineAfterLatency) +{ + for (const auto frequency : { -1000.0, 0.0, 1000.0 }) + { + Oscillator oscillator; + oscillator.prepare (sampleRate, 512, bank); + Oscillator::Parameters p; + p.frequency = frequency; + std::array output {}; + ASSERT_TRUE (oscillator.processBlock (output.data(), 512, p)); + + for (int i = 64; i < 512; ++i) + EXPECT_NEAR (std::sin (MathConstants::twoPi * frequency * (i - oscillator.getLatencyInSamples()) / sampleRate), + output[static_cast (i)], 0.002); + } +} + +TEST_F (ModulatedOscillatorTests, PhaseModulationDoesNotChangeTheAccumulator) +{ + Oscillator oscillator; + oscillator.prepare (sampleRate, 64, bank); + Oscillator::Parameters p; + p.frequency = 0.0; + p.phaseModulation = 0.25; + std::array output {}; + ASSERT_TRUE (oscillator.processBlock (output.data(), 64, p)); + EXPECT_EQ (0.0, oscillator.getPhase()); + EXPECT_NEAR (1.0, output.back(), 1e-5); +} + +TEST_F (ModulatedOscillatorTests, FractionalSyncKeepsThePostResetRemainder) +{ + Oscillator oscillator; + oscillator.prepare (sampleRate, 100, bank); + Oscillator::Parameters p; + p.frequency = 1100.0; + p.syncFrequency = 731.0; + std::array output {}; + ASSERT_TRUE (oscillator.processBlock (output.data(), 100, p)); + + const auto leaderCycles = 100.0 * p.syncFrequency / sampleRate; + const auto expected = (leaderCycles - std::floor (leaderCycles)) * p.frequency / p.syncFrequency; + EXPECT_NEAR (expected - std::floor (expected), oscillator.getPhase(), 1e-12); +} + +TEST_F (ModulatedOscillatorTests, ModulationIsIndependentOfBlockPartition) +{ + Oscillator whole; + Oscillator split; + whole.prepare (sampleRate, 256, bank); + split.prepare (sampleRate, 256, bank); + std::array expected {}; + std::array actual {}; + ASSERT_TRUE (whole.processModulatedBlock (expected.data(), 256, modulation)); + + int offset = 0; + for (const auto size : { 1, 3, 17, 64, 171 }) + { + ASSERT_TRUE (split.processModulatedBlock (actual.data() + offset, size, + [offset] (int index) { return modulation (offset * 4 + index); })); + offset += size; + } + + for (std::size_t i = 0; i < actual.size(); ++i) + { + EXPECT_TRUE (std::isfinite (actual[i])); + EXPECT_NEAR (expected[i], actual[i], 1e-12); + } +} + +TEST_F (ModulatedOscillatorTests, ResetReproducesTheSameModulatedOutput) +{ + Oscillator oscillator; + oscillator.prepare (sampleRate, 256, bank); + std::array first {}; + std::array second {}; + oscillator.processModulatedBlock (first.data(), 256, modulation); + oscillator.reset(); + oscillator.processModulatedBlock (second.data(), 256, modulation); + EXPECT_EQ (first, second); +} + +TEST_F (ModulatedOscillatorTests, RejectsInvalidBlocksWithoutAdvancingState) +{ + Oscillator oscillator; + oscillator.prepare (sampleRate, 16, bank); + std::array output {}; + Oscillator::Parameters p; + EXPECT_FALSE (oscillator.processBlock (output.data(), 17, p)); + EXPECT_FALSE (oscillator.processBlock (output.data(), 0, p)); + EXPECT_FALSE (oscillator.processBlock (nullptr, 16, p)); + EXPECT_EQ (0.0, oscillator.getPhase()); +} + +TEST_F (ModulatedOscillatorTests, FloatCoefficientsAndOutputRemainFiniteAtExtremeControls) +{ + std::array, 1> source { FourierSeries::create (Waveform::sawtooth, 32) }; + WaveformBank floatBank; + floatBank.prepare ({ source.data(), source.size() }); + ModulatedOscillator oscillator; + oscillator.prepare (sampleRate, 128, floatBank); + std::array output {}; + for (const auto breakpoint : { 0.0, 0.5, 1.0 }) + { + decltype (oscillator)::Parameters p; + p.frequency = -30000.0; + p.exponentialFM = 2.0; + p.phaseDistortion = breakpoint; + p.syncFrequency = 30000.0; + ASSERT_TRUE (oscillator.processBlock (output.data(), 128, p)); + for (const auto value : output) + EXPECT_TRUE (std::isfinite (value)); + } +} + +TEST_F (ModulatedOscillatorTests, SyncAndPhaseDistortionApproachAnIndependentHighRateReference) +{ + constexpr int count = 1024; + constexpr int radius = 32; + constexpr double carrier = 3000.0; + constexpr double leader = 1700.0; + constexpr double breakpoint = 0.2; + constexpr double morph = 0.3; + + ModulatedOscillator oscillator; + oscillator.prepare (sampleRate, count, bank); + decltype (oscillator)::Parameters p; + p.frequency = carrier; + p.syncFrequency = leader; + p.phaseDistortion = breakpoint; + p.morph = morph; + + std::array actual {}; + ASSERT_TRUE (oscillator.processBlock (actual.data(), count, p)); + + const auto continuousWaveform = [] (double time) + { + auto phase = carrier * (time - std::floor (time * leader) / leader); + phase -= std::floor (phase); + const auto distorted = phase < breakpoint ? 0.5 * phase / breakpoint + : 0.5 + 0.5 * (phase - breakpoint) / (1.0 - breakpoint); + const auto angle = MathConstants::twoPi * distorted; + return (1.0 - morph) * std::sin (angle) + morph * std::cos (angle); + }; + + const auto renderReference = [&]() + { + Oversampler decimator; + decimator.prepare (sampleRate, 1, count); + decimator.beginGeneration (1, count); + auto* internal = decimator.getOversampledChannelData (0); + for (int i = 0; i < count * factor; ++i) + internal[i] = continuousWaveform (i / (sampleRate * factor)); + + std::array result {}; + double* channels[] = { result.data() }; + decimator.downsample (channels, 1, count); + return result; + }; + + const auto reference = renderReference.template operator()<64>(); + const auto coarserReference = renderReference.template operator()<32>(); + double convergenceError = 0.0; + + double correctedError = 0.0; + double naiveError = 0.0; + for (int i = 2 * radius; i < count; ++i) + { + const auto expected = reference[static_cast (i)]; + const auto convergence = coarserReference[static_cast (i)] - expected; + convergenceError += convergence * convergence; + const auto corrected = actual[static_cast (i)] - expected; + const auto naive = continuousWaveform ((i - radius) / sampleRate) - expected; + correctedError += corrected * corrected; + naiveError += naive * naive; + } + + EXPECT_LT (std::sqrt (convergenceError / (count - 2 * radius)), 0.01); + EXPECT_LT (std::sqrt (correctedError / (count - 2 * radius)), 0.04); + EXPECT_LT (correctedError, naiveError); +} + +TEST_F (ModulatedOscillatorTests, AudioRatePhaseModulationMatchesAnalyticReference) +{ + constexpr int count = 1024; + Oscillator oscillator; + oscillator.prepare (sampleRate, count, bank); + std::array output {}; + ASSERT_TRUE (oscillator.processModulatedBlock (output.data(), count, [] (int i) + { + Oscillator::Parameters p; + p.frequency = 2000.0; + p.phaseModulation = 0.1 * std::sin (MathConstants::twoPi * 1000.0 * i / (sampleRate * 4)); + return p; + })); + + for (int i = 64; i < count; ++i) + { + const auto time = (i - oscillator.getLatencyInSamples()) / sampleRate; + const auto phase = 2000.0 * time + 0.1 * std::sin (MathConstants::twoPi * 1000.0 * time); + EXPECT_NEAR (std::sin (MathConstants::twoPi * phase), output[static_cast (i)], 0.003); + } +} + +TEST_F (ModulatedOscillatorTests, ThroughZeroFMApproachesTheIntegratedFrequencyReference) +{ + constexpr int count = 2048; + Oscillator oscillator; + oscillator.prepare (sampleRate, count, bank); + std::array output {}; + ASSERT_TRUE (oscillator.processModulatedBlock (output.data(), count, [] (int i) + { + Oscillator::Parameters p; + p.frequency = 200.0; + p.linearFM = 400.0 * std::sin (MathConstants::twoPi * 137.0 * i / (sampleRate * 4)); + return p; + })); + + for (int i = 64; i < count; ++i) + { + const auto time = (i - oscillator.getLatencyInSamples()) / sampleRate; + const auto phase = 200.0 * time + 400.0 * (1.0 - std::cos (MathConstants::twoPi * 137.0 * time)) + / (MathConstants::twoPi * 137.0); + EXPECT_NEAR (std::sin (MathConstants::twoPi * phase), output[static_cast (i)], 0.01); + } +} diff --git a/tests/yup_dsp/yup_MorphingOscillator.cpp b/tests/yup_dsp/yup_MorphingOscillator.cpp new file mode 100644 index 000000000..a9a8c2fbd --- /dev/null +++ b/tests/yup_dsp/yup_MorphingOscillator.cpp @@ -0,0 +1,80 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +using namespace yup; + +class MorphingOscillatorTests : public ::testing::Test +{ +protected: + const FourierSeries first = FourierSeries::create (Waveform::sawtooth, 32); + const FourierSeries second = FourierSeries::create (Waveform::square, 32); +}; + +TEST_F (MorphingOscillatorTests, MorphCommutesWithFixedRatioSynchronization) +{ + for (const auto mode : { SyncMode::none, SyncMode::hard, SyncMode::mirrored, SyncMode::pulsar }) + { + MorphingOscillator oscillator; + oscillator.setSynthesis (MorphingOscillator::Synthesis::additive); + oscillator.prepare (48000.0, 32); + oscillator.setSeries (first, second); + oscillator.setSyncMode (mode); + oscillator.setFollowerRatio (1.375); + oscillator.setFrequency (1000.0); + oscillator.update(); + + FourierSeries blended (32); + for (int n = 1; n <= 32; ++n) + blended.setHarmonic (n, 0.0, 0.75 * first.getSine (n) + 0.25 * second.getSine (n)); + + SyncOscillator reference; + reference.setSynthesis (SyncOscillator::Synthesis::additive); + reference.prepare (48000.0, 32); + reference.setFollowerSeries (blended); + reference.setSyncMode (mode); + reference.setFollowerRatio (1.375); + reference.setFrequency (1000.0); + reference.update(); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (reference.processSample(), oscillator.processSample (0.25), 1e-10); + EXPECT_FALSE (oscillator.needsUpdate()); + } +} + +TEST_F (MorphingOscillatorTests, PerSampleMorphDoesNotRequireAnUpdate) +{ + MorphingOscillator oscillator; + oscillator.setSynthesis (MorphingOscillator::Synthesis::additive); + oscillator.prepare (48000.0, 1); + oscillator.setSeries (FourierSeries::create (Waveform::sine, 1), + FourierSeries::create (Waveform::cosine, 1)); + oscillator.setFrequency (0.0); + oscillator.setPhase (0.25); + oscillator.update(); + + for (const auto morph : { -1.0, 0.0, 0.25, 0.5, 1.0, 2.0 }) + EXPECT_NEAR (1.0 - jlimit (0.0, 1.0, morph), oscillator.processSample (morph), 1e-12); + EXPECT_FALSE (oscillator.needsUpdate()); +} diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_Oversampler.cpp index 591c849c6..f15a8c7fb 100644 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ b/tests/yup_dsp/yup_Oversampler.cpp @@ -287,3 +287,45 @@ TEST (OversamplerTypeAliasTest, TypeAliasesCompile) } } // namespace yup::test + +namespace yup::test +{ + +TEST_F (OversamplerTest, DirectGenerationPreservesDCWithoutInputInterpolation) +{ + ASSERT_TRUE (os4x.beginGeneration (1, blockSize)); + FloatVectorOperations::fill (os4x.getOversampledChannelData (0), 0.25f, blockSize * 4); + std::vector output (blockSize); + float* channels[] = { output.data() }; + os4x.downsample (channels, 1, blockSize); + + EXPECT_EQ (8, os4x.getGenerationLatencyInSamples()); + EXPECT_EQ (0, os4x.getOversampledNumSamples()); + for (int i = 32; i < blockSize; ++i) + EXPECT_NEAR (0.25f, output[static_cast (i)], 1e-6f); +} + +TEST_F (OversamplerTest, InvalidGenerationRequestsPreserveThePendingBlock) +{ + ASSERT_TRUE (os4x.beginGeneration (1, 16)); + EXPECT_FALSE (os4x.beginGeneration (0, 16)); + EXPECT_FALSE (os4x.beginGeneration (1, 0)); + EXPECT_FALSE (os4x.beginGeneration (maxChannels + 1, 16)); + EXPECT_FALSE (os4x.beginGeneration (1, blockSize + 1)); + EXPECT_EQ (64, os4x.getOversampledNumSamples()); +} + +TEST_F (OversamplerTest, DirectGenerationImpulseHasTheReportedLatency) +{ + ASSERT_TRUE (os4x.beginGeneration (1, blockSize)); + auto* internal = os4x.getOversampledChannelData (0); + FloatVectorOperations::clear (internal, blockSize * 4); + internal[0] = 1.0f; + std::vector output (blockSize); + float* channels[] = { output.data() }; + os4x.downsample (channels, 1, blockSize); + const auto peak = std::max_element (output.begin(), output.end()); + EXPECT_EQ (os4x.getGenerationLatencyInSamples(), static_cast (peak - output.begin())); +} + +} // namespace yup::test diff --git a/tests/yup_dsp/yup_SyncOscillator.cpp b/tests/yup_dsp/yup_SyncOscillator.cpp index 482b4f058..ed8472434 100644 --- a/tests/yup_dsp/yup_SyncOscillator.cpp +++ b/tests/yup_dsp/yup_SyncOscillator.cpp @@ -236,6 +236,27 @@ TEST_F (SyncOscillatorTests, FollowerFrequencySetsTheRatio) EXPECT_NEAR (880.0, oscillator.getOutputFrequency(), 1e-12); } +TEST_F (SyncOscillatorTests, LowerPitchRestoresPreviouslyOmittedHarmonics) +{ + SyncOscillator oscillator; + oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + oscillator.prepare (testSampleRate, 64); + oscillator.setFollowerSeries (FourierSeries::create (Waveform::sine, 1)); + oscillator.setSyncMode (SyncMode::hard); + oscillator.setFollowerRatio (1.375); + oscillator.setFrequency (10000.0); + oscillator.update(); + + EXPECT_EQ (0.0, oscillator.getSyncedSeries().getMagnitude (8)); + EXPECT_FALSE (oscillator.needsUpdate()); + + oscillator.setFrequency (440.0); + EXPECT_TRUE (oscillator.needsUpdate()); + oscillator.update(); + EXPECT_GT (oscillator.getSyncedSeries().getMagnitude (8), 1e-3); + EXPECT_FALSE (oscillator.needsUpdate()); +} + TEST_F (SyncOscillatorTests, NoneModePlaysTheFollowerAtTheLeaderPitch) { SyncOscillator oscillator; diff --git a/tests/yup_dsp/yup_SyncSpectralResampler.cpp b/tests/yup_dsp/yup_SyncSpectralResampler.cpp index f87eaee43..0d340e28a 100644 --- a/tests/yup_dsp/yup_SyncSpectralResampler.cpp +++ b/tests/yup_dsp/yup_SyncSpectralResampler.cpp @@ -116,12 +116,12 @@ class SyncSpectralResamplerTests : public ::testing::Test if (mode == SyncMode::hard) { - for (int k = 1; k <= followerHarmonics; ++k) + for (int k = 1; k <= follower.getNumHarmonics(); ++k) dc += rotated.cosine[static_cast (k)] * normalizedSinc (k * ratio); } else if (mode == SyncMode::mirrored) { - for (int k = 1; k <= followerHarmonics; ++k) + for (int k = 1; k <= follower.getNumHarmonics(); ++k) dc += rotated.cosine[static_cast (k)] * normalizedSinc (2.0 * k * ratio) - rotated.sine[static_cast (k)] * normalizedVersinc (2.0 * k * ratio); } @@ -133,7 +133,7 @@ class SyncSpectralResamplerTests : public ::testing::Test double a = 0.0; double b = 0.0; - for (int k = 1; k <= followerHarmonics; ++k) + for (int k = 1; k <= follower.getNumHarmonics(); ++k) { const auto index = static_cast (k); const auto a_k = rotated.cosine[index]; @@ -544,3 +544,28 @@ TEST_F (SyncSpectralResamplerTests, RepeatedTransformsDoNotAccumulateState) EXPECT_EQ (first, output.getSine (3)); } + +TEST_F (SyncSpectralResamplerTests, FusedAccumulationMatchesScalarWithPartialSIMDLanes) +{ + for (const auto count : { 1, 3, 5, 13 }) + { + FourierSeries follower (count); + for (int n = 1; n <= count; ++n) + follower.setHarmonic (n, 0.3 / n, (n % 2 == 0 ? -0.7 : 0.7) / n); + + for (const auto mode : { SyncMode::hard, SyncMode::mirrored, SyncMode::pulsar }) + { + for (const auto ratio : { 1.0, 1.375, 2.0 }) + { + const auto expected = scalarReferenceTransform (follower, ratio, mode, 24); + const auto actual = runTransform (follower, ratio, mode, 24); + EXPECT_NEAR (expected.getDC(), actual.getDC(), 1e-12); + for (int n = 1; n <= 24; ++n) + { + EXPECT_NEAR (expected.getCosine (n), actual.getCosine (n), 1e-10); + EXPECT_NEAR (expected.getSine (n), actual.getSine (n), 1e-10); + } + } + } + } +} diff --git a/tests/yup_dsp/yup_WaveformBank.cpp b/tests/yup_dsp/yup_WaveformBank.cpp new file mode 100644 index 000000000..acba1b501 --- /dev/null +++ b/tests/yup_dsp/yup_WaveformBank.cpp @@ -0,0 +1,89 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +using namespace yup; + +class WaveformBankTests : public ::testing::Test +{ +protected: + std::array, 2> frames { + FourierSeries::create (Waveform::sine, 16), + FourierSeries::create (Waveform::cosine, 16) + }; + WaveformBank bank; + + void SetUp() override { bank.prepare ({ frames.data(), frames.size() }); } +}; + +TEST_F (WaveformBankTests, MorphsEndpointsAtTheSamePhase) +{ + for (const auto morph : { 0.0, 0.25, 0.5, 1.0 }) + { + for (int i = 0; i < 97; ++i) + { + const auto phase = i / 97.0; + const auto angle = MathConstants::twoPi * phase; + EXPECT_NEAR ((1.0 - morph) * std::sin (angle) + morph * std::cos (angle), + bank.getValue (phase, morph, 32.0), 2e-5); + EXPECT_NEAR (MathConstants::twoPi * ((1.0 - morph) * std::cos (angle) - morph * std::sin (angle)), + bank.getSlope (phase, morph, 32.0), 0.005); + } + } +} + +TEST_F (WaveformBankTests, BandwidthSelectionExcludesUnsafeHarmonics) +{ + frames[0].clear(); + frames[0].setDC (0.25); + frames[0].setHarmonic (8, 1.0, 0.0); + bank.prepare ({ frames.data(), frames.size() }); + + EXPECT_NEAR (1.25, bank.getValue (0.0, 0.0, 32.0), 1e-6); + EXPECT_NEAR (0.25, bank.getValue (0.0, 0.0, 8.0), 1e-6); + EXPECT_NEAR (0.25, bank.getValue (0.2, 0.0, 0.0), 1e-6); +} + +TEST_F (WaveformBankTests, BandwidthTransitionsAreContinuous) +{ + frames[0].setWaveform (Waveform::sawtooth); + bank.prepare ({ frames.data(), frames.size() }); + + for (const auto boundary : { 1.0, 2.0, 4.0, 8.0, 16.0, 32.0 }) + EXPECT_NEAR (bank.getValue (0.173, 0.0, boundary - 1e-7), + bank.getValue (0.173, 0.0, boundary + 1e-7), 1e-6); +} + +TEST_F (WaveformBankTests, WrapsPhaseAndClampsFramePosition) +{ + EXPECT_EQ (bank.getValue (0.25, 0.0, 32.0), bank.getValue (-0.75, -1.0, 32.0)); + EXPECT_EQ (bank.getValue (0.25, 1.0, 32.0), bank.getValue (1.25, 2.0, 32.0)); +} + +TEST_F (WaveformBankTests, EmptyBankIsSilent) +{ + bank.prepare ({}); + EXPECT_EQ (0, bank.getNumFrames()); + EXPECT_EQ (0.0, bank.getValue (0.3, 0.5, 32.0)); + EXPECT_EQ (0.0, bank.getSlope (0.3, 0.5, 32.0)); +} diff --git a/tests/yup_dsp/yup_WavetableOscillator.cpp b/tests/yup_dsp/yup_WavetableOscillator.cpp index 607a008ab..60935fb6b 100644 --- a/tests/yup_dsp/yup_WavetableOscillator.cpp +++ b/tests/yup_dsp/yup_WavetableOscillator.cpp @@ -219,3 +219,15 @@ TEST_F (WavetableOscillatorTests, SilenceWithoutRenderedHarmonics) EXPECT_EQ (0, oscillator.getNumRenderedHarmonics()); EXPECT_EQ (0.0, peakMagnitude (oscillator, 64)); } + +TEST_F (WavetableOscillatorTests, DirectPhaseReadsAndDerivativesDoNotAdvancePlayback) +{ + WavetableOscillator oscillator; + oscillator.prepare (testSampleRate, 16); + oscillator.setWaveform (Waveform::sine); + oscillator.render (false); + + EXPECT_NEAR (1.0, oscillator.getValueAtPhase (-0.75), 1e-6); + EXPECT_NEAR (MathConstants::twoPi, oscillator.getSlopeAtPhase (0.0), 0.005); + EXPECT_EQ (0.0, oscillator.getPhase()); +} From 95292172f674831723c6bfdaab54b54c62f6a578 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Mon, 21 Sep 2026 22:56:27 +0200 Subject: [PATCH 03/37] More audio fun --- examples/graphics/source/examples/Audio.h | 1096 +++++++++++++++------ 1 file changed, 776 insertions(+), 320 deletions(-) diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 3880da603..b1e6b5424 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -21,219 +21,532 @@ #pragma once +#include +#include +#include #include -#include // For sine wave generation +#include //============================================================================== +/** Sizing shared by the polyphonic engine and its user interface. */ +namespace SynthExample +{ +constexpr int voiceCount = 8; +constexpr int oscillatorCount = 2; +constexpr int maxHarmonics = 128; +constexpr int maxBlockSize = 2048; +constexpr double envelopeRampSeconds = 0.02; +constexpr double levelRampSeconds = 0.01; +} // namespace SynthExample + +//============================================================================== +/** The synthesis algorithm a voice oscillator renders with. + + Every value maps onto one of the bandlimited yup_dsp oscillators, which differ + in how they derive their spectrum and in how they can be modulated. -class HarmonicSineGenerator + @see SynthOscillator +*/ +enum class SynthOscillatorType { -public: - HarmonicSineGenerator() - : sampleRate (44100.0) - , currentAngle (0.0) - , frequency (0.0) - , amplitude (0.0) - { - } + additive, /**< Exact additive synthesis of a Fourier series */ + wavetable, /**< The same series rendered once, then played back */ + sync, /**< Alias-free spectral oscillator synchronization */ + morphing, /**< Blends two synchronized endpoint spectra */ + modulated /**< Oversampled morph, FM, PM and phase distortion */ +}; - void setSampleRate (double newSampleRate) - { - sampleRate = newSampleRate; - frequency.reset (newSampleRate, 0.05); - amplitude.reset (newSampleRate, 0.02); - } +/** @internal Item names for SynthOscillatorType, index aligned with the enumeration. */ +inline yup::StringArray getSynthOscillatorTypeNames() +{ + return { "Additive", "Wavetable", "Sync", "Morphing", "Modulated" }; +} - void setFrequency (double newFrequency) - { - frequency.setTargetValue ((yup::MathConstants::twoPi * newFrequency) / sampleRate); - } +/** @internal Item names for yup::Waveform, index aligned with the enumeration. */ +inline yup::StringArray getSynthWaveformNames() +{ + return { "Sine", "Cosine", "Sawtooth", "Square", "Triangle", "Pulse" }; +} - void setAmplitude (float newAmplitude) - { - amplitude.setTargetValue (newAmplitude); - } +/** @internal Item names for yup::SyncMode, index aligned with the enumeration. */ +inline yup::StringArray getSynthSyncModeNames() +{ + return { "None", "Hard", "Mirrored", "Pulsar" }; +} + +//============================================================================== +/** A plain snapshot of one oscillator's controls. - float getCurrentAmplitude() const + The audio thread takes one snapshot per block and compares it with the values it + applied last time, so a control that did not move never costs a spectral + transform or a table render. +*/ +struct SynthOscillatorValues +{ + SynthOscillatorType type = SynthOscillatorType::wavetable; + yup::Waveform waveform = yup::Waveform::sawtooth; + yup::Waveform shape = yup::Waveform::square; + yup::SyncMode syncMode = yup::SyncMode::hard; + float level = 0.5f; + float detuneSemitones = 0.0f; + float followerRatio = 1.5f; + float morph = 0.0f; + float phaseDistortion = 0.5f; + float fmAmount = 0.0f; + float fmRatio = 2.0f; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. */ +struct SynthOscillatorSettings +{ + std::atomic type { static_cast (SynthOscillatorType::wavetable) }; + std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; + std::atomic shape { static_cast (yup::Waveform::square) }; + std::atomic syncMode { static_cast (yup::SyncMode::hard) }; + std::atomic level { 0.5f }; + std::atomic detuneSemitones { 0.0f }; + std::atomic followerRatio { 1.5f }; + std::atomic morph { 0.0f }; + std::atomic phaseDistortion { 0.5f }; + std::atomic fmAmount { 0.0f }; + std::atomic fmRatio { 2.0f }; + + /** Takes a snapshot for one block of audio. */ + SynthOscillatorValues read() const noexcept { - return amplitude.getCurrentValue(); + return { static_cast (type.load()), + static_cast (waveform.load()), + static_cast (shape.load()), + static_cast (syncMode.load()), + level.load(), + detuneSemitones.load(), + followerRatio.load(), + morph.load(), + phaseDistortion.load(), + fmAmount.load(), + fmRatio.load() }; } +}; - float getNextSample() +//============================================================================== +/** Immutable waveform data, prepared once and read by every voice. + + Building a FourierSeries allocates, and rendering a WaveformBank runs one inverse + FFT per frame and bandwidth level, so both belong at construction time. Once + prepared the resources are read-only and safe to share across voices. + + @see SynthOscillator +*/ +class SynthOscillatorResources +{ +public: + SynthOscillatorResources() { - auto sample = std::sin (currentAngle) * amplitude.getNextValue(); + const yup::Waveform waveforms[] = { yup::Waveform::sine, + yup::Waveform::cosine, + yup::Waveform::sawtooth, + yup::Waveform::square, + yup::Waveform::triangle, + yup::Waveform::pulse }; - currentAngle += frequency.getNextValue(); - if (currentAngle >= yup::MathConstants::twoPi) - currentAngle -= yup::MathConstants::twoPi; + for (std::size_t index = 0; index < frames.size(); ++index) + frames[index] = yup::FourierSeries::create (waveforms[index], SynthExample::maxHarmonics); + + bank.prepare ({ frames.data(), frames.size() }); + } - return static_cast (sample); + /** Returns the series of one of the Waveform presets. */ + const yup::FourierSeries& getFrame (yup::Waveform waveform) const noexcept + { + return frames[static_cast (waveform)]; } + /** Returns the bank of frames the modulated oscillator morphs across. */ + const yup::WaveformBank& getBank() const noexcept { return bank; } + private: - double sampleRate; - double currentAngle; - yup::SmoothedValue frequency; - yup::SmoothedValue amplitude; + std::array, 6> frames; + yup::WaveformBank bank; }; //============================================================================== +/** One of a voice's oscillators, owning every algorithm it can switch between. -class HarmonicSynth + prepare() allocates all the backends; renderBlock() is allocation-free and pushes + only the controls that actually moved, so editing a slider is the only thing that + pays for a spectral transform or a table render. + + @see SynthOscillatorSettings, SynthOscillatorResources +*/ +class SynthOscillator { public: - HarmonicSynth() - : isNoteOn (false) - , currentNote (-1) - , fundamentalFrequency (0.0) - , masterAmplitude (0.5f) - { - // Initialize harmonic generators - const int numHarmonics = 16; // 4x4 grid - harmonicGenerators.resize (numHarmonics); - harmonicMultipliers.resize (numHarmonics); - harmonicAmplitudes.resize (numHarmonics); - - for (int i = 0; i < numHarmonics; ++i) - { - harmonicGenerators[i] = std::make_unique(); + /** Allocates every backend and attaches the shared waveform resources. */ + void prepare (double newSampleRate, int maxBlockSize, const SynthOscillatorResources& oscillatorResources) + { + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + resources = &oscillatorResources; - // Set up harmonic relationships (1st, 2nd, 3rd harmonic, etc., plus some non-integer ratios) - if (i < 8) - harmonicMultipliers[i] = (i + 1); // 1x, 2x, 3x, 4x, 5x, 6x, 7x, 8x - else - harmonicMultipliers[i] = (i - 7) * 0.5 + 0.5; // 0.5x, 1x, 1.5x, 2x, 2.5x, 3x, 3.5x, 4x + additive.prepare (sampleRate, SynthExample::maxHarmonics); + wavetable.prepare (sampleRate, SynthExample::maxHarmonics); + sync.prepare (sampleRate, SynthExample::maxHarmonics); + morphing.prepare (sampleRate, SynthExample::maxHarmonics); + modulated.prepare (sampleRate, maxBlockSize, resources->getBank()); - harmonicAmplitudes[i] = 0.0f; // Start silent - } + applied = {}; + hasAppliedValues = false; + } + + /** Restarts every backend from a common phase. */ + void reset (double initialPhase) noexcept + { + const auto phase = static_cast (initialPhase); + + additive.setPhase (phase); + wavetable.setPhase (phase); + sync.setPhase (phase); + morphing.setPhase (phase); + modulated.reset (initialPhase); + + modulatorPhase = 0.0; } - void setSampleRate (double newSampleRate) + /** Applies the pending changes and writes one block of the selected algorithm. */ + void renderBlock (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept { - for (auto& generator : harmonicGenerators) - generator->setSampleRate (newSampleRate); + applyParameters (values, frequency); + + switch (values.type) + { + case SynthOscillatorType::additive: + additive.processBlock (output, numSamples); + break; + + case SynthOscillatorType::wavetable: + if (wavetable.needsRender()) + wavetable.render(); + + wavetable.processBlock (output, numSamples); + break; + + case SynthOscillatorType::sync: + sync.update(); + sync.processBlock (output, numSamples); + break; + + case SynthOscillatorType::morphing: + morphing.update(); + morphing.processBlock (output, numSamples, static_cast (values.morph)); + break; + + case SynthOscillatorType::modulated: + renderModulatedBlock (output, numSamples, values, frequency); + break; + } } - void noteOn (int midiNoteNumber, float velocity) +private: + //============================================================================== + /** Renders the oversampled modulation path, driving its FM from an internal sine. */ + void renderModulatedBlock (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept { - currentNote = midiNoteNumber; - isNoteOn = true; + yup::ModulatedOscillator::Parameters parameters; + parameters.frequency = frequency; + parameters.morph = static_cast (values.morph); + parameters.phaseDistortion = static_cast (values.phaseDistortion); + parameters.syncFrequency = values.syncMode == yup::SyncMode::none + ? 0.0 + : frequency * static_cast (values.followerRatio); + + const auto internalSampleRate = modulated.getInternalSampleRate(); + const auto modulatorIncrement = static_cast (values.fmRatio) * frequency / internalSampleRate; + const auto modulatorDepth = static_cast (values.fmAmount) * frequency; + + modulated.processModulatedBlock (output, numSamples, [this, ¶meters, modulatorIncrement, modulatorDepth] (int) + { + parameters.linearFM = modulatorDepth * std::sin (yup::MathConstants::twoPi * modulatorPhase); - // Convert MIDI note to frequency: f = 440 * 2^((n-69)/12) - fundamentalFrequency = 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); + modulatorPhase += modulatorIncrement; + modulatorPhase -= std::floor (modulatorPhase); - updateHarmonicFrequencies(); - updateHarmonicAmplitudes (velocity); + return parameters; + }); } - void noteOff (int midiNoteNumber) + //============================================================================== + /** Pushes only the controls whose value changed since the last block. */ + void applyParameters (const SynthOscillatorValues& values, double frequency) noexcept { - if (currentNote == midiNoteNumber) + const auto typeChanged = ! hasAppliedValues || applied.type != values.type; + const auto waveformChanged = typeChanged || applied.waveform != values.waveform; + const auto shapeChanged = typeChanged || applied.shape != values.shape; + const auto syncModeChanged = typeChanged || applied.syncMode != values.syncMode; + const auto ratioChanged = typeChanged || applied.followerRatio != values.followerRatio; + + switch (values.type) { - isNoteOn = false; - for (auto& generator : harmonicGenerators) - generator->setAmplitude (0.0f); + case SynthOscillatorType::additive: + if (waveformChanged) + additive.setWaveform (values.waveform); + break; + + case SynthOscillatorType::wavetable: + if (waveformChanged) + wavetable.setWaveform (values.waveform); + break; + + case SynthOscillatorType::sync: + if (waveformChanged) + sync.setWaveform (values.waveform); + if (syncModeChanged) + sync.setSyncMode (values.syncMode); + if (ratioChanged) + sync.setFollowerRatio (values.followerRatio); + break; + + case SynthOscillatorType::morphing: + if (waveformChanged || shapeChanged) + morphing.setSeries (resources->getFrame (values.waveform), resources->getFrame (values.shape)); + if (syncModeChanged) + morphing.setSyncMode (values.syncMode); + if (ratioChanged) + morphing.setFollowerRatio (values.followerRatio); + break; + + case SynthOscillatorType::modulated: + break; } + + additive.setFrequency (frequency); + wavetable.setFrequency (frequency); + sync.setFrequency (frequency); + morphing.setFrequency (frequency); + + applied = values; + hasAppliedValues = true; } - void allNotesOff() + //============================================================================== + yup::AdditiveOscillator additive; + yup::WavetableOscillator wavetable; + yup::SyncOscillator sync; + yup::MorphingOscillator morphing; + yup::ModulatedOscillator modulated; + + const SynthOscillatorResources* resources = nullptr; + SynthOscillatorValues applied; + double sampleRate = 44100.0; + double modulatorPhase = 0.0; + bool hasAppliedValues = false; +}; + +//============================================================================== +/** The single sound the example synthesiser plays. */ +class SynthSound : public yup::SynthesiserSound +{ +public: + bool appliesToNote (int) override { return true; } + bool appliesToChannel (int) override { return true; } +}; + +//============================================================================== +/** A polyphonic voice made of two independently configurable oscillators. */ +class SynthVoice : public yup::SynthesiserVoice +{ +public: + SynthVoice (const std::array& oscillatorSettings, + const SynthOscillatorResources& oscillatorResources) + : settings (oscillatorSettings) + , resources (oscillatorResources) { - isNoteOn = false; - currentNote = -1; - for (auto& generator : harmonicGenerators) - generator->setAmplitude (0.0f); } - void setHarmonicAmplitude (int harmonicIndex, float amplitude) + /** Allocates every oscillator backend. Must run outside the audio callback. */ + void prepare (double sampleRate, int maxBlockSize) { - if (harmonicIndex >= 0 && harmonicIndex < harmonicAmplitudes.size()) - { - harmonicAmplitudes[harmonicIndex] = amplitude; - if (isNoteOn) - updateHarmonicAmplitudes (1.0f); // Use current velocity - } + for (auto& oscillator : oscillators) + oscillator.prepare (sampleRate, maxBlockSize, resources); + + for (auto& level : levels) + level.reset (sampleRate, SynthExample::levelRampSeconds); + + envelope.reset (sampleRate, SynthExample::envelopeRampSeconds); + + const auto blockSize = static_cast (yup::jmax (1, maxBlockSize)); + + scratch.assign (blockSize, 0.0f); + mix.assign (blockSize, 0.0f); } - void setMasterAmplitude (float newAmplitude) + //============================================================================== + bool canPlaySound (yup::SynthesiserSound* sound) override { - masterAmplitude = newAmplitude; - if (isNoteOn) - updateHarmonicAmplitudes (1.0f); + return dynamic_cast (sound) != nullptr; } - float getMasterAmplitude() const + void startNote (int midiNoteNumber, float velocity, yup::SynthesiserSound*, int currentPitchWheelPosition) override { - return masterAmplitude; + noteFrequency = midiNoteToFrequency (midiNoteNumber); + velocityGain = yup::jmax (0.05f, velocity); + releaseRequested = false; + + pitchWheelMoved (currentPitchWheelPosition); + + for (auto& oscillator : oscillators) + oscillator.reset (0.0); + + envelope.reset (getSampleRate(), SynthExample::envelopeRampSeconds); + envelope.setTargetValue (1.0f); } - bool isPlaying() const + void stopNote (float, bool allowTailOff) override { - if (! isNoteOn) - return false; + envelope.setTargetValue (0.0f); - for (const auto& generator : harmonicGenerators) + if (! allowTailOff) { - if (generator->getCurrentAmplitude() > 0.001f) - return true; + releaseRequested = false; + clearCurrentNote(); + return; } - return false; + + releaseRequested = true; } - int getCurrentNote() const + void pitchWheelMoved (int newPitchWheelValue) override { - return currentNote; + const auto normalized = (static_cast (newPitchWheelValue) - 8192.0) / 8192.0; + pitchWheelRatio = std::pow (2.0, normalized * pitchWheelRangeSemitones / 12.0); } - float getNextSample() + void controllerMoved (int, int) override {} + + //============================================================================== + void renderNextBlock (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) override { - float mixedSample = 0.0f; + if (! isVoiceActive() || numSamples <= 0) + return; + + jassert (numSamples <= static_cast (scratch.size())); + + const auto frequency = noteFrequency * pitchWheelRatio; + const auto numChannelsToWrite = yup::jmin (outputBuffer.getNumChannels(), 2); - for (auto& generator : harmonicGenerators) + float* channels[2] = {}; + + for (int channel = 0; channel < numChannelsToWrite; ++channel) + channels[channel] = outputBuffer.getWritePointer (channel, startSample); + + yup::FloatVectorOperations::clear (mix.data(), numSamples); + + for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - mixedSample += generator->getNextSample(); + const auto values = settings[static_cast (index)].read(); + auto& level = levels[static_cast (index)]; + + yup::FloatVectorOperations::clear (scratch.data(), numSamples); + oscillators[static_cast (index)].renderBlock (scratch.data(), numSamples, values, frequency); + + level.setTargetValue (values.level); + + for (int sample = 0; sample < numSamples; ++sample) + mix[static_cast (sample)] += scratch[static_cast (sample)] * level.getNextValue(); + } + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto value = mix[static_cast (sample)] * velocityGain * envelope.getNextValue(); + + for (int channel = 0; channel < numChannelsToWrite; ++channel) + channels[channel][sample] += value; } - return mixedSample * masterAmplitude; + if (releaseRequested && envelope.getCurrentValue() <= 0.0f) + { + releaseRequested = false; + clearCurrentNote(); + } } - double getHarmonicMultiplier (int index) const +private: + //============================================================================== + static double midiNoteToFrequency (int midiNoteNumber) noexcept { - if (index >= 0 && index < harmonicMultipliers.size()) - return harmonicMultipliers[index]; - return 1.0; + return 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); } -private: - void updateHarmonicFrequencies() + //============================================================================== + static constexpr double pitchWheelRangeSemitones = 2.0; + + const std::array& settings; + const SynthOscillatorResources& resources; + + std::array oscillators; + std::array, SynthExample::oscillatorCount> levels; + + yup::SmoothedValue envelope; + std::vector scratch; + std::vector mix; + + double noteFrequency = 440.0; + double pitchWheelRatio = 1.0; + float velocityGain = 1.0f; + bool releaseRequested = false; +}; + +//============================================================================== +/** Polyphonic synthesiser rendering the two oscillators of every voice. */ +class HarmonicSynthEngine : public yup::Synthesiser +{ +public: + HarmonicSynthEngine() { - for (size_t i = 0; i < harmonicGenerators.size(); ++i) + addSound (new SynthSound()); + + for (int index = 0; index < SynthExample::voiceCount; ++index) { - double harmonicFreq = fundamentalFrequency * harmonicMultipliers[i]; - harmonicGenerators[i]->setFrequency (harmonicFreq); + auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, resources)); + + addVoice (voice); + ownedVoices.add (voice); } } - void updateHarmonicAmplitudes (float velocity) + /** Prepares every voice, including the oscillators of the modulated algorithm. */ + void prepare (double sampleRate, int maxBlockSize) { - for (size_t i = 0; i < harmonicGenerators.size(); ++i) - { - float amplitude = harmonicAmplitudes[i] * velocity * masterAmplitude; - harmonicGenerators[i]->setAmplitude (amplitude); - } + setCurrentPlaybackSampleRate (sampleRate); + + for (int index = 0; index < ownedVoices.size(); ++index) + ownedVoices[index]->prepare (sampleRate, maxBlockSize); + } + + /** Returns the settings edited by one of the user interface panels. */ + SynthOscillatorSettings& getOscillatorSettings (int oscillatorIndex) noexcept + { + return settings[static_cast (oscillatorIndex)]; + } + + /** Returns the note of a sounding voice, or -1 when the synthesiser is silent. */ + int getCurrentlyPlayingNote() const noexcept + { + for (int index = 0; index < ownedVoices.size(); ++index) + if (ownedVoices[index] != nullptr && ownedVoices[index]->isVoiceActive()) + return ownedVoices[index]->getCurrentlyPlayingNote(); + + return -1; } - std::vector> harmonicGenerators; - std::vector harmonicMultipliers; - std::vector harmonicAmplitudes; +private: + SynthOscillatorResources resources; + std::array settings; + yup::ReferenceCountedArray ownedVoices; - bool isNoteOn; - int currentNote; - double fundamentalFrequency; - float masterAmplitude; + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HarmonicSynthEngine) }; //============================================================================== - +/** Draws the most recent block of rendered audio as a waveform. */ class Oscilloscope : public yup::Component { public: @@ -242,36 +555,29 @@ class Oscilloscope : public yup::Component { } - void setRenderData (const std::vector& data, int newReadPos) + /** Copies the samples to display. Called from the message thread. */ + void setRenderData (const std::vector& data) { - renderData.resize (data.size()); - - for (std::size_t i = 0; i < data.size(); ++i) - renderData[i] = data[i]; + renderData = data; } void paint (yup::Graphics& g) override { - auto bounds = getLocalBounds(); - - auto backgroundColor = yup::Color (0xff101010); - g.setFillColor (backgroundColor); + g.setFillColor (yup::Color (0xff101010)); g.fillAll(); - auto lineColor = yup::Color (0xff4b4bff); if (renderData.empty()) return; - float xSize = getWidth() / float (renderData.size()); - float centerY = getHeight() * 0.5f; + const auto lineColor = yup::Color (0xff4b4bff); + const auto xSize = getWidth() / static_cast (renderData.size()); - // Build the main waveform path path.clear(); - path.reserveSpace ((int) renderData.size()); + path.reserveSpace (static_cast (renderData.size())); path.moveTo (0.0f, (renderData[0] + 1.0f) * 0.5f * getHeight()); for (std::size_t i = 1; i < renderData.size(); ++i) - path.lineTo (i * xSize, (renderData[i] + 1.0f) * 0.5f * getHeight()); + path.lineTo (static_cast (i) * xSize, (renderData[i] + 1.0f) * 0.5f * getHeight()); filledPath = path.createStrokePolygon (4.0f); @@ -303,136 +609,294 @@ class Oscilloscope : public yup::Component }; //============================================================================== +/** A caption and a combo box laid out as a single row. */ +class LabeledComboBox : public yup::Component +{ +public: + LabeledComboBox (const yup::String& caption, const yup::StringArray& items, const yup::Font& font) + { + setOpaque (false); // the row draws nothing itself, only its caption and combo box do + + label.setText (caption, yup::dontSendNotification); + label.setFont (font); + label.setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); + addAndMakeVisible (label); + + comboBox.addItemList (items, 1); + comboBox.setTextWhenNothingSelected ("-"); + comboBox.onSelectedItemChanged = [this] + { + if (onChange != nullptr) + onChange (comboBox.getSelectedId()); + }; + addAndMakeVisible (comboBox); + } + + /** Called with the 1-based identifier of the newly selected item. */ + std::function onChange; + + yup::ComboBox& getComboBox() noexcept { return comboBox; } + + void resized() override + { + auto bounds = getLocalBounds(); + label.setBounds (bounds.removeFromLeft (captionWidth())); + comboBox.setBounds (bounds); + } + +private: + int captionWidth() const noexcept { return yup::jmax (28, (int) getWidth() / 3); } + + yup::Label label; + yup::ComboBox comboBox; +}; + +//============================================================================== +/** A caption and a horizontal slider laid out as a single row. */ +class LabeledSlider : public yup::Component +{ +public: + LabeledSlider (const yup::String& caption, + double minimum, + double maximum, + double interval, + double defaultValue, + const yup::Font& font) + { + setOpaque (false); // the row draws nothing itself, only its caption and slider do + + label.setText (caption, yup::dontSendNotification); + label.setFont (font); + label.setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); + addAndMakeVisible (label); + + slider.setRange (minimum, maximum, interval); + slider.setDefaultValue (defaultValue); + slider.setValue (defaultValue, yup::dontSendNotification); + slider.onValueChanged = [this] (double value) + { + if (onChange != nullptr) + onChange (value); + }; + addAndMakeVisible (slider); + } + + /** Called with the new slider value. */ + std::function onChange; + + yup::Slider& getSlider() noexcept { return slider; } + + void resized() override + { + auto bounds = getLocalBounds(); + label.setBounds (bounds.removeFromLeft (captionWidth())); + slider.setBounds (bounds); + } + +private: + int captionWidth() const noexcept { return yup::jmax (28, (int) getWidth() / 3); } + + yup::Label label; + yup::Slider slider { yup::Slider::LinearHorizontal }; +}; + +//============================================================================== +/** The editing surface of one oscillator, writing straight into the voice settings. + + Every widget is wired to a single atomic setting, and refresh() copies the + settings back into the widgets for changes coming from somewhere else, such as + the randomize button. + + @see SynthOscillatorSettings +*/ +class SynthOscillatorPanel : public yup::Component +{ +public: + SynthOscillatorPanel (const yup::String& panelTitle, SynthOscillatorSettings& settingsToEdit, const yup::Font& font) + : settings (settingsToEdit) + , algorithmRow ("Algorithm", getSynthOscillatorTypeNames(), font) + , waveformRow ("Waveform", getSynthWaveformNames(), font) + , shapeRow ("Shape B", getSynthWaveformNames(), font) + , syncModeRow ("Sync Mode", getSynthSyncModeNames(), font) + , levelRow ("Level", 0.0, 1.0, 0.001, 0.5, font) + , detuneRow ("Detune", -24.0, 24.0, 0.1, 0.0, font) + , ratioRow ("Sync Ratio", 0.25, 8.0, 0.01, 1.5, font) + , morphRow ("Morph", 0.0, 1.0, 0.001, 0.0, font) + , distortionRow ("Distortion", 0.01, 0.99, 0.001, 0.5, font) + , fmAmountRow ("FM Amount", 0.0, 4.0, 0.001, 0.0, font) + , fmRatioRow ("FM Ratio", 0.25, 8.0, 0.01, 2.0, font) + { + titleLabel.setText (panelTitle, yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, yup::Colors::white); + addAndMakeVisible (titleLabel); + + const auto addRow = [this] (yup::Component& row) + { + addAndMakeVisible (row); + rows.push_back (&row); + }; + + addRow (algorithmRow); + addRow (waveformRow); + addRow (shapeRow); + addRow (syncModeRow); + addRow (levelRow); + addRow (detuneRow); + addRow (ratioRow); + addRow (morphRow); + addRow (distortionRow); + addRow (fmAmountRow); + addRow (fmRatioRow); + + algorithmRow.onChange = [this] (int id) { settings.type = id - 1; }; + waveformRow.onChange = [this] (int id) { settings.waveform = id - 1; }; + shapeRow.onChange = [this] (int id) { settings.shape = id - 1; }; + syncModeRow.onChange = [this] (int id) { settings.syncMode = id - 1; }; + levelRow.onChange = [this] (double value) { settings.level = static_cast (value); }; + detuneRow.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value); }; + ratioRow.onChange = [this] (double value) { settings.followerRatio = static_cast (value); }; + morphRow.onChange = [this] (double value) { settings.morph = static_cast (value); }; + distortionRow.onChange = [this] (double value) { settings.phaseDistortion = static_cast (value); }; + fmAmountRow.onChange = [this] (double value) { settings.fmAmount = static_cast (value); }; + fmRatioRow.onChange = [this] (double value) { settings.fmRatio = static_cast (value); }; + + refresh(); + } + + /** Reads the settings back into the widgets. */ + void refresh() + { + algorithmRow.getComboBox().setSelectedId (settings.type.load() + 1, yup::dontSendNotification); + waveformRow.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); + shapeRow.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); + syncModeRow.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); + + levelRow.getSlider().setValue (settings.level.load(), yup::dontSendNotification); + detuneRow.getSlider().setValue (settings.detuneSemitones.load(), yup::dontSendNotification); + ratioRow.getSlider().setValue (settings.followerRatio.load(), yup::dontSendNotification); + morphRow.getSlider().setValue (settings.morph.load(), yup::dontSendNotification); + distortionRow.getSlider().setValue (settings.phaseDistortion.load(), yup::dontSendNotification); + fmAmountRow.getSlider().setValue (settings.fmAmount.load(), yup::dontSendNotification); + fmRatioRow.getSlider().setValue (settings.fmRatio.load(), yup::dontSendNotification); + } + + void resized() override + { + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (titleHeight())); + if (rows.empty()) + return; + + const auto rowHeight = bounds.getHeight() / static_cast (rows.size()); + + for (auto* row : rows) + row->setBounds (bounds.removeFromTop (rowHeight)); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray).darker (0.4f)); + g.fillAll(); + } + +private: + int titleHeight() const noexcept { return 18; } + + SynthOscillatorSettings& settings; + + yup::Label titleLabel; + std::vector rows; + + LabeledComboBox algorithmRow; + LabeledComboBox waveformRow; + LabeledComboBox shapeRow; + LabeledComboBox syncModeRow; + LabeledSlider levelRow; + LabeledSlider detuneRow; + LabeledSlider ratioRow; + LabeledSlider morphRow; + LabeledSlider distortionRow; + LabeledSlider fmAmountRow; + LabeledSlider fmRatioRow; +}; + +//============================================================================== class AudioExample : public yup::Component , public yup::AudioIODeviceCallback - , public yup::MidiKeyboardState::Listener { public: AudioExample() : Component ("AudioExample") , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) { - // Initialize the audio device deviceManager.initialiseWithDefaultDevices (0, 2); - // Initialize harmonic synthesizer - double sampleRate = deviceManager.getAudioDeviceSetup().sampleRate; - harmonicSynth.setSampleRate (sampleRate); - - // Set up MIDI keyboard - keyboardState.addListener (this); + // The keyboard state is pumped into the synth by processNextMidiBuffer(), so no note + // listener is registered here: listening as well would trigger every note twice. keyboardComponent.setAvailableRange (36, 84); // C2 to C6 keyboardComponent.setLowestVisibleKey (48); // Start from C3 keyboardComponent.setMidiChannel (1); keyboardComponent.setVelocity (0.7f); addAndMakeVisible (keyboardComponent); - // Create title and subtitle labels titleLabel = std::make_unique ("Title"); - titleLabel->setText ("YUP Harmonic Synthesizer"); - //titleLabel->setJustification (yup::Justification::centred); - //titleLabel->setFont (16.0f); + titleLabel->setText ("YUP Polyphonic Oscillator Synthesizer"); titleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); addAndMakeVisible (*titleLabel); subtitleLabel = std::make_unique ("Subtitle"); - subtitleLabel->setText ("Each knob controls a harmonic of the played note - experiment to create rich tones!"); - //subtitleLabel->setJustification (yup::Justification::centred); - //subtitleLabel->setFont (12.0f); + subtitleLabel->setText ("Eight voices, two oscillators each - play the keyboard and shape both oscillators of the sound"); subtitleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); addAndMakeVisible (*subtitleLabel); - // Create note indicator label noteIndicatorLabel = std::make_unique ("NoteIndicator"); noteIndicatorLabel->setText (""); - //noteIndicatorLabel->setJustification (yup::Justification::centred); - //noteIndicatorLabel->setFont (12.0f); noteIndicatorLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::black); noteIndicatorLabel->setColor (yup::Label::Style::backgroundColorId, yup::Colors::yellow.withAlpha (0.8f)); addChildComponent (*noteIndicatorLabel); - auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); + const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); - // Add harmonic control sliders (4x4 grid) - for (int i = 0; i < totalRows * totalColumns; ++i) + for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - auto slider = sliders.add (std::make_unique (yup::Slider::RotaryVerticalDrag)); + oscillatorPanels[static_cast (index)] = std::make_unique ( + yup::String ("Oscillator ") + yup::String (index + 1), + synth.getOscillatorSettings (index), + font.withHeight (11.0f)); - // Configure slider range and default value - slider->setRange (0.0f, 1.0f); - slider->setDefaultValue (0.0f); - - slider->onValueChanged = [this, i] (double value) - { - harmonicSynth.setHarmonicAmplitude (i, (float) value * 0.4f); // Scale down to prevent clipping - }; - - addAndMakeVisible (slider); - - // Create harmonic labels for each slider - auto label = harmonicLabels.add (std::make_unique (yup::String ("HarmonicLabel") + yup::String (i))); - //label->setJustificationType (yup::Justification::centred); - //label->setFont (10.0f); - label->setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); - label->setFont (font.withHeight (8.0f)); - - // Set the harmonic multiplier text - auto multiplier = harmonicSynth.getHarmonicMultiplier (i); - label->setText (yup::String (multiplier, 1) + "x", yup::dontSendNotification); - - addAndMakeVisible (*label); + addAndMakeVisible (*oscillatorPanels[static_cast (index)]); } - // Add buttons randomizeButton = std::make_unique ("Randomize"); - randomizeButton->onClick = [this] - { - for (int i = 0; i < sliders.size(); ++i) - sliders[i]->setValue (yup::Random::getSystemRandom().nextFloat()); - }; + randomizeButton->onClick = [this] { randomizeOscillators(); }; addAndMakeVisible (*randomizeButton); - // Add clear all notes button clearButton = std::make_unique ("All Notes Off"); clearButton->onClick = [this] { keyboardState.allNotesOff (0); // Turn off all notes on all channels - harmonicSynth.allNotesOff(); + synth.allNotesOff (0, true); }; addAndMakeVisible (*clearButton); - // Add volume control volumeSlider = std::make_unique (yup::Slider::LinearHorizontal, "Volume"); - - // Configure slider range and default value - volumeSlider->setRange ({ 0.0f, 1.0f }); - volumeSlider->setDefaultValue (0.5f); - + volumeSlider->setRange (0.0, 1.0, 0.001); + volumeSlider->setDefaultValue (0.5); volumeSlider->onValueChanged = [this] (double value) { - masterVolume = (float) value; + masterVolume = static_cast (value); }; - volumeSlider->setValue (0.5f); // Set initial volume to 50% + volumeSlider->setValue (0.5, yup::dontSendNotification); addAndMakeVisible (*volumeSlider); - // Add the oscilloscope addAndMakeVisible (oscilloscope); - - // Set some initial harmonic values for a nice sound - if (sliders.size() >= 4) - { - sliders[0]->setValue (0.8f); // Fundamental - sliders[1]->setValue (0.4f); // 2nd harmonic - sliders[2]->setValue (0.2f); // 3rd harmonic - sliders[3]->setValue (0.1f); // 4th harmonic - } } ~AudioExample() override { - keyboardState.removeListener (this); deviceManager.removeAudioCallback (this); deviceManager.closeAudioDevice(); } @@ -441,67 +905,32 @@ class AudioExample { auto bounds = getLocalBounds(); - // Title area at the top - auto titleHeight = proportionOfHeight (0.05f); - auto titleBounds = bounds.removeFromTop (titleHeight); - titleLabel->setBounds (titleBounds); + titleLabel->setBounds (bounds.removeFromTop (proportionOfHeight (0.05f))); + subtitleLabel->setBounds (bounds.removeFromTop (proportionOfHeight (0.03f))); - // Subtitle area - auto subtitleHeight = proportionOfHeight (0.03f); - auto subtitleBounds = bounds.removeFromTop (subtitleHeight); - subtitleLabel->setBounds (subtitleBounds); + keyboardComponent.setBounds (bounds.removeFromBottom (proportionOfHeight (0.20f)) + .reduced (proportionOfWidth (0.02f), proportionOfHeight (0.01f))); - // Reserve space for MIDI keyboard at the bottom - auto keyboardHeight = proportionOfHeight (0.20f); - auto keyboardBounds = bounds.removeFromBottom (keyboardHeight); - keyboardComponent.setBounds (keyboardBounds.reduced (proportionOfWidth (0.02f), proportionOfHeight (0.01f))); + oscilloscope.setBounds (bounds.removeFromBottom (proportionOfHeight (0.20f)) + .reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); - // Reserve space for oscilloscope above the keyboard - auto oscilloscopeHeight = proportionOfHeight (0.2f); - auto oscilloscopeBounds = bounds.removeFromBottom (oscilloscopeHeight); - oscilloscope.setBounds (oscilloscopeBounds.reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + auto buttonArea = bounds.removeFromBottom (proportionOfHeight (0.08f)); - // Reserve space for buttons area - auto buttonHeight = proportionOfHeight (0.08f); - auto buttonArea = bounds.removeFromBottom (buttonHeight); + const auto buttonWidth = buttonArea.getWidth() / 3; + const auto buttonInsetX = proportionOfWidth (0.01f); + const auto buttonInsetY = proportionOfHeight (0.01f); - auto buttonWidth = buttonArea.getWidth() / 3; - if (randomizeButton != nullptr) - randomizeButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + randomizeButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); + clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); + volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); - if (clearButton != nullptr) - clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + const auto panelWidth = bounds.getWidth() / SynthExample::oscillatorCount; - if (volumeSlider != nullptr) - volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + for (auto& panel : oscillatorPanels) + if (panel != nullptr) + panel->setBounds (bounds.removeFromLeft (panelWidth).reduced (6)); - // Use remaining space for harmonic control sliders with labels - auto sliderBounds = bounds.reduced (proportionOfWidth (0.05f), proportionOfHeight (0.02f)); - auto width = sliderBounds.getWidth() / totalColumns; - auto height = sliderBounds.getHeight() / totalRows; - - for (int i = 0; i < totalRows && i * totalColumns < sliders.size(); ++i) - { - auto row = sliderBounds.removeFromTop (height); - for (int j = 0; j < totalColumns && i * totalColumns + j < sliders.size(); ++j) - { - auto col = row.removeFromLeft (width); - auto harmonicIndex = i * totalColumns + j; - - // Reserve space for label at bottom of column - auto labelHeight = 10; - auto labelBounds = col.removeFromBottom (labelHeight); - harmonicLabels[harmonicIndex]->setBounds (labelBounds); - - // Use remaining space for slider - make it rectangular for slider appearance - auto sliderArea = col.largestFittingSquare(); - sliders.getUnchecked (harmonicIndex)->setBounds (sliderArea); - } - } - - // Position note indicator at bottom left - auto noteIndicatorBounds = yup::Rectangle (10, getHeight() - 40, 200, 30); - noteIndicatorLabel->setBounds (noteIndicatorBounds); + noteIndicatorLabel->setBounds (yup::Rectangle (10, getHeight() - 40, 200, 30)); } void paint (yup::Graphics& g) override @@ -510,30 +939,28 @@ class AudioExample g.fillAll(); } - void mouseDown (const yup::MouseEvent& event) override + void mouseDown (const yup::MouseEvent&) override { takeKeyboardFocus(); } - void refreshDisplay (double lastFrameTimeSeconds) override + void refreshDisplay (double) override { { const yup::CriticalSection::ScopedLockType sl (renderMutex); - oscilloscope.setRenderData (renderData, readPos); + oscilloscope.setRenderData (renderData); } if (oscilloscope.isVisible()) oscilloscope.repaint(); - // Update note indicator - if (harmonicSynth.isPlaying()) + const auto playingNote = synth.getCurrentlyPlayingNote(); + + if (playingNote >= 0) { - if (! noteIndicatorLabel->isVisible()) - { - noteIndicatorLabel->setVisible (true); - } - auto noteText = yup::String ("Playing Note: ") + yup::String (harmonicSynth.getCurrentNote()); - noteIndicatorLabel->setText (noteText, yup::dontSendNotification); + noteIndicatorLabel->setText (yup::String ("Playing Note: ") + yup::String (playingNote), + yup::dontSendNotification); + noteIndicatorLabel->setVisible (true); } else { @@ -541,58 +968,63 @@ class AudioExample } } - // MIDI keyboard event handlers - void handleNoteOn (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override + void audioDeviceAboutToStart (yup::AudioIODevice* device) override { - harmonicSynth.noteOn (midiNoteNumber, velocity); + const auto maxBlockSize = yup::jmax (device->getDefaultBufferSize(), SynthExample::maxBlockSize); + + synth.prepare (device->getCurrentSampleRate(), maxBlockSize); + + renderBuffer.setSize (2, maxBlockSize, false, true, true); + inputData.assign (static_cast (maxBlockSize), 0.0f); + renderData.assign (static_cast (maxBlockSize), 0.0f); } - void handleNoteOff (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override + void audioDeviceStopped() override { - harmonicSynth.noteOff (midiNoteNumber); } - void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, - int numInputChannels, + void audioDeviceIOCallbackWithContext (const float* const*, + int, float* const* outputChannelData, int numOutputChannels, int numSamples, - const yup::AudioIODeviceCallbackContext& context) override + const yup::AudioIODeviceCallbackContext&) override { - for (int sample = 0; sample < numSamples; ++sample) + if (numSamples > renderBuffer.getNumSamples()) { - // Generate the next sample from the harmonic synth - float synthSample = harmonicSynth.getNextSample(); + for (int channel = 0; channel < numOutputChannels; ++channel) + yup::FloatVectorOperations::clear (outputChannelData[channel], numSamples); - // Apply master volume - synthSample *= masterVolume; + return; + } - // Apply soft limiting to prevent clipping - synthSample = std::tanh (synthSample); + for (int channel = 0; channel < renderBuffer.getNumChannels(); ++channel) + yup::FloatVectorOperations::clear (renderBuffer.getWritePointer (channel), numSamples); - // Output to all channels - for (int channel = 0; channel < numOutputChannels; ++channel) - outputChannelData[channel][sample] = synthSample; + midiBuffer.clear(); + keyboardState.processNextMidiBuffer (midiBuffer, 0, numSamples, true); + synth.renderNextBlock (renderBuffer, midiBuffer, 0, numSamples); + + const auto gain = masterVolume.load(); + const auto* display = renderBuffer.getReadPointer (0); - // Store for oscilloscope display - auto pos = readPos.fetch_add (1); - inputData[pos] = synthSample; - readPos = readPos % inputData.size(); + for (int channel = 0; channel < numOutputChannels; ++channel) + { + const auto* source = renderBuffer.getReadPointer (yup::jmin (channel, renderBuffer.getNumChannels() - 1)); + auto* destination = outputChannelData[channel]; + + for (int sample = 0; sample < numSamples; ++sample) + destination[sample] = std::tanh (source[sample] * gain); } - const yup::CriticalSection::ScopedLockType sl (renderMutex); - std::swap (inputData, renderData); - } + { + const yup::CriticalSection::ScopedLockType sl (renderMutex); - void audioDeviceAboutToStart (yup::AudioIODevice* device) override - { - inputData.resize (device->getDefaultBufferSize()); - renderData.resize (device->getDefaultBufferSize()); - readPos = 0; - } + for (int sample = 0; sample < numSamples; ++sample) + inputData[static_cast (sample)] = std::tanh (display[sample] * gain); - void audioDeviceStopped() override - { + std::swap (inputData, renderData); + } } void visibilityChanged() override @@ -604,32 +1036,56 @@ class AudioExample } private: + void randomizeOscillators() + { + auto& random = yup::Random::getSystemRandom(); + + for (int index = 0; index < SynthExample::oscillatorCount; ++index) + { + auto& settings = synth.getOscillatorSettings (index); + + settings.type = random.nextInt (5); + settings.waveform = random.nextInt (6); + settings.shape = random.nextInt (6); + settings.syncMode = random.nextInt (4); + settings.level = 0.2f + random.nextFloat() * 0.8f; + settings.detuneSemitones = random.nextFloat() * 24.0f - 12.0f; + settings.followerRatio = 0.5f + random.nextFloat() * 3.0f; + settings.morph = random.nextFloat(); + settings.phaseDistortion = 0.1f + random.nextFloat() * 0.8f; + settings.fmAmount = random.nextFloat() * 2.0f; + settings.fmRatio = 0.5f + random.nextFloat() * 3.0f; + + if (auto& panel = oscillatorPanels[static_cast (index)]; panel != nullptr) + panel->refresh(); + } + } + + //============================================================================== yup::AudioDeviceManager deviceManager; - HarmonicSynth harmonicSynth; + HarmonicSynthEngine synth; // MIDI keyboard components yup::MidiKeyboardState keyboardState; yup::MidiKeyboardComponent keyboardComponent; + yup::AudioBuffer renderBuffer; + yup::MidiBuffer midiBuffer; std::vector renderData; std::vector inputData; yup::CriticalSection renderMutex; - std::atomic_int readPos = 0; // UI Components std::unique_ptr titleLabel; std::unique_ptr subtitleLabel; std::unique_ptr noteIndicatorLabel; - yup::OwnedArray sliders; - yup::OwnedArray harmonicLabels; - int totalRows = 4; - int totalColumns = 4; + std::array, SynthExample::oscillatorCount> oscillatorPanels; std::unique_ptr randomizeButton; std::unique_ptr clearButton; std::unique_ptr volumeSlider; Oscilloscope oscilloscope; - float masterVolume = 0.5f; + std::atomic masterVolume { 0.5f }; }; From 0ab44cb5d73502482460ad0cec025d5bcfade128 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Mon, 21 Sep 2026 23:54:02 +0200 Subject: [PATCH 04/37] More fun stuff --- CHANGELOG.md | 1 + examples/graphics/source/examples/Audio.h | 1631 +++++++++++++++++---- 2 files changed, 1341 insertions(+), 291 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1740ec80..7a4c732f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -420,6 +420,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - `ArtboardDemo` example (`examples/graphics/source/examples/Artboard.h`): the loaded Rive artboard now queries a named node via `Artboard::findNode` (name, type, bounds shown in a status label) and attaches a rectangular marker component to it with `Artboard::attachComponentToNode` — a "Marker" combo switches between filling the node's bounds and tracking only its position, "Pivot" and "Anchor" combos choose which component point lands on which node point in track mode, and an "Apply transform" toggle rotates the marker with the node — and the marker follows the node on reflows and resizes. - New `ArtboardLayoutDemo` example (`examples/graphics/source/examples/Artboard.h`, registered as "Artboard Layout"): shares `ArtboardDemoBase`'s controls with `ArtboardDemo` but loads `data/layout-ui.riv` and attaches a live, nested `Artboard` (playing the file's `Keyboard` artboard) to the `keyboard_slot` layout placeholder in the main `Wireframe` artboard, instead of a plain marker rectangle. - `ArtboardDemo` example: the displayed Rive file can now be replaced at runtime by dropping a `.riv` file onto the demo, which rebuilds the artboards from the dropped file while keeping the current fit, alignment and marker settings. The demo outlines itself while a `.riv` is dragged over it. +- `AudioExample` example (`examples/graphics/source/examples/Audio.h`): reworked into a Vital-style instrument. Each oscillator gets a display that either draws the reconstructed waveform or edits its partials as draggable magnitude bars, writing sine coefficients the wavetable, sync and morphing backends all render; the drag publishes at most one generation bump per UI frame so it cannot queue an inverse FFT per mouse event across every sounding voice, and the reconstruction's peak is measured on the message thread and applied as a coefficient scale so an edited spectrum cannot exceed full scale. Unison adds up to five detuned, stereo-spread slots per oscillator, built from bare `WavetableOscillator` satellites rather than further `SynthOscillator` copies (which own four wavetable oscillators each once sync and morphing are counted) and offered on the wavetable algorithm alone, where they are exact. A DAHDSR envelope with a drawn curve replaces the single `SmoothedValue` fade, and the voice now renders stereo. Fixes the `Detune` control, which the UI wrote but the engine never read, so both oscillators always played the same frequency. ### Build System diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index b1e6b5424..d52d79c4c 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -2,7 +2,7 @@ ============================================================================== This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com + Copyright (c) 2026 - kunitoki@gmail.com YUP is an open source library subject to open-source licensing. @@ -35,7 +35,16 @@ constexpr int voiceCount = 8; constexpr int oscillatorCount = 2; constexpr int maxHarmonics = 128; constexpr int maxBlockSize = 2048; -constexpr double envelopeRampSeconds = 0.02; + +/** Unison slots per oscillator, counting the one running the selected algorithm. */ +constexpr int maxUnisonVoices = 5; + +/** Harmonics the partial editor exposes, a subset of the maxHarmonics the engine renders. */ +constexpr int editableHarmonics = 32; + +/** Harmonics the waveform display sums, capped well below maxHarmonics to keep repaints cheap. */ +constexpr int displayHarmonics = 64; + constexpr double levelRampSeconds = 0.01; } // namespace SynthExample @@ -49,7 +58,6 @@ constexpr double levelRampSeconds = 0.01; */ enum class SynthOscillatorType { - additive, /**< Exact additive synthesis of a Fourier series */ wavetable, /**< The same series rendered once, then played back */ sync, /**< Alias-free spectral oscillator synchronization */ morphing, /**< Blends two synchronized endpoint spectra */ @@ -59,7 +67,7 @@ enum class SynthOscillatorType /** @internal Item names for SynthOscillatorType, index aligned with the enumeration. */ inline yup::StringArray getSynthOscillatorTypeNames() { - return { "Additive", "Wavetable", "Sync", "Morphing", "Modulated" }; + return { "Wavetable", "Sync", "Morphing", "Modulated" }; } /** @internal Item names for yup::Waveform, index aligned with the enumeration. */ @@ -74,12 +82,248 @@ inline yup::StringArray getSynthSyncModeNames() return { "None", "Hard", "Mirrored", "Pulsar" }; } +//============================================================================== +/** Sums a Fourier series at one point of its period. + + yup::FourierSeries stores coefficients rather than samples, so the waveform display + reconstructs them on demand. Only the message thread calls this. + + @param series The coefficients to sum + @param phase The position in the period, normalized to 0 to 1 + @param maxHarmonic The highest harmonic to include + + @returns The value of the series at that phase. +*/ +inline float evaluateFourierSeries (const yup::FourierSeries& series, double phase, int maxHarmonic) noexcept +{ + const auto count = yup::jmin (maxHarmonic, series.getNumHarmonics()); + const auto theta = yup::MathConstants::twoPi * phase; + + auto value = series.getDC(); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + { + const auto angle = theta * harmonic; + + value += series.getCosine (harmonic) * std::cos (angle) + + series.getSine (harmonic) * std::sin (angle); + } + + return static_cast (value); +} + +//============================================================================== +/** A plain snapshot of the amplitude envelope's controls. */ +struct SynthEnvelopeValues +{ + float delay = 0.0f; + float attack = 0.005f; + float hold = 0.0f; + float decay = 0.35f; + float sustain = 0.7f; + float release = 0.35f; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. */ +struct SynthEnvelopeSettings +{ + std::atomic delay { 0.0f }; + std::atomic attack { 0.005f }; + std::atomic hold { 0.0f }; + std::atomic decay { 0.35f }; + std::atomic sustain { 0.7f }; + std::atomic release { 0.35f }; + + /** Takes a snapshot for one block of audio. */ + SynthEnvelopeValues read() const noexcept + { + return { delay.load(), attack.load(), hold.load(), decay.load(), sustain.load(), release.load() }; + } +}; + +//============================================================================== +/** A delay, attack, hold, decay, sustain and release envelope with linear segments. + + yup_dsp has no envelope generator, so the example carries its own. It replaces the + single yup::SmoothedValue the earlier revision faded notes with, which could only + ramp between two levels and gave every patch the same shape. + + The stage is advanced one sample at a time and the note ends when the envelope + falls idle, so a voice stays allocated for exactly as long as it is audible. + + @see SynthVoice +*/ +class SynthEnvelope +{ +public: + /** Prepares the envelope and leaves it idle. */ + void prepare (double newSampleRate) noexcept + { + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + reset(); + } + + /** Silences the envelope and returns it to the idle stage. */ + void reset() noexcept + { + stage = Stage::idle; + level = 0.0f; + stageSample = 0; + } + + /** Converts the control values into per sample increments. Safe to call every block. */ + void setParameters (const SynthEnvelopeValues& values) noexcept + { + sustainLevel = yup::jlimit (0.0f, 1.0f, values.sustain); + + delaySamples = toSamples (values.delay); + holdSamples = toSamples (values.hold); + releaseSamples = toSamples (values.release); + + attackIncrement = 1.0f / static_cast (toSamples (values.attack)); + decayIncrement = (1.0f - sustainLevel) / static_cast (toSamples (values.decay)); + } + + /** Starts a new note from the delay stage. */ + void noteOn() noexcept + { + stage = Stage::delay; + level = 0.0f; + stageSample = 0; + } + + /** Begins the release from whatever level the envelope currently sits at. */ + void noteOff() noexcept + { + if (stage == Stage::idle) + return; + + releaseIncrement = level / static_cast (releaseSamples); + stage = Stage::release; + stageSample = 0; + } + + /** Silences the envelope immediately, ending the note without a tail. */ + void noteOffImmediate() noexcept + { + reset(); + } + + /** Returns true while the envelope still contributes to the output. */ + bool isActive() const noexcept { return stage != Stage::idle; } + + /** Advances one sample and returns the new gain. */ + float getNextValue() noexcept + { + switch (stage) + { + case Stage::idle: + return 0.0f; + + case Stage::delay: + if (++stageSample >= delaySamples) + advanceTo (Stage::attack); + + return 0.0f; + + case Stage::attack: + level += attackIncrement; + + if (level >= 1.0f) + { + level = 1.0f; + advanceTo (Stage::hold); + } + + return level; + + case Stage::hold: + if (++stageSample >= holdSamples) + advanceTo (Stage::decay); + + return level; + + case Stage::decay: + level -= decayIncrement; + + if (level <= sustainLevel || decayIncrement <= 0.0f) + { + level = sustainLevel; + advanceTo (Stage::sustain); + } + + return level; + + case Stage::sustain: + level = sustainLevel; + + return level; + + case Stage::release: + level -= releaseIncrement; + + if (level <= 0.0f || releaseIncrement <= 0.0f) + { + level = 0.0f; + advanceTo (Stage::idle); + } + + return level; + } + + return level; + } + +private: + //============================================================================== + enum class Stage + { + idle, + delay, + attack, + hold, + decay, + sustain, + release + }; + + void advanceTo (Stage newStage) noexcept + { + stage = newStage; + stageSample = 0; + } + + int toSamples (float seconds) const noexcept + { + return yup::jmax (1, static_cast (static_cast (seconds) * sampleRate)); + } + + //============================================================================== + Stage stage = Stage::idle; + + double sampleRate = 44100.0; + float level = 0.0f; + float sustainLevel = 0.7f; + float attackIncrement = 1.0f; + float decayIncrement = 1.0f; + float releaseIncrement = 1.0f; + + int delaySamples = 1; + int holdSamples = 1; + int releaseSamples = 1; + int stageSample = 0; +}; + //============================================================================== /** A plain snapshot of one oscillator's controls. The audio thread takes one snapshot per block and compares it with the values it applied last time, so a control that did not move never costs a spectral transform or a table render. + + The edited partials are deliberately not copied here. They live behind + harmonicGeneration, so a block only pays for them when the editor actually moved. */ struct SynthOscillatorValues { @@ -94,11 +338,31 @@ struct SynthOscillatorValues float phaseDistortion = 0.5f; float fmAmount = 0.0f; float fmRatio = 2.0f; + int unisonVoices = 1; + float unisonDetune = 0.2f; + float unisonSpread = 0.6f; + float harmonicScale = 1.0f; + bool usesCustomSeries = false; + int harmonicGeneration = 0; }; -/** The same controls, edited from the message thread while the audio thread reads them. */ +/** The same controls, edited from the message thread while the audio thread reads them. + + The partial editor writes magnitudes continuously while the mouse is down but bumps + harmonicGeneration at most once per user interface frame. The audio thread rebuilds + its series only when that counter moves, which keeps a drag from forcing an inverse + FFT per mouse event on every sounding voice. + + @see SynthOscillator, WaveformEditor +*/ struct SynthOscillatorSettings { + SynthOscillatorSettings() + { + for (auto& harmonic : harmonics) + harmonic.store (0.0f); + } + std::atomic type { static_cast (SynthOscillatorType::wavetable) }; std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; std::atomic shape { static_cast (yup::Waveform::square) }; @@ -110,6 +374,14 @@ struct SynthOscillatorSettings std::atomic phaseDistortion { 0.5f }; std::atomic fmAmount { 0.0f }; std::atomic fmRatio { 2.0f }; + std::atomic unisonVoices { 1 }; + std::atomic unisonDetune { 0.2f }; + std::atomic unisonSpread { 0.6f }; + + std::array, SynthExample::editableHarmonics> harmonics; + std::atomic harmonicScale { 1.0f }; + std::atomic usesCustomSeries { false }; + std::atomic harmonicGeneration { 0 }; /** Takes a snapshot for one block of audio. */ SynthOscillatorValues read() const noexcept @@ -124,7 +396,54 @@ struct SynthOscillatorSettings morph.load(), phaseDistortion.load(), fmAmount.load(), - fmRatio.load() }; + fmRatio.load(), + unisonVoices.load(), + unisonDetune.load(), + unisonSpread.load(), + harmonicScale.load(), + usesCustomSeries.load(), + harmonicGeneration.load() }; + } + + /** Rebuilds a prepared series from the edited magnitudes, without allocating. + + The editor works in magnitudes only and writes them as sine coefficients, the + same convention yup::FourierSeries::setWaveform uses for its sawtooth, square + and triangle presets. + + Nothing stops the editor from asking for every harmonic at once, which would sum + to many times full scale, so the caller passes the scale that brings the + reconstruction back to a peak of one. WaveformEditor measures it while it redraws + and publishes it with the same generation bump; the audio thread passes it back + in here rather than measuring anything itself. + + @param series The prepared series to overwrite + @param scale The factor to apply to every coefficient + */ + void copyHarmonicsInto (yup::FourierSeries& series, float scale) const noexcept + { + series.clear(); + + const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + { + const auto magnitude = harmonics[static_cast (harmonic - 1)].load() * scale; + + series.setHarmonic (harmonic, 0.0, static_cast (magnitude)); + } + } + + /** Seeds the edited magnitudes from a series, so editing starts at the visible shape. */ + void seedHarmonicsFrom (const yup::FourierSeries& series) noexcept + { + const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + harmonics[static_cast (harmonic - 1)].store (static_cast (series.getMagnitude (harmonic))); + + for (int harmonic = count; harmonic < SynthExample::editableHarmonics; ++harmonic) + harmonics[static_cast (harmonic)].store (0.0f); } }; @@ -173,55 +492,160 @@ class SynthOscillatorResources /** One of a voice's oscillators, owning every algorithm it can switch between. prepare() allocates all the backends; renderBlock() is allocation-free and pushes - only the controls that actually moved, so editing a slider is the only thing that + only the controls that actually moved, so editing a knob is the only thing that pays for a spectral transform or a table render. + Unison is built from bare yup::WavetableOscillator satellites rather than from + further copies of this class. A SynthOscillator owns four wavetable oscillators + once the sync and morphing backends are counted, each with its own FFT and tables, + so replicating it per unison slot would cost several times the memory and startup + work that one satellite does. The satellites play the same series as the selected + algorithm, detuned and panned around it, which is exact for the wavetable algorithm + and is why unison is offered there alone. + @see SynthOscillatorSettings, SynthOscillatorResources */ class SynthOscillator { public: + /** Returns true if the algorithm can be widened with unison satellites. */ + static bool supportsUnison (SynthOscillatorType type) noexcept + { + return type == SynthOscillatorType::wavetable; + } + /** Allocates every backend and attaches the shared waveform resources. */ void prepare (double newSampleRate, int maxBlockSize, const SynthOscillatorResources& oscillatorResources) { sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; resources = &oscillatorResources; - additive.prepare (sampleRate, SynthExample::maxHarmonics); wavetable.prepare (sampleRate, SynthExample::maxHarmonics); sync.prepare (sampleRate, SynthExample::maxHarmonics); morphing.prepare (sampleRate, SynthExample::maxHarmonics); modulated.prepare (sampleRate, maxBlockSize, resources->getBank()); + for (auto& satellite : satellites) + satellite.prepare (sampleRate, SynthExample::maxHarmonics); + + customSeries.resize (SynthExample::maxHarmonics); + slotBuffer.assign (static_cast (yup::jmax (1, maxBlockSize)), 0.0f); + applied = {}; hasAppliedValues = false; } - /** Restarts every backend from a common phase. */ + /** Restarts every backend, spreading the satellites so they do not stack in phase. */ void reset (double initialPhase) noexcept { const auto phase = static_cast (initialPhase); - additive.setPhase (phase); wavetable.setPhase (phase); sync.setPhase (phase); morphing.setPhase (phase); modulated.reset (initialPhase); + for (std::size_t index = 0; index < satellites.size(); ++index) + { + const auto offset = static_cast (index + 1) / static_cast (satellites.size() + 1); + + satellites[index].setPhase (phase + offset - std::floor (phase + offset)); + } + modulatorPhase = 0.0; } - /** Applies the pending changes and writes one block of the selected algorithm. */ - void renderBlock (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept + /** Applies the pending changes and writes one stereo block of the selected algorithm. + + The buffers are overwritten rather than added to, so the caller does not have to + clear them first. + */ + void renderBlock (float* left, + float* right, + int numSamples, + const SynthOscillatorValues& values, + const SynthOscillatorSettings& settings, + double frequency) noexcept { - applyParameters (values, frequency); + yup::FloatVectorOperations::clear (left, numSamples); + yup::FloatVectorOperations::clear (right, numSamples); - switch (values.type) + const auto slotCount = supportsUnison (values.type) + ? yup::jlimit (1, SynthExample::maxUnisonVoices, values.unisonVoices) + : 1; + + const auto centreIndex = (slotCount - 1) / 2; + const auto slotGain = 1.0f / std::sqrt (static_cast (slotCount)); + + applyParameters (values, settings, detunedFrequency (frequency, values, centreIndex, slotCount)); + + renderAlgorithm (slotBuffer.data(), numSamples, values, frequency); + accumulateSlot (left, right, numSamples, slotOffset (centreIndex, slotCount) * values.unisonSpread, slotGain); + + for (int index = 0, satellite = 0; index < slotCount; ++index) { - case SynthOscillatorType::additive: - additive.processBlock (output, numSamples); - break; + if (index == centreIndex) + continue; + + renderSatellite (satellites[static_cast (satellite++)], + numSamples, + detunedFrequency (frequency, values, index, slotCount)); + + accumulateSlot (left, right, numSamples, slotOffset (index, slotCount) * values.unisonSpread, slotGain); + } + } + +private: + //============================================================================== + /** Returns where a unison slot sits across the spread, from -1 to 1. */ + static float slotOffset (int slotIndex, int slotCount) noexcept + { + if (slotCount <= 1) + return 0.0f; + + return 2.0f * static_cast (slotIndex) / static_cast (slotCount - 1) - 1.0f; + } + + /** Combines the oscillator's own detune with the slot's share of the unison spread. */ + static double detunedFrequency (double frequency, const SynthOscillatorValues& values, int slotIndex, int slotCount) noexcept + { + const auto semitones = static_cast (values.unisonDetune) * static_cast (slotOffset (slotIndex, slotCount)); + + return frequency * std::pow (2.0, semitones / 12.0); + } + + /** Mixes the rendered slot into the stereo pair with an equal power pan. */ + void accumulateSlot (float* left, float* right, int numSamples, float pan, float gain) noexcept + { + const auto angle = (yup::jlimit (-1.0f, 1.0f, pan) + 1.0f) * 0.25f * yup::MathConstants::pi; + const auto leftGain = std::cos (angle) * gain; + const auto rightGain = std::sin (angle) * gain; + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto value = slotBuffer[static_cast (sample)]; + + left[sample] += value * leftGain; + right[sample] += value * rightGain; + } + } + /** Renders one unison satellite, which always plays the wavetable algorithm. */ + void renderSatellite (yup::WavetableOscillator& satellite, int numSamples, double frequency) noexcept + { + satellite.setFrequency (frequency); + + if (satellite.needsRender()) + satellite.render(); + + satellite.processBlock (slotBuffer.data(), numSamples); + } + + /** Writes the block of whichever algorithm is selected. */ + void renderAlgorithm (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept + { + switch (values.type) + { case SynthOscillatorType::wavetable: if (wavetable.needsRender()) wavetable.render(); @@ -245,7 +669,6 @@ class SynthOscillator } } -private: //============================================================================== /** Renders the oversampled modulation path, driving its FM from an internal sine. */ void renderModulatedBlock (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept @@ -262,7 +685,9 @@ class SynthOscillator const auto modulatorIncrement = static_cast (values.fmRatio) * frequency / internalSampleRate; const auto modulatorDepth = static_cast (values.fmAmount) * frequency; - modulated.processModulatedBlock (output, numSamples, [this, ¶meters, modulatorIncrement, modulatorDepth] (int) + // Unlike the other backends this one can decline to write anything, which would + // otherwise leave the previous unison slot's samples in the shared buffer. + const auto rendered = modulated.processModulatedBlock (output, numSamples, [this, ¶meters, modulatorIncrement, modulatorDepth] (int) { parameters.linearFM = modulatorDepth * std::sin (yup::MathConstants::twoPi * modulatorPhase); @@ -271,11 +696,14 @@ class SynthOscillator return parameters; }); + + if (! rendered) + yup::FloatVectorOperations::clear (output, numSamples); } //============================================================================== /** Pushes only the controls whose value changed since the last block. */ - void applyParameters (const SynthOscillatorValues& values, double frequency) noexcept + void applyParameters (const SynthOscillatorValues& values, const SynthOscillatorSettings& settings, double frequency) noexcept { const auto typeChanged = ! hasAppliedValues || applied.type != values.type; const auto waveformChanged = typeChanged || applied.waveform != values.waveform; @@ -283,21 +711,30 @@ class SynthOscillator const auto syncModeChanged = typeChanged || applied.syncMode != values.syncMode; const auto ratioChanged = typeChanged || applied.followerRatio != values.followerRatio; + const auto partialsChanged = ! hasAppliedValues + || applied.usesCustomSeries != values.usesCustomSeries + || applied.harmonicGeneration != values.harmonicGeneration; + + if (partialsChanged && values.usesCustomSeries) + settings.copyHarmonicsInto (customSeries, values.harmonicScale); + + const auto seriesChanged = waveformChanged || partialsChanged; + const auto& series = values.usesCustomSeries ? customSeries : resources->getFrame (values.waveform); + + if (seriesChanged) + for (auto& satellite : satellites) + satellite.setSeries (series); + switch (values.type) { - case SynthOscillatorType::additive: - if (waveformChanged) - additive.setWaveform (values.waveform); - break; - case SynthOscillatorType::wavetable: - if (waveformChanged) - wavetable.setWaveform (values.waveform); + if (seriesChanged) + wavetable.setSeries (series); break; case SynthOscillatorType::sync: - if (waveformChanged) - sync.setWaveform (values.waveform); + if (seriesChanged) + sync.setFollowerSeries (series); if (syncModeChanged) sync.setSyncMode (values.syncMode); if (ratioChanged) @@ -305,8 +742,8 @@ class SynthOscillator break; case SynthOscillatorType::morphing: - if (waveformChanged || shapeChanged) - morphing.setSeries (resources->getFrame (values.waveform), resources->getFrame (values.shape)); + if (seriesChanged || shapeChanged) + morphing.setSeries (series, resources->getFrame (values.shape)); if (syncModeChanged) morphing.setSyncMode (values.syncMode); if (ratioChanged) @@ -317,7 +754,6 @@ class SynthOscillator break; } - additive.setFrequency (frequency); wavetable.setFrequency (frequency); sync.setFrequency (frequency); morphing.setFrequency (frequency); @@ -327,14 +763,17 @@ class SynthOscillator } //============================================================================== - yup::AdditiveOscillator additive; yup::WavetableOscillator wavetable; yup::SyncOscillator sync; yup::MorphingOscillator morphing; yup::ModulatedOscillator modulated; + std::array, SynthExample::maxUnisonVoices - 1> satellites; + const SynthOscillatorResources* resources = nullptr; SynthOscillatorValues applied; + yup::FourierSeries customSeries; + std::vector slotBuffer; double sampleRate = 44100.0; double modulatorPhase = 0.0; bool hasAppliedValues = false; @@ -355,8 +794,10 @@ class SynthVoice : public yup::SynthesiserVoice { public: SynthVoice (const std::array& oscillatorSettings, + const SynthEnvelopeSettings& sharedEnvelopeSettings, const SynthOscillatorResources& oscillatorResources) : settings (oscillatorSettings) + , envelopeSettings (sharedEnvelopeSettings) , resources (oscillatorResources) { } @@ -370,12 +811,14 @@ class SynthVoice : public yup::SynthesiserVoice for (auto& level : levels) level.reset (sampleRate, SynthExample::levelRampSeconds); - envelope.reset (sampleRate, SynthExample::envelopeRampSeconds); + envelope.prepare (sampleRate); const auto blockSize = static_cast (yup::jmax (1, maxBlockSize)); - scratch.assign (blockSize, 0.0f); - mix.assign (blockSize, 0.0f); + oscLeft.assign (blockSize, 0.0f); + oscRight.assign (blockSize, 0.0f); + mixLeft.assign (blockSize, 0.0f); + mixRight.assign (blockSize, 0.0f); } //============================================================================== @@ -388,29 +831,26 @@ class SynthVoice : public yup::SynthesiserVoice { noteFrequency = midiNoteToFrequency (midiNoteNumber); velocityGain = yup::jmax (0.05f, velocity); - releaseRequested = false; pitchWheelMoved (currentPitchWheelPosition); for (auto& oscillator : oscillators) oscillator.reset (0.0); - envelope.reset (getSampleRate(), SynthExample::envelopeRampSeconds); - envelope.setTargetValue (1.0f); + envelope.setParameters (envelopeSettings.read()); + envelope.noteOn(); } void stopNote (float, bool allowTailOff) override { - envelope.setTargetValue (0.0f); - - if (! allowTailOff) + if (allowTailOff) { - releaseRequested = false; - clearCurrentNote(); + envelope.noteOff(); return; } - releaseRequested = true; + envelope.noteOffImmediate(); + clearCurrentNote(); } void pitchWheelMoved (int newPitchWheelValue) override @@ -427,7 +867,7 @@ class SynthVoice : public yup::SynthesiserVoice if (! isVoiceActive() || numSamples <= 0) return; - jassert (numSamples <= static_cast (scratch.size())); + jassert (numSamples <= static_cast (mixLeft.size())); const auto frequency = noteFrequency * pitchWheelRatio; const auto numChannelsToWrite = yup::jmin (outputBuffer.getNumChannels(), 2); @@ -437,35 +877,54 @@ class SynthVoice : public yup::SynthesiserVoice for (int channel = 0; channel < numChannelsToWrite; ++channel) channels[channel] = outputBuffer.getWritePointer (channel, startSample); - yup::FloatVectorOperations::clear (mix.data(), numSamples); + yup::FloatVectorOperations::clear (mixLeft.data(), numSamples); + yup::FloatVectorOperations::clear (mixRight.data(), numSamples); for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - const auto values = settings[static_cast (index)].read(); + const auto& oscillatorSettings = settings[static_cast (index)]; + const auto values = oscillatorSettings.read(); auto& level = levels[static_cast (index)]; - yup::FloatVectorOperations::clear (scratch.data(), numSamples); - oscillators[static_cast (index)].renderBlock (scratch.data(), numSamples, values, frequency); + // The oscillator's own detune is what makes the two of them beat against each + // other, so it has to reach the frequency the backends are driven with. + const auto detuned = frequency * std::pow (2.0, static_cast (values.detuneSemitones) / 12.0); + + oscillators[static_cast (index)] + .renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, oscillatorSettings, detuned); level.setTargetValue (values.level); for (int sample = 0; sample < numSamples; ++sample) - mix[static_cast (sample)] += scratch[static_cast (sample)] * level.getNextValue(); + { + const auto gain = level.getNextValue(); + + mixLeft[static_cast (sample)] += oscLeft[static_cast (sample)] * gain; + mixRight[static_cast (sample)] += oscRight[static_cast (sample)] * gain; + } } + envelope.setParameters (envelopeSettings.read()); + for (int sample = 0; sample < numSamples; ++sample) { - const auto value = mix[static_cast (sample)] * velocityGain * envelope.getNextValue(); - - for (int channel = 0; channel < numChannelsToWrite; ++channel) - channels[channel][sample] += value; + const auto gain = envelope.getNextValue() * velocityGain; + const auto left = mixLeft[static_cast (sample)] * gain; + const auto right = mixRight[static_cast (sample)] * gain; + + if (numChannelsToWrite == 1) + { + channels[0][sample] += (left + right) * 0.5f; + } + else + { + channels[0][sample] += left; + channels[1][sample] += right; + } } - if (releaseRequested && envelope.getCurrentValue() <= 0.0f) - { - releaseRequested = false; + if (! envelope.isActive()) clearCurrentNote(); - } } private: @@ -479,19 +938,22 @@ class SynthVoice : public yup::SynthesiserVoice static constexpr double pitchWheelRangeSemitones = 2.0; const std::array& settings; + const SynthEnvelopeSettings& envelopeSettings; const SynthOscillatorResources& resources; std::array oscillators; std::array, SynthExample::oscillatorCount> levels; - yup::SmoothedValue envelope; - std::vector scratch; - std::vector mix; + SynthEnvelope envelope; + + std::vector oscLeft; + std::vector oscRight; + std::vector mixLeft; + std::vector mixRight; double noteFrequency = 440.0; double pitchWheelRatio = 1.0; float velocityGain = 1.0f; - bool releaseRequested = false; }; //============================================================================== @@ -505,7 +967,7 @@ class HarmonicSynthEngine : public yup::Synthesiser for (int index = 0; index < SynthExample::voiceCount; ++index) { - auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, resources)); + auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, envelopeSettings, resources)); addVoice (voice); ownedVoices.add (voice); @@ -527,6 +989,12 @@ class HarmonicSynthEngine : public yup::Synthesiser return settings[static_cast (oscillatorIndex)]; } + /** Returns the settings edited by the envelope panel. */ + SynthEnvelopeSettings& getEnvelopeSettings() noexcept { return envelopeSettings; } + + /** Returns the shared waveform presets, which the waveform displays also read. */ + const SynthOscillatorResources& getResources() const noexcept { return resources; } + /** Returns the note of a sounding voice, or -1 when the synthesiser is silent. */ int getCurrentlyPlayingNote() const noexcept { @@ -537,93 +1005,137 @@ class HarmonicSynthEngine : public yup::Synthesiser return -1; } + /** Returns how many voices are currently sounding. */ + int getNumActiveVoices() const noexcept + { + int count = 0; + + for (int index = 0; index < ownedVoices.size(); ++index) + if (ownedVoices[index] != nullptr && ownedVoices[index]->isVoiceActive()) + ++count; + + return count; + } + private: SynthOscillatorResources resources; std::array settings; + SynthEnvelopeSettings envelopeSettings; yup::ReferenceCountedArray ownedVoices; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HarmonicSynthEngine) }; //============================================================================== -/** Draws the most recent block of rendered audio as a waveform. */ -class Oscilloscope : public yup::Component +/** The palette the synthesiser panels share. + + The example draws its own chrome rather than leaning on the theme, so that the + oscillator, envelope and display panels read as one instrument. +*/ +namespace SynthTheme { -public: - Oscilloscope() - : Component ("Oscilloscope") - { - } +inline constexpr yup::Color windowBackground { 0xff16191d }; +inline constexpr yup::Color panelBackground { 0xff21262c }; +inline constexpr yup::Color panelBorder { 0xff2e353d }; +inline constexpr yup::Color displayBackground { 0xff0e1114 }; +inline constexpr yup::Color accent { 0xff4dc3ff }; +inline constexpr yup::Color accentDim { 0xff2b6f8f }; +inline constexpr yup::Color textPrimary { 0xffe6ebf0 }; +inline constexpr yup::Color textSecondary { 0xff8b96a0 }; + +constexpr float panelCorner = 6.0f; +} // namespace SynthTheme + +/** @internal Paints the rounded frame every panel of the instrument sits in. */ +inline void paintSynthPanel (yup::Graphics& g, yup::Rectangle bounds) +{ + g.setFillColor (SynthTheme::panelBackground); + g.fillRoundedRect (bounds, SynthTheme::panelCorner); - /** Copies the samples to display. Called from the message thread. */ - void setRenderData (const std::vector& data) - { - renderData = data; - } + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), SynthTheme::panelCorner); +} - void paint (yup::Graphics& g) override +//============================================================================== +/** A rotary knob with its caption underneath. */ +class KnobControl : public yup::Component +{ +public: + KnobControl (const yup::String& caption, + double minimum, + double maximum, + double interval, + double defaultValue, + const yup::Font& font) + : slider (yup::Slider::RotaryVerticalDrag) { - g.setFillColor (yup::Color (0xff101010)); - g.fillAll(); + setOpaque (false); // the knob and its caption paint themselves, the row draws nothing - if (renderData.empty()) - return; - - const auto lineColor = yup::Color (0xff4b4bff); - const auto xSize = getWidth() / static_cast (renderData.size()); - - path.clear(); - path.reserveSpace (static_cast (renderData.size())); - path.moveTo (0.0f, (renderData[0] + 1.0f) * 0.5f * getHeight()); + slider.setRange (minimum, maximum, interval); + slider.setDefaultValue (defaultValue); + slider.setValue (defaultValue, yup::dontSendNotification); + slider.setColor (yup::Slider::Style::backgroundColorId, SynthTheme::displayBackground); + slider.setColor (yup::Slider::Style::trackColorId, SynthTheme::accent); + slider.setColor (yup::Slider::Style::thumbColorId, SynthTheme::textPrimary); + slider.setColor (yup::Slider::Style::thumbOverColorId, SynthTheme::accent); + slider.setColor (yup::Slider::Style::thumbDownColorId, SynthTheme::accent); + slider.onValueChanged = [this] (double value) + { + if (onChange != nullptr) + onChange (value); + }; + addAndMakeVisible (slider); - for (std::size_t i = 1; i < renderData.size(); ++i) - path.lineTo (static_cast (i) * xSize, (renderData[i] + 1.0f) * 0.5f * getHeight()); + label.setText (caption, yup::dontSendNotification); + label.setFont (font); + label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (label); + } - filledPath = path.createStrokePolygon (4.0f); + /** Called with the new knob value. */ + std::function onChange; - g.setFillColor (lineColor); - g.setFeather (8.0f); - g.fillPath (filledPath); + yup::Slider& getSlider() noexcept { return slider; } - g.setFillColor (lineColor.brighter (0.2f)); - g.setFeather (4.0f); - g.fillPath (filledPath); + void resized() override + { + auto bounds = getLocalBounds(); - g.setStrokeColor (lineColor.withAlpha (0.8f)); - g.setStrokeWidth (2.0f); - g.strokePath (path); + label.setBounds (bounds.removeFromBottom (captionHeight)); - g.setStrokeColor (lineColor.brighter (0.3f)); - g.setStrokeWidth (1.0f); - g.strokePath (path); + const auto size = yup::jmin (bounds.getWidth(), bounds.getHeight()); - g.setStrokeColor (yup::Colors::white.withAlpha (0.9f)); - g.setStrokeWidth (0.5f); - g.strokePath (path); + slider.setBounds (bounds.withSizeKeepingCenter (size, size)); } private: - std::vector renderData; - yup::Path path; - yup::Path filledPath; + static constexpr float captionHeight = 13.0f; + + yup::Slider slider; + yup::Label label; }; //============================================================================== -/** A caption and a combo box laid out as a single row. */ -class LabeledComboBox : public yup::Component +/** A combo box with its caption above it. */ +class ChoiceControl : public yup::Component { public: - LabeledComboBox (const yup::String& caption, const yup::StringArray& items, const yup::Font& font) + ChoiceControl (const yup::String& caption, const yup::StringArray& items, const yup::Font& font) { - setOpaque (false); // the row draws nothing itself, only its caption and combo box do + setOpaque (false); // the caption and combo box paint themselves, the row draws nothing label.setText (caption, yup::dontSendNotification); label.setFont (font); - label.setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); + label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); addAndMakeVisible (label); comboBox.addItemList (items, 1); comboBox.setTextWhenNothingSelected ("-"); + comboBox.setColor (yup::ComboBox::Style::backgroundColorId, SynthTheme::displayBackground); + comboBox.setColor (yup::ComboBox::Style::textColorId, SynthTheme::textPrimary); + comboBox.setColor (yup::ComboBox::Style::borderColorId, SynthTheme::panelBorder); + comboBox.setColor (yup::ComboBox::Style::arrowColorId, SynthTheme::accent); comboBox.onSelectedItemChanged = [this] { if (onChange != nullptr) @@ -640,64 +1152,494 @@ class LabeledComboBox : public yup::Component void resized() override { auto bounds = getLocalBounds(); - label.setBounds (bounds.removeFromLeft (captionWidth())); + + label.setBounds (bounds.removeFromTop (captionHeight)); comboBox.setBounds (bounds); } private: - int captionWidth() const noexcept { return yup::jmax (28, (int) getWidth() / 3); } + static constexpr float captionHeight = 13.0f; yup::Label label; yup::ComboBox comboBox; }; //============================================================================== -/** A caption and a horizontal slider laid out as a single row. */ -class LabeledSlider : public yup::Component +/** The waveform of one oscillator, either drawn or edited a partial at a time. + + In drawing mode the component reconstructs the series and shows one period of it. + In editing mode it shows the magnitude of each harmonic as a bar that can be + dragged, which is what actually defines the waveform the oscillator renders. + + Dragging writes straight into the settings, but the generation counter the audio + thread watches is only bumped by commitPendingEdits(), once per user interface + frame. Without that, one drag would queue an inverse FFT per mouse event on every + sounding voice. + + @see SynthOscillatorSettings +*/ +class WaveformEditor : public yup::Component { public: - LabeledSlider (const yup::String& caption, - double minimum, - double maximum, - double interval, - double defaultValue, - const yup::Font& font) + WaveformEditor (SynthOscillatorSettings& settingsToEdit, const SynthOscillatorResources& sharedResources) + : settings (settingsToEdit) + , resources (sharedResources) { - setOpaque (false); // the row draws nothing itself, only its caption and slider do + displaySeries.resize (SynthExample::maxHarmonics); + displaySamples.assign (displayResolution, 0.0f); - label.setText (caption, yup::dontSendNotification); - label.setFont (font); - label.setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); - addAndMakeVisible (label); + refresh(); + } - slider.setRange (minimum, maximum, interval); - slider.setDefaultValue (defaultValue); - slider.setValue (defaultValue, yup::dontSendNotification); - slider.onValueChanged = [this] (double value) + /** Called whenever a drag changed the partials, so the panel can follow along. */ + std::function onPartialsChanged; + + /** Switches between drawing the waveform and editing its partials. */ + void setEditingPartials (bool shouldEdit) + { + editingPartials = shouldEdit; + repaint(); + } + + bool isEditingPartials() const noexcept { return editingPartials; } + + /** Rereads the series from the settings, following a preset or randomize change. */ + void refresh() + { + const auto usesCustomSeries = settings.usesCustomSeries.load(); + + if (usesCustomSeries) + settings.copyHarmonicsInto (displaySeries, 1.0f); + else + displaySeries.copyFrom (resources.getFrame (static_cast (settings.waveform.load()))); + + for (int index = 0; index < displayResolution; ++index) { - if (onChange != nullptr) - onChange (value); + const auto phase = static_cast (index) / static_cast (displayResolution - 1); + + displaySamples[static_cast (index)] = + evaluateFourierSeries (displaySeries, phase, SynthExample::displayHarmonics); + } + + auto peak = 0.0f; + + for (auto sample : displaySamples) + peak = yup::jmax (peak, std::abs (sample)); + + if (peak <= 1.0e-6f) + { + repaint(); + return; + } + + // The reconstruction is measured here, on the message thread, so the audio thread + // never has to work out how loud an edited spectrum turned out to be. + if (usesCustomSeries) + settings.harmonicScale.store (1.0f / peak); + + const auto scale = 1.0f / peak; + + for (auto& sample : displaySamples) + sample *= scale; + + repaint(); + } + + /** Publishes a pending drag to the audio thread, coalescing a frame's worth of edits. */ + void commitPendingEdits() + { + if (! pendingEdit) + return; + + pendingEdit = false; + settings.harmonicGeneration.fetch_add (1); + } + + /** Drops the edited partials and returns to the selected waveform preset. */ + void revertToPreset() + { + settings.usesCustomSeries.store (false); + pendingEdit = true; + + refresh(); + + if (onPartialsChanged != nullptr) + onPartialsChanged(); + } + + //============================================================================== + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + + if (editingPartials) + paintPartials (g, bounds.reduced (contentInset)); + else + paintWaveform (g, bounds.reduced (contentInset)); + } + + void mouseDown (const yup::MouseEvent& event) override { applyEdit (event); } + + void mouseDrag (const yup::MouseEvent& event) override { applyEdit (event); } + +private: + //============================================================================== + /** Draws one period of the reconstructed series. */ + void paintWaveform (yup::Graphics& g, yup::Rectangle bounds) + { + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + + path.clear(); + path.reserveSpace (displayResolution); + + for (int index = 0; index < displayResolution; ++index) + { + const auto x = bounds.getX() + bounds.getWidth() * static_cast (index) + / static_cast (displayResolution - 1); + + const auto y = bounds.getCenterY() - displaySamples[static_cast (index)] * bounds.getHeight() * 0.45f; + + if (index == 0) + path.moveTo (x, y); + else + path.lineTo (x, y); + } + + g.setStrokeColor (SynthTheme::accent.withAlpha (0.35f)); + g.setStrokeWidth (4.0f); + g.setFeather (6.0f); + g.strokePath (path); + + g.setFeather (0.0f); + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + } + + /** Draws the editable magnitude of every harmonic. */ + void paintPartials (yup::Graphics& g, yup::Rectangle bounds) + { + const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); + + for (int index = 0; index < SynthExample::editableHarmonics; ++index) + { + const auto magnitude = yup::jlimit (0.0f, 1.0f, settings.harmonics[static_cast (index)].load()); + const auto height = yup::jmax (1.0f, magnitude * bounds.getHeight()); + const auto x = bounds.getX() + barWidth * static_cast (index); + + const yup::Rectangle bar { x + barGap, bounds.getBottom() - height, yup::jmax (1.0f, barWidth - barGap * 2.0f), height }; + + g.setFillColor (magnitude > 0.0f ? SynthTheme::accent : SynthTheme::panelBorder); + g.fillRect (bar); + } + } + + //============================================================================== + /** Turns a mouse position into the magnitude of one harmonic. */ + void applyEdit (const yup::MouseEvent& event) + { + if (! editingPartials) + return; + + const auto bounds = getLocalBounds().reduced (contentInset); + + if (bounds.getWidth() <= 0.0f || bounds.getHeight() <= 0.0f) + return; + + // Editing a preset copies its partials in first, so the drag starts from the + // shape that is on screen instead of from silence. + if (! settings.usesCustomSeries.load()) + { + settings.seedHarmonicsFrom (displaySeries); + settings.usesCustomSeries.store (true); + } + + const auto position = event.getPosition(); + const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); + const auto index = yup::jlimit (0, + SynthExample::editableHarmonics - 1, + static_cast ((position.getX() - bounds.getX()) / barWidth)); + + const auto magnitude = yup::jlimit (0.0f, 1.0f, (bounds.getBottom() - position.getY()) / bounds.getHeight()); + + settings.harmonics[static_cast (index)].store (magnitude); + pendingEdit = true; + + refresh(); + + if (onPartialsChanged != nullptr) + onPartialsChanged(); + } + + //============================================================================== + static constexpr int displayResolution = 256; + static constexpr float contentInset = 6.0f; + static constexpr float barGap = 1.0f; + + SynthOscillatorSettings& settings; + const SynthOscillatorResources& resources; + + yup::FourierSeries displaySeries; + std::vector displaySamples; + yup::Path path; + + bool editingPartials = false; + bool pendingEdit = false; +}; + +//============================================================================== +/** Draws the most recent block of rendered audio as a waveform. */ +class Oscilloscope : public yup::Component +{ +public: + Oscilloscope() + : Component ("Oscilloscope") + { + } + + /** Copies the samples to display. Called from the message thread. */ + void setRenderData (const std::vector& data) + { + renderData = data; + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + + if (renderData.empty()) + return; + + const auto xSize = bounds.getWidth() / static_cast (renderData.size()); + + path.clear(); + path.reserveSpace (static_cast (renderData.size())); + path.moveTo (bounds.getX(), bounds.getCenterY() - renderData[0] * bounds.getHeight() * 0.45f); + + for (std::size_t i = 1; i < renderData.size(); ++i) + path.lineTo (bounds.getX() + static_cast (i) * xSize, + bounds.getCenterY() - renderData[i] * bounds.getHeight() * 0.45f); + + filledPath = path.createStrokePolygon (4.0f); + + g.setFillColor (SynthTheme::accent.withAlpha (0.5f)); + g.setFeather (8.0f); + g.fillPath (filledPath); + + g.setFeather (4.0f); + g.fillPath (filledPath); + + g.setFeather (0.0f); + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + } + +private: + std::vector renderData; + yup::Path path; + yup::Path filledPath; +}; + +//============================================================================== +/** @internal Spreads controls evenly across a row. */ +inline void layoutControlsInRow (yup::Rectangle area, const std::vector& controls) +{ + if (controls.empty()) + return; + + const auto width = area.getWidth() / static_cast (controls.size()); + + for (auto* control : controls) + control->setBounds (area.removeFromLeft (width).reduced (3.0f, 0.0f)); +} + +//============================================================================== +/** Draws the shape the amplitude envelope traces, with a point at every breakpoint. */ +class EnvelopeDisplay : public yup::Component +{ +public: + /** Updates the drawn shape. Called from the message thread. */ + void setValues (const SynthEnvelopeValues& newValues) + { + values = newValues; + repaint(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + + const auto area = bounds.reduced (8.0f); + + if (area.getWidth() <= 0.0f || area.getHeight() <= 0.0f) + return; + + // The sustain stage has no duration of its own, so it is given a fixed share of + // the width and the timed stages share what is left. + const auto totalSeconds = yup::jmax (1.0e-4f, values.delay + values.attack + values.hold + values.decay + values.release); + const auto timedWidth = area.getWidth() * (1.0f - sustainShare); + const auto secondsToPixels = timedWidth / totalSeconds; + + const auto levelToY = [area] (float level) + { + return area.getBottom() - yup::jlimit (0.0f, 1.0f, level) * area.getHeight(); }; - addAndMakeVisible (slider); + + constexpr int numPoints = 7; + + const yup::Point points[numPoints] = { + { area.getX(), levelToY (0.0f) }, + { area.getX() + values.delay * secondsToPixels, levelToY (0.0f) }, + { area.getX() + (values.delay + values.attack) * secondsToPixels, levelToY (1.0f) }, + { area.getX() + (values.delay + values.attack + values.hold) * secondsToPixels, levelToY (1.0f) }, + { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels, levelToY (values.sustain) }, + { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels + area.getWidth() * sustainShare, levelToY (values.sustain) }, + { area.getRight(), levelToY (0.0f) } + }; + + path.clear(); + path.reserveSpace (numPoints + 2); + path.moveTo (points[0]); + + for (int index = 1; index < numPoints; ++index) + path.lineTo (points[index]); + + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + + for (int index = 1; index < numPoints - 1; ++index) + { + g.setFillColor (SynthTheme::accent); + g.fillEllipse (yup::Rectangle (points[index].getX() - pointRadius, + points[index].getY() - pointRadius, + pointRadius * 2.0f, + pointRadius * 2.0f)); + } } - /** Called with the new slider value. */ - std::function onChange; +private: + static constexpr float sustainShare = 0.22f; + static constexpr float pointRadius = 3.0f; - yup::Slider& getSlider() noexcept { return slider; } + SynthEnvelopeValues values; + yup::Path path; +}; + +//============================================================================== +/** The editing surface of the amplitude envelope. + + @see SynthEnvelopeSettings +*/ +class SynthEnvelopePanel : public yup::Component +{ +public: + SynthEnvelopePanel (SynthEnvelopeSettings& settingsToEdit, const yup::Font& font) + : settings (settingsToEdit) + , delayKnob ("DELAY", 0.0, 2.0, 0.001, 0.0, font) + , attackKnob ("ATTACK", 0.001, 4.0, 0.001, 0.005, font) + , holdKnob ("HOLD", 0.0, 2.0, 0.001, 0.0, font) + , decayKnob ("DECAY", 0.001, 4.0, 0.001, 0.35, font) + , sustainKnob ("SUSTAIN", 0.0, 1.0, 0.001, 0.7, font) + , releaseKnob ("RELEASE", 0.001, 8.0, 0.001, 0.35, font) + { + titleLabel.setText ("ENVELOPE", yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + addAndMakeVisible (display); + + for (auto* knob : { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }) + addAndMakeVisible (*knob); + + delayKnob.onChange = [this] (double value) { settings.delay = static_cast (value); refreshDisplay(); }; + attackKnob.onChange = [this] (double value) { settings.attack = static_cast (value); refreshDisplay(); }; + holdKnob.onChange = [this] (double value) { settings.hold = static_cast (value); refreshDisplay(); }; + decayKnob.onChange = [this] (double value) { settings.decay = static_cast (value); refreshDisplay(); }; + sustainKnob.onChange = [this] (double value) { settings.sustain = static_cast (value); refreshDisplay(); }; + releaseKnob.onChange = [this] (double value) { settings.release = static_cast (value); refreshDisplay(); }; + + refresh(); + } + + /** Reads the settings back into the knobs and the drawn shape. */ + void refresh() + { + delayKnob.getSlider().setValue (settings.delay.load(), yup::dontSendNotification); + attackKnob.getSlider().setValue (settings.attack.load(), yup::dontSendNotification); + holdKnob.getSlider().setValue (settings.hold.load(), yup::dontSendNotification); + decayKnob.getSlider().setValue (settings.decay.load(), yup::dontSendNotification); + sustainKnob.getSlider().setValue (settings.sustain.load(), yup::dontSendNotification); + releaseKnob.getSlider().setValue (settings.release.load(), yup::dontSendNotification); + + refreshDisplay(); + } void resized() override { - auto bounds = getLocalBounds(); - label.setBounds (bounds.removeFromLeft (captionWidth())); - slider.setBounds (bounds); + auto bounds = getLocalBounds().reduced (panelInset); + + titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + bounds.removeFromTop (spacing); + + auto knobArea = bounds.removeFromBottom (knobRowHeight); + bounds.removeFromBottom (spacing); + + display.setBounds (bounds); + + layoutControlsInRow (knobArea, { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }); + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); } private: - int captionWidth() const noexcept { return yup::jmax (28, (int) getWidth() / 3); } + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 16.0f; + static constexpr float knobRowHeight = 58.0f; + static constexpr float spacing = 6.0f; - yup::Label label; - yup::Slider slider { yup::Slider::LinearHorizontal }; + void refreshDisplay() { display.setValues (settings.read()); } + + SynthEnvelopeSettings& settings; + + yup::Label titleLabel; + EnvelopeDisplay display; + + KnobControl delayKnob; + KnobControl attackKnob; + KnobControl holdKnob; + KnobControl decayKnob; + KnobControl sustainKnob; + KnobControl releaseKnob; }; //============================================================================== @@ -707,59 +1649,88 @@ class LabeledSlider : public yup::Component settings back into the widgets for changes coming from somewhere else, such as the randomize button. - @see SynthOscillatorSettings + @see SynthOscillatorSettings, WaveformEditor */ class SynthOscillatorPanel : public yup::Component { public: - SynthOscillatorPanel (const yup::String& panelTitle, SynthOscillatorSettings& settingsToEdit, const yup::Font& font) + SynthOscillatorPanel (const yup::String& panelTitle, + SynthOscillatorSettings& settingsToEdit, + const SynthOscillatorResources& resources, + const yup::Font& font) : settings (settingsToEdit) - , algorithmRow ("Algorithm", getSynthOscillatorTypeNames(), font) - , waveformRow ("Waveform", getSynthWaveformNames(), font) - , shapeRow ("Shape B", getSynthWaveformNames(), font) - , syncModeRow ("Sync Mode", getSynthSyncModeNames(), font) - , levelRow ("Level", 0.0, 1.0, 0.001, 0.5, font) - , detuneRow ("Detune", -24.0, 24.0, 0.1, 0.0, font) - , ratioRow ("Sync Ratio", 0.25, 8.0, 0.01, 1.5, font) - , morphRow ("Morph", 0.0, 1.0, 0.001, 0.0, font) - , distortionRow ("Distortion", 0.01, 0.99, 0.001, 0.5, font) - , fmAmountRow ("FM Amount", 0.0, 4.0, 0.001, 0.0, font) - , fmRatioRow ("FM Ratio", 0.25, 8.0, 0.01, 2.0, font) + , editor (settingsToEdit, resources) + , algorithmChoice ("ALGORITHM", getSynthOscillatorTypeNames(), font) + , waveformChoice ("WAVEFORM", getSynthWaveformNames(), font) + , shapeChoice ("SHAPE B", getSynthWaveformNames(), font) + , syncModeChoice ("SYNC", getSynthSyncModeNames(), font) + , levelKnob ("LEVEL", 0.0, 1.0, 0.001, 0.5, font) + , detuneKnob ("DETUNE", -24.0, 24.0, 0.01, 0.0, font) + , ratioKnob ("RATIO", 0.25, 8.0, 0.01, 1.5, font) + , morphKnob ("MORPH", 0.0, 1.0, 0.001, 0.0, font) + , distortionKnob ("DIST", 0.01, 0.99, 0.001, 0.5, font) + , fmAmountKnob ("FM AMT", 0.0, 4.0, 0.001, 0.0, font) + , fmRatioKnob ("FM RATIO", 0.25, 8.0, 0.01, 2.0, font) + , unisonKnob ("UNISON", 1.0, static_cast (SynthExample::maxUnisonVoices), 1.0, 1.0, font) + , unisonDetuneKnob ("U.DETUNE", 0.0, 1.0, 0.001, 0.2, font) + , spreadKnob ("SPREAD", 0.0, 1.0, 0.001, 0.6, font) { titleLabel.setText (panelTitle, yup::dontSendNotification); titleLabel.setFont (font.withHeight (12.0f)); - titleLabel.setColor (yup::Label::Style::textFillColorId, yup::Colors::white); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); addAndMakeVisible (titleLabel); - const auto addRow = [this] (yup::Component& row) + partialsButton.setButtonText ("PARTIALS"); + partialsButton.setColor (yup::ToggleButton::Style::backgroundColorId, SynthTheme::displayBackground); + partialsButton.setColor (yup::ToggleButton::Style::backgroundToggledColorId, SynthTheme::accentDim); + partialsButton.setColor (yup::ToggleButton::Style::textColorId, SynthTheme::textSecondary); + partialsButton.setColor (yup::ToggleButton::Style::textToggledColorId, SynthTheme::textPrimary); + partialsButton.setColor (yup::ToggleButton::Style::borderColorId, SynthTheme::panelBorder); + partialsButton.setColor (yup::ToggleButton::Style::borderToggledColorId, SynthTheme::accent); + partialsButton.onClick = [this] { editor.setEditingPartials (partialsButton.getToggleState()); }; + addAndMakeVisible (partialsButton); + + resetButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::displayBackground); + resetButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textSecondary); + resetButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); + resetButton.onClick = [this] { editor.revertToPreset(); }; + addAndMakeVisible (resetButton); + + addAndMakeVisible (editor); + + for (auto* choice : { &algorithmChoice, &waveformChoice, &shapeChoice, &syncModeChoice }) + addAndMakeVisible (*choice); + + for (auto* knob : { &levelKnob, &detuneKnob, &ratioKnob, &morphKnob, &distortionKnob, + &fmAmountKnob, &fmRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }) + addAndMakeVisible (*knob); + + algorithmChoice.onChange = [this] (int id) { - addAndMakeVisible (row); - rows.push_back (&row); + settings.type = id - 1; + updateUnisonAvailability(); }; - addRow (algorithmRow); - addRow (waveformRow); - addRow (shapeRow); - addRow (syncModeRow); - addRow (levelRow); - addRow (detuneRow); - addRow (ratioRow); - addRow (morphRow); - addRow (distortionRow); - addRow (fmAmountRow); - addRow (fmRatioRow); - - algorithmRow.onChange = [this] (int id) { settings.type = id - 1; }; - waveformRow.onChange = [this] (int id) { settings.waveform = id - 1; }; - shapeRow.onChange = [this] (int id) { settings.shape = id - 1; }; - syncModeRow.onChange = [this] (int id) { settings.syncMode = id - 1; }; - levelRow.onChange = [this] (double value) { settings.level = static_cast (value); }; - detuneRow.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value); }; - ratioRow.onChange = [this] (double value) { settings.followerRatio = static_cast (value); }; - morphRow.onChange = [this] (double value) { settings.morph = static_cast (value); }; - distortionRow.onChange = [this] (double value) { settings.phaseDistortion = static_cast (value); }; - fmAmountRow.onChange = [this] (double value) { settings.fmAmount = static_cast (value); }; - fmRatioRow.onChange = [this] (double value) { settings.fmRatio = static_cast (value); }; + // Picking a preset drops any edited partials, otherwise the oscillator would keep + // playing the edited shape while the combo box claims something else. + waveformChoice.onChange = [this] (int id) + { + settings.waveform = id - 1; + editor.revertToPreset(); + }; + + shapeChoice.onChange = [this] (int id) { settings.shape = id - 1; }; + syncModeChoice.onChange = [this] (int id) { settings.syncMode = id - 1; }; + levelKnob.onChange = [this] (double value) { settings.level = static_cast (value); }; + detuneKnob.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value); }; + ratioKnob.onChange = [this] (double value) { settings.followerRatio = static_cast (value); }; + morphKnob.onChange = [this] (double value) { settings.morph = static_cast (value); }; + distortionKnob.onChange = [this] (double value) { settings.phaseDistortion = static_cast (value); }; + fmAmountKnob.onChange = [this] (double value) { settings.fmAmount = static_cast (value); }; + fmRatioKnob.onChange = [this] (double value) { settings.fmRatio = static_cast (value); }; + unisonKnob.onChange = [this] (double value) { settings.unisonVoices = static_cast (value); }; + unisonDetuneKnob.onChange = [this] (double value) { settings.unisonDetune = static_cast (value); }; + spreadKnob.onChange = [this] (double value) { settings.unisonSpread = static_cast (value); }; refresh(); } @@ -767,59 +1738,108 @@ class SynthOscillatorPanel : public yup::Component /** Reads the settings back into the widgets. */ void refresh() { - algorithmRow.getComboBox().setSelectedId (settings.type.load() + 1, yup::dontSendNotification); - waveformRow.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); - shapeRow.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); - syncModeRow.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); - - levelRow.getSlider().setValue (settings.level.load(), yup::dontSendNotification); - detuneRow.getSlider().setValue (settings.detuneSemitones.load(), yup::dontSendNotification); - ratioRow.getSlider().setValue (settings.followerRatio.load(), yup::dontSendNotification); - morphRow.getSlider().setValue (settings.morph.load(), yup::dontSendNotification); - distortionRow.getSlider().setValue (settings.phaseDistortion.load(), yup::dontSendNotification); - fmAmountRow.getSlider().setValue (settings.fmAmount.load(), yup::dontSendNotification); - fmRatioRow.getSlider().setValue (settings.fmRatio.load(), yup::dontSendNotification); + algorithmChoice.getComboBox().setSelectedId (settings.type.load() + 1, yup::dontSendNotification); + waveformChoice.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); + shapeChoice.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); + syncModeChoice.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); + + levelKnob.getSlider().setValue (settings.level.load(), yup::dontSendNotification); + detuneKnob.getSlider().setValue (settings.detuneSemitones.load(), yup::dontSendNotification); + ratioKnob.getSlider().setValue (settings.followerRatio.load(), yup::dontSendNotification); + morphKnob.getSlider().setValue (settings.morph.load(), yup::dontSendNotification); + distortionKnob.getSlider().setValue (settings.phaseDistortion.load(), yup::dontSendNotification); + fmAmountKnob.getSlider().setValue (settings.fmAmount.load(), yup::dontSendNotification); + fmRatioKnob.getSlider().setValue (settings.fmRatio.load(), yup::dontSendNotification); + unisonKnob.getSlider().setValue (settings.unisonVoices.load(), yup::dontSendNotification); + unisonDetuneKnob.getSlider().setValue (settings.unisonDetune.load(), yup::dontSendNotification); + spreadKnob.getSlider().setValue (settings.unisonSpread.load(), yup::dontSendNotification); + + updateUnisonAvailability(); + + editor.refresh(); } + /** Publishes a frame's worth of partial edits to the audio thread. */ + void commitPendingEdits() { editor.commitPendingEdits(); } + void resized() override { - auto bounds = getLocalBounds().reduced (4); - titleLabel.setBounds (bounds.removeFromTop (titleHeight())); + auto bounds = getLocalBounds().reduced (panelInset); - if (rows.empty()) - return; + auto header = bounds.removeFromTop (headerHeight); + resetButton.setBounds (header.removeFromRight (buttonWidth)); + header.removeFromRight (spacing); + partialsButton.setBounds (header.removeFromRight (buttonWidth)); + titleLabel.setBounds (header); + + bounds.removeFromTop (spacing); + + auto knobArea = bounds.removeFromBottom (knobRowHeight * 2.0f + spacing); + bounds.removeFromBottom (spacing); - const auto rowHeight = bounds.getHeight() / static_cast (rows.size()); + auto choiceArea = bounds.removeFromBottom (choiceRowHeight); + bounds.removeFromBottom (spacing); - for (auto* row : rows) - row->setBounds (bounds.removeFromTop (rowHeight)); + editor.setBounds (bounds); + + layoutControlsInRow (choiceArea, { &algorithmChoice, &waveformChoice, &shapeChoice, &syncModeChoice }); + + layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), + { &levelKnob, &detuneKnob, &ratioKnob, &morphKnob, &distortionKnob }); + + knobArea.removeFromTop (spacing); + + layoutControlsInRow (knobArea, + { &fmAmountKnob, &fmRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }); } void paint (yup::Graphics& g) override { - g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray).darker (0.4f)); - g.fillAll(); + paintSynthPanel (g, getLocalBounds()); } private: - int titleHeight() const noexcept { return 18; } + //============================================================================== + /** Greys out the unison knobs for the algorithms the satellites cannot reproduce. */ + void updateUnisonAvailability() + { + const auto supported = SynthOscillator::supportsUnison (static_cast (settings.type.load())); + + unisonKnob.setEnabled (supported); + unisonDetuneKnob.setEnabled (supported); + spreadKnob.setEnabled (supported); + } + + //============================================================================== + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 18.0f; + static constexpr float choiceRowHeight = 36.0f; + static constexpr float knobRowHeight = 58.0f; + static constexpr float buttonWidth = 68.0f; + static constexpr float spacing = 6.0f; SynthOscillatorSettings& settings; yup::Label titleLabel; - std::vector rows; - - LabeledComboBox algorithmRow; - LabeledComboBox waveformRow; - LabeledComboBox shapeRow; - LabeledComboBox syncModeRow; - LabeledSlider levelRow; - LabeledSlider detuneRow; - LabeledSlider ratioRow; - LabeledSlider morphRow; - LabeledSlider distortionRow; - LabeledSlider fmAmountRow; - LabeledSlider fmRatioRow; + yup::ToggleButton partialsButton; + yup::TextButton resetButton { "RESET" }; + WaveformEditor editor; + + ChoiceControl algorithmChoice; + ChoiceControl waveformChoice; + ChoiceControl shapeChoice; + ChoiceControl syncModeChoice; + + KnobControl levelKnob; + KnobControl detuneKnob; + KnobControl ratioKnob; + KnobControl morphKnob; + KnobControl distortionKnob; + KnobControl fmAmountKnob; + KnobControl fmRatioKnob; + KnobControl unisonKnob; + KnobControl unisonDetuneKnob; + KnobControl spreadKnob; }; //============================================================================== @@ -840,57 +1860,64 @@ class AudioExample keyboardComponent.setLowestVisibleKey (48); // Start from C3 keyboardComponent.setMidiChannel (1); keyboardComponent.setVelocity (0.7f); + keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::whiteKeyColorId, yup::Color (0xffd7dde3)); + keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::whiteKeyPressedColorId, SynthTheme::accent); + keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyColorId, yup::Color (0xff191d21)); + keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyPressedColorId, SynthTheme::accentDim); + keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::keyOutlineColorId, SynthTheme::panelBorder); addAndMakeVisible (keyboardComponent); - titleLabel = std::make_unique ("Title"); - titleLabel->setText ("YUP Polyphonic Oscillator Synthesizer"); - titleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); - addAndMakeVisible (*titleLabel); + const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); - subtitleLabel = std::make_unique ("Subtitle"); - subtitleLabel->setText ("Eight voices, two oscillators each - play the keyboard and shape both oscillators of the sound"); - subtitleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); - addAndMakeVisible (*subtitleLabel); + titleLabel.setText ("YUP POLYPHONIC SYNTHESIZER", yup::dontSendNotification); + titleLabel.setFont (font.withHeight (17.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); - noteIndicatorLabel = std::make_unique ("NoteIndicator"); - noteIndicatorLabel->setText (""); - noteIndicatorLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::black); - noteIndicatorLabel->setColor (yup::Label::Style::backgroundColorId, yup::Colors::yellow.withAlpha (0.8f)); - addChildComponent (*noteIndicatorLabel); + subtitleLabel.setText ("Two unison oscillators per voice - draw the waveform or edit its partials", yup::dontSendNotification); + subtitleLabel.setFont (font.withHeight (11.0f)); + subtitleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (subtitleLabel); - const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); + voiceLabel.setText ("", yup::dontSendNotification); + voiceLabel.setFont (font.withHeight (11.0f)); + voiceLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::accent); + addAndMakeVisible (voiceLabel); for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - oscillatorPanels[static_cast (index)] = std::make_unique ( - yup::String ("Oscillator ") + yup::String (index + 1), + auto panel = std::make_unique ( + yup::String ("OSC ") + yup::String (index + 1), synth.getOscillatorSettings (index), - font.withHeight (11.0f)); + synth.getResources(), + font.withHeight (10.0f)); - addAndMakeVisible (*oscillatorPanels[static_cast (index)]); + addAndMakeVisible (*panel); + oscillatorPanels[static_cast (index)] = std::move (panel); } - randomizeButton = std::make_unique ("Randomize"); - randomizeButton->onClick = [this] { randomizeOscillators(); }; - addAndMakeVisible (*randomizeButton); + envelopePanel = std::make_unique (synth.getEnvelopeSettings(), font.withHeight (10.0f)); + addAndMakeVisible (*envelopePanel); - clearButton = std::make_unique ("All Notes Off"); - clearButton->onClick = [this] + randomizeButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::panelBackground); + randomizeButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textPrimary); + randomizeButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); + randomizeButton.onClick = [this] { randomizeOscillators(); }; + addAndMakeVisible (randomizeButton); + + clearButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::panelBackground); + clearButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textPrimary); + clearButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); + clearButton.onClick = [this] { keyboardState.allNotesOff (0); // Turn off all notes on all channels synth.allNotesOff (0, true); }; - addAndMakeVisible (*clearButton); + addAndMakeVisible (clearButton); - volumeSlider = std::make_unique (yup::Slider::LinearHorizontal, "Volume"); - volumeSlider->setRange (0.0, 1.0, 0.001); - volumeSlider->setDefaultValue (0.5); - volumeSlider->onValueChanged = [this] (double value) - { - masterVolume = static_cast (value); - }; - volumeSlider->setValue (0.5, yup::dontSendNotification); - addAndMakeVisible (*volumeSlider); + volumeKnob = std::make_unique ("VOLUME", 0.0, 1.0, 0.001, 0.5, font.withHeight (10.0f)); + volumeKnob->onChange = [this] (double value) { masterVolume = static_cast (value); }; + addAndMakeVisible (*volumeKnob); addAndMakeVisible (oscilloscope); } @@ -903,39 +1930,49 @@ class AudioExample void resized() override { - auto bounds = getLocalBounds(); + auto bounds = getLocalBounds().reduced (outerInset); - titleLabel->setBounds (bounds.removeFromTop (proportionOfHeight (0.05f))); - subtitleLabel->setBounds (bounds.removeFromTop (proportionOfHeight (0.03f))); + auto header = bounds.removeFromTop (headerHeight); - keyboardComponent.setBounds (bounds.removeFromBottom (proportionOfHeight (0.20f)) - .reduced (proportionOfWidth (0.02f), proportionOfHeight (0.01f))); + volumeKnob->setBounds (header.removeFromRight (64.0f)); + header.removeFromRight (spacing); + clearButton.setBounds (header.removeFromRight (110.0f).reduced (0.0f, 14.0f)); + header.removeFromRight (spacing); + randomizeButton.setBounds (header.removeFromRight (110.0f).reduced (0.0f, 14.0f)); + header.removeFromRight (spacing * 2.0f); + voiceLabel.setBounds (header.removeFromRight (110.0f)); - oscilloscope.setBounds (bounds.removeFromBottom (proportionOfHeight (0.20f)) - .reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + titleLabel.setBounds (header.removeFromTop (header.getHeight() * 0.5f)); + subtitleLabel.setBounds (header); - auto buttonArea = bounds.removeFromBottom (proportionOfHeight (0.08f)); + bounds.removeFromTop (spacing); - const auto buttonWidth = buttonArea.getWidth() / 3; - const auto buttonInsetX = proportionOfWidth (0.01f); - const auto buttonInsetY = proportionOfHeight (0.01f); + keyboardComponent.setBounds (bounds.removeFromBottom (proportionOfHeight (0.19f))); - randomizeButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); - clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); - volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (buttonInsetX, buttonInsetY)); + bounds.removeFromBottom (spacing); - const auto panelWidth = bounds.getWidth() / SynthExample::oscillatorCount; + auto rightColumn = bounds.removeFromRight (bounds.getWidth() * 0.42f); + bounds.removeFromRight (spacing); + + envelopePanel->setBounds (rightColumn.removeFromTop (rightColumn.getHeight() * 0.5f)); + rightColumn.removeFromTop (spacing); + oscilloscope.setBounds (rightColumn); + + const auto panelHeight = (bounds.getHeight() - spacing) / static_cast (SynthExample::oscillatorCount); for (auto& panel : oscillatorPanels) - if (panel != nullptr) - panel->setBounds (bounds.removeFromLeft (panelWidth).reduced (6)); + { + if (panel == nullptr) + continue; - noteIndicatorLabel->setBounds (yup::Rectangle (10, getHeight() - 40, 200, 30)); + panel->setBounds (bounds.removeFromTop (panelHeight)); + bounds.removeFromTop (spacing); + } } void paint (yup::Graphics& g) override { - g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray)); + g.setFillColor (SynthTheme::windowBackground); g.fillAll(); } @@ -954,18 +1991,15 @@ class AudioExample if (oscilloscope.isVisible()) oscilloscope.repaint(); - const auto playingNote = synth.getCurrentlyPlayingNote(); + // One generation bump per frame, however many mouse events the drag produced. + for (auto& panel : oscillatorPanels) + if (panel != nullptr) + panel->commitPendingEdits(); - if (playingNote >= 0) - { - noteIndicatorLabel->setText (yup::String ("Playing Note: ") + yup::String (playingNote), - yup::dontSendNotification); - noteIndicatorLabel->setVisible (true); - } - else - { - noteIndicatorLabel->setVisible (false); - } + const auto activeVoices = synth.getNumActiveVoices(); + + voiceLabel.setText (activeVoices > 0 ? yup::String (activeVoices) + " VOICES" : yup::String(), + yup::dontSendNotification); } void audioDeviceAboutToStart (yup::AudioIODevice* device) override @@ -1044,23 +2078,37 @@ class AudioExample { auto& settings = synth.getOscillatorSettings (index); - settings.type = random.nextInt (5); + settings.type = random.nextInt (4); settings.waveform = random.nextInt (6); settings.shape = random.nextInt (6); settings.syncMode = random.nextInt (4); settings.level = 0.2f + random.nextFloat() * 0.8f; - settings.detuneSemitones = random.nextFloat() * 24.0f - 12.0f; + // The knob still reaches two octaves for deliberate stacking, but randomizing + // that far apart just sounds out of tune, so this stays within a beating range. + settings.detuneSemitones = random.nextFloat() - 0.5f; settings.followerRatio = 0.5f + random.nextFloat() * 3.0f; settings.morph = random.nextFloat(); settings.phaseDistortion = 0.1f + random.nextFloat() * 0.8f; settings.fmAmount = random.nextFloat() * 2.0f; settings.fmRatio = 0.5f + random.nextFloat() * 3.0f; + settings.unisonVoices = 1 + random.nextInt (SynthExample::maxUnisonVoices); + settings.unisonDetune = random.nextFloat() * 0.5f; + settings.unisonSpread = random.nextFloat(); + + // A randomized waveform is only audible once the edited partials are dropped. + settings.usesCustomSeries = false; + settings.harmonicGeneration.fetch_add (1); if (auto& panel = oscillatorPanels[static_cast (index)]; panel != nullptr) panel->refresh(); } } + //============================================================================== + static constexpr float outerInset = 10.0f; + static constexpr float headerHeight = 44.0f; + static constexpr float spacing = 8.0f; + //============================================================================== yup::AudioDeviceManager deviceManager; HarmonicSynthEngine synth; @@ -1076,15 +2124,16 @@ class AudioExample yup::CriticalSection renderMutex; // UI Components - std::unique_ptr titleLabel; - std::unique_ptr subtitleLabel; - std::unique_ptr noteIndicatorLabel; + yup::Label titleLabel; + yup::Label subtitleLabel; + yup::Label voiceLabel; std::array, SynthExample::oscillatorCount> oscillatorPanels; + std::unique_ptr envelopePanel; - std::unique_ptr randomizeButton; - std::unique_ptr clearButton; - std::unique_ptr volumeSlider; + yup::TextButton randomizeButton { "RANDOMIZE" }; + yup::TextButton clearButton { "ALL NOTES OFF" }; + std::unique_ptr volumeKnob; Oscilloscope oscilloscope; std::atomic masterVolume { 0.5f }; From 608b986ef4d6ee1454aa740efa5cf2b16be23d44 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 11:27:42 +0200 Subject: [PATCH 05/37] More oversampling fun --- CHANGELOG.md | 1 + docs/dsp/resampling.md | 47 +- .../source/examples/SpectrumAnalyzer.h | 218 ++++++--- modules/yup_dsp/resampling/yup_Oversampler.h | 260 +++++------ modules/yup_dsp/resampling/yup_SincTable.h | 17 +- modules/yup_dsp/utilities/yup_DspMath.cpp | 6 + modules/yup_dsp/utilities/yup_DspMath.h | 4 + tests/yup_dsp/yup_Oversampler.cpp | 436 ++++++++++++++++++ 8 files changed, 763 insertions(+), 226 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4c732f7..8396e420e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Audio +- Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `Oversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor - Added shared `WaveformBank`, spectral `MorphingOscillator`, and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `Oversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. - Fixed the pulsar spectral resampler's alternating coefficient sign and corrected oscillator regression tests for fixed-size copies, startup crossfades, and spectral window leakage. diff --git a/docs/dsp/resampling.md b/docs/dsp/resampling.md index 275ee1b05..b1be93d43 100644 --- a/docs/dsp/resampling.md +++ b/docs/dsp/resampling.md @@ -12,7 +12,7 @@ buffer and the sinc lookup table. `CircularBuffer` is a fixed-size compile-time ring buffer for O(1) push plus random-access sample history — the per-channel -history primitive used by the resamplers: +history primitive used by `Resampler`: ```cpp yup::CircularBuffer history; @@ -35,7 +35,7 @@ OversampleFactor` entries. yup::SincTable table; table.configureWithCutoff (20000.0, 44100.0); // explicit cutoff (downsampling) table.configure (44100.0); // or cutoff = sampleRate/2 (upsampling) -table.applyKaiserWindow (5.0); // optional Kaiser windowing, beta = 5 +table.applyKaiserWindow (9.0); // optional Kaiser windowing, beta = 9 double v = table (tap, delta); // fractional-phase access; negative taps mirrored ``` @@ -44,7 +44,10 @@ double v = table (tap, delta); // fractional-phase access; negative taps mirro upsampling); `configureWithCutoff` takes an explicit cutoff in `(0, sampleRate/2]` (correct for downsampling, where the anti-aliasing cutoff is the target Nyquist). `applyKaiserWindow` multiplies the stored half-kernel -by the second half of a Kaiser window without touching the center coefficient. +by the second half of a Kaiser window spanning exactly the kernel radius +(`2 · SincRadius · OversampleFactor + 1` samples) without touching the center +coefficient; the entries beyond the radius (tap `SincRadius` with a nonzero +fractional phase) are zeroed, so the kernel decays smoothly to zero at its edge. ## Oversampler @@ -54,7 +57,7 @@ processing chains that need headroom — distortion, nonlinear filters, etc. Compile-time constraints: `OversampleFactor >= 2`, `SincRadius >= 1`. ```cpp -yup::Oversampler os; // 4x oversampling, sinc radius 8 +yup::Oversampler os; // 4x oversampling, sinc radius 16 os.prepare (44100.0, 2, 512); // audio thread: @@ -63,22 +66,40 @@ os.processOversampledBlock ([] (auto& buffer) { applyDistortion (buffer); }); os.downsample (outPtrs, numChannels, numSamples); ``` -- `prepare` builds the interpolation table (Kaiser β = 5), the decimation - table (cutoff at `0.45 × input Nyquist`, leaving transition bandwidth), and - allocates the per-channel history and staging buffers. **Not** realtime-safe. +- `prepare` designs the interpolation kernel (cutoff at the input Nyquist) and + the decimation kernel (cutoff at `0.45 × input sample rate`, leaving + transition bandwidth), both Kaiser-windowed with β = 9 (~90 dB stopband when + the radius allows), normalized to unity DC gain per phase and stored with the + gain baked in, and allocates the per-channel staging buffers. The kernels are + designed in `CoeffType` (default `double`) and accumulate in `CoeffType` + regardless of `SampleType`. **Not** realtime-safe. - `upsample` writes `numSamples × OversampleFactor` bandlimited samples per channel into an internal buffer; exact phase multiples pass through - directly, fractional phases use the `2·SincRadius + 1`-tap sinc. + directly, fractional phases use the `2·SincRadius + 1`-tap sinc. Each + channel keeps the previous `2·SincRadius` input samples contiguously in + front of its staging buffer, so every output sample is one contiguous + `dotProduct` (SIMD for `float`/`float` and `double`/`double`) and any block + size up to `maxBlockSize` produces identical results. - `processOversampledBlock (callback)` hands the internal oversampled `AudioBuffer` to your callback for the nonlinear processing. -- `downsample` applies the anti-aliasing FIR and decimates back; it must be - called after the oversampled block was processed, with matching channel and - sample counts. -- `getLatencyInSamples()` returns `2 × SincRadius` (input-rate samples). +- `beginGeneration (numChannels, numSamples)` starts a block generated directly + at the oversampled rate (an oscillator, for example) without any input + interpolation; fill `getOversampledChannelData()` and call `downsample`. + Returns `false` for nonpositive sizes or sizes beyond the prepared capacity. +- `downsample` applies the anti-aliasing FIR (`2·SincRadius·OversampleFactor + 1` + taps, `2·SincRadius·OversampleFactor` samples of contiguous history) and + decimates back; it must be called after the oversampled block was processed, + with matching channel and sample counts. +- `getLatencyInSamples()` returns `2 × SincRadius` (input-rate samples); + `getGenerationLatencyInSamples()` returns `SincRadius`, the latency of + generation followed by `downsample`. - `reset()` clears history without re-preparing. Convenience aliases: `Oversampler2xFloat`, `Oversampler4xFloat`, -`Oversampler8xFloat` and the `Double` variants (all radius 8). +`Oversampler8xFloat`, `Oversampler16xFloat`, `Oversampler32xFloat` and the +`Double` variants (all radius 16, latency 32 input samples). The decimation +FIR has `2·SincRadius·OversampleFactor + 1` taps per output sample, so 16× and +32× cost 513 and 1025 taps respectively. ## Resampler diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index b8d29ca98..299a14ef9 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -48,9 +48,19 @@ class SignalGenerator { direct, resampled, - resampledOversampled2x, - resampledOversampled4x, - resampledOversampled8x + oversampled2x, + oversampled4x, + oversampled8x, + oversampled16x, + oversampled32x + }; + + enum class Waveform + { + sine, + triangle, + saw, + square }; SignalGenerator() @@ -129,6 +139,11 @@ class SignalGenerator resetSweepPlaybackState(); } + void setWaveform (Waveform newWaveform) + { + waveform = newWaveform; + } + void setSweepParameters (double startFreq, double endFreq, double durationSeconds) { sweepStartFreq = startFreq; @@ -148,10 +163,37 @@ class SignalGenerator if (output == nullptr || numSamples <= 0) return; - if (signalType == SignalType::frequencySweep && sweepPlaybackMode != SweepPlaybackMode::direct) + if (signalType == SignalType::frequencySweep) { - renderResampledSweepBlock (output, numSamples); - return; + switch (sweepPlaybackMode) + { + case SweepPlaybackMode::direct: + break; + + case SweepPlaybackMode::resampled: + renderResampledSweepBlock (output, numSamples); + return; + + case SweepPlaybackMode::oversampled2x: + renderOversampledSweepBlock (oversampler2x, 2, output, numSamples); + return; + + case SweepPlaybackMode::oversampled4x: + renderOversampledSweepBlock (oversampler4x, 4, output, numSamples); + return; + + case SweepPlaybackMode::oversampled8x: + renderOversampledSweepBlock (oversampler8x, 8, output, numSamples); + return; + + case SweepPlaybackMode::oversampled16x: + renderOversampledSweepBlock (oversampler16x, 16, output, numSamples); + return; + + case SweepPlaybackMode::oversampled32x: + renderOversampledSweepBlock (oversampler32x, 32, output, numSamples); + return; + } } for (int sample = 0; sample < numSamples; ++sample) @@ -209,13 +251,17 @@ class SignalGenerator resampledBlockCapacity = maxOutputBlockSize + 64; sourceBuffer.assign (static_cast (sourceBlockCapacity), 0.0f); - silenceBuffer.assign (static_cast (sourceBlockCapacity), 0.0f); resampledBuffer.assign (static_cast (resampledBlockCapacity), 0.0f); resampler.prepare (sourceSampleRate, sampleRate, 1, sourceBlockCapacity); - oversampler2x.prepare (sourceSampleRate, 1, sourceBlockCapacity); - oversampler4x.prepare (sourceSampleRate, 1, sourceBlockCapacity); - oversampler8x.prepare (sourceSampleRate, 1, sourceBlockCapacity); + + // The oversampled sweeps decimate straight to the device rate, so the + // resampler's alias floor never enters their chain. + oversampler2x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler4x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler8x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler16x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler32x.prepare (sampleRate, 1, maxOutputBlockSize); resetSweepPlaybackState(); } @@ -237,6 +283,8 @@ class SignalGenerator oversampler2x.reset(); oversampler4x.reset(); oversampler8x.reset(); + oversampler16x.reset(); + oversampler32x.reset(); } void renderResampledSweepBlock (float* output, int numSamples) @@ -253,7 +301,7 @@ class SignalGenerator const int sourceSamplesNeeded = yup::jmin (sourceBlockCapacity, yup::jmax (1, remainingOutputSamples * static_cast (resamplerSourceRateMultiplier) + 32)); - renderSourceSweepBlock (sourceSamplesNeeded); + renderWavetableSweepBlock (sourceBuffer.data(), sourceSamplesNeeded, sourceSampleRate); const float* inputPtrs[] = { sourceBuffer.data() }; float* outputPtrs[] = { resampledBuffer.data() }; @@ -280,40 +328,26 @@ class SignalGenerator } } - void renderSourceSweepBlock (int numSamples) + template + void renderOversampledSweepBlock (OversamplerType& oversampler, int oversampleFactor, float* output, int numSamples) { - switch (sweepPlaybackMode) - { - case SweepPlaybackMode::direct: - case SweepPlaybackMode::resampled: - renderWavetableSweepBlock (sourceBuffer.data(), numSamples, sourceSampleRate); - break; - - case SweepPlaybackMode::resampledOversampled2x: - renderOversampledSourceBlock (oversampler2x, 2, numSamples); - break; - - case SweepPlaybackMode::resampledOversampled4x: - renderOversampledSourceBlock (oversampler4x, 4, numSamples); - break; + ensurePreparedForBlock (numSamples); - case SweepPlaybackMode::resampledOversampled8x: - renderOversampledSourceBlock (oversampler8x, 8, numSamples); - break; + if (! oversampler.beginGeneration (1, numSamples)) + { + std::fill_n (output, numSamples, 0.0f); + return; } - } - template - void renderOversampledSourceBlock (OversamplerType& oversampler, int oversampleFactor, int numSamples) - { - const float* inputPtrs[] = { silenceBuffer.data() }; - oversampler.upsample (inputPtrs, 1, numSamples); - - auto* oversampledData = oversampler.getOversampledChannelData (0); - renderWavetableSweepBlock (oversampledData, oversampler.getOversampledNumSamples(), sourceSampleRate * static_cast (oversampleFactor)); + renderWavetableSweepBlock (oversampler.getOversampledChannelData (0), + oversampler.getOversampledNumSamples(), + sampleRate * static_cast (oversampleFactor)); - float* outputPtrs[] = { sourceBuffer.data() }; + float* outputPtrs[] = { output }; oversampler.downsample (outputPtrs, 1, numSamples); + + for (int i = 0; i < numSamples; ++i) + output[i] *= smoothedAmplitude.getNextValue(); } void renderWavetableSweepBlock (float* output, int numSamples, double generationSampleRate) @@ -322,6 +356,27 @@ class SignalGenerator output[i] = generateSweepAtRate (generationSampleRate); } + // Naive shapes read straight from the phase, so the non-sine waveforms alias + // and make the sweep oversampling modes audible and visible. + float readWaveform() const + { + const auto position = static_cast (phase); + + switch (waveform) + { + case Waveform::triangle: + return 1.0f - 4.0f * std::abs (position - 0.5f); + case Waveform::saw: + return 2.0f * position - 1.0f; + case Waveform::square: + return position < 0.5f ? 1.0f : -1.0f; + case Waveform::sine: + break; + } + + return readSineTable(); + } + float readSineTable() const { const double tablePosition = phase * static_cast (wavetableSize); @@ -344,7 +399,7 @@ class SignalGenerator float generateSine (double freq) { - float sample = readSineTable(); + float sample = readWaveform(); advancePhase (freq, sampleRate); return sample; @@ -359,7 +414,7 @@ class SignalGenerator { // Linear frequency sweep double currentFreq = sweepStartFreq + (sweepEndFreq - sweepStartFreq) * sweepProgress; - float sample = readSineTable(); + float sample = readWaveform(); advancePhase (currentFreq, generationSampleRate); // Update sweep progress @@ -418,6 +473,7 @@ class SignalGenerator SignalType signalType; SweepPlaybackMode sweepPlaybackMode; + Waveform waveform = Waveform::sine; // Sweep parameters double sweepStartFreq, sweepEndFreq, sweepDurationSeconds; @@ -437,8 +493,9 @@ class SignalGenerator yup::Oversampler2xFloat oversampler2x; yup::Oversampler4xFloat oversampler4x; yup::Oversampler8xFloat oversampler8x; + yup::Oversampler16xFloat oversampler16x; + yup::Oversampler32xFloat oversampler32x; std::vector sourceBuffer; - std::vector silenceBuffer; std::vector resampledBuffer; int maxOutputBlockSize = 0; int sourceBlockCapacity = 0; @@ -724,10 +781,12 @@ class SpectrumAnalyzerDemo signalTypeCombo->addItem ("Sweep 2x", 4); signalTypeCombo->addItem ("Sweep 4x", 5); signalTypeCombo->addItem ("Sweep 8x", 6); - signalTypeCombo->addItem ("White Noise", 7); - signalTypeCombo->addItem ("Pink Noise", 8); - signalTypeCombo->addItem ("Brown Noise", 9); - signalTypeCombo->addItem ("Audio File", 10); + signalTypeCombo->addItem ("Sweep 16x", 7); + signalTypeCombo->addItem ("Sweep 32x", 8); + signalTypeCombo->addItem ("White Noise", 9); + signalTypeCombo->addItem ("Pink Noise", 10); + signalTypeCombo->addItem ("Brown Noise", 11); + signalTypeCombo->addItem ("Audio File", 12); signalTypeCombo->setSelectedId (3); signalTypeCombo->onSelectedItemChanged = [this] { @@ -735,6 +794,19 @@ class SpectrumAnalyzerDemo }; addAndMakeVisible (*signalTypeCombo); + // Waveform selector (tone and sweep sources) + waveformCombo = std::make_unique ("Waveform"); + waveformCombo->addItem ("Sine", 1); + waveformCombo->addItem ("Triangle", 2); + waveformCombo->addItem ("Saw", 3); + waveformCombo->addItem ("Square", 4); + waveformCombo->setSelectedId (1); + waveformCombo->onSelectedItemChanged = [this] + { + updateWaveform(); + }; + addAndMakeVisible (*waveformCombo); + // Frequency control frequencySlider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency"); frequencySlider->setRange ({ 20.0, 22000.0 }); @@ -935,7 +1007,7 @@ class SpectrumAnalyzerDemo // Create parameter labels with proper font sizing auto labelFont = font.withHeight (12.0f); - for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:" }) + for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:", "Waveform:" }) { auto label = parameterLabels.add (std::make_unique (labelText)); label->setText (labelText); @@ -1019,6 +1091,7 @@ class SpectrumAnalyzerDemo auto releaseSection = row3.removeFromLeft (colWidth); auto overlapSection = row3.removeFromLeft (colWidth); auto levelModeSection = row3.removeFromLeft (colWidth); + auto waveformSection = row3.removeFromLeft (colWidth); parameterLabels[9]->setBounds (releaseSection.removeFromTop (labelHeight)); releaseSlider->setBounds (releaseSection.removeFromTop (controlHeight)); @@ -1029,6 +1102,9 @@ class SpectrumAnalyzerDemo parameterLabels[12]->setBounds (levelModeSection.removeFromTop (labelHeight)); levelModeCombo->setBounds (levelModeSection.removeFromTop (controlHeight)); + parameterLabels[13]->setBounds (waveformSection.removeFromTop (labelHeight)); + waveformCombo->setBounds (waveformSection.removeFromTop (controlHeight)); + // Fourth row: Status labels auto row4 = bounds.removeFromTop (30); auto freqStatus = row4.removeFromLeft (bounds.getWidth() / 3); @@ -1077,23 +1153,31 @@ class SpectrumAnalyzerDemo break; case 4: signalType = SignalGenerator::SignalType::frequencySweep; - sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::resampledOversampled2x; + sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::oversampled2x; break; case 5: signalType = SignalGenerator::SignalType::frequencySweep; - sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::resampledOversampled4x; + sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::oversampled4x; break; case 6: signalType = SignalGenerator::SignalType::frequencySweep; - sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::resampledOversampled8x; + sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::oversampled8x; break; case 7: - signalType = SignalGenerator::SignalType::whiteNoise; + signalType = SignalGenerator::SignalType::frequencySweep; + sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::oversampled16x; break; case 8: - signalType = SignalGenerator::SignalType::pinkNoise; + signalType = SignalGenerator::SignalType::frequencySweep; + sweepPlaybackMode = SignalGenerator::SweepPlaybackMode::oversampled32x; break; case 9: + signalType = SignalGenerator::SignalType::whiteNoise; + break; + case 10: + signalType = SignalGenerator::SignalType::pinkNoise; + break; + case 11: signalType = SignalGenerator::SignalType::brownNoise; break; } @@ -1101,7 +1185,7 @@ class SpectrumAnalyzerDemo // The "Audio File" source plays the pre-decoded mp3 through the file // player instead of the signal generator (loaded in the constructor, so // it is immutable while the audio callback reads it). - useAudioFile = (signalTypeCombo->getSelectedId() == 10); + useAudioFile = (signalTypeCombo->getSelectedId() == 12); updateSignalGenerator ([signalType, sweepPlaybackMode] (SignalGenerator& generator) { @@ -1112,6 +1196,33 @@ class SpectrumAnalyzerDemo // Enable/disable frequency and sweep controls based on signal type frequencySlider->setEnabled (signalType == SignalGenerator::SignalType::singleTone); sweepDurationSlider->setEnabled (signalType == SignalGenerator::SignalType::frequencySweep); + waveformCombo->setEnabled (signalType == SignalGenerator::SignalType::singleTone + || signalType == SignalGenerator::SignalType::frequencySweep); + } + + void updateWaveform() + { + auto waveform = SignalGenerator::Waveform::sine; + + switch (waveformCombo->getSelectedId()) + { + case 2: + waveform = SignalGenerator::Waveform::triangle; + break; + case 3: + waveform = SignalGenerator::Waveform::saw; + break; + case 4: + waveform = SignalGenerator::Waveform::square; + break; + default: + break; + } + + updateSignalGenerator ([waveform] (SignalGenerator& generator) + { + generator.setWaveform (waveform); + }); } void updateFFTSize() @@ -1284,6 +1395,7 @@ class SpectrumAnalyzerDemo // Signal controls std::unique_ptr signalTypeCombo; + std::unique_ptr waveformCombo; std::unique_ptr frequencySlider; std::unique_ptr amplitudeSlider; std::unique_ptr sweepDurationSlider; diff --git a/modules/yup_dsp/resampling/yup_Oversampler.h b/modules/yup_dsp/resampling/yup_Oversampler.h index fdbc5baa6..623ad9fa5 100644 --- a/modules/yup_dsp/resampling/yup_Oversampler.h +++ b/modules/yup_dsp/resampling/yup_Oversampler.h @@ -29,12 +29,21 @@ namespace yup Multi-channel integer-factor oversampler using windowed sinc interpolation. Oversampler up- and downsamples audio by an integer factor with - bandlimited interpolation and anti-aliasing. Internal per-channel history - buffers allow seamless multi-block (real-time) operation. + bandlimited interpolation and anti-aliasing. Each channel keeps the last + kernel-length of samples contiguously in front of its staging buffer, so + every output sample is a single contiguous dot product and multi-block + (real-time) operation is seamless. + + Both kernels are Kaiser-windowed sincs (beta = 9, roughly 90 dB of + stopband rejection when the radius allows it) designed in CoeffType and + applied to SampleType data, accumulating in CoeffType. The interpolator + cuts off at the input Nyquist frequency, the decimator at 0.45 times the + input sample rate. The total round-trip latency is 2 * SincRadius input + samples; generation followed by downsample() costs SincRadius. Typical usage in an audio effect: @code - yup::Oversampler os; + yup::Oversampler os; os.prepare (44100.0, 2, 512); // Inside your audio callback: @@ -49,9 +58,9 @@ namespace yup @tparam SampleType Audio sample type (float or double). @tparam OversampleFactor Integer upsample ratio (2, 4, 8, …). @tparam SincRadius Half-width of the sinc kernel in original-rate samples. - Higher values give better stopband rejection at the - cost of more computation. - @tparam CoeffType Precision for internal filter coefficients (default double). + Higher values give a steeper transition and deeper + stopband at the cost of more computation and latency. + @tparam CoeffType Precision for filter design and accumulation (default double). */ template class Oversampler @@ -71,8 +80,8 @@ class Oversampler /** Prepares the oversampler for processing. - Configures the internal windowed sinc tables and allocates per-channel - history and staging buffers. Must be called before upsample() or downsample(). + Designs the interpolation and decimation kernels and allocates the + per-channel staging buffers. Must be called before upsample() or downsample(). @param sampleRate Input sample rate in Hz. @param maxChannels Maximum number of audio channels. @@ -82,56 +91,27 @@ class Oversampler { jassert (sampleRate > 0.0 && maxChannels > 0 && maxBlockSize > 0); - interpolationTable.configure (static_cast (sampleRate)); - interpolationTable.applyKaiserWindow (CoeffType (5)); + buildInterpolationTaps (static_cast (sampleRate)); + buildDecimationTaps (static_cast (sampleRate)); - decimationTable.configureWithCutoff (static_cast (sampleRate) * antiAliasCutoffRatio, - static_cast (sampleRate)); - decimationTable.applyKaiserWindow (CoeffType (5)); + maxInputSamples = maxBlockSize; - normalizeFilterGains(); + xInterp.setSize (maxChannels, maxBlockSize + interpolationHistory); + xDecim.setSize (maxChannels, maxBlockSize * OversampleFactor + decimationHistory); + oversampledBuffer.setSize (maxChannels, maxBlockSize * OversampleFactor, false, false, true); - const int maxInterpolated = maxBlockSize * OversampleFactor; - - interpolBeginBufs.assign (maxChannels, CircularBuffer {}); - interpolEndBufs.assign (maxChannels, CircularBuffer {}); - decimBeginBufs.assign (maxChannels, CircularBuffer {}); - decimEndBufs.assign (maxChannels, CircularBuffer {}); - - xInterp.setSize (maxChannels, maxBlockSize + SincRadius); - xInterp.clear(); - - xDecim.setSize (maxChannels, maxInterpolated + SincRadius * OversampleFactor); - xDecim.clear(); - - oversampledBuffer.setSize (maxChannels, maxInterpolated, false, false, true); - oversampledBuffer.clear(); - - currentOversampledSize = 0; - currentNumChannels = 0; + reset(); } /** Resets all internal processing state. - Clears all history buffers so that a fresh processing session can begin + Clears all history so that a fresh processing session can begin without artifacts from a previous session. Filter coefficients are preserved; there is no need to call prepare() again. */ void reset() noexcept { - for (auto& b : interpolBeginBufs) - b.clear(); - - for (auto& b : interpolEndBufs) - b.clear(); - - for (auto& b : decimBeginBufs) - b.clear(); - - for (auto& b : decimEndBufs) - b.clear(); - xInterp.clear(); xDecim.clear(); oversampledBuffer.clear(); @@ -158,18 +138,7 @@ class Oversampler jassert (numChannels > 0 && numSamples > 0); jassert (numChannels <= xInterp.getNumChannels()); - jassert (numSamples + SincRadius <= xInterp.getNumSamples()); - - for (int ch = 0; ch < numChannels; ++ch) - { - const auto* inputData = input[ch]; - - auto* xBuf = xInterp.getWritePointer (ch); - auto& endBuf = interpolEndBufs[static_cast (ch)]; - - for (int i = 0; i < numSamples + SincRadius; ++i) - *xBuf++ = (i >= SincRadius) ? inputData[i - SincRadius] : endBuf[i]; - } + jassert (numSamples <= maxInputSamples); currentOversampledSize = numSamples * OversampleFactor; currentNumChannels = numChannels; @@ -177,41 +146,21 @@ class Oversampler for (int ch = 0; ch < numChannels; ++ch) { - auto* xBuf = xInterp.getReadPointer (ch); - auto& beginBuf = interpolBeginBufs[static_cast (ch)]; - auto& endBuf = interpolEndBufs[static_cast (ch)]; + auto* history = xInterp.getWritePointer (ch); + FloatVectorOperations::copy (history + interpolationHistory, input[ch], numSamples); - auto* outBuf = oversampledBuffer.getWritePointer (ch); - *outBuf++ = *xBuf; + auto* out = oversampledBuffer.getWritePointer (ch); - for (int k = 1; k < currentOversampledSize; ++k) + for (int i = 0; i < numSamples; ++i) { - const int delta = k % OversampleFactor; - const int index = k / OversampleFactor; - - if (delta != 0) - { - CoeffType acc = CoeffType (0); - - for (int n = -SincRadius; n <= 0; ++n) - acc += interpolationTable (n, delta) * static_cast (xBuf[static_cast (index - n)]); - - for (int n = 1; n <= SincRadius; ++n) - acc += interpolationTable (n, delta) * static_cast (beginBuf[SincRadius - n]); - - *outBuf++ = static_cast (acc * interpolationGains[static_cast (delta)]); - } - else - { - *outBuf++ = xBuf[static_cast (index)]; - beginBuf.push (xBuf[static_cast (index - 1)]); - } - } + const auto* window = history + i; + *out++ = window[SincRadius]; - beginBuf.push (xBuf[static_cast (numSamples - 1)]); + for (int delta = 1; delta < OversampleFactor; ++delta) + *out++ = dotProduct (interpolationTaps.data() + delta * interpolationTapCount, window, static_cast (interpolationTapCount)); + } - for (int i = 0; i < SincRadius; ++i) - endBuf.push (xBuf[static_cast (numSamples + i)]); + std::copy (history + numSamples, history + numSamples + interpolationHistory, history); } } @@ -231,7 +180,7 @@ class Oversampler { if (numChannels <= 0 || numSamples <= 0 || numChannels > xInterp.getNumChannels() - || numSamples > xInterp.getNumSamples() - SincRadius) + || numSamples > maxInputSamples) return false; currentOversampledSize = numSamples * OversampleFactor; @@ -269,50 +218,17 @@ class Oversampler jassert (currentOversampledSize > 0); jassert (numSamples * OversampleFactor == currentOversampledSize); - const int interpolatedSize = currentOversampledSize; - for (int ch = 0; ch < numChannels; ++ch) { - auto* inBuf = oversampledBuffer.getReadPointer (ch); + auto* history = xDecim.getWritePointer (ch); + FloatVectorOperations::copy (history + decimationHistory, oversampledBuffer.getReadPointer (ch), currentOversampledSize); - auto* xBuf = xDecim.getWritePointer (ch); - auto& dEndBuf = decimEndBufs[static_cast (ch)]; - - for (int i = 0; i < interpolatedSize + SincRadius * OversampleFactor; ++i) - { - *xBuf++ = (i >= SincRadius * OversampleFactor) - ? inBuf[static_cast (i - SincRadius * OversampleFactor)] - : dEndBuf[i]; - } - } - - for (int ch = 0; ch < numChannels; ++ch) - { - auto* outputData = output[ch]; - - auto* xBuf = xDecim.getReadPointer (ch); - auto& beginBuf = decimBeginBufs[static_cast (ch)]; - auto& dEndBuf = decimEndBufs[static_cast (ch)]; + auto* out = output[ch]; for (int k = 0; k < numSamples; ++k) - { - const int index = OversampleFactor * k; - CoeffType acc = CoeffType (0); - - for (int n = 1; n <= SincRadius * OversampleFactor; ++n) - acc += decimationTable[n] * static_cast (beginBuf[SincRadius * OversampleFactor - n]); - - for (int n = 0; n >= -(SincRadius * OversampleFactor); --n) - acc += decimationTable[n] * static_cast (xBuf[static_cast (index - n)]); - - for (int i = 0; i < OversampleFactor; ++i) - beginBuf.push (xBuf[static_cast (index + i)]); - - outputData[k] = static_cast (acc * decimationGain); - } + out[k] = dotProduct (decimationTaps.data(), history + k * OversampleFactor, static_cast (decimationTapCount)); - for (int i = 0; i < SincRadius * OversampleFactor; ++i) - dEndBuf.push (xBuf[static_cast (interpolatedSize + i)]); + std::copy (history + currentOversampledSize, history + currentOversampledSize + decimationHistory, history); } currentOversampledSize = 0; @@ -404,44 +320,76 @@ class Oversampler private: //============================================================================== - void normalizeFilterGains() noexcept + static constexpr int interpolationTapCount = 2 * SincRadius + 1; + static constexpr int interpolationHistory = 2 * SincRadius; + static constexpr int decimationTapCount = 2 * SincRadius * OversampleFactor + 1; + static constexpr int decimationHistory = 2 * SincRadius * OversampleFactor; + + static constexpr CoeffType kaiserBeta = CoeffType (9); + + // Leave transition width before the original Nyquist frequency for decimation. + static constexpr CoeffType antiAliasCutoffRatio = CoeffType (0.45); + + //============================================================================== + // Phase-major taps, each phase normalized to unity DC gain. Phase 0 is the + // pass-through sample and is never read. + void buildInterpolationTaps (CoeffType sampleRate) { - for (int delta = 0; delta < OversampleFactor; ++delta) + SincTable table; + table.configure (sampleRate); + table.applyKaiserWindow (kaiserBeta); + + interpolationTaps.assign (static_cast (OversampleFactor * interpolationTapCount), CoeffType (0)); + + for (int delta = 1; delta < OversampleFactor; ++delta) { + auto* taps = interpolationTaps.data() + delta * interpolationTapCount; CoeffType sum = CoeffType (0); - for (int n = -SincRadius; n <= SincRadius; ++n) - sum += interpolationTable (n, delta); + for (int j = 0; j < interpolationTapCount; ++j) + { + taps[j] = table (SincRadius - j, delta); + sum += taps[j]; + } jassert (sum != CoeffType (0)); - interpolationGains[static_cast (delta)] = CoeffType (1) / sum; + const CoeffType gain = CoeffType (1) / sum; + + for (int j = 0; j < interpolationTapCount; ++j) + taps[j] *= gain; } + } - CoeffType decimationSum = decimationTable[0]; + void buildDecimationTaps (CoeffType sampleRate) + { + SincTable table; + table.configureWithCutoff (sampleRate * antiAliasCutoffRatio, sampleRate); + table.applyKaiserWindow (kaiserBeta); - for (int n = 1; n <= SincRadius * OversampleFactor; ++n) - decimationSum += CoeffType (2) * decimationTable[n]; + decimationTaps.resize (static_cast (decimationTapCount)); + CoeffType sum = CoeffType (0); - jassert (decimationSum != CoeffType (0)); - decimationGain = CoeffType (1) / decimationSum; - } + for (int j = 0; j < decimationTapCount; ++j) + { + decimationTaps[static_cast (j)] = table[j - SincRadius * OversampleFactor]; + sum += decimationTaps[static_cast (j)]; + } - // Leave transition width before the original Nyquist frequency for decimation. - static constexpr CoeffType antiAliasCutoffRatio = CoeffType (0.45); + jassert (sum != CoeffType (0)); + const CoeffType gain = CoeffType (1) / sum; - SincTable interpolationTable; - SincTable decimationTable; - std::array (OversampleFactor)> interpolationGains {}; - CoeffType decimationGain = CoeffType (1); + for (auto& tap : decimationTaps) + tap *= gain; + } - std::vector> interpolBeginBufs; - std::vector> interpolEndBufs; - std::vector> decimBeginBufs; - std::vector> decimEndBufs; + //============================================================================== + std::vector interpolationTaps; + std::vector decimationTaps; AudioBuffer xInterp; AudioBuffer xDecim; AudioBuffer oversampledBuffer; + int maxInputSamples = 0; int currentOversampledSize = 0; int currentNumChannels = 0; @@ -449,12 +397,16 @@ class Oversampler }; //============================================================================== -/** @name Convenience type aliases for common oversampling configurations */ -using Oversampler2xFloat = Oversampler; /**< 2x oversampler, float, 8-tap radius */ -using Oversampler4xFloat = Oversampler; /**< 4x oversampler, float, 8-tap radius */ -using Oversampler8xFloat = Oversampler; /**< 8x oversampler, float, 8-tap radius */ -using Oversampler2xDouble = Oversampler; /**< 2x oversampler, double, 8-tap radius */ -using Oversampler4xDouble = Oversampler; /**< 4x oversampler, double, 8-tap radius */ -using Oversampler8xDouble = Oversampler; /**< 8x oversampler, double, 8-tap radius */ +/** @name Convenience type aliases for common oversampling configurations (latency 32 samples) */ +using Oversampler2xFloat = Oversampler; /**< 2x oversampler, float, 16-tap radius */ +using Oversampler4xFloat = Oversampler; /**< 4x oversampler, float, 16-tap radius */ +using Oversampler8xFloat = Oversampler; /**< 8x oversampler, float, 16-tap radius */ +using Oversampler16xFloat = Oversampler; /**< 16x oversampler, float, 16-tap radius */ +using Oversampler32xFloat = Oversampler; /**< 32x oversampler, float, 16-tap radius */ +using Oversampler2xDouble = Oversampler; /**< 2x oversampler, double, 16-tap radius */ +using Oversampler4xDouble = Oversampler; /**< 4x oversampler, double, 16-tap radius */ +using Oversampler8xDouble = Oversampler; /**< 8x oversampler, double, 16-tap radius */ +using Oversampler16xDouble = Oversampler; /**< 16x oversampler, double, 16-tap radius */ +using Oversampler32xDouble = Oversampler; /**< 32x oversampler, double, 16-tap radius */ } // namespace yup diff --git a/modules/yup_dsp/resampling/yup_SincTable.h b/modules/yup_dsp/resampling/yup_SincTable.h index dc4046c98..6e62b0aff 100644 --- a/modules/yup_dsp/resampling/yup_SincTable.h +++ b/modules/yup_dsp/resampling/yup_SincTable.h @@ -115,20 +115,25 @@ class SincTable /** Multiplies the stored half-kernel by the second half of a Kaiser window. - The full symmetric window has 2 * tableSize - 1 samples, so the stored - center coefficient is exactly aligned with the window center and remains - unchanged. + The window spans exactly the kernel radius: 2 * SincRadius * OversampleFactor + 1 + samples centered on the stored center coefficient, which remains unchanged. + Entries beyond the radius (tap == SincRadius with a nonzero fractional phase) + are set to zero, so the kernel decays smoothly to zero at its edge instead of + being cut off part-way through the window. @param beta Kaiser window shape parameter (higher = more side-lobe suppression). */ void applyKaiserWindow (CoeffType beta = CoeffType (5)) noexcept { - constexpr int N = tableSize * 2 - 1; - constexpr int center = tableSize - 1; + constexpr int center = SincRadius * OversampleFactor; + constexpr int N = 2 * center + 1; - for (int i = 0; i < tableSize; ++i) + for (int i = 0; i <= center; ++i) table[static_cast (i)] *= WindowFunctions::kaiser (center + i, N, beta); + + for (int i = center + 1; i < tableSize; ++i) + table[static_cast (i)] = CoeffType (0); } //============================================================================== diff --git a/modules/yup_dsp/utilities/yup_DspMath.cpp b/modules/yup_dsp/utilities/yup_DspMath.cpp index 8b31c31c3..0b857e18a 100644 --- a/modules/yup_dsp/utilities/yup_DspMath.cpp +++ b/modules/yup_dsp/utilities/yup_DspMath.cpp @@ -30,4 +30,10 @@ float dotProduct (const float* __restrict a, const float* __restrict b, std::siz return FloatVectorOperations::dotProduct (a, b, length); } +template <> +double dotProduct (const double* __restrict a, const double* __restrict b, std::size_t length) noexcept +{ + return FloatVectorOperations::dotProduct (a, b, length); +} + } // namespace yup diff --git a/modules/yup_dsp/utilities/yup_DspMath.h b/modules/yup_dsp/utilities/yup_DspMath.h index eb43c9d47..ef8cef648 100644 --- a/modules/yup_dsp/utilities/yup_DspMath.h +++ b/modules/yup_dsp/utilities/yup_DspMath.h @@ -177,6 +177,10 @@ SampleType dotProduct (const CoeffType* __restrict a, const SampleType* __restri template <> float dotProduct (const float* __restrict a, const float* __restrict b, std::size_t length) noexcept; +/** Fast specialization for dotProduct using SIMD */ +template <> +double dotProduct (const double* __restrict a, const double* __restrict b, std::size_t length) noexcept; + //============================================================================== /** Bilinear transform from s-plane to z-plane with frequency warping */ diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_Oversampler.cpp index f15a8c7fb..bc9667b98 100644 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ b/tests/yup_dsp/yup_Oversampler.cpp @@ -275,6 +275,8 @@ TEST (OversamplerTypeAliasTest, TypeAliasesCompile) Oversampler8xFloat c; Oversampler2xDouble d; Oversampler4xDouble e; + Oversampler16xFloat f; + Oversampler32xDouble g; // Prepare briefly to confirm the types are usable a.prepare (44100.0, 1, 64); @@ -282,6 +284,8 @@ TEST (OversamplerTypeAliasTest, TypeAliasesCompile) c.prepare (44100.0, 1, 64); d.prepare (44100.0, 1, 64); e.prepare (44100.0, 1, 64); + f.prepare (44100.0, 1, 64); + g.prepare (44100.0, 1, 64); SUCCEED(); } @@ -329,3 +333,435 @@ TEST_F (OversamplerTest, DirectGenerationImpulseHasTheReportedLatency) } } // namespace yup::test + +namespace yup::test +{ + +//============================================================================== +class OversamplerAccuracyTest : public ::testing::Test +{ +protected: + static constexpr double sampleRate = 48000.0; + static constexpr double kaiserBeta = 9.0; + + template + struct Streams + { + std::vector upsampled; + std::vector roundTrip; + }; + + template + static std::vector makeNoise (int numSamples, int64 seed) + { + Random random (seed); + std::vector result (static_cast (numSamples)); + + for (auto& value : result) + value = static_cast (random.nextDouble() * 2.0 - 1.0); + + return result; + } + + template + static std::vector makeSine (int numSamples, double normalizedFrequency, int offset = 0) + { + std::vector result (static_cast (numSamples)); + + for (int i = 0; i < numSamples; ++i) + result[static_cast (i)] = static_cast (std::sin (MathConstants::twoPi * normalizedFrequency * (i + offset))); + + return result; + } + + /** Streams the input through upsample/downsample using the block sizes in schedule (cycled). */ + template + static Streams process (Os& os, const std::vector& input, const std::vector& schedule) + { + Streams streams; + const auto total = static_cast (input.size()); + std::size_t step = 0; + + for (int position = 0; position < total;) + { + const int numSamples = jmin (schedule[step++ % schedule.size()], total - position); + + const SampleType* inputPtrs[] = { input.data() + position }; + os.upsample (inputPtrs, 1, numSamples); + + const auto* up = os.getOversampledChannelData (0); + streams.upsampled.insert (streams.upsampled.end(), up, up + os.getOversampledNumSamples()); + + std::vector output (static_cast (numSamples)); + SampleType* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, numSamples); + streams.roundTrip.insert (streams.roundTrip.end(), output.begin(), output.end()); + + position += numSamples; + } + + return streams; + } + + template + static void expectNear (const std::vector& actual, const std::vector& expected, double tolerance) + { + ASSERT_EQ (expected.size(), actual.size()); + + for (std::size_t i = 0; i < actual.size(); ++i) + ASSERT_NEAR (expected[i], actual[i], tolerance) << "at index " << i; + } + + template + static void checkBlockSizeIndependence (double tolerance) + { + constexpr int total = 512; + const auto input = makeNoise (total, 7); + + Oversampler wholeBlock, fixedBlocks, irregularBlocks; + wholeBlock.prepare (sampleRate, 1, total); + fixedBlocks.prepare (sampleRate, 1, total); + irregularBlocks.prepare (sampleRate, 1, total); + + const auto reference = process (wholeBlock, input, { total }); + const auto fixedResult = process (fixedBlocks, input, { 256 }); + const auto irregularResult = process (irregularBlocks, input, { 1, 3, 7, 5, 2, 13, 64, 17, 31, 9, 128 }); + + expectNear (fixedResult.upsampled, reference.upsampled, tolerance); + expectNear (fixedResult.roundTrip, reference.roundTrip, tolerance); + expectNear (irregularResult.upsampled, reference.upsampled, tolerance); + expectNear (irregularResult.roundTrip, reference.roundTrip, tolerance); + } + + static std::vector magnitudeSpectrum (const std::vector& signal) + { + const auto size = static_cast (signal.size()); + FFTProcessor fft (size); + + std::vector spectrum (static_cast (size) * 2); + fft.performRealFFTForward (signal.data(), spectrum.data()); + + std::vector magnitude (static_cast (size) / 2 + 1); + + for (int k = 0; k <= size / 2; ++k) + magnitude[static_cast (k)] = std::hypot (spectrum[static_cast (2 * k)], spectrum[static_cast (2 * k + 1)]); + + return magnitude; + } + + /** Level of the strongest bin outside the fundamental's guard band, relative to the fundamental. */ + static double worstSpuriousDb (const std::vector& magnitude, int fundamentalBin, int guardBins) + { + const auto fundamental = magnitude[static_cast (fundamentalBin)]; + double worst = 0.0; + + for (std::size_t k = 0; k < magnitude.size(); ++k) + { + if (std::abs (static_cast (k) - fundamentalBin) <= guardBins) + continue; + + worst = jmax (worst, magnitude[k]); + } + + return 20.0 * std::log10 (jmax (worst, 1e-12 * fundamental) / fundamental); + } + + template + static double upsampledWorstImageDb (int fundamentalBin) + { + constexpr int blockSize = 1024; + const double frequency = fundamentalBin / static_cast (blockSize); + + Oversampler os; + os.prepare (sampleRate, 1, blockSize); + + std::vector steadyState; + + for (int block = 0; block < 2; ++block) + { + const auto input = makeSine (blockSize, frequency, block * blockSize); + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + const auto* up = os.getOversampledChannelData (0); + steadyState.assign (up, up + os.getOversampledNumSamples()); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + } + + return worstSpuriousDb (magnitudeSpectrum (steadyState), fundamentalBin, 2); + } + + struct RoundTripAccuracy + { + double maxError = 0.0; + double snrDb = 0.0; + }; + + /** Compares the round trip of a unit sine against the input delayed by the reported latency. */ + template + static RoundTripAccuracy roundTripAccuracy (double normalizedFrequency) + { + constexpr int blockSize = 512; + + Oversampler os; + os.prepare (sampleRate, 1, blockSize); + + RoundTripAccuracy accuracy; + double signalEnergy = 0.0; + double errorEnergy = 0.0; + + for (int block = 0; block < 4; ++block) + { + const auto input = makeSine (blockSize, normalizedFrequency, block * blockSize); + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + + if (block == 0) + continue; + + for (int i = 0; i < blockSize; ++i) + { + const auto expected = std::sin (MathConstants::twoPi * normalizedFrequency * (block * blockSize + i - os.getLatencyInSamples())); + const auto error = output[static_cast (i)] - expected; + accuracy.maxError = jmax (accuracy.maxError, std::abs (error)); + signalEnergy += expected * expected; + errorEnergy += error * error; + } + } + + accuracy.snrDb = 10.0 * std::log10 (signalEnergy / jmax (errorEnergy, 1e-30)); + return accuracy; + } +}; + +//============================================================================== +TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceFloat2x) +{ + checkBlockSizeIndependence (1e-6); +} + +TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceFloat4x) +{ + checkBlockSizeIndependence (1e-6); +} + +TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceDouble4x) +{ + checkBlockSizeIndependence (1e-12); +} + +TEST_F (OversamplerAccuracyTest, ImpulseLatencyWithTinyBlocks) +{ + constexpr int radius = 8; + constexpr int factor = 4; + constexpr int total = 96; + constexpr int impulsePosition = 11; + + std::vector input (total, 0.0f); + input[impulsePosition] = 1.0f; + + Oversampler os; + os.prepare (sampleRate, 1, total); + const auto streams = process (os, input, { 3 }); + + for (int i = 0; i < total; ++i) + { + const float expected = (i == impulsePosition + radius) ? 1.0f : 0.0f; + EXPECT_EQ (expected, streams.upsampled[static_cast (i * factor)]) << "input index " << i; + } + + const auto peak = std::max_element (streams.roundTrip.begin(), streams.roundTrip.end()); + EXPECT_EQ (impulsePosition + 2 * radius, static_cast (peak - streams.roundTrip.begin())); +} + +TEST_F (OversamplerAccuracyTest, UpsampleMatchesScalarSincReference) +{ + constexpr int radius = 8; + constexpr int factor = 4; + constexpr int total = 256; + const auto input = makeNoise (total, 3); + + Oversampler os; + os.prepare (sampleRate, 1, total); + const auto streams = process (os, input, { 64 }); + + SincTable table; + table.configure (sampleRate); + table.applyKaiserWindow (kaiserBeta); + + const auto sampleAt = [&] (int index) + { + return (index >= 0 && index < total) ? static_cast (input[static_cast (index)]) : 0.0; + }; + + for (int i = 0; i < total; ++i) + { + const int center = i - radius; + EXPECT_NEAR (sampleAt (center), streams.upsampled[static_cast (i * factor)], 1e-6); + + for (int delta = 1; delta < factor; ++delta) + { + double acc = 0.0; + double sum = 0.0; + + for (int n = -radius; n <= radius; ++n) + { + const auto tap = table (n, delta); + acc += tap * sampleAt (center - n); + sum += tap; + } + + EXPECT_NEAR (acc / sum, streams.upsampled[static_cast (i * factor + delta)], 1e-6) << "sample " << i << " phase " << delta; + } + } +} + +TEST_F (OversamplerAccuracyTest, ChannelsAreIndependent) +{ + constexpr int blockSize = 100; + constexpr int total = 300; + const auto left = makeNoise (total, 1); + const auto right = makeNoise (total, 2); + + Oversampler stereo, monoLeft, monoRight; + stereo.prepare (sampleRate, 2, blockSize); + monoLeft.prepare (sampleRate, 1, blockSize); + monoRight.prepare (sampleRate, 1, blockSize); + + Streams stereoLeft, stereoRight; + + for (int position = 0; position < total; position += blockSize) + { + const float* inputPtrs[] = { left.data() + position, right.data() + position }; + stereo.upsample (inputPtrs, 2, blockSize); + + const auto* upLeft = stereo.getOversampledChannelData (0); + const auto* upRight = stereo.getOversampledChannelData (1); + stereoLeft.upsampled.insert (stereoLeft.upsampled.end(), upLeft, upLeft + stereo.getOversampledNumSamples()); + stereoRight.upsampled.insert (stereoRight.upsampled.end(), upRight, upRight + stereo.getOversampledNumSamples()); + + std::vector outLeft (blockSize), outRight (blockSize); + float* outputPtrs[] = { outLeft.data(), outRight.data() }; + stereo.downsample (outputPtrs, 2, blockSize); + stereoLeft.roundTrip.insert (stereoLeft.roundTrip.end(), outLeft.begin(), outLeft.end()); + stereoRight.roundTrip.insert (stereoRight.roundTrip.end(), outRight.begin(), outRight.end()); + } + + const auto expectedLeft = process (monoLeft, left, { blockSize }); + const auto expectedRight = process (monoRight, right, { blockSize }); + + expectNear (stereoLeft.upsampled, expectedLeft.upsampled, 1e-7); + expectNear (stereoLeft.roundTrip, expectedLeft.roundTrip, 1e-7); + expectNear (stereoRight.upsampled, expectedRight.upsampled, 1e-7); + expectNear (stereoRight.roundTrip, expectedRight.roundTrip, 1e-7); +} + +TEST_F (OversamplerAccuracyTest, ResetMatchesFreshInstance) +{ + constexpr int blockSize = 128; + + Oversampler reused, fresh; + reused.prepare (sampleRate, 1, blockSize); + fresh.prepare (sampleRate, 1, blockSize); + + process (reused, makeNoise (512, 5), { blockSize }); + reused.reset(); + + const auto input = makeNoise (256, 6); + const auto reusedResult = process (reused, input, { blockSize }); + const auto freshResult = process (fresh, input, { blockSize }); + + expectNear (reusedResult.upsampled, freshResult.upsampled, 1e-7); + expectNear (reusedResult.roundTrip, freshResult.roundTrip, 1e-7); +} + +TEST_F (OversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) +{ + constexpr int blockSize = 128; + const auto blockA = makeNoise (blockSize, 8); + const auto blockB = makeNoise (blockSize, 9); + + Oversampler withGeneration, withoutGeneration; + withGeneration.prepare (sampleRate, 1, blockSize); + withoutGeneration.prepare (sampleRate, 1, blockSize); + + const auto roundTrip = [] (auto& os, const std::vector& input) + { + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + const auto* up = os.getOversampledChannelData (0); + std::vector upsampled (up, up + os.getOversampledNumSamples()); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + return upsampled; + }; + + roundTrip (withGeneration, blockA); + roundTrip (withoutGeneration, blockA); + + ASSERT_TRUE (withGeneration.beginGeneration (1, blockSize)); + FloatVectorOperations::fill (withGeneration.getOversampledChannelData (0), 0.7f, withGeneration.getOversampledNumSamples()); + std::vector generated (blockSize); + float* generatedPtrs[] = { generated.data() }; + withGeneration.downsample (generatedPtrs, 1, blockSize); + + expectNear (roundTrip (withGeneration, blockB), roundTrip (withoutGeneration, blockB), 1e-7); +} + +//============================================================================== +TEST_F (OversamplerAccuracyTest, UpsampledImageIsRejected) +{ + // 0.25 fs tone: image at 0.75 fs sits deep in the interpolator's stopband. + EXPECT_LT (upsampledWorstImageDb<4, 16> (256), -80.0); + + // 0.4 fs tone: image at 0.6 fs sits at the edge of the transition band. + EXPECT_LT (upsampledWorstImageDb<4, 16> (410), -70.0); +} + +TEST_F (OversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadius16) +{ + constexpr int factor = 2; + constexpr int blockSize = 2048; + constexpr double toneRatio = 0.6; // of the input sample rate, above the input Nyquist + + Oversampler os; + os.prepare (sampleRate, 1, blockSize); + + ASSERT_TRUE (os.beginGeneration (1, blockSize)); + auto* internal = os.getOversampledChannelData (0); + + for (int i = 0; i < os.getOversampledNumSamples(); ++i) + internal[i] = static_cast (std::sin (MathConstants::twoPi * toneRatio * i / factor)); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + + constexpr int measured = blockSize / 2; + const auto rms = FloatVectorOperations::rms (output.data() + blockSize - measured, measured); + const auto levelDb = 20.0 * std::log10 (jmax (static_cast (rms), 1e-12) * MathConstants::sqrt2); + EXPECT_LT (levelDb, -70.0); +} + +TEST_F (OversamplerAccuracyTest, RoundTripPassbandIsFlat) +{ + EXPECT_LT (roundTripAccuracy<4, 16> (1000.0 / sampleRate).maxError, 0.005); + EXPECT_LT (roundTripAccuracy<4, 16> (0.3).maxError, 0.005); +} + +TEST_F (OversamplerAccuracyTest, RoundTripSineSNR) +{ + EXPECT_GT (roundTripAccuracy<4, 16> (0.1).snrDb, 80.0); +} + +} // namespace yup::test From 19cf68ea8bb7b956a0a0eefd81f881fc1ceb1efa Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 12:04:30 +0200 Subject: [PATCH 06/37] More oversampling fun --- CHANGELOG.md | 3 + docs/dsp/index.md | 4 +- docs/dsp/oscillators.md | 3 +- docs/dsp/resampling.md | 80 +- .../source/examples/SpectrumAnalyzer.h | 56 +- .../resampling/yup_HalfbandOversampler.h | 779 ++++++++++++++++++ modules/yup_dsp/resampling/yup_Resampler.h | 4 +- ...up_Oversampler.h => yup_SincOversampler.h} | 33 +- modules/yup_dsp/yup_dsp.h | 5 +- tests/yup_dsp.cpp | 3 +- tests/yup_dsp/yup_HalfbandOversampler.cpp | 638 ++++++++++++++ ...versampler.cpp => yup_SincOversampler.cpp} | 81 +- 12 files changed, 1594 insertions(+), 95 deletions(-) create mode 100644 modules/yup_dsp/resampling/yup_HalfbandOversampler.h rename modules/yup_dsp/resampling/{yup_Oversampler.h => yup_SincOversampler.h} (91%) create mode 100644 tests/yup_dsp/yup_HalfbandOversampler.cpp rename tests/yup_dsp/{yup_Oversampler.cpp => yup_SincOversampler.cpp} (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8396e420e..98ef3a111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `GpuBuffer::Impl`, `GpuBuffer::getImpl()` and `GpuBuffer::createWithImpl()` moved from `public:` to `private:` (they were marked `@internal` by comment only); the backend factories that use them are friends. `GpuTexture::getOreTexture()` and `GpuTexture::getRenderImage()` were removed - neither had any caller. - `DragAndDropData` is now a MIME store and moved from `component/` to the new `dragdrop/` folder. Payloads are held as an `Array` under well-known MIME types (`DragAndDropData::mimeTypeText` / `mimeTypeUriList` / `mimeTypePng`), with text, files, URIs and images exposed as convenience accessors over that single store plus an optional same-process `var` native object. Consequently `getFiles()`, `getText()` and `getUris()` now return by value (`Array` / `String` / `StringArray`) rather than `const&` (they decode from the MIME store), and the class gained `withImage` / `withMimeData` / `withNativeObject` with the matching `get*` / `has*` / `getMimeTypes` / `getAllMimeData` accessors. There are no lazy or promised data providers: every MIME blob is an eagerly-owned `MemoryBlock`. - The five drag-and-drop virtuals on `Component` (`isInterestedInDrag`, `itemsDropped`, `itemDragEnter`, `itemDragMove`, `itemDragExit`) and the `Component::internalItemDrag*` dispatch they fed have been removed, so `Component` no longer carries any drag-and-drop surface. Drop targets are now an opt-in mixin: derive from `DragAndDropTarget` (in `dragdrop/`) alongside `Component` and override `isInterestedInDragSource` / `itemDropped` / `itemDragEnter` / `itemDragMove` / `itemDragExit` — or assign the matching `std::function` members — each receiving a single `DragAndDropSourceDetails` that carries the payload, the source component, the target-local position, the allowed actions and the suggested action. The library finds targets with a `dynamic_cast` (see `DragAndDropTarget::dispatchItemDrop()` and friends), preserving the previous enter/move/exit and child-to-parent drop-bubbling semantics. The Python bindings for the removed `Component` hooks were dropped; `DragAndDropTarget` and `DragAndDropTargetComponent` are bound, as are `DragAndDropSource` and its `DragOptions`. +- `Oversampler` was renamed `SincOversampler` (`resampling/yup_SincOversampler.h`) now that it is one of two oversampler designs, and the `Oversampler2xFloat` … `Oversampler32xDouble` aliases name `HalfbandOversampler` instantiations (100 dB, passband to 0.45 of the input rate, linear-phase FIR) instead of it. The call surface is identical, but `getLatencyInSamples()` reports the cascade's own delay (72 input samples at 4× with the defaults, against 32 before) and `getGenerationLatencyInSamples()` is no longer `static constexpr`. Code that needs the sinc design or a non power-of-two factor should spell out `SincOversampler` - `ComponentNative::setFocusedComponent()` takes a second `FocusChangeType` argument saying what moved the focus. It defaults to `FocusChangeType::focusChangedDirectly`, so callers are unaffected, but any class implementing the pure virtual has to match the new signature. ### Core @@ -40,6 +41,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `Oversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor - Added shared `WaveformBank`, spectral `MorphingOscillator`, and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `Oversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. +- Added `HalfbandOversampler` (`yup_dsp/resampling/`), a power-of-two oversampler built from a cascade of 2× halfband stages so that only the stage next to the base rate has to be steep. `Design` selects the family and targets: `linearPhaseFIR` designs Kaiser-windowed halfbands, verifies every stage's stopband numerically at `prepare()` and keeps both latencies whole input samples by rounding the cascade's delay at the top rate; `polyphaseIIR` designs elliptic halfbands as two allpass branches (Valenzuela & Constantinides) for a few multiplies per sample and minimal, frequency-dependent latency. Defaults are 100 dB of rejection with a passband to 0.45 of the input rate. At 4× the FIR decimates in about 94 MACs per input sample against 128 for the radius-16 `SincOversampler` at 90 dB and a 0.36 passband; at 32× it is about 330 against 1024, and the IIR cascade needs about 76 multiplies. The public methods mirror `SincOversampler` so the two are drop-in replacements +- `SincOversampler` (the single-stage polyphase sinc formerly named `Oversampler`) is several times faster and rejects images and aliases far better. Each channel now keeps its history contiguously in front of the staging buffer so every output sample is one `dotProduct` (SIMD for `float`/`float` and, newly, `double`/`double` via `FloatVectorOperations`), replacing the per-tap circular-buffer modulo and strided table reads; the kernels are prebuilt phase-major with the gain baked in. Both kernels use Kaiser β = 9 (was 5), and `SincTable::applyKaiserWindow` now spans exactly the kernel radius (it previously windowed over `(SincRadius + 1) · OversampleFactor` entries while the kernel was used out to `SincRadius · OversampleFactor`, cutting it off at about 8 % of the window peak and capping the rejection of `SincOversampler` and `Resampler` alike). The `Oversampler2xFloat` … `Oversampler8xDouble` aliases moved from radius 8 to radius 16, so their reported latency grows from 16 to 32 input samples, and `Oversampler16xFloat` / `Oversampler32xFloat` plus their `Double` variants were added - Fixed the pulsar spectral resampler's alternating coefficient sign and corrected oscillator regression tests for fixed-size copies, startup crossfades, and spectral window leakage. diff --git a/docs/dsp/index.md b/docs/dsp/index.md index 7a4f12304..e46c8f865 100644 --- a/docs/dsp/index.md +++ b/docs/dsp/index.md @@ -41,8 +41,8 @@ available in the build. and the end-to-end `OnsetDetector`. - [Convolution & delay](convolution-and-delay.md) - the `PartitionedConvolver` and the `FractionallyAddressedDelay` interpolation delay line. -- [Resampling](resampling.md) - `Oversampler`, `Resampler`, `SincTable`, and - the `CircularBuffer` helper. +- [Resampling](resampling.md) - `HalfbandOversampler`, `SincOversampler`, + `Resampler`, `SincTable`, and the `CircularBuffer` helper. - [Time-stretching & pitch-shifting](time-stretching.md) - the `TimeStretchProcessor` with its time-domain and Bungee backends. - [Oscillators](oscillators.md) - `FourierSeries`, the alias-free diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 66d387bf7..8ae217943 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -271,7 +271,8 @@ including worst-case parameter updates. No timing claims are implied by the API. uses for its inverse transform. - [Math, windowing & noise](math.md) - `DspMath`, which provides the harmonic phasor table used by the transform and the pre-rotation. -- [Resampling](resampling.md) - `Oversampler` and `Resampler` for sample-rate +- [Resampling](resampling.md) - `SincOversampler`, `HalfbandOversampler` and + `Resampler` for sample-rate conversion, as opposed to the spectral resampling done here. ## Reference diff --git a/docs/dsp/resampling.md b/docs/dsp/resampling.md index b1be93d43..dd0e9b069 100644 --- a/docs/dsp/resampling.md +++ b/docs/dsp/resampling.md @@ -2,9 +2,10 @@ The resampling stack is built on precomputed windowed-sinc interpolation tables with per-channel history buffers, so it operates seamlessly across -audio blocks (real-time safe). It covers integer-factor oversampling, async -sample-rate conversion, and the two building blocks: a compile-time circular -buffer and the sinc lookup table. +audio blocks (real-time safe). It covers integer-factor oversampling (a +halfband cascade for power-of-two factors and a single-stage sinc for any +factor), async sample-rate conversion, and the two building blocks: a +compile-time circular buffer and the sinc lookup table. ## Building blocks @@ -49,15 +50,15 @@ by the second half of a Kaiser window spanning exactly the kernel radius coefficient; the entries beyond the radius (tap `SincRadius` with a nonzero fractional phase) are zeroed, so the kernel decays smoothly to zero at its edge. -## Oversampler +## SincOversampler -`Oversampler` provides +`SincOversampler` provides multi-channel integer-factor oversampling (typically 2×/4×/8×) for processing chains that need headroom — distortion, nonlinear filters, etc. Compile-time constraints: `OversampleFactor >= 2`, `SincRadius >= 1`. ```cpp -yup::Oversampler os; // 4x oversampling, sinc radius 16 +yup::SincOversampler os; // 4x oversampling, sinc radius 16 os.prepare (44100.0, 2, 512); // audio thread: @@ -95,11 +96,72 @@ os.downsample (outPtrs, numChannels, numSamples); generation followed by `downsample`. - `reset()` clears history without re-preparing. +This single-stage design remains for arbitrary integer factors and fixed, +compile-time kernel sizes. For power-of-two factors prefer +`HalfbandOversampler` below, which is what the `Oversampler2xFloat` … +`Oversampler32xDouble` aliases refer to. + +## HalfbandOversampler + +`HalfbandOversampler` oversamples by +a power of two through a cascade of 2× halfband stages. Every other tap of a +halfband filter is zero, so a stage costs about a quarter of its nominal +length, and only the stage next to the base rate has to be steep: each further +stage only rejects what would fold into the passband and shrinks to a handful +of taps. The result is a deeper stopband and a steeper edge than +`SincOversampler` for less work, and the advantage grows with the factor. + +```cpp +yup::HalfbandOversampler os; // 4x, linear-phase FIR, 100 dB, flat to 0.45 fs +os.prepare (44100.0, 2, 512); + +yup::HalfbandOversamplerDesign design; // shared by every instantiation +design.filterType = yup::HalfbandFilterType::polyphaseIIR; +design.stopbandAttenuationDb = 120.0; +design.passbandEdge = 0.40; // fraction of the input rate +yup::HalfbandOversampler lowLatency; +lowLatency.prepare (44100.0, 2, 512, design); + +// audio thread, identical to SincOversampler: +os.upsample (inPtrs, numChannels, numSamples); +os.processOversampledBlock ([] (auto& buffer) { applyDistortion (buffer); }); +os.downsample (outPtrs, numChannels, numSamples); +``` + +- `HalfbandOversamplerDesign` (aliased as `Design` inside the class) selects + the filter family and the targets every stage must meet: + `stopbandAttenuationDb` (default 100) and `passbandEdge` as a fraction of the + input rate (default 0.45, i.e. 19.8 kHz at 44.1 kHz). Content between + `passbandEdge` and `1 - passbandEdge` of the input Nyquist folds back into + the top of the band, as with every halfband design. +- `HalfbandFilterType::linearPhaseFIR` designs Kaiser-windowed halfbands and + verifies each stage's stopband numerically at `prepare()` time, lengthening + the filter until the target is met. Phase is exactly linear and both + latencies are whole input samples: a small delay at the top rate rounds the + cascade's fractional delay up. With the defaults, 4× costs about 94 MACs per + input sample to decimate (188 for the round trip) with a 72-sample round-trip + latency; 32× costs about 330 / 660 MACs. `SincOversampler` at radius 16 + needs 128 / 256 and 1024 / 2048 for 90 dB and a passband to 0.36 fs. +- `HalfbandFilterType::polyphaseIIR` designs elliptic halfbands realised as + two allpass branches (Valenzuela & Constantinides). A 100 dB first stage is + order 17, eight multiplies per sample, and the whole 32× cascade decimates in + about 76 multiplies per input sample. Latency is a few samples but the phase + is nonlinear near the passband edge; `getLatencyInSamples()` reports the + low-frequency group delay rounded to the nearest sample. +- `prepare` is **not** realtime-safe. `sampleRate` is accepted for symmetry + with `SincOversampler`; the design itself is rate independent. +- `upsample`, `beginGeneration`, `processOversampledBlock`, + `getOversampledChannelData`, `downsample`, `reset`, `getLatencyInSamples` + and `getGenerationLatencyInSamples` behave exactly as on `SincOversampler`, so + the two classes are drop-in replacements for each other. +- `getDesign()` returns the applied design; `getStageFilterOrder (stage)` + returns the FIR length or the elliptic order of a stage (stage 0 runs next to + the input rate) for diagnostics. + Convenience aliases: `Oversampler2xFloat`, `Oversampler4xFloat`, `Oversampler8xFloat`, `Oversampler16xFloat`, `Oversampler32xFloat` and the -`Double` variants (all radius 16, latency 32 input samples). The decimation -FIR has `2·SincRadius·OversampleFactor + 1` taps per output sample, so 16× and -32× cost 513 and 1025 taps respectively. +`Double` variants are `HalfbandOversampler` instantiations with the default +design. ## Resampler diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index 299a14ef9..eda05e048 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -144,6 +144,15 @@ class SignalGenerator waveform = newWaveform; } + void setOversamplerFilterType (yup::HalfbandFilterType type) + { + if (oversamplerFilterType == type) + return; + + oversamplerFilterType = type; + prepareResampling (maxOutputBlockSize); + } + void setSweepParameters (double startFreq, double endFreq, double durationSeconds) { sweepStartFreq = startFreq; @@ -257,11 +266,14 @@ class SignalGenerator // The oversampled sweeps decimate straight to the device rate, so the // resampler's alias floor never enters their chain. - oversampler2x.prepare (sampleRate, 1, maxOutputBlockSize); - oversampler4x.prepare (sampleRate, 1, maxOutputBlockSize); - oversampler8x.prepare (sampleRate, 1, maxOutputBlockSize); - oversampler16x.prepare (sampleRate, 1, maxOutputBlockSize); - oversampler32x.prepare (sampleRate, 1, maxOutputBlockSize); + yup::HalfbandOversamplerDesign design; + design.filterType = oversamplerFilterType; + + oversampler2x.prepare (sampleRate, 1, maxOutputBlockSize, design); + oversampler4x.prepare (sampleRate, 1, maxOutputBlockSize, design); + oversampler8x.prepare (sampleRate, 1, maxOutputBlockSize, design); + oversampler16x.prepare (sampleRate, 1, maxOutputBlockSize, design); + oversampler32x.prepare (sampleRate, 1, maxOutputBlockSize, design); resetSweepPlaybackState(); } @@ -474,6 +486,7 @@ class SignalGenerator SignalType signalType; SweepPlaybackMode sweepPlaybackMode; Waveform waveform = Waveform::sine; + yup::HalfbandFilterType oversamplerFilterType = yup::HalfbandFilterType::linearPhaseFIR; // Sweep parameters double sweepStartFreq, sweepEndFreq, sweepDurationSeconds; @@ -807,6 +820,17 @@ class SpectrumAnalyzerDemo }; addAndMakeVisible (*waveformCombo); + // Halfband filter family used by the oversampled sweep modes + oversamplerFilterCombo = std::make_unique ("OversamplerFilter"); + oversamplerFilterCombo->addItem ("Linear-phase FIR", 1); + oversamplerFilterCombo->addItem ("Polyphase IIR", 2); + oversamplerFilterCombo->setSelectedId (1); + oversamplerFilterCombo->onSelectedItemChanged = [this] + { + updateOversamplerFilter(); + }; + addAndMakeVisible (*oversamplerFilterCombo); + // Frequency control frequencySlider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency"); frequencySlider->setRange ({ 20.0, 22000.0 }); @@ -1007,7 +1031,7 @@ class SpectrumAnalyzerDemo // Create parameter labels with proper font sizing auto labelFont = font.withHeight (12.0f); - for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:", "Waveform:" }) + for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:", "Waveform:", "OS Filter:" }) { auto label = parameterLabels.add (std::make_unique (labelText)); label->setText (labelText); @@ -1092,6 +1116,7 @@ class SpectrumAnalyzerDemo auto overlapSection = row3.removeFromLeft (colWidth); auto levelModeSection = row3.removeFromLeft (colWidth); auto waveformSection = row3.removeFromLeft (colWidth); + auto oversamplerFilterSection = row3.removeFromLeft (colWidth); parameterLabels[9]->setBounds (releaseSection.removeFromTop (labelHeight)); releaseSlider->setBounds (releaseSection.removeFromTop (controlHeight)); @@ -1105,6 +1130,9 @@ class SpectrumAnalyzerDemo parameterLabels[13]->setBounds (waveformSection.removeFromTop (labelHeight)); waveformCombo->setBounds (waveformSection.removeFromTop (controlHeight)); + parameterLabels[14]->setBounds (oversamplerFilterSection.removeFromTop (labelHeight)); + oversamplerFilterCombo->setBounds (oversamplerFilterSection.removeFromTop (controlHeight)); + // Fourth row: Status labels auto row4 = bounds.removeFromTop (30); auto freqStatus = row4.removeFromLeft (bounds.getWidth() / 3); @@ -1198,6 +1226,9 @@ class SpectrumAnalyzerDemo sweepDurationSlider->setEnabled (signalType == SignalGenerator::SignalType::frequencySweep); waveformCombo->setEnabled (signalType == SignalGenerator::SignalType::singleTone || signalType == SignalGenerator::SignalType::frequencySweep); + oversamplerFilterCombo->setEnabled (signalType == SignalGenerator::SignalType::frequencySweep + && sweepPlaybackMode != SignalGenerator::SweepPlaybackMode::direct + && sweepPlaybackMode != SignalGenerator::SweepPlaybackMode::resampled); } void updateWaveform() @@ -1225,6 +1256,18 @@ class SpectrumAnalyzerDemo }); } + void updateOversamplerFilter() + { + const auto type = oversamplerFilterCombo->getSelectedId() == 2 + ? yup::HalfbandFilterType::polyphaseIIR + : yup::HalfbandFilterType::linearPhaseFIR; + + updateSignalGenerator ([type] (SignalGenerator& generator) + { + generator.setOversamplerFilterType (type); + }); + } + void updateFFTSize() { int selectedId = fftSizeCombo->getSelectedId(); @@ -1396,6 +1439,7 @@ class SpectrumAnalyzerDemo // Signal controls std::unique_ptr signalTypeCombo; std::unique_ptr waveformCombo; + std::unique_ptr oversamplerFilterCombo; std::unique_ptr frequencySlider; std::unique_ptr amplitudeSlider; std::unique_ptr sweepDurationSlider; diff --git a/modules/yup_dsp/resampling/yup_HalfbandOversampler.h b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h new file mode 100644 index 000000000..55d5d3cc5 --- /dev/null +++ b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h @@ -0,0 +1,779 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Halfband filter families available to HalfbandOversampler. */ +enum class HalfbandFilterType +{ + linearPhaseFIR, /**< Kaiser-windowed halfband FIR: exact linear phase and integer latency. */ + polyphaseIIR /**< Elliptic allpass polyphase halfband: a few multiplies per sample and + minimal latency, at the cost of a nonlinear phase near the band edge. */ +}; + +//============================================================================== +/** Filter design targets applied by HalfbandOversampler::prepare(). */ +struct HalfbandOversamplerDesign +{ + /** Filter family used by every stage. */ + HalfbandFilterType filterType = HalfbandFilterType::linearPhaseFIR; + + /** Minimum stopband rejection of every stage, in dB. */ + double stopbandAttenuationDb = 100.0; + + /** Passband edge as a fraction of the input sample rate, in (0, 0.5). */ + double passbandEdge = 0.45; +}; + +//============================================================================== +/** + Multi-channel power-of-two oversampler built from a cascade of 2x halfband stages. + + Each stage doubles or halves the rate with a halfband filter, whose every + other tap is zero, so a stage costs about a quarter of its nominal length. + Only the stage next to the base rate has to be steep; every further stage + only rejects what would fold into the passband and shrinks to a handful of + taps. Compared with a single polyphase sinc kernel this gives a deeper + stopband and a steeper edge for less work, and the advantage grows with the + factor. + + Two filter families are available through Design::filterType: + - linearPhaseFIR designs Kaiser-windowed halfbands whose stopband is verified + numerically at prepare() time. Latency is an exact integer number of input + samples on both the round trip and the generation path. + - polyphaseIIR designs elliptic halfbands realised as two allpass branches. + They cost a few multiplies per sample and have a fraction of the FIR + latency, but the phase is nonlinear near the passband edge and the latency + reported is the low-frequency group delay rounded to the nearest sample. + + The passband extends to Design::passbandEdge times the input rate (0.45 by + default, 19.8 kHz at 44.1 kHz) with at least Design::stopbandAttenuationDb of + rejection (100 dB by default). Content between passbandEdge and + 1 - passbandEdge of the input Nyquist folds back into the top of the band, + as with every halfband design. + + Typical usage: + @code + yup::HalfbandOversampler os; + os.prepare (44100.0, 2, 512); + + os.upsample (inputPtrs, numChannels, numSamples); + os.processOversampledBlock ([&] (auto& buf) { applyDistortion (buf); }); + os.downsample (outputPtrs, numChannels, numSamples); + @endcode + + @tparam SampleType Audio sample type (float or double). + @tparam OversampleFactor Integer upsample ratio, a power of two (2, 4, 8, …). + @tparam CoeffType Precision for filter design, coefficients and accumulation (default double). + + @see SincOversampler +*/ +template +class HalfbandOversampler +{ +public: + static_assert (OversampleFactor >= 2 && isPowerOfTwo (OversampleFactor), "OversampleFactor must be a power of two >= 2"); + + /** Number of 2x stages in the cascade (log2 of the factor). */ + static constexpr int numStages = std::bit_width (static_cast (OversampleFactor)) - 1; + + /** Filter design targets, applied by prepare(); shared by every instantiation. */ + using Design = HalfbandOversamplerDesign; + + //============================================================================== + /** Default constructor. Call prepare() before any processing. */ + HalfbandOversampler() = default; + + /** Destructor. */ + ~HalfbandOversampler() = default; + + //============================================================================== + /** + Prepares the oversampler for processing. + + Designs every stage from the requested Design and allocates the staging + buffers. Must be called before upsample(), beginGeneration() or downsample(). + Not realtime-safe. + + @param sampleRate Input sample rate in Hz (the design itself is rate independent). + @param maxChannels Maximum number of audio channels. + @param maxBlockSize Maximum input block size in samples. + @param newDesign Filter family and quality targets. + */ + void prepare (double sampleRate, int maxChannels, int maxBlockSize, const Design& newDesign = {}) + { + jassert (sampleRate > 0.0 && maxChannels > 0 && maxBlockSize > 0); + jassert (newDesign.stopbandAttenuationDb >= 20.0); + jassert (newDesign.passbandEdge > 0.0 && newDesign.passbandEdge < 0.5); + ignoreUnused (sampleRate); + + design = newDesign; + design.stopbandAttenuationDb = jlimit (20.0, 200.0, design.stopbandAttenuationDb); + design.passbandEdge = jlimit (0.05, 0.49, design.passbandEdge); + maxChannelCount = maxChannels; + maxInputSamples = maxBlockSize; + + double interpolationDelay = 0.0; + + for (int s = 0; s < numStages; ++s) + { + auto& stage = stages[static_cast (s)]; + const int lowRate = 1 << s; + const double transition = 0.5 - 2.0 * design.passbandEdge / (2 * lowRate); + + if (design.filterType == HalfbandFilterType::linearPhaseFIR) + { + designFirStage (stage, transition); + interpolationDelay += (2.0 * stage.halfLength + 1.0) / (2 * lowRate); + } + else + { + designIirStage (stage, transition); + interpolationDelay += stage.lowFrequencyDelay / lowRate; + } + + const int lowBlock = maxBlockSize * lowRate; + const int branchTaps = static_cast (stage.decimationTaps.size()); + + stage.interpolationInput.setSize (maxChannels, lowBlock + jmax (0, branchTaps - 1)); + stage.interpolationOutput.setSize (maxChannels, 2 * lowBlock); + stage.evenInput.setSize (maxChannels, lowBlock + jmax (0, branchTaps - 1)); + stage.oddInput.setSize (maxChannels, lowBlock + stage.halfLength + 1); + stage.decimationOutput.setSize (maxChannels, lowBlock); + + const auto numSections = stage.directAllpass.size() + stage.delayedAllpass.size(); + stage.upStates.assign (static_cast (maxChannels) * 2 * numSections, CoeffType (0)); + stage.downStates.assign (static_cast (maxChannels) * (2 * numSections + 1), CoeffType (0)); + } + + if (design.filterType == HalfbandFilterType::linearPhaseFIR) + { + const double rounded = std::ceil (interpolationDelay); + paddingSamples = roundToInt ((rounded - interpolationDelay) * OversampleFactor); + generationLatency = static_cast (rounded); + roundTripLatency = 2 * generationLatency; + } + else + { + paddingSamples = 0; + generationLatency = roundToInt (interpolationDelay); + roundTripLatency = roundToInt (2.0 * interpolationDelay); + } + + auto& top = stages[static_cast (numStages - 1)]; + top.interpolationOutput.setSize (maxChannels, maxBlockSize * OversampleFactor + paddingSamples); + paddedInput.setSize (maxChannels, maxBlockSize * OversampleFactor + paddingSamples); + oversampledBuffer.setSize (maxChannels, maxBlockSize * OversampleFactor, false, false, true); + + reset(); + } + + /** + Resets all internal processing state. + + Clears every stage's history and filter state; the design is preserved, + so there is no need to call prepare() again. + */ + void reset() noexcept + { + for (auto& stage : stages) + { + stage.interpolationInput.clear(); + stage.interpolationOutput.clear(); + stage.evenInput.clear(); + stage.oddInput.clear(); + stage.decimationOutput.clear(); + std::fill (stage.upStates.begin(), stage.upStates.end(), CoeffType (0)); + std::fill (stage.downStates.begin(), stage.downStates.end(), CoeffType (0)); + } + + paddedInput.clear(); + oversampledBuffer.clear(); + + currentOversampledSize = 0; + currentNumChannels = 0; + } + + //============================================================================== + /** + Upsample an input block into the internal oversampled buffer. + + After this call the internal buffer holds numSamples * OversampleFactor + bandlimited samples per channel, accessible via getOversampledChannelData() + or processOversampledBlock(). + + @param input Array of read pointers, one per channel (channel-major). + @param numChannels Number of channels to process (must be <= maxChannels from prepare()). + @param numSamples Number of input samples per channel (must be <= maxBlockSize). + */ + void upsample (const SampleType* const* input, int numChannels, int numSamples) noexcept + { + ScopedNoDenormals noDenormals; + + jassert (numChannels > 0 && numSamples > 0); + jassert (numChannels <= maxChannelCount); + jassert (numSamples <= maxInputSamples); + + currentOversampledSize = numSamples * OversampleFactor; + currentNumChannels = numChannels; + oversampledBuffer.setSize (numChannels, currentOversampledSize, false, false, true); + + for (int ch = 0; ch < numChannels; ++ch) + { + const SampleType* current = input[ch]; + int count = numSamples; + + for (int s = 0; s < numStages; ++s) + { + auto& stage = stages[static_cast (s)]; + auto* out = stage.interpolationOutput.getWritePointer (ch) + ((s == numStages - 1) ? paddingSamples : 0); + + if (design.filterType == HalfbandFilterType::linearPhaseFIR) + interpolateFir (stage, ch, current, out, count); + else + interpolateIir (stage, ch, current, out, count); + + current = out; + count *= 2; + } + + // The top stage writes behind paddingSamples carried from the previous + // block, which rounds the cascade's delay to a whole input sample. + auto* top = stages[static_cast (numStages - 1)].interpolationOutput.getWritePointer (ch); + FloatVectorOperations::copy (oversampledBuffer.getWritePointer (ch), top, count); + std::copy (top + count, top + count + paddingSamples, top); + } + } + + /** Starts a block generated directly at the oversampled rate. + + Call after prepare(), fill every sample obtained through + getOversampledChannelData(), then call downsample(). No input upsampling + is performed and no memory is allocated. Returns false for nonpositive + sizes or sizes exceeding the prepared channel/block capacity; a pending + block is left unchanged on failure. The buffer contents are unspecified. + + @param numChannels Number of generated channels. + @param numSamples Number of samples per channel at the output rate. + @see getGenerationLatencyInSamples + */ + bool beginGeneration (int numChannels, int numSamples) noexcept + { + if (numChannels <= 0 || numSamples <= 0 + || numChannels > maxChannelCount + || numSamples > maxInputSamples) + return false; + + currentOversampledSize = numSamples * OversampleFactor; + currentNumChannels = numChannels; + oversampledBuffer.setSize (numChannels, currentOversampledSize, false, false, true); + return true; + } + + /** + Downsample the internal oversampled buffer into an output block. + + Runs the decimation cascade on the oversampled data. Must be called after + the oversampled buffer has been processed (e.g. via processOversampledBlock()). + + @param output Array of write pointers, one per channel. + @param numChannels Number of channels to write (must match the numChannels + passed to the preceding upsample() or beginGeneration() call). + @param numSamples Number of output samples per channel (must match the numSamples + passed to the preceding upsample() or beginGeneration() call). + */ + void downsample (SampleType* const* output, int numChannels, int numSamples) noexcept + { + ScopedNoDenormals noDenormals; + + jassert (numChannels > 0 && numSamples > 0); + jassert (numChannels == currentNumChannels); + jassert (currentOversampledSize > 0); + jassert (numSamples * OversampleFactor == currentOversampledSize); + + for (int ch = 0; ch < numChannels; ++ch) + { + const SampleType* current = oversampledBuffer.getReadPointer (ch); + int count = currentOversampledSize; + + auto* padded = paddedInput.getWritePointer (ch); + + if (paddingSamples > 0) + { + FloatVectorOperations::copy (padded + paddingSamples, current, count); + current = padded; + } + + for (int s = numStages - 1; s >= 0; --s) + { + auto& stage = stages[static_cast (s)]; + auto* out = (s == 0) ? output[ch] : stage.decimationOutput.getWritePointer (ch); + + if (design.filterType == HalfbandFilterType::linearPhaseFIR) + decimateFir (stage, ch, current, out, count); + else + decimateIir (stage, ch, current, out, count); + + current = out; + count /= 2; + } + + if (paddingSamples > 0) + std::copy (padded + currentOversampledSize, padded + currentOversampledSize + paddingSamples, padded); + } + + currentOversampledSize = 0; + currentNumChannels = 0; + } + + //============================================================================== + /** + Invokes a callback with the internal oversampled multi-channel buffer. + + The callback receives a reference to the internal `AudioBuffer` + with the channel count of the most recent upsample() or beginGeneration() + call and getOversampledNumSamples() samples per channel. If there is no + pending oversampled block, the callback receives an empty buffer. + + @param callback Callable with signature `void(AudioBuffer&)`. + */ + template + void processOversampledBlock (Callable&& callback) + { + if (currentOversampledSize == 0 || currentNumChannels == 0) + { + AudioBuffer emptyBuffer; + callback (emptyBuffer); + return; + } + + callback (oversampledBuffer); + } + + //============================================================================== + /** + Returns a writable pointer to the data for a single oversampled channel. + + @param channel Zero-based channel index. + @return Pointer to getOversampledNumSamples() contiguous samples, + or nullptr if the channel index is out of range, prepare() + has not been called, or the channel was not processed by + the most recent upsample() or beginGeneration() call. + */ + forcedinline SampleType* getOversampledChannelData (int channel) noexcept + { + if (channel < 0 || channel >= currentNumChannels) + return nullptr; + + return oversampledBuffer.getWritePointer (channel); + } + + /** @copydoc getOversampledChannelData(int) */ + const forcedinline SampleType* getOversampledChannelData (int channel) const noexcept + { + if (channel < 0 || channel >= currentNumChannels) + return nullptr; + + return oversampledBuffer.getReadPointer (channel); + } + + /** + Returns the number of samples currently in each oversampled channel. + + Equal to the numSamples argument of the pending upsample() or + beginGeneration() call multiplied by OversampleFactor. Returns 0 before + either call, after downsample(), or after reset(). + */ + forcedinline int getOversampledNumSamples() const noexcept + { + return currentOversampledSize; + } + + /** + Returns the round-trip latency of upsample() followed by downsample(). + + Exact for linearPhaseFIR; the low-frequency group delay rounded to the + nearest sample for polyphaseIIR. Valid after prepare(). + + @return Latency in input-rate samples. + */ + forcedinline int getLatencyInSamples() const noexcept + { + return roundTripLatency; + } + + /** Returns the latency of generation followed by downsample(), in output samples. + + Unlike getLatencyInSamples(), this excludes the input interpolation stage. + Valid after prepare(). + */ + forcedinline int getGenerationLatencyInSamples() const noexcept + { + return generationLatency; + } + + /** Returns the design applied by the last prepare() call. */ + const Design& getDesign() const noexcept + { + return design; + } + + /** + Returns the filter order of one stage: the FIR length for linearPhaseFIR, + the elliptic order for polyphaseIIR. Stage 0 runs next to the input rate. + */ + int getStageFilterOrder (int stage) const noexcept + { + if (stage < 0 || stage >= numStages) + return 0; + + const auto& s = stages[static_cast (stage)]; + + if (design.filterType == HalfbandFilterType::linearPhaseFIR) + return 4 * s.halfLength + 3; + + return 2 * static_cast (s.directAllpass.size() + s.delayedAllpass.size()) + 1; + } + +private: + //============================================================================== + struct Stage + { + int halfLength = 0; // FIR length is 4 * halfLength + 3 + std::vector decimationTaps; // nonzero taps of one polyphase branch, summing to 0.5 + std::vector interpolationTaps; // the same taps scaled by 2 + + std::vector directAllpass; // IIR sections of the direct branch + std::vector delayedAllpass; // IIR sections of the branch behind the unit delay + double lowFrequencyDelay = 0.0; // IIR group delay at DC, in low-rate samples + + AudioBuffer interpolationInput; + AudioBuffer interpolationOutput; + AudioBuffer evenInput; + AudioBuffer oddInput; + AudioBuffer decimationOutput; + + std::vector upStates; + std::vector downStates; + }; + + //============================================================================== + static double kaiserBeta (double attenuationDb) noexcept + { + if (attenuationDb > 50.0) + return 0.1102 * (attenuationDb - 8.7); + + if (attenuationDb > 21.0) + return 0.5842 * std::pow (attenuationDb - 21.0, 0.4) + 0.07886 * (attenuationDb - 21.0); + + return 0.0; + } + + // Builds the nonzero polyphase branch of a Kaiser-windowed halfband of the + // given length (which must be 3 mod 4), normalized to a DC gain of exactly 1. + static void buildHalfbandBranch (int length, double beta, std::vector& branch) + { + const int q = (length - 3) / 4; + const int center = 2 * q + 1; + branch.resize (static_cast (2 * q + 2)); + double sum = 0.0; + + for (int j = 0; j < static_cast (branch.size()); ++j) + { + const double x = (2 * j - center) / 2.0; + const double sinc = std::sin (MathConstants::pi * x) / (MathConstants::pi * x); + branch[static_cast (j)] = 0.5 * sinc * WindowFunctions::kaiser (2 * j, length, beta); + sum += branch[static_cast (j)]; + } + + for (auto& tap : branch) + tap *= 0.5 / sum; + } + + // Worst magnitude of the halfband response over its stopband, sampled densely enough + // to catch every ripple of a filter of the given length. + static double worstStopbandGain (const std::vector& branch, int length, double transition) noexcept + { + const int center = length / 2; + const double stopbandEdge = 0.5 - (0.5 - transition) / 2.0; + const int numPoints = 8 * length; + double worst = 0.0; + + for (int p = 0; p <= numPoints; ++p) + { + const double frequency = stopbandEdge + (0.5 - stopbandEdge) * p / numPoints; + const double omega = MathConstants::twoPi * frequency; + double response = 0.5; + + for (int j = 0; j < static_cast (branch.size()); ++j) + response += branch[static_cast (j)] * std::cos (omega * (center - 2 * j)); + + worst = jmax (worst, std::abs (response)); + } + + return worst; + } + + void designFirStage (Stage& stage, double transition) const + { + const double attenuation = design.stopbandAttenuationDb; + const double beta = kaiserBeta (attenuation); + const double stopbandGain = std::pow (10.0, -attenuation / 20.0); + + int length = static_cast (std::ceil ((attenuation - 8.0) / (2.285 * MathConstants::twoPi * transition) + 1.0)); + length = jmax (length, 7); + + while (length % 4 != 3) + ++length; + + std::vector branch; + + for (;;) + { + buildHalfbandBranch (length, beta, branch); + + if (worstStopbandGain (branch, length, transition) <= stopbandGain || length >= maxFirLength) + break; + + length += 4; + } + + stage.halfLength = (length - 3) / 4; + stage.decimationTaps.resize (branch.size()); + stage.interpolationTaps.resize (branch.size()); + + for (std::size_t j = 0; j < branch.size(); ++j) + { + stage.decimationTaps[j] = static_cast (branch[j]); + stage.interpolationTaps[j] = static_cast (2.0 * branch[j]); + } + + stage.directAllpass.clear(); + stage.delayedAllpass.clear(); + stage.lowFrequencyDelay = 0.0; + } + + // Elliptic halfband as two allpass branches (Valenzuela & Constantinides). The + // odd-numbered sections form the direct branch, the even-numbered ones sit + // behind the unit delay; both branches share the same group delay at DC. + void designIirStage (Stage& stage, double transition) const + { + const double wt = MathConstants::twoPi * transition; + const double ds = std::pow (10.0, -design.stopbandAttenuationDb / 20.0); + const double k = square (std::tan ((MathConstants::pi - wt) / 4.0)); + const double kp = std::sqrt (1.0 - k * k); + const double e = 0.5 * (1.0 - std::sqrt (kp)) / (1.0 + std::sqrt (kp)); + const double q = e + 2.0 * std::pow (e, 5) + 15.0 * std::pow (e, 9) + 150.0 * std::pow (e, 13); + const double k1 = ds * ds / (1.0 - ds * ds); + + int order = static_cast (std::ceil (std::log (k1 * k1 / 16.0) / std::log (q))); + order = jmax (order, 3); + + if (order % 2 == 0) + ++order; + + const int numSections = (order - 1) / 2; + stage.directAllpass.clear(); + stage.delayedAllpass.clear(); + stage.lowFrequencyDelay = 0.0; + + for (int i = 1; i <= numSections; ++i) + { + double numerator = 0.0; + + for (int m = 0; m < 64; ++m) + { + const double delta = ((m % 2 == 0) ? 1.0 : -1.0) * std::pow (q, m * (m + 1)) * std::sin ((2 * m + 1) * MathConstants::pi * i / order); + numerator += delta; + + if (std::abs (delta) < 1e-100) + break; + } + + numerator *= 2.0 * std::pow (q, 0.25); + double denominator = 0.0; + + for (int m = 1; m < 64; ++m) + { + const double delta = ((m % 2 == 0) ? 1.0 : -1.0) * std::pow (q, m * m) * std::cos (2 * m * MathConstants::pi * i / order); + denominator += delta; + + if (std::abs (delta) < 1e-100) + break; + } + + denominator = 1.0 + 2.0 * denominator; + + const double w = numerator / denominator; + const double a = std::sqrt (jmax (0.0, (1.0 - w * w * k) * (1.0 - w * w / k))) / (1.0 + w * w); + const double alpha = (1.0 - a) / (1.0 + a); + + if (i % 2 == 1) + { + stage.directAllpass.push_back (static_cast (alpha)); + stage.lowFrequencyDelay += (1.0 - alpha) / (1.0 + alpha); + } + else + { + stage.delayedAllpass.push_back (static_cast (alpha)); + } + } + + stage.halfLength = 0; + stage.decimationTaps.clear(); + stage.interpolationTaps.clear(); + } + + //============================================================================== + void interpolateFir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept + { + const auto numTaps = stage.interpolationTaps.size(); + const int history = static_cast (numTaps) - 1; + auto* buffer = stage.interpolationInput.getWritePointer (channel); + + FloatVectorOperations::copy (buffer + history, input, count); + + const auto* taps = stage.interpolationTaps.data(); + const int passThrough = stage.halfLength + 1; + + for (int m = 0; m < count; ++m) + { + *output++ = dotProduct (taps, buffer + m, numTaps); + *output++ = buffer[m + passThrough]; + } + + std::copy (buffer + count, buffer + count + history, buffer); + } + + void decimateFir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept + { + const auto numTaps = stage.decimationTaps.size(); + const int evenHistory = static_cast (numTaps) - 1; + const int oddHistory = stage.halfLength + 1; + const int half = count / 2; + + auto* even = stage.evenInput.getWritePointer (channel); + auto* odd = stage.oddInput.getWritePointer (channel); + + for (int i = 0; i < half; ++i) + { + even[evenHistory + i] = input[2 * i]; + odd[oddHistory + i] = input[2 * i + 1]; + } + + const auto* taps = stage.decimationTaps.data(); + + for (int m = 0; m < half; ++m) + output[m] = dotProduct (taps, even + m, numTaps) + static_cast (0.5) * odd[m]; + + std::copy (even + half, even + half + evenHistory, even); + std::copy (odd + half, odd + half + oddHistory, odd); + } + + // First-order allpass (alpha + z^-1) / (1 + alpha z^-1) in one multiply. + static forcedinline CoeffType allpassTick (CoeffType alpha, CoeffType input, CoeffType* state) noexcept + { + const CoeffType output = state[0] + alpha * (input - state[1]); + state[0] = input; + state[1] = output; + return output; + } + + static forcedinline CoeffType runAllpassChain (const std::vector& alphas, CoeffType input, CoeffType* states) noexcept + { + for (std::size_t i = 0; i < alphas.size(); ++i) + input = allpassTick (alphas[i], input, states + 2 * i); + + return input; + } + + void interpolateIir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept + { + const auto numSections = stage.directAllpass.size() + stage.delayedAllpass.size(); + auto* direct = stage.upStates.data() + static_cast (channel) * 2 * numSections; + auto* delayed = direct + 2 * stage.directAllpass.size(); + + for (int m = 0; m < count; ++m) + { + const auto x = static_cast (input[m]); + *output++ = static_cast (runAllpassChain (stage.directAllpass, x, direct)); + *output++ = static_cast (runAllpassChain (stage.delayedAllpass, x, delayed)); + } + } + + void decimateIir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept + { + const auto numSections = stage.directAllpass.size() + stage.delayedAllpass.size(); + auto* direct = stage.downStates.data() + static_cast (channel) * (2 * numSections + 1); + auto* delayed = direct + 2 * stage.directAllpass.size(); + auto& previousOdd = direct[2 * numSections]; + + const int half = count / 2; + + for (int m = 0; m < half; ++m) + { + const auto even = static_cast (input[2 * m]); + const auto oddBefore = (m == 0) ? previousOdd : static_cast (input[2 * m - 1]); + + output[m] = static_cast (CoeffType (0.5) * (runAllpassChain (stage.directAllpass, even, direct) + + runAllpassChain (stage.delayedAllpass, oddBefore, delayed))); + } + + previousOdd = static_cast (input[count - 1]); + } + + //============================================================================== + static constexpr int maxFirLength = 4095; + + Design design; + std::array (numStages)> stages; + + AudioBuffer paddedInput; + AudioBuffer oversampledBuffer; + int paddingSamples = 0; + int generationLatency = 0; + int roundTripLatency = 0; + int maxChannelCount = 0; + int maxInputSamples = 0; + int currentOversampledSize = 0; + int currentNumChannels = 0; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HalfbandOversampler) +}; + +//============================================================================== +/** @name Convenience type aliases for common oversampling configurations (100 dB, passband 0.45) */ +using HalfbandOversampler2xFloat = HalfbandOversampler; /**< 2x halfband oversampler, float */ +using HalfbandOversampler4xFloat = HalfbandOversampler; /**< 4x halfband oversampler, float */ +using HalfbandOversampler8xFloat = HalfbandOversampler; /**< 8x halfband oversampler, float */ +using HalfbandOversampler16xFloat = HalfbandOversampler; /**< 16x halfband oversampler, float */ +using HalfbandOversampler32xFloat = HalfbandOversampler; /**< 32x halfband oversampler, float */ +using HalfbandOversampler2xDouble = HalfbandOversampler; /**< 2x halfband oversampler, double */ +using HalfbandOversampler4xDouble = HalfbandOversampler; /**< 4x halfband oversampler, double */ +using HalfbandOversampler8xDouble = HalfbandOversampler; /**< 8x halfband oversampler, double */ +using HalfbandOversampler16xDouble = HalfbandOversampler; /**< 16x halfband oversampler, double */ +using HalfbandOversampler32xDouble = HalfbandOversampler; /**< 32x halfband oversampler, double */ + +} // namespace yup diff --git a/modules/yup_dsp/resampling/yup_Resampler.h b/modules/yup_dsp/resampling/yup_Resampler.h index 6f6fe06a1..e8f8c6442 100644 --- a/modules/yup_dsp/resampling/yup_Resampler.h +++ b/modules/yup_dsp/resampling/yup_Resampler.h @@ -235,8 +235,8 @@ class Resampler //============================================================================== /** @name Convenience type aliases for common resampling configurations */ ///@{ -using ResamplerFloat = Resampler; /**< Resampler for float samples, 8-tap radius */ -using ResamplerDouble = Resampler; /**< Resampler for double samples, 8-tap radius */ +using ResamplerFloat = Resampler; /**< Resampler for float samples, 16-tap radius */ +using ResamplerDouble = Resampler; /**< Resampler for double samples, 16-tap radius */ ///@} } // namespace yup diff --git a/modules/yup_dsp/resampling/yup_Oversampler.h b/modules/yup_dsp/resampling/yup_SincOversampler.h similarity index 91% rename from modules/yup_dsp/resampling/yup_Oversampler.h rename to modules/yup_dsp/resampling/yup_SincOversampler.h index 623ad9fa5..db6d19f5e 100644 --- a/modules/yup_dsp/resampling/yup_Oversampler.h +++ b/modules/yup_dsp/resampling/yup_SincOversampler.h @@ -28,7 +28,7 @@ namespace yup /** Multi-channel integer-factor oversampler using windowed sinc interpolation. - Oversampler up- and downsamples audio by an integer factor with + SincOversampler up- and downsamples audio by an integer factor with bandlimited interpolation and anti-aliasing. Each channel keeps the last kernel-length of samples contiguously in front of its staging buffer, so every output sample is a single contiguous dot product and multi-block @@ -43,7 +43,7 @@ namespace yup Typical usage in an audio effect: @code - yup::Oversampler os; + yup::SincOversampler os; os.prepare (44100.0, 2, 512); // Inside your audio callback: @@ -55,15 +55,21 @@ namespace yup os.downsample (outputPtrs, numChannels, numSamples); @endcode + For power-of-two factors, HalfbandOversampler reaches a deeper stopband and a + steeper edge for less work; this single-stage design remains for arbitrary + integer factors and for fixed, compile-time kernel sizes. + @tparam SampleType Audio sample type (float or double). - @tparam OversampleFactor Integer upsample ratio (2, 4, 8, …). + @tparam OversampleFactor Integer upsample ratio (2, 3, 4, 5, …). @tparam SincRadius Half-width of the sinc kernel in original-rate samples. Higher values give a steeper transition and deeper stopband at the cost of more computation and latency. @tparam CoeffType Precision for filter design and accumulation (default double). + + @see HalfbandOversampler */ template -class Oversampler +class SincOversampler { public: static_assert (OversampleFactor >= 2, "OversampleFactor must be at least 2"); @@ -71,10 +77,10 @@ class Oversampler //============================================================================== /** Default constructor. Call prepare() before any processing. */ - Oversampler() = default; + SincOversampler() = default; /** Destructor. */ - ~Oversampler() = default; + ~SincOversampler() = default; //============================================================================== /** @@ -393,20 +399,7 @@ class Oversampler int currentOversampledSize = 0; int currentNumChannels = 0; - YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Oversampler) + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SincOversampler) }; -//============================================================================== -/** @name Convenience type aliases for common oversampling configurations (latency 32 samples) */ -using Oversampler2xFloat = Oversampler; /**< 2x oversampler, float, 16-tap radius */ -using Oversampler4xFloat = Oversampler; /**< 4x oversampler, float, 16-tap radius */ -using Oversampler8xFloat = Oversampler; /**< 8x oversampler, float, 16-tap radius */ -using Oversampler16xFloat = Oversampler; /**< 16x oversampler, float, 16-tap radius */ -using Oversampler32xFloat = Oversampler; /**< 32x oversampler, float, 16-tap radius */ -using Oversampler2xDouble = Oversampler; /**< 2x oversampler, double, 16-tap radius */ -using Oversampler4xDouble = Oversampler; /**< 4x oversampler, double, 16-tap radius */ -using Oversampler8xDouble = Oversampler; /**< 8x oversampler, double, 16-tap radius */ -using Oversampler16xDouble = Oversampler; /**< 16x oversampler, double, 16-tap radius */ -using Oversampler32xDouble = Oversampler; /**< 32x oversampler, double, 16-tap radius */ - } // namespace yup diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 30a4f5181..164b5ecb5 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -213,8 +213,9 @@ // Oversampling and sample-rate conversion #include "resampling/yup_CircularBuffer.h" #include "resampling/yup_SincTable.h" -#include "resampling/yup_Oversampler.h" +#include "resampling/yup_SincOversampler.h" +#include "resampling/yup_HalfbandOversampler.h" #include "resampling/yup_Resampler.h" -// Audio-rate oscillator modulation (needs Oversampler) +// Audio-rate oscillator modulation (needs HalfbandOversampler) #include "oscillators/yup_ModulatedOscillator.h" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index 9d9f2d061..c298c5cc4 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -34,6 +34,7 @@ #include "yup_dsp/yup_FirstOrderFilter.cpp" #include "yup_dsp/yup_FourierSeries.cpp" #include "yup_dsp/yup_FractionallyAddressedDelay.cpp" +#include "yup_dsp/yup_HalfbandOversampler.cpp" #include "yup_dsp/yup_KMeterState.cpp" #include "yup_dsp/yup_LevelProcessor.cpp" #include "yup_dsp/yup_LinkwitzRileyFilter.cpp" @@ -42,7 +43,7 @@ #include "yup_dsp/yup_MorphingOscillator.cpp" #include "yup_dsp/yup_NoiseGenerators.cpp" #include "yup_dsp/yup_OnsetDetector.cpp" -#include "yup_dsp/yup_Oversampler.cpp" +#include "yup_dsp/yup_SincOversampler.cpp" #include "yup_dsp/yup_PartitionedConvolver.cpp" #include "yup_dsp/yup_RbjFilter.cpp" #include "yup_dsp/yup_Resampler.cpp" diff --git a/tests/yup_dsp/yup_HalfbandOversampler.cpp b/tests/yup_dsp/yup_HalfbandOversampler.cpp new file mode 100644 index 000000000..73bb67320 --- /dev/null +++ b/tests/yup_dsp/yup_HalfbandOversampler.cpp @@ -0,0 +1,638 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +namespace yup::test +{ + +//============================================================================== +class HalfbandOversamplerTest : public ::testing::Test +{ +protected: + static constexpr double sampleRate = 48000.0; + + using Design = HalfbandOversampler::Design; + + static Design firDesign() + { + return {}; + } + + static Design iirDesign() + { + Design design; + design.filterType = HalfbandFilterType::polyphaseIIR; + return design; + } + + template + struct Streams + { + std::vector upsampled; + std::vector roundTrip; + }; + + template + static std::vector makeNoise (int numSamples, int64 seed) + { + Random random (seed); + std::vector result (static_cast (numSamples)); + + for (auto& value : result) + value = static_cast (random.nextDouble() * 2.0 - 1.0); + + return result; + } + + template + static std::vector makeSine (int numSamples, double normalizedFrequency, int offset = 0) + { + std::vector result (static_cast (numSamples)); + + for (int i = 0; i < numSamples; ++i) + result[static_cast (i)] = static_cast (std::sin (MathConstants::twoPi * normalizedFrequency * (i + offset))); + + return result; + } + + /** Streams the input through upsample/downsample using the block sizes in schedule (cycled). */ + template + static Streams process (Os& os, const std::vector& input, const std::vector& schedule) + { + Streams streams; + const auto total = static_cast (input.size()); + std::size_t step = 0; + + for (int position = 0; position < total;) + { + const int numSamples = jmin (schedule[step++ % schedule.size()], total - position); + + const SampleType* inputPtrs[] = { input.data() + position }; + os.upsample (inputPtrs, 1, numSamples); + + const auto* up = os.getOversampledChannelData (0); + streams.upsampled.insert (streams.upsampled.end(), up, up + os.getOversampledNumSamples()); + + std::vector output (static_cast (numSamples)); + SampleType* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, numSamples); + streams.roundTrip.insert (streams.roundTrip.end(), output.begin(), output.end()); + + position += numSamples; + } + + return streams; + } + + template + static void expectNear (const std::vector& actual, const std::vector& expected, double tolerance) + { + ASSERT_EQ (expected.size(), actual.size()); + + for (std::size_t i = 0; i < actual.size(); ++i) + ASSERT_NEAR (expected[i], actual[i], tolerance) << "at index " << i; + } + + static double rms (const float* data, int numSamples) + { + return FloatVectorOperations::rms (data, numSamples); + } + + static std::vector magnitudeSpectrum (const std::vector& signal) + { + const auto size = static_cast (signal.size()); + FFTProcessor fft (size); + + std::vector spectrum (static_cast (size) * 2); + fft.performRealFFTForward (signal.data(), spectrum.data()); + + std::vector magnitude (static_cast (size) / 2 + 1); + + for (int k = 0; k <= size / 2; ++k) + magnitude[static_cast (k)] = std::hypot (spectrum[static_cast (2 * k)], spectrum[static_cast (2 * k + 1)]); + + return magnitude; + } + + /** Level of the strongest bin outside the fundamental's guard band, relative to the fundamental. */ + static double worstSpuriousDb (const std::vector& magnitude, int fundamentalBin, int guardBins) + { + const auto fundamental = magnitude[static_cast (fundamentalBin)]; + double worst = 0.0; + + for (std::size_t k = 0; k < magnitude.size(); ++k) + { + if (std::abs (static_cast (k) - fundamentalBin) <= guardBins) + continue; + + worst = jmax (worst, magnitude[k]); + } + + return 20.0 * std::log10 (jmax (worst, 1e-12 * fundamental) / fundamental); + } + + /** Upsamples a bin-exact tone and returns the strongest image relative to it, in dB. */ + template + static double upsampledWorstImageDb (const Design& design, int fundamentalBin) + { + constexpr int blockSize = 1024; + const double frequency = fundamentalBin / static_cast (blockSize); + + HalfbandOversampler os; + os.prepare (sampleRate, 1, blockSize, design); + + std::vector steadyState; + + for (int block = 0; block < 3; ++block) + { + const auto input = makeSine (blockSize, frequency, block * blockSize); + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + const auto* up = os.getOversampledChannelData (0); + steadyState.assign (up, up + os.getOversampledNumSamples()); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + } + + return worstSpuriousDb (magnitudeSpectrum (steadyState), fundamentalBin, 2); + } + + /** Generates a tone in the oversampled domain and returns the decimated level in dB relative to it. */ + template + static double decimatedToneLevelDb (const Design& design, double frequencyRatioOfInputRate) + { + constexpr int blockSize = 2048; + + HalfbandOversampler os; + os.prepare (sampleRate, 1, blockSize, design); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + + for (int block = 0; block < 2; ++block) + { + if (! os.beginGeneration (1, blockSize)) + { + ADD_FAILURE() << "beginGeneration refused a block within the prepared capacity"; + return 0.0; + } + + auto* internal = os.getOversampledChannelData (0); + + for (int i = 0; i < os.getOversampledNumSamples(); ++i) + internal[i] = static_cast (std::sin (MathConstants::twoPi * frequencyRatioOfInputRate * (block * blockSize * Factor + i) / Factor)); + + os.downsample (outputPtrs, 1, blockSize); + } + + return 20.0 * std::log10 (jmax (rms (output.data(), blockSize), 1e-12) * MathConstants::sqrt2); + } + + struct RoundTripAccuracy + { + double maxError = 0.0; + double snrDb = 0.0; + double amplitudeRatio = 0.0; + }; + + /** Round-trips a unit sine and compares it with the input delayed by the reported latency. */ + template + static RoundTripAccuracy roundTripAccuracy (const Design& design, double normalizedFrequency) + { + constexpr int blockSize = 1024; + + HalfbandOversampler os; + os.prepare (sampleRate, 1, blockSize, design); + + RoundTripAccuracy accuracy; + double signalEnergy = 0.0; + double errorEnergy = 0.0; + double outputEnergy = 0.0; + + for (int block = 0; block < 4; ++block) + { + const auto input = makeSine (blockSize, normalizedFrequency, block * blockSize); + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + + if (block == 0) + continue; + + for (int i = 0; i < blockSize; ++i) + { + const auto expected = std::sin (MathConstants::twoPi * normalizedFrequency * (block * blockSize + i - os.getLatencyInSamples())); + const auto error = output[static_cast (i)] - expected; + accuracy.maxError = jmax (accuracy.maxError, std::abs (error)); + signalEnergy += expected * expected; + errorEnergy += error * error; + outputEnergy += output[static_cast (i)] * output[static_cast (i)]; + } + } + + accuracy.snrDb = 10.0 * std::log10 (signalEnergy / jmax (errorEnergy, 1e-30)); + accuracy.amplitudeRatio = std::sqrt (outputEnergy / signalEnergy); + return accuracy; + } + + /** Frequency of an FFT bin for the 1024-sample blocks used by the round-trip helpers. */ + static constexpr double bin (int index) noexcept + { + return index / 1024.0; + } +}; + +//============================================================================== +TEST_F (HalfbandOversamplerTest, StageCountFollowsTheFactor) +{ + EXPECT_EQ (1, (HalfbandOversampler::numStages)); + EXPECT_EQ (2, (HalfbandOversampler::numStages)); + EXPECT_EQ (3, (HalfbandOversampler::numStages)); + EXPECT_EQ (5, (HalfbandOversampler::numStages)); +} + +TEST_F (HalfbandOversamplerTest, AliasesUseTheHalfbandDesign) +{ + static_assert (std::is_same_v>); + static_assert (std::is_same_v>); + + Oversampler2xFloat a; + Oversampler4xFloat b; + Oversampler8xFloat c; + Oversampler16xFloat d; + Oversampler32xFloat e; + Oversampler2xDouble f; + Oversampler4xDouble g; + Oversampler8xDouble h; + Oversampler16xDouble i; + Oversampler32xDouble j; + + for (auto* os : { &a, &b, &c, &d, &e }) + os->prepare (44100.0, 1, 64); + + for (auto* os : { &f, &g, &h, &i, &j }) + os->prepare (44100.0, 1, 64); + + EXPECT_EQ (HalfbandFilterType::linearPhaseFIR, a.getDesign().filterType); + EXPECT_GT (e.getLatencyInSamples(), 0); +} + +TEST_F (HalfbandOversamplerTest, DefaultConstructionReportsNoPendingBlock) +{ + HalfbandOversampler os; + EXPECT_EQ (0, os.getOversampledNumSamples()); + EXPECT_EQ (nullptr, os.getOversampledChannelData (0)); + EXPECT_FALSE (os.beginGeneration (1, 16)); +} + +TEST_F (HalfbandOversamplerTest, FirLatencyIsAnIntegerRoundTripOfTwoGenerationDelays) +{ + HalfbandOversampler os; + os.prepare (sampleRate, 1, 256); + + EXPECT_GT (os.getGenerationLatencyInSamples(), 0); + EXPECT_EQ (2 * os.getGenerationLatencyInSamples(), os.getLatencyInSamples()); + + HalfbandOversampler wide; + wide.prepare (sampleRate, 1, 256); + EXPECT_GE (wide.getLatencyInSamples(), os.getLatencyInSamples()); +} + +TEST_F (HalfbandOversamplerTest, FirstStageIsTheLongest) +{ + HalfbandOversampler os; + os.prepare (sampleRate, 1, 256); + + EXPECT_GT (os.getStageFilterOrder (0), os.getStageFilterOrder (1)); + EXPECT_GE (os.getStageFilterOrder (1), os.getStageFilterOrder (2)); + EXPECT_EQ (3, os.getStageFilterOrder (0) % 4); + EXPECT_EQ (0, os.getStageFilterOrder (3)); +} + +TEST_F (HalfbandOversamplerTest, WiderTransitionAndLowerAttenuationShortenTheFilters) +{ + HalfbandOversampler reference, relaxedEdge, relaxedAttenuation, stricter; + + Design edge; + edge.passbandEdge = 0.40; + + Design attenuation; + attenuation.stopbandAttenuationDb = 80.0; + + Design strict; + strict.stopbandAttenuationDb = 120.0; + + reference.prepare (sampleRate, 1, 256); + relaxedEdge.prepare (sampleRate, 1, 256, edge); + relaxedAttenuation.prepare (sampleRate, 1, 256, attenuation); + stricter.prepare (sampleRate, 1, 256, strict); + + EXPECT_LT (relaxedEdge.getLatencyInSamples(), reference.getLatencyInSamples()); + EXPECT_LT (relaxedAttenuation.getStageFilterOrder (0), reference.getStageFilterOrder (0)); + EXPECT_GT (stricter.getStageFilterOrder (0), reference.getStageFilterOrder (0)); + EXPECT_GT (stricter.getLatencyInSamples(), reference.getLatencyInSamples()); +} + +TEST_F (HalfbandOversamplerTest, IirIsCheaperAndHasLowerLatencyThanFir) +{ + HalfbandOversampler fir, iir; + fir.prepare (sampleRate, 1, 256, firDesign()); + iir.prepare (sampleRate, 1, 256, iirDesign()); + + EXPECT_LT (iir.getStageFilterOrder (0), fir.getStageFilterOrder (0)); + EXPECT_LT (iir.getLatencyInSamples(), fir.getLatencyInSamples()); + EXPECT_GT (iir.getLatencyInSamples(), 0); + EXPECT_EQ (1, iir.getStageFilterOrder (0) % 2); +} + +TEST_F (HalfbandOversamplerTest, InvalidGenerationRequestsPreserveThePendingBlock) +{ + HalfbandOversampler os; + os.prepare (sampleRate, 2, 256); + + ASSERT_TRUE (os.beginGeneration (1, 16)); + EXPECT_FALSE (os.beginGeneration (0, 16)); + EXPECT_FALSE (os.beginGeneration (1, 0)); + EXPECT_FALSE (os.beginGeneration (3, 16)); + EXPECT_FALSE (os.beginGeneration (1, 257)); + EXPECT_EQ (64, os.getOversampledNumSamples()); +} + +TEST_F (HalfbandOversamplerTest, ChannelCapacityDoesNotShrinkAfterAMonoBlock) +{ + HalfbandOversampler os; + os.prepare (sampleRate, 2, 64); + + std::vector mono (64, 0.5f); + const float* monoPtrs[] = { mono.data() }; + std::vector out (64); + float* outPtrs[] = { out.data() }; + os.upsample (monoPtrs, 1, 64); + os.downsample (outPtrs, 1, 64); + + EXPECT_TRUE (os.beginGeneration (2, 64)); + EXPECT_NE (nullptr, os.getOversampledChannelData (1)); +} + +//============================================================================== +TEST_F (HalfbandOversamplerTest, FirImpulsePeaksAtTheReportedLatencies) +{ + constexpr int total = 320; + constexpr int impulsePosition = 11; + + std::vector input (total, 0.0f); + input[impulsePosition] = 1.0f; + + HalfbandOversampler os; + os.prepare (sampleRate, 1, total); + const auto streams = process (os, input, { 7 }); + + const auto roundTripPeak = std::max_element (streams.roundTrip.begin(), streams.roundTrip.end()); + EXPECT_EQ (impulsePosition + os.getLatencyInSamples(), static_cast (roundTripPeak - streams.roundTrip.begin())); + EXPECT_NEAR (0.9, *roundTripPeak, 0.1); + + const auto upsampledPeak = std::max_element (streams.upsampled.begin(), streams.upsampled.end()); + EXPECT_EQ (4 * (impulsePosition + os.getGenerationLatencyInSamples()), static_cast (upsampledPeak - streams.upsampled.begin())); + + os.reset(); + ASSERT_TRUE (os.beginGeneration (1, total)); + auto* internal = os.getOversampledChannelData (0); + FloatVectorOperations::clear (internal, total * 4); + internal[0] = 1.0f; + + std::vector generated (total); + float* generatedPtrs[] = { generated.data() }; + os.downsample (generatedPtrs, 1, total); + + const auto generatedPeak = std::max_element (generated.begin(), generated.end()); + EXPECT_EQ (os.getGenerationLatencyInSamples(), static_cast (generatedPeak - generated.begin())); +} + +TEST_F (HalfbandOversamplerTest, DcIsPreservedExactlyByBothFamilies) +{ + for (const auto& design : { firDesign(), iirDesign() }) + { + constexpr int blockSize = 256; + HalfbandOversampler os; + os.prepare (sampleRate, 1, blockSize, design); + + std::vector input (blockSize, 0.5f); + std::vector output (blockSize); + const float* inputPtrs[] = { input.data() }; + float* outputPtrs[] = { output.data() }; + + for (int block = 0; block < 4; ++block) + { + os.upsample (inputPtrs, 1, blockSize); + + if (block == 3) + { + const auto* up = os.getOversampledChannelData (0); + + for (int i = 0; i < os.getOversampledNumSamples(); ++i) + ASSERT_NEAR (0.5f, up[i], 1e-5f) << "oversampled index " << i; + } + + os.downsample (outputPtrs, 1, blockSize); + } + + for (int i = 0; i < blockSize; ++i) + ASSERT_NEAR (0.5f, output[static_cast (i)], 1e-5f) << "output index " << i; + } +} + +TEST_F (HalfbandOversamplerTest, BlockSizeIndependence) +{ + for (const auto& design : { firDesign(), iirDesign() }) + { + constexpr int total = 512; + const auto input = makeNoise (total, 7); + + HalfbandOversampler wholeBlock, irregularBlocks; + wholeBlock.prepare (sampleRate, 1, total, design); + irregularBlocks.prepare (sampleRate, 1, total, design); + + const auto reference = process (wholeBlock, input, { total }); + const auto irregular = process (irregularBlocks, input, { 1, 3, 7, 5, 2, 13, 64, 17, 31, 9, 128 }); + + expectNear (irregular.upsampled, reference.upsampled, 1e-6); + expectNear (irregular.roundTrip, reference.roundTrip, 1e-6); + } +} + +TEST_F (HalfbandOversamplerTest, ChannelsAreIndependent) +{ + constexpr int blockSize = 100; + constexpr int total = 300; + const auto left = makeNoise (total, 1); + const auto right = makeNoise (total, 2); + + for (const auto& design : { firDesign(), iirDesign() }) + { + HalfbandOversampler stereo, monoRight; + stereo.prepare (sampleRate, 2, blockSize, design); + monoRight.prepare (sampleRate, 1, blockSize, design); + + Streams stereoRight; + + for (int position = 0; position < total; position += blockSize) + { + const float* inputPtrs[] = { left.data() + position, right.data() + position }; + stereo.upsample (inputPtrs, 2, blockSize); + + const auto* upRight = stereo.getOversampledChannelData (1); + stereoRight.upsampled.insert (stereoRight.upsampled.end(), upRight, upRight + stereo.getOversampledNumSamples()); + + std::vector outLeft (blockSize), outRight (blockSize); + float* outputPtrs[] = { outLeft.data(), outRight.data() }; + stereo.downsample (outputPtrs, 2, blockSize); + stereoRight.roundTrip.insert (stereoRight.roundTrip.end(), outRight.begin(), outRight.end()); + } + + const auto expectedRight = process (monoRight, right, { blockSize }); + expectNear (stereoRight.upsampled, expectedRight.upsampled, 1e-7); + expectNear (stereoRight.roundTrip, expectedRight.roundTrip, 1e-7); + } +} + +TEST_F (HalfbandOversamplerTest, ResetMatchesFreshInstance) +{ + for (const auto& design : { firDesign(), iirDesign() }) + { + constexpr int blockSize = 128; + + HalfbandOversampler reused, fresh; + reused.prepare (sampleRate, 1, blockSize, design); + fresh.prepare (sampleRate, 1, blockSize, design); + + process (reused, makeNoise (512, 5), { blockSize }); + reused.reset(); + + const auto input = makeNoise (256, 6); + const auto reusedResult = process (reused, input, { blockSize }); + const auto freshResult = process (fresh, input, { blockSize }); + + expectNear (reusedResult.upsampled, freshResult.upsampled, 1e-7); + expectNear (reusedResult.roundTrip, freshResult.roundTrip, 1e-7); + } +} + +TEST_F (HalfbandOversamplerTest, GenerationDoesNotDisturbUpsampleHistory) +{ + constexpr int blockSize = 128; + const auto blockA = makeNoise (blockSize, 8); + const auto blockB = makeNoise (blockSize, 9); + + HalfbandOversampler withGeneration, withoutGeneration; + withGeneration.prepare (sampleRate, 1, blockSize); + withoutGeneration.prepare (sampleRate, 1, blockSize); + + const auto roundTrip = [] (auto& os, const std::vector& input) + { + const float* inputPtrs[] = { input.data() }; + os.upsample (inputPtrs, 1, blockSize); + + const auto* up = os.getOversampledChannelData (0); + std::vector upsampled (up, up + os.getOversampledNumSamples()); + + std::vector output (blockSize); + float* outputPtrs[] = { output.data() }; + os.downsample (outputPtrs, 1, blockSize); + return upsampled; + }; + + roundTrip (withGeneration, blockA); + roundTrip (withoutGeneration, blockA); + + ASSERT_TRUE (withGeneration.beginGeneration (1, blockSize)); + FloatVectorOperations::fill (withGeneration.getOversampledChannelData (0), 0.7f, withGeneration.getOversampledNumSamples()); + std::vector generated (blockSize); + float* generatedPtrs[] = { generated.data() }; + withGeneration.downsample (generatedPtrs, 1, blockSize); + + expectNear (roundTrip (withGeneration, blockB), roundTrip (withoutGeneration, blockB), 1e-7); +} + +//============================================================================== +TEST_F (HalfbandOversamplerTest, UpsampledImagesAreRejected) +{ + for (const auto& design : { firDesign(), iirDesign() }) + { + // 0.25 fs: images at 0.75, 1.25 and 1.75 fs, deep in every stage's stopband. + EXPECT_LT (upsampledWorstImageDb<4> (design, 256), -95.0); + + // 0.4 fs: the nearest image at 0.6 fs sits just past the 0.55 fs stopband edge. + EXPECT_LT (upsampledWorstImageDb<4> (design, 410), -95.0); + + // 8x: the higher stages must not let their own images through either. + EXPECT_LT (upsampledWorstImageDb<8> (design, 300), -95.0); + } +} + +TEST_F (HalfbandOversamplerTest, DecimationRejectsTonesAboveTheStopbandEdge) +{ + for (const auto& design : { firDesign(), iirDesign() }) + { + EXPECT_LT (decimatedToneLevelDb<2> (design, 0.6), -95.0); + EXPECT_LT (decimatedToneLevelDb<4> (design, 0.9), -95.0); + EXPECT_LT (decimatedToneLevelDb<8> (design, 1.7), -95.0); + EXPECT_LT (decimatedToneLevelDb<8> (design, 3.7), -95.0); + } +} + +TEST_F (HalfbandOversamplerTest, FirPassbandIsFlatAndLinearPhaseUpToTheDesignEdge) +{ + EXPECT_LT (roundTripAccuracy<4> (firDesign(), bin (20)).maxError, 1e-3); + EXPECT_LT (roundTripAccuracy<4> (firDesign(), bin (307)).maxError, 1e-3); + EXPECT_LT (roundTripAccuracy<4> (firDesign(), bin (450)).maxError, 1e-3); + EXPECT_GT (roundTripAccuracy<4> (firDesign(), bin (102)).snrDb, 95.0); +} + +TEST_F (HalfbandOversamplerTest, IirPassbandAmplitudeIsFlatUpToTheDesignEdge) +{ + EXPECT_NEAR (1.0, roundTripAccuracy<4> (iirDesign(), bin (20)).amplitudeRatio, 1e-3); + EXPECT_NEAR (1.0, roundTripAccuracy<4> (iirDesign(), bin (307)).amplitudeRatio, 1e-3); + EXPECT_NEAR (1.0, roundTripAccuracy<4> (iirDesign(), bin (448)).amplitudeRatio, 1e-3); +} + +TEST_F (HalfbandOversamplerTest, LowerAttenuationTargetIsStillMet) +{ + Design design; + design.stopbandAttenuationDb = 60.0; + + EXPECT_LT (upsampledWorstImageDb<4> (design, 256), -58.0); + EXPECT_LT (decimatedToneLevelDb<2> (design, 0.6), -58.0); +} + +} // namespace yup::test diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_SincOversampler.cpp similarity index 91% rename from tests/yup_dsp/yup_Oversampler.cpp rename to tests/yup_dsp/yup_SincOversampler.cpp index bc9667b98..07d5f8893 100644 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ b/tests/yup_dsp/yup_SincOversampler.cpp @@ -30,7 +30,7 @@ namespace yup::test { //============================================================================== -class OversamplerTest : public ::testing::Test +class SincOversamplerTest : public ::testing::Test { protected: static constexpr double sampleRate = 44100.0; @@ -64,20 +64,20 @@ class OversamplerTest : public ::testing::Test std::fill (buf.begin(), buf.end(), value); } - Oversampler os2x; - Oversampler os4x; + SincOversampler os2x; + SincOversampler os4x; }; //============================================================================== -TEST_F (OversamplerTest, DefaultConstructionDoesNotCrash) +TEST_F (SincOversamplerTest, DefaultConstructionDoesNotCrash) { - Oversampler os; + SincOversampler os; EXPECT_EQ (os.getOversampledNumSamples(), 0); EXPECT_EQ (os.getLatencyInSamples(), 16); EXPECT_EQ (os.getOversampledChannelData (0), nullptr); } -TEST_F (OversamplerTest, PrepareAllocatesOversampledBuffer) +TEST_F (SincOversamplerTest, PrepareAllocatesOversampledBuffer) { EXPECT_EQ (os2x.getOversampledNumSamples(), 0); @@ -89,13 +89,13 @@ TEST_F (OversamplerTest, PrepareAllocatesOversampledBuffer) EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); } -TEST_F (OversamplerTest, LatencyReturnsCorrectValue) +TEST_F (SincOversamplerTest, LatencyReturnsCorrectValue) { EXPECT_EQ (os2x.getLatencyInSamples(), 16); // 2 * SincRadius = 2 * 8 EXPECT_EQ (os4x.getLatencyInSamples(), 16); } -TEST_F (OversamplerTest, ResetClearsOversampledSize) +TEST_F (SincOversamplerTest, ResetClearsOversampledSize) { std::vector ch0 (blockSize, 1.0f); const float* inputPtrs[] = { ch0.data() }; @@ -107,7 +107,7 @@ TEST_F (OversamplerTest, ResetClearsOversampledSize) EXPECT_EQ (os2x.getOversampledNumSamples(), 0); } -TEST_F (OversamplerTest, UpsampleDCSignalHasCorrectMagnitude) +TEST_F (SincOversamplerTest, UpsampleDCSignalHasCorrectMagnitude) { constexpr float dcValue = 0.5f; std::vector ch0 (blockSize, dcValue); @@ -126,7 +126,7 @@ TEST_F (OversamplerTest, UpsampleDCSignalHasCorrectMagnitude) EXPECT_NEAR (rms, dcValue, 0.05f); } -TEST_F (OversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) +TEST_F (SincOversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) { constexpr int shortBlockSize = 64; std::vector ch0 (shortBlockSize, 0.0f); @@ -145,7 +145,7 @@ TEST_F (OversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) EXPECT_EQ (callbackSamples, shortBlockSize * 2); } -TEST_F (OversamplerTest, ProcessOversampledBlockReceivesEmptyBufferWithoutPendingBlock) +TEST_F (SincOversamplerTest, ProcessOversampledBlockReceivesEmptyBufferWithoutPendingBlock) { int callbackChannels = -1; int callbackSamples = -1; @@ -160,7 +160,7 @@ TEST_F (OversamplerTest, ProcessOversampledBlockReceivesEmptyBufferWithoutPendin EXPECT_EQ (callbackSamples, 0); } -TEST_F (OversamplerTest, DownsampleConsumesPendingOversampledBlock) +TEST_F (SincOversamplerTest, DownsampleConsumesPendingOversampledBlock) { std::vector ch0 (blockSize, 0.0f); std::vector output (blockSize, 0.0f); @@ -176,7 +176,7 @@ TEST_F (OversamplerTest, DownsampleConsumesPendingOversampledBlock) EXPECT_EQ (os2x.getOversampledChannelData (0), nullptr); } -TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) +TEST_F (SincOversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) { constexpr float dcValue = 0.5f; std::vector input (blockSize, dcValue); @@ -193,7 +193,7 @@ TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) EXPECT_NEAR (calculateRMS (output.data(), blockSize), dcValue, 0.02f); } -TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesLowFrequencySine) +TEST_F (SincOversamplerTest, UpsampleThenDownsamplePreservesLowFrequencySine) { constexpr float frequency = 440.0f; // A4 - well below Nyquist/4 std::vector input (blockSize); @@ -216,7 +216,7 @@ TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesLowFrequencySine) EXPECT_NEAR (rmsOut, rmsIn, rmsIn * 0.1f); // within 10% } -TEST_F (OversamplerTest, DecimationFiltersOversampledDomainHighFrequency) +TEST_F (SincOversamplerTest, DecimationFiltersOversampledDomainHighFrequency) { // Upsample silence to get a clean oversampled buffer std::vector silence (blockSize, 0.0f); @@ -247,7 +247,7 @@ TEST_F (OversamplerTest, DecimationFiltersOversampledDomainHighFrequency) EXPECT_LT (rmsOut, injectedAmplitude * 0.5f); } -TEST_F (OversamplerTest, OversampledChannelDataNotNullAfterUpsample) +TEST_F (SincOversamplerTest, OversampledChannelDataNotNullAfterUpsample) { std::vector ch0 (blockSize, 0.0f); const float* inputPtrs[] = { ch0.data() }; @@ -258,7 +258,7 @@ TEST_F (OversamplerTest, OversampledChannelDataNotNullAfterUpsample) EXPECT_EQ (os2x.getOversampledChannelData (-1), nullptr); // invalid index } -TEST_F (OversamplerTest, FourXOversamplerHasCorrectOutputSize) +TEST_F (SincOversamplerTest, FourXOversamplerHasCorrectOutputSize) { std::vector ch0 (blockSize, 0.0f); const float* inputPtrs[] = { ch0.data() }; @@ -267,35 +267,12 @@ TEST_F (OversamplerTest, FourXOversamplerHasCorrectOutputSize) EXPECT_EQ (os4x.getOversampledNumSamples(), blockSize * 4); } -//============================================================================== -TEST (OversamplerTypeAliasTest, TypeAliasesCompile) -{ - Oversampler2xFloat a; - Oversampler4xFloat b; - Oversampler8xFloat c; - Oversampler2xDouble d; - Oversampler4xDouble e; - Oversampler16xFloat f; - Oversampler32xDouble g; - - // Prepare briefly to confirm the types are usable - a.prepare (44100.0, 1, 64); - b.prepare (44100.0, 1, 64); - c.prepare (44100.0, 1, 64); - d.prepare (44100.0, 1, 64); - e.prepare (44100.0, 1, 64); - f.prepare (44100.0, 1, 64); - g.prepare (44100.0, 1, 64); - - SUCCEED(); -} - } // namespace yup::test namespace yup::test { -TEST_F (OversamplerTest, DirectGenerationPreservesDCWithoutInputInterpolation) +TEST_F (SincOversamplerTest, DirectGenerationPreservesDCWithoutInputInterpolation) { ASSERT_TRUE (os4x.beginGeneration (1, blockSize)); FloatVectorOperations::fill (os4x.getOversampledChannelData (0), 0.25f, blockSize * 4); @@ -309,7 +286,7 @@ TEST_F (OversamplerTest, DirectGenerationPreservesDCWithoutInputInterpolation) EXPECT_NEAR (0.25f, output[static_cast (i)], 1e-6f); } -TEST_F (OversamplerTest, InvalidGenerationRequestsPreserveThePendingBlock) +TEST_F (SincOversamplerTest, InvalidGenerationRequestsPreserveThePendingBlock) { ASSERT_TRUE (os4x.beginGeneration (1, 16)); EXPECT_FALSE (os4x.beginGeneration (0, 16)); @@ -319,7 +296,7 @@ TEST_F (OversamplerTest, InvalidGenerationRequestsPreserveThePendingBlock) EXPECT_EQ (64, os4x.getOversampledNumSamples()); } -TEST_F (OversamplerTest, DirectGenerationImpulseHasTheReportedLatency) +TEST_F (SincOversamplerTest, DirectGenerationImpulseHasTheReportedLatency) { ASSERT_TRUE (os4x.beginGeneration (1, blockSize)); auto* internal = os4x.getOversampledChannelData (0); @@ -418,7 +395,7 @@ class OversamplerAccuracyTest : public ::testing::Test constexpr int total = 512; const auto input = makeNoise (total, 7); - Oversampler wholeBlock, fixedBlocks, irregularBlocks; + SincOversampler wholeBlock, fixedBlocks, irregularBlocks; wholeBlock.prepare (sampleRate, 1, total); fixedBlocks.prepare (sampleRate, 1, total); irregularBlocks.prepare (sampleRate, 1, total); @@ -472,7 +449,7 @@ class OversamplerAccuracyTest : public ::testing::Test constexpr int blockSize = 1024; const double frequency = fundamentalBin / static_cast (blockSize); - Oversampler os; + SincOversampler os; os.prepare (sampleRate, 1, blockSize); std::vector steadyState; @@ -506,7 +483,7 @@ class OversamplerAccuracyTest : public ::testing::Test { constexpr int blockSize = 512; - Oversampler os; + SincOversampler os; os.prepare (sampleRate, 1, blockSize); RoundTripAccuracy accuracy; @@ -567,7 +544,7 @@ TEST_F (OversamplerAccuracyTest, ImpulseLatencyWithTinyBlocks) std::vector input (total, 0.0f); input[impulsePosition] = 1.0f; - Oversampler os; + SincOversampler os; os.prepare (sampleRate, 1, total); const auto streams = process (os, input, { 3 }); @@ -588,7 +565,7 @@ TEST_F (OversamplerAccuracyTest, UpsampleMatchesScalarSincReference) constexpr int total = 256; const auto input = makeNoise (total, 3); - Oversampler os; + SincOversampler os; os.prepare (sampleRate, 1, total); const auto streams = process (os, input, { 64 }); @@ -630,7 +607,7 @@ TEST_F (OversamplerAccuracyTest, ChannelsAreIndependent) const auto left = makeNoise (total, 1); const auto right = makeNoise (total, 2); - Oversampler stereo, monoLeft, monoRight; + SincOversampler stereo, monoLeft, monoRight; stereo.prepare (sampleRate, 2, blockSize); monoLeft.prepare (sampleRate, 1, blockSize); monoRight.prepare (sampleRate, 1, blockSize); @@ -667,7 +644,7 @@ TEST_F (OversamplerAccuracyTest, ResetMatchesFreshInstance) { constexpr int blockSize = 128; - Oversampler reused, fresh; + SincOversampler reused, fresh; reused.prepare (sampleRate, 1, blockSize); fresh.prepare (sampleRate, 1, blockSize); @@ -688,7 +665,7 @@ TEST_F (OversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) const auto blockA = makeNoise (blockSize, 8); const auto blockB = makeNoise (blockSize, 9); - Oversampler withGeneration, withoutGeneration; + SincOversampler withGeneration, withoutGeneration; withGeneration.prepare (sampleRate, 1, blockSize); withoutGeneration.prepare (sampleRate, 1, blockSize); @@ -734,7 +711,7 @@ TEST_F (OversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadiu constexpr int blockSize = 2048; constexpr double toneRatio = 0.6; // of the input sample rate, above the input Nyquist - Oversampler os; + SincOversampler os; os.prepare (sampleRate, 1, blockSize); ASSERT_TRUE (os.beginGeneration (1, blockSize)); From c3431ee6eb60ddb3ed89c36f4365ac85c02bc9d0 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 12:14:34 +0200 Subject: [PATCH 07/37] Remove oscillator --- docs/dsp/oscillators.md | 58 +++---- .../oscillators/yup_MorphingOscillator.h | 160 ------------------ modules/yup_dsp/yup_dsp.h | 2 +- tests/yup_dsp.cpp | 1 - tests/yup_dsp/yup_MorphingOscillator.cpp | 80 --------- 5 files changed, 24 insertions(+), 277 deletions(-) delete mode 100644 modules/yup_dsp/oscillators/yup_MorphingOscillator.h delete mode 100644 tests/yup_dsp/yup_MorphingOscillator.cpp diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 8ae217943..035863624 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -11,7 +11,7 @@ Oscillator Synchronization via Additive Synthesis"* (DAFx26, paper 49). **Headers:** `yup_dsp/oscillators/` - `yup_FourierSeries.h`, `yup_SyncSpectralResampler.h`, `yup_AdditiveOscillator.h`, `yup_WavetableOscillator.h`, `yup_SyncOscillator.h`, `yup_WaveformBank.h`, -`yup_MorphingOscillator.h`, `yup_ModulatedOscillator.h`. +`yup_ModulatedOscillator.h`. ## The idea @@ -133,33 +133,6 @@ crossfade length passed to `prepare()` (64 samples by default). When comparing it with additive synthesis, let this ramp finish and align the playback phases before measuring. Subsequent renders crossfade from the current table. -## Endpoint morphing with spectral synchronization - -`MorphingOscillator` composes two `SyncOscillator`s with -identical phase, pitch, mode and ratio. Set endpoint series once and call `update()` -after changing the sync parameters. The morph position is supplied per sample or -as a block of values; changing it never runs a transform or FFT. - -```cpp -auto first = yup::FourierSeries::create (yup::Waveform::sawtooth, 128); -auto second = yup::FourierSeries::create (yup::Waveform::square, 128); -yup::MorphingOscillator oscillator; -oscillator.prepare (48000.0, 128); -oscillator.setSeries (first, second); -oscillator.setSyncMode (yup::SyncMode::hard); -oscillator.setFollowerRatio (1.375); -oscillator.update(); -auto sample = oscillator.processSample (0.25); -``` - -At a fixed ratio the transform is linear: transforming a coefficient blend equals -blending the transformed endpoints. Morphing uses linear amplitude interpolation, -not magnitude/phase interpolation or loudness normalization. Align endpoint phases -when cancellations are undesirable. Table-replacement crossfades remain separate -from the morph control. Morph changes create amplitude-modulation sidebands, so -smooth control-rate automation and reserve bandwidth. For audio-rate morphing use -the oversampled path below. - ## Prepared waveform banks `WaveformBank` renders any number of Fourier-series frames @@ -179,9 +152,20 @@ frame/level currently retains its FFT scratch storage as well as its table; shar banks across voices to amortize preparation and memory. No bank rebuild is needed for pitch, morph, FM, PM or phase-distortion changes. +`refreshFrames(frames)` replaces the coefficients of an already prepared bank +without allocating, the counterpart of `WavetableOscillator`'s `setSeries`/`render` +split. Table sizes and the per-level harmonic limits chosen by `prepare()` are +kept, so each level stays correctly bandlimited; frames with fewer harmonics are +zero-extended. It returns `false` and changes nothing when the frame count differs +from `prepare()` or a frame exceeds `getNumHarmonics()`. It is still one inverse FFT +per frame and level, so it belongs off the audio thread, and it mutates the bank in +place - refresh a second bank and hand it to `ModulatedOscillator::setBank()` or +`PrismOscillator::setBank()` (both allocation-free pointer swaps) rather than +rewriting tables that voices are reading. + ## Oversampled audio-rate modulation -`ModulatedOscillator` +`ModulatedOscillator` reads a shared bank and provides signed linear FM, exponential pitch modulation, PM, frame morphing, breakpoint phase distortion and fractional hard sync. @@ -219,6 +203,7 @@ interpolate external controls to it. Callbacks must not allocate, block or throw | `morph` | Normalized bank position, clamped to [0, 1]. | | `phaseDistortion` | Input phase that maps to half a waveform cycle; 0.5 is identity, clamped to [0.01, 0.99]. | | `syncFrequency` | Nonnegative leader frequency; zero disables hard sync. | +| `bandwidthFrequency` | Optional Hz floor compared against the post-FM frequency when picking a bandwidth level; raise it when a modulator will push the carrier above its current pitch. | Carrier and leader increments are limited to half an internal sample-rate cycle. Leader wraps reset the follower at the fractional event time, preserving its @@ -228,8 +213,11 @@ estimate includes carrier speed, PM differences and the maximum phase-map slope. It is a conservative table selection heuristic, not a bound on modulation sidebands. The entire synthesis/modulation path is generated at the elevated rate, low-pass -filtered, then decimated. Latency is `SincRadius` output samples; report -`getLatencyInSamples()` to the owning audio processor. `reset()` clears phase, +filtered, then decimated through a `HalfbandOversampler`. `prepare()` accepts an +optional `HalfbandOversamplerDesign` (default: 100 dB linear-phase FIR, flat to +0.45 of the output rate); the latency follows that design, so read +`getLatencyInSamples()` after `prepare()` and report it to the owning audio +processor. `reset()` clears phase, residuals and filter history. Processing is allocation-free within the block size passed to `prepare()`. Invalid block sizes or null output return false without advancing state. All parameter values must be finite. @@ -237,13 +225,13 @@ advancing state. All parameter values must be finite. These are **antialiased**, not unconditionally alias-free, modulation algorithms. Finite correction kernels do not correct all higher derivatives of an arbitrary waveform. Parameters are treated as constant within each internal sample interval; -abrupt control changes are not automatically smoothed. Higher oversampling and -filter radius improve different error sources at increased CPU cost. Extreme +abrupt control changes are not automatically smoothed. Higher oversampling and a +stricter decimator design improve different error sources at increased CPU cost. Extreme modulation needs explicit bandwidth/depth constraints. Do not upsample an already aliased base-rate oscillator and expect its aliases to disappear. -`Oversampler::beginGeneration()` exposes the same allocation-free generation path -for other sources. Fill its high-rate buffer directly and call `downsample()`; +`HalfbandOversampler::beginGeneration()` (and its `SincOversampler` twin) exposes +the same allocation-free generation path for other sources. Fill its high-rate buffer directly and call `downsample()`; `getGenerationLatencyInSamples()` reports decimation-only latency, while the existing `getLatencyInSamples()` still describes the complete up/down path. diff --git a/modules/yup_dsp/oscillators/yup_MorphingOscillator.h b/modules/yup_dsp/oscillators/yup_MorphingOscillator.h deleted file mode 100644 index 0994578c6..000000000 --- a/modules/yup_dsp/oscillators/yup_MorphingOscillator.h +++ /dev/null @@ -1,160 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#pragma once - -namespace yup -{ - -//============================================================================== -/** Morphs two spectral oscillators without rebuilding spectra for morph changes. - - The endpoints share pitch, phase, sync mode and follower ratio. update() applies - the synchronization transform independently to each endpoint; because that - transform is linear, blending their output is equivalent to transforming the - blended input at a fixed ratio. No transform or FFT runs in processSample(). - - Morph is linear amplitude interpolation, clamped to [0, 1]. Moving it creates - modulation sidebands. Smooth control-rate changes and leave bandwidth headroom; - use ModulatedOscillator for oversampled audio-rate morphing. Endpoint render - crossfades remain independent of the morph control. - - @tparam SampleType Output sample precision. - @tparam CoeffType Fourier coefficient precision. - @see SyncOscillator, ModulatedOscillator -*/ -template -class MorphingOscillator -{ -public: - /** Runtime synthesis backend, shared by both endpoints. */ - using Synthesis = typename SyncOscillator::Synthesis; - - /** Allocates both endpoints outside the audio callback. */ - void prepare (double sampleRate, int maxHarmonics = 128, int crossfadeLengthInSamples = 64) - { - first.prepare (sampleRate, maxHarmonics, crossfadeLengthInSamples); - second.prepare (sampleRate, maxHarmonics, crossfadeLengthInSamples); - } - - /** Copies two endpoint spectra into prepared storage. Call update() afterward. */ - void setSeries (const FourierSeries& a, const FourierSeries& b) noexcept - { - first.setFollowerSeries (a); - second.setFollowerSeries (b); - } - - /** Sets the common leader frequency in Hz. Call update() to refresh bandwidth. */ - void setFrequency (CoeffType frequency) noexcept - { - first.setFrequency (frequency); - second.setFrequency (frequency); - } - - /** Sets the common synchronization mode, pending update(). */ - void setSyncMode (SyncMode mode) noexcept - { - first.setSyncMode (mode); - second.setSyncMode (mode); - } - - /** Sets the common follower/leader frequency ratio, pending update(). */ - void setFollowerRatio (CoeffType ratio) noexcept - { - first.setFollowerRatio (ratio); - second.setFollowerRatio (ratio); - } - - /** Selects both synthesis backends, pending update(). */ - void setSynthesis (Synthesis synthesis) noexcept - { - first.setSynthesis (synthesis); - second.setSynthesis (synthesis); - } - - /** Sets the common phase in periods. */ - void setPhase (CoeffType phase) noexcept - { - first.setPhase (phase); - second.setPhase (phase); - } - - /** Returns the common playback phase in periods. */ - CoeffType getPhase() const noexcept { return first.getPhase(); } - - /** Resets both phases, keeping the prepared spectra and tables. */ - void reset() noexcept - { - first.reset(); - second.reset(); - } - - /** Selects whether endpoint DC coefficients are synthesized, pending update(). */ - void setIncludeDC (bool include) noexcept - { - first.setIncludeDC (include); - second.setIncludeDC (include); - } - - /** Returns whether either endpoint needs a control-rate refresh. */ - bool needsUpdate() const noexcept { return first.needsUpdate() || second.needsUpdate(); } - - /** Refreshes endpoint spectra/tables without allocating. Call once per block. */ - void update() noexcept - { - first.update(); - second.update(); - } - - /** Produces a sample, with morph 0 selecting the first endpoint and 1 the second. */ - SampleType processSample (CoeffType morph) noexcept - { - const auto a = first.processSample(); - const auto b = second.processSample(); - return a + (b - a) * static_cast (jlimit (CoeffType (0), CoeffType (1), morph)); - } - - /** Produces a block with a fixed morph position. */ - void processBlock (SampleType* output, int numSamples, CoeffType morph) noexcept - { - if (output == nullptr) - return; - - for (int i = 0; i < numSamples; ++i) - output[i] = processSample (morph); - } - - /** Produces a block with one morph position per output sample. */ - void processBlock (SampleType* output, Span morph) noexcept - { - if (output == nullptr) - return; - - for (std::size_t i = 0; i < morph.size(); ++i) - output[i] = processSample (morph[i]); - } - -private: - SyncOscillator first; - SyncOscillator second; -}; - -} // namespace yup diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 164b5ecb5..3f882d547 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -117,6 +117,7 @@ #include #include #include +#include #include #include #include @@ -147,7 +148,6 @@ #include "oscillators/yup_WavetableOscillator.h" #include "oscillators/yup_SyncOscillator.h" #include "oscillators/yup_WaveformBank.h" -#include "oscillators/yup_MorphingOscillator.h" // Onset detection #include "onsets/yup_FilterBank.h" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index c298c5cc4..11a3952f2 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -40,7 +40,6 @@ #include "yup_dsp/yup_LinkwitzRileyFilter.cpp" #include "yup_dsp/yup_LoudnessFilter.cpp" #include "yup_dsp/yup_ModulatedOscillator.cpp" -#include "yup_dsp/yup_MorphingOscillator.cpp" #include "yup_dsp/yup_NoiseGenerators.cpp" #include "yup_dsp/yup_OnsetDetector.cpp" #include "yup_dsp/yup_SincOversampler.cpp" diff --git a/tests/yup_dsp/yup_MorphingOscillator.cpp b/tests/yup_dsp/yup_MorphingOscillator.cpp deleted file mode 100644 index a9a8c2fbd..000000000 --- a/tests/yup_dsp/yup_MorphingOscillator.cpp +++ /dev/null @@ -1,80 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include -#include - -using namespace yup; - -class MorphingOscillatorTests : public ::testing::Test -{ -protected: - const FourierSeries first = FourierSeries::create (Waveform::sawtooth, 32); - const FourierSeries second = FourierSeries::create (Waveform::square, 32); -}; - -TEST_F (MorphingOscillatorTests, MorphCommutesWithFixedRatioSynchronization) -{ - for (const auto mode : { SyncMode::none, SyncMode::hard, SyncMode::mirrored, SyncMode::pulsar }) - { - MorphingOscillator oscillator; - oscillator.setSynthesis (MorphingOscillator::Synthesis::additive); - oscillator.prepare (48000.0, 32); - oscillator.setSeries (first, second); - oscillator.setSyncMode (mode); - oscillator.setFollowerRatio (1.375); - oscillator.setFrequency (1000.0); - oscillator.update(); - - FourierSeries blended (32); - for (int n = 1; n <= 32; ++n) - blended.setHarmonic (n, 0.0, 0.75 * first.getSine (n) + 0.25 * second.getSine (n)); - - SyncOscillator reference; - reference.setSynthesis (SyncOscillator::Synthesis::additive); - reference.prepare (48000.0, 32); - reference.setFollowerSeries (blended); - reference.setSyncMode (mode); - reference.setFollowerRatio (1.375); - reference.setFrequency (1000.0); - reference.update(); - - for (int i = 0; i < 512; ++i) - EXPECT_NEAR (reference.processSample(), oscillator.processSample (0.25), 1e-10); - EXPECT_FALSE (oscillator.needsUpdate()); - } -} - -TEST_F (MorphingOscillatorTests, PerSampleMorphDoesNotRequireAnUpdate) -{ - MorphingOscillator oscillator; - oscillator.setSynthesis (MorphingOscillator::Synthesis::additive); - oscillator.prepare (48000.0, 1); - oscillator.setSeries (FourierSeries::create (Waveform::sine, 1), - FourierSeries::create (Waveform::cosine, 1)); - oscillator.setFrequency (0.0); - oscillator.setPhase (0.25); - oscillator.update(); - - for (const auto morph : { -1.0, 0.0, 0.25, 0.5, 1.0, 2.0 }) - EXPECT_NEAR (1.0 - jlimit (0.0, 1.0, morph), oscillator.processSample (morph), 1e-12); - EXPECT_FALSE (oscillator.needsUpdate()); -} From 7d14989ed9a1b00a41016c50e2b51bbc93f331da Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 12:16:03 +0200 Subject: [PATCH 08/37] More fixes --- tests/yup_dsp/yup_SincOversampler.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/yup_dsp/yup_SincOversampler.cpp b/tests/yup_dsp/yup_SincOversampler.cpp index 07d5f8893..1e4e56f01 100644 --- a/tests/yup_dsp/yup_SincOversampler.cpp +++ b/tests/yup_dsp/yup_SincOversampler.cpp @@ -315,7 +315,7 @@ namespace yup::test { //============================================================================== -class OversamplerAccuracyTest : public ::testing::Test +class SincOversamplerAccuracyTest : public ::testing::Test { protected: static constexpr double sampleRate = 48000.0; @@ -519,22 +519,22 @@ class OversamplerAccuracyTest : public ::testing::Test }; //============================================================================== -TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceFloat2x) +TEST_F (SincOversamplerAccuracyTest, BlockSizeIndependenceFloat2x) { checkBlockSizeIndependence (1e-6); } -TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceFloat4x) +TEST_F (SincOversamplerAccuracyTest, BlockSizeIndependenceFloat4x) { checkBlockSizeIndependence (1e-6); } -TEST_F (OversamplerAccuracyTest, BlockSizeIndependenceDouble4x) +TEST_F (SincOversamplerAccuracyTest, BlockSizeIndependenceDouble4x) { checkBlockSizeIndependence (1e-12); } -TEST_F (OversamplerAccuracyTest, ImpulseLatencyWithTinyBlocks) +TEST_F (SincOversamplerAccuracyTest, ImpulseLatencyWithTinyBlocks) { constexpr int radius = 8; constexpr int factor = 4; @@ -558,7 +558,7 @@ TEST_F (OversamplerAccuracyTest, ImpulseLatencyWithTinyBlocks) EXPECT_EQ (impulsePosition + 2 * radius, static_cast (peak - streams.roundTrip.begin())); } -TEST_F (OversamplerAccuracyTest, UpsampleMatchesScalarSincReference) +TEST_F (SincOversamplerAccuracyTest, UpsampleMatchesScalarSincReference) { constexpr int radius = 8; constexpr int factor = 4; @@ -600,7 +600,7 @@ TEST_F (OversamplerAccuracyTest, UpsampleMatchesScalarSincReference) } } -TEST_F (OversamplerAccuracyTest, ChannelsAreIndependent) +TEST_F (SincOversamplerAccuracyTest, ChannelsAreIndependent) { constexpr int blockSize = 100; constexpr int total = 300; @@ -640,7 +640,7 @@ TEST_F (OversamplerAccuracyTest, ChannelsAreIndependent) expectNear (stereoRight.roundTrip, expectedRight.roundTrip, 1e-7); } -TEST_F (OversamplerAccuracyTest, ResetMatchesFreshInstance) +TEST_F (SincOversamplerAccuracyTest, ResetMatchesFreshInstance) { constexpr int blockSize = 128; @@ -659,7 +659,7 @@ TEST_F (OversamplerAccuracyTest, ResetMatchesFreshInstance) expectNear (reusedResult.roundTrip, freshResult.roundTrip, 1e-7); } -TEST_F (OversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) +TEST_F (SincOversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) { constexpr int blockSize = 128; const auto blockA = makeNoise (blockSize, 8); @@ -696,7 +696,7 @@ TEST_F (OversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) } //============================================================================== -TEST_F (OversamplerAccuracyTest, UpsampledImageIsRejected) +TEST_F (SincOversamplerAccuracyTest, UpsampledImageIsRejected) { // 0.25 fs tone: image at 0.75 fs sits deep in the interpolator's stopband. EXPECT_LT (upsampledWorstImageDb<4, 16> (256), -80.0); @@ -705,7 +705,7 @@ TEST_F (OversamplerAccuracyTest, UpsampledImageIsRejected) EXPECT_LT (upsampledWorstImageDb<4, 16> (410), -70.0); } -TEST_F (OversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadius16) +TEST_F (SincOversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadius16) { constexpr int factor = 2; constexpr int blockSize = 2048; @@ -730,13 +730,13 @@ TEST_F (OversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadiu EXPECT_LT (levelDb, -70.0); } -TEST_F (OversamplerAccuracyTest, RoundTripPassbandIsFlat) +TEST_F (SincOversamplerAccuracyTest, RoundTripPassbandIsFlat) { EXPECT_LT (roundTripAccuracy<4, 16> (1000.0 / sampleRate).maxError, 0.005); EXPECT_LT (roundTripAccuracy<4, 16> (0.3).maxError, 0.005); } -TEST_F (OversamplerAccuracyTest, RoundTripSineSNR) +TEST_F (SincOversamplerAccuracyTest, RoundTripSineSNR) { EXPECT_GT (roundTripAccuracy<4, 16> (0.1).snrDb, 80.0); } From 08283e90ad6ca2e9e41d08b906e9f91eb42f3992 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 12:25:02 +0200 Subject: [PATCH 09/37] More oversampling improvements --- docs/dsp/resampling.md | 46 ++-- .../audiograph/source/nodes/DistortionNodes.h | 6 +- .../resampling/yup_HalfbandOversampler.h | 237 ++++++++++++------ tests/yup_dsp/yup_HalfbandOversampler.cpp | 55 ++-- 4 files changed, 239 insertions(+), 105 deletions(-) diff --git a/docs/dsp/resampling.md b/docs/dsp/resampling.md index dd0e9b069..5f3f629fb 100644 --- a/docs/dsp/resampling.md +++ b/docs/dsp/resampling.md @@ -97,9 +97,9 @@ os.downsample (outPtrs, numChannels, numSamples); - `reset()` clears history without re-preparing. This single-stage design remains for arbitrary integer factors and fixed, -compile-time kernel sizes. For power-of-two factors prefer -`HalfbandOversampler` below, which is what the `Oversampler2xFloat` … -`Oversampler32xDouble` aliases refer to. +compile-time kernel sizes, and it is what the spectrum analyzer example uses +for its oversampled sweeps. For power-of-two factors in effects, see +`HalfbandOversampler` below. ## HalfbandOversampler @@ -129,25 +129,35 @@ os.downsample (outPtrs, numChannels, numSamples); ``` - `HalfbandOversamplerDesign` (aliased as `Design` inside the class) selects - the filter family and the targets every stage must meet: - `stopbandAttenuationDb` (default 100) and `passbandEdge` as a fraction of the - input rate (default 0.45, i.e. 19.8 kHz at 44.1 kHz). Content between - `passbandEdge` and `1 - passbandEdge` of the input Nyquist folds back into - the top of the band, as with every halfband design. -- `HalfbandFilterType::linearPhaseFIR` designs Kaiser-windowed halfbands and + the filter family and the targets: `stopbandAttenuationDb` (default 100), + `passbandEdge` as a fraction of the input rate (default 0.45, i.e. 19.8 kHz + at 44.1 kHz) and `stopbandEdge` (default 0.5). With the default stopband + edge the linear-phase FIR rejects everything above the input Nyquist, so + nothing folds back into the band; its first stage is then a general + polyphase lowpass and dominates the cost. Setting `stopbandEdge` to + `1 - passbandEdge` makes the first stage a pure halfband, about a quarter + of the cost and half the latency, but content between 0.5 and 0.55 of the + input rate folds into the top of the band with only partial attenuation. + Every further stage protects the whole band up to Nyquist. +- `HalfbandFilterType::linearPhaseFIR` designs Kaiser-windowed stages and verifies each stage's stopband numerically at `prepare()` time, lengthening the filter until the target is met. Phase is exactly linear and both latencies are whole input samples: a small delay at the top rate rounds the - cascade's fractional delay up. With the defaults, 4× costs about 94 MACs per - input sample to decimate (188 for the round trip) with a 72-sample round-trip - latency; 32× costs about 330 / 660 MACs. `SincOversampler` at radius 16 - needs 128 / 256 and 1024 / 2048 for 90 dB and a passband to 0.36 fs. + cascade's fractional delay up. With the defaults, 4× decimates in about 300 + MACs per input sample (600 for the round trip) with a generation latency of + about 72 samples; 32× is about 600 / 1200 MACs. With a pure halfband first + stage those figures drop to about 100 / 200 and 330 / 660 MACs with a + 40-sample generation latency. `SincOversampler` at radius 16 needs + 128 / 256 and 1024 / 2048 for 90 dB and a passband to 0.36 fs, and leaks + content between 0.5 and 0.54 fs at 40 to 90 dB below full scale. - `HalfbandFilterType::polyphaseIIR` designs elliptic halfbands realised as two allpass branches (Valenzuela & Constantinides). A 100 dB first stage is order 17, eight multiplies per sample, and the whole 32× cascade decimates in about 76 multiplies per input sample. Latency is a few samples but the phase is nonlinear near the passband edge; `getLatencyInSamples()` reports the - low-frequency group delay rounded to the nearest sample. + low-frequency group delay rounded to the nearest sample. Its first stage is + always a halfband, so `stopbandEdge` is ignored and content between 0.5 and + 0.55 of the input rate folds into the top of the band. - `prepare` is **not** realtime-safe. `sampleRate` is accepted for symmetry with `SincOversampler`; the design itself is rate independent. - `upsample`, `beginGeneration`, `processOversampledBlock`, @@ -158,10 +168,10 @@ os.downsample (outPtrs, numChannels, numSamples); returns the FIR length or the elliptic order of a stage (stage 0 runs next to the input rate) for diagnostics. -Convenience aliases: `Oversampler2xFloat`, `Oversampler4xFloat`, -`Oversampler8xFloat`, `Oversampler16xFloat`, `Oversampler32xFloat` and the -`Double` variants are `HalfbandOversampler` instantiations with the default -design. +Convenience aliases: `HalfbandOversampler2xFloat`, `HalfbandOversampler4xFloat`, +`HalfbandOversampler8xFloat`, `HalfbandOversampler16xFloat`, +`HalfbandOversampler32xFloat` and the `Double` variants are +`HalfbandOversampler` instantiations with the default design. ## Resampler diff --git a/examples/audiograph/source/nodes/DistortionNodes.h b/examples/audiograph/source/nodes/DistortionNodes.h index ff161c978..648c7ff5d 100644 --- a/examples/audiograph/source/nodes/DistortionNodes.h +++ b/examples/audiograph/source/nodes/DistortionNodes.h @@ -257,9 +257,9 @@ class TanhDistortionProcessor final : public yup::AudioProcessor yup::AudioParameter::Ptr drive; yup::AudioParameter::Ptr oversamplingIndex; yup::SmoothedValue smoothedDrive; - yup::Oversampler2xFloat oversampler2x; - yup::Oversampler4xFloat oversampler4x; - yup::Oversampler8xFloat oversampler8x; + yup::HalfbandOversampler2xFloat oversampler2x; + yup::HalfbandOversampler4xFloat oversampler4x; + yup::HalfbandOversampler8xFloat oversampler8x; bool oversamplersPrepared = false; }; diff --git a/modules/yup_dsp/resampling/yup_HalfbandOversampler.h b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h index 55d5d3cc5..7a344e5ac 100644 --- a/modules/yup_dsp/resampling/yup_HalfbandOversampler.h +++ b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h @@ -45,6 +45,18 @@ struct HalfbandOversamplerDesign /** Passband edge as a fraction of the input sample rate, in (0, 0.5). */ double passbandEdge = 0.45; + + /** Stopband edge of the stage next to the input rate, as a fraction of the + input sample rate, in (passbandEdge, 1 - passbandEdge]. + + 0.5 (the default) rejects everything above the input Nyquist, so nothing + folds back into the band. 1 - passbandEdge makes that stage a pure + halfband: about a quarter of the cost and half the latency, but content + between 0.5 and 1 - passbandEdge folds into the top of the band with only + partial attenuation. Values in between trade one for the other. Only + linearPhaseFIR honours this; polyphaseIIR is always a pure halfband. + */ + double stopbandEdge = 0.5; }; //============================================================================== @@ -70,9 +82,14 @@ struct HalfbandOversamplerDesign The passband extends to Design::passbandEdge times the input rate (0.45 by default, 19.8 kHz at 44.1 kHz) with at least Design::stopbandAttenuationDb of - rejection (100 dB by default). Content between passbandEdge and - 1 - passbandEdge of the input Nyquist folds back into the top of the band, - as with every halfband design. + rejection (100 dB by default) from Design::stopbandEdge upwards. With the + default edge of 0.5 the linear-phase FIR rejects everything above the input + Nyquist, so nothing folds back into the band; that first stage is then a + general polyphase lowpass rather than a halfband and dominates the cost. + Setting stopbandEdge to 1 - passbandEdge makes it a pure halfband, about a + quarter of the cost and half the latency, at the price of content between + 0.5 and 0.55 of the input rate folding into the top of the band. The IIR + family is always a pure halfband and shows that fold-back. Typical usage: @code @@ -132,6 +149,7 @@ class HalfbandOversampler design = newDesign; design.stopbandAttenuationDb = jlimit (20.0, 200.0, design.stopbandAttenuationDb); design.passbandEdge = jlimit (0.05, 0.49, design.passbandEdge); + design.stopbandEdge = jlimit (design.passbandEdge + 0.01, 1.0 - design.passbandEdge, design.stopbandEdge); maxChannelCount = maxChannels; maxInputSamples = maxBlockSize; @@ -141,26 +159,29 @@ class HalfbandOversampler { auto& stage = stages[static_cast (s)]; const int lowRate = 1 << s; - const double transition = 0.5 - 2.0 * design.passbandEdge / (2 * lowRate); + + const double passband = (s == 0 ? design.passbandEdge : 0.5) / (2 * lowRate); + const double stopband = (s == 0 && design.filterType == HalfbandFilterType::linearPhaseFIR) + ? design.stopbandEdge / 2.0 + : 0.5 - passband; if (design.filterType == HalfbandFilterType::linearPhaseFIR) { - designFirStage (stage, transition); - interpolationDelay += (2.0 * stage.halfLength + 1.0) / (2 * lowRate); + designFirStage (stage, passband, stopband); + interpolationDelay += static_cast (stage.halfLength) / (2 * lowRate); } else { - designIirStage (stage, transition); + designIirStage (stage, stopband - passband); interpolationDelay += stage.lowFrequencyDelay / lowRate; } const int lowBlock = maxBlockSize * lowRate; - const int branchTaps = static_cast (stage.decimationTaps.size()); - stage.interpolationInput.setSize (maxChannels, lowBlock + jmax (0, branchTaps - 1)); + stage.interpolationInput.setSize (maxChannels, lowBlock + stage.halfLength); stage.interpolationOutput.setSize (maxChannels, 2 * lowBlock); - stage.evenInput.setSize (maxChannels, lowBlock + jmax (0, branchTaps - 1)); - stage.oddInput.setSize (maxChannels, lowBlock + stage.halfLength + 1); + stage.evenInput.setSize (maxChannels, lowBlock + stage.halfLength); + stage.oddInput.setSize (maxChannels, lowBlock + stage.halfLength); stage.decimationOutput.setSize (maxChannels, lowBlock); const auto numSections = stage.directAllpass.size() + stage.delayedAllpass.size(); @@ -452,7 +473,7 @@ class HalfbandOversampler const auto& s = stages[static_cast (stage)]; if (design.filterType == HalfbandFilterType::linearPhaseFIR) - return 4 * s.halfLength + 3; + return 2 * s.halfLength + 1; return 2 * static_cast (s.directAllpass.size() + s.delayedAllpass.size()) + 1; } @@ -461,9 +482,12 @@ class HalfbandOversampler //============================================================================== struct Stage { - int halfLength = 0; // FIR length is 4 * halfLength + 3 - std::vector decimationTaps; // nonzero taps of one polyphase branch, summing to 0.5 - std::vector interpolationTaps; // the same taps scaled by 2 + int halfLength = 0; // FIR group delay in high-rate samples; length is 2 * halfLength + 1 + bool halfband = true; // odd branch is the pure delay 0.5 * z^-halfLength + std::vector evenDecimation; // even polyphase branch (halfLength + 1 taps), summing to 0.5 + std::vector evenInterpolation; // the same taps scaled by 2 + std::vector oddDecimation; // odd polyphase branch (halfLength taps), empty for halfbands + std::vector oddInterpolation; // the same taps scaled by 2 std::vector directAllpass; // IIR sections of the direct branch std::vector delayedAllpass; // IIR sections of the branch behind the unit delay @@ -493,42 +517,60 @@ class HalfbandOversampler // Builds the nonzero polyphase branch of a Kaiser-windowed halfband of the // given length (which must be 3 mod 4), normalized to a DC gain of exactly 1. - static void buildHalfbandBranch (int length, double beta, std::vector& branch) + // Kaiser-windowed linear-phase lowpass of odd length with the given cutoff (fraction + // of the stage rate), split into its even and odd polyphase branches. Each branch is + // normalized to a DC gain of exactly 0.5, which also zeroes the response at the stage + // Nyquist. A halfband (cutoff 0.25) leaves the odd branch with only its center tap. + static void buildLowpassBranches (int length, double cutoff, double beta, std::vector& even, std::vector& odd) { - const int q = (length - 3) / 4; - const int center = 2 * q + 1; - branch.resize (static_cast (2 * q + 2)); - double sum = 0.0; + const int center = (length - 1) / 2; + even.assign (static_cast (center + 1), 0.0); + odd.assign (static_cast (center), 0.0); - for (int j = 0; j < static_cast (branch.size()); ++j) + const auto tap = [&] (int n) { - const double x = (2 * j - center) / 2.0; - const double sinc = std::sin (MathConstants::pi * x) / (MathConstants::pi * x); - branch[static_cast (j)] = 0.5 * sinc * WindowFunctions::kaiser (2 * j, length, beta); - sum += branch[static_cast (j)]; - } + const double x = 2.0 * cutoff * (n - center); + const double sinc = (n == center) ? 1.0 : std::sin (MathConstants::pi * x) / (MathConstants::pi * x); + return 2.0 * cutoff * sinc * WindowFunctions::kaiser (n, length, beta); + }; + + for (int j = 0; j <= center; ++j) + even[static_cast (j)] = tap (2 * j); + + for (int j = 0; j < center; ++j) + odd[static_cast (j)] = tap (2 * j + 1); + + for (auto* branch : { &even, &odd }) + { + double sum = 0.0; - for (auto& tap : branch) - tap *= 0.5 / sum; + for (const auto value : *branch) + sum += value; + + for (auto& value : *branch) + value *= 0.5 / sum; + } } - // Worst magnitude of the halfband response over its stopband, sampled densely enough - // to catch every ripple of a filter of the given length. - static double worstStopbandGain (const std::vector& branch, int length, double transition) noexcept + // Worst magnitude of the symmetric lowpass over [stopbandEdge, 0.5] of the stage + // rate, sampled densely enough to catch every ripple of a filter of this length. + static double worstStopbandGain (const std::vector& even, const std::vector& odd, double stopbandEdge) noexcept { - const int center = length / 2; - const double stopbandEdge = 0.5 - (0.5 - transition) / 2.0; + const int center = static_cast (odd.size()); + const int length = 2 * center + 1; const int numPoints = 8 * length; double worst = 0.0; for (int p = 0; p <= numPoints; ++p) { - const double frequency = stopbandEdge + (0.5 - stopbandEdge) * p / numPoints; - const double omega = MathConstants::twoPi * frequency; - double response = 0.5; + const double omega = MathConstants::twoPi * (stopbandEdge + (0.5 - stopbandEdge) * p / numPoints); + double response = 0.0; + + for (int j = 0; j <= center; ++j) + response += even[static_cast (j)] * std::cos (omega * (2 * j - center)); - for (int j = 0; j < static_cast (branch.size()); ++j) - response += branch[static_cast (j)] * std::cos (omega * (center - 2 * j)); + for (int j = 0; j < center; ++j) + response += odd[static_cast (j)] * std::cos (omega * (2 * j + 1 - center)); worst = jmax (worst, std::abs (response)); } @@ -536,38 +578,67 @@ class HalfbandOversampler return worst; } - void designFirStage (Stage& stage, double transition) const + void designFirStage (Stage& stage, double passband, double stopband) const { const double attenuation = design.stopbandAttenuationDb; const double beta = kaiserBeta (attenuation); const double stopbandGain = std::pow (10.0, -attenuation / 20.0); + const double transition = stopband - passband; + const double cutoff = (passband + stopband) / 2.0; + const bool halfband = std::abs (cutoff - 0.25) < 1e-9; int length = static_cast (std::ceil ((attenuation - 8.0) / (2.285 * MathConstants::twoPi * transition) + 1.0)); length = jmax (length, 7); - while (length % 4 != 3) - ++length; + const auto roundLength = [halfband] (int n) + { + if (halfband) + while (n % 4 != 3) + ++n; + else if (n % 2 == 0) + ++n; + + return n; + }; - std::vector branch; + length = roundLength (length); + std::vector even, odd; for (;;) { - buildHalfbandBranch (length, beta, branch); + buildLowpassBranches (length, cutoff, beta, even, odd); - if (worstStopbandGain (branch, length, transition) <= stopbandGain || length >= maxFirLength) + if (worstStopbandGain (even, odd, stopband) <= stopbandGain || length >= maxFirLength) break; - length += 4; + length = roundLength (length + 2); + } + + stage.halfLength = (length - 1) / 2; + stage.halfband = halfband; + + stage.evenDecimation.resize (even.size()); + stage.evenInterpolation.resize (even.size()); + + for (std::size_t j = 0; j < even.size(); ++j) + { + stage.evenDecimation[j] = static_cast (even[j]); + stage.evenInterpolation[j] = static_cast (2.0 * even[j]); } - stage.halfLength = (length - 3) / 4; - stage.decimationTaps.resize (branch.size()); - stage.interpolationTaps.resize (branch.size()); + stage.oddDecimation.clear(); + stage.oddInterpolation.clear(); - for (std::size_t j = 0; j < branch.size(); ++j) + if (! halfband) { - stage.decimationTaps[j] = static_cast (branch[j]); - stage.interpolationTaps[j] = static_cast (2.0 * branch[j]); + stage.oddDecimation.resize (odd.size()); + stage.oddInterpolation.resize (odd.size()); + + for (std::size_t j = 0; j < odd.size(); ++j) + { + stage.oddDecimation[j] = static_cast (odd[j]); + stage.oddInterpolation[j] = static_cast (2.0 * odd[j]); + } } stage.directAllpass.clear(); @@ -642,26 +713,42 @@ class HalfbandOversampler } stage.halfLength = 0; - stage.decimationTaps.clear(); - stage.interpolationTaps.clear(); + stage.halfband = true; + stage.evenDecimation.clear(); + stage.evenInterpolation.clear(); + stage.oddDecimation.clear(); + stage.oddInterpolation.clear(); } //============================================================================== void interpolateFir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept { - const auto numTaps = stage.interpolationTaps.size(); - const int history = static_cast (numTaps) - 1; + const int history = stage.halfLength; auto* buffer = stage.interpolationInput.getWritePointer (channel); FloatVectorOperations::copy (buffer + history, input, count); - const auto* taps = stage.interpolationTaps.data(); - const int passThrough = stage.halfLength + 1; + const auto* evenTaps = stage.evenInterpolation.data(); + const auto numEven = stage.evenInterpolation.size(); + const auto* oddTaps = stage.oddInterpolation.data(); + const auto numOdd = stage.oddInterpolation.size(); + const int passThrough = (history - 1) / 2 + 1; - for (int m = 0; m < count; ++m) + if (stage.halfband) + { + for (int m = 0; m < count; ++m) + { + *output++ = dotProduct (evenTaps, buffer + m, numEven); + *output++ = buffer[m + passThrough]; + } + } + else { - *output++ = dotProduct (taps, buffer + m, numTaps); - *output++ = buffer[m + passThrough]; + for (int m = 0; m < count; ++m) + { + *output++ = dotProduct (evenTaps, buffer + m, numEven); + *output++ = dotProduct (oddTaps, buffer + m + 1, numOdd); + } } std::copy (buffer + count, buffer + count + history, buffer); @@ -669,9 +756,7 @@ class HalfbandOversampler void decimateFir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept { - const auto numTaps = stage.decimationTaps.size(); - const int evenHistory = static_cast (numTaps) - 1; - const int oddHistory = stage.halfLength + 1; + const int history = stage.halfLength; const int half = count / 2; auto* even = stage.evenInput.getWritePointer (channel); @@ -679,17 +764,29 @@ class HalfbandOversampler for (int i = 0; i < half; ++i) { - even[evenHistory + i] = input[2 * i]; - odd[oddHistory + i] = input[2 * i + 1]; + even[history + i] = input[2 * i]; + odd[history + i] = input[2 * i + 1]; } - const auto* taps = stage.decimationTaps.data(); + const auto* evenTaps = stage.evenDecimation.data(); + const auto numEven = stage.evenDecimation.size(); + const auto* oddTaps = stage.oddDecimation.data(); + const auto numOdd = stage.oddDecimation.size(); + const int passThrough = (history - 1) / 2; - for (int m = 0; m < half; ++m) - output[m] = dotProduct (taps, even + m, numTaps) + static_cast (0.5) * odd[m]; + if (stage.halfband) + { + for (int m = 0; m < half; ++m) + output[m] = dotProduct (evenTaps, even + m, numEven) + static_cast (0.5) * odd[m + passThrough]; + } + else + { + for (int m = 0; m < half; ++m) + output[m] = dotProduct (evenTaps, even + m, numEven) + dotProduct (oddTaps, odd + m, numOdd); + } - std::copy (even + half, even + half + evenHistory, even); - std::copy (odd + half, odd + half + oddHistory, odd); + std::copy (even + half, even + half + history, even); + std::copy (odd + half, odd + half + history, odd); } // First-order allpass (alpha + z^-1) / (1 + alpha z^-1) in one multiply. diff --git a/tests/yup_dsp/yup_HalfbandOversampler.cpp b/tests/yup_dsp/yup_HalfbandOversampler.cpp index 73bb67320..7b1207ed1 100644 --- a/tests/yup_dsp/yup_HalfbandOversampler.cpp +++ b/tests/yup_dsp/yup_HalfbandOversampler.cpp @@ -284,19 +284,19 @@ TEST_F (HalfbandOversamplerTest, StageCountFollowsTheFactor) TEST_F (HalfbandOversamplerTest, AliasesUseTheHalfbandDesign) { - static_assert (std::is_same_v>); - static_assert (std::is_same_v>); - - Oversampler2xFloat a; - Oversampler4xFloat b; - Oversampler8xFloat c; - Oversampler16xFloat d; - Oversampler32xFloat e; - Oversampler2xDouble f; - Oversampler4xDouble g; - Oversampler8xDouble h; - Oversampler16xDouble i; - Oversampler32xDouble j; + static_assert (std::is_same_v>); + static_assert (std::is_same_v>); + + HalfbandOversampler2xFloat a; + HalfbandOversampler4xFloat b; + HalfbandOversampler8xFloat c; + HalfbandOversampler16xFloat d; + HalfbandOversampler32xFloat e; + HalfbandOversampler2xDouble f; + HalfbandOversampler4xDouble g; + HalfbandOversampler8xDouble h; + HalfbandOversampler16xDouble i; + HalfbandOversampler32xDouble j; for (auto* os : { &a, &b, &c, &d, &e }) os->prepare (44100.0, 1, 64); @@ -336,10 +336,37 @@ TEST_F (HalfbandOversamplerTest, FirstStageIsTheLongest) EXPECT_GT (os.getStageFilterOrder (0), os.getStageFilterOrder (1)); EXPECT_GE (os.getStageFilterOrder (1), os.getStageFilterOrder (2)); - EXPECT_EQ (3, os.getStageFilterOrder (0) % 4); + EXPECT_EQ (1, os.getStageFilterOrder (0) % 2); + EXPECT_EQ (3, os.getStageFilterOrder (1) % 4); EXPECT_EQ (0, os.getStageFilterOrder (3)); } +TEST_F (HalfbandOversamplerTest, HalfbandFirstStageIsCheaperButFoldsBackAboveNyquist) +{ + Design halfband; + halfband.stopbandEdge = 1.0 - halfband.passbandEdge; + + HalfbandOversampler strict, relaxed; + strict.prepare (sampleRate, 1, 256); + relaxed.prepare (sampleRate, 1, 256, halfband); + + EXPECT_EQ (3, relaxed.getStageFilterOrder (0) % 4); + EXPECT_LT (relaxed.getStageFilterOrder (0), strict.getStageFilterOrder (0)); + EXPECT_LT (relaxed.getLatencyInSamples(), strict.getLatencyInSamples()); + + // A tone just above Nyquist: rejected by the default, folded back by the halfband. + EXPECT_LT (decimatedToneLevelDb<2> (firDesign(), 0.52), -95.0); + EXPECT_GT (decimatedToneLevelDb<2> (halfband, 0.52), -40.0); +} + +TEST_F (HalfbandOversamplerTest, HigherStagesProtectTheWholeBandUpToNyquist) +{ + // 1.52 fs at 4x folds to 0.48 fs at the second stage: inside the base band + // but above the passband edge, so only a Nyquist-protecting stage rejects it. + EXPECT_LT (decimatedToneLevelDb<4> (firDesign(), 1.52), -95.0); + EXPECT_LT (decimatedToneLevelDb<8> (firDesign(), 3.52), -95.0); +} + TEST_F (HalfbandOversamplerTest, WiderTransitionAndLowerAttenuationShortenTheFilters) { HalfbandOversampler reference, relaxedEdge, relaxedAttenuation, stricter; From 9816424dcc76c32eaaeb5edb138c9d8a9abcd410 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 14:01:48 +0200 Subject: [PATCH 10/37] More DSP --- CHANGELOG.md | 17 +- docs/dsp/index.md | 3 +- docs/dsp/oscillators.md | 101 ++ examples/graphics/source/examples/Audio.h | 1286 +++++++++++------ .../source/examples/SpectrumAnalyzer.h | 68 +- .../oscillators/yup_ModulatedOscillator.h | 345 +++-- .../yup_dsp/oscillators/yup_PrismSpectrum.h | 293 ++++ .../yup_dsp/oscillators/yup_SyncOscillator.h | 55 +- .../yup_dsp/oscillators/yup_WaveformBank.h | 37 +- modules/yup_dsp/yup_dsp.h | 1 + tests/yup_dsp.cpp | 1 + tests/yup_dsp/yup_ModulatedOscillator.cpp | 127 +- tests/yup_dsp/yup_PrismSpectrum.cpp | 389 +++++ tests/yup_dsp/yup_WaveformBank.cpp | 78 + 14 files changed, 2136 insertions(+), 665 deletions(-) create mode 100644 modules/yup_dsp/oscillators/yup_PrismSpectrum.h create mode 100644 tests/yup_dsp/yup_PrismSpectrum.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ef3a111..41d0388f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `GpuBuffer::Impl`, `GpuBuffer::getImpl()` and `GpuBuffer::createWithImpl()` moved from `public:` to `private:` (they were marked `@internal` by comment only); the backend factories that use them are friends. `GpuTexture::getOreTexture()` and `GpuTexture::getRenderImage()` were removed - neither had any caller. - `DragAndDropData` is now a MIME store and moved from `component/` to the new `dragdrop/` folder. Payloads are held as an `Array` under well-known MIME types (`DragAndDropData::mimeTypeText` / `mimeTypeUriList` / `mimeTypePng`), with text, files, URIs and images exposed as convenience accessors over that single store plus an optional same-process `var` native object. Consequently `getFiles()`, `getText()` and `getUris()` now return by value (`Array` / `String` / `StringArray`) rather than `const&` (they decode from the MIME store), and the class gained `withImage` / `withMimeData` / `withNativeObject` with the matching `get*` / `has*` / `getMimeTypes` / `getAllMimeData` accessors. There are no lazy or promised data providers: every MIME blob is an eagerly-owned `MemoryBlock`. - The five drag-and-drop virtuals on `Component` (`isInterestedInDrag`, `itemsDropped`, `itemDragEnter`, `itemDragMove`, `itemDragExit`) and the `Component::internalItemDrag*` dispatch they fed have been removed, so `Component` no longer carries any drag-and-drop surface. Drop targets are now an opt-in mixin: derive from `DragAndDropTarget` (in `dragdrop/`) alongside `Component` and override `isInterestedInDragSource` / `itemDropped` / `itemDragEnter` / `itemDragMove` / `itemDragExit` — or assign the matching `std::function` members — each receiving a single `DragAndDropSourceDetails` that carries the payload, the source component, the target-local position, the allowed actions and the suggested action. The library finds targets with a `dynamic_cast` (see `DragAndDropTarget::dispatchItemDrop()` and friends), preserving the previous enter/move/exit and child-to-parent drop-bubbling semantics. The Python bindings for the removed `Component` hooks were dropped; `DragAndDropTarget` and `DragAndDropTargetComponent` are bound, as are `DragAndDropSource` and its `DragOptions`. -- `Oversampler` was renamed `SincOversampler` (`resampling/yup_SincOversampler.h`) now that it is one of two oversampler designs, and the `Oversampler2xFloat` … `Oversampler32xDouble` aliases name `HalfbandOversampler` instantiations (100 dB, passband to 0.45 of the input rate, linear-phase FIR) instead of it. The call surface is identical, but `getLatencyInSamples()` reports the cascade's own delay (72 input samples at 4× with the defaults, against 32 before) and `getGenerationLatencyInSamples()` is no longer `static constexpr`. Code that needs the sinc design or a non power-of-two factor should spell out `SincOversampler` +- `Oversampler` was renamed `SincOversampler` (`resampling/yup_SincOversampler.h`) now that it is one of two oversampler designs, and the `Oversampler2xFloat` … `Oversampler8xDouble` aliases were removed: `HalfbandOversampler2xFloat` … `HalfbandOversampler32xDouble` name `HalfbandOversampler` instantiations with the default design (100 dB, passband to 0.45 of the input rate, stopband from Nyquist, linear-phase FIR). The call surface is identical, but `getLatencyInSamples()` reports the cascade's own delay (about 140 input samples for the 4× round trip with the defaults, against 32 for the radius-16 sinc) and `getGenerationLatencyInSamples()` is no longer `static constexpr`. Code that needs the sinc design or a non power-of-two factor should spell out `SincOversampler` - `ComponentNative::setFocusedComponent()` takes a second `FocusChangeType` argument saying what moved the focus. It defaults to `FocusChangeType::focusChangedDirectly`, so callers are unaffected, but any class implementing the pure virtual has to match the new signature. ### Core @@ -39,10 +39,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Audio -- Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `Oversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor -- Added shared `WaveformBank`, spectral `MorphingOscillator`, and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `Oversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. -- Added `HalfbandOversampler` (`yup_dsp/resampling/`), a power-of-two oversampler built from a cascade of 2× halfband stages so that only the stage next to the base rate has to be steep. `Design` selects the family and targets: `linearPhaseFIR` designs Kaiser-windowed halfbands, verifies every stage's stopband numerically at `prepare()` and keeps both latencies whole input samples by rounding the cascade's delay at the top rate; `polyphaseIIR` designs elliptic halfbands as two allpass branches (Valenzuela & Constantinides) for a few multiplies per sample and minimal, frequency-dependent latency. Defaults are 100 dB of rejection with a passband to 0.45 of the input rate. At 4× the FIR decimates in about 94 MACs per input sample against 128 for the radius-16 `SincOversampler` at 90 dB and a 0.36 passband; at 32× it is about 330 against 1024, and the IIR cascade needs about 76 multiplies. The public methods mirror `SincOversampler` so the two are drop-in replacements +- Added `HalfbandOversampler` (`yup_dsp/resampling/`), a power-of-two oversampler built from a cascade of 2× halfband stages so that only the stage next to the base rate has to be steep. `Design` selects the family and targets: `linearPhaseFIR` designs Kaiser-windowed halfbands, verifies every stage's stopband numerically at `prepare()` and keeps both latencies whole input samples by rounding the cascade's delay at the top rate; `polyphaseIIR` designs elliptic halfbands as two allpass branches (Valenzuela & Constantinides) for a few multiplies per sample and minimal, frequency-dependent latency. Defaults are 100 dB of rejection, a passband to 0.45 of the input rate and, for the FIR, a stopband starting at the input Nyquist, so nothing folds back into the band (a pure halfband first stage, selected with `stopbandEdge = 1 - passbandEdge`, costs a quarter as much but folds 0.5 to 0.55 of the input rate into the top of the band, which is what the IIR family always does). With the defaults 4× decimates in about 300 MACs per input sample against 128 for the radius-16 `SincOversampler` at 90 dB, a 0.36 passband and a 40 dB leak at Nyquist; at 32× it is about 600 against 1024, and the IIR cascade needs about 76 multiplies. The public methods mirror `SincOversampler` so the two are drop-in replacements. `ModulatedOscillator` now decimates through it: its `SincRadius` template parameter is gone (`ModulatedOscillator`), `prepare()` takes an optional `HalfbandOversamplerDesign`, and `getLatencyInSamples()` became an instance method that reports the design's latency after `prepare()` - `SincOversampler` (the single-stage polyphase sinc formerly named `Oversampler`) is several times faster and rejects images and aliases far better. Each channel now keeps its history contiguously in front of the staging buffer so every output sample is one `dotProduct` (SIMD for `float`/`float` and, newly, `double`/`double` via `FloatVectorOperations`), replacing the per-tap circular-buffer modulo and strided table reads; the kernels are prebuilt phase-major with the gain baked in. Both kernels use Kaiser β = 9 (was 5), and `SincTable::applyKaiserWindow` now spans exactly the kernel radius (it previously windowed over `(SincRadius + 1) · OversampleFactor` entries while the kernel was used out to `SincRadius · OversampleFactor`, cutting it off at about 8 % of the window peak and capping the rejection of `SincOversampler` and `Resampler` alike). The `Oversampler2xFloat` … `Oversampler8xDouble` aliases moved from radius 8 to radius 16, so their reported latency grows from 16 to 32 input samples, and `Oversampler16xFloat` / `Oversampler32xFloat` plus their `Double` variants were added +- Added `PrismSpectrum` (`yup_dsp/oscillators/`), promoting the graphics example's Prism recipe into the library. It shapes a `FourierSeries` through a raised-cosine ridge comb in log2-harmonic space, magnitude companding, exact spectral pulse-width modulation and a quadratic phase dispersion, renormalizing to the source's `sum |c|`. A spectral tilt, an odd/even balance, a movable formant resonance and a pseudo-random phase scatter sit alongside those, each continuous through its own neutral value so it can be modulated without a step; scatter shares dispersion's rotation and so costs nothing extra. Every stage is a per-harmonic scale or rotation, so none can add a frequency and the backend's own bandlimiting is preserved. There is no transform behind it, so a shape can be re-derived once per audio block and the controls modulated; `prepare()` precomputes the per-harmonic `log2` tables. The pulse-width depth fades in over the first `squeezeFadeWidth`, because the raw factor tends to a differentiator rather than to unity as the width falls, which would otherwise make zero a discontinuity +- Added `WaveformBank::refreshFrames()`, replacing a prepared bank's coefficients without allocating and keeping the per-level harmonic limits `prepare()` chose, plus `ModulatedOscillator::setBank()` for adopting a bank rebuilt off-thread +- `ModulatedOscillator::Parameters` moved to namespace scope as `ModulatedOscillatorParameters` (still aliased as the nested `Parameters`), and the modulation maths moved into `detail::ModulatedOscillatorVoice` so oscillators can compose over it without duplicating the phase map, sync and BLEP/BLAMP corrections + +- Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `SincOversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor +- Reduced the graphics synthesizer example to a single Prism oscillator per slot, deriving each slot's spectrum once per block rather than once per voice so the shape controls can be modulated, and exposing squeeze, squash, tilt, odd/even, formant and scatter alongside ridges, color and dispersion. The sync modes moved onto Prism: the shaped series goes through `SyncSpectralResampler` at the slot, driven by the sync mode chooser and a new sync ratio knob, so unison satellites and the waveform preview follow it for free; the Modulated algorithm, its FM knobs and the algorithm chooser are gone from the example +- Improved the graphics synthesizer example with a spectral Prism oscillator, octave and cents controls, poly/mono/legato modes, portamento, rendered voice-stealing tails, a revised instrument layout, and audio-load/overrun metering. Reduced callback and scope overhead, removed output waveshaping, and added regression coverage. + +- Added shared `WaveformBank` and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `SincOversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. - Fixed the pulsar spectral resampler's alternating coefficient sign and corrected oscillator regression tests for fixed-size copies, startup crossfades, and spectral window leakage. @@ -424,7 +431,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - `ArtboardDemo` example (`examples/graphics/source/examples/Artboard.h`): the loaded Rive artboard now queries a named node via `Artboard::findNode` (name, type, bounds shown in a status label) and attaches a rectangular marker component to it with `Artboard::attachComponentToNode` — a "Marker" combo switches between filling the node's bounds and tracking only its position, "Pivot" and "Anchor" combos choose which component point lands on which node point in track mode, and an "Apply transform" toggle rotates the marker with the node — and the marker follows the node on reflows and resizes. - New `ArtboardLayoutDemo` example (`examples/graphics/source/examples/Artboard.h`, registered as "Artboard Layout"): shares `ArtboardDemoBase`'s controls with `ArtboardDemo` but loads `data/layout-ui.riv` and attaches a live, nested `Artboard` (playing the file's `Keyboard` artboard) to the `keyboard_slot` layout placeholder in the main `Wireframe` artboard, instead of a plain marker rectangle. - `ArtboardDemo` example: the displayed Rive file can now be replaced at runtime by dropping a `.riv` file onto the demo, which rebuilds the artboards from the dropped file while keeping the current fit, alignment and marker settings. The demo outlines itself while a `.riv` is dragged over it. -- `AudioExample` example (`examples/graphics/source/examples/Audio.h`): reworked into a Vital-style instrument. Each oscillator gets a display that either draws the reconstructed waveform or edits its partials as draggable magnitude bars, writing sine coefficients the wavetable, sync and morphing backends all render; the drag publishes at most one generation bump per UI frame so it cannot queue an inverse FFT per mouse event across every sounding voice, and the reconstruction's peak is measured on the message thread and applied as a coefficient scale so an edited spectrum cannot exceed full scale. Unison adds up to five detuned, stereo-spread slots per oscillator, built from bare `WavetableOscillator` satellites rather than further `SynthOscillator` copies (which own four wavetable oscillators each once sync and morphing are counted) and offered on the wavetable algorithm alone, where they are exact. A DAHDSR envelope with a drawn curve replaces the single `SmoothedValue` fade, and the voice now renders stereo. Fixes the `Detune` control, which the UI wrote but the engine never read, so both oscillators always played the same frequency. +- `AudioExample` example (`examples/graphics/source/examples/Audio.h`): reworked into a Vital-style instrument. Each oscillator gets a display that either draws the reconstructed waveform or edits its partials as draggable magnitude bars, writing sine coefficients the wavetable, sync and morphing backends all render; the drag publishes at most one generation bump per UI frame so it cannot queue an inverse FFT per mouse event across every sounding voice, and the reconstruction's peak is measured on the message thread and applied as a coefficient scale so an edited spectrum cannot exceed full scale. Unison adds up to five detuned, stereo-spread slots per oscillator, built from bare `WavetableOscillator` satellites rather than further `SynthOscillator` copies (which own four wavetable oscillators each once sync and morphing are counted) and offered on the wavetable algorithm alone, where they are exact. A DAHDSR envelope with a drawn curve replaces the single `SmoothedValue` fade, and the voice now renders stereo. The demo also plays the first available hardware MIDI input, collected through a `MidiMessageCollector` into the same buffer `MidiKeyboardState` reads, so external notes light up the drawn keys as well as sounding. Fixes the `Detune` control, which the UI wrote but the engine never read, so both oscillators always played the same frequency. ### Build System diff --git a/docs/dsp/index.md b/docs/dsp/index.md index e46c8f865..3ea7f32e3 100644 --- a/docs/dsp/index.md +++ b/docs/dsp/index.md @@ -48,7 +48,8 @@ available in the build. - [Oscillators](oscillators.md) - `FourierSeries`, the alias-free `SyncSpectralResampler`, the `AdditiveOscillator` / `WavetableOscillator` synthesis backends, the `SyncOscillator` facade, shared `WaveformBank` frames, - and `MorphingOscillator` / `ModulatedOscillator` for morphing, FM, PM and sync. + `ModulatedOscillator` for morphing, FM, PM and sync, and + `PrismSpectrum` for modulatable ridge, dispersion and exact-PWM shaping. ## Key building blocks diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 035863624..780656133 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -235,6 +235,107 @@ the same allocation-free generation path for other sources. Fill its high-rate b `getGenerationLatencyInSamples()` reports decimation-only latency, while the existing `getLatencyInSamples()` still describes the complete up/down path. +## Prism spectral shaping + +`PrismSpectrum` shapes one `FourierSeries` into another. It is a +pure spectral transform with no DSP state and no transform behind it, so it composes +in front of whichever synthesis backend you were already going to use. + +Applied to harmonic `h`, writing `u = log2 (h)` and `c` for the harmonic's complex +coefficient (`cosine + i sine`), the pipeline runs in this order: + +| Stage | Operation | Control | +|---|---|---| +| Ridge | `gain = 0.15 + 0.85 * ridge^2`, `ridge = 0.5 + 0.5 * cos (2 pi (u / ridgeSpacing - color))` | `ridgeSpacing` in octaves, clamped to [0.25, 8]; `color` slides the comb and is periodic. | +| Tilt | `gain *= 2^(-tilt * u)` | `tilt` in gain octaves per harmonic octave, clamped to +/-4; 0 is flat. The ridge is periodic in `u`, so it cannot express an overall slope. | +| Odd/even | odd and even harmonics scaled against each other | `oddEven` clamped to [0, 1]; 0 keeps only odd, 1 only even, 0.5 is neutral. No transcendentals at all. | +| Formant | `gain *= 2^(formant * exp (-((u - formantPosition) / formantWidth)^2))` | `formant` is a signed depth in gain octaves, clamped to +/-4, 0 is bypass; `formantPosition` is the center in harmonic octaves, clamped to [0, 12]. One movable resonance against the ridge's periodic comb. | +| Squash | `\|c\|` becomes `\|c\|^squash`, phase kept | `squash` clamped to [0.1, 4]; 1 is bypass. | +| Squeeze | `c` multiplied by `1 - d * cis (-2 pi h w)` | `squeeze` is a pulse width in periods, clamped to [0, 0.5]; the depth `d` opens from 0 to 1 over the first `squeezeFadeWidth`, so 0 is a true bypass. | +| Dispersion + scatter | `c` multiplied by `cis ((dispersion - 0.5) * u^2 + scatter * angle[h])` | `dispersion` clamped to [0, 1], 0.5 is flat; `scatter` clamped to [0, 1], 0 is bypass. Both are rotations, so their angles add and one `sincos` serves both - scatter is free on top of dispersion. `angle[h]` is fixed per harmonic, so a shape stays a timbre instead of re-scattering every block. | +| Normalize | whole series scaled so `sum \|c\|` matches the source's | - | + +Every stage is a per-harmonic scale or rotation, so **no stage can produce a +frequency the source did not already contain**. That is the whole antialiasing +argument: shaping cannot alias, and whatever plays the result still bandlimits per +pitch. It is also the rule any further stage must obey: `c[h]` may be multiplied by +anything, but a harmonic's frequency is pinned at `h` times the fundamental and cannot +be moved - stretched or inharmonic partials are not expressible in a periodic series at +all, and need a different oscillator rather than another stage here. Squeeze deserves the emphasis because it looks like it should alias - +pulse-width modulation normally does - but subtracting a phase-shifted copy of a +bandlimited signal is still bandlimited, so this is exact PWM. At `w = 0.5` the factor +is zero for even `h` and two for odd, the square-from-sawtooth identity. + +The faded depth is not cosmetic. The raw factor tends to `i * h * 2 pi w` as the width +falls, and renormalizing that leaves a differentiator rather than the source spectrum, +so the stage has no bypass width - a knob stepping off zero would jump. Fading `d` in +over `squeezeFadeWidth` is what makes zero continuous with its neighbourhood, and it +costs one multiply. The factor stays a per-harmonic complex scale either way, so the +antialiasing argument is untouched. + +Normalization preserves `sum |c|` rather than the waveform's peak, and the two are not +the same: `sum |c|` bounds a peak from well above (about three times over for a +sawtooth), and how close the waveform comes to that bound depends on how aligned its +harmonic phases are. `dispersion` and `scatter` control exactly that, so if constant +output level matters, measure the shaped waveform's peak and rescale - once per patch +alongside the shaping, not per voice. + +Normalization is what makes the controls level-safe. The ridge gain, the companding +and the pulse-width factor (whose magnitude reaches 2) all change level, and +`sum |c|` is an upper bound on the waveform's peak, so preserving it guarantees the +output can never be louder than the source's worst case. The sum runs over harmonics +only; DC is not carried across and the destination's DC is always zero. + +```cpp +constexpr int numHarmonics = 128; + +yup::PrismSpectrum spectrum; +spectrum.prepare (numHarmonics); + +const auto source = yup::FourierSeries::create (yup::Waveform::sawtooth, numHarmonics); +yup::FourierSeries shaped (numHarmonics); + +yup::PrismSpectrum::Shape shape; +shape.ridgeSpacing = 1.5; +shape.dispersion = 0.65; +spectrum.process (source, shaped, shape, 0.3 /* color */); + +// Either backend will do; this one trades an inverse FFT per update for cheap samples. +yup::WavetableOscillator oscillator; +oscillator.prepare (48000.0, numHarmonics); +oscillator.setSeries (shaped); +oscillator.setFrequency (220.0); +oscillator.render(); // crossfades into the new table +``` + +### Modulating the shape + +`prepare (maxHarmonics)` precomputes `log2 (h)` and `log2 (h)^2`, which deletes a +`log2` per harmonic outright. What remains is a handful of transcendentals per +harmonic and **no transform**, which is cheap enough to re-run once per audio block - +so the shape controls are modulatable, not just settable. + +Two rules make that affordable: + +- **Shape once, not once per voice.** The shape belongs to a patch, not to a note. One + `process()` per block feeding every voice costs the same at one voice as at sixteen; + calling it inside each voice is what makes it expensive. +- **Let the backend absorb the update rate.** `WavetableOscillator::render()` + crossfades into the new table, so a per-block update sounds continuous, and nothing + is rendered at all while the controls are still. Note the cost is one inverse FFT per + *sounding table* per block, so it scales linearly with unison width as well as with + polyphony: a few percent of a core at one table per voice, several times that at five. + Where that matters, refresh a single-frame `WaveformBank` once per patch instead - it + is a fixed cost in voices, which is exactly what `refreshFrames()` is for. `AdditiveOscillator` removes the transform entirely and takes new + coefficients with zero latency, at the price of a multiply-accumulate per harmonic + per sample. + +Pre-rendering a sweep of `color` positions into a `WaveformBank` is the other obvious +move, and it is a trap unless `color` is the only control you modulate: it buys +audio-rate `color` but multiplies the cost of every *other* shape control by the number +of frames times the number of bandwidth levels, which is precisely the work you were +trying to avoid. + ## Verification and performance The tests include scalar spectral references, exact Nyquist boundaries, phasor diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index d52d79c4c..92a9e374c 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -48,28 +48,6 @@ constexpr int displayHarmonics = 64; constexpr double levelRampSeconds = 0.01; } // namespace SynthExample -//============================================================================== -/** The synthesis algorithm a voice oscillator renders with. - - Every value maps onto one of the bandlimited yup_dsp oscillators, which differ - in how they derive their spectrum and in how they can be modulated. - - @see SynthOscillator -*/ -enum class SynthOscillatorType -{ - wavetable, /**< The same series rendered once, then played back */ - sync, /**< Alias-free spectral oscillator synchronization */ - morphing, /**< Blends two synchronized endpoint spectra */ - modulated /**< Oversampled morph, FM, PM and phase distortion */ -}; - -/** @internal Item names for SynthOscillatorType, index aligned with the enumeration. */ -inline yup::StringArray getSynthOscillatorTypeNames() -{ - return { "Wavetable", "Sync", "Morphing", "Modulated" }; -} - /** @internal Item names for yup::Waveform, index aligned with the enumeration. */ inline yup::StringArray getSynthWaveformNames() { @@ -179,9 +157,9 @@ class SynthEnvelope delaySamples = toSamples (values.delay); holdSamples = toSamples (values.hold); - releaseSamples = toSamples (values.release); + releaseSamples = toSamples (yup::jmax (0.003f, values.release)); - attackIncrement = 1.0f / static_cast (toSamples (values.attack)); + attackIncrement = 1.0f / static_cast (toSamples (yup::jmax (0.003f, values.attack))); decayIncrement = (1.0f - sustainLevel) / static_cast (toSamples (values.decay)); } @@ -327,17 +305,22 @@ class SynthEnvelope */ struct SynthOscillatorValues { - SynthOscillatorType type = SynthOscillatorType::wavetable; yup::Waveform waveform = yup::Waveform::sawtooth; - yup::Waveform shape = yup::Waveform::square; - yup::SyncMode syncMode = yup::SyncMode::hard; + yup::SyncMode syncMode = yup::SyncMode::none; + float syncRatio = 1.5f; float level = 0.5f; + int octave = 0; float detuneSemitones = 0.0f; - float followerRatio = 1.5f; - float morph = 0.0f; - float phaseDistortion = 0.5f; - float fmAmount = 0.0f; - float fmRatio = 2.0f; + float ridgeSpacing = 1.5f; + float color = 0.0f; + float dispersion = 0.5f; + float squeeze = 0.0f; + float squash = 1.0f; + float tilt = 0.0f; + float oddEven = 0.5f; + float formant = 0.0f; + float formantPosition = 2.0f; + float scatter = 0.0f; int unisonVoices = 1; float unisonDetune = 0.2f; float unisonSpread = 0.6f; @@ -363,17 +346,22 @@ struct SynthOscillatorSettings harmonic.store (0.0f); } - std::atomic type { static_cast (SynthOscillatorType::wavetable) }; std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; - std::atomic shape { static_cast (yup::Waveform::square) }; - std::atomic syncMode { static_cast (yup::SyncMode::hard) }; + std::atomic syncMode { static_cast (yup::SyncMode::none) }; + std::atomic syncRatio { 1.5f }; std::atomic level { 0.5f }; + std::atomic octave { 0 }; std::atomic detuneSemitones { 0.0f }; - std::atomic followerRatio { 1.5f }; - std::atomic morph { 0.0f }; - std::atomic phaseDistortion { 0.5f }; - std::atomic fmAmount { 0.0f }; - std::atomic fmRatio { 2.0f }; + std::atomic ridgeSpacing { 1.5f }; + std::atomic color { 0.0f }; + std::atomic dispersion { 0.5f }; + std::atomic squeeze { 0.0f }; + std::atomic squash { 1.0f }; + std::atomic tilt { 0.0f }; + std::atomic oddEven { 0.5f }; + std::atomic formant { 0.0f }; + std::atomic formantPosition { 2.0f }; + std::atomic scatter { 0.0f }; std::atomic unisonVoices { 1 }; std::atomic unisonDetune { 0.2f }; std::atomic unisonSpread { 0.6f }; @@ -386,17 +374,22 @@ struct SynthOscillatorSettings /** Takes a snapshot for one block of audio. */ SynthOscillatorValues read() const noexcept { - return { static_cast (type.load()), - static_cast (waveform.load()), - static_cast (shape.load()), + return { static_cast (waveform.load()), static_cast (syncMode.load()), + syncRatio.load(), level.load(), + octave.load(), detuneSemitones.load(), - followerRatio.load(), - morph.load(), - phaseDistortion.load(), - fmAmount.load(), - fmRatio.load(), + ridgeSpacing.load(), + color.load(), + dispersion.load(), + squeeze.load(), + squash.load(), + tilt.load(), + oddEven.load(), + formant.load(), + formantPosition.load(), + scatter.load(), unisonVoices.load(), unisonDetune.load(), unisonSpread.load(), @@ -450,8 +443,7 @@ struct SynthOscillatorSettings //============================================================================== /** Immutable waveform data, prepared once and read by every voice. - Building a FourierSeries allocates, and rendering a WaveformBank runs one inverse - FFT per frame and bandwidth level, so both belong at construction time. Once + Building a FourierSeries allocates, so it belongs at construction time. Once prepared the resources are read-only and safe to share across voices. @see SynthOscillator @@ -470,8 +462,6 @@ class SynthOscillatorResources for (std::size_t index = 0; index < frames.size(); ++index) frames[index] = yup::FourierSeries::create (waveforms[index], SynthExample::maxHarmonics); - - bank.prepare ({ frames.data(), frames.size() }); } /** Returns the series of one of the Waveform presets. */ @@ -480,59 +470,239 @@ class SynthOscillatorResources return frames[static_cast (waveform)]; } - /** Returns the bank of frames the modulated oscillator morphs across. */ - const yup::WaveformBank& getBank() const noexcept { return bank; } - private: std::array, 6> frames; - yup::WaveformBank bank; }; //============================================================================== -/** One of a voice's oscillators, owning every algorithm it can switch between. +/** Turns a control snapshot into the shape yup::PrismSpectrum reads. */ +inline yup::PrismSpectrum::Shape toPrismShape (const SynthOscillatorValues& values) noexcept +{ + return { static_cast (values.ridgeSpacing), + static_cast (values.dispersion), + static_cast (values.squeeze), + static_cast (values.squash), + static_cast (values.tilt), + static_cast (values.oddEven), + static_cast (values.formant), + static_cast (values.formantPosition), + static_cast (values.scatter) }; +} - prepare() allocates all the backends; renderBlock() is allocation-free and pushes - only the controls that actually moved, so editing a knob is the only thing that - pays for a spectral transform or a table render. +//============================================================================== +/** Shapes a source through the Prism stages and then through the sync transform. - Unison is built from bare yup::WavetableOscillator satellites rather than from - further copies of this class. A SynthOscillator owns four wavetable oscillators - once the sync and morphing backends are counted, each with its own FFT and tables, - so replicating it per unison slot would cost several times the memory and startup - work that one satellite does. The satellites play the same series as the selected - algorithm, detuned and panned around it, which is exact for the wavetable algorithm - and is why unison is offered there alone. + The audio thread and the waveform display both derive their series here, so the + preview cannot drift from what the voices play. yup::PrismSpectrum is stateless + and shared; the resampler keeps scratch storage, so every caller brings its own. - @see SynthOscillatorSettings, SynthOscillatorResources + @returns The series to play or draw: the shaped one, or the synced one when a + sync mode is selected. */ -class SynthOscillator +inline yup::FourierSeries& derivePrismSeries (const yup::PrismSpectrum& spectrum, + yup::SyncSpectralResampler& resampler, + const yup::FourierSeries& source, + yup::FourierSeries& shaped, + yup::FourierSeries& synced, + const SynthOscillatorValues& values) noexcept +{ + spectrum.process (source, shaped, toPrismShape (values), values.color); + + if (values.syncMode == yup::SyncMode::none) + return shaped; + + resampler.transform (shaped, static_cast (values.syncRatio), values.syncMode, synced); + + return synced; +} + +//============================================================================== +/** The series every voice of one oscillator slot plays, rebuilt once per block. + + A slot's spectrum does not vary per voice: the waveform, the edited partials, the + Prism shape and the sync settings are all slot-wide. Deriving them once here rather + than inside each voice is what makes every control modulatable - shaping 128 + harmonics is cheap, the sync transform less so, and neither should run once per + voice per block. + + The sync transform is run for every harmonic the slot renders rather than for one + voice's Nyquist limit, which is what lets a single derivation serve voices at + different pitches: each voice's wavetable drops what its own pitch cannot carry. + + Voices publish nothing back; they compare getGeneration() and re-render their own + table when it moves, which keeps yup::WavetableOscillator's crossfade doing the + smoothing. Everything here runs on the audio thread at the top of a block, before + any voice reads it, so no publication handshake is needed. Nothing allocates. + + @see SynthOscillator, derivePrismSeries +*/ +class SynthOscillatorSlot { public: - /** Returns true if the algorithm can be widened with unison satellites. */ - static bool supportsUnison (SynthOscillatorType type) noexcept + SynthOscillatorSlot() { - return type == SynthOscillatorType::wavetable; + spectrum.prepare (SynthExample::maxHarmonics); + resampler.prepare (SynthExample::maxHarmonics); + customSeries.resize (SynthExample::maxHarmonics); + shapedSeries.resize (SynthExample::maxHarmonics); + syncedSeries.resize (SynthExample::maxHarmonics); + + // The meter is only ever asked for a waveform, never played, and a frequency of + // zero keeps every harmonic whatever rate it was prepared at. + peakMeter.prepare (48000.0, SynthExample::maxHarmonics); + peakMeter.setFrequency (0.0); + peakMeter.setIncludeDC (true); } - /** Allocates every backend and attaches the shared waveform resources. */ - void prepare (double newSampleRate, int maxBlockSize, const SynthOscillatorResources& oscillatorResources) + /** Rebuilds the slot's series if anything it depends on moved. Audio thread. */ + void update (const SynthOscillatorValues& values, + const SynthOscillatorSettings& settings, + const SynthOscillatorResources& resources) noexcept { - sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; - resources = &oscillatorResources; + const auto partialsChanged = ! hasApplied + || applied.usesCustomSeries != values.usesCustomSeries + || applied.harmonicGeneration != values.harmonicGeneration + || applied.harmonicScale != values.harmonicScale; + + if (partialsChanged && values.usesCustomSeries) + settings.copyHarmonicsInto (customSeries, values.harmonicScale); + + const auto& source = values.usesCustomSeries ? customSeries : resources.getFrame (values.waveform); + const auto sourceChanged = partialsChanged || applied.waveform != values.waveform; + + const auto shapeChanged = applied.syncMode != values.syncMode + || applied.syncRatio != values.syncRatio + || applied.ridgeSpacing != values.ridgeSpacing + || applied.color != values.color + || applied.dispersion != values.dispersion + || applied.squeeze != values.squeeze + || applied.squash != values.squash + || applied.tilt != values.tilt + || applied.oddEven != values.oddEven + || applied.formant != values.formant + || applied.formantPosition != values.formantPosition + || applied.scatter != values.scatter; + + if (sourceChanged) + sourcePeak = measurePeak (source); + + if (sourceChanged || shapeChanged) + { + auto& derived = derivePrismSeries (spectrum, resampler, source, shapedSeries, syncedSeries, values); + + matchSourcePeak (derived); + published = &derived; + ++generation; + } + + applied = values; + hasApplied = true; + } + + /** Returns the series the voices of this slot should be playing. */ + const yup::FourierSeries& getSeries() const noexcept { return *published; } + + /** Bumped whenever getSeries() changed, so a voice knows to re-render its table. */ + int getGeneration() const noexcept { return generation; } + + /** The shaper, which the waveform display reuses for its preview. */ + const yup::PrismSpectrum& getSpectrum() const noexcept { return spectrum; } + +private: + /** Points the display samples the peak search walks. Twice the harmonic count + resolves the highest harmonic; this is four times it, for a little margin. */ + static constexpr int peakResolution = 512; + + /** Returns the largest absolute value one period of a series reaches. + + The series is rendered with the same inverse FFT the voices use rather than + summed harmonic by harmonic, which would cost a transcendental per harmonic per + point. One transform per slot per block is nothing beside the one each sounding + voice already pays. + */ + double measurePeak (const yup::FourierSeries& series) noexcept + { + peakMeter.setSeries (series); + peakMeter.render (false); + + auto peak = 0.0; + + for (int index = 0; index < peakResolution; ++index) + { + const auto phase = static_cast (index) / static_cast (peakResolution); + + peak = yup::jmax (peak, std::abs (static_cast (peakMeter.getValueAtPhase (phase)))); + } + + return peak; + } + + /** Rescales a derived series so its waveform peaks where the source's does. + + yup::PrismSpectrum preserves the coefficient sum, which bounds a peak from well + above - three times over for a sawtooth - and how close the waveform comes to + that bound depends on how aligned the harmonic phases are. Dispersion, scatter + and the sync reset all move exactly that, so without this they would swing the + output level as they are swept rather than only recolouring it. + */ + void matchSourcePeak (yup::FourierSeries& series) noexcept + { + const auto derivedPeak = measurePeak (series); + + if (derivedPeak <= 1.0e-9 || sourcePeak <= 1.0e-9) + return; + + const auto scale = sourcePeak / derivedPeak; + + for (int harmonic = 1; harmonic <= series.getNumHarmonics(); ++harmonic) + series.setHarmonic (harmonic, + series.getCosine (harmonic) * scale, + series.getSine (harmonic) * scale); + } + + yup::PrismSpectrum spectrum; + yup::SyncSpectralResampler resampler; + yup::WavetableOscillator peakMeter; + double sourcePeak = 0.0; + yup::FourierSeries customSeries; + yup::FourierSeries shapedSeries; + yup::FourierSeries syncedSeries; + const yup::FourierSeries* published = &shapedSeries; + SynthOscillatorValues applied; + int generation = 0; + bool hasApplied = false; +}; + +//============================================================================== +/** One of a voice's oscillators: a wavetable playing the slot's series, plus unison. + + prepare() allocates the backends; renderBlock() is allocation-free and only + re-renders a table when the slot's series moved, so editing a control is the only + thing that pays for an inverse FFT. + + Unison is built from bare yup::WavetableOscillator satellites playing the same + series as the center, detuned and panned around it. + + @see SynthOscillatorSettings, SynthOscillatorSlot +*/ +class SynthOscillator +{ +public: + /** Allocates every backend and attaches the shared slot. */ + void prepare (double newSampleRate, int maxBlockSize, const SynthOscillatorSlot& sharedSlot) + { + const auto sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + slot = &sharedSlot; wavetable.prepare (sampleRate, SynthExample::maxHarmonics); - sync.prepare (sampleRate, SynthExample::maxHarmonics); - morphing.prepare (sampleRate, SynthExample::maxHarmonics); - modulated.prepare (sampleRate, maxBlockSize, resources->getBank()); for (auto& satellite : satellites) satellite.prepare (sampleRate, SynthExample::maxHarmonics); - customSeries.resize (SynthExample::maxHarmonics); slotBuffer.assign (static_cast (yup::jmax (1, maxBlockSize)), 0.0f); - applied = {}; - hasAppliedValues = false; + appliedSeriesGeneration = -1; } /** Restarts every backend, spreading the satellites so they do not stack in phase. */ @@ -541,9 +711,6 @@ class SynthOscillator const auto phase = static_cast (initialPhase); wavetable.setPhase (phase); - sync.setPhase (phase); - morphing.setPhase (phase); - modulated.reset (initialPhase); for (std::size_t index = 0; index < satellites.size(); ++index) { @@ -551,11 +718,9 @@ class SynthOscillator satellites[index].setPhase (phase + offset - std::floor (phase + offset)); } - - modulatorPhase = 0.0; } - /** Applies the pending changes and writes one stereo block of the selected algorithm. + /** Applies the pending changes and writes one stereo block. The buffers are overwritten rather than added to, so the caller does not have to clear them first. @@ -564,22 +729,22 @@ class SynthOscillator float* right, int numSamples, const SynthOscillatorValues& values, - const SynthOscillatorSettings& settings, double frequency) noexcept { yup::FloatVectorOperations::clear (left, numSamples); yup::FloatVectorOperations::clear (right, numSamples); - const auto slotCount = supportsUnison (values.type) - ? yup::jlimit (1, SynthExample::maxUnisonVoices, values.unisonVoices) - : 1; + // The table holds one period of the synced waveform, which for mirrored sync is + // two leader periods, so it is played at the fundamental the transform produced. + const auto played = frequency * yup::SyncSpectralResampler::getFundamentalScale (values.syncMode); + const auto slotCount = yup::jlimit (1, SynthExample::maxUnisonVoices, values.unisonVoices); const auto centreIndex = (slotCount - 1) / 2; - const auto slotGain = 1.0f / std::sqrt (static_cast (slotCount)); + const auto slotGain = 1.0f / static_cast (slotCount); - applyParameters (values, settings, detunedFrequency (frequency, values, centreIndex, slotCount)); + applySeries(); - renderAlgorithm (slotBuffer.data(), numSamples, values, frequency); + renderSatellite (wavetable, numSamples, detunedFrequency (played, values, centreIndex, slotCount)); accumulateSlot (left, right, numSamples, slotOffset (centreIndex, slotCount) * values.unisonSpread, slotGain); for (int index = 0, satellite = 0; index < slotCount; ++index) @@ -589,7 +754,7 @@ class SynthOscillator renderSatellite (satellites[static_cast (satellite++)], numSamples, - detunedFrequency (frequency, values, index, slotCount)); + detunedFrequency (played, values, index, slotCount)); accumulateSlot (left, right, numSamples, slotOffset (index, slotCount) * values.unisonSpread, slotGain); } @@ -630,7 +795,8 @@ class SynthOscillator } } - /** Renders one unison satellite, which always plays the wavetable algorithm. */ + /** Renders one unison slot; render() crossfades into a new table, which is what + keeps a modulated shape from stepping. */ void renderSatellite (yup::WavetableOscillator& satellite, int numSamples, double frequency) noexcept { satellite.setFrequency (frequency); @@ -641,142 +807,31 @@ class SynthOscillator satellite.processBlock (slotBuffer.data(), numSamples); } - /** Writes the block of whichever algorithm is selected. */ - void renderAlgorithm (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept - { - switch (values.type) - { - case SynthOscillatorType::wavetable: - if (wavetable.needsRender()) - wavetable.render(); - - wavetable.processBlock (output, numSamples); - break; - - case SynthOscillatorType::sync: - sync.update(); - sync.processBlock (output, numSamples); - break; - - case SynthOscillatorType::morphing: - morphing.update(); - morphing.processBlock (output, numSamples, static_cast (values.morph)); - break; - - case SynthOscillatorType::modulated: - renderModulatedBlock (output, numSamples, values, frequency); - break; - } - } - - //============================================================================== - /** Renders the oversampled modulation path, driving its FM from an internal sine. */ - void renderModulatedBlock (float* output, int numSamples, const SynthOscillatorValues& values, double frequency) noexcept - { - yup::ModulatedOscillator::Parameters parameters; - parameters.frequency = frequency; - parameters.morph = static_cast (values.morph); - parameters.phaseDistortion = static_cast (values.phaseDistortion); - parameters.syncFrequency = values.syncMode == yup::SyncMode::none - ? 0.0 - : frequency * static_cast (values.followerRatio); - - const auto internalSampleRate = modulated.getInternalSampleRate(); - const auto modulatorIncrement = static_cast (values.fmRatio) * frequency / internalSampleRate; - const auto modulatorDepth = static_cast (values.fmAmount) * frequency; - - // Unlike the other backends this one can decline to write anything, which would - // otherwise leave the previous unison slot's samples in the shared buffer. - const auto rendered = modulated.processModulatedBlock (output, numSamples, [this, ¶meters, modulatorIncrement, modulatorDepth] (int) - { - parameters.linearFM = modulatorDepth * std::sin (yup::MathConstants::twoPi * modulatorPhase); - - modulatorPhase += modulatorIncrement; - modulatorPhase -= std::floor (modulatorPhase); - - return parameters; - }); - - if (! rendered) - yup::FloatVectorOperations::clear (output, numSamples); - } - - //============================================================================== - /** Pushes only the controls whose value changed since the last block. */ - void applyParameters (const SynthOscillatorValues& values, const SynthOscillatorSettings& settings, double frequency) noexcept + /** Hands the slot's series to every table when it moved since the last block. */ + void applySeries() noexcept { - const auto typeChanged = ! hasAppliedValues || applied.type != values.type; - const auto waveformChanged = typeChanged || applied.waveform != values.waveform; - const auto shapeChanged = typeChanged || applied.shape != values.shape; - const auto syncModeChanged = typeChanged || applied.syncMode != values.syncMode; - const auto ratioChanged = typeChanged || applied.followerRatio != values.followerRatio; - - const auto partialsChanged = ! hasAppliedValues - || applied.usesCustomSeries != values.usesCustomSeries - || applied.harmonicGeneration != values.harmonicGeneration; + const auto generation = slot->getGeneration(); - if (partialsChanged && values.usesCustomSeries) - settings.copyHarmonicsInto (customSeries, values.harmonicScale); + if (generation == appliedSeriesGeneration) + return; - const auto seriesChanged = waveformChanged || partialsChanged; - const auto& series = values.usesCustomSeries ? customSeries : resources->getFrame (values.waveform); + appliedSeriesGeneration = generation; - if (seriesChanged) - for (auto& satellite : satellites) - satellite.setSeries (series); + const auto& series = slot->getSeries(); - switch (values.type) - { - case SynthOscillatorType::wavetable: - if (seriesChanged) - wavetable.setSeries (series); - break; - - case SynthOscillatorType::sync: - if (seriesChanged) - sync.setFollowerSeries (series); - if (syncModeChanged) - sync.setSyncMode (values.syncMode); - if (ratioChanged) - sync.setFollowerRatio (values.followerRatio); - break; - - case SynthOscillatorType::morphing: - if (seriesChanged || shapeChanged) - morphing.setSeries (series, resources->getFrame (values.shape)); - if (syncModeChanged) - morphing.setSyncMode (values.syncMode); - if (ratioChanged) - morphing.setFollowerRatio (values.followerRatio); - break; - - case SynthOscillatorType::modulated: - break; - } + wavetable.setSeries (series); - wavetable.setFrequency (frequency); - sync.setFrequency (frequency); - morphing.setFrequency (frequency); - - applied = values; - hasAppliedValues = true; + for (auto& satellite : satellites) + satellite.setSeries (series); } //============================================================================== yup::WavetableOscillator wavetable; - yup::SyncOscillator sync; - yup::MorphingOscillator morphing; - yup::ModulatedOscillator modulated; - std::array, SynthExample::maxUnisonVoices - 1> satellites; - const SynthOscillatorResources* resources = nullptr; - SynthOscillatorValues applied; - yup::FourierSeries customSeries; + const SynthOscillatorSlot* slot = nullptr; std::vector slotBuffer; - double sampleRate = 44100.0; - double modulatorPhase = 0.0; - bool hasAppliedValues = false; + int appliedSeriesGeneration = -1; }; //============================================================================== @@ -795,23 +850,35 @@ class SynthVoice : public yup::SynthesiserVoice public: SynthVoice (const std::array& oscillatorSettings, const SynthEnvelopeSettings& sharedEnvelopeSettings, - const SynthOscillatorResources& oscillatorResources) + const std::array& sharedSlots) : settings (oscillatorSettings) , envelopeSettings (sharedEnvelopeSettings) - , resources (oscillatorResources) + , slots (sharedSlots) { } /** Allocates every oscillator backend. Must run outside the audio callback. */ void prepare (double sampleRate, int maxBlockSize) { - for (auto& oscillator : oscillators) - oscillator.prepare (sampleRate, maxBlockSize, resources); + for (std::size_t slot = 0; slot < oscillators.size(); ++slot) + oscillators[slot].prepare (sampleRate, maxBlockSize, slots[slot]); for (auto& level : levels) level.reset (sampleRate, SynthExample::levelRampSeconds); envelope.prepare (sampleRate); + playbackRate = sampleRate; + hasPlayed = false; + preserveNote = false; + glideSeconds = 0.0; + pitch.setCurrentAndTargetValue (69.0); + bend.reset (sampleRate, SynthExample::levelRampSeconds); + bend.setCurrentAndTargetValue (0.0); + tailLength = yup::jlimit (2, yup::jmax (2, maxBlockSize), static_cast (sampleRate * 0.006)); + tailBuffer.setSize (2, tailLength); + tailScratch.setSize (2, tailLength); + tailPosition = tailLength; + clearCurrentNote(); const auto blockSize = static_cast (yup::jmax (1, maxBlockSize)); @@ -827,18 +894,39 @@ class SynthVoice : public yup::SynthesiserVoice return dynamic_cast (sound) != nullptr; } + /** Configures the next mono transition. Legato retains the envelope and phases. + Glide is measured in seconds and interpolates pitch in semitones. */ + void setTransition (double seconds, bool legato) noexcept + { + glideSeconds = yup::jlimit (0.0, 2.0, seconds); + preserveNote = legato && envelope.isActive(); + } + void startNote (int midiNoteNumber, float velocity, yup::SynthesiserSound*, int currentPitchWheelPosition) override { - noteFrequency = midiNoteToFrequency (midiNoteNumber); - velocityGain = yup::jmax (0.05f, velocity); + const auto currentPitch = pitch.getCurrentValue(); + pitch.reset (playbackRate, glideSeconds); + pitch.setCurrentAndTargetValue (currentPitch); + if (glideSeconds > 0.0 && hasPlayed) + pitch.setTargetValue (static_cast (midiNoteNumber)); + else + pitch.setCurrentAndTargetValue (static_cast (midiNoteNumber)); pitchWheelMoved (currentPitchWheelPosition); - for (auto& oscillator : oscillators) - oscillator.reset (0.0); + if (! preserveNote) + { + velocityGain = yup::jlimit (0.0f, 1.0f, velocity); + for (auto& oscillator : oscillators) + oscillator.reset (0.0); + + envelope.setParameters (envelopeSettings.read()); + envelope.noteOn(); + } - envelope.setParameters (envelopeSettings.read()); - envelope.noteOn(); + preserveNote = false; + hasPlayed = true; + glideSeconds = 0.0; } void stopNote (float, bool allowTailOff) override @@ -849,77 +937,103 @@ class SynthVoice : public yup::SynthesiserVoice return; } - envelope.noteOffImmediate(); + if (! preserveNote) + { + if (envelope.isActive()) + { + tailScratch.clear(); + renderNextBlock (tailScratch, 0, tailLength); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < tailLength; ++sample) + { + const auto fade = 0.5 + 0.5 * std::cos (yup::MathConstants::pi + * sample / (tailLength - 1)); + tailBuffer.setSample (channel, sample, tailScratch.getSample (channel, sample) * static_cast (fade)); + } + tailPosition = 0; + } + envelope.noteOffImmediate(); + } clearCurrentNote(); } void pitchWheelMoved (int newPitchWheelValue) override { const auto normalized = (static_cast (newPitchWheelValue) - 8192.0) / 8192.0; - pitchWheelRatio = std::pow (2.0, normalized * pitchWheelRangeSemitones / 12.0); + bend.setTargetValue (normalized * pitchWheelRangeSemitones); } + /** Includes a recycled voice's short continuation in the activity meter. */ + bool isSounding() const noexcept { return isVoiceActive() || tailPosition < tailLength; } + void controllerMoved (int, int) override {} //============================================================================== void renderNextBlock (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) override { - if (! isVoiceActive() || numSamples <= 0) + if (! isSounding() || numSamples <= 0 || outputBuffer.getNumChannels() == 0) return; - jassert (numSamples <= static_cast (mixLeft.size())); - - const auto frequency = noteFrequency * pitchWheelRatio; - const auto numChannelsToWrite = yup::jmin (outputBuffer.getNumChannels(), 2); - - float* channels[2] = {}; + for (std::size_t index = 0; index < blockValues.size(); ++index) + blockValues[index] = settings[index].read(); + envelope.setParameters (envelopeSettings.read()); - for (int channel = 0; channel < numChannelsToWrite; ++channel) - channels[channel] = outputBuffer.getWritePointer (channel, startSample); + for (int offset = 0; offset < numSamples;) + { + const auto controlBlock = pitch.isSmoothing() || bend.isSmoothing() ? 128 : numSamples; + const auto count = yup::jmin (numSamples - offset, static_cast (mixLeft.size()), controlBlock); + if (count <= 0) + return; + renderChunk (outputBuffer, startSample + offset, count); + offset += count; + } + } +private: + //============================================================================== + void renderChunk (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) noexcept + { yup::FloatVectorOperations::clear (mixLeft.data(), numSamples); yup::FloatVectorOperations::clear (mixRight.data(), numSamples); - for (int index = 0; index < SynthExample::oscillatorCount; ++index) + if (isVoiceActive()) { - const auto& oscillatorSettings = settings[static_cast (index)]; - const auto values = oscillatorSettings.read(); - auto& level = levels[static_cast (index)]; - - // The oscillator's own detune is what makes the two of them beat against each - // other, so it has to reach the frequency the backends are driven with. - const auto detuned = frequency * std::pow (2.0, static_cast (values.detuneSemitones) / 12.0); - - oscillators[static_cast (index)] - .renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, oscillatorSettings, detuned); - - level.setTargetValue (values.level); - - for (int sample = 0; sample < numSamples; ++sample) + const auto frequency = midiNoteToFrequency (pitch.skip (numSamples) + bend.skip (numSamples)); + for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - const auto gain = level.getNextValue(); - - mixLeft[static_cast (sample)] += oscLeft[static_cast (sample)] * gain; - mixRight[static_cast (sample)] += oscRight[static_cast (sample)] * gain; + const auto slot = static_cast (index); + const auto& values = blockValues[slot]; + auto& level = levels[slot]; + const auto detuned = frequency * std::exp2 (values.octave + values.detuneSemitones / 12.0); + oscillators[slot].renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, detuned); + level.setTargetValue (values.level); + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto gain = level.getNextValue(); + mixLeft[static_cast (sample)] += oscLeft[static_cast (sample)] * gain; + mixRight[static_cast (sample)] += oscRight[static_cast (sample)] * gain; + } } } - envelope.setParameters (envelopeSettings.read()); - for (int sample = 0; sample < numSamples; ++sample) { const auto gain = envelope.getNextValue() * velocityGain; - const auto left = mixLeft[static_cast (sample)] * gain; - const auto right = mixRight[static_cast (sample)] * gain; - - if (numChannelsToWrite == 1) + auto left = mixLeft[static_cast (sample)] * gain; + auto right = mixRight[static_cast (sample)] * gain; + if (tailPosition < tailLength) { - channels[0][sample] += (left + right) * 0.5f; + left += tailBuffer.getSample (0, tailPosition); + right += tailBuffer.getSample (1, tailPosition++); } + + if (outputBuffer.getNumChannels() == 1) + outputBuffer.addSample (0, startSample + sample, (left + right) * 0.5f); else { - channels[0][sample] += left; - channels[1][sample] += right; + outputBuffer.addSample (0, startSample + sample, left); + outputBuffer.addSample (1, startSample + sample, right); } } @@ -927,9 +1041,7 @@ class SynthVoice : public yup::SynthesiserVoice clearCurrentNote(); } -private: - //============================================================================== - static double midiNoteToFrequency (int midiNoteNumber) noexcept + static double midiNoteToFrequency (double midiNoteNumber) noexcept { return 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); } @@ -939,9 +1051,10 @@ class SynthVoice : public yup::SynthesiserVoice const std::array& settings; const SynthEnvelopeSettings& envelopeSettings; - const SynthOscillatorResources& resources; + const std::array& slots; std::array oscillators; + std::array blockValues; std::array, SynthExample::oscillatorCount> levels; SynthEnvelope envelope; @@ -951,38 +1064,152 @@ class SynthVoice : public yup::SynthesiserVoice std::vector mixLeft; std::vector mixRight; - double noteFrequency = 440.0; - double pitchWheelRatio = 1.0; + yup::SmoothedValue pitch; + yup::SmoothedValue bend; + yup::AudioBuffer tailBuffer; + yup::AudioBuffer tailScratch; + double playbackRate = 44100.0; + double glideSeconds = 0.0; + int tailLength = 0; + int tailPosition = 0; + bool preserveNote = false; + bool hasPlayed = false; float velocityGain = 1.0f; }; //============================================================================== -/** Polyphonic synthesiser rendering the two oscillators of every voice. */ +/** Keyboard allocation and envelope behavior. */ +enum class SynthPlayMode +{ + poly, /**< Eight voices with rendered release tails when recycled. */ + mono, /**< Last-note priority, retriggering the envelope on each note. */ + legato /**< Overlapping notes preserve phases and envelope; glide is optional. */ +}; + +/** Polyphonic synthesiser rendering the two oscillators of every voice. + MIDI and rendering methods belong to the audio thread; the UI edits atomic controls. */ class HarmonicSynthEngine : public yup::Synthesiser { public: HarmonicSynthEngine() { addSound (new SynthSound()); + setMinimumRenderingSubdivisionSize (1, true); + settings[0].color = 0.2f; + settings[1].waveform = static_cast (yup::Waveform::triangle); + settings[1].syncMode = static_cast (yup::SyncMode::hard); + settings[1].octave = -1; + settings[1].level = 0.3f; for (int index = 0; index < SynthExample::voiceCount; ++index) { - auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, envelopeSettings, resources)); + auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, envelopeSettings, oscillatorSlots)); addVoice (voice); ownedVoices.add (voice); } } - /** Prepares every voice, including the oscillators of the modulated algorithm. */ + /** Prepares every voice. Must run outside the audio callback. */ void prepare (double sampleRate, int maxBlockSize) { + allNotesOff (0, false); setCurrentPlaybackSampleRate (sampleRate); + activeVoices.store (0); for (int index = 0; index < ownedVoices.size(); ++index) ownedVoices[index]->prepare (sampleRate, maxBlockSize); } + /** UI-facing performance controls, sampled at the next render boundary. */ + std::atomic playMode { static_cast (SynthPlayMode::poly) }; + std::atomic portamento { 0.12f }; + + /** Requests a release of all keys without taking the synthesiser lock on the UI thread. */ + void requestAllNotesOff() noexcept { releaseRequested.store (true); } + + /** Renders MIDI with sample-accurate note boundaries and publishes the voice meter. */ + void renderNextBlock (yup::AudioBuffer& output, const yup::MidiBuffer& midi, int start, int count) + { + const auto requestedMode = static_cast (playMode.load()); + const auto release = releaseRequested.exchange (false); + if (requestedMode != mode || release) + { + allNotesOff (0, true); + mode = requestedMode; + } + // Every voice of a slot plays the same spectrum, so it is derived once here + // rather than once per voice: that is what lets the Prism and sync controls be + // modulated without paying for the shaping eight times over. + for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) + oscillatorSlots[slot].update (settings[slot].read(), settings[slot], resources); + + yup::Synthesiser::renderNextBlock (output, midi, start, count); + int active = 0; + for (auto* voice : ownedVoices) + active += voice->isSounding() ? 1 : 0; + activeVoices.store (active); + } + + void noteOn (int channel, int note, float velocity) override + { + if (mode == SynthPlayMode::poly) + { + yup::Synthesiser::noteOn (channel, note, velocity); + return; + } + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + held = { ++noteOrder, velocity, true }; + playMonoNote (channel, note, velocity); + } + + void noteOff (int channel, int note, float velocity, bool tailOff) override + { + if (mode == SynthPlayMode::poly) + { + yup::Synthesiser::noteOff (channel, note, velocity, tailOff); + return; + } + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + held.down = false; + if (! sustain[static_cast (channel - 1)] || ! tailOff) + held.order = 0; + selectMonoNote (tailOff); + } + + void allNotesOff (int channel, bool tailOff) override + { + for (int index = 0; index < static_cast (heldNotes.size()); ++index) + if (channel <= 0 || index / 128 == channel - 1) + heldNotes[static_cast (index)] = {}; + for (int index = 0; index < 16; ++index) + if (channel <= 0 || index == channel - 1) + sustain[static_cast (index)] = false; + yup::Synthesiser::allNotesOff (channel, tailOff); + if (mode != SynthPlayMode::poly) + selectMonoNote (tailOff); + } + + void handleController (int channel, int controller, int value) override + { + if (mode != SynthPlayMode::poly && controller == 64) + { + sustain[static_cast (channel - 1)] = value >= 64; + if (value < 64) + { + for (int note = 0; note < 128; ++note) + { + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + if (! held.down) + held.order = 0; + } + selectMonoNote (true); + } + return; + } + yup::Synthesiser::handleController (channel, controller, value); + } + /** Returns the settings edited by one of the user interface panels. */ SynthOscillatorSettings& getOscillatorSettings (int oscillatorIndex) noexcept { @@ -995,6 +1222,12 @@ class HarmonicSynthEngine : public yup::Synthesiser /** Returns the shared waveform presets, which the waveform displays also read. */ const SynthOscillatorResources& getResources() const noexcept { return resources; } + /** Returns the shared series derivation of one oscillator slot. */ + SynthOscillatorSlot& getOscillatorSlot (int oscillatorIndex) noexcept + { + return oscillatorSlots[static_cast (oscillatorIndex)]; + } + /** Returns the note of a sounding voice, or -1 when the synthesiser is silent. */ int getCurrentlyPlayingNote() const noexcept { @@ -1006,19 +1239,58 @@ class HarmonicSynthEngine : public yup::Synthesiser } /** Returns how many voices are currently sounding. */ - int getNumActiveVoices() const noexcept + int getNumActiveVoices() const noexcept { return activeVoices.load(); } + +private: + void playMonoNote (int channel, int note, float velocity) { - int count = 0; + auto* voice = ownedVoices[0].get(); + const auto overlapping = voice->isVoiceActive() && monoKeyActive; + voice->setTransition (overlapping ? portamento.load() : 0.0, + overlapping && mode == SynthPlayMode::legato); + startVoice (voice, getSound (0).get(), channel, note, velocity); + monoKeyActive = true; + } - for (int index = 0; index < ownedVoices.size(); ++index) - if (ownedVoices[index] != nullptr && ownedVoices[index]->isVoiceActive()) - ++count; + void selectMonoNote (bool tailOff) + { + int latest = -1; + for (int index = 0; index < static_cast (heldNotes.size()); ++index) + if (heldNotes[static_cast (index)].order != 0 + && (latest < 0 || heldNotes[static_cast (index)].order > heldNotes[static_cast (latest)].order)) + latest = index; - return count; + auto* voice = ownedVoices[0].get(); + if (latest < 0) + { + if (monoKeyActive) + voice->stopNote (0.0f, tailOff); + monoKeyActive = false; + return; + } + const auto channel = latest / 128 + 1; + const auto note = latest % 128; + if (! voice->isVoiceActive() || voice->getCurrentlyPlayingNote() != note || ! voice->isPlayingChannel (channel)) + playMonoNote (channel, note, heldNotes[static_cast (latest)].velocity); } -private: + struct HeldNote + { + yup::uint64 order = 0; + float velocity = 0.0f; + bool down = false; + }; + + std::array heldNotes {}; + std::array sustain {}; + yup::uint64 noteOrder = 0; + SynthPlayMode mode = SynthPlayMode::poly; + bool monoKeyActive = false; + std::atomic releaseRequested { false }; + std::atomic activeVoices { 0 }; + SynthOscillatorResources resources; + std::array oscillatorSlots; std::array settings; SynthEnvelopeSettings envelopeSettings; yup::ReferenceCountedArray ownedVoices; @@ -1038,8 +1310,8 @@ inline constexpr yup::Color windowBackground { 0xff16191d }; inline constexpr yup::Color panelBackground { 0xff21262c }; inline constexpr yup::Color panelBorder { 0xff2e353d }; inline constexpr yup::Color displayBackground { 0xff0e1114 }; -inline constexpr yup::Color accent { 0xff4dc3ff }; -inline constexpr yup::Color accentDim { 0xff2b6f8f }; +inline constexpr yup::Color accent { 0xff72ead2 }; +inline constexpr yup::Color accentDim { 0xff287f78 }; inline constexpr yup::Color textPrimary { 0xffe6ebf0 }; inline constexpr yup::Color textSecondary { 0xff8b96a0 }; @@ -1181,11 +1453,17 @@ class ChoiceControl : public yup::Component class WaveformEditor : public yup::Component { public: - WaveformEditor (SynthOscillatorSettings& settingsToEdit, const SynthOscillatorResources& sharedResources) + WaveformEditor (SynthOscillatorSettings& settingsToEdit, + const SynthOscillatorResources& sharedResources, + const SynthOscillatorSlot& sharedSlot) : settings (settingsToEdit) , resources (sharedResources) + , slot (sharedSlot) { + resampler.prepare (SynthExample::maxHarmonics); displaySeries.resize (SynthExample::maxHarmonics); + shapedSeries.resize (SynthExample::maxHarmonics); + syncedSeries.resize (SynthExample::maxHarmonics); displaySamples.assign (displayResolution, 0.0f); refresh(); @@ -1213,36 +1491,61 @@ class WaveformEditor : public yup::Component else displaySeries.copyFrom (resources.getFrame (static_cast (settings.waveform.load()))); - for (int index = 0; index < displayResolution; ++index) + reconstruct (displaySeries); + + // The reconstruction is measured here, on the message thread, so the audio thread + // never has to work out how loud an edited spectrum turned out to be. This is the + // source's peak, which is a different quantity from the drawn waveform's below. + if (usesCustomSeries) { - const auto phase = static_cast (index) / static_cast (displayResolution - 1); + const auto sourcePeak = measurePeak(); - displaySamples[static_cast (index)] = - evaluateFourierSeries (displaySeries, phase, SynthExample::displayHarmonics); + if (sourcePeak > 1.0e-6f) + settings.harmonicScale.store (1.0f / sourcePeak); } - auto peak = 0.0f; + // Shaping and sync have no inverse FFT behind them, so the preview follows every + // control live, derived exactly as the voices derive theirs once per block. + reconstruct (derivePrismSeries (slot.getSpectrum(), resampler, displaySeries, shapedSeries, syncedSeries, settings.read())); - for (auto sample : displaySamples) - peak = yup::jmax (peak, std::abs (sample)); + // Normalize whatever is actually drawn. The shaper preserves the coefficient sum + // rather than the peak, and a sum bounds a peak from well above - three times over + // for a sawtooth - so scaling the derived waveform by the source's peak would draw + // it clean outside the display. + const auto peak = measurePeak(); - if (peak <= 1.0e-6f) + if (peak > 1.0e-6f) { - repaint(); - return; + const auto scale = 1.0f / peak; + + for (auto& sample : displaySamples) + sample *= scale; } - // The reconstruction is measured here, on the message thread, so the audio thread - // never has to work out how loud an edited spectrum turned out to be. - if (usesCustomSeries) - settings.harmonicScale.store (1.0f / peak); + repaint(); + } - const auto scale = 1.0f / peak; + /** Sums a series into the display buffer at the display's resolution. */ + void reconstruct (const yup::FourierSeries& series) noexcept + { + for (int index = 0; index < displayResolution; ++index) + { + const auto phase = static_cast (index) / static_cast (displayResolution - 1); - for (auto& sample : displaySamples) - sample *= scale; + displaySamples[static_cast (index)] = + evaluateFourierSeries (series, phase, SynthExample::displayHarmonics); + } + } - repaint(); + /** Returns the largest magnitude currently in the display buffer. */ + float measurePeak() const noexcept + { + auto peak = 0.0f; + + for (auto sample : displaySamples) + peak = yup::jmax (peak, std::abs (sample)); + + return peak; } /** Publishes a pending drag to the audio thread, coalescing a frame's worth of edits. */ @@ -1332,7 +1635,7 @@ class WaveformEditor : public yup::Component for (int index = 0; index < SynthExample::editableHarmonics; ++index) { - const auto magnitude = yup::jlimit (0.0f, 1.0f, settings.harmonics[static_cast (index)].load()); + const auto magnitude = yup::jlimit (0.0f, 1.0f, static_cast (displaySeries.getMagnitude (index + 1))); const auto height = yup::jmax (1.0f, magnitude * bounds.getHeight()); const auto x = bounds.getX() + barWidth * static_cast (index); @@ -1387,8 +1690,12 @@ class WaveformEditor : public yup::Component SynthOscillatorSettings& settings; const SynthOscillatorResources& resources; + const SynthOscillatorSlot& slot; + yup::SyncSpectralResampler resampler; yup::FourierSeries displaySeries; + yup::FourierSeries shapedSeries; + yup::FourierSeries syncedSeries; std::vector displaySamples; yup::Path path; @@ -1427,15 +1734,19 @@ class Oscilloscope : public yup::Component if (renderData.empty()) return; - const auto xSize = bounds.getWidth() / static_cast (renderData.size()); + const auto pointCount = yup::jmin (512, static_cast (renderData.size())); + const auto xSize = bounds.getWidth() / static_cast (yup::jmax (1, pointCount - 1)); path.clear(); - path.reserveSpace (static_cast (renderData.size())); + path.reserveSpace (pointCount); path.moveTo (bounds.getX(), bounds.getCenterY() - renderData[0] * bounds.getHeight() * 0.45f); - for (std::size_t i = 1; i < renderData.size(); ++i) + for (int i = 1; i < pointCount; ++i) + { + const auto sample = static_cast (i) * (renderData.size() - 1) / static_cast (pointCount - 1); path.lineTo (bounds.getX() + static_cast (i) * xSize, - bounds.getCenterY() - renderData[i] * bounds.getHeight() * 0.45f); + bounds.getCenterY() - renderData[sample] * bounds.getHeight() * 0.45f); + } filledPath = path.createStrokePolygon (4.0f); @@ -1657,20 +1968,26 @@ class SynthOscillatorPanel : public yup::Component SynthOscillatorPanel (const yup::String& panelTitle, SynthOscillatorSettings& settingsToEdit, const SynthOscillatorResources& resources, + const SynthOscillatorSlot& sharedSlot, const yup::Font& font) : settings (settingsToEdit) - , editor (settingsToEdit, resources) - , algorithmChoice ("ALGORITHM", getSynthOscillatorTypeNames(), font) + , editor (settingsToEdit, resources, sharedSlot) , waveformChoice ("WAVEFORM", getSynthWaveformNames(), font) - , shapeChoice ("SHAPE B", getSynthWaveformNames(), font) , syncModeChoice ("SYNC", getSynthSyncModeNames(), font) , levelKnob ("LEVEL", 0.0, 1.0, 0.001, 0.5, font) - , detuneKnob ("DETUNE", -24.0, 24.0, 0.01, 0.0, font) - , ratioKnob ("RATIO", 0.25, 8.0, 0.01, 1.5, font) - , morphKnob ("MORPH", 0.0, 1.0, 0.001, 0.0, font) - , distortionKnob ("DIST", 0.01, 0.99, 0.001, 0.5, font) - , fmAmountKnob ("FM AMT", 0.0, 4.0, 0.001, 0.0, font) - , fmRatioKnob ("FM RATIO", 0.25, 8.0, 0.01, 2.0, font) + , octaveKnob ("OCTAVE", -3.0, 3.0, 1.0, 0.0, font) + , detuneKnob ("CENTS", -100.0, 100.0, 1.0, 0.0, font) + , ridgesKnob ("RIDGES", 0.25, 8.0, 0.01, 1.5, font) + , colorKnob ("COLOR", 0.0, 1.0, 0.001, 0.0, font) + , dispersionKnob ("DISPERSION", 0.01, 0.99, 0.001, 0.5, font) + , squeezeKnob ("SQUEEZE", 0.0, 0.5, 0.001, 0.0, font) + , squashKnob ("SQUASH", 0.1, 4.0, 0.01, 1.0, font) + , tiltKnob ("TILT", -4.0, 4.0, 0.01, 0.0, font) + , oddEvenKnob ("ODD/EVEN", 0.0, 1.0, 0.001, 0.5, font) + , formantKnob ("FORMANT", -4.0, 4.0, 0.01, 0.0, font) + , formantPositionKnob ("F.POS", 0.0, 7.0, 0.01, 2.0, font) + , scatterKnob ("SCATTER", 0.0, 1.0, 0.001, 0.0, font) + , syncRatioKnob ("SYNC RATIO", 1.0, 8.0, 0.01, 1.5, font) , unisonKnob ("UNISON", 1.0, static_cast (SynthExample::maxUnisonVoices), 1.0, 1.0, font) , unisonDetuneKnob ("U.DETUNE", 0.0, 1.0, 0.001, 0.2, font) , spreadKnob ("SPREAD", 0.0, 1.0, 0.001, 0.6, font) @@ -1698,19 +2015,15 @@ class SynthOscillatorPanel : public yup::Component addAndMakeVisible (editor); - for (auto* choice : { &algorithmChoice, &waveformChoice, &shapeChoice, &syncModeChoice }) + for (auto* choice : { &waveformChoice, &syncModeChoice }) addAndMakeVisible (*choice); - for (auto* knob : { &levelKnob, &detuneKnob, &ratioKnob, &morphKnob, &distortionKnob, - &fmAmountKnob, &fmRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }) + for (auto* knob : { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob, + &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, + &formantPositionKnob, &scatterKnob, &syncRatioKnob, + &unisonKnob, &unisonDetuneKnob, &spreadKnob }) addAndMakeVisible (*knob); - algorithmChoice.onChange = [this] (int id) - { - settings.type = id - 1; - updateUnisonAvailability(); - }; - // Picking a preset drops any edited partials, otherwise the oscillator would keep // playing the edited shape while the combo box claims something else. waveformChoice.onChange = [this] (int id) @@ -1719,15 +2032,27 @@ class SynthOscillatorPanel : public yup::Component editor.revertToPreset(); }; - shapeChoice.onChange = [this] (int id) { settings.shape = id - 1; }; - syncModeChoice.onChange = [this] (int id) { settings.syncMode = id - 1; }; + syncModeChoice.onChange = [this] (int id) + { + settings.syncMode = id - 1; + updateSyncAvailability(); + editor.refresh(); + }; + levelKnob.onChange = [this] (double value) { settings.level = static_cast (value); }; - detuneKnob.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value); }; - ratioKnob.onChange = [this] (double value) { settings.followerRatio = static_cast (value); }; - morphKnob.onChange = [this] (double value) { settings.morph = static_cast (value); }; - distortionKnob.onChange = [this] (double value) { settings.phaseDistortion = static_cast (value); }; - fmAmountKnob.onChange = [this] (double value) { settings.fmAmount = static_cast (value); }; - fmRatioKnob.onChange = [this] (double value) { settings.fmRatio = static_cast (value); }; + octaveKnob.onChange = [this] (double value) { settings.octave = static_cast (value); }; + detuneKnob.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value * 0.01); }; + ridgesKnob.onChange = [this] (double value) { settings.ridgeSpacing = static_cast (value); editor.refresh(); }; + colorKnob.onChange = [this] (double value) { settings.color = static_cast (value); editor.refresh(); }; + dispersionKnob.onChange = [this] (double value) { settings.dispersion = static_cast (value); editor.refresh(); }; + squeezeKnob.onChange = [this] (double value) { settings.squeeze = static_cast (value); editor.refresh(); }; + squashKnob.onChange = [this] (double value) { settings.squash = static_cast (value); editor.refresh(); }; + tiltKnob.onChange = [this] (double value) { settings.tilt = static_cast (value); editor.refresh(); }; + oddEvenKnob.onChange = [this] (double value) { settings.oddEven = static_cast (value); editor.refresh(); }; + formantKnob.onChange = [this] (double value) { settings.formant = static_cast (value); editor.refresh(); }; + formantPositionKnob.onChange = [this] (double value) { settings.formantPosition = static_cast (value); editor.refresh(); }; + scatterKnob.onChange = [this] (double value) { settings.scatter = static_cast (value); editor.refresh(); }; + syncRatioKnob.onChange = [this] (double value) { settings.syncRatio = static_cast (value); editor.refresh(); }; unisonKnob.onChange = [this] (double value) { settings.unisonVoices = static_cast (value); }; unisonDetuneKnob.onChange = [this] (double value) { settings.unisonDetune = static_cast (value); }; spreadKnob.onChange = [this] (double value) { settings.unisonSpread = static_cast (value); }; @@ -1738,23 +2063,28 @@ class SynthOscillatorPanel : public yup::Component /** Reads the settings back into the widgets. */ void refresh() { - algorithmChoice.getComboBox().setSelectedId (settings.type.load() + 1, yup::dontSendNotification); waveformChoice.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); - shapeChoice.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); syncModeChoice.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); levelKnob.getSlider().setValue (settings.level.load(), yup::dontSendNotification); - detuneKnob.getSlider().setValue (settings.detuneSemitones.load(), yup::dontSendNotification); - ratioKnob.getSlider().setValue (settings.followerRatio.load(), yup::dontSendNotification); - morphKnob.getSlider().setValue (settings.morph.load(), yup::dontSendNotification); - distortionKnob.getSlider().setValue (settings.phaseDistortion.load(), yup::dontSendNotification); - fmAmountKnob.getSlider().setValue (settings.fmAmount.load(), yup::dontSendNotification); - fmRatioKnob.getSlider().setValue (settings.fmRatio.load(), yup::dontSendNotification); + octaveKnob.getSlider().setValue (settings.octave.load(), yup::dontSendNotification); + detuneKnob.getSlider().setValue (settings.detuneSemitones.load() * 100.0, yup::dontSendNotification); + ridgesKnob.getSlider().setValue (settings.ridgeSpacing.load(), yup::dontSendNotification); + colorKnob.getSlider().setValue (settings.color.load(), yup::dontSendNotification); + dispersionKnob.getSlider().setValue (settings.dispersion.load(), yup::dontSendNotification); + squeezeKnob.getSlider().setValue (settings.squeeze.load(), yup::dontSendNotification); + squashKnob.getSlider().setValue (settings.squash.load(), yup::dontSendNotification); + tiltKnob.getSlider().setValue (settings.tilt.load(), yup::dontSendNotification); + oddEvenKnob.getSlider().setValue (settings.oddEven.load(), yup::dontSendNotification); + formantKnob.getSlider().setValue (settings.formant.load(), yup::dontSendNotification); + formantPositionKnob.getSlider().setValue (settings.formantPosition.load(), yup::dontSendNotification); + scatterKnob.getSlider().setValue (settings.scatter.load(), yup::dontSendNotification); + syncRatioKnob.getSlider().setValue (settings.syncRatio.load(), yup::dontSendNotification); unisonKnob.getSlider().setValue (settings.unisonVoices.load(), yup::dontSendNotification); unisonDetuneKnob.getSlider().setValue (settings.unisonDetune.load(), yup::dontSendNotification); spreadKnob.getSlider().setValue (settings.unisonSpread.load(), yup::dontSendNotification); - updateUnisonAvailability(); + updateSyncAvailability(); editor.refresh(); } @@ -1774,7 +2104,7 @@ class SynthOscillatorPanel : public yup::Component bounds.removeFromTop (spacing); - auto knobArea = bounds.removeFromBottom (knobRowHeight * 2.0f + spacing); + auto knobArea = bounds.removeFromBottom (knobRowHeight * 3.0f + spacing * 2.0f); bounds.removeFromBottom (spacing); auto choiceArea = bounds.removeFromBottom (choiceRowHeight); @@ -1782,15 +2112,20 @@ class SynthOscillatorPanel : public yup::Component editor.setBounds (bounds); - layoutControlsInRow (choiceArea, { &algorithmChoice, &waveformChoice, &shapeChoice, &syncModeChoice }); + layoutControlsInRow (choiceArea, { &waveformChoice, &syncModeChoice }); layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), - { &levelKnob, &detuneKnob, &ratioKnob, &morphKnob, &distortionKnob }); + { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob }); + + knobArea.removeFromTop (spacing); + + layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), + { &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, &formantPositionKnob }); knobArea.removeFromTop (spacing); layoutControlsInRow (knobArea, - { &fmAmountKnob, &fmRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }); + { &scatterKnob, &syncRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }); } void paint (yup::Graphics& g) override @@ -1800,14 +2135,10 @@ class SynthOscillatorPanel : public yup::Component private: //============================================================================== - /** Greys out the unison knobs for the algorithms the satellites cannot reproduce. */ - void updateUnisonAvailability() + /** Greys out the ratio knob while no sync mode reads it. */ + void updateSyncAvailability() { - const auto supported = SynthOscillator::supportsUnison (static_cast (settings.type.load())); - - unisonKnob.setEnabled (supported); - unisonDetuneKnob.setEnabled (supported); - spreadKnob.setEnabled (supported); + syncRatioKnob.setEnabled (static_cast (settings.syncMode.load()) != yup::SyncMode::none); } //============================================================================== @@ -1825,18 +2156,23 @@ class SynthOscillatorPanel : public yup::Component yup::TextButton resetButton { "RESET" }; WaveformEditor editor; - ChoiceControl algorithmChoice; ChoiceControl waveformChoice; - ChoiceControl shapeChoice; ChoiceControl syncModeChoice; KnobControl levelKnob; + KnobControl octaveKnob; KnobControl detuneKnob; - KnobControl ratioKnob; - KnobControl morphKnob; - KnobControl distortionKnob; - KnobControl fmAmountKnob; - KnobControl fmRatioKnob; + KnobControl ridgesKnob; + KnobControl colorKnob; + KnobControl dispersionKnob; + KnobControl squeezeKnob; + KnobControl squashKnob; + KnobControl tiltKnob; + KnobControl oddEvenKnob; + KnobControl formantKnob; + KnobControl formantPositionKnob; + KnobControl scatterKnob; + KnobControl syncRatioKnob; KnobControl unisonKnob; KnobControl unisonDetuneKnob; KnobControl spreadKnob; @@ -1852,7 +2188,7 @@ class AudioExample : Component ("AudioExample") , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) { - deviceManager.initialiseWithDefaultDevices (0, 2); + audioDeviceError = deviceManager.initialiseWithDefaultDevices (0, 2); // The keyboard state is pumped into the synth by processNextMidiBuffer(), so no note // listener is registered here: listening as well would trigger every note twice. @@ -1866,19 +2202,24 @@ class AudioExample keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyPressedColorId, SynthTheme::accentDim); keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::keyOutlineColorId, SynthTheme::panelBorder); addAndMakeVisible (keyboardComponent); + keyboardComponent.setVisible (false); const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); - titleLabel.setText ("YUP POLYPHONIC SYNTHESIZER", yup::dontSendNotification); + titleLabel.setText ("P R I S M / SPECTRAL SYNTH", yup::dontSendNotification); titleLabel.setFont (font.withHeight (17.0f)); titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); addAndMakeVisible (titleLabel); - subtitleLabel.setText ("Two unison oscillators per voice - draw the waveform or edit its partials", yup::dontSendNotification); + subtitleLabel.setText ("Sculpt harmonics. Scatter phases. Play the spectrum.", yup::dontSendNotification); subtitleLabel.setFont (font.withHeight (11.0f)); subtitleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); addAndMakeVisible (subtitleLabel); + loadLabel.setFont (font.withHeight (11.0f)); + loadLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (loadLabel); + voiceLabel.setText ("", yup::dontSendNotification); voiceLabel.setFont (font.withHeight (11.0f)); voiceLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::accent); @@ -1890,6 +2231,7 @@ class AudioExample yup::String ("OSC ") + yup::String (index + 1), synth.getOscillatorSettings (index), synth.getResources(), + synth.getOscillatorSlot (index), font.withHeight (10.0f)); addAndMakeVisible (*panel); @@ -1911,7 +2253,7 @@ class AudioExample clearButton.onClick = [this] { keyboardState.allNotesOff (0); // Turn off all notes on all channels - synth.allNotesOff (0, true); + synth.requestAllNotesOff(); }; addAndMakeVisible (clearButton); @@ -1919,11 +2261,40 @@ class AudioExample volumeKnob->onChange = [this] (double value) { masterVolume = static_cast (value); }; addAndMakeVisible (*volumeKnob); + modeChoice = std::make_unique ("VOICE MODE", yup::StringArray { "Poly / 8 voices", "Mono / retrigger", "Legato / glide" }, font.withHeight (10.0f)); + modeChoice->getComboBox().setSelectedId (1, yup::dontSendNotification); + modeChoice->onChange = [this] (int id) + { + synth.playMode = id - 1; + glideKnob->setEnabled (id != 1); + }; + addAndMakeVisible (*modeChoice); + glideKnob = std::make_unique ("GLIDE / ms", 0.0, 2000.0, 1.0, 120.0, font.withHeight (10.0f)); + glideKnob->onChange = [this] (double value) { synth.portamento = static_cast (value * 0.001); }; + glideKnob->setEnabled (false); + addAndMakeVisible (*glideKnob); + midiDevices = yup::MidiInput::getAvailableDevices(); + yup::StringArray midiNames { "No MIDI input" }; + for (const auto& device : midiDevices) + midiNames.add (device.name); + midiChoice = std::make_unique ("MIDI INPUT", midiNames, font.withHeight (10.0f)); + midiChoice->getComboBox().setSelectedId (midiDevices.isEmpty() ? 1 : 2, yup::dontSendNotification); + midiChoice->onChange = [this] (int) + { + closeMidiInput(); + synth.requestAllNotesOff(); + if (isVisible()) + openMidiInput(); + }; + addAndMakeVisible (*midiChoice); + renderData.resize (SynthExample::maxBlockSize); addAndMakeVisible (oscilloscope); } ~AudioExample() override { + closeMidiInput(); + deviceManager.removeAudioCallback (this); deviceManager.closeAudioDevice(); } @@ -1951,22 +2322,26 @@ class AudioExample bounds.removeFromBottom (spacing); - auto rightColumn = bounds.removeFromRight (bounds.getWidth() * 0.42f); - bounds.removeFromRight (spacing); - - envelopePanel->setBounds (rightColumn.removeFromTop (rightColumn.getHeight() * 0.5f)); - rightColumn.removeFromTop (spacing); - oscilloscope.setBounds (rightColumn); + auto performance = bounds.removeFromBottom (58.0f); + modeChoice->setBounds (performance.removeFromLeft (190.0f).reduced (4.0f, 9.0f)); + glideKnob->setBounds (performance.removeFromLeft (78.0f)); + performance.removeFromLeft (spacing); + midiChoice->setBounds (performance.removeFromLeft (210.0f).reduced (4.0f, 9.0f)); + performance.removeFromLeft (spacing); + loadLabel.setBounds (performance); + bounds.removeFromBottom (spacing); - const auto panelHeight = (bounds.getHeight() - spacing) / static_cast (SynthExample::oscillatorCount); + auto modulation = bounds.removeFromBottom (yup::jmin (150.0f, bounds.getHeight() * 0.32f)); + envelopePanel->setBounds (modulation.removeFromLeft (modulation.getWidth() * 0.62f)); + modulation.removeFromLeft (spacing); + oscilloscope.setBounds (modulation); + bounds.removeFromBottom (spacing); + const auto panelWidth = (bounds.getWidth() - spacing) / static_cast (SynthExample::oscillatorCount); for (auto& panel : oscillatorPanels) { - if (panel == nullptr) - continue; - - panel->setBounds (bounds.removeFromTop (panelHeight)); - bounds.removeFromTop (spacing); + panel->setBounds (bounds.removeFromLeft (panelWidth)); + bounds.removeFromLeft (spacing); } } @@ -1983,8 +2358,10 @@ class AudioExample void refreshDisplay (double) override { + if (scopeReady.load (std::memory_order_acquire)) { - const yup::CriticalSection::ScopedLockType sl (renderMutex); + renderData.assign (scopeSamples.begin(), scopeSamples.begin() + scopeCount); + scopeReady.store (false, std::memory_order_release); oscilloscope.setRenderData (renderData); } @@ -1998,7 +2375,13 @@ class AudioExample const auto activeVoices = synth.getNumActiveVoices(); - voiceLabel.setText (activeVoices > 0 ? yup::String (activeVoices) + " VOICES" : yup::String(), + const auto status = audioDeviceError.isNotEmpty() ? audioDeviceError + : midiInputError.isNotEmpty() ? midiInputError + : yup::String (loadMeasurer.getLoadAsPercentage(), 1) + "% AUDIO / " + + yup::String (loadMeasurer.getXRunCount()) + " OVERRUNS / " + + yup::String (receivedNoteOns.load()) + " NOTES IN"; + loadLabel.setText (status, yup::dontSendNotification); + voiceLabel.setText (yup::String (activeVoices) + " / 8 VOICES", yup::dontSendNotification); } @@ -2009,8 +2392,13 @@ class AudioExample synth.prepare (device->getCurrentSampleRate(), maxBlockSize); renderBuffer.setSize (2, maxBlockSize, false, true, true); - inputData.assign (static_cast (maxBlockSize), 0.0f); - renderData.assign (static_cast (maxBlockSize), 0.0f); + loadMeasurer.reset (device->getCurrentSampleRate(), device->getDefaultBufferSize()); + midiBuffer.ensureSize (16384); + outputGain.reset (device->getCurrentSampleRate(), 0.02); + outputGain.setCurrentAndTargetValue (masterVolume.load()); + + midiCollector.reset (device->getCurrentSampleRate()); + midiCollector.ensureStorageAllocated (midiQueueBytes); } void audioDeviceStopped() override @@ -2024,10 +2412,15 @@ class AudioExample int numSamples, const yup::AudioIODeviceCallbackContext&) override { - if (numSamples > renderBuffer.getNumSamples()) + if (numSamples <= 0) + return; + const yup::ScopedNoDenormals noDenormals; + const yup::AudioProcessLoadMeasurer::ScopedTimer renderTimer (loadMeasurer, numSamples); + if (numSamples <= 0 || numSamples > renderBuffer.getNumSamples()) { for (int channel = 0; channel < numOutputChannels; ++channel) - yup::FloatVectorOperations::clear (outputChannelData[channel], numSamples); + if (outputChannelData[channel] != nullptr) + yup::FloatVectorOperations::clear (outputChannelData[channel], numSamples); return; } @@ -2036,40 +2429,89 @@ class AudioExample yup::FloatVectorOperations::clear (renderBuffer.getWritePointer (channel), numSamples); midiBuffer.clear(); + + // processNextMidiBuffer() reads whatever is already in the buffer before injecting + // the on-screen keyboard's own events, so collecting the hardware input first is + // what lights up the drawn keys as well as playing the notes. + midiCollector.removeNextBlockOfMessages (midiBuffer, numSamples); keyboardState.processNextMidiBuffer (midiBuffer, 0, numSamples, true); + for (const auto metadata : midiBuffer) + if (metadata.getMessage().isNoteOn()) + receivedNoteOns.fetch_add (1, std::memory_order_relaxed); synth.renderNextBlock (renderBuffer, midiBuffer, 0, numSamples); - const auto gain = masterVolume.load(); - const auto* display = renderBuffer.getReadPointer (0); - - for (int channel = 0; channel < numOutputChannels; ++channel) + outputGain.setTargetValue (masterVolume.load()); + for (int sample = 0; sample < numSamples; ++sample) { - const auto* source = renderBuffer.getReadPointer (yup::jmin (channel, renderBuffer.getNumChannels() - 1)); - auto* destination = outputChannelData[channel]; - - for (int sample = 0; sample < numSamples; ++sample) - destination[sample] = std::tanh (source[sample] * gain); + const auto gain = outputGain.getNextValue(); + for (int channel = 0; channel < numOutputChannels; ++channel) + { + const auto sourceChannel = yup::jmin (channel, renderBuffer.getNumChannels() - 1); + if (outputChannelData[channel] != nullptr) + outputChannelData[channel][sample] = renderBuffer.getSample (sourceChannel, sample) * gain; + } + renderBuffer.setSample (0, sample, renderBuffer.getSample (0, sample) * gain); } + if (! scopeReady.load (std::memory_order_acquire)) { - const yup::CriticalSection::ScopedLockType sl (renderMutex); - - for (int sample = 0; sample < numSamples; ++sample) - inputData[static_cast (sample)] = std::tanh (display[sample] * gain); - - std::swap (inputData, renderData); + scopeCount = yup::jmin (numSamples, SynthExample::maxBlockSize); + std::copy_n (renderBuffer.getReadPointer (0), scopeCount, scopeSamples.begin()); + scopeReady.store (true, std::memory_order_release); } } void visibilityChanged() override { if (! isVisible()) + { + closeMidiInput(); deviceManager.removeAudioCallback (this); + } else + { deviceManager.addAudioCallback (this); + openMidiInput(); + } } private: + //============================================================================== + /** Opens the selected hardware input and reports device-open failures in the UI. */ + void openMidiInput() + { + if (midiInputIdentifier.isNotEmpty() || midiChoice == nullptr) + return; + + midiInputError.clear(); + const auto index = midiChoice->getComboBox().getSelectedId() - 2; + if (! yup::isPositiveAndBelow (index, midiDevices.size())) + return; + + const auto identifier = midiDevices[index].identifier; + deviceManager.setMidiInputDeviceEnabled (identifier, true); + if (! deviceManager.isMidiInputDeviceEnabled (identifier)) + { + midiInputError = "Cannot open MIDI input: " + midiDevices[index].name; + return; + } + midiInputIdentifier = identifier; + deviceManager.addMidiInputDeviceCallback (midiInputIdentifier, &midiCollector); + } + + /** Releases the input again, so a hidden demo does not hold the device open. */ + void closeMidiInput() + { + if (midiInputIdentifier.isEmpty()) + return; + + deviceManager.removeMidiInputDeviceCallback (midiInputIdentifier, &midiCollector); + deviceManager.setMidiInputDeviceEnabled (midiInputIdentifier, false); + + midiInputIdentifier.clear(); + } + + //============================================================================== void randomizeOscillators() { auto& random = yup::Random::getSystemRandom(); @@ -2078,19 +2520,22 @@ class AudioExample { auto& settings = synth.getOscillatorSettings (index); - settings.type = random.nextInt (4); settings.waveform = random.nextInt (6); - settings.shape = random.nextInt (6); settings.syncMode = random.nextInt (4); + settings.syncRatio = 1.0f + random.nextFloat() * 3.0f; settings.level = 0.2f + random.nextFloat() * 0.8f; - // The knob still reaches two octaves for deliberate stacking, but randomizing - // that far apart just sounds out of tune, so this stays within a beating range. - settings.detuneSemitones = random.nextFloat() - 0.5f; - settings.followerRatio = 0.5f + random.nextFloat() * 3.0f; - settings.morph = random.nextFloat(); - settings.phaseDistortion = 0.1f + random.nextFloat() * 0.8f; - settings.fmAmount = random.nextFloat() * 2.0f; - settings.fmRatio = 0.5f + random.nextFloat() * 3.0f; + settings.octave = random.nextInt (3) - 1; + settings.detuneSemitones = (random.nextFloat() - 0.5f) * 0.3f; + settings.ridgeSpacing = 0.5f + random.nextFloat() * 3.0f; + settings.color = random.nextFloat(); + settings.dispersion = 0.1f + random.nextFloat() * 0.8f; + settings.squeeze = random.nextBool() ? 0.0f : random.nextFloat() * 0.5f; + settings.squash = 0.4f + random.nextFloat() * 1.6f; + settings.tilt = (random.nextFloat() - 0.5f) * 3.0f; + settings.oddEven = 0.2f + random.nextFloat() * 0.6f; + settings.formant = (random.nextFloat() - 0.4f) * 5.0f; + settings.formantPosition = random.nextFloat() * 6.0f; + settings.scatter = random.nextBool() ? 0.0f : random.nextFloat() * 0.6f; settings.unisonVoices = 1 + random.nextInt (SynthExample::maxUnisonVoices); settings.unisonDetune = random.nextFloat() * 0.5f; settings.unisonSpread = random.nextFloat(); @@ -2105,6 +2550,8 @@ class AudioExample } //============================================================================== + static constexpr std::size_t midiQueueBytes = 2048; + static constexpr float outerInset = 10.0f; static constexpr float headerHeight = 44.0f; static constexpr float spacing = 8.0f; @@ -2116,17 +2563,27 @@ class AudioExample // MIDI keyboard components yup::MidiKeyboardState keyboardState; yup::MidiKeyboardComponent keyboardComponent; + yup::MidiMessageCollector midiCollector; + yup::String midiInputIdentifier; + yup::String audioDeviceError; + yup::String midiInputError; + yup::Array midiDevices; + std::atomic receivedNoteOns { 0 }; yup::AudioBuffer renderBuffer; yup::MidiBuffer midiBuffer; std::vector renderData; - std::vector inputData; - yup::CriticalSection renderMutex; + std::array scopeSamples {}; + std::atomic scopeReady { false }; + int scopeCount = 0; + yup::SmoothedValue outputGain; + yup::AudioProcessLoadMeasurer loadMeasurer; // UI Components yup::Label titleLabel; yup::Label subtitleLabel; yup::Label voiceLabel; + yup::Label loadLabel; std::array, SynthExample::oscillatorCount> oscillatorPanels; std::unique_ptr envelopePanel; @@ -2134,6 +2591,9 @@ class AudioExample yup::TextButton randomizeButton { "RANDOMIZE" }; yup::TextButton clearButton { "ALL NOTES OFF" }; std::unique_ptr volumeKnob; + std::unique_ptr modeChoice; + std::unique_ptr midiChoice; + std::unique_ptr glideKnob; Oscilloscope oscilloscope; std::atomic masterVolume { 0.5f }; diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index eda05e048..2714b766d 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -144,15 +144,6 @@ class SignalGenerator waveform = newWaveform; } - void setOversamplerFilterType (yup::HalfbandFilterType type) - { - if (oversamplerFilterType == type) - return; - - oversamplerFilterType = type; - prepareResampling (maxOutputBlockSize); - } - void setSweepParameters (double startFreq, double endFreq, double durationSeconds) { sweepStartFreq = startFreq; @@ -266,14 +257,11 @@ class SignalGenerator // The oversampled sweeps decimate straight to the device rate, so the // resampler's alias floor never enters their chain. - yup::HalfbandOversamplerDesign design; - design.filterType = oversamplerFilterType; - - oversampler2x.prepare (sampleRate, 1, maxOutputBlockSize, design); - oversampler4x.prepare (sampleRate, 1, maxOutputBlockSize, design); - oversampler8x.prepare (sampleRate, 1, maxOutputBlockSize, design); - oversampler16x.prepare (sampleRate, 1, maxOutputBlockSize, design); - oversampler32x.prepare (sampleRate, 1, maxOutputBlockSize, design); + oversampler2x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler4x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler8x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler16x.prepare (sampleRate, 1, maxOutputBlockSize); + oversampler32x.prepare (sampleRate, 1, maxOutputBlockSize); resetSweepPlaybackState(); } @@ -486,7 +474,6 @@ class SignalGenerator SignalType signalType; SweepPlaybackMode sweepPlaybackMode; Waveform waveform = Waveform::sine; - yup::HalfbandFilterType oversamplerFilterType = yup::HalfbandFilterType::linearPhaseFIR; // Sweep parameters double sweepStartFreq, sweepEndFreq, sweepDurationSeconds; @@ -503,11 +490,11 @@ class SignalGenerator // Resampling state yup::ResamplerFloat resampler; - yup::Oversampler2xFloat oversampler2x; - yup::Oversampler4xFloat oversampler4x; - yup::Oversampler8xFloat oversampler8x; - yup::Oversampler16xFloat oversampler16x; - yup::Oversampler32xFloat oversampler32x; + yup::SincOversampler oversampler2x; + yup::SincOversampler oversampler4x; + yup::SincOversampler oversampler8x; + yup::SincOversampler oversampler16x; + yup::SincOversampler oversampler32x; std::vector sourceBuffer; std::vector resampledBuffer; int maxOutputBlockSize = 0; @@ -536,8 +523,6 @@ class AudioFilePlayer if (numFrames <= 0) return false; - // Decode the whole file into an AudioBuffer, then downmix to mono - // floats so playback is an indexed read. yup::AudioBuffer decoded (numChannels, static_cast (numFrames)); if (! newReader->read (&decoded, 0, static_cast (numFrames), 0, true, numChannels > 1)) return false; @@ -820,17 +805,6 @@ class SpectrumAnalyzerDemo }; addAndMakeVisible (*waveformCombo); - // Halfband filter family used by the oversampled sweep modes - oversamplerFilterCombo = std::make_unique ("OversamplerFilter"); - oversamplerFilterCombo->addItem ("Linear-phase FIR", 1); - oversamplerFilterCombo->addItem ("Polyphase IIR", 2); - oversamplerFilterCombo->setSelectedId (1); - oversamplerFilterCombo->onSelectedItemChanged = [this] - { - updateOversamplerFilter(); - }; - addAndMakeVisible (*oversamplerFilterCombo); - // Frequency control frequencySlider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency"); frequencySlider->setRange ({ 20.0, 22000.0 }); @@ -1031,7 +1005,7 @@ class SpectrumAnalyzerDemo // Create parameter labels with proper font sizing auto labelFont = font.withHeight (12.0f); - for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:", "Waveform:", "OS Filter:" }) + for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:", "Level Mode:", "Waveform:" }) { auto label = parameterLabels.add (std::make_unique (labelText)); label->setText (labelText); @@ -1116,7 +1090,6 @@ class SpectrumAnalyzerDemo auto overlapSection = row3.removeFromLeft (colWidth); auto levelModeSection = row3.removeFromLeft (colWidth); auto waveformSection = row3.removeFromLeft (colWidth); - auto oversamplerFilterSection = row3.removeFromLeft (colWidth); parameterLabels[9]->setBounds (releaseSection.removeFromTop (labelHeight)); releaseSlider->setBounds (releaseSection.removeFromTop (controlHeight)); @@ -1130,9 +1103,6 @@ class SpectrumAnalyzerDemo parameterLabels[13]->setBounds (waveformSection.removeFromTop (labelHeight)); waveformCombo->setBounds (waveformSection.removeFromTop (controlHeight)); - parameterLabels[14]->setBounds (oversamplerFilterSection.removeFromTop (labelHeight)); - oversamplerFilterCombo->setBounds (oversamplerFilterSection.removeFromTop (controlHeight)); - // Fourth row: Status labels auto row4 = bounds.removeFromTop (30); auto freqStatus = row4.removeFromLeft (bounds.getWidth() / 3); @@ -1226,9 +1196,6 @@ class SpectrumAnalyzerDemo sweepDurationSlider->setEnabled (signalType == SignalGenerator::SignalType::frequencySweep); waveformCombo->setEnabled (signalType == SignalGenerator::SignalType::singleTone || signalType == SignalGenerator::SignalType::frequencySweep); - oversamplerFilterCombo->setEnabled (signalType == SignalGenerator::SignalType::frequencySweep - && sweepPlaybackMode != SignalGenerator::SweepPlaybackMode::direct - && sweepPlaybackMode != SignalGenerator::SweepPlaybackMode::resampled); } void updateWaveform() @@ -1256,18 +1223,6 @@ class SpectrumAnalyzerDemo }); } - void updateOversamplerFilter() - { - const auto type = oversamplerFilterCombo->getSelectedId() == 2 - ? yup::HalfbandFilterType::polyphaseIIR - : yup::HalfbandFilterType::linearPhaseFIR; - - updateSignalGenerator ([type] (SignalGenerator& generator) - { - generator.setOversamplerFilterType (type); - }); - } - void updateFFTSize() { int selectedId = fftSizeCombo->getSelectedId(); @@ -1439,7 +1394,6 @@ class SpectrumAnalyzerDemo // Signal controls std::unique_ptr signalTypeCombo; std::unique_ptr waveformCombo; - std::unique_ptr oversamplerFilterCombo; std::unique_ptr frequencySlider; std::unique_ptr amplitudeSlider; std::unique_ptr sweepDurationSlider; diff --git a/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h index 67eb370d4..d042a231f 100644 --- a/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h +++ b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h @@ -25,61 +25,67 @@ namespace yup { //============================================================================== -/** Oversampled waveform morphing, through-zero FM, PM, phase distortion and hard sync. +/** Controls for one internal sample interval of a modulated oscillator voice. - Reads a shared, immutable WaveformBank. The phase accumulator is signed and - independent of phase modulation. A two-segment phase map places half a waveform - cycle at phaseDistortion (0.5 is the identity). Positive leader wraps reset the - follower at their fractional internal-sample position. Two-sample polynomial - BLEP/BLAMP residuals correct value/slope jumps at resets and phase-map corners. + All fields must be finite. Shared by ModulatedOscillator and by any oscillator + composed over detail::ModulatedOscillatorVoice. - The whole modulation path runs at OversampleFactor times the output rate and - reuses Oversampler's decimation filter. This reduces aliasing; finite kernels, - table interpolation and oversampling do not guarantee alias-free arbitrary - modulation. Higher waveform derivatives and abrupt parameter changes remain - approximate. Choose bandwidth, modulation depth and oversampling accordingly. + @see ModulatedOscillator, PrismOscillator +*/ +struct ModulatedOscillatorParameters +{ + double frequency = 440.0; /**< Signed carrier frequency in Hz. */ + double linearFM = 0.0; /**< Signed frequency deviation in Hz. */ + double exponentialFM = 0.0; /**< Pitch offset in octaves, clamped to +/-16. */ + double phaseModulation = 0.0; /**< Read-phase offset in periods; does not reset phase. */ + double morph = 0.0; /**< Normalized waveform-bank position, clamped to [0, 1]. */ + double phaseDistortion = 0.5; /**< Phase-map breakpoint, clamped to [0.01, 0.99]. */ + double syncFrequency = 0.0; /**< Leader frequency in Hz; zero disables hard sync. */ + double bandwidthFrequency = 0.0; /**< Optional lower bound on the frequency used for waveform bandwidth selection, in Hz. */ +}; - prepare() allocates; processing and reset() do not. A bank must outlive this - oscillator and must not be modified during playback. Each voice owns its phase, - residual and decimator state; voices can share the bank. +namespace detail +{ - @tparam SampleType Output precision. - @tparam OversampleFactor Internal sample-rate multiplier (at least 2). - @tparam SincRadius Decimator radius in output samples. - @tparam CoeffType Waveform coefficient precision. +//============================================================================== +/** One unoversampled modulated oscillator voice. + + Implements the modulation maths ModulatedOscillator exposes: the signed phase + accumulator, the two-segment phase map, fractional hard sync and the polynomial + BLEP/BLAMP residuals that correct value and slope jumps. It runs at the caller's + internal rate and knows nothing about oversampling or block sizes. + + Nothing here allocates, including prepare(); the owning class is responsible for + the decimator storage and for the bank's lifetime. + + @tparam SampleType Output precision. + @tparam CoeffType Waveform coefficient precision. + + @see ModulatedOscillator, PrismOscillator */ -template -class ModulatedOscillator +template +class ModulatedOscillatorVoice { public: - /** Controls for one internal sample interval. All fields must be finite. */ - struct Parameters - { - double frequency = 440.0; /**< Signed carrier frequency in Hz. */ - double linearFM = 0.0; /**< Signed frequency deviation in Hz. */ - double exponentialFM = 0.0; /**< Pitch offset in octaves, clamped to +/-16. */ - double phaseModulation = 0.0; /**< Read-phase offset in periods; does not reset phase. */ - double morph = 0.0; /**< Normalized waveform-bank position, clamped to [0, 1]. */ - double phaseDistortion = 0.5; /**< Phase-map breakpoint, clamped to [0.01, 0.99]. */ - double syncFrequency = 0.0; /**< Leader frequency in Hz; zero disables hard sync. */ - }; + using Parameters = ModulatedOscillatorParameters; - /** Allocates decimator storage and attaches a prepared waveform bank. + //============================================================================== + /** Attaches a prepared waveform bank and sets the rate processSample() runs at. - @param sampleRate Output rate in Hz, positive. - @param maxBlockSize Maximum output block size, positive. - @param waveforms Immutable bank; must remain alive throughout playback. + @param newInternalSampleRate Rate the voice is clocked at in Hz, positive. + @param waveforms Immutable bank; must remain alive throughout playback. */ - void prepare (double sampleRate, int maxBlockSize, const WaveformBank& waveforms) + void prepare (double newInternalSampleRate, const WaveformBank& waveforms) noexcept { - jassert (sampleRate > 0.0 && maxBlockSize > 0); - internalSampleRate = jmax (1.0, sampleRate) * OversampleFactor; + jassert (newInternalSampleRate > 0.0); + + internalSampleRate = jmax (1.0, newInternalSampleRate); bank = &waveforms; - oversampler.prepare (jmax (1.0, sampleRate), 1, jmax (1, maxBlockSize)); + reset(); } - /** Resets phase, sync leader, residuals and decimator history. + /** Resets phase, sync leader and pending residuals. @param initialPhase Initial follower phase in periods, wrapped internally. */ @@ -90,59 +96,70 @@ class ModulatedOscillator nextCorrection = 0.0; previousPhaseModulation = 0.0; hasPreviousParameters = false; - oversampler.reset(); } + /** Swaps in another prepared bank without allocating or touching phase. */ + void setBank (const WaveformBank& waveforms) noexcept { bank = &waveforms; } + + /** Returns true once a bank has been attached. */ + bool isPrepared() const noexcept { return bank != nullptr; } + /** Returns the unmodulated follower accumulator in periods. */ double getPhase() const noexcept { return phase; } - /** Returns the internal rate at which the modulation callback is invoked. */ + /** Returns the rate processSample() is expected to be called at. */ double getInternalSampleRate() const noexcept { return internalSampleRate; } - /** Returns the output latency in samples, including the decimation filter. */ - static constexpr int getLatencyInSamples() noexcept { return SincRadius; } - - /** Produces a block with constant controls. Returns false for invalid block sizes. + //============================================================================== + /** Advances the voice by one internal sample and returns its corrected value. - A rejected block leaves output and oscillator state unchanged. Carrier and - leader frequencies are limited to half the internal rate, bounding the - number of fractional events per interval. Negative carriers run backward. + The controls describe the interval that follows; event interpolation assumes + they stay constant over it. A bank must have been attached first. */ - bool processBlock (SampleType* output, int numSamples, const Parameters& parameters) noexcept + double processSample (Parameters controls) noexcept { - return processModulatedBlock (output, numSamples, [&] (int) { return parameters; }); - } - - /** Produces a block with controls evaluated at the internal sample rate. - - The callback is invoked as Parameters(int internalSampleIndex), in order, - for numSamples * OversampleFactor samples. Its index restarts at zero for - each call; keep modulator phase in caller-owned state across blocks. Run - coupled modulators here or interpolate external control signals to this - rate. The callback must not allocate, block or throw. Controls describe - the following internal sample interval; event interpolation assumes they - remain constant over that interval. + controls.morph = jlimit (0.0, 1.0, controls.morph); + controls.phaseDistortion = jlimit (0.01, 0.99, controls.phaseDistortion); + const auto pitchScale = controls.exponentialFM == 0.0 ? 1.0 : std::exp2 (jlimit (-16.0, 16.0, controls.exponentialFM)); + const auto frequency = (controls.frequency + controls.linearFM) * pitchScale; + const auto increment = jlimit (-0.5, 0.5, frequency / internalSampleRate); + const auto leaderIncrement = jlimit (0.0, 0.5, controls.syncFrequency / internalSampleRate); + const auto phaseModulationSpeed = hasPreviousParameters ? (controls.phaseModulation - previousPhaseModulation) * internalSampleRate : 0.0; + const auto maximumWarpSlope = 0.5 / jmin (controls.phaseDistortion, 1.0 - controls.phaseDistortion); + const auto carrierSpeed = jmax (std::abs (increment) * internalSampleRate, + jmax (0.0, controls.bandwidthFrequency)); + const auto phaseSpeed = (carrierSpeed + std::abs (phaseModulationSpeed)) * maximumWarpSlope; + const auto bandwidth = phaseSpeed > 0.0 ? 0.45 * internalSampleRate / phaseSpeed + : 2.0 * bank->getNumHarmonics(); + previousPhaseModulation = controls.phaseModulation; + hasPreviousParameters = true; - Returns false without invoking the callback for null output, unprepared - state, or nonpositive/oversized blocks. Abrupt control changes are not - automatically smoothed and can create audible transients. - */ - template - bool processModulatedBlock (SampleType* output, int numSamples, Modulation&& modulation) noexcept - { - if (output == nullptr || bank == nullptr || ! oversampler.beginGeneration (1, numSamples)) - return false; + auto current = value (phase, controls, bandwidth) + nextCorrection; + nextCorrection = 0.0; - auto* internal = oversampler.getOversampledChannelData (0); - for (int i = 0; i < oversampler.getOversampledNumSamples(); ++i) - internal[i] = static_cast (processInternal (modulation (i))); + if (leaderIncrement > 0.0 && leaderPhase + leaderIncrement >= 1.0) + { + const auto fraction = (1.0 - leaderPhase) / leaderIncrement; + const auto beforeReset = phase + increment * fraction; + correctCorners (phase, increment, fraction, 0.0, controls, bandwidth, current); + const auto jump = value (0.0, controls, bandwidth) - value (beforeReset, controls, bandwidth); + const auto slopeJump = (slope (0.0, increment, controls, bandwidth) - slope (beforeReset, increment, controls, bandwidth)) * increment; + correctEvent (fraction, jump, slopeJump, current); + correctCorners (0.0, increment, 1.0 - fraction, fraction, controls, bandwidth, current); + phase = wrap (increment * (1.0 - fraction)); + } + else + { + correctCorners (phase, increment, 1.0, 0.0, controls, bandwidth, current); + phase = wrap (phase + increment); + } - SampleType* channels[] = { output }; - oversampler.downsample (channels, 1, numSamples); - return true; + leaderPhase = wrap (leaderPhase + leaderIncrement); + return current; } private: + //============================================================================== static double wrap (double value) noexcept { return value - std::floor (value); } static double warp (double phase, double breakpoint) noexcept @@ -212,48 +229,8 @@ class ModulatedOscillator } } - double processInternal (Parameters controls) noexcept - { - controls.morph = jlimit (0.0, 1.0, controls.morph); - controls.phaseDistortion = jlimit (0.01, 0.99, controls.phaseDistortion); - const auto pitchScale = controls.exponentialFM == 0.0 ? 1.0 : std::exp2 (jlimit (-16.0, 16.0, controls.exponentialFM)); - const auto frequency = (controls.frequency + controls.linearFM) * pitchScale; - const auto increment = jlimit (-0.5, 0.5, frequency / internalSampleRate); - const auto leaderIncrement = jlimit (0.0, 0.5, controls.syncFrequency / internalSampleRate); - const auto phaseModulationSpeed = hasPreviousParameters ? (controls.phaseModulation - previousPhaseModulation) * internalSampleRate : 0.0; - const auto maximumWarpSlope = 0.5 / jmin (controls.phaseDistortion, 1.0 - controls.phaseDistortion); - const auto phaseSpeed = (std::abs (increment) * internalSampleRate + std::abs (phaseModulationSpeed)) * maximumWarpSlope; - const auto bandwidth = phaseSpeed > 0.0 ? 0.45 * internalSampleRate / phaseSpeed - : 2.0 * bank->getNumHarmonics(); - previousPhaseModulation = controls.phaseModulation; - hasPreviousParameters = true; - - auto current = value (phase, controls, bandwidth) + nextCorrection; - nextCorrection = 0.0; - - if (leaderIncrement > 0.0 && leaderPhase + leaderIncrement >= 1.0) - { - const auto fraction = (1.0 - leaderPhase) / leaderIncrement; - const auto beforeReset = phase + increment * fraction; - correctCorners (phase, increment, fraction, 0.0, controls, bandwidth, current); - const auto jump = value (0.0, controls, bandwidth) - value (beforeReset, controls, bandwidth); - const auto slopeJump = (slope (0.0, increment, controls, bandwidth) - slope (beforeReset, increment, controls, bandwidth)) * increment; - correctEvent (fraction, jump, slopeJump, current); - correctCorners (0.0, increment, 1.0 - fraction, fraction, controls, bandwidth, current); - phase = wrap (increment * (1.0 - fraction)); - } - else - { - correctCorners (phase, increment, 1.0, 0.0, controls, bandwidth, current); - phase = wrap (phase + increment); - } - - leaderPhase = wrap (leaderPhase + leaderIncrement); - return current; - } - + //============================================================================== const WaveformBank* bank = nullptr; - Oversampler oversampler; double internalSampleRate = 1.0; double phase = 0.0; double leaderPhase = 0.0; @@ -262,4 +239,136 @@ class ModulatedOscillator bool hasPreviousParameters = false; }; +} // namespace detail + +//============================================================================== +/** Oversampled waveform morphing, through-zero FM, PM, phase distortion and hard sync. + + Reads a shared, immutable WaveformBank. The phase accumulator is signed and + independent of phase modulation. A two-segment phase map places half a waveform + cycle at phaseDistortion (0.5 is the identity). Positive leader wraps reset the + follower at their fractional internal-sample position. Two-sample polynomial + BLEP/BLAMP residuals correct value/slope jumps at resets and phase-map corners. + + The whole modulation path runs at OversampleFactor times the output rate and + reuses HalfbandOversampler's decimation cascade. This reduces aliasing; finite + kernels, table interpolation and oversampling do not guarantee alias-free + arbitrary modulation. Higher waveform derivatives and abrupt parameter changes + remain approximate. Choose bandwidth, modulation depth and oversampling + accordingly. + + prepare() allocates; processing and reset() do not. A bank must outlive this + oscillator and must not be modified during playback. Each voice owns its phase, + residual and decimator state; voices can share the bank. + + @tparam SampleType Output precision. + @tparam OversampleFactor Internal sample-rate multiplier, a power of two (at least 2). + @tparam CoeffType Waveform coefficient and decimator precision. +*/ +template +class ModulatedOscillator +{ +public: + /** Controls for one internal sample interval. All fields must be finite. */ + using Parameters = ModulatedOscillatorParameters; + + /** Allocates decimator storage and attaches a prepared waveform bank. + + @param sampleRate Output rate in Hz, positive. + @param maxBlockSize Maximum output block size, positive. + @param waveforms Immutable bank; must remain alive throughout playback. + @param decimation Halfband design of the decimator; the default is the + 100 dB linear-phase FIR flat to 0.45 of the output rate. + */ + void prepare (double sampleRate, int maxBlockSize, const WaveformBank& waveforms, const HalfbandOversamplerDesign& decimation = {}) + { + jassert (sampleRate > 0.0 && maxBlockSize > 0); + voice.prepare (jmax (1.0, sampleRate) * OversampleFactor, waveforms); + oversampler.prepare (jmax (1.0, sampleRate), 1, jmax (1, maxBlockSize), decimation); + reset(); + } + + /** Resets phase, sync leader, residuals and decimator history. + + @param initialPhase Initial follower phase in periods, wrapped internally. + */ + void reset (double initialPhase = 0.0) noexcept + { + voice.reset (initialPhase); + oversampler.reset(); + } + + /** Points the oscillator at another prepared bank, keeping phase and residuals. + + Allocation-free, so it is safe between blocks on the audio thread. Use it to + adopt a bank rebuilt on another thread: publish the replacement first, then + swap, and keep the outgoing bank alive until every voice has swapped. The + waveform changes instantly at the next sample and may click, so reserve it + for edits the listener is already expecting. + + @param waveforms Prepared bank; must remain alive throughout playback. + */ + void setBank (const WaveformBank& waveforms) noexcept + { + voice.setBank (waveforms); + } + + /** Returns the unmodulated follower accumulator in periods. */ + double getPhase() const noexcept { return voice.getPhase(); } + + /** Returns the internal rate at which the modulation callback is invoked. */ + double getInternalSampleRate() const noexcept { return voice.getInternalSampleRate(); } + + /** Returns the output latency in samples, including the decimation filter. + + Valid after prepare(); it follows the decimator design (exact for the FIR + families, the rounded low-frequency group delay for the IIR ones). + */ + int getLatencyInSamples() const noexcept { return oversampler.getGenerationLatencyInSamples(); } + + /** Produces a block with constant controls. Returns false for invalid block sizes. + + A rejected block leaves output and oscillator state unchanged. Carrier and + leader frequencies are limited to half the internal rate, bounding the + number of fractional events per interval. Negative carriers run backward. + */ + bool processBlock (SampleType* output, int numSamples, const Parameters& parameters) noexcept + { + return processModulatedBlock (output, numSamples, [&] (int) { return parameters; }); + } + + /** Produces a block with controls evaluated at the internal sample rate. + + The callback is invoked as Parameters(int internalSampleIndex), in order, + for numSamples * OversampleFactor samples. Its index restarts at zero for + each call; keep modulator phase in caller-owned state across blocks. Run + coupled modulators here or interpolate external control signals to this + rate. The callback must not allocate, block or throw. Controls describe + the following internal sample interval; event interpolation assumes they + remain constant over that interval. + + Returns false without invoking the callback for null output, unprepared + state, or nonpositive/oversized blocks. Abrupt control changes are not + automatically smoothed and can create audible transients. + */ + template + bool processModulatedBlock (SampleType* output, int numSamples, Modulation&& modulation) noexcept + { + if (output == nullptr || ! voice.isPrepared() || ! oversampler.beginGeneration (1, numSamples)) + return false; + + auto* internal = oversampler.getOversampledChannelData (0); + for (int i = 0; i < oversampler.getOversampledNumSamples(); ++i) + internal[i] = static_cast (voice.processSample (modulation (i))); + + SampleType* channels[] = { output }; + oversampler.downsample (channels, 1, numSamples); + return true; + } + +private: + detail::ModulatedOscillatorVoice voice; + HalfbandOversampler oversampler; +}; + } // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_PrismSpectrum.h b/modules/yup_dsp/oscillators/yup_PrismSpectrum.h new file mode 100644 index 000000000..05a1acccb --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_PrismSpectrum.h @@ -0,0 +1,293 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Spectral shaper placing ridges, dispersion and pulse width on a Fourier series. + + Every stage scales or rotates a harmonic that the source already contains, so no + stage can produce a frequency the source did not have. That is the rule any further + stage has to obey to stay alias-free: c[h] may be multiplied by anything, but a + harmonic's frequency is fixed at h times the fundamental and cannot be moved - a + stretched or inharmonic partial is not expressible in a periodic series at all. Rendering the result with + a bandlimited backend therefore keeps the source's antialiasing guarantee, and + the shape parameters can be swept without an antialiasing strategy of their own. + + Applied to each harmonic h, writing u = log2 (h) and c for the harmonic's complex + coefficient (cosine + i sine): + + 1. Ridge - a raised-cosine comb in u, periodic with ridgeSpacing octaves and slid + along by color: gain = 0.15 + 0.85 * ridge^2 with + ridge = 0.5 + 0.5 * cos (2 pi (u / ridgeSpacing - color)). + 1b. Tilt - an overall slope, gain is multiplied by 2^(-tilt * u), which the ridge + comb cannot express because it is periodic in u rather than monotonic in it. + 1c. Odd/even - odd and even harmonics scaled against each other, the cheapest + stage here and the one that walks between sawtooth-like and square-like. + 1d. Formant - a single movable resonance, gain multiplied by + 2^(formant * exp (-((u - formantPosition) / formantWidth)^2)). Where the ridge + is a periodic comb this is one peak or notch, so the two stack into vowel-like + shapes rather than duplicating each other. + 2. Squash - magnitude companding, |c| becomes |c|^squash with the phase kept. + It follows the four gain stages so that it compands the shaped spectrum. + 3. Squeeze - exact pulse-width modulation, c is multiplied by 1 - d * cis (-2 pi h w). + Subtracting a phase-shifted copy of a bandlimited signal stays bandlimited, so + this is PWM without the usual aliasing. The depth d opens from 0 to 1 over the + first squeezeFadeWidth of the width, which is what makes a width of zero a true + bypass: the raw factor tends to i * h * 2 pi w as w falls, and renormalizing that + leaves a differentiator rather than the original spectrum, so fading the depth is + the only way the control can pass continuously through zero. + 4. Dispersion and scatter - one rotation carrying both angles, quadratic in u for + dispersion plus a fixed pseudo-random offset per harmonic for scatter. Neither + changes any magnitude, so they recolour the waveform and its transient without + touching the timbre's brightness; dispersion sweeps like a chirp where scatter + diffuses. Sharing a rotation makes scatter free on top of dispersion. + 5. Normalize - the whole series is scaled so that sum |c| matches the source's. + + The normalization is what keeps the shape parameters level-safe: the ridge gain, + the companding and the pulse-width factor (whose magnitude reaches 2) all change + level, and sum |c| bounds the waveform's peak, so preserving it means the output + can never exceed the source's worst case. The sum runs over harmonics only. DC is + not carried across; the destination's DC is always zero. + + Shaping is O (numHarmonics) with a handful of transcendentals per harmonic and no + transform, so it is cheap enough to re-run once per audio block, which is what makes + the shape controls modulatable. Feed the result to AdditiveOscillator to hear it + immediately, or to WavetableOscillator / WaveformBank to trade one inverse FFT per + update for a much cheaper per-sample cost - the usual choice between the two + backends in this folder, unchanged by the shaping in front of them. + + The class holds no DSP state and the shape is a property of a patch rather than of + a note, so shape once and let every voice play the result: doing it per voice is the + one thing that makes it expensive. + + @tparam CoeffType Precision of the series being shaped. + + @see FourierSeries, AdditiveOscillator, WavetableOscillator +*/ +template +class PrismSpectrum +{ +public: + //============================================================================== + /** The shape controls, all independent of the ridge offset (color). + + tilt, squeeze, squash, formant and scatter each have a true bypass value and are + continuous through it, so they can be modulated without a step there; oddEven is + continuous through its neutral 0.5. The ridge has no bypass - its gain is always + applied, and ridgeSpacing merely widens the comb until it is nearly flat over the + source's range - so ridgeSpacing and color are shape rather than amount. + */ + struct Shape + { + double ridgeSpacing = 1.0; /**< Ridge period in octaves, clamped to [0.25, 8]. */ + double dispersion = 0.5; /**< Quadratic phase in log2 space; 0.5 is flat. */ + double squeeze = 0.0; /**< Pulse width in periods, clamped to [0, 0.5]; 0 is a true bypass. */ + double squash = 1.0; /**< Magnitude exponent, clamped to [0.1, 4]; 1 is bypass. */ + double tilt = 0.0; /**< Spectral slope, gain octaves per harmonic octave, clamped to +/-4; 0 is flat. */ + double oddEven = 0.5; /**< Odd/even balance, clamped to [0, 1]; 0 keeps only odd harmonics, 1 only even, 0.5 is neutral. */ + double formant = 0.0; /**< Signed resonance depth in gain octaves, clamped to +/-4; 0 is bypass. */ + double formantPosition = 2.0; /**< Resonance center in harmonic octaves (log2 of the harmonic index), clamped to [0, 12]. */ + double scatter = 0.0; /**< Pseudo-random phase spread in periods, clamped to [0, 1]; 0 is bypass. */ + }; + + /** Width over which the pulse-width stage fades in, so that zero bypasses it. */ + static constexpr double squeezeFadeWidth = 0.05; + + /** Half-width of the formant resonance, in harmonic octaves. */ + static constexpr double formantWidth = 0.75; + + //============================================================================== + /** Precomputes the per-harmonic log2 tables the ridge and dispersion stages read. + + Allocates, and must be called before any shaping. Harmonics above the prepared + count are ignored rather than shaped, so size this to the largest source. + + @param maxHarmonics Largest harmonic index the shaper will handle. + */ + void prepare (int maxHarmonics) + { + jassert (maxHarmonics >= 0); + + const auto count = static_cast (jmax (0, maxHarmonics)); + + logHarmonic.resize (count); + logHarmonicSquared.resize (count); + scatterAngle.resize (count); + + // A fixed generator rather than a shared one, so a given harmonic always gets + // the same offset: scatter has to be a stable property of the shape, otherwise + // re-deriving it every block would sound like noise rather than like a timbre. + std::uint32_t state = 0x9e3779b9u; + + for (std::size_t index = 0; index < count; ++index) + { + const auto u = std::log2 (static_cast (index + 1)); + + logHarmonic[index] = u; + logHarmonicSquared[index] = u * u; + + state = state * 1664525u + 1013904223u; + scatterAngle[index] = MathConstants::twoPi * (static_cast (state >> 8) / 16777216.0); + } + } + + /** Returns the largest harmonic index prepare() sized the tables for. */ + int getMaxHarmonics() const noexcept { return static_cast (logHarmonic.size()); } + + //============================================================================== + /** Shapes a source series into a destination series at one ridge offset. + + The destination is cleared first, so harmonics the source does not reach stay + silent and the antialiasing guarantee survives a destination larger than the + source. Harmonics beyond either series or beyond prepare()'s count are dropped. + The two series may not alias each other. + + @param source Series to shape; left untouched. + @param destination Series to write; cleared and rewritten. + @param shape Ridge spacing, dispersion, pulse width and companding. + @param color Ridge offset in ridge periods. Periodic with period 1. + */ + void process (const FourierSeries& source, + FourierSeries& destination, + const Shape& shape, + double color) const noexcept + { + jassert (&source != &destination); + + destination.clear(); + + const auto limit = jmin (source.getNumHarmonics(), destination.getNumHarmonics(), getMaxHarmonics()); + if (limit <= 0) + return; + + const auto ridgeScale = MathConstants::twoPi / jlimit (0.25, 8.0, shape.ridgeSpacing); + const auto ridgeOffset = MathConstants::twoPi * color; + const auto width = jlimit (0.0, 0.5, shape.squeeze); + const auto depth = jmin (1.0, width / squeezeFadeWidth); + const auto exponent = jlimit (0.1, 4.0, shape.squash); + const auto curvature = jlimit (0.0, 1.0, shape.dispersion) - 0.5; + const auto slope = jlimit (-4.0, 4.0, shape.tilt); + const auto balance = jlimit (0.0, 1.0, shape.oddEven); + const auto oddGain = jmin (1.0, 2.0 - 2.0 * balance); + const auto evenGain = jmin (1.0, 2.0 * balance); + const auto resonance = jlimit (-4.0, 4.0, shape.formant); + const auto center = jlimit (0.0, 12.0, shape.formantPosition); + const auto spread = jlimit (0.0, 1.0, shape.scatter); + + auto sourceSum = 0.0; + auto shapedSum = 0.0; + + for (int harmonic = 1; harmonic <= limit; ++harmonic) + { + const auto index = static_cast (harmonic - 1); + + auto real = static_cast (source.getCosine (harmonic)); + auto imaginary = static_cast (source.getSine (harmonic)); + + sourceSum += std::hypot (real, imaginary); + + const auto u = logHarmonic[index]; + const auto ridge = 0.5 + 0.5 * std::cos (ridgeScale * u - ridgeOffset); + + auto gain = 0.15 + 0.85 * ridge * ridge; + + if (slope != 0.0) + gain *= std::exp2 (-slope * u); + + gain *= (harmonic % 2 == 1) ? oddGain : evenGain; + + if (resonance != 0.0) + { + const auto offset = (u - center) / formantWidth; + + gain *= std::exp2 (resonance * std::exp (-offset * offset)); + } + + real *= gain; + imaginary *= gain; + + if (exponent != 1.0) + { + const auto magnitude = std::hypot (real, imaginary); + + if (magnitude > 0.0) + { + const auto companded = std::pow (magnitude, exponent); + + real = real / magnitude * companded; + imaginary = imaginary / magnitude * companded; + } + } + + if (depth > 0.0) + { + const auto angle = MathConstants::twoPi * std::fmod (static_cast (harmonic) * width, 1.0); + + rotate (real, imaginary, 1.0 - depth * std::cos (angle), depth * std::sin (angle)); + } + + // Both remaining stages are rotations, so their angles add and one sincos + // serves both: scatter costs nothing on top of dispersion. + if (curvature != 0.0 || spread > 0.0) + { + const auto angle = curvature * logHarmonicSquared[index] + spread * scatterAngle[index]; + + rotate (real, imaginary, std::cos (angle), std::sin (angle)); + } + + shapedSum += std::hypot (real, imaginary); + + destination.setHarmonic (harmonic, static_cast (real), static_cast (imaginary)); + } + + if (shapedSum <= 0.0 || sourceSum <= 0.0 || shapedSum == sourceSum) + return; + + const auto normalization = static_cast (sourceSum / shapedSum); + + for (int harmonic = 1; harmonic <= limit; ++harmonic) + destination.setHarmonic (harmonic, + destination.getCosine (harmonic) * normalization, + destination.getSine (harmonic) * normalization); + } + +private: + //============================================================================== + /** Multiplies the complex coefficient in place by cosine + i sine. */ + static void rotate (double& real, double& imaginary, double cosine, double sine) noexcept + { + const auto rotated = real * cosine - imaginary * sine; + + imaginary = real * sine + imaginary * cosine; + real = rotated; + } + + //============================================================================== + std::vector logHarmonic; + std::vector logHarmonicSquared; + std::vector scatterAngle; +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_SyncOscillator.h b/modules/yup_dsp/oscillators/yup_SyncOscillator.h index 8994b84d7..6e11648aa 100644 --- a/modules/yup_dsp/oscillators/yup_SyncOscillator.h +++ b/modules/yup_dsp/oscillators/yup_SyncOscillator.h @@ -58,20 +58,12 @@ namespace yup @tparam SampleType Type for the synthesized samples (float or double). @tparam CoeffType Type for the coefficients and phase math (default double). - @see FourierSeries, SyncSpectralResampler, AdditiveOscillator, WavetableOscillator + @see FourierSeries, SyncSpectralResampler, WavetableOscillator */ template class SyncOscillator { public: - //============================================================================== - /** Synthesis backend. */ - enum class Synthesis - { - wavetable, /**< Inverse FFT into a table, cheap per sample */ - additive /**< Exact additive synthesis, one multiply accumulate per harmonic and sample */ - }; - //============================================================================== /** Default constructor. Call prepare() before processing. */ SyncOscillator() = default; @@ -97,7 +89,6 @@ class SyncOscillator synced.resize (this->maxHarmonics); resampler.prepare (this->maxHarmonics); - additive.prepare (sampleRate, this->maxHarmonics); wavetable.prepare (sampleRate, this->maxHarmonics, crossfadeLengthInSamples); followerRatio = CoeffType (1); @@ -106,7 +97,6 @@ class SyncOscillator setFrequency (leaderFrequency); setPhase (CoeffType (0)); - additive.setIncludeDC (includeDC); wavetable.setIncludeDC (includeDC); update(); @@ -115,30 +105,9 @@ class SyncOscillator /** Resets the playback phase of both backends. */ void reset() noexcept { - additive.reset(); wavetable.reset(); } - //============================================================================== - /** Selects the synthesis backend, keeping the phase continuous. - - Call update() before processing to refresh a previously inactive wavetable. - */ - void setSynthesis (Synthesis newSynthesis) noexcept - { - if (synthesis == newSynthesis) - return; - - const auto currentPhase = getPhase(); - - synthesis = newSynthesis; - - setPhase (currentPhase); - } - - /** Returns the active synthesis backend. */ - Synthesis getSynthesis() const noexcept { return synthesis; } - //============================================================================== /** Sets the leader (note) pitch in Hz. @@ -153,7 +122,6 @@ class SyncOscillator const auto outputFrequency = getOutputFrequency(); - additive.setFrequency (outputFrequency); wavetable.setFrequency (outputFrequency); } @@ -246,14 +214,13 @@ class SyncOscillator /** Sets the phase of both backends, normalized to one period. */ void setPhase (CoeffType newPhase) noexcept { - additive.setPhase (newPhase); wavetable.setPhase (newPhase); } /** Returns the phase of the active backend, normalized to one period. */ CoeffType getPhase() const noexcept { - return synthesis == Synthesis::additive ? additive.getPhase() : wavetable.getPhase(); + return wavetable.getPhase(); } /** Selects whether the synthesized series includes its DC coefficient. */ @@ -264,7 +231,6 @@ class SyncOscillator includeDC = shouldIncludeDC; - additive.setIncludeDC (shouldIncludeDC); wavetable.setIncludeDC (shouldIncludeDC); } @@ -276,8 +242,7 @@ class SyncOscillator bool needsUpdate() const noexcept { return dirty - || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics) - || (synthesis == Synthesis::wavetable && wavetable.needsRender()); + || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics); } /** @@ -296,14 +261,13 @@ class SyncOscillator resampler.transform (follower, followerRatio, syncMode, synced, numOutputHarmonics); - additive.setSeries (synced); wavetable.setSeries (synced); computedHarmonics = numOutputHarmonics; dirty = false; } - if (synthesis == Synthesis::wavetable && wavetable.needsRender()) + if (wavetable.needsRender()) wavetable.render(); } @@ -311,8 +275,7 @@ class SyncOscillator /** Produces one sample with the active backend. */ SampleType processSample() noexcept { - return synthesis == Synthesis::additive ? additive.processSample() - : wavetable.processSample(); + return wavetable.processSample(); } /** Produces a block of samples with the active backend. */ @@ -321,13 +284,9 @@ class SyncOscillator if (output == nullptr) return; - if (synthesis == Synthesis::additive) - additive.processBlock (output, numSamples); - else - wavetable.processBlock (output, numSamples); + wavetable.processBlock (output, numSamples); } - //============================================================================== private: //============================================================================== int getOutputHarmonicLimit() const noexcept @@ -336,7 +295,6 @@ class SyncOscillator } SyncSpectralResampler resampler; - AdditiveOscillator additive; WavetableOscillator wavetable; FourierSeries follower; FourierSeries synced; @@ -344,7 +302,6 @@ class SyncOscillator CoeffType leaderFrequency = static_cast (440); CoeffType followerRatio = CoeffType (1); SyncMode syncMode = SyncMode::none; - Synthesis synthesis = Synthesis::wavetable; int maxHarmonics = 128; int computedHarmonics = 0; bool includeDC = false; diff --git a/modules/yup_dsp/oscillators/yup_WaveformBank.h b/modules/yup_dsp/oscillators/yup_WaveformBank.h index 3803952aa..22c45adb1 100644 --- a/modules/yup_dsp/oscillators/yup_WaveformBank.h +++ b/modules/yup_dsp/oscillators/yup_WaveformBank.h @@ -41,7 +41,7 @@ namespace yup @tparam SampleType Table sample precision. @tparam CoeffType Fourier coefficient precision. - @see MorphingOscillator, ModulatedOscillator + @see ModulatedOscillator */ template class WaveformBank @@ -85,6 +85,41 @@ class WaveformBank } } + /** Replaces the contents of every prepared frame without allocating. + + The counterpart of WavetableOscillator's setSeries/render split: the tables, + their sizes and the per-level harmonic limits chosen by prepare() are kept, + and only the coefficients are re-rendered. Frames carrying fewer harmonics + than the bank was prepared for are zero-extended. + + Rendering is still one inverse FFT per frame and level, so this belongs off + the audio thread, and the bank must not be read while it runs. + + @param frames Replacement series, one per prepared frame, each with at most + getNumHarmonics() harmonics. + + @returns false, leaving the bank untouched and readable, when the frame count + differs from prepare() or a frame carries too many harmonics. + */ + bool refreshFrames (Span> frames) noexcept + { + if (static_cast (frames.size()) != frameCount) + return false; + + for (const auto& frame : frames) + if (frame.getNumHarmonics() > getNumHarmonics()) + return false; + + for (std::size_t index = 0; index < tables.size(); ++index) + { + auto& table = tables[index]; + table.setSeries (frames[index / harmonicLimits.size()]); + table.render (false); + } + + return true; + } + /** Returns the number of prepared frames. */ int getNumFrames() const noexcept { return frameCount; } diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 3f882d547..46631d750 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -148,6 +148,7 @@ #include "oscillators/yup_WavetableOscillator.h" #include "oscillators/yup_SyncOscillator.h" #include "oscillators/yup_WaveformBank.h" +#include "oscillators/yup_PrismSpectrum.h" // Onset detection #include "onsets/yup_FilterBank.h" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index 11a3952f2..75fc66202 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -44,6 +44,7 @@ #include "yup_dsp/yup_OnsetDetector.cpp" #include "yup_dsp/yup_SincOversampler.cpp" #include "yup_dsp/yup_PartitionedConvolver.cpp" +#include "yup_dsp/yup_PrismSpectrum.cpp" #include "yup_dsp/yup_RbjFilter.cpp" #include "yup_dsp/yup_Resampler.cpp" #include "yup_dsp/yup_SincTable.cpp" diff --git a/tests/yup_dsp/yup_ModulatedOscillator.cpp b/tests/yup_dsp/yup_ModulatedOscillator.cpp index faefc06e1..830506234 100644 --- a/tests/yup_dsp/yup_ModulatedOscillator.cpp +++ b/tests/yup_dsp/yup_ModulatedOscillator.cpp @@ -62,7 +62,7 @@ TEST_F (ModulatedOscillatorTests, SignedFrequencyMatchesAnalyticSineAfterLatency std::array output {}; ASSERT_TRUE (oscillator.processBlock (output.data(), 512, p)); - for (int i = 64; i < 512; ++i) + for (int i = 2 * oscillator.getLatencyInSamples(); i < 512; ++i) EXPECT_NEAR (std::sin (MathConstants::twoPi * frequency * (i - oscillator.getLatencyInSamples()) / sampleRate), output[static_cast (i)], 0.002); } @@ -71,12 +71,12 @@ TEST_F (ModulatedOscillatorTests, SignedFrequencyMatchesAnalyticSineAfterLatency TEST_F (ModulatedOscillatorTests, PhaseModulationDoesNotChangeTheAccumulator) { Oscillator oscillator; - oscillator.prepare (sampleRate, 64, bank); + oscillator.prepare (sampleRate, 256, bank); Oscillator::Parameters p; p.frequency = 0.0; p.phaseModulation = 0.25; - std::array output {}; - ASSERT_TRUE (oscillator.processBlock (output.data(), 64, p)); + std::array output {}; + ASSERT_TRUE (oscillator.processBlock (output.data(), 256, p)); EXPECT_EQ (0.0, oscillator.getPhase()); EXPECT_NEAR (1.0, output.back(), 1e-5); } @@ -150,7 +150,7 @@ TEST_F (ModulatedOscillatorTests, FloatCoefficientsAndOutputRemainFiniteAtExtrem std::array, 1> source { FourierSeries::create (Waveform::sawtooth, 32) }; WaveformBank floatBank; floatBank.prepare ({ source.data(), source.size() }); - ModulatedOscillator oscillator; + ModulatedOscillator oscillator; oscillator.prepare (sampleRate, 128, floatBank); std::array output {}; for (const auto breakpoint : { 0.0, 0.5, 1.0 }) @@ -169,13 +169,12 @@ TEST_F (ModulatedOscillatorTests, FloatCoefficientsAndOutputRemainFiniteAtExtrem TEST_F (ModulatedOscillatorTests, SyncAndPhaseDistortionApproachAnIndependentHighRateReference) { constexpr int count = 1024; - constexpr int radius = 32; constexpr double carrier = 3000.0; constexpr double leader = 1700.0; constexpr double breakpoint = 0.2; constexpr double morph = 0.3; - ModulatedOscillator oscillator; + ModulatedOscillator oscillator; oscillator.prepare (sampleRate, count, bank); decltype (oscillator)::Parameters p; p.frequency = carrier; @@ -196,40 +195,53 @@ TEST_F (ModulatedOscillatorTests, SyncAndPhaseDistortionApproachAnIndependentHig return (1.0 - morph) * std::sin (angle) + morph * std::cos (angle); }; + // Every rendering carries its own decimator latency, so the comparison is + // made in continuous time: sample t of the waveform sits at index t + latency. + struct Rendering + { + std::array samples {}; + int latency = 0; + }; + const auto renderReference = [&]() { - Oversampler decimator; + HalfbandOversampler decimator; decimator.prepare (sampleRate, 1, count); decimator.beginGeneration (1, count); auto* internal = decimator.getOversampledChannelData (0); for (int i = 0; i < count * factor; ++i) internal[i] = continuousWaveform (i / (sampleRate * factor)); - std::array result {}; - double* channels[] = { result.data() }; + Rendering rendering; + rendering.latency = decimator.getGenerationLatencyInSamples(); + double* channels[] = { rendering.samples.data() }; decimator.downsample (channels, 1, count); - return result; + return rendering; }; const auto reference = renderReference.template operator()<64>(); const auto coarserReference = renderReference.template operator()<32>(); - double convergenceError = 0.0; + const int actualLatency = oscillator.getLatencyInSamples(); + const int settle = std::max ({ reference.latency, coarserReference.latency, actualLatency }); + const int compared = count - 2 * settle; + ASSERT_GT (compared, count / 2); + double convergenceError = 0.0; double correctedError = 0.0; double naiveError = 0.0; - for (int i = 2 * radius; i < count; ++i) + for (int t = settle; t < count - settle; ++t) { - const auto expected = reference[static_cast (i)]; - const auto convergence = coarserReference[static_cast (i)] - expected; + const auto expected = reference.samples[static_cast (t + reference.latency)]; + const auto convergence = coarserReference.samples[static_cast (t + coarserReference.latency)] - expected; convergenceError += convergence * convergence; - const auto corrected = actual[static_cast (i)] - expected; - const auto naive = continuousWaveform ((i - radius) / sampleRate) - expected; + const auto corrected = actual[static_cast (t + actualLatency)] - expected; + const auto naive = continuousWaveform (t / sampleRate) - expected; correctedError += corrected * corrected; naiveError += naive * naive; } - EXPECT_LT (std::sqrt (convergenceError / (count - 2 * radius)), 0.01); - EXPECT_LT (std::sqrt (correctedError / (count - 2 * radius)), 0.04); + EXPECT_LT (std::sqrt (convergenceError / compared), 0.01); + EXPECT_LT (std::sqrt (correctedError / compared), 0.04); EXPECT_LT (correctedError, naiveError); } @@ -247,7 +259,7 @@ TEST_F (ModulatedOscillatorTests, AudioRatePhaseModulationMatchesAnalyticReferen return p; })); - for (int i = 64; i < count; ++i) + for (int i = 2 * oscillator.getLatencyInSamples(); i < count; ++i) { const auto time = (i - oscillator.getLatencyInSamples()) / sampleRate; const auto phase = 2000.0 * time + 0.1 * std::sin (MathConstants::twoPi * 1000.0 * time); @@ -269,7 +281,7 @@ TEST_F (ModulatedOscillatorTests, ThroughZeroFMApproachesTheIntegratedFrequencyR return p; })); - for (int i = 64; i < count; ++i) + for (int i = 2 * oscillator.getLatencyInSamples(); i < count; ++i) { const auto time = (i - oscillator.getLatencyInSamples()) / sampleRate; const auto phase = 200.0 * time + 400.0 * (1.0 - std::cos (MathConstants::twoPi * 137.0 * time)) @@ -277,3 +289,76 @@ TEST_F (ModulatedOscillatorTests, ThroughZeroFMApproachesTheIntegratedFrequencyR EXPECT_NEAR (std::sin (MathConstants::twoPi * phase), output[static_cast (i)], 0.01); } } + +TEST_F (ModulatedOscillatorTests, BandwidthHintDoesNotChangeCarrierPhase) +{ + Oscillator automatic; + Oscillator bounded; + automatic.prepare (sampleRate, 256, bank); + bounded.prepare (sampleRate, 256, bank); + std::array first {}; + std::array second {}; + auto parameters = modulation (0); + parameters.syncFrequency = 0.0; + ASSERT_TRUE (automatic.processBlock (first.data(), 256, parameters)); + parameters.bandwidthFrequency = 12000.0; + ASSERT_TRUE (bounded.processBlock (second.data(), 256, parameters)); + EXPECT_DOUBLE_EQ (automatic.getPhase(), bounded.getPhase()); +} + +TEST_F (ModulatedOscillatorTests, ConservativeBandwidthMatchesAnIndependentlyFilteredCarrier) +{ + constexpr int count = 1024; + constexpr double bandwidthFrequency = 18000.0; + constexpr double bandwidth = 0.45 * sampleRate * 4 / bandwidthFrequency; + auto source = FourierSeries::create (Waveform::sawtooth, 16); + auto filtered = source; + for (int harmonic = 1; harmonic <= 16; ++harmonic) + { + // At bandwidth 4.8 the bank blends its 2- and 4-harmonic tables by 0.2. + const auto gain = harmonic <= 2 ? 1.0 : harmonic <= 4 ? (bandwidth - 4.0) / 4.0 : 0.0; + filtered.setHarmonic (harmonic, source.getCosine (harmonic) * gain, source.getSine (harmonic) * gain); + } + WaveformBank fullBank; + WaveformBank filteredBank; + fullBank.prepare ({ &source, 1 }); + filteredBank.prepare ({ &filtered, 1 }); + Oscillator bounded; + Oscillator reference; + bounded.prepare (sampleRate, count, fullBank); + reference.prepare (sampleRate, count, filteredBank); + std::array actual {}; + std::array expected {}; + const auto controls = [] (int i) + { + Oscillator::Parameters p; + p.frequency = 200.0; + p.linearFM = 400.0 * std::sin (MathConstants::twoPi * 137.0 * i / (sampleRate * 4)); + return p; + }; + ASSERT_TRUE (bounded.processModulatedBlock (actual.data(), count, [&] (int i) + { + auto p = controls (i); + p.bandwidthFrequency = bandwidthFrequency; + return p; + })); + ASSERT_TRUE (reference.processModulatedBlock (expected.data(), count, controls)); + for (int i = 2 * oscillator.getLatencyInSamples(); i < count; ++i) + EXPECT_NEAR (expected[static_cast (i)], actual[static_cast (i)], 1.0e-5); +} + +TEST_F (ModulatedOscillatorTests, BandwidthHintCannotOverrideFasterInstantaneousMotion) +{ + Oscillator automatic; + Oscillator bounded; + automatic.prepare (sampleRate, 256, bank); + bounded.prepare (sampleRate, 256, bank); + std::array first {}; + std::array second {}; + Oscillator::Parameters parameters; + parameters.frequency = -5000.0; + ASSERT_TRUE (automatic.processBlock (first.data(), 256, parameters)); + parameters.bandwidthFrequency = 100.0; + ASSERT_TRUE (bounded.processBlock (second.data(), 256, parameters)); + EXPECT_EQ (first, second); +} diff --git a/tests/yup_dsp/yup_PrismSpectrum.cpp b/tests/yup_dsp/yup_PrismSpectrum.cpp new file mode 100644 index 000000000..21e98601c --- /dev/null +++ b/tests/yup_dsp/yup_PrismSpectrum.cpp @@ -0,0 +1,389 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +using namespace yup; + +class PrismSpectrumTests : public ::testing::Test +{ +protected: + static constexpr int numHarmonics = 64; + + using Shape = PrismSpectrum::Shape; + + PrismSpectrum spectrum; + FourierSeries source { FourierSeries::create (Waveform::sawtooth, numHarmonics) }; + FourierSeries shaped { numHarmonics }; + + void SetUp() override { spectrum.prepare (numHarmonics); } + + static double sumOfMagnitudes (const FourierSeries& series) noexcept + { + auto sum = 0.0; + + for (int harmonic = 1; harmonic <= series.getNumHarmonics(); ++harmonic) + sum += series.getMagnitude (harmonic); + + return sum; + } + + /** Every shape the sweeping tests walk. + + A full cross product of nine controls would be enormous, so the core four are + gridded and the modifiers are then swept over a fixed base. Out-of-range values + are deliberate: the clamps are part of what is being tested. + */ + static std::vector shapeGrid() + { + std::vector shapes; + + for (const auto spacing : { 0.1, 0.5, 1.0, 3.0, 12.0 }) + for (const auto dispersion : { 0.0, 0.3, 0.5, 1.0 }) + for (const auto squeeze : { 0.0, 0.02, 0.17, 0.5 }) + for (const auto squash : { 0.05, 0.5, 1.0, 2.0, 6.0 }) + shapes.push_back ({ spacing, dispersion, squeeze, squash }); + + for (const auto tilt : { -6.0, -1.0, 0.0, 1.0, 6.0 }) + for (const auto oddEven : { -1.0, 0.0, 0.5, 1.0, 2.0 }) + for (const auto formant : { -6.0, 0.0, 2.5, 6.0 }) + for (const auto position : { 0.0, 2.0, 9.0 }) + for (const auto scatter : { 0.0, 0.4, 1.0, 3.0 }) + shapes.push_back ({ 1.5, 0.4, 0.1, 1.2, tilt, oddEven, formant, position, scatter }); + + return shapes; + } +}; + +TEST_F (PrismSpectrumTests, PrecomputedLogTablesMatchTheStandardLibrary) +{ + // The tables exist only to delete a log2 per harmonic, so they have to be exact. + FourierSeries impulse { numHarmonics }; + FourierSeries rotated { numHarmonics }; + + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + { + impulse.clear(); + impulse.setHarmonic (harmonic, 1.0, 0.0); + + // dispersion = 1 rotates harmonic h by 0.5 * log2 (h) ^ 2, and the ridge and + // normalization stages cancel for a lone harmonic, so the angle is readable. + spectrum.process (impulse, rotated, { 1.0, 1.0, 0.0, 1.0 }, 0.0); + + const auto u = std::log2 (static_cast (harmonic)); + const auto expected = 0.5 * u * u; + + EXPECT_NEAR (std::cos (expected), rotated.getCosine (harmonic), 1e-15); + EXPECT_NEAR (std::sin (expected), rotated.getSine (harmonic), 1e-15); + } +} + +TEST_F (PrismSpectrumTests, NeverCreatesAHarmonicAboveTheSourceLimit) +{ + // The whole antialiasing claim: every stage scales or rotates, none synthesizes. + source.clear(); + for (int harmonic = 1; harmonic <= 9; ++harmonic) + source.setHarmonic (harmonic, 0.3 / harmonic, 0.7 / harmonic); + + for (const auto& shape : shapeGrid()) + { + for (const auto color : { 0.0, 0.31, 0.5, 0.87, 1.0 }) + { + spectrum.process (source, shaped, shape, color); + + for (int harmonic = 10; harmonic <= numHarmonics; ++harmonic) + { + EXPECT_EQ (0.0, shaped.getCosine (harmonic)); + EXPECT_EQ (0.0, shaped.getSine (harmonic)); + } + + EXPECT_EQ (0.0, shaped.getDC()); + } + } +} + +TEST_F (PrismSpectrumTests, PreservesTheSumOfMagnitudes) +{ + const auto expected = sumOfMagnitudes (source); + + for (const auto& shape : shapeGrid()) + { + for (const auto color : { 0.0, 0.4, 0.75 }) + { + spectrum.process (source, shaped, shape, color); + + EXPECT_NEAR (expected, sumOfMagnitudes (shaped), 1e-9 * expected); + } + } +} + +TEST_F (PrismSpectrumTests, AllZeroAndSparseSourcesDoNotProduceNonFiniteCoefficients) +{ + // A sparse source can be annihilated outright - harmonic 2 alone at squeeze 0.5 - + // and the normalization must not divide by the resulting zero. + FourierSeries sparse { numHarmonics }; + + for (const auto& shape : shapeGrid()) + { + for (const auto harmonic : { 0, 1, 2, 4 }) + { + sparse.clear(); + if (harmonic > 0) + sparse.setHarmonic (harmonic, 1.0, 0.0); + + spectrum.process (sparse, shaped, shape, 0.25); + + for (int index = 1; index <= numHarmonics; ++index) + { + EXPECT_TRUE (std::isfinite (shaped.getCosine (index))); + EXPECT_TRUE (std::isfinite (shaped.getSine (index))); + } + } + } +} + +TEST_F (PrismSpectrumTests, BypassShapeLeavesTheFundamentalDominant) +{ + // A ridge period far wider than the source's bandwidth is flat over it. + spectrum.process (source, shaped, { 8.0, 0.5, 0.0, 1.0 }, 0.0); + + for (int harmonic = 2; harmonic <= numHarmonics; ++harmonic) + EXPECT_LT (shaped.getMagnitude (harmonic), shaped.getMagnitude (1)); + + EXPECT_NEAR (sumOfMagnitudes (source), sumOfMagnitudes (shaped), 1e-12); +} + +TEST_F (PrismSpectrumTests, SquashCompressesOrExpandsTheHarmonicRange) +{ + const auto spread = [this] (double squash) + { + spectrum.process (source, shaped, { 8.0, 0.5, 0.0, squash }, 0.0); + + auto weakest = std::numeric_limits::max(); + auto strongest = 0.0; + + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + { + weakest = jmin (weakest, shaped.getMagnitude (harmonic)); + strongest = jmax (strongest, shaped.getMagnitude (harmonic)); + } + + return weakest / strongest; + }; + + const auto neutral = spread (1.0); + + EXPECT_GT (spread (0.5), neutral); + EXPECT_LT (spread (2.0), neutral); +} + +TEST_F (PrismSpectrumTests, SqueezeAtHalfRemovesEveryEvenHarmonic) +{ + // 1 - cis (-pi h) is zero for even h and 2 for odd, the square-from-saw identity. + spectrum.process (source, shaped, { 8.0, 0.5, 0.5, 1.0 }, 0.0); + + for (int harmonic = 2; harmonic <= numHarmonics; harmonic += 2) + { + EXPECT_NEAR (0.0, shaped.getCosine (harmonic), 1e-14); + EXPECT_NEAR (0.0, shaped.getSine (harmonic), 1e-14); + } + + for (int harmonic = 1; harmonic <= numHarmonics; harmonic += 2) + EXPECT_GT (shaped.getMagnitude (harmonic), 0.0); +} + +TEST_F (PrismSpectrumTests, NeutralShapeOnALoneHarmonicIsTheIdentity) +{ + // Every stage but the ridge is off, and the ridge is a real scalar the + // normalization undoes exactly, so a single harmonic must come back untouched. + FourierSeries lone { numHarmonics }; + lone.setHarmonic (5, 0.6, -0.8); + + spectrum.process (lone, shaped, { 1.0, 0.5, 0.0, 1.0 }, 0.3); + + EXPECT_NEAR (0.6, shaped.getCosine (5), 1e-15); + EXPECT_NEAR (-0.8, shaped.getSine (5), 1e-15); +} + +TEST_F (PrismSpectrumTests, SqueezeRotatesTheSameLoneHarmonicAwayFromTheIdentity) +{ + FourierSeries lone { numHarmonics }; + lone.setHarmonic (5, 0.6, -0.8); + + spectrum.process (lone, shaped, { 1.0, 0.5, 0.17, 1.0 }, 0.3); + + // 1 - cis (-2 pi h w) has unit magnitude nowhere, so normalization restores the + // magnitude and leaves the rotation, which is what makes squeeze audible at all. + EXPECT_NEAR (1.0, shaped.getMagnitude (5), 1e-14); + EXPECT_GT (std::abs (shaped.getCosine (5) - 0.6), 1e-3); +} + +TEST_F (PrismSpectrumTests, SqueezePassesContinuouslyThroughZero) +{ + // The raw factor tends to a differentiator rather than to 1 as the width falls, so + // the depth is faded in instead. Without that fade, stepping off zero is a jump: + // the unfaded factor at w = 0.0005 reweights harmonic h by h and rotates it by 90 + // degrees, which renormalization then scales straight back up to full level. + FourierSeries bypassed { numHarmonics }; + spectrum.process (source, bypassed, { 2.0, 0.5, 0.0, 1.0 }, 0.3); + + for (const auto squeeze : { 0.0001, 0.0005, 0.001 }) + { + spectrum.process (source, shaped, { 2.0, 0.5, squeeze, 1.0 }, 0.3); + + auto difference = 0.0; + + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + difference += std::abs (shaped.getCosine (harmonic) - bypassed.getCosine (harmonic)) + + std::abs (shaped.getSine (harmonic) - bypassed.getSine (harmonic)); + + // Depth reaches only squeeze/squeezeFadeWidth here, so the whole series should + // still sit within a couple of percent of the bypass. + EXPECT_LT (difference, 0.05 * sumOfMagnitudes (source)) << "at squeeze " << squeeze; + } +} + +TEST_F (PrismSpectrumTests, HarmonicsBeyondThePreparedCountAreDropped) +{ + PrismSpectrum narrow; + narrow.prepare (8); + + narrow.process (source, shaped, { 1.0, 0.5, 0.0, 1.0 }, 0.0); + + for (int harmonic = 9; harmonic <= numHarmonics; ++harmonic) + EXPECT_EQ (0.0, shaped.getMagnitude (harmonic)); + + EXPECT_GT (shaped.getMagnitude (1), 0.0); +} + +TEST_F (PrismSpectrumTests, TiltSlopesTheSpectrumAndIsFlatAtZero) +{ + const auto ratioOfHarmonics = [this] (double tilt) + { + Shape shape; + shape.ridgeSpacing = 8.0; + shape.tilt = tilt; + spectrum.process (source, shaped, shape, 0.0); + + return shaped.getMagnitude (32) / shaped.getMagnitude (2); + }; + + const auto flat = ratioOfHarmonics (0.0); + + EXPECT_LT (ratioOfHarmonics (1.0), flat); + EXPECT_GT (ratioOfHarmonics (-1.0), flat); + + // Four octaves apart, one gain octave per harmonic octave is a factor of sixteen. + EXPECT_NEAR (flat / 16.0, ratioOfHarmonics (1.0), 1e-12); +} + +TEST_F (PrismSpectrumTests, OddEvenBalanceIsolatesEitherHalfAndIsNeutralAtTheCenter) +{ + Shape shape; + shape.ridgeSpacing = 8.0; + + shape.oddEven = 0.0; + spectrum.process (source, shaped, shape, 0.0); + for (int harmonic = 2; harmonic <= numHarmonics; harmonic += 2) + EXPECT_EQ (0.0, shaped.getMagnitude (harmonic)); + EXPECT_GT (shaped.getMagnitude (1), 0.0); + + shape.oddEven = 1.0; + spectrum.process (source, shaped, shape, 0.0); + for (int harmonic = 1; harmonic <= numHarmonics; harmonic += 2) + EXPECT_EQ (0.0, shaped.getMagnitude (harmonic)); + EXPECT_GT (shaped.getMagnitude (2), 0.0); + + shape.oddEven = 0.5; + spectrum.process (source, shaped, shape, 0.0); + FourierSeries neutral { numHarmonics }; + spectrum.process (source, neutral, { 8.0, 0.5, 0.0, 1.0 }, 0.0); + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + EXPECT_DOUBLE_EQ (neutral.getCosine (harmonic), shaped.getCosine (harmonic)); +} + +TEST_F (PrismSpectrumTests, FormantPeaksAtItsPositionAndBypassesAtZero) +{ + Shape shape; + shape.ridgeSpacing = 8.0; + shape.formantPosition = 3.0; // harmonic 8 + shape.formant = 3.0; + + FourierSeries withoutFormant { numHarmonics }; + spectrum.process (source, withoutFormant, { 8.0, 0.5, 0.0, 1.0 }, 0.0); + spectrum.process (source, shaped, shape, 0.0); + + // Relative to the unresonated spectrum, harmonic 8 must gain on its neighbours. + const auto boosted = shaped.getMagnitude (8) / shaped.getMagnitude (1); + const auto plain = withoutFormant.getMagnitude (8) / withoutFormant.getMagnitude (1); + EXPECT_GT (boosted, plain); + + shape.formant = -3.0; + spectrum.process (source, shaped, shape, 0.0); + EXPECT_LT (shaped.getMagnitude (8) / shaped.getMagnitude (1), plain); + + shape.formant = 0.0; + spectrum.process (source, shaped, shape, 0.0); + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + EXPECT_DOUBLE_EQ (withoutFormant.getCosine (harmonic), shaped.getCosine (harmonic)); +} + +TEST_F (PrismSpectrumTests, ScatterRotatesWithoutChangingAnyMagnitude) +{ + Shape shape; + shape.ridgeSpacing = 8.0; + + FourierSeries unscattered { numHarmonics }; + spectrum.process (source, unscattered, shape, 0.0); + + shape.scatter = 1.0; + spectrum.process (source, shaped, shape, 0.0); + + auto rotated = 0; + + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + { + EXPECT_NEAR (unscattered.getMagnitude (harmonic), shaped.getMagnitude (harmonic), 1e-12); + + if (std::abs (unscattered.getSine (harmonic) - shaped.getSine (harmonic)) > 1e-6) + ++rotated; + } + + EXPECT_GT (rotated, numHarmonics / 2); +} + +TEST_F (PrismSpectrumTests, ScatterIsReproducibleAcrossInstances) +{ + // The angles are a property of the shape, so two shapers must agree - otherwise + // re-deriving a block would re-scatter and sound like noise. + PrismSpectrum other; + other.prepare (numHarmonics); + + FourierSeries otherResult { numHarmonics }; + const Shape shape { 8.0, 0.5, 0.0, 1.0, 0.0, 0.5, 0.0, 2.0, 0.8 }; + + spectrum.process (source, shaped, shape, 0.2); + other.process (source, otherResult, shape, 0.2); + + for (int harmonic = 1; harmonic <= numHarmonics; ++harmonic) + EXPECT_DOUBLE_EQ (shaped.getSine (harmonic), otherResult.getSine (harmonic)); +} diff --git a/tests/yup_dsp/yup_WaveformBank.cpp b/tests/yup_dsp/yup_WaveformBank.cpp index acba1b501..f2b577d8f 100644 --- a/tests/yup_dsp/yup_WaveformBank.cpp +++ b/tests/yup_dsp/yup_WaveformBank.cpp @@ -87,3 +87,81 @@ TEST_F (WaveformBankTests, EmptyBankIsSilent) EXPECT_EQ (0.0, bank.getValue (0.3, 0.5, 32.0)); EXPECT_EQ (0.0, bank.getSlope (0.3, 0.5, 32.0)); } + +TEST_F (WaveformBankTests, RefreshedFramesReadLikeAFreshlyPreparedBank) +{ + std::array, 2> replacements { + FourierSeries::create (Waveform::sawtooth, 16), + FourierSeries::create (Waveform::triangle, 16) + }; + replacements[0].setDC (0.125); + + WaveformBank reference; + reference.prepare ({ replacements.data(), replacements.size() }); + + ASSERT_TRUE (bank.refreshFrames ({ replacements.data(), replacements.size() })); + EXPECT_EQ (reference.getNumFrames(), bank.getNumFrames()); + EXPECT_EQ (reference.getNumHarmonics(), bank.getNumHarmonics()); + + for (const auto position : { 0.0, 0.3, 0.5, 1.0 }) + { + for (const auto bandwidth : { 0.0, 1.0, 4.0, 16.0, 32.0 }) + { + for (int i = 0; i < 37; ++i) + { + const auto phase = i / 37.0; + EXPECT_DOUBLE_EQ (reference.getValue (phase, position, bandwidth), + bank.getValue (phase, position, bandwidth)); + EXPECT_DOUBLE_EQ (reference.getSlope (phase, position, bandwidth), + bank.getSlope (phase, position, bandwidth)); + } + } + } +} + +TEST_F (WaveformBankTests, RefreshZeroExtendsShorterFrames) +{ + std::array, 2> shorter { + FourierSeries::create (Waveform::sine, 4), + FourierSeries::create (Waveform::sine, 4) + }; + + ASSERT_TRUE (bank.refreshFrames ({ shorter.data(), shorter.size() })); + EXPECT_EQ (16, bank.getNumHarmonics()); + + for (int i = 0; i < 37; ++i) + { + const auto phase = i / 37.0; + EXPECT_NEAR (std::sin (MathConstants::twoPi * phase), + bank.getValue (phase, 1.0, 32.0), 2e-5); + } +} + +TEST_F (WaveformBankTests, RejectsMismatchedRefreshesAndLeavesTheBankUnchanged) +{ + const auto before = bank.getValue (0.173, 0.25, 32.0); + + std::array, 3> tooManyFrames { + FourierSeries::create (Waveform::sawtooth, 16), + FourierSeries::create (Waveform::sawtooth, 16), + FourierSeries::create (Waveform::sawtooth, 16) + }; + EXPECT_FALSE (bank.refreshFrames ({ tooManyFrames.data(), tooManyFrames.size() })); + + std::array, 2> tooManyHarmonics { + FourierSeries::create (Waveform::sawtooth, 16), + FourierSeries::create (Waveform::sawtooth, 32) + }; + EXPECT_FALSE (bank.refreshFrames ({ tooManyHarmonics.data(), tooManyHarmonics.size() })); + + EXPECT_EQ (2, bank.getNumFrames()); + EXPECT_EQ (16, bank.getNumHarmonics()); + EXPECT_DOUBLE_EQ (before, bank.getValue (0.173, 0.25, 32.0)); +} + +TEST_F (WaveformBankTests, RefreshingAnEmptyBankSucceedsWithNoFrames) +{ + bank.prepare ({}); + EXPECT_TRUE (bank.refreshFrames ({})); + EXPECT_EQ (0, bank.getNumFrames()); +} From 99c6d17c9ec48f55a8bb450f93081378b56489d3 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 14:43:18 +0200 Subject: [PATCH 11/37] More nice stuff --- CHANGELOG.md | 4 +- docs/dsp/filters.md | 24 + docs/dsp/index.md | 2 +- docs/dsp/oscillators.md | 23 +- examples/graphics/source/examples/Audio.h | 2295 +---------------- .../source/examples/audio/SynthEngine.h | 1277 +++++++++ .../examples/audio/SynthModulationPage.h | 140 + .../source/examples/audio/SynthPanels.h | 1248 +++++++++ .../source/examples/audio/SynthSettings.h | 577 +++++ .../filters/yup_VAStateVariableFilter.h | 348 +++ modules/yup_dsp/oscillators/yup_LFO.h | 214 ++ .../yup_dsp/oscillators/yup_PrismSpectrum.h | 2 +- modules/yup_dsp/yup_dsp.h | 2 + .../yup_AudioFormatWriter.cpp | 22 +- tests/yup_dsp/yup_LFO.cpp | 186 ++ tests/yup_dsp/yup_VAStateVariableFilter.cpp | 290 +++ 16 files changed, 4475 insertions(+), 2179 deletions(-) create mode 100644 examples/graphics/source/examples/audio/SynthEngine.h create mode 100644 examples/graphics/source/examples/audio/SynthModulationPage.h create mode 100644 examples/graphics/source/examples/audio/SynthPanels.h create mode 100644 examples/graphics/source/examples/audio/SynthSettings.h create mode 100644 modules/yup_dsp/filters/yup_VAStateVariableFilter.h create mode 100644 modules/yup_dsp/oscillators/yup_LFO.h create mode 100644 tests/yup_dsp/yup_LFO.cpp create mode 100644 tests/yup_dsp/yup_VAStateVariableFilter.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d0388f5..0074f4ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,11 +42,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added `HalfbandOversampler` (`yup_dsp/resampling/`), a power-of-two oversampler built from a cascade of 2× halfband stages so that only the stage next to the base rate has to be steep. `Design` selects the family and targets: `linearPhaseFIR` designs Kaiser-windowed halfbands, verifies every stage's stopband numerically at `prepare()` and keeps both latencies whole input samples by rounding the cascade's delay at the top rate; `polyphaseIIR` designs elliptic halfbands as two allpass branches (Valenzuela & Constantinides) for a few multiplies per sample and minimal, frequency-dependent latency. Defaults are 100 dB of rejection, a passband to 0.45 of the input rate and, for the FIR, a stopband starting at the input Nyquist, so nothing folds back into the band (a pure halfband first stage, selected with `stopbandEdge = 1 - passbandEdge`, costs a quarter as much but folds 0.5 to 0.55 of the input rate into the top of the band, which is what the IIR family always does). With the defaults 4× decimates in about 300 MACs per input sample against 128 for the radius-16 `SincOversampler` at 90 dB, a 0.36 passband and a 40 dB leak at Nyquist; at 32× it is about 600 against 1024, and the IIR cascade needs about 76 multiplies. The public methods mirror `SincOversampler` so the two are drop-in replacements. `ModulatedOscillator` now decimates through it: its `SincRadius` template parameter is gone (`ModulatedOscillator`), `prepare()` takes an optional `HalfbandOversamplerDesign`, and `getLatencyInSamples()` became an instance method that reports the design's latency after `prepare()` - `SincOversampler` (the single-stage polyphase sinc formerly named `Oversampler`) is several times faster and rejects images and aliases far better. Each channel now keeps its history contiguously in front of the staging buffer so every output sample is one `dotProduct` (SIMD for `float`/`float` and, newly, `double`/`double` via `FloatVectorOperations`), replacing the per-tap circular-buffer modulo and strided table reads; the kernels are prebuilt phase-major with the gain baked in. Both kernels use Kaiser β = 9 (was 5), and `SincTable::applyKaiserWindow` now spans exactly the kernel radius (it previously windowed over `(SincRadius + 1) · OversampleFactor` entries while the kernel was used out to `SincRadius · OversampleFactor`, cutting it off at about 8 % of the window peak and capping the rejection of `SincOversampler` and `Resampler` alike). The `Oversampler2xFloat` … `Oversampler8xDouble` aliases moved from radius 8 to radius 16, so their reported latency grows from 16 to 32 input samples, and `Oversampler16xFloat` / `Oversampler32xFloat` plus their `Double` variants were added - Added `PrismSpectrum` (`yup_dsp/oscillators/`), promoting the graphics example's Prism recipe into the library. It shapes a `FourierSeries` through a raised-cosine ridge comb in log2-harmonic space, magnitude companding, exact spectral pulse-width modulation and a quadratic phase dispersion, renormalizing to the source's `sum |c|`. A spectral tilt, an odd/even balance, a movable formant resonance and a pseudo-random phase scatter sit alongside those, each continuous through its own neutral value so it can be modulated without a step; scatter shares dispersion's rotation and so costs nothing extra. Every stage is a per-harmonic scale or rotation, so none can add a frequency and the backend's own bandlimiting is preserved. There is no transform behind it, so a shape can be re-derived once per audio block and the controls modulated; `prepare()` precomputes the per-harmonic `log2` tables. The pulse-width depth fades in over the first `squeezeFadeWidth`, because the raw factor tends to a differentiator rather than to unity as the width falls, which would otherwise make zero a discontinuity +- Added `VAStateVariableFilter` (`yup_dsp/filters/`), a topology preserving transform state variable filter modeled on Zavalishin's papers. One pass yields lowpass, highpass, constant-skirt and unity-gain bandpass, band shelf, notch, allpass and lowpass-minus-highpass outputs; resonance can be set in 0..1 or as Q, the shelf gain in dB, and the complex response is analytic +- Added `LFO` (`yup_dsp/oscillators/`), an allocation-free control-rate oscillator with sine, triangle, sawtooth, square and seeded sample-and-hold shapes, a phase offset, and `skip()` for block-rate consumers - Added `WaveformBank::refreshFrames()`, replacing a prepared bank's coefficients without allocating and keeping the per-level harmonic limits `prepare()` chose, plus `ModulatedOscillator::setBank()` for adopting a bank rebuilt off-thread - `ModulatedOscillator::Parameters` moved to namespace scope as `ModulatedOscillatorParameters` (still aliased as the nested `Parameters`), and the modulation maths moved into `detail::ModulatedOscillatorVoice` so oscillators can compose over it without duplicating the phase map, sync and BLEP/BLAMP corrections - Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `SincOversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor -- Reduced the graphics synthesizer example to a single Prism oscillator per slot, deriving each slot's spectrum once per block rather than once per voice so the shape controls can be modulated, and exposing squeeze, squash, tilt, odd/even, formant and scatter alongside ridges, color and dispersion. The sync modes moved onto Prism: the shaped series goes through `SyncSpectralResampler` at the slot, driven by the sync mode chooser and a new sync ratio knob, so unison satellites and the waveform preview follow it for free; the Modulated algorithm, its FM knobs and the algorithm chooser are gone from the example +- Reduced the graphics synthesizer example to a single Prism oscillator per slot, deriving each slot's spectrum once per block rather than once per voice so the shape controls can be modulated, and exposing squeeze, squash, tilt, odd/even, formant and scatter alongside ridges, color and dispersion. The sync modes moved onto Prism: the shaped series goes through `SyncSpectralResampler` at the slot, driven by the sync mode chooser and a new sync ratio knob, so unison satellites and the waveform preview follow it for free; the Modulated algorithm, its FM knobs and the algorithm chooser are gone from the example. The example then grew a per-voice `VAStateVariableFilter` stage with drive and keytracking, a second envelope, two global `LFO`s with animated displays, and an eight-slot modulation matrix on a second page: LFO routings are applied once per block at the slot, envelope routings per voice, and a voice derives a private spectrum only while an envelope is routed into one of its spectrum parameters. Knob captions show the value while dragging. The example is split into `examples/graphics/source/examples/audio/` (settings, engine, panels, modulation page) - Improved the graphics synthesizer example with a spectral Prism oscillator, octave and cents controls, poly/mono/legato modes, portamento, rendered voice-stealing tails, a revised instrument layout, and audio-load/overrun metering. Reduced callback and scope overhead, removed output waveshaping, and added regression coverage. - Added shared `WaveformBank` and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `SincOversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. diff --git a/docs/dsp/filters.md b/docs/dsp/filters.md index fc70a26b4..aa35d6746 100644 --- a/docs/dsp/filters.md +++ b/docs/dsp/filters.md @@ -147,6 +147,30 @@ svf.processMultipleOutputs (in, lp, hp, bp, bs, n); // any output buffer may be lowpass). Coefficients are `k = 1/Q`, `g = tan(ω/2)`, normalized as `g / (1 + g·(k + g))`. +### VAStateVariableFilter + +`VAStateVariableFilter` is a topology preserving transform SVF after Zavalishin. +Where `StateVariableFilter` offers four outputs and clamps Q, this one produces +eight outputs from one pass, takes a resonance in `0..1` (`Q = 1 / (2 (1 - r))`) +or a raw Q, and adds a band shelf whose gain is set in dB. + +```cpp +VAStateVariableFilter svf; +svf.setParameters (yup::FilterMode::bandpassCpg, 1200.f, 4.f, 0.f, 48000.0); +svf.setResonance (0.8f); // or setQ() +svf.setCutoffPitch (60.f); // MIDI note, 440 Hz at 69 + +float y = svf.processSample (x); // output selected by the mode +auto outs = svf.processAllOutputs (x); // .lowpass .highpass .bandpass .unityGainBandpass + // .bandShelf .notch .allpass .peak +``` + +Supported modes: `lowpass`, `highpass`, `bandpassCsg` (peak gain `Q`), +`bandpassCpg` (unity peak gain), `bandstop`, `allpass` and `peak` (the band shelf, +`input + K · 2 R · BP` with `K = 10^(dB / 20) - 1`). The lowpass-minus-highpass +output of the original is available as `Outputs::peak` only. `getComplexResponse` +is analytic through the bilinear substitution, so it matches the processed signal. + ### ButterworthFilter `ButterworthFilter` is a mathematically correct Butterworth design (analog diff --git a/docs/dsp/index.md b/docs/dsp/index.md index 3ea7f32e3..46f8c984b 100644 --- a/docs/dsp/index.md +++ b/docs/dsp/index.md @@ -29,7 +29,7 @@ available in the build. `AnalogFilterCoefficients`, `StateVariableCoefficients`). - [Filters](filters.md) - the processing primitives (`FirstOrder`, `Biquad`, cascades, coefficient structs) and the ready-to-use filter classes: - first-order, RBJ biquad, Zoelzer, state-variable, Butterworth, + first-order, RBJ biquad, Zoelzer, state-variable (Chamberlin and VA/TPT), Butterworth, Linkwitz-Riley crossovers, direct FIR, analog-mapped filters, and comb filters. - [Dynamics & metering](dynamics.md) - `HardClipper`, `SoftClipper`, diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 780656133..1391eb52f 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -11,7 +11,7 @@ Oscillator Synchronization via Additive Synthesis"* (DAFx26, paper 49). **Headers:** `yup_dsp/oscillators/` - `yup_FourierSeries.h`, `yup_SyncSpectralResampler.h`, `yup_AdditiveOscillator.h`, `yup_WavetableOscillator.h`, `yup_SyncOscillator.h`, `yup_WaveformBank.h`, -`yup_ModulatedOscillator.h`. +`yup_ModulatedOscillator.h`, `yup_PrismSpectrum.h`, `yup_LFO.h`. ## The idea @@ -378,3 +378,24 @@ Additional algorithm references: describes breakpoint phase maps and modulation. - [Practical Linear and Exponential Frequency Modulation for Digital Music Synthesis](https://www.dafx.de/paper-archive/2020/proceedings/papers/DAFx2020_paper_61.pdf) discusses FM semantics, sideband bandwidth and oversampling. + +## LFO + +`LFO` is a control-rate oscillator: a plain phase accumulator read as a +sine, triangle, rising sawtooth, square or sample-and-hold, bipolar in `[-1, 1]`. +It is deliberately not bandlimited and never allocates. + +```cpp +yup::LFO lfo; +lfo.prepare (48000.0); +lfo.setShape (yup::LFO::Shape::sampleAndHold); +lfo.setFrequency (3.0f); +lfo.setPhaseOffset (0.25f); // read a quarter period ahead + +float now = lfo.getValue(); // value at the top of the block +lfo.skip (numSamples); // advance by one block +lfo.processBlock (out, numSamples); // or audio-rate +``` + +Sample-and-hold draws from a seeded linear congruential generator every time the +phase wraps, so `setSeed()` plus `reset()` makes a modulation reproducible. diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 92a9e374c..f555c52e8 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -21,2161 +21,31 @@ #pragma once -#include -#include -#include -#include -#include - -//============================================================================== -/** Sizing shared by the polyphonic engine and its user interface. */ -namespace SynthExample -{ -constexpr int voiceCount = 8; -constexpr int oscillatorCount = 2; -constexpr int maxHarmonics = 128; -constexpr int maxBlockSize = 2048; - -/** Unison slots per oscillator, counting the one running the selected algorithm. */ -constexpr int maxUnisonVoices = 5; - -/** Harmonics the partial editor exposes, a subset of the maxHarmonics the engine renders. */ -constexpr int editableHarmonics = 32; - -/** Harmonics the waveform display sums, capped well below maxHarmonics to keep repaints cheap. */ -constexpr int displayHarmonics = 64; - -constexpr double levelRampSeconds = 0.01; -} // namespace SynthExample - -/** @internal Item names for yup::Waveform, index aligned with the enumeration. */ -inline yup::StringArray getSynthWaveformNames() -{ - return { "Sine", "Cosine", "Sawtooth", "Square", "Triangle", "Pulse" }; -} - -/** @internal Item names for yup::SyncMode, index aligned with the enumeration. */ -inline yup::StringArray getSynthSyncModeNames() -{ - return { "None", "Hard", "Mirrored", "Pulsar" }; -} - -//============================================================================== -/** Sums a Fourier series at one point of its period. - - yup::FourierSeries stores coefficients rather than samples, so the waveform display - reconstructs them on demand. Only the message thread calls this. - - @param series The coefficients to sum - @param phase The position in the period, normalized to 0 to 1 - @param maxHarmonic The highest harmonic to include - - @returns The value of the series at that phase. -*/ -inline float evaluateFourierSeries (const yup::FourierSeries& series, double phase, int maxHarmonic) noexcept -{ - const auto count = yup::jmin (maxHarmonic, series.getNumHarmonics()); - const auto theta = yup::MathConstants::twoPi * phase; - - auto value = series.getDC(); - - for (int harmonic = 1; harmonic <= count; ++harmonic) - { - const auto angle = theta * harmonic; - - value += series.getCosine (harmonic) * std::cos (angle) - + series.getSine (harmonic) * std::sin (angle); - } - - return static_cast (value); -} - -//============================================================================== -/** A plain snapshot of the amplitude envelope's controls. */ -struct SynthEnvelopeValues -{ - float delay = 0.0f; - float attack = 0.005f; - float hold = 0.0f; - float decay = 0.35f; - float sustain = 0.7f; - float release = 0.35f; -}; - -/** The same controls, edited from the message thread while the audio thread reads them. */ -struct SynthEnvelopeSettings -{ - std::atomic delay { 0.0f }; - std::atomic attack { 0.005f }; - std::atomic hold { 0.0f }; - std::atomic decay { 0.35f }; - std::atomic sustain { 0.7f }; - std::atomic release { 0.35f }; - - /** Takes a snapshot for one block of audio. */ - SynthEnvelopeValues read() const noexcept - { - return { delay.load(), attack.load(), hold.load(), decay.load(), sustain.load(), release.load() }; - } -}; - -//============================================================================== -/** A delay, attack, hold, decay, sustain and release envelope with linear segments. - - yup_dsp has no envelope generator, so the example carries its own. It replaces the - single yup::SmoothedValue the earlier revision faded notes with, which could only - ramp between two levels and gave every patch the same shape. - - The stage is advanced one sample at a time and the note ends when the envelope - falls idle, so a voice stays allocated for exactly as long as it is audible. - - @see SynthVoice -*/ -class SynthEnvelope -{ -public: - /** Prepares the envelope and leaves it idle. */ - void prepare (double newSampleRate) noexcept - { - sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; - - reset(); - } - - /** Silences the envelope and returns it to the idle stage. */ - void reset() noexcept - { - stage = Stage::idle; - level = 0.0f; - stageSample = 0; - } - - /** Converts the control values into per sample increments. Safe to call every block. */ - void setParameters (const SynthEnvelopeValues& values) noexcept - { - sustainLevel = yup::jlimit (0.0f, 1.0f, values.sustain); - - delaySamples = toSamples (values.delay); - holdSamples = toSamples (values.hold); - releaseSamples = toSamples (yup::jmax (0.003f, values.release)); - - attackIncrement = 1.0f / static_cast (toSamples (yup::jmax (0.003f, values.attack))); - decayIncrement = (1.0f - sustainLevel) / static_cast (toSamples (values.decay)); - } - - /** Starts a new note from the delay stage. */ - void noteOn() noexcept - { - stage = Stage::delay; - level = 0.0f; - stageSample = 0; - } - - /** Begins the release from whatever level the envelope currently sits at. */ - void noteOff() noexcept - { - if (stage == Stage::idle) - return; - - releaseIncrement = level / static_cast (releaseSamples); - stage = Stage::release; - stageSample = 0; - } - - /** Silences the envelope immediately, ending the note without a tail. */ - void noteOffImmediate() noexcept - { - reset(); - } - - /** Returns true while the envelope still contributes to the output. */ - bool isActive() const noexcept { return stage != Stage::idle; } - - /** Advances one sample and returns the new gain. */ - float getNextValue() noexcept - { - switch (stage) - { - case Stage::idle: - return 0.0f; - - case Stage::delay: - if (++stageSample >= delaySamples) - advanceTo (Stage::attack); - - return 0.0f; - - case Stage::attack: - level += attackIncrement; - - if (level >= 1.0f) - { - level = 1.0f; - advanceTo (Stage::hold); - } - - return level; - - case Stage::hold: - if (++stageSample >= holdSamples) - advanceTo (Stage::decay); - - return level; - - case Stage::decay: - level -= decayIncrement; - - if (level <= sustainLevel || decayIncrement <= 0.0f) - { - level = sustainLevel; - advanceTo (Stage::sustain); - } - - return level; - - case Stage::sustain: - level = sustainLevel; - - return level; - - case Stage::release: - level -= releaseIncrement; - - if (level <= 0.0f || releaseIncrement <= 0.0f) - { - level = 0.0f; - advanceTo (Stage::idle); - } - - return level; - } - - return level; - } - -private: - //============================================================================== - enum class Stage - { - idle, - delay, - attack, - hold, - decay, - sustain, - release - }; - - void advanceTo (Stage newStage) noexcept - { - stage = newStage; - stageSample = 0; - } - - int toSamples (float seconds) const noexcept - { - return yup::jmax (1, static_cast (static_cast (seconds) * sampleRate)); - } - - //============================================================================== - Stage stage = Stage::idle; - - double sampleRate = 44100.0; - float level = 0.0f; - float sustainLevel = 0.7f; - float attackIncrement = 1.0f; - float decayIncrement = 1.0f; - float releaseIncrement = 1.0f; - - int delaySamples = 1; - int holdSamples = 1; - int releaseSamples = 1; - int stageSample = 0; -}; - -//============================================================================== -/** A plain snapshot of one oscillator's controls. - - The audio thread takes one snapshot per block and compares it with the values it - applied last time, so a control that did not move never costs a spectral - transform or a table render. - - The edited partials are deliberately not copied here. They live behind - harmonicGeneration, so a block only pays for them when the editor actually moved. -*/ -struct SynthOscillatorValues -{ - yup::Waveform waveform = yup::Waveform::sawtooth; - yup::SyncMode syncMode = yup::SyncMode::none; - float syncRatio = 1.5f; - float level = 0.5f; - int octave = 0; - float detuneSemitones = 0.0f; - float ridgeSpacing = 1.5f; - float color = 0.0f; - float dispersion = 0.5f; - float squeeze = 0.0f; - float squash = 1.0f; - float tilt = 0.0f; - float oddEven = 0.5f; - float formant = 0.0f; - float formantPosition = 2.0f; - float scatter = 0.0f; - int unisonVoices = 1; - float unisonDetune = 0.2f; - float unisonSpread = 0.6f; - float harmonicScale = 1.0f; - bool usesCustomSeries = false; - int harmonicGeneration = 0; -}; - -/** The same controls, edited from the message thread while the audio thread reads them. - - The partial editor writes magnitudes continuously while the mouse is down but bumps - harmonicGeneration at most once per user interface frame. The audio thread rebuilds - its series only when that counter moves, which keeps a drag from forcing an inverse - FFT per mouse event on every sounding voice. - - @see SynthOscillator, WaveformEditor -*/ -struct SynthOscillatorSettings -{ - SynthOscillatorSettings() - { - for (auto& harmonic : harmonics) - harmonic.store (0.0f); - } - - std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; - std::atomic syncMode { static_cast (yup::SyncMode::none) }; - std::atomic syncRatio { 1.5f }; - std::atomic level { 0.5f }; - std::atomic octave { 0 }; - std::atomic detuneSemitones { 0.0f }; - std::atomic ridgeSpacing { 1.5f }; - std::atomic color { 0.0f }; - std::atomic dispersion { 0.5f }; - std::atomic squeeze { 0.0f }; - std::atomic squash { 1.0f }; - std::atomic tilt { 0.0f }; - std::atomic oddEven { 0.5f }; - std::atomic formant { 0.0f }; - std::atomic formantPosition { 2.0f }; - std::atomic scatter { 0.0f }; - std::atomic unisonVoices { 1 }; - std::atomic unisonDetune { 0.2f }; - std::atomic unisonSpread { 0.6f }; - - std::array, SynthExample::editableHarmonics> harmonics; - std::atomic harmonicScale { 1.0f }; - std::atomic usesCustomSeries { false }; - std::atomic harmonicGeneration { 0 }; - - /** Takes a snapshot for one block of audio. */ - SynthOscillatorValues read() const noexcept - { - return { static_cast (waveform.load()), - static_cast (syncMode.load()), - syncRatio.load(), - level.load(), - octave.load(), - detuneSemitones.load(), - ridgeSpacing.load(), - color.load(), - dispersion.load(), - squeeze.load(), - squash.load(), - tilt.load(), - oddEven.load(), - formant.load(), - formantPosition.load(), - scatter.load(), - unisonVoices.load(), - unisonDetune.load(), - unisonSpread.load(), - harmonicScale.load(), - usesCustomSeries.load(), - harmonicGeneration.load() }; - } - - /** Rebuilds a prepared series from the edited magnitudes, without allocating. - - The editor works in magnitudes only and writes them as sine coefficients, the - same convention yup::FourierSeries::setWaveform uses for its sawtooth, square - and triangle presets. - - Nothing stops the editor from asking for every harmonic at once, which would sum - to many times full scale, so the caller passes the scale that brings the - reconstruction back to a peak of one. WaveformEditor measures it while it redraws - and publishes it with the same generation bump; the audio thread passes it back - in here rather than measuring anything itself. - - @param series The prepared series to overwrite - @param scale The factor to apply to every coefficient - */ - void copyHarmonicsInto (yup::FourierSeries& series, float scale) const noexcept - { - series.clear(); - - const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); - - for (int harmonic = 1; harmonic <= count; ++harmonic) - { - const auto magnitude = harmonics[static_cast (harmonic - 1)].load() * scale; - - series.setHarmonic (harmonic, 0.0, static_cast (magnitude)); - } - } - - /** Seeds the edited magnitudes from a series, so editing starts at the visible shape. */ - void seedHarmonicsFrom (const yup::FourierSeries& series) noexcept - { - const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); - - for (int harmonic = 1; harmonic <= count; ++harmonic) - harmonics[static_cast (harmonic - 1)].store (static_cast (series.getMagnitude (harmonic))); - - for (int harmonic = count; harmonic < SynthExample::editableHarmonics; ++harmonic) - harmonics[static_cast (harmonic)].store (0.0f); - } -}; - -//============================================================================== -/** Immutable waveform data, prepared once and read by every voice. - - Building a FourierSeries allocates, so it belongs at construction time. Once - prepared the resources are read-only and safe to share across voices. - - @see SynthOscillator -*/ -class SynthOscillatorResources -{ -public: - SynthOscillatorResources() - { - const yup::Waveform waveforms[] = { yup::Waveform::sine, - yup::Waveform::cosine, - yup::Waveform::sawtooth, - yup::Waveform::square, - yup::Waveform::triangle, - yup::Waveform::pulse }; - - for (std::size_t index = 0; index < frames.size(); ++index) - frames[index] = yup::FourierSeries::create (waveforms[index], SynthExample::maxHarmonics); - } - - /** Returns the series of one of the Waveform presets. */ - const yup::FourierSeries& getFrame (yup::Waveform waveform) const noexcept - { - return frames[static_cast (waveform)]; - } - -private: - std::array, 6> frames; -}; - -//============================================================================== -/** Turns a control snapshot into the shape yup::PrismSpectrum reads. */ -inline yup::PrismSpectrum::Shape toPrismShape (const SynthOscillatorValues& values) noexcept -{ - return { static_cast (values.ridgeSpacing), - static_cast (values.dispersion), - static_cast (values.squeeze), - static_cast (values.squash), - static_cast (values.tilt), - static_cast (values.oddEven), - static_cast (values.formant), - static_cast (values.formantPosition), - static_cast (values.scatter) }; -} - -//============================================================================== -/** Shapes a source through the Prism stages and then through the sync transform. - - The audio thread and the waveform display both derive their series here, so the - preview cannot drift from what the voices play. yup::PrismSpectrum is stateless - and shared; the resampler keeps scratch storage, so every caller brings its own. - - @returns The series to play or draw: the shaped one, or the synced one when a - sync mode is selected. -*/ -inline yup::FourierSeries& derivePrismSeries (const yup::PrismSpectrum& spectrum, - yup::SyncSpectralResampler& resampler, - const yup::FourierSeries& source, - yup::FourierSeries& shaped, - yup::FourierSeries& synced, - const SynthOscillatorValues& values) noexcept -{ - spectrum.process (source, shaped, toPrismShape (values), values.color); - - if (values.syncMode == yup::SyncMode::none) - return shaped; - - resampler.transform (shaped, static_cast (values.syncRatio), values.syncMode, synced); - - return synced; -} - -//============================================================================== -/** The series every voice of one oscillator slot plays, rebuilt once per block. - - A slot's spectrum does not vary per voice: the waveform, the edited partials, the - Prism shape and the sync settings are all slot-wide. Deriving them once here rather - than inside each voice is what makes every control modulatable - shaping 128 - harmonics is cheap, the sync transform less so, and neither should run once per - voice per block. - - The sync transform is run for every harmonic the slot renders rather than for one - voice's Nyquist limit, which is what lets a single derivation serve voices at - different pitches: each voice's wavetable drops what its own pitch cannot carry. - - Voices publish nothing back; they compare getGeneration() and re-render their own - table when it moves, which keeps yup::WavetableOscillator's crossfade doing the - smoothing. Everything here runs on the audio thread at the top of a block, before - any voice reads it, so no publication handshake is needed. Nothing allocates. - - @see SynthOscillator, derivePrismSeries -*/ -class SynthOscillatorSlot -{ -public: - SynthOscillatorSlot() - { - spectrum.prepare (SynthExample::maxHarmonics); - resampler.prepare (SynthExample::maxHarmonics); - customSeries.resize (SynthExample::maxHarmonics); - shapedSeries.resize (SynthExample::maxHarmonics); - syncedSeries.resize (SynthExample::maxHarmonics); - - // The meter is only ever asked for a waveform, never played, and a frequency of - // zero keeps every harmonic whatever rate it was prepared at. - peakMeter.prepare (48000.0, SynthExample::maxHarmonics); - peakMeter.setFrequency (0.0); - peakMeter.setIncludeDC (true); - } - - /** Rebuilds the slot's series if anything it depends on moved. Audio thread. */ - void update (const SynthOscillatorValues& values, - const SynthOscillatorSettings& settings, - const SynthOscillatorResources& resources) noexcept - { - const auto partialsChanged = ! hasApplied - || applied.usesCustomSeries != values.usesCustomSeries - || applied.harmonicGeneration != values.harmonicGeneration - || applied.harmonicScale != values.harmonicScale; - - if (partialsChanged && values.usesCustomSeries) - settings.copyHarmonicsInto (customSeries, values.harmonicScale); - - const auto& source = values.usesCustomSeries ? customSeries : resources.getFrame (values.waveform); - const auto sourceChanged = partialsChanged || applied.waveform != values.waveform; - - const auto shapeChanged = applied.syncMode != values.syncMode - || applied.syncRatio != values.syncRatio - || applied.ridgeSpacing != values.ridgeSpacing - || applied.color != values.color - || applied.dispersion != values.dispersion - || applied.squeeze != values.squeeze - || applied.squash != values.squash - || applied.tilt != values.tilt - || applied.oddEven != values.oddEven - || applied.formant != values.formant - || applied.formantPosition != values.formantPosition - || applied.scatter != values.scatter; - - if (sourceChanged) - sourcePeak = measurePeak (source); - - if (sourceChanged || shapeChanged) - { - auto& derived = derivePrismSeries (spectrum, resampler, source, shapedSeries, syncedSeries, values); - - matchSourcePeak (derived); - published = &derived; - ++generation; - } - - applied = values; - hasApplied = true; - } - - /** Returns the series the voices of this slot should be playing. */ - const yup::FourierSeries& getSeries() const noexcept { return *published; } - - /** Bumped whenever getSeries() changed, so a voice knows to re-render its table. */ - int getGeneration() const noexcept { return generation; } - - /** The shaper, which the waveform display reuses for its preview. */ - const yup::PrismSpectrum& getSpectrum() const noexcept { return spectrum; } - -private: - /** Points the display samples the peak search walks. Twice the harmonic count - resolves the highest harmonic; this is four times it, for a little margin. */ - static constexpr int peakResolution = 512; - - /** Returns the largest absolute value one period of a series reaches. - - The series is rendered with the same inverse FFT the voices use rather than - summed harmonic by harmonic, which would cost a transcendental per harmonic per - point. One transform per slot per block is nothing beside the one each sounding - voice already pays. - */ - double measurePeak (const yup::FourierSeries& series) noexcept - { - peakMeter.setSeries (series); - peakMeter.render (false); - - auto peak = 0.0; - - for (int index = 0; index < peakResolution; ++index) - { - const auto phase = static_cast (index) / static_cast (peakResolution); - - peak = yup::jmax (peak, std::abs (static_cast (peakMeter.getValueAtPhase (phase)))); - } - - return peak; - } - - /** Rescales a derived series so its waveform peaks where the source's does. - - yup::PrismSpectrum preserves the coefficient sum, which bounds a peak from well - above - three times over for a sawtooth - and how close the waveform comes to - that bound depends on how aligned the harmonic phases are. Dispersion, scatter - and the sync reset all move exactly that, so without this they would swing the - output level as they are swept rather than only recolouring it. - */ - void matchSourcePeak (yup::FourierSeries& series) noexcept - { - const auto derivedPeak = measurePeak (series); - - if (derivedPeak <= 1.0e-9 || sourcePeak <= 1.0e-9) - return; - - const auto scale = sourcePeak / derivedPeak; - - for (int harmonic = 1; harmonic <= series.getNumHarmonics(); ++harmonic) - series.setHarmonic (harmonic, - series.getCosine (harmonic) * scale, - series.getSine (harmonic) * scale); - } - - yup::PrismSpectrum spectrum; - yup::SyncSpectralResampler resampler; - yup::WavetableOscillator peakMeter; - double sourcePeak = 0.0; - yup::FourierSeries customSeries; - yup::FourierSeries shapedSeries; - yup::FourierSeries syncedSeries; - const yup::FourierSeries* published = &shapedSeries; - SynthOscillatorValues applied; - int generation = 0; - bool hasApplied = false; -}; - -//============================================================================== -/** One of a voice's oscillators: a wavetable playing the slot's series, plus unison. - - prepare() allocates the backends; renderBlock() is allocation-free and only - re-renders a table when the slot's series moved, so editing a control is the only - thing that pays for an inverse FFT. - - Unison is built from bare yup::WavetableOscillator satellites playing the same - series as the center, detuned and panned around it. - - @see SynthOscillatorSettings, SynthOscillatorSlot -*/ -class SynthOscillator -{ -public: - /** Allocates every backend and attaches the shared slot. */ - void prepare (double newSampleRate, int maxBlockSize, const SynthOscillatorSlot& sharedSlot) - { - const auto sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; - - slot = &sharedSlot; - - wavetable.prepare (sampleRate, SynthExample::maxHarmonics); - - for (auto& satellite : satellites) - satellite.prepare (sampleRate, SynthExample::maxHarmonics); - - slotBuffer.assign (static_cast (yup::jmax (1, maxBlockSize)), 0.0f); - - appliedSeriesGeneration = -1; - } - - /** Restarts every backend, spreading the satellites so they do not stack in phase. */ - void reset (double initialPhase) noexcept - { - const auto phase = static_cast (initialPhase); - - wavetable.setPhase (phase); - - for (std::size_t index = 0; index < satellites.size(); ++index) - { - const auto offset = static_cast (index + 1) / static_cast (satellites.size() + 1); - - satellites[index].setPhase (phase + offset - std::floor (phase + offset)); - } - } - - /** Applies the pending changes and writes one stereo block. - - The buffers are overwritten rather than added to, so the caller does not have to - clear them first. - */ - void renderBlock (float* left, - float* right, - int numSamples, - const SynthOscillatorValues& values, - double frequency) noexcept - { - yup::FloatVectorOperations::clear (left, numSamples); - yup::FloatVectorOperations::clear (right, numSamples); - - // The table holds one period of the synced waveform, which for mirrored sync is - // two leader periods, so it is played at the fundamental the transform produced. - const auto played = frequency * yup::SyncSpectralResampler::getFundamentalScale (values.syncMode); - - const auto slotCount = yup::jlimit (1, SynthExample::maxUnisonVoices, values.unisonVoices); - const auto centreIndex = (slotCount - 1) / 2; - const auto slotGain = 1.0f / static_cast (slotCount); - - applySeries(); - - renderSatellite (wavetable, numSamples, detunedFrequency (played, values, centreIndex, slotCount)); - accumulateSlot (left, right, numSamples, slotOffset (centreIndex, slotCount) * values.unisonSpread, slotGain); - - for (int index = 0, satellite = 0; index < slotCount; ++index) - { - if (index == centreIndex) - continue; - - renderSatellite (satellites[static_cast (satellite++)], - numSamples, - detunedFrequency (played, values, index, slotCount)); - - accumulateSlot (left, right, numSamples, slotOffset (index, slotCount) * values.unisonSpread, slotGain); - } - } - -private: - //============================================================================== - /** Returns where a unison slot sits across the spread, from -1 to 1. */ - static float slotOffset (int slotIndex, int slotCount) noexcept - { - if (slotCount <= 1) - return 0.0f; - - return 2.0f * static_cast (slotIndex) / static_cast (slotCount - 1) - 1.0f; - } - - /** Combines the oscillator's own detune with the slot's share of the unison spread. */ - static double detunedFrequency (double frequency, const SynthOscillatorValues& values, int slotIndex, int slotCount) noexcept - { - const auto semitones = static_cast (values.unisonDetune) * static_cast (slotOffset (slotIndex, slotCount)); - - return frequency * std::pow (2.0, semitones / 12.0); - } - - /** Mixes the rendered slot into the stereo pair with an equal power pan. */ - void accumulateSlot (float* left, float* right, int numSamples, float pan, float gain) noexcept - { - const auto angle = (yup::jlimit (-1.0f, 1.0f, pan) + 1.0f) * 0.25f * yup::MathConstants::pi; - const auto leftGain = std::cos (angle) * gain; - const auto rightGain = std::sin (angle) * gain; - - for (int sample = 0; sample < numSamples; ++sample) - { - const auto value = slotBuffer[static_cast (sample)]; - - left[sample] += value * leftGain; - right[sample] += value * rightGain; - } - } - - /** Renders one unison slot; render() crossfades into a new table, which is what - keeps a modulated shape from stepping. */ - void renderSatellite (yup::WavetableOscillator& satellite, int numSamples, double frequency) noexcept - { - satellite.setFrequency (frequency); - - if (satellite.needsRender()) - satellite.render(); - - satellite.processBlock (slotBuffer.data(), numSamples); - } - - /** Hands the slot's series to every table when it moved since the last block. */ - void applySeries() noexcept - { - const auto generation = slot->getGeneration(); - - if (generation == appliedSeriesGeneration) - return; - - appliedSeriesGeneration = generation; - - const auto& series = slot->getSeries(); - - wavetable.setSeries (series); - - for (auto& satellite : satellites) - satellite.setSeries (series); - } - - //============================================================================== - yup::WavetableOscillator wavetable; - std::array, SynthExample::maxUnisonVoices - 1> satellites; - - const SynthOscillatorSlot* slot = nullptr; - std::vector slotBuffer; - int appliedSeriesGeneration = -1; -}; - -//============================================================================== -/** The single sound the example synthesiser plays. */ -class SynthSound : public yup::SynthesiserSound -{ -public: - bool appliesToNote (int) override { return true; } - bool appliesToChannel (int) override { return true; } -}; - -//============================================================================== -/** A polyphonic voice made of two independently configurable oscillators. */ -class SynthVoice : public yup::SynthesiserVoice -{ -public: - SynthVoice (const std::array& oscillatorSettings, - const SynthEnvelopeSettings& sharedEnvelopeSettings, - const std::array& sharedSlots) - : settings (oscillatorSettings) - , envelopeSettings (sharedEnvelopeSettings) - , slots (sharedSlots) - { - } - - /** Allocates every oscillator backend. Must run outside the audio callback. */ - void prepare (double sampleRate, int maxBlockSize) - { - for (std::size_t slot = 0; slot < oscillators.size(); ++slot) - oscillators[slot].prepare (sampleRate, maxBlockSize, slots[slot]); - - for (auto& level : levels) - level.reset (sampleRate, SynthExample::levelRampSeconds); - - envelope.prepare (sampleRate); - playbackRate = sampleRate; - hasPlayed = false; - preserveNote = false; - glideSeconds = 0.0; - pitch.setCurrentAndTargetValue (69.0); - bend.reset (sampleRate, SynthExample::levelRampSeconds); - bend.setCurrentAndTargetValue (0.0); - tailLength = yup::jlimit (2, yup::jmax (2, maxBlockSize), static_cast (sampleRate * 0.006)); - tailBuffer.setSize (2, tailLength); - tailScratch.setSize (2, tailLength); - tailPosition = tailLength; - clearCurrentNote(); - - const auto blockSize = static_cast (yup::jmax (1, maxBlockSize)); - - oscLeft.assign (blockSize, 0.0f); - oscRight.assign (blockSize, 0.0f); - mixLeft.assign (blockSize, 0.0f); - mixRight.assign (blockSize, 0.0f); - } - - //============================================================================== - bool canPlaySound (yup::SynthesiserSound* sound) override - { - return dynamic_cast (sound) != nullptr; - } - - /** Configures the next mono transition. Legato retains the envelope and phases. - Glide is measured in seconds and interpolates pitch in semitones. */ - void setTransition (double seconds, bool legato) noexcept - { - glideSeconds = yup::jlimit (0.0, 2.0, seconds); - preserveNote = legato && envelope.isActive(); - } - - void startNote (int midiNoteNumber, float velocity, yup::SynthesiserSound*, int currentPitchWheelPosition) override - { - const auto currentPitch = pitch.getCurrentValue(); - pitch.reset (playbackRate, glideSeconds); - pitch.setCurrentAndTargetValue (currentPitch); - if (glideSeconds > 0.0 && hasPlayed) - pitch.setTargetValue (static_cast (midiNoteNumber)); - else - pitch.setCurrentAndTargetValue (static_cast (midiNoteNumber)); - - pitchWheelMoved (currentPitchWheelPosition); - - if (! preserveNote) - { - velocityGain = yup::jlimit (0.0f, 1.0f, velocity); - for (auto& oscillator : oscillators) - oscillator.reset (0.0); - - envelope.setParameters (envelopeSettings.read()); - envelope.noteOn(); - } - - preserveNote = false; - hasPlayed = true; - glideSeconds = 0.0; - } - - void stopNote (float, bool allowTailOff) override - { - if (allowTailOff) - { - envelope.noteOff(); - return; - } - - if (! preserveNote) - { - if (envelope.isActive()) - { - tailScratch.clear(); - renderNextBlock (tailScratch, 0, tailLength); - for (int channel = 0; channel < 2; ++channel) - for (int sample = 0; sample < tailLength; ++sample) - { - const auto fade = 0.5 + 0.5 * std::cos (yup::MathConstants::pi - * sample / (tailLength - 1)); - tailBuffer.setSample (channel, sample, tailScratch.getSample (channel, sample) * static_cast (fade)); - } - tailPosition = 0; - } - envelope.noteOffImmediate(); - } - clearCurrentNote(); - } - - void pitchWheelMoved (int newPitchWheelValue) override - { - const auto normalized = (static_cast (newPitchWheelValue) - 8192.0) / 8192.0; - bend.setTargetValue (normalized * pitchWheelRangeSemitones); - } - - /** Includes a recycled voice's short continuation in the activity meter. */ - bool isSounding() const noexcept { return isVoiceActive() || tailPosition < tailLength; } - - void controllerMoved (int, int) override {} - - //============================================================================== - void renderNextBlock (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) override - { - if (! isSounding() || numSamples <= 0 || outputBuffer.getNumChannels() == 0) - return; - - for (std::size_t index = 0; index < blockValues.size(); ++index) - blockValues[index] = settings[index].read(); - envelope.setParameters (envelopeSettings.read()); - - for (int offset = 0; offset < numSamples;) - { - const auto controlBlock = pitch.isSmoothing() || bend.isSmoothing() ? 128 : numSamples; - const auto count = yup::jmin (numSamples - offset, static_cast (mixLeft.size()), controlBlock); - if (count <= 0) - return; - renderChunk (outputBuffer, startSample + offset, count); - offset += count; - } - } - -private: - //============================================================================== - void renderChunk (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) noexcept - { - yup::FloatVectorOperations::clear (mixLeft.data(), numSamples); - yup::FloatVectorOperations::clear (mixRight.data(), numSamples); - - if (isVoiceActive()) - { - const auto frequency = midiNoteToFrequency (pitch.skip (numSamples) + bend.skip (numSamples)); - for (int index = 0; index < SynthExample::oscillatorCount; ++index) - { - const auto slot = static_cast (index); - const auto& values = blockValues[slot]; - auto& level = levels[slot]; - const auto detuned = frequency * std::exp2 (values.octave + values.detuneSemitones / 12.0); - oscillators[slot].renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, detuned); - level.setTargetValue (values.level); - - for (int sample = 0; sample < numSamples; ++sample) - { - const auto gain = level.getNextValue(); - mixLeft[static_cast (sample)] += oscLeft[static_cast (sample)] * gain; - mixRight[static_cast (sample)] += oscRight[static_cast (sample)] * gain; - } - } - } - - for (int sample = 0; sample < numSamples; ++sample) - { - const auto gain = envelope.getNextValue() * velocityGain; - auto left = mixLeft[static_cast (sample)] * gain; - auto right = mixRight[static_cast (sample)] * gain; - if (tailPosition < tailLength) - { - left += tailBuffer.getSample (0, tailPosition); - right += tailBuffer.getSample (1, tailPosition++); - } - - if (outputBuffer.getNumChannels() == 1) - outputBuffer.addSample (0, startSample + sample, (left + right) * 0.5f); - else - { - outputBuffer.addSample (0, startSample + sample, left); - outputBuffer.addSample (1, startSample + sample, right); - } - } - - if (! envelope.isActive()) - clearCurrentNote(); - } - - static double midiNoteToFrequency (double midiNoteNumber) noexcept - { - return 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); - } - - //============================================================================== - static constexpr double pitchWheelRangeSemitones = 2.0; - - const std::array& settings; - const SynthEnvelopeSettings& envelopeSettings; - const std::array& slots; - - std::array oscillators; - std::array blockValues; - std::array, SynthExample::oscillatorCount> levels; - - SynthEnvelope envelope; - - std::vector oscLeft; - std::vector oscRight; - std::vector mixLeft; - std::vector mixRight; - - yup::SmoothedValue pitch; - yup::SmoothedValue bend; - yup::AudioBuffer tailBuffer; - yup::AudioBuffer tailScratch; - double playbackRate = 44100.0; - double glideSeconds = 0.0; - int tailLength = 0; - int tailPosition = 0; - bool preserveNote = false; - bool hasPlayed = false; - float velocityGain = 1.0f; -}; - -//============================================================================== -/** Keyboard allocation and envelope behavior. */ -enum class SynthPlayMode -{ - poly, /**< Eight voices with rendered release tails when recycled. */ - mono, /**< Last-note priority, retriggering the envelope on each note. */ - legato /**< Overlapping notes preserve phases and envelope; glide is optional. */ -}; - -/** Polyphonic synthesiser rendering the two oscillators of every voice. - MIDI and rendering methods belong to the audio thread; the UI edits atomic controls. */ -class HarmonicSynthEngine : public yup::Synthesiser -{ -public: - HarmonicSynthEngine() - { - addSound (new SynthSound()); - setMinimumRenderingSubdivisionSize (1, true); - settings[0].color = 0.2f; - settings[1].waveform = static_cast (yup::Waveform::triangle); - settings[1].syncMode = static_cast (yup::SyncMode::hard); - settings[1].octave = -1; - settings[1].level = 0.3f; - - for (int index = 0; index < SynthExample::voiceCount; ++index) - { - auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, envelopeSettings, oscillatorSlots)); - - addVoice (voice); - ownedVoices.add (voice); - } - } - - /** Prepares every voice. Must run outside the audio callback. */ - void prepare (double sampleRate, int maxBlockSize) - { - allNotesOff (0, false); - setCurrentPlaybackSampleRate (sampleRate); - activeVoices.store (0); - - for (int index = 0; index < ownedVoices.size(); ++index) - ownedVoices[index]->prepare (sampleRate, maxBlockSize); - } - - /** UI-facing performance controls, sampled at the next render boundary. */ - std::atomic playMode { static_cast (SynthPlayMode::poly) }; - std::atomic portamento { 0.12f }; - - /** Requests a release of all keys without taking the synthesiser lock on the UI thread. */ - void requestAllNotesOff() noexcept { releaseRequested.store (true); } - - /** Renders MIDI with sample-accurate note boundaries and publishes the voice meter. */ - void renderNextBlock (yup::AudioBuffer& output, const yup::MidiBuffer& midi, int start, int count) - { - const auto requestedMode = static_cast (playMode.load()); - const auto release = releaseRequested.exchange (false); - if (requestedMode != mode || release) - { - allNotesOff (0, true); - mode = requestedMode; - } - // Every voice of a slot plays the same spectrum, so it is derived once here - // rather than once per voice: that is what lets the Prism and sync controls be - // modulated without paying for the shaping eight times over. - for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) - oscillatorSlots[slot].update (settings[slot].read(), settings[slot], resources); - - yup::Synthesiser::renderNextBlock (output, midi, start, count); - int active = 0; - for (auto* voice : ownedVoices) - active += voice->isSounding() ? 1 : 0; - activeVoices.store (active); - } - - void noteOn (int channel, int note, float velocity) override - { - if (mode == SynthPlayMode::poly) - { - yup::Synthesiser::noteOn (channel, note, velocity); - return; - } - auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; - held = { ++noteOrder, velocity, true }; - playMonoNote (channel, note, velocity); - } - - void noteOff (int channel, int note, float velocity, bool tailOff) override - { - if (mode == SynthPlayMode::poly) - { - yup::Synthesiser::noteOff (channel, note, velocity, tailOff); - return; - } - auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; - held.down = false; - if (! sustain[static_cast (channel - 1)] || ! tailOff) - held.order = 0; - selectMonoNote (tailOff); - } - - void allNotesOff (int channel, bool tailOff) override - { - for (int index = 0; index < static_cast (heldNotes.size()); ++index) - if (channel <= 0 || index / 128 == channel - 1) - heldNotes[static_cast (index)] = {}; - for (int index = 0; index < 16; ++index) - if (channel <= 0 || index == channel - 1) - sustain[static_cast (index)] = false; - yup::Synthesiser::allNotesOff (channel, tailOff); - if (mode != SynthPlayMode::poly) - selectMonoNote (tailOff); - } - - void handleController (int channel, int controller, int value) override - { - if (mode != SynthPlayMode::poly && controller == 64) - { - sustain[static_cast (channel - 1)] = value >= 64; - if (value < 64) - { - for (int note = 0; note < 128; ++note) - { - auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; - if (! held.down) - held.order = 0; - } - selectMonoNote (true); - } - return; - } - yup::Synthesiser::handleController (channel, controller, value); - } - - /** Returns the settings edited by one of the user interface panels. */ - SynthOscillatorSettings& getOscillatorSettings (int oscillatorIndex) noexcept - { - return settings[static_cast (oscillatorIndex)]; - } - - /** Returns the settings edited by the envelope panel. */ - SynthEnvelopeSettings& getEnvelopeSettings() noexcept { return envelopeSettings; } - - /** Returns the shared waveform presets, which the waveform displays also read. */ - const SynthOscillatorResources& getResources() const noexcept { return resources; } - - /** Returns the shared series derivation of one oscillator slot. */ - SynthOscillatorSlot& getOscillatorSlot (int oscillatorIndex) noexcept - { - return oscillatorSlots[static_cast (oscillatorIndex)]; - } - - /** Returns the note of a sounding voice, or -1 when the synthesiser is silent. */ - int getCurrentlyPlayingNote() const noexcept - { - for (int index = 0; index < ownedVoices.size(); ++index) - if (ownedVoices[index] != nullptr && ownedVoices[index]->isVoiceActive()) - return ownedVoices[index]->getCurrentlyPlayingNote(); - - return -1; - } - - /** Returns how many voices are currently sounding. */ - int getNumActiveVoices() const noexcept { return activeVoices.load(); } - -private: - void playMonoNote (int channel, int note, float velocity) - { - auto* voice = ownedVoices[0].get(); - const auto overlapping = voice->isVoiceActive() && monoKeyActive; - voice->setTransition (overlapping ? portamento.load() : 0.0, - overlapping && mode == SynthPlayMode::legato); - startVoice (voice, getSound (0).get(), channel, note, velocity); - monoKeyActive = true; - } - - void selectMonoNote (bool tailOff) - { - int latest = -1; - for (int index = 0; index < static_cast (heldNotes.size()); ++index) - if (heldNotes[static_cast (index)].order != 0 - && (latest < 0 || heldNotes[static_cast (index)].order > heldNotes[static_cast (latest)].order)) - latest = index; - - auto* voice = ownedVoices[0].get(); - if (latest < 0) - { - if (monoKeyActive) - voice->stopNote (0.0f, tailOff); - monoKeyActive = false; - return; - } - const auto channel = latest / 128 + 1; - const auto note = latest % 128; - if (! voice->isVoiceActive() || voice->getCurrentlyPlayingNote() != note || ! voice->isPlayingChannel (channel)) - playMonoNote (channel, note, heldNotes[static_cast (latest)].velocity); - } - - struct HeldNote - { - yup::uint64 order = 0; - float velocity = 0.0f; - bool down = false; - }; - - std::array heldNotes {}; - std::array sustain {}; - yup::uint64 noteOrder = 0; - SynthPlayMode mode = SynthPlayMode::poly; - bool monoKeyActive = false; - std::atomic releaseRequested { false }; - std::atomic activeVoices { 0 }; - - SynthOscillatorResources resources; - std::array oscillatorSlots; - std::array settings; - SynthEnvelopeSettings envelopeSettings; - yup::ReferenceCountedArray ownedVoices; - - YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HarmonicSynthEngine) -}; - -//============================================================================== -/** The palette the synthesiser panels share. - - The example draws its own chrome rather than leaning on the theme, so that the - oscillator, envelope and display panels read as one instrument. -*/ -namespace SynthTheme -{ -inline constexpr yup::Color windowBackground { 0xff16191d }; -inline constexpr yup::Color panelBackground { 0xff21262c }; -inline constexpr yup::Color panelBorder { 0xff2e353d }; -inline constexpr yup::Color displayBackground { 0xff0e1114 }; -inline constexpr yup::Color accent { 0xff72ead2 }; -inline constexpr yup::Color accentDim { 0xff287f78 }; -inline constexpr yup::Color textPrimary { 0xffe6ebf0 }; -inline constexpr yup::Color textSecondary { 0xff8b96a0 }; - -constexpr float panelCorner = 6.0f; -} // namespace SynthTheme - -/** @internal Paints the rounded frame every panel of the instrument sits in. */ -inline void paintSynthPanel (yup::Graphics& g, yup::Rectangle bounds) -{ - g.setFillColor (SynthTheme::panelBackground); - g.fillRoundedRect (bounds, SynthTheme::panelCorner); - - g.setStrokeColor (SynthTheme::panelBorder); - g.setStrokeWidth (1.0f); - g.strokeRoundedRect (bounds.reduced (0.5f), SynthTheme::panelCorner); -} - -//============================================================================== -/** A rotary knob with its caption underneath. */ -class KnobControl : public yup::Component -{ -public: - KnobControl (const yup::String& caption, - double minimum, - double maximum, - double interval, - double defaultValue, - const yup::Font& font) - : slider (yup::Slider::RotaryVerticalDrag) - { - setOpaque (false); // the knob and its caption paint themselves, the row draws nothing - - slider.setRange (minimum, maximum, interval); - slider.setDefaultValue (defaultValue); - slider.setValue (defaultValue, yup::dontSendNotification); - slider.setColor (yup::Slider::Style::backgroundColorId, SynthTheme::displayBackground); - slider.setColor (yup::Slider::Style::trackColorId, SynthTheme::accent); - slider.setColor (yup::Slider::Style::thumbColorId, SynthTheme::textPrimary); - slider.setColor (yup::Slider::Style::thumbOverColorId, SynthTheme::accent); - slider.setColor (yup::Slider::Style::thumbDownColorId, SynthTheme::accent); - slider.onValueChanged = [this] (double value) - { - if (onChange != nullptr) - onChange (value); - }; - addAndMakeVisible (slider); - - label.setText (caption, yup::dontSendNotification); - label.setFont (font); - label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); - addAndMakeVisible (label); - } - - /** Called with the new knob value. */ - std::function onChange; - - yup::Slider& getSlider() noexcept { return slider; } - - void resized() override - { - auto bounds = getLocalBounds(); - - label.setBounds (bounds.removeFromBottom (captionHeight)); - - const auto size = yup::jmin (bounds.getWidth(), bounds.getHeight()); - - slider.setBounds (bounds.withSizeKeepingCenter (size, size)); - } - -private: - static constexpr float captionHeight = 13.0f; - - yup::Slider slider; - yup::Label label; -}; - -//============================================================================== -/** A combo box with its caption above it. */ -class ChoiceControl : public yup::Component -{ -public: - ChoiceControl (const yup::String& caption, const yup::StringArray& items, const yup::Font& font) - { - setOpaque (false); // the caption and combo box paint themselves, the row draws nothing - - label.setText (caption, yup::dontSendNotification); - label.setFont (font); - label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); - addAndMakeVisible (label); - - comboBox.addItemList (items, 1); - comboBox.setTextWhenNothingSelected ("-"); - comboBox.setColor (yup::ComboBox::Style::backgroundColorId, SynthTheme::displayBackground); - comboBox.setColor (yup::ComboBox::Style::textColorId, SynthTheme::textPrimary); - comboBox.setColor (yup::ComboBox::Style::borderColorId, SynthTheme::panelBorder); - comboBox.setColor (yup::ComboBox::Style::arrowColorId, SynthTheme::accent); - comboBox.onSelectedItemChanged = [this] - { - if (onChange != nullptr) - onChange (comboBox.getSelectedId()); - }; - addAndMakeVisible (comboBox); - } - - /** Called with the 1-based identifier of the newly selected item. */ - std::function onChange; - - yup::ComboBox& getComboBox() noexcept { return comboBox; } - - void resized() override - { - auto bounds = getLocalBounds(); - - label.setBounds (bounds.removeFromTop (captionHeight)); - comboBox.setBounds (bounds); - } - -private: - static constexpr float captionHeight = 13.0f; - - yup::Label label; - yup::ComboBox comboBox; -}; - -//============================================================================== -/** The waveform of one oscillator, either drawn or edited a partial at a time. - - In drawing mode the component reconstructs the series and shows one period of it. - In editing mode it shows the magnitude of each harmonic as a bar that can be - dragged, which is what actually defines the waveform the oscillator renders. - - Dragging writes straight into the settings, but the generation counter the audio - thread watches is only bumped by commitPendingEdits(), once per user interface - frame. Without that, one drag would queue an inverse FFT per mouse event on every - sounding voice. - - @see SynthOscillatorSettings -*/ -class WaveformEditor : public yup::Component -{ -public: - WaveformEditor (SynthOscillatorSettings& settingsToEdit, - const SynthOscillatorResources& sharedResources, - const SynthOscillatorSlot& sharedSlot) - : settings (settingsToEdit) - , resources (sharedResources) - , slot (sharedSlot) - { - resampler.prepare (SynthExample::maxHarmonics); - displaySeries.resize (SynthExample::maxHarmonics); - shapedSeries.resize (SynthExample::maxHarmonics); - syncedSeries.resize (SynthExample::maxHarmonics); - displaySamples.assign (displayResolution, 0.0f); - - refresh(); - } - - /** Called whenever a drag changed the partials, so the panel can follow along. */ - std::function onPartialsChanged; - - /** Switches between drawing the waveform and editing its partials. */ - void setEditingPartials (bool shouldEdit) - { - editingPartials = shouldEdit; - repaint(); - } - - bool isEditingPartials() const noexcept { return editingPartials; } - - /** Rereads the series from the settings, following a preset or randomize change. */ - void refresh() - { - const auto usesCustomSeries = settings.usesCustomSeries.load(); - - if (usesCustomSeries) - settings.copyHarmonicsInto (displaySeries, 1.0f); - else - displaySeries.copyFrom (resources.getFrame (static_cast (settings.waveform.load()))); - - reconstruct (displaySeries); - - // The reconstruction is measured here, on the message thread, so the audio thread - // never has to work out how loud an edited spectrum turned out to be. This is the - // source's peak, which is a different quantity from the drawn waveform's below. - if (usesCustomSeries) - { - const auto sourcePeak = measurePeak(); - - if (sourcePeak > 1.0e-6f) - settings.harmonicScale.store (1.0f / sourcePeak); - } - - // Shaping and sync have no inverse FFT behind them, so the preview follows every - // control live, derived exactly as the voices derive theirs once per block. - reconstruct (derivePrismSeries (slot.getSpectrum(), resampler, displaySeries, shapedSeries, syncedSeries, settings.read())); - - // Normalize whatever is actually drawn. The shaper preserves the coefficient sum - // rather than the peak, and a sum bounds a peak from well above - three times over - // for a sawtooth - so scaling the derived waveform by the source's peak would draw - // it clean outside the display. - const auto peak = measurePeak(); - - if (peak > 1.0e-6f) - { - const auto scale = 1.0f / peak; - - for (auto& sample : displaySamples) - sample *= scale; - } - - repaint(); - } - - /** Sums a series into the display buffer at the display's resolution. */ - void reconstruct (const yup::FourierSeries& series) noexcept - { - for (int index = 0; index < displayResolution; ++index) - { - const auto phase = static_cast (index) / static_cast (displayResolution - 1); - - displaySamples[static_cast (index)] = - evaluateFourierSeries (series, phase, SynthExample::displayHarmonics); - } - } - - /** Returns the largest magnitude currently in the display buffer. */ - float measurePeak() const noexcept - { - auto peak = 0.0f; - - for (auto sample : displaySamples) - peak = yup::jmax (peak, std::abs (sample)); - - return peak; - } - - /** Publishes a pending drag to the audio thread, coalescing a frame's worth of edits. */ - void commitPendingEdits() - { - if (! pendingEdit) - return; - - pendingEdit = false; - settings.harmonicGeneration.fetch_add (1); - } - - /** Drops the edited partials and returns to the selected waveform preset. */ - void revertToPreset() - { - settings.usesCustomSeries.store (false); - pendingEdit = true; - - refresh(); - - if (onPartialsChanged != nullptr) - onPartialsChanged(); - } - - //============================================================================== - void paint (yup::Graphics& g) override - { - const auto bounds = getLocalBounds(); - - g.setFillColor (SynthTheme::displayBackground); - g.fillRoundedRect (bounds, 4.0f); - - g.setStrokeColor (SynthTheme::panelBorder); - g.setStrokeWidth (1.0f); - g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); - - if (editingPartials) - paintPartials (g, bounds.reduced (contentInset)); - else - paintWaveform (g, bounds.reduced (contentInset)); - } - - void mouseDown (const yup::MouseEvent& event) override { applyEdit (event); } - - void mouseDrag (const yup::MouseEvent& event) override { applyEdit (event); } - -private: - //============================================================================== - /** Draws one period of the reconstructed series. */ - void paintWaveform (yup::Graphics& g, yup::Rectangle bounds) - { - g.setStrokeColor (SynthTheme::panelBorder); - g.setStrokeWidth (1.0f); - g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); - - path.clear(); - path.reserveSpace (displayResolution); - - for (int index = 0; index < displayResolution; ++index) - { - const auto x = bounds.getX() + bounds.getWidth() * static_cast (index) - / static_cast (displayResolution - 1); - - const auto y = bounds.getCenterY() - displaySamples[static_cast (index)] * bounds.getHeight() * 0.45f; - - if (index == 0) - path.moveTo (x, y); - else - path.lineTo (x, y); - } - - g.setStrokeColor (SynthTheme::accent.withAlpha (0.35f)); - g.setStrokeWidth (4.0f); - g.setFeather (6.0f); - g.strokePath (path); - - g.setFeather (0.0f); - g.setStrokeColor (SynthTheme::accent); - g.setStrokeWidth (1.5f); - g.strokePath (path); - } - - /** Draws the editable magnitude of every harmonic. */ - void paintPartials (yup::Graphics& g, yup::Rectangle bounds) - { - const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); - - for (int index = 0; index < SynthExample::editableHarmonics; ++index) - { - const auto magnitude = yup::jlimit (0.0f, 1.0f, static_cast (displaySeries.getMagnitude (index + 1))); - const auto height = yup::jmax (1.0f, magnitude * bounds.getHeight()); - const auto x = bounds.getX() + barWidth * static_cast (index); - - const yup::Rectangle bar { x + barGap, bounds.getBottom() - height, yup::jmax (1.0f, barWidth - barGap * 2.0f), height }; - - g.setFillColor (magnitude > 0.0f ? SynthTheme::accent : SynthTheme::panelBorder); - g.fillRect (bar); - } - } - - //============================================================================== - /** Turns a mouse position into the magnitude of one harmonic. */ - void applyEdit (const yup::MouseEvent& event) - { - if (! editingPartials) - return; - - const auto bounds = getLocalBounds().reduced (contentInset); - - if (bounds.getWidth() <= 0.0f || bounds.getHeight() <= 0.0f) - return; - - // Editing a preset copies its partials in first, so the drag starts from the - // shape that is on screen instead of from silence. - if (! settings.usesCustomSeries.load()) - { - settings.seedHarmonicsFrom (displaySeries); - settings.usesCustomSeries.store (true); - } - - const auto position = event.getPosition(); - const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); - const auto index = yup::jlimit (0, - SynthExample::editableHarmonics - 1, - static_cast ((position.getX() - bounds.getX()) / barWidth)); - - const auto magnitude = yup::jlimit (0.0f, 1.0f, (bounds.getBottom() - position.getY()) / bounds.getHeight()); - - settings.harmonics[static_cast (index)].store (magnitude); - pendingEdit = true; - - refresh(); - - if (onPartialsChanged != nullptr) - onPartialsChanged(); - } - - //============================================================================== - static constexpr int displayResolution = 256; - static constexpr float contentInset = 6.0f; - static constexpr float barGap = 1.0f; - - SynthOscillatorSettings& settings; - const SynthOscillatorResources& resources; - const SynthOscillatorSlot& slot; - - yup::SyncSpectralResampler resampler; - yup::FourierSeries displaySeries; - yup::FourierSeries shapedSeries; - yup::FourierSeries syncedSeries; - std::vector displaySamples; - yup::Path path; - - bool editingPartials = false; - bool pendingEdit = false; -}; - -//============================================================================== -/** Draws the most recent block of rendered audio as a waveform. */ -class Oscilloscope : public yup::Component -{ -public: - Oscilloscope() - : Component ("Oscilloscope") - { - } - - /** Copies the samples to display. Called from the message thread. */ - void setRenderData (const std::vector& data) - { - renderData = data; - } - - void paint (yup::Graphics& g) override - { - const auto bounds = getLocalBounds(); - - g.setFillColor (SynthTheme::displayBackground); - g.fillRoundedRect (bounds, 4.0f); - - g.setStrokeColor (SynthTheme::panelBorder); - g.setStrokeWidth (1.0f); - g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); - g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); - - if (renderData.empty()) - return; - - const auto pointCount = yup::jmin (512, static_cast (renderData.size())); - const auto xSize = bounds.getWidth() / static_cast (yup::jmax (1, pointCount - 1)); - - path.clear(); - path.reserveSpace (pointCount); - path.moveTo (bounds.getX(), bounds.getCenterY() - renderData[0] * bounds.getHeight() * 0.45f); - - for (int i = 1; i < pointCount; ++i) - { - const auto sample = static_cast (i) * (renderData.size() - 1) / static_cast (pointCount - 1); - path.lineTo (bounds.getX() + static_cast (i) * xSize, - bounds.getCenterY() - renderData[sample] * bounds.getHeight() * 0.45f); - } - - filledPath = path.createStrokePolygon (4.0f); - - g.setFillColor (SynthTheme::accent.withAlpha (0.5f)); - g.setFeather (8.0f); - g.fillPath (filledPath); - - g.setFeather (4.0f); - g.fillPath (filledPath); - - g.setFeather (0.0f); - g.setStrokeColor (SynthTheme::accent); - g.setStrokeWidth (1.5f); - g.strokePath (path); - } - -private: - std::vector renderData; - yup::Path path; - yup::Path filledPath; -}; - -//============================================================================== -/** @internal Spreads controls evenly across a row. */ -inline void layoutControlsInRow (yup::Rectangle area, const std::vector& controls) -{ - if (controls.empty()) - return; - - const auto width = area.getWidth() / static_cast (controls.size()); - - for (auto* control : controls) - control->setBounds (area.removeFromLeft (width).reduced (3.0f, 0.0f)); -} - -//============================================================================== -/** Draws the shape the amplitude envelope traces, with a point at every breakpoint. */ -class EnvelopeDisplay : public yup::Component -{ -public: - /** Updates the drawn shape. Called from the message thread. */ - void setValues (const SynthEnvelopeValues& newValues) - { - values = newValues; - repaint(); - } - - void paint (yup::Graphics& g) override - { - const auto bounds = getLocalBounds(); - - g.setFillColor (SynthTheme::displayBackground); - g.fillRoundedRect (bounds, 4.0f); - - g.setStrokeColor (SynthTheme::panelBorder); - g.setStrokeWidth (1.0f); - g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); - - const auto area = bounds.reduced (8.0f); - - if (area.getWidth() <= 0.0f || area.getHeight() <= 0.0f) - return; - - // The sustain stage has no duration of its own, so it is given a fixed share of - // the width and the timed stages share what is left. - const auto totalSeconds = yup::jmax (1.0e-4f, values.delay + values.attack + values.hold + values.decay + values.release); - const auto timedWidth = area.getWidth() * (1.0f - sustainShare); - const auto secondsToPixels = timedWidth / totalSeconds; - - const auto levelToY = [area] (float level) - { - return area.getBottom() - yup::jlimit (0.0f, 1.0f, level) * area.getHeight(); - }; - - constexpr int numPoints = 7; - - const yup::Point points[numPoints] = { - { area.getX(), levelToY (0.0f) }, - { area.getX() + values.delay * secondsToPixels, levelToY (0.0f) }, - { area.getX() + (values.delay + values.attack) * secondsToPixels, levelToY (1.0f) }, - { area.getX() + (values.delay + values.attack + values.hold) * secondsToPixels, levelToY (1.0f) }, - { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels, levelToY (values.sustain) }, - { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels + area.getWidth() * sustainShare, levelToY (values.sustain) }, - { area.getRight(), levelToY (0.0f) } - }; - - path.clear(); - path.reserveSpace (numPoints + 2); - path.moveTo (points[0]); +#include "audio/SynthSettings.h" +#include "audio/SynthEngine.h" +#include "audio/SynthPanels.h" +#include "audio/SynthModulationPage.h" - for (int index = 1; index < numPoints; ++index) - path.lineTo (points[index]); - - g.setStrokeColor (SynthTheme::accent); - g.setStrokeWidth (1.5f); - g.strokePath (path); - - for (int index = 1; index < numPoints - 1; ++index) - { - g.setFillColor (SynthTheme::accent); - g.fillEllipse (yup::Rectangle (points[index].getX() - pointRadius, - points[index].getY() - pointRadius, - pointRadius * 2.0f, - pointRadius * 2.0f)); - } - } - -private: - static constexpr float sustainShare = 0.22f; - static constexpr float pointRadius = 3.0f; - - SynthEnvelopeValues values; - yup::Path path; -}; - -//============================================================================== -/** The editing surface of the amplitude envelope. - - @see SynthEnvelopeSettings -*/ -class SynthEnvelopePanel : public yup::Component -{ -public: - SynthEnvelopePanel (SynthEnvelopeSettings& settingsToEdit, const yup::Font& font) - : settings (settingsToEdit) - , delayKnob ("DELAY", 0.0, 2.0, 0.001, 0.0, font) - , attackKnob ("ATTACK", 0.001, 4.0, 0.001, 0.005, font) - , holdKnob ("HOLD", 0.0, 2.0, 0.001, 0.0, font) - , decayKnob ("DECAY", 0.001, 4.0, 0.001, 0.35, font) - , sustainKnob ("SUSTAIN", 0.0, 1.0, 0.001, 0.7, font) - , releaseKnob ("RELEASE", 0.001, 8.0, 0.001, 0.35, font) - { - titleLabel.setText ("ENVELOPE", yup::dontSendNotification); - titleLabel.setFont (font.withHeight (12.0f)); - titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); - addAndMakeVisible (titleLabel); - - addAndMakeVisible (display); - - for (auto* knob : { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }) - addAndMakeVisible (*knob); - - delayKnob.onChange = [this] (double value) { settings.delay = static_cast (value); refreshDisplay(); }; - attackKnob.onChange = [this] (double value) { settings.attack = static_cast (value); refreshDisplay(); }; - holdKnob.onChange = [this] (double value) { settings.hold = static_cast (value); refreshDisplay(); }; - decayKnob.onChange = [this] (double value) { settings.decay = static_cast (value); refreshDisplay(); }; - sustainKnob.onChange = [this] (double value) { settings.sustain = static_cast (value); refreshDisplay(); }; - releaseKnob.onChange = [this] (double value) { settings.release = static_cast (value); refreshDisplay(); }; - - refresh(); - } - - /** Reads the settings back into the knobs and the drawn shape. */ - void refresh() - { - delayKnob.getSlider().setValue (settings.delay.load(), yup::dontSendNotification); - attackKnob.getSlider().setValue (settings.attack.load(), yup::dontSendNotification); - holdKnob.getSlider().setValue (settings.hold.load(), yup::dontSendNotification); - decayKnob.getSlider().setValue (settings.decay.load(), yup::dontSendNotification); - sustainKnob.getSlider().setValue (settings.sustain.load(), yup::dontSendNotification); - releaseKnob.getSlider().setValue (settings.release.load(), yup::dontSendNotification); - - refreshDisplay(); - } - - void resized() override - { - auto bounds = getLocalBounds().reduced (panelInset); - - titleLabel.setBounds (bounds.removeFromTop (headerHeight)); - bounds.removeFromTop (spacing); - - auto knobArea = bounds.removeFromBottom (knobRowHeight); - bounds.removeFromBottom (spacing); - - display.setBounds (bounds); - - layoutControlsInRow (knobArea, { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }); - } - - void paint (yup::Graphics& g) override - { - paintSynthPanel (g, getLocalBounds()); - } - -private: - static constexpr float panelInset = 8.0f; - static constexpr float headerHeight = 16.0f; - static constexpr float knobRowHeight = 58.0f; - static constexpr float spacing = 6.0f; - - void refreshDisplay() { display.setValues (settings.read()); } - - SynthEnvelopeSettings& settings; - - yup::Label titleLabel; - EnvelopeDisplay display; - - KnobControl delayKnob; - KnobControl attackKnob; - KnobControl holdKnob; - KnobControl decayKnob; - KnobControl sustainKnob; - KnobControl releaseKnob; -}; +#include +#include +#include +#include +#include +#include //============================================================================== -/** The editing surface of one oscillator, writing straight into the voice settings. - - Every widget is wired to a single atomic setting, and refresh() copies the - settings back into the widgets for changes coming from somewhere else, such as - the randomize button. - - @see SynthOscillatorSettings, WaveformEditor -*/ -class SynthOscillatorPanel : public yup::Component +/** A page of the instrument: paints nothing and hands clicks on its background back. */ +class SynthPage : public yup::Component { public: - SynthOscillatorPanel (const yup::String& panelTitle, - SynthOscillatorSettings& settingsToEdit, - const SynthOscillatorResources& resources, - const SynthOscillatorSlot& sharedSlot, - const yup::Font& font) - : settings (settingsToEdit) - , editor (settingsToEdit, resources, sharedSlot) - , waveformChoice ("WAVEFORM", getSynthWaveformNames(), font) - , syncModeChoice ("SYNC", getSynthSyncModeNames(), font) - , levelKnob ("LEVEL", 0.0, 1.0, 0.001, 0.5, font) - , octaveKnob ("OCTAVE", -3.0, 3.0, 1.0, 0.0, font) - , detuneKnob ("CENTS", -100.0, 100.0, 1.0, 0.0, font) - , ridgesKnob ("RIDGES", 0.25, 8.0, 0.01, 1.5, font) - , colorKnob ("COLOR", 0.0, 1.0, 0.001, 0.0, font) - , dispersionKnob ("DISPERSION", 0.01, 0.99, 0.001, 0.5, font) - , squeezeKnob ("SQUEEZE", 0.0, 0.5, 0.001, 0.0, font) - , squashKnob ("SQUASH", 0.1, 4.0, 0.01, 1.0, font) - , tiltKnob ("TILT", -4.0, 4.0, 0.01, 0.0, font) - , oddEvenKnob ("ODD/EVEN", 0.0, 1.0, 0.001, 0.5, font) - , formantKnob ("FORMANT", -4.0, 4.0, 0.01, 0.0, font) - , formantPositionKnob ("F.POS", 0.0, 7.0, 0.01, 2.0, font) - , scatterKnob ("SCATTER", 0.0, 1.0, 0.001, 0.0, font) - , syncRatioKnob ("SYNC RATIO", 1.0, 8.0, 0.01, 1.5, font) - , unisonKnob ("UNISON", 1.0, static_cast (SynthExample::maxUnisonVoices), 1.0, 1.0, font) - , unisonDetuneKnob ("U.DETUNE", 0.0, 1.0, 0.001, 0.2, font) - , spreadKnob ("SPREAD", 0.0, 1.0, 0.001, 0.6, font) - { - titleLabel.setText (panelTitle, yup::dontSendNotification); - titleLabel.setFont (font.withHeight (12.0f)); - titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); - addAndMakeVisible (titleLabel); - - partialsButton.setButtonText ("PARTIALS"); - partialsButton.setColor (yup::ToggleButton::Style::backgroundColorId, SynthTheme::displayBackground); - partialsButton.setColor (yup::ToggleButton::Style::backgroundToggledColorId, SynthTheme::accentDim); - partialsButton.setColor (yup::ToggleButton::Style::textColorId, SynthTheme::textSecondary); - partialsButton.setColor (yup::ToggleButton::Style::textToggledColorId, SynthTheme::textPrimary); - partialsButton.setColor (yup::ToggleButton::Style::borderColorId, SynthTheme::panelBorder); - partialsButton.setColor (yup::ToggleButton::Style::borderToggledColorId, SynthTheme::accent); - partialsButton.onClick = [this] { editor.setEditingPartials (partialsButton.getToggleState()); }; - addAndMakeVisible (partialsButton); - - resetButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::displayBackground); - resetButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textSecondary); - resetButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); - resetButton.onClick = [this] { editor.revertToPreset(); }; - addAndMakeVisible (resetButton); - - addAndMakeVisible (editor); - - for (auto* choice : { &waveformChoice, &syncModeChoice }) - addAndMakeVisible (*choice); - - for (auto* knob : { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob, - &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, - &formantPositionKnob, &scatterKnob, &syncRatioKnob, - &unisonKnob, &unisonDetuneKnob, &spreadKnob }) - addAndMakeVisible (*knob); - - // Picking a preset drops any edited partials, otherwise the oscillator would keep - // playing the edited shape while the combo box claims something else. - waveformChoice.onChange = [this] (int id) - { - settings.waveform = id - 1; - editor.revertToPreset(); - }; - - syncModeChoice.onChange = [this] (int id) - { - settings.syncMode = id - 1; - updateSyncAvailability(); - editor.refresh(); - }; + /** Called when the page itself, not one of its children, is clicked. */ + std::function onMouseDown; - levelKnob.onChange = [this] (double value) { settings.level = static_cast (value); }; - octaveKnob.onChange = [this] (double value) { settings.octave = static_cast (value); }; - detuneKnob.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value * 0.01); }; - ridgesKnob.onChange = [this] (double value) { settings.ridgeSpacing = static_cast (value); editor.refresh(); }; - colorKnob.onChange = [this] (double value) { settings.color = static_cast (value); editor.refresh(); }; - dispersionKnob.onChange = [this] (double value) { settings.dispersion = static_cast (value); editor.refresh(); }; - squeezeKnob.onChange = [this] (double value) { settings.squeeze = static_cast (value); editor.refresh(); }; - squashKnob.onChange = [this] (double value) { settings.squash = static_cast (value); editor.refresh(); }; - tiltKnob.onChange = [this] (double value) { settings.tilt = static_cast (value); editor.refresh(); }; - oddEvenKnob.onChange = [this] (double value) { settings.oddEven = static_cast (value); editor.refresh(); }; - formantKnob.onChange = [this] (double value) { settings.formant = static_cast (value); editor.refresh(); }; - formantPositionKnob.onChange = [this] (double value) { settings.formantPosition = static_cast (value); editor.refresh(); }; - scatterKnob.onChange = [this] (double value) { settings.scatter = static_cast (value); editor.refresh(); }; - syncRatioKnob.onChange = [this] (double value) { settings.syncRatio = static_cast (value); editor.refresh(); }; - unisonKnob.onChange = [this] (double value) { settings.unisonVoices = static_cast (value); }; - unisonDetuneKnob.onChange = [this] (double value) { settings.unisonDetune = static_cast (value); }; - spreadKnob.onChange = [this] (double value) { settings.unisonSpread = static_cast (value); }; - - refresh(); - } - - /** Reads the settings back into the widgets. */ - void refresh() - { - waveformChoice.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); - syncModeChoice.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); - - levelKnob.getSlider().setValue (settings.level.load(), yup::dontSendNotification); - octaveKnob.getSlider().setValue (settings.octave.load(), yup::dontSendNotification); - detuneKnob.getSlider().setValue (settings.detuneSemitones.load() * 100.0, yup::dontSendNotification); - ridgesKnob.getSlider().setValue (settings.ridgeSpacing.load(), yup::dontSendNotification); - colorKnob.getSlider().setValue (settings.color.load(), yup::dontSendNotification); - dispersionKnob.getSlider().setValue (settings.dispersion.load(), yup::dontSendNotification); - squeezeKnob.getSlider().setValue (settings.squeeze.load(), yup::dontSendNotification); - squashKnob.getSlider().setValue (settings.squash.load(), yup::dontSendNotification); - tiltKnob.getSlider().setValue (settings.tilt.load(), yup::dontSendNotification); - oddEvenKnob.getSlider().setValue (settings.oddEven.load(), yup::dontSendNotification); - formantKnob.getSlider().setValue (settings.formant.load(), yup::dontSendNotification); - formantPositionKnob.getSlider().setValue (settings.formantPosition.load(), yup::dontSendNotification); - scatterKnob.getSlider().setValue (settings.scatter.load(), yup::dontSendNotification); - syncRatioKnob.getSlider().setValue (settings.syncRatio.load(), yup::dontSendNotification); - unisonKnob.getSlider().setValue (settings.unisonVoices.load(), yup::dontSendNotification); - unisonDetuneKnob.getSlider().setValue (settings.unisonDetune.load(), yup::dontSendNotification); - spreadKnob.getSlider().setValue (settings.unisonSpread.load(), yup::dontSendNotification); - - updateSyncAvailability(); - - editor.refresh(); - } - - /** Publishes a frame's worth of partial edits to the audio thread. */ - void commitPendingEdits() { editor.commitPendingEdits(); } - - void resized() override - { - auto bounds = getLocalBounds().reduced (panelInset); - - auto header = bounds.removeFromTop (headerHeight); - resetButton.setBounds (header.removeFromRight (buttonWidth)); - header.removeFromRight (spacing); - partialsButton.setBounds (header.removeFromRight (buttonWidth)); - titleLabel.setBounds (header); - - bounds.removeFromTop (spacing); - - auto knobArea = bounds.removeFromBottom (knobRowHeight * 3.0f + spacing * 2.0f); - bounds.removeFromBottom (spacing); - - auto choiceArea = bounds.removeFromBottom (choiceRowHeight); - bounds.removeFromBottom (spacing); - - editor.setBounds (bounds); - - layoutControlsInRow (choiceArea, { &waveformChoice, &syncModeChoice }); - - layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), - { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob }); - - knobArea.removeFromTop (spacing); - - layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), - { &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, &formantPositionKnob }); - - knobArea.removeFromTop (spacing); - - layoutControlsInRow (knobArea, - { &scatterKnob, &syncRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }); - } - - void paint (yup::Graphics& g) override - { - paintSynthPanel (g, getLocalBounds()); - } - -private: - //============================================================================== - /** Greys out the ratio knob while no sync mode reads it. */ - void updateSyncAvailability() + void mouseDown (const yup::MouseEvent&) override { - syncRatioKnob.setEnabled (static_cast (settings.syncMode.load()) != yup::SyncMode::none); + if (onMouseDown != nullptr) + onMouseDown(); } - - //============================================================================== - static constexpr float panelInset = 8.0f; - static constexpr float headerHeight = 18.0f; - static constexpr float choiceRowHeight = 36.0f; - static constexpr float knobRowHeight = 58.0f; - static constexpr float buttonWidth = 68.0f; - static constexpr float spacing = 6.0f; - - SynthOscillatorSettings& settings; - - yup::Label titleLabel; - yup::ToggleButton partialsButton; - yup::TextButton resetButton { "RESET" }; - WaveformEditor editor; - - ChoiceControl waveformChoice; - ChoiceControl syncModeChoice; - - KnobControl levelKnob; - KnobControl octaveKnob; - KnobControl detuneKnob; - KnobControl ridgesKnob; - KnobControl colorKnob; - KnobControl dispersionKnob; - KnobControl squeezeKnob; - KnobControl squashKnob; - KnobControl tiltKnob; - KnobControl oddEvenKnob; - KnobControl formantKnob; - KnobControl formantPositionKnob; - KnobControl scatterKnob; - KnobControl syncRatioKnob; - KnobControl unisonKnob; - KnobControl unisonDetuneKnob; - KnobControl spreadKnob; }; //============================================================================== @@ -2201,7 +71,7 @@ class AudioExample keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyColorId, yup::Color (0xff191d21)); keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyPressedColorId, SynthTheme::accentDim); keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::keyOutlineColorId, SynthTheme::panelBorder); - addAndMakeVisible (keyboardComponent); + mainPage.addAndMakeVisible (keyboardComponent); keyboardComponent.setVisible (false); const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); @@ -2218,7 +88,7 @@ class AudioExample loadLabel.setFont (font.withHeight (11.0f)); loadLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); - addAndMakeVisible (loadLabel); + mainPage.addAndMakeVisible (loadLabel); voiceLabel.setText ("", yup::dontSendNotification); voiceLabel.setFont (font.withHeight (11.0f)); @@ -2231,15 +101,55 @@ class AudioExample yup::String ("OSC ") + yup::String (index + 1), synth.getOscillatorSettings (index), synth.getResources(), - synth.getOscillatorSlot (index), font.withHeight (10.0f)); - addAndMakeVisible (*panel); + mainPage.addAndMakeVisible (*panel); oscillatorPanels[static_cast (index)] = std::move (panel); } - envelopePanel = std::make_unique (synth.getEnvelopeSettings(), font.withHeight (10.0f)); - addAndMakeVisible (*envelopePanel); + filterPanel = std::make_unique (synth.getFilterSettings(), font.withHeight (10.0f)); + mainPage.addAndMakeVisible (*filterPanel); + + for (int index = 0; index < SynthExample::envelopeCount; ++index) + { + auto& panel = envelopePanels[static_cast (index)]; + panel = std::make_unique (yup::String ("ENV ") + yup::String (index + 1), + synth.getEnvelopeSettings (index), + font.withHeight (10.0f)); + mainPage.addAndMakeVisible (*panel); + } + + for (int index = 0; index < SynthExample::lfoCount; ++index) + { + auto& panel = lfoPanels[static_cast (index)]; + panel = std::make_unique (yup::String ("LFO ") + yup::String (index + 1), + synth.getLFOSettings (index), + font.withHeight (10.0f)); + mainPage.addAndMakeVisible (*panel); + } + + modulationPage = std::make_unique (synth.getModulationSettings(), font.withHeight (10.0f)); + addChildComponent (*modulationPage); + + mainPage.onMouseDown = [this] { takeKeyboardFocus(); }; + addAndMakeVisible (mainPage); + + for (auto* button : { &mainPageButton, &modulationPageButton }) + { + button->setColor (yup::ToggleButton::Style::backgroundColorId, SynthTheme::panelBackground); + button->setColor (yup::ToggleButton::Style::backgroundToggledColorId, SynthTheme::accentDim); + button->setColor (yup::ToggleButton::Style::textColorId, SynthTheme::textSecondary); + button->setColor (yup::ToggleButton::Style::textToggledColorId, SynthTheme::textPrimary); + button->setColor (yup::ToggleButton::Style::borderColorId, SynthTheme::panelBorder); + button->setColor (yup::ToggleButton::Style::borderToggledColorId, SynthTheme::accent); + addAndMakeVisible (*button); + } + + mainPageButton.setButtonText ("MAIN"); + modulationPageButton.setButtonText ("MOD"); + mainPageButton.onClick = [this] { showModulationPage (false); }; + modulationPageButton.onClick = [this] { showModulationPage (true); }; + showModulationPage (false); randomizeButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::panelBackground); randomizeButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textPrimary); @@ -2258,6 +168,7 @@ class AudioExample addAndMakeVisible (clearButton); volumeKnob = std::make_unique ("VOLUME", 0.0, 1.0, 0.001, 0.5, font.withHeight (10.0f)); + volumeKnob->formatValue = SynthFormat::percent; volumeKnob->onChange = [this] (double value) { masterVolume = static_cast (value); }; addAndMakeVisible (*volumeKnob); @@ -2268,11 +179,12 @@ class AudioExample synth.playMode = id - 1; glideKnob->setEnabled (id != 1); }; - addAndMakeVisible (*modeChoice); - glideKnob = std::make_unique ("GLIDE / ms", 0.0, 2000.0, 1.0, 120.0, font.withHeight (10.0f)); + mainPage.addAndMakeVisible (*modeChoice); + glideKnob = std::make_unique ("GLIDE", 0.0, 2000.0, 1.0, 120.0, font.withHeight (10.0f)); + glideKnob->formatValue = [] (double value) { return SynthFormat::milliseconds (value * 0.001); }; glideKnob->onChange = [this] (double value) { synth.portamento = static_cast (value * 0.001); }; glideKnob->setEnabled (false); - addAndMakeVisible (*glideKnob); + mainPage.addAndMakeVisible (*glideKnob); midiDevices = yup::MidiInput::getAvailableDevices(); yup::StringArray midiNames { "No MIDI input" }; for (const auto& device : midiDevices) @@ -2286,9 +198,9 @@ class AudioExample if (isVisible()) openMidiInput(); }; - addAndMakeVisible (*midiChoice); + mainPage.addAndMakeVisible (*midiChoice); renderData.resize (SynthExample::maxBlockSize); - addAndMakeVisible (oscilloscope); + mainPage.addAndMakeVisible (oscilloscope); } ~AudioExample() override @@ -2312,14 +224,28 @@ class AudioExample randomizeButton.setBounds (header.removeFromRight (110.0f).reduced (0.0f, 14.0f)); header.removeFromRight (spacing * 2.0f); voiceLabel.setBounds (header.removeFromRight (110.0f)); + header.removeFromRight (spacing); + modulationPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, 14.0f)); + header.removeFromRight (spacing); + mainPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, 14.0f)); titleLabel.setBounds (header.removeFromTop (header.getHeight() * 0.5f)); subtitleLabel.setBounds (header); bounds.removeFromTop (spacing); - keyboardComponent.setBounds (bounds.removeFromBottom (proportionOfHeight (0.19f))); + mainPage.setBounds (bounds); + modulationPage->setBounds (bounds); + + layoutMainPage(); + } + + /** Lays the main page out from the bottom up: keyboard, performance row, LFOs, shaping, oscillators. */ + void layoutMainPage() + { + auto bounds = mainPage.getLocalBounds(); + keyboardComponent.setBounds (bounds.removeFromBottom (mainPage.proportionOfHeight (0.19f))); bounds.removeFromBottom (spacing); auto performance = bounds.removeFromBottom (58.0f); @@ -2331,10 +257,24 @@ class AudioExample loadLabel.setBounds (performance); bounds.removeFromBottom (spacing); - auto modulation = bounds.removeFromBottom (yup::jmin (150.0f, bounds.getHeight() * 0.32f)); - envelopePanel->setBounds (modulation.removeFromLeft (modulation.getWidth() * 0.62f)); - modulation.removeFromLeft (spacing); - oscilloscope.setBounds (modulation); + const auto rowHeight = yup::jmin (150.0f, bounds.getHeight() * 0.26f); + + auto lfoRow = bounds.removeFromBottom (rowHeight); + const auto lfoWidth = (lfoRow.getWidth() - spacing * 2.0f) * 0.3f; + lfoPanels[0]->setBounds (lfoRow.removeFromLeft (lfoWidth)); + lfoRow.removeFromLeft (spacing); + lfoPanels[1]->setBounds (lfoRow.removeFromLeft (lfoWidth)); + lfoRow.removeFromLeft (spacing); + oscilloscope.setBounds (lfoRow); + bounds.removeFromBottom (spacing); + + auto shapingRow = bounds.removeFromBottom (rowHeight); + const auto shapingWidth = (shapingRow.getWidth() - spacing * 2.0f) / 3.0f; + filterPanel->setBounds (shapingRow.removeFromLeft (shapingWidth)); + shapingRow.removeFromLeft (spacing); + envelopePanels[0]->setBounds (shapingRow.removeFromLeft (shapingWidth)); + shapingRow.removeFromLeft (spacing); + envelopePanels[1]->setBounds (shapingRow); bounds.removeFromBottom (spacing); const auto panelWidth = (bounds.getWidth() - spacing) / static_cast (SynthExample::oscillatorCount); @@ -2345,6 +285,15 @@ class AudioExample } } + /** Switches between the main page and the modulation matrix. */ + void showModulationPage (bool show) + { + mainPageButton.setToggleState (! show, yup::dontSendNotification); + modulationPageButton.setToggleState (show, yup::dontSendNotification); + mainPage.setVisible (! show); + modulationPage->setVisible (show); + } + void paint (yup::Graphics& g) override { g.setFillColor (SynthTheme::windowBackground); @@ -2373,6 +322,9 @@ class AudioExample if (panel != nullptr) panel->commitPendingEdits(); + for (int index = 0; index < SynthExample::lfoCount; ++index) + lfoPanels[static_cast (index)]->setPhase (synth.getLFOPhase (index)); + const auto activeVoices = synth.getNumActiveVoices(); const auto status = audioDeviceError.isNotEmpty() ? audioDeviceError @@ -2547,6 +499,14 @@ class AudioExample if (auto& panel = oscillatorPanels[static_cast (index)]; panel != nullptr) panel->refresh(); } + + auto& filter = synth.getFilterSettings(); + filter.type = 1 + random.nextInt (6); + filter.cutoff = 200.0f * std::exp2 (random.nextFloat() * 6.0f); + filter.resonance = random.nextFloat() * 0.8f; + filter.drive = random.nextBool() ? 0.0f : random.nextFloat() * 0.6f; + filter.keytrack = random.nextBool() ? 0.0f : 1.0f; + filterPanel->refresh(); } //============================================================================== @@ -2555,6 +515,7 @@ class AudioExample static constexpr float outerInset = 10.0f; static constexpr float headerHeight = 44.0f; static constexpr float spacing = 8.0f; + static constexpr float pageButtonWidth = 68.0f; //============================================================================== yup::AudioDeviceManager deviceManager; @@ -2585,8 +546,14 @@ class AudioExample yup::Label voiceLabel; yup::Label loadLabel; + SynthPage mainPage; std::array, SynthExample::oscillatorCount> oscillatorPanels; - std::unique_ptr envelopePanel; + std::unique_ptr filterPanel; + std::array, SynthExample::envelopeCount> envelopePanels; + std::array, SynthExample::lfoCount> lfoPanels; + std::unique_ptr modulationPage; + yup::ToggleButton mainPageButton; + yup::ToggleButton modulationPageButton; yup::TextButton randomizeButton { "RANDOMIZE" }; yup::TextButton clearButton { "ALL NOTES OFF" }; diff --git a/examples/graphics/source/examples/audio/SynthEngine.h b/examples/graphics/source/examples/audio/SynthEngine.h new file mode 100644 index 000000000..bc1479eb2 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthEngine.h @@ -0,0 +1,1277 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include "SynthSettings.h" + +#include +#include +#include +#include + +//============================================================================== +/** A delay, attack, hold, decay, sustain and release envelope with linear segments. + + yup_dsp has no envelope generator, so the example carries its own. It replaces the + single yup::SmoothedValue the earlier revision faded notes with, which could only + ramp between two levels and gave every patch the same shape. + + The stage is advanced one sample at a time and the note ends when the envelope + falls idle, so a voice stays allocated for exactly as long as it is audible. + + @see SynthVoice +*/ +class SynthEnvelope +{ +public: + /** Prepares the envelope and leaves it idle. */ + void prepare (double newSampleRate) noexcept + { + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + reset(); + } + + /** Silences the envelope and returns it to the idle stage. */ + void reset() noexcept + { + stage = Stage::idle; + level = 0.0f; + stageSample = 0; + } + + /** Converts the control values into per sample increments. Safe to call every block. */ + void setParameters (const SynthEnvelopeValues& values) noexcept + { + sustainLevel = yup::jlimit (0.0f, 1.0f, values.sustain); + + delaySamples = toSamples (values.delay); + holdSamples = toSamples (values.hold); + releaseSamples = toSamples (yup::jmax (0.003f, values.release)); + + attackIncrement = 1.0f / static_cast (toSamples (yup::jmax (0.003f, values.attack))); + decayIncrement = (1.0f - sustainLevel) / static_cast (toSamples (values.decay)); + } + + /** Starts a new note from the delay stage. */ + void noteOn() noexcept + { + stage = Stage::delay; + level = 0.0f; + stageSample = 0; + } + + /** Begins the release from whatever level the envelope currently sits at. */ + void noteOff() noexcept + { + if (stage == Stage::idle) + return; + + releaseIncrement = level / static_cast (releaseSamples); + stage = Stage::release; + stageSample = 0; + } + + /** Silences the envelope immediately, ending the note without a tail. */ + void noteOffImmediate() noexcept + { + reset(); + } + + /** Returns true while the envelope still contributes to the output. */ + bool isActive() const noexcept { return stage != Stage::idle; } + + /** Returns the gain the envelope last produced, for use as a modulation source. */ + float getLevel() const noexcept { return level; } + + /** Advances one sample and returns the new gain. */ + float getNextValue() noexcept + { + switch (stage) + { + case Stage::idle: + return 0.0f; + + case Stage::delay: + if (++stageSample >= delaySamples) + advanceTo (Stage::attack); + + return 0.0f; + + case Stage::attack: + level += attackIncrement; + + if (level >= 1.0f) + { + level = 1.0f; + advanceTo (Stage::hold); + } + + return level; + + case Stage::hold: + if (++stageSample >= holdSamples) + advanceTo (Stage::decay); + + return level; + + case Stage::decay: + level -= decayIncrement; + + if (level <= sustainLevel || decayIncrement <= 0.0f) + { + level = sustainLevel; + advanceTo (Stage::sustain); + } + + return level; + + case Stage::sustain: + level = sustainLevel; + + return level; + + case Stage::release: + level -= releaseIncrement; + + if (level <= 0.0f || releaseIncrement <= 0.0f) + { + level = 0.0f; + advanceTo (Stage::idle); + } + + return level; + } + + return level; + } + +private: + //============================================================================== + enum class Stage + { + idle, + delay, + attack, + hold, + decay, + sustain, + release + }; + + void advanceTo (Stage newStage) noexcept + { + stage = newStage; + stageSample = 0; + } + + int toSamples (float seconds) const noexcept + { + return yup::jmax (1, static_cast (static_cast (seconds) * sampleRate)); + } + + //============================================================================== + Stage stage = Stage::idle; + + double sampleRate = 44100.0; + float level = 0.0f; + float sustainLevel = 0.7f; + float attackIncrement = 1.0f; + float decayIncrement = 1.0f; + float releaseIncrement = 1.0f; + + int delaySamples = 1; + int holdSamples = 1; + int releaseSamples = 1; + int stageSample = 0; +}; + +//============================================================================== +/** Immutable waveform data, prepared once and read by every voice. + + Building a FourierSeries allocates, so it belongs at construction time. Once + prepared the resources are read-only and safe to share across voices. + + @see SynthOscillator +*/ +class SynthOscillatorResources +{ +public: + SynthOscillatorResources() + { + const yup::Waveform waveforms[] = { yup::Waveform::sine, + yup::Waveform::cosine, + yup::Waveform::sawtooth, + yup::Waveform::square, + yup::Waveform::triangle, + yup::Waveform::pulse }; + + for (std::size_t index = 0; index < frames.size(); ++index) + frames[index] = yup::FourierSeries::create (waveforms[index], SynthExample::maxHarmonics); + } + + /** Returns the series of one of the Waveform presets. */ + const yup::FourierSeries& getFrame (yup::Waveform waveform) const noexcept + { + return frames[static_cast (waveform)]; + } + +private: + std::array, 6> frames; +}; + +//============================================================================== +/** Turns a control snapshot into the shape yup::PrismSpectrum reads. */ +inline yup::PrismSpectrum::Shape toPrismShape (const SynthOscillatorValues& values) noexcept +{ + return { static_cast (values.ridgeSpacing), + static_cast (values.dispersion), + static_cast (values.squeeze), + static_cast (values.squash), + static_cast (values.tilt), + static_cast (values.oddEven), + static_cast (values.formant), + static_cast (values.formantPosition), + static_cast (values.scatter) }; +} + +//============================================================================== +/** Derives the series one oscillator plays: partials or preset, Prism shaping, sync. + + Shared by the engine's slot, by a voice that needs its own spectrum while an + envelope modulates it, and by the waveform preview. prepare() allocates; update() + does not. + + The derived series is rescaled so its waveform peaks where the source's does. + yup::PrismSpectrum preserves the coefficient sum, which bounds a peak from well + above - three times over for a sawtooth - and how close the waveform comes to + that bound depends on how aligned the harmonic phases are. Dispersion, scatter and + the sync reset all move exactly that, so without the rescale they would swing the + output level as they are swept rather than only recolouring it. + + @see SynthOscillatorSlot, SynthOscillator, WaveformEditor +*/ +class SynthSpectrumDerivation +{ +public: + /** Allocates the shaper, the resampler, the series and the peak meter. */ + void prepare() + { + spectrum.prepare (SynthExample::maxHarmonics); + resampler.prepare (SynthExample::maxHarmonics); + customSeries.resize (SynthExample::maxHarmonics); + shapedSeries.resize (SynthExample::maxHarmonics); + syncedSeries.resize (SynthExample::maxHarmonics); + + // The meter is only ever asked for a waveform, never played, and a frequency of + // zero keeps every harmonic whatever rate it was prepared at. + peakMeter.prepare (48000.0, SynthExample::maxHarmonics); + peakMeter.setFrequency (0.0); + peakMeter.setIncludeDC (true); + + hasApplied = false; + } + + /** Rebuilds the series if anything it depends on moved. Returns true when it did. */ + bool update (const SynthOscillatorValues& values, + const SynthOscillatorSettings& settings, + const SynthOscillatorResources& resources) noexcept + { + const auto partialsChanged = ! hasApplied + || applied.usesCustomSeries != values.usesCustomSeries + || applied.harmonicGeneration != values.harmonicGeneration + || applied.harmonicScale != values.harmonicScale; + + if (partialsChanged && values.usesCustomSeries) + settings.copyHarmonicsInto (customSeries, values.harmonicScale); + + const auto& source = values.usesCustomSeries ? customSeries : resources.getFrame (values.waveform); + const auto sourceChanged = partialsChanged || applied.waveform != values.waveform; + + const auto shapeChanged = applied.syncMode != values.syncMode + || applied.syncRatio != values.syncRatio + || applied.ridgeSpacing != values.ridgeSpacing + || applied.color != values.color + || applied.dispersion != values.dispersion + || applied.squeeze != values.squeeze + || applied.squash != values.squash + || applied.tilt != values.tilt + || applied.oddEven != values.oddEven + || applied.formant != values.formant + || applied.formantPosition != values.formantPosition + || applied.scatter != values.scatter; + + applied = values; + hasApplied = true; + + if (sourceChanged) + sourcePeak = measurePeak (source); + + if (! (sourceChanged || shapeChanged)) + return false; + + spectrum.process (source, shapedSeries, toPrismShape (values), values.color); + + if (values.syncMode == yup::SyncMode::none) + { + derived = &shapedSeries; + } + else + { + resampler.transform (shapedSeries, static_cast (values.syncRatio), values.syncMode, syncedSeries); + derived = &syncedSeries; + } + + matchSourcePeak (*derived); + return true; + } + + /** Returns the series the last update() derived. */ + const yup::FourierSeries& getSeries() const noexcept { return *derived; } + + /** Forgets what was applied so the next update() rebuilds unconditionally. */ + void invalidate() noexcept { hasApplied = false; } + +private: + /** Points the display samples the peak search walks. Twice the harmonic count + resolves the highest harmonic; this is four times it, for a little margin. */ + static constexpr int peakResolution = 512; + + /** Returns the largest absolute value one period of a series reaches. + + The series is rendered with the same inverse FFT the voices use rather than + summed harmonic by harmonic, which would cost a transcendental per harmonic per + point. + */ + double measurePeak (const yup::FourierSeries& series) noexcept + { + peakMeter.setSeries (series); + peakMeter.render (false); + + auto peak = 0.0; + + for (int index = 0; index < peakResolution; ++index) + { + const auto phase = static_cast (index) / static_cast (peakResolution); + + peak = yup::jmax (peak, std::abs (static_cast (peakMeter.getValueAtPhase (phase)))); + } + + return peak; + } + + void matchSourcePeak (yup::FourierSeries& series) noexcept + { + const auto derivedPeak = measurePeak (series); + + if (derivedPeak <= 1.0e-9 || sourcePeak <= 1.0e-9) + return; + + const auto scale = sourcePeak / derivedPeak; + + for (int harmonic = 1; harmonic <= series.getNumHarmonics(); ++harmonic) + series.setHarmonic (harmonic, + series.getCosine (harmonic) * scale, + series.getSine (harmonic) * scale); + } + + yup::PrismSpectrum spectrum; + yup::SyncSpectralResampler resampler; + yup::WavetableOscillator peakMeter; + double sourcePeak = 0.0; + yup::FourierSeries customSeries; + yup::FourierSeries shapedSeries; + yup::FourierSeries syncedSeries; + yup::FourierSeries* derived = &shapedSeries; + SynthOscillatorValues applied; + bool hasApplied = false; +}; + +//============================================================================== +/** The series every voice of one oscillator slot plays, rebuilt once per block. + + A slot's spectrum does not vary per voice unless an envelope is routed into it, + so it is derived once here from the LFO-modulated values rather than inside each + voice. Voices publish nothing back; they compare getGeneration() and re-render + their own table when it moves, which keeps yup::WavetableOscillator's crossfade + doing the smoothing. Runs on the audio thread before any voice reads it. + + @see SynthSpectrumDerivation, SynthOscillator +*/ +class SynthOscillatorSlot +{ +public: + SynthOscillatorSlot() { derivation.prepare(); } + + /** Rebuilds the slot's series if anything it depends on moved. Audio thread. */ + void update (const SynthOscillatorValues& values, + const SynthOscillatorSettings& settings, + const SynthOscillatorResources& resources) noexcept + { + if (derivation.update (values, settings, resources)) + ++generation; + } + + /** Returns the series the voices of this slot should be playing. */ + const yup::FourierSeries& getSeries() const noexcept { return derivation.getSeries(); } + + /** Bumped whenever getSeries() changed, so a voice knows to re-render its table. */ + int getGeneration() const noexcept { return generation; } + +private: + SynthSpectrumDerivation derivation; + int generation = 0; +}; + +//============================================================================== +/** One of a voice's oscillators: a wavetable playing a derived series, plus unison. + + prepare() allocates the backends; renderBlock() is allocation-free and only + re-renders a table when its series moved. The series normally comes from the + shared slot; while an envelope is routed into this oscillator's spectrum it is + derived here instead, from the voice's own values. + + Unison is built from bare yup::WavetableOscillator satellites playing the same + series as the center, detuned and panned around it. + + @see SynthOscillatorSettings, SynthOscillatorSlot, SynthSpectrumDerivation +*/ +class SynthOscillator +{ +public: + /** Allocates every backend and attaches the shared slot, settings and resources. */ + void prepare (double newSampleRate, + int maxBlockSize, + const SynthOscillatorSlot& sharedSlot, + const SynthOscillatorSettings& oscillatorSettings, + const SynthOscillatorResources& oscillatorResources) + { + const auto sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + slot = &sharedSlot; + settings = &oscillatorSettings; + resources = &oscillatorResources; + + wavetable.prepare (sampleRate, SynthExample::maxHarmonics); + + for (auto& satellite : satellites) + satellite.prepare (sampleRate, SynthExample::maxHarmonics); + + localDerivation.prepare(); + slotBuffer.assign (static_cast (yup::jmax (1, maxBlockSize)), 0.0f); + + appliedSeriesGeneration = -1; + usingLocalSeries = false; + } + + /** Restarts every backend, spreading the satellites so they do not stack in phase. */ + void reset (double initialPhase) noexcept + { + const auto phase = static_cast (initialPhase); + + wavetable.setPhase (phase); + + for (std::size_t index = 0; index < satellites.size(); ++index) + { + const auto offset = static_cast (index + 1) / static_cast (satellites.size() + 1); + + satellites[index].setPhase (phase + offset - std::floor (phase + offset)); + } + } + + /** Applies the pending changes and writes one stereo block. + + The buffers are overwritten rather than added to, so the caller does not have to + clear them first. + + @param deriveLocally True while an envelope modulates this oscillator's spectrum, + in which case the series is derived here from these values + rather than taken from the slot. + */ + void renderBlock (float* left, + float* right, + int numSamples, + const SynthOscillatorValues& values, + double frequency, + bool deriveLocally) noexcept + { + yup::FloatVectorOperations::clear (left, numSamples); + yup::FloatVectorOperations::clear (right, numSamples); + + // The table holds one period of the synced waveform, which for mirrored sync is + // two leader periods, so it is played at the fundamental the transform produced. + const auto played = frequency * yup::SyncSpectralResampler::getFundamentalScale (values.syncMode); + + const auto slotCount = yup::jlimit (1, SynthExample::maxUnisonVoices, values.unisonVoices); + const auto centreIndex = (slotCount - 1) / 2; + const auto slotGain = 1.0f / static_cast (slotCount); + + applySeries (values, deriveLocally); + + renderTable (wavetable, numSamples, detunedFrequency (played, values, centreIndex, slotCount)); + accumulateSlot (left, right, numSamples, slotOffset (centreIndex, slotCount) * values.unisonSpread, slotGain); + + for (int index = 0, satellite = 0; index < slotCount; ++index) + { + if (index == centreIndex) + continue; + + renderTable (satellites[static_cast (satellite++)], + numSamples, + detunedFrequency (played, values, index, slotCount)); + + accumulateSlot (left, right, numSamples, slotOffset (index, slotCount) * values.unisonSpread, slotGain); + } + } + +private: + //============================================================================== + /** Returns where a unison slot sits across the spread, from -1 to 1. */ + static float slotOffset (int slotIndex, int slotCount) noexcept + { + if (slotCount <= 1) + return 0.0f; + + return 2.0f * static_cast (slotIndex) / static_cast (slotCount - 1) - 1.0f; + } + + /** Combines the oscillator's own detune with the slot's share of the unison spread. */ + static double detunedFrequency (double frequency, const SynthOscillatorValues& values, int slotIndex, int slotCount) noexcept + { + const auto semitones = static_cast (values.unisonDetune) * static_cast (slotOffset (slotIndex, slotCount)); + + return frequency * std::pow (2.0, semitones / 12.0); + } + + /** Mixes the rendered slot into the stereo pair with an equal power pan. */ + void accumulateSlot (float* left, float* right, int numSamples, float pan, float gain) noexcept + { + const auto angle = (yup::jlimit (-1.0f, 1.0f, pan) + 1.0f) * 0.25f * yup::MathConstants::pi; + const auto leftGain = std::cos (angle) * gain; + const auto rightGain = std::sin (angle) * gain; + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto value = slotBuffer[static_cast (sample)]; + + left[sample] += value * leftGain; + right[sample] += value * rightGain; + } + } + + /** Renders one table into the slot buffer; render() crossfades into a new table, + which is what keeps a modulated shape from stepping. */ + void renderTable (yup::WavetableOscillator& table, int numSamples, double frequency) noexcept + { + table.setFrequency (frequency); + + if (table.needsRender()) + table.render(); + + table.processBlock (slotBuffer.data(), numSamples); + } + + /** Hands the right series to every table when it moved since the last block. */ + void applySeries (const SynthOscillatorValues& values, bool deriveLocally) noexcept + { + if (deriveLocally) + { + if (! usingLocalSeries) + { + localDerivation.invalidate(); + usingLocalSeries = true; + } + + if (localDerivation.update (values, *settings, *resources)) + setSeries (localDerivation.getSeries()); + + return; + } + + const auto generation = slot->getGeneration(); + + if (usingLocalSeries || generation != appliedSeriesGeneration) + { + usingLocalSeries = false; + appliedSeriesGeneration = generation; + setSeries (slot->getSeries()); + } + } + + void setSeries (const yup::FourierSeries& series) noexcept + { + wavetable.setSeries (series); + + for (auto& satellite : satellites) + satellite.setSeries (series); + } + + //============================================================================== + yup::WavetableOscillator wavetable; + std::array, SynthExample::maxUnisonVoices - 1> satellites; + SynthSpectrumDerivation localDerivation; + + const SynthOscillatorSlot* slot = nullptr; + const SynthOscillatorSettings* settings = nullptr; + const SynthOscillatorResources* resources = nullptr; + std::vector slotBuffer; + int appliedSeriesGeneration = -1; + bool usingLocalSeries = false; +}; + +//============================================================================== +/** The per-voice filter: a drive stage into one VAStateVariableFilter per channel. + + Cutoff, resonance and drive are smoothed across a control chunk so modulation + cannot zipper. Off skips everything, leaving the filter state where it was. + + @see SynthFilterSettings +*/ +class SynthFilterStage +{ +public: + /** Prepares both channels and the parameter smoothers. */ + void prepare (double sampleRate) + { + for (auto& filter : filters) + filter.prepare (sampleRate, SynthExample::maxBlockSize); + + cutoff.reset (sampleRate, 0.005); + resonance.reset (sampleRate, 0.005); + drive.reset (sampleRate, 0.005); + + cutoff.setCurrentAndTargetValue (8000.0f); + resonance.setCurrentAndTargetValue (0.2f); + drive.setCurrentAndTargetValue (0.0f); + } + + /** Clears the filter memory, for a fresh note. */ + void reset() noexcept + { + for (auto& filter : filters) + filter.reset(); + } + + /** Filters a stereo chunk in place at the note's keytracked cutoff. */ + void process (float* left, float* right, int numSamples, const SynthFilterValues& values, double midiNote) noexcept + { + if (values.type == SynthFilterType::off) + return; + + const auto tracked = values.cutoff * std::exp2 (values.keytrack * static_cast (midiNote - 60.0) / 12.0f); + + cutoff.setTargetValue (yup::jlimit (20.0f, 20000.0f, tracked)); + resonance.setTargetValue (values.resonance); + drive.setTargetValue (values.drive); + + const auto mode = toFilterMode (values.type); + + for (auto& filter : filters) + { + filter.setMode (mode); + filter.setShelfGain (12.0f); + } + + float* channels[] = { left, right }; + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto frequency = cutoff.getNextValue(); + const auto q = yup::VAStateVariableFilter::resonanceToQ (resonance.getNextValue()); + const auto amount = drive.getNextValue(); + const auto gain = 1.0f + 9.0f * amount; + const auto makeup = amount > 0.0f ? 1.0f / std::tanh (gain) : 1.0f; + + for (std::size_t channel = 0; channel < filters.size(); ++channel) + { + auto& filter = filters[channel]; + filter.setCutoffFrequency (frequency); + filter.setQ (q); + + auto x = channels[channel][sample]; + + if (amount > 0.0f) + x = std::tanh (x * gain) * makeup; + + channels[channel][sample] = filter.processSample (x); + } + } + } + +private: + std::array, 2> filters; + yup::SmoothedValue cutoff; + yup::SmoothedValue resonance; + yup::SmoothedValue drive; +}; + +//============================================================================== +/** What the engine derived for this block, read by every voice while it renders. + + Written at the top of HarmonicSynthEngine::renderNextBlock, before any voice runs, + on the same thread, so no publication handshake is needed. +*/ +struct SynthBlockContext +{ + SynthPatchValues patch; /**< Settings with the LFO routings applied. */ + SynthModulationValues modulation; /**< The routes, for the voice's envelope layer. */ + std::array envelopes; +}; + +//============================================================================== +/** The single sound the example synthesiser plays. */ +class SynthSound : public yup::SynthesiserSound +{ +public: + bool appliesToNote (int) override { return true; } + bool appliesToChannel (int) override { return true; } +}; + +//============================================================================== +/** A polyphonic voice: two oscillators into a filter, shaped by two envelopes. */ +class SynthVoice : public yup::SynthesiserVoice +{ +public: + SynthVoice (const std::array& oscillatorSettings, + const SynthOscillatorResources& oscillatorResources, + const std::array& sharedSlots, + const SynthBlockContext& blockContext) + : settings (oscillatorSettings) + , resources (oscillatorResources) + , slots (sharedSlots) + , context (blockContext) + { + } + + /** Allocates every oscillator backend. Must run outside the audio callback. */ + void prepare (double sampleRate, int maxBlockSize) + { + for (std::size_t slot = 0; slot < oscillators.size(); ++slot) + oscillators[slot].prepare (sampleRate, maxBlockSize, slots[slot], settings[slot], resources); + + for (auto& level : levels) + level.reset (sampleRate, SynthExample::levelRampSeconds); + + filter.prepare (sampleRate); + envelope.prepare (sampleRate); + modulationEnvelope.prepare (sampleRate); + playbackRate = sampleRate; + hasPlayed = false; + preserveNote = false; + glideSeconds = 0.0; + pitch.setCurrentAndTargetValue (69.0); + bend.reset (sampleRate, SynthExample::levelRampSeconds); + bend.setCurrentAndTargetValue (0.0); + tailLength = yup::jlimit (2, yup::jmax (2, maxBlockSize), static_cast (sampleRate * 0.006)); + tailBuffer.setSize (2, tailLength); + tailScratch.setSize (2, tailLength); + tailPosition = tailLength; + clearCurrentNote(); + + const auto blockSize = static_cast (yup::jmax (1, maxBlockSize)); + + oscLeft.assign (blockSize, 0.0f); + oscRight.assign (blockSize, 0.0f); + mixLeft.assign (blockSize, 0.0f); + mixRight.assign (blockSize, 0.0f); + } + + //============================================================================== + bool canPlaySound (yup::SynthesiserSound* sound) override + { + return dynamic_cast (sound) != nullptr; + } + + /** Configures the next mono transition. Legato retains the envelope and phases. + Glide is measured in seconds and interpolates pitch in semitones. */ + void setTransition (double seconds, bool legato) noexcept + { + glideSeconds = yup::jlimit (0.0, 2.0, seconds); + preserveNote = legato && envelope.isActive(); + } + + void startNote (int midiNoteNumber, float velocity, yup::SynthesiserSound*, int currentPitchWheelPosition) override + { + const auto currentPitch = pitch.getCurrentValue(); + pitch.reset (playbackRate, glideSeconds); + pitch.setCurrentAndTargetValue (currentPitch); + if (glideSeconds > 0.0 && hasPlayed) + pitch.setTargetValue (static_cast (midiNoteNumber)); + else + pitch.setCurrentAndTargetValue (static_cast (midiNoteNumber)); + + pitchWheelMoved (currentPitchWheelPosition); + + if (! preserveNote) + { + velocityGain = yup::jlimit (0.0f, 1.0f, velocity); + for (auto& oscillator : oscillators) + oscillator.reset (0.0); + + envelope.setParameters (context.envelopes[0]); + envelope.noteOn(); + modulationEnvelope.setParameters (context.envelopes[1]); + modulationEnvelope.noteOn(); + filter.reset(); + } + + preserveNote = false; + hasPlayed = true; + glideSeconds = 0.0; + } + + void stopNote (float, bool allowTailOff) override + { + if (allowTailOff) + { + envelope.noteOff(); + modulationEnvelope.noteOff(); + return; + } + + if (! preserveNote) + { + if (envelope.isActive()) + { + tailScratch.clear(); + renderNextBlock (tailScratch, 0, tailLength); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < tailLength; ++sample) + { + const auto fade = 0.5 + 0.5 * std::cos (yup::MathConstants::pi + * sample / (tailLength - 1)); + tailBuffer.setSample (channel, sample, tailScratch.getSample (channel, sample) * static_cast (fade)); + } + tailPosition = 0; + } + envelope.noteOffImmediate(); + modulationEnvelope.noteOffImmediate(); + } + clearCurrentNote(); + } + + void pitchWheelMoved (int newPitchWheelValue) override + { + const auto normalized = (static_cast (newPitchWheelValue) - 8192.0) / 8192.0; + bend.setTargetValue (normalized * pitchWheelRangeSemitones); + } + + /** Includes a recycled voice's short continuation in the activity meter. */ + bool isSounding() const noexcept { return isVoiceActive() || tailPosition < tailLength; } + + void controllerMoved (int, int) override {} + + //============================================================================== + void renderNextBlock (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) override + { + if (! isSounding() || numSamples <= 0 || outputBuffer.getNumChannels() == 0) + return; + + envelope.setParameters (context.envelopes[0]); + modulationEnvelope.setParameters (context.envelopes[1]); + + for (int offset = 0; offset < numSamples;) + { + const auto count = yup::jmin (numSamples - offset, static_cast (mixLeft.size()), SynthExample::controlChunk); + if (count <= 0) + return; + renderChunk (outputBuffer, startSample + offset, count); + offset += count; + } + } + +private: + //============================================================================== + void renderChunk (yup::AudioBuffer& outputBuffer, int startSample, int numSamples) noexcept + { + yup::FloatVectorOperations::clear (mixLeft.data(), numSamples); + yup::FloatVectorOperations::clear (mixRight.data(), numSamples); + + if (isVoiceActive()) + { + // The engine applied the LFO routings for this block; the envelopes are per + // voice, so their routings are applied here on top, once per control chunk. + auto patch = context.patch; + const std::array sources { envelope.getLevel(), modulationEnvelope.getLevel(), 0.0f, 0.0f }; + applyModulation (patch, context.modulation, sources); + + const auto note = pitch.skip (numSamples) + bend.skip (numSamples); + const auto frequency = midiNoteToFrequency (note); + for (int index = 0; index < SynthExample::oscillatorCount; ++index) + { + const auto slot = static_cast (index); + const auto& values = patch.oscillators[slot]; + auto& level = levels[slot]; + const auto detuned = frequency * std::exp2 (values.octave + values.detuneSemitones / 12.0); + oscillators[slot].renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, detuned, + context.modulation.hasVoiceSpectrumRoute (index)); + level.setTargetValue (values.level); + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto gain = level.getNextValue(); + mixLeft[static_cast (sample)] += oscLeft[static_cast (sample)] * gain; + mixRight[static_cast (sample)] += oscRight[static_cast (sample)] * gain; + } + } + + filter.process (mixLeft.data(), mixRight.data(), numSamples, patch.filter, note); + } + + for (int sample = 0; sample < numSamples; ++sample) + { + const auto gain = envelope.getNextValue() * velocityGain; + modulationEnvelope.getNextValue(); + auto left = mixLeft[static_cast (sample)] * gain; + auto right = mixRight[static_cast (sample)] * gain; + if (tailPosition < tailLength) + { + left += tailBuffer.getSample (0, tailPosition); + right += tailBuffer.getSample (1, tailPosition++); + } + + if (outputBuffer.getNumChannels() == 1) + outputBuffer.addSample (0, startSample + sample, (left + right) * 0.5f); + else + { + outputBuffer.addSample (0, startSample + sample, left); + outputBuffer.addSample (1, startSample + sample, right); + } + } + + if (! envelope.isActive()) + clearCurrentNote(); + } + + static double midiNoteToFrequency (double midiNoteNumber) noexcept + { + return 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); + } + + //============================================================================== + static constexpr double pitchWheelRangeSemitones = 2.0; + + const std::array& settings; + const SynthOscillatorResources& resources; + const std::array& slots; + const SynthBlockContext& context; + + std::array oscillators; + std::array, SynthExample::oscillatorCount> levels; + + SynthEnvelope envelope; + SynthEnvelope modulationEnvelope; + SynthFilterStage filter; + + std::vector oscLeft; + std::vector oscRight; + std::vector mixLeft; + std::vector mixRight; + + yup::SmoothedValue pitch; + yup::SmoothedValue bend; + yup::AudioBuffer tailBuffer; + yup::AudioBuffer tailScratch; + double playbackRate = 44100.0; + double glideSeconds = 0.0; + int tailLength = 0; + int tailPosition = 0; + bool preserveNote = false; + bool hasPlayed = false; + float velocityGain = 1.0f; +}; + +//============================================================================== +/** Keyboard allocation and envelope behavior. */ +enum class SynthPlayMode +{ + poly, /**< Eight voices with rendered release tails when recycled. */ + mono, /**< Last-note priority, retriggering the envelope on each note. */ + legato /**< Overlapping notes preserve phases and envelope; glide is optional. */ +}; + +/** Polyphonic synthesiser rendering the two oscillators of every voice. + MIDI and rendering methods belong to the audio thread; the UI edits atomic controls. */ +class HarmonicSynthEngine : public yup::Synthesiser +{ +public: + HarmonicSynthEngine() + { + addSound (new SynthSound()); + setMinimumRenderingSubdivisionSize (1, true); + settings[0].color = 0.2f; + settings[1].waveform = static_cast (yup::Waveform::triangle); + settings[1].syncMode = static_cast (yup::SyncMode::hard); + settings[1].octave = -1; + settings[1].level = 0.3f; + envelopeSettings[1].attack = 0.2f; + envelopeSettings[1].decay = 0.5f; + envelopeSettings[1].sustain = 0.3f; + envelopeSettings[1].release = 0.5f; + + for (int index = 0; index < SynthExample::voiceCount; ++index) + { + auto voice = yup::ReferenceCountedObjectPtr (new SynthVoice (settings, resources, oscillatorSlots, context)); + + addVoice (voice); + ownedVoices.add (voice); + } + } + + /** Prepares every voice. Must run outside the audio callback. */ + void prepare (double sampleRate, int maxBlockSize) + { + allNotesOff (0, false); + setCurrentPlaybackSampleRate (sampleRate); + activeVoices.store (0); + + for (auto& lfo : lfos) + lfo.prepare (sampleRate); + + for (int index = 0; index < ownedVoices.size(); ++index) + ownedVoices[index]->prepare (sampleRate, maxBlockSize); + } + + /** UI-facing performance controls, sampled at the next render boundary. */ + std::atomic playMode { static_cast (SynthPlayMode::poly) }; + std::atomic portamento { 0.12f }; + + /** Requests a release of all keys without taking the synthesiser lock on the UI thread. */ + void requestAllNotesOff() noexcept { releaseRequested.store (true); } + + /** Renders MIDI with sample-accurate note boundaries and publishes the voice meter. */ + void renderNextBlock (yup::AudioBuffer& output, const yup::MidiBuffer& midi, int start, int count) + { + const auto requestedMode = static_cast (playMode.load()); + const auto release = releaseRequested.exchange (false); + if (requestedMode != mode || release) + { + allNotesOff (0, true); + mode = requestedMode; + } + // The LFOs are global, so their routings are applied once here and every voice + // of a slot plays the same spectrum; the envelope routings are per voice and are + // applied by each voice on top of this. + std::array sources { 0.0f, 0.0f, 0.0f, 0.0f }; + + for (std::size_t index = 0; index < lfos.size(); ++index) + { + const auto values = lfoSettings[index].read(); + auto& lfo = lfos[index]; + + lfo.setShape (values.shape); + lfo.setFrequency (values.rate); + lfo.setPhaseOffset (values.phase); + + sources[2 + index] = lfo.getValue(); + lfoPhases[index].store (lfo.getPhase()); + lfo.skip (count); + } + + for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) + context.patch.oscillators[slot] = settings[slot].read(); + + context.patch.filter = filterSettings.read(); + context.modulation = modulationSettings.read(); + + for (std::size_t index = 0; index < envelopeSettings.size(); ++index) + context.envelopes[index] = envelopeSettings[index].read(); + + applyModulation (context.patch, context.modulation, sources); + + for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) + oscillatorSlots[slot].update (context.patch.oscillators[slot], settings[slot], resources); + + yup::Synthesiser::renderNextBlock (output, midi, start, count); + int active = 0; + for (auto* voice : ownedVoices) + active += voice->isSounding() ? 1 : 0; + activeVoices.store (active); + } + + void noteOn (int channel, int note, float velocity) override + { + if (mode == SynthPlayMode::poly) + { + yup::Synthesiser::noteOn (channel, note, velocity); + return; + } + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + held = { ++noteOrder, velocity, true }; + playMonoNote (channel, note, velocity); + } + + void noteOff (int channel, int note, float velocity, bool tailOff) override + { + if (mode == SynthPlayMode::poly) + { + yup::Synthesiser::noteOff (channel, note, velocity, tailOff); + return; + } + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + held.down = false; + if (! sustain[static_cast (channel - 1)] || ! tailOff) + held.order = 0; + selectMonoNote (tailOff); + } + + void allNotesOff (int channel, bool tailOff) override + { + for (int index = 0; index < static_cast (heldNotes.size()); ++index) + if (channel <= 0 || index / 128 == channel - 1) + heldNotes[static_cast (index)] = {}; + for (int index = 0; index < 16; ++index) + if (channel <= 0 || index == channel - 1) + sustain[static_cast (index)] = false; + yup::Synthesiser::allNotesOff (channel, tailOff); + if (mode != SynthPlayMode::poly) + selectMonoNote (tailOff); + } + + void handleController (int channel, int controller, int value) override + { + if (mode != SynthPlayMode::poly && controller == 64) + { + sustain[static_cast (channel - 1)] = value >= 64; + if (value < 64) + { + for (int note = 0; note < 128; ++note) + { + auto& held = heldNotes[static_cast ((channel - 1) * 128 + note)]; + if (! held.down) + held.order = 0; + } + selectMonoNote (true); + } + return; + } + yup::Synthesiser::handleController (channel, controller, value); + } + + /** Returns the settings edited by one of the user interface panels. */ + SynthOscillatorSettings& getOscillatorSettings (int oscillatorIndex) noexcept + { + return settings[static_cast (oscillatorIndex)]; + } + + /** Returns the settings edited by one of the envelope panels. */ + SynthEnvelopeSettings& getEnvelopeSettings (int envelopeIndex) noexcept + { + return envelopeSettings[static_cast (envelopeIndex)]; + } + + /** Returns the settings edited by the filter panel. */ + SynthFilterSettings& getFilterSettings() noexcept { return filterSettings; } + + /** Returns the settings edited by one of the LFO panels. */ + SynthLFOSettings& getLFOSettings (int lfoIndex) noexcept + { + return lfoSettings[static_cast (lfoIndex)]; + } + + /** Returns the routings edited by the modulation page. */ + SynthModulationSettings& getModulationSettings() noexcept { return modulationSettings; } + + /** Returns the phase of an LFO at the top of the last block, for its display. */ + float getLFOPhase (int lfoIndex) const noexcept + { + return lfoPhases[static_cast (lfoIndex)].load(); + } + + /** Returns the shared waveform presets, which the waveform displays also read. */ + const SynthOscillatorResources& getResources() const noexcept { return resources; } + + /** Returns the note of a sounding voice, or -1 when the synthesiser is silent. */ + int getCurrentlyPlayingNote() const noexcept + { + for (int index = 0; index < ownedVoices.size(); ++index) + if (ownedVoices[index] != nullptr && ownedVoices[index]->isVoiceActive()) + return ownedVoices[index]->getCurrentlyPlayingNote(); + + return -1; + } + + /** Returns how many voices are currently sounding. */ + int getNumActiveVoices() const noexcept { return activeVoices.load(); } + +private: + void playMonoNote (int channel, int note, float velocity) + { + auto* voice = ownedVoices[0].get(); + const auto overlapping = voice->isVoiceActive() && monoKeyActive; + voice->setTransition (overlapping ? portamento.load() : 0.0, + overlapping && mode == SynthPlayMode::legato); + startVoice (voice, getSound (0).get(), channel, note, velocity); + monoKeyActive = true; + } + + void selectMonoNote (bool tailOff) + { + int latest = -1; + for (int index = 0; index < static_cast (heldNotes.size()); ++index) + if (heldNotes[static_cast (index)].order != 0 + && (latest < 0 || heldNotes[static_cast (index)].order > heldNotes[static_cast (latest)].order)) + latest = index; + + auto* voice = ownedVoices[0].get(); + if (latest < 0) + { + if (monoKeyActive) + voice->stopNote (0.0f, tailOff); + monoKeyActive = false; + return; + } + const auto channel = latest / 128 + 1; + const auto note = latest % 128; + if (! voice->isVoiceActive() || voice->getCurrentlyPlayingNote() != note || ! voice->isPlayingChannel (channel)) + playMonoNote (channel, note, heldNotes[static_cast (latest)].velocity); + } + + struct HeldNote + { + yup::uint64 order = 0; + float velocity = 0.0f; + bool down = false; + }; + + std::array heldNotes {}; + std::array sustain {}; + yup::uint64 noteOrder = 0; + SynthPlayMode mode = SynthPlayMode::poly; + bool monoKeyActive = false; + std::atomic releaseRequested { false }; + std::atomic activeVoices { 0 }; + + SynthOscillatorResources resources; + std::array oscillatorSlots; + std::array settings; + std::array envelopeSettings; + SynthFilterSettings filterSettings; + std::array lfoSettings; + SynthModulationSettings modulationSettings; + std::array, SynthExample::lfoCount> lfos; + std::array, SynthExample::lfoCount> lfoPhases {}; + SynthBlockContext context; + yup::ReferenceCountedArray ownedVoices; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HarmonicSynthEngine) +}; diff --git a/examples/graphics/source/examples/audio/SynthModulationPage.h b/examples/graphics/source/examples/audio/SynthModulationPage.h new file mode 100644 index 000000000..57144a943 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthModulationPage.h @@ -0,0 +1,140 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include "SynthPanels.h" + +#include +#include + +//============================================================================== +/** One routing: which source drives which destination, and how much. */ +class SynthModulationRow : public yup::Component +{ +public: + SynthModulationRow (SynthModulationSettings::Slot& slotToEdit, const yup::Font& font) + : slot (slotToEdit) + , sourceChoice ("SOURCE", getSynthModulationSourceNames(), font) + , destinationChoice ("DESTINATION", getSynthModulationDestinationNames(), font) + , depthKnob ("DEPTH", -1.0, 1.0, 0.01, 0.0, font) + { + addAndMakeVisible (sourceChoice); + addAndMakeVisible (destinationChoice); + addAndMakeVisible (depthKnob); + + depthKnob.formatValue = SynthFormat::bipolar; + + sourceChoice.onChange = [this] (int id) { slot.source = id - 1; }; + destinationChoice.onChange = [this] (int id) { slot.destination = id - 1; }; + depthKnob.onChange = [this] (double value) { slot.depth = static_cast (value); }; + + refresh(); + } + + /** Reads the slot back into the widgets. */ + void refresh() + { + sourceChoice.getComboBox().setSelectedId (slot.source.load() + 1, yup::dontSendNotification); + destinationChoice.getComboBox().setSelectedId (slot.destination.load() + 1, yup::dontSendNotification); + depthKnob.getSlider().setValue (slot.depth.load(), yup::dontSendNotification); + } + + void resized() override + { + auto bounds = getLocalBounds(); + + depthKnob.setBounds (bounds.removeFromRight (knobWidth)); + bounds.removeFromRight (spacing); + sourceChoice.setBounds (bounds.removeFromLeft (bounds.getWidth() * 0.35f).reduced (0.0f, 8.0f)); + bounds.removeFromLeft (spacing); + destinationChoice.setBounds (bounds.reduced (0.0f, 8.0f)); + } + +private: + static constexpr float knobWidth = 58.0f; + static constexpr float spacing = 6.0f; + + SynthModulationSettings::Slot& slot; + + ChoiceControl sourceChoice; + ChoiceControl destinationChoice; + KnobControl depthKnob; +}; + +//============================================================================== +/** The modulation matrix: eight routings in a panel. + + @see SynthModulationSettings +*/ +class SynthModulationPage : public yup::Component +{ +public: + SynthModulationPage (SynthModulationSettings& settingsToEdit, const yup::Font& font) + { + titleLabel.setText ("MODULATION MATRIX", yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + for (std::size_t index = 0; index < rows.size(); ++index) + { + rows[index] = std::make_unique (settingsToEdit.slots[index], font); + addAndMakeVisible (*rows[index]); + } + } + + /** Reads every slot back into its row. */ + void refresh() + { + for (auto& row : rows) + row->refresh(); + } + + void resized() override + { + auto bounds = getLocalBounds().reduced (panelInset); + + titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + bounds.removeFromTop (spacing); + + const auto rowHeight = (bounds.getHeight() - spacing * static_cast (rows.size() - 1)) / static_cast (rows.size()); + + for (auto& row : rows) + { + row->setBounds (bounds.removeFromTop (rowHeight)); + bounds.removeFromTop (spacing); + } + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); + } + +private: + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 18.0f; + static constexpr float spacing = 6.0f; + + yup::Label titleLabel; + std::array, SynthExample::modulationSlots> rows; +}; diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h new file mode 100644 index 000000000..3c22c6a61 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -0,0 +1,1248 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include "SynthEngine.h" + +#include +#include +#include +#include + +//============================================================================== +/** The palette the synthesiser panels share. + + The example draws its own chrome rather than leaning on the theme, so that the + oscillator, envelope and display panels read as one instrument. +*/ +namespace SynthTheme +{ +inline constexpr yup::Color windowBackground { 0xff16191d }; +inline constexpr yup::Color panelBackground { 0xff21262c }; +inline constexpr yup::Color panelBorder { 0xff2e353d }; +inline constexpr yup::Color displayBackground { 0xff0e1114 }; +inline constexpr yup::Color accent { 0xff72ead2 }; +inline constexpr yup::Color accentDim { 0xff287f78 }; +inline constexpr yup::Color textPrimary { 0xffe6ebf0 }; +inline constexpr yup::Color textSecondary { 0xff8b96a0 }; + +constexpr float panelCorner = 6.0f; +} // namespace SynthTheme + +/** @internal Paints the rounded frame every panel of the instrument sits in. */ +inline void paintSynthPanel (yup::Graphics& g, yup::Rectangle bounds) +{ + g.setFillColor (SynthTheme::panelBackground); + g.fillRoundedRect (bounds, SynthTheme::panelCorner); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), SynthTheme::panelCorner); +} + +//============================================================================== +/** Text for a knob's value while it is being dragged. */ +namespace SynthFormat +{ +inline yup::String plain (double value, int decimals) { return yup::String (value, decimals); } +inline yup::String hertz (double value) { return value >= 1000.0 ? yup::String (value / 1000.0, 2) + " kHz" : yup::String (value, value < 100.0 ? 2 : 0) + " Hz"; } +inline yup::String percent (double value) { return yup::String (static_cast (std::round (value * 100.0))) + " %"; } +inline yup::String cents (double value) { return yup::String (static_cast (std::round (value))) + " ct"; } +inline yup::String octaves (double value) { return yup::String (static_cast (value)) + " oct"; } +inline yup::String milliseconds (double seconds) { return seconds >= 1.0 ? yup::String (seconds, 2) + " s" : yup::String (static_cast (std::round (seconds * 1000.0))) + " ms"; } +inline yup::String ratio (double value) { return yup::String (value, 2) + " x"; } +inline yup::String count (double value) { return yup::String (static_cast (value)); } +inline yup::String bipolar (double value) { return yup::String (value >= 0.0 ? "+" : "") + yup::String (static_cast (std::round (value * 100.0))) + " %"; } +} // namespace SynthFormat + +//============================================================================== +/** A rotary knob with its caption underneath, which shows the value while dragging. */ +class KnobControl : public yup::Component +{ +public: + KnobControl (const yup::String& caption, + double minimum, + double maximum, + double interval, + double defaultValue, + const yup::Font& font) + : slider (yup::Slider::RotaryVerticalDrag) + { + setOpaque (false); // the knob and its caption paint themselves, the row draws nothing + + slider.setRange (minimum, maximum, interval); + slider.setDefaultValue (defaultValue); + slider.setValue (defaultValue, yup::dontSendNotification); + slider.setColor (yup::Slider::Style::backgroundColorId, SynthTheme::displayBackground); + slider.setColor (yup::Slider::Style::trackColorId, SynthTheme::accent); + slider.setColor (yup::Slider::Style::thumbColorId, SynthTheme::textPrimary); + slider.setColor (yup::Slider::Style::thumbOverColorId, SynthTheme::accent); + slider.setColor (yup::Slider::Style::thumbDownColorId, SynthTheme::accent); + slider.onValueChanged = [this] (double value) + { + if (onChange != nullptr) + onChange (value); + + if (showingValue) + label.setText (formatCurrentValue(), yup::dontSendNotification); + }; + slider.onDragStart = [this] (const yup::MouseEvent&) + { + showingValue = true; + label.setText (formatCurrentValue(), yup::dontSendNotification); + }; + slider.onDragEnd = [this] (const yup::MouseEvent&) + { + showingValue = false; + label.setText (captionText, yup::dontSendNotification); + }; + addAndMakeVisible (slider); + + captionText = caption; + label.setText (caption, yup::dontSendNotification); + label.setFont (font); + label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (label); + } + + /** Called with the new knob value. */ + std::function onChange; + + /** Turns the value into the text shown while dragging; decimals from the interval when unset. */ + std::function formatValue; + + yup::Slider& getSlider() noexcept { return slider; } + + void resized() override + { + auto bounds = getLocalBounds(); + + label.setBounds (bounds.removeFromBottom (captionHeight)); + + const auto size = yup::jmin (bounds.getWidth(), bounds.getHeight()); + + slider.setBounds (bounds.withSizeKeepingCenter (size, size)); + } + +private: + static constexpr float captionHeight = 13.0f; + + yup::String formatCurrentValue() const + { + const auto value = slider.getValue(); + + if (formatValue != nullptr) + return formatValue (value); + + const auto interval = slider.getInterval(); + const auto decimals = interval >= 1.0 ? 0 : interval >= 0.1 ? 1 : interval >= 0.01 ? 2 : 3; + + return SynthFormat::plain (value, decimals); + } + + yup::Slider slider; + yup::Label label; + yup::String captionText; + bool showingValue = false; +}; + +//============================================================================== +/** A combo box with its caption above it. */ +class ChoiceControl : public yup::Component +{ +public: + ChoiceControl (const yup::String& caption, const yup::StringArray& items, const yup::Font& font) + { + setOpaque (false); // the caption and combo box paint themselves, the row draws nothing + + label.setText (caption, yup::dontSendNotification); + label.setFont (font); + label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (label); + + comboBox.addItemList (items, 1); + comboBox.setTextWhenNothingSelected ("-"); + comboBox.setColor (yup::ComboBox::Style::backgroundColorId, SynthTheme::displayBackground); + comboBox.setColor (yup::ComboBox::Style::textColorId, SynthTheme::textPrimary); + comboBox.setColor (yup::ComboBox::Style::borderColorId, SynthTheme::panelBorder); + comboBox.setColor (yup::ComboBox::Style::arrowColorId, SynthTheme::accent); + comboBox.onSelectedItemChanged = [this] + { + if (onChange != nullptr) + onChange (comboBox.getSelectedId()); + }; + addAndMakeVisible (comboBox); + } + + /** Called with the 1-based identifier of the newly selected item. */ + std::function onChange; + + yup::ComboBox& getComboBox() noexcept { return comboBox; } + + void resized() override + { + auto bounds = getLocalBounds(); + + label.setBounds (bounds.removeFromTop (captionHeight)); + comboBox.setBounds (bounds); + } + +private: + static constexpr float captionHeight = 13.0f; + + yup::Label label; + yup::ComboBox comboBox; +}; + +//============================================================================== +/** Sums a Fourier series at one point of its period. + + yup::FourierSeries stores coefficients rather than samples, so the waveform display + reconstructs them on demand. Only the message thread calls this. + + @param series The coefficients to sum + @param phase The position in the period, normalized to 0 to 1 + @param maxHarmonic The highest harmonic to include + + @returns The value of the series at that phase. +*/ +inline float evaluateFourierSeries (const yup::FourierSeries& series, double phase, int maxHarmonic) noexcept +{ + const auto count = yup::jmin (maxHarmonic, series.getNumHarmonics()); + const auto theta = yup::MathConstants::twoPi * phase; + + auto value = series.getDC(); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + { + const auto angle = theta * harmonic; + + value += series.getCosine (harmonic) * std::cos (angle) + + series.getSine (harmonic) * std::sin (angle); + } + + return static_cast (value); +} + +//============================================================================== +/** The waveform of one oscillator, either drawn or edited a partial at a time. + + In drawing mode the component reconstructs the series and shows one period of it. + In editing mode it shows the magnitude of each harmonic as a bar that can be + dragged, which is what actually defines the waveform the oscillator renders. + + Dragging writes straight into the settings, but the generation counter the audio + thread watches is only bumped by commitPendingEdits(), once per user interface + frame. Without that, one drag would queue an inverse FFT per mouse event on every + sounding voice. + + @see SynthOscillatorSettings +*/ +class WaveformEditor : public yup::Component +{ +public: + WaveformEditor (SynthOscillatorSettings& settingsToEdit, + const SynthOscillatorResources& sharedResources) + : settings (settingsToEdit) + , resources (sharedResources) + { + preview.prepare(); + displaySeries.resize (SynthExample::maxHarmonics); + displaySamples.assign (displayResolution, 0.0f); + + refresh(); + } + + /** Called whenever a drag changed the partials, so the panel can follow along. */ + std::function onPartialsChanged; + + /** Switches between drawing the waveform and editing its partials. */ + void setEditingPartials (bool shouldEdit) + { + editingPartials = shouldEdit; + repaint(); + } + + bool isEditingPartials() const noexcept { return editingPartials; } + + /** Rereads the series from the settings, following a preset or randomize change. */ + void refresh() + { + const auto usesCustomSeries = settings.usesCustomSeries.load(); + + if (usesCustomSeries) + settings.copyHarmonicsInto (displaySeries, 1.0f); + else + displaySeries.copyFrom (resources.getFrame (static_cast (settings.waveform.load()))); + + reconstruct (displaySeries); + + // The reconstruction is measured here, on the message thread, so the audio thread + // never has to work out how loud an edited spectrum turned out to be. This is the + // source's peak, which is a different quantity from the drawn waveform's below. + if (usesCustomSeries) + { + const auto sourcePeak = measurePeak(); + + if (sourcePeak > 1.0e-6f) + settings.harmonicScale.store (1.0f / sourcePeak); + } + + // Derived exactly as the voices derive theirs, so the preview cannot drift from + // what is played. + preview.invalidate(); + preview.update (settings.read(), settings, resources); + reconstruct (preview.getSeries()); + + // Normalize whatever is actually drawn. The shaper preserves the coefficient sum + // rather than the peak, and a sum bounds a peak from well above - three times over + // for a sawtooth - so scaling the derived waveform by the source's peak would draw + // it clean outside the display. + const auto peak = measurePeak(); + + if (peak > 1.0e-6f) + { + const auto scale = 1.0f / peak; + + for (auto& sample : displaySamples) + sample *= scale; + } + + repaint(); + } + + /** Sums a series into the display buffer at the display's resolution. */ + void reconstruct (const yup::FourierSeries& series) noexcept + { + for (int index = 0; index < displayResolution; ++index) + { + const auto phase = static_cast (index) / static_cast (displayResolution - 1); + + displaySamples[static_cast (index)] = + evaluateFourierSeries (series, phase, SynthExample::displayHarmonics); + } + } + + /** Returns the largest magnitude currently in the display buffer. */ + float measurePeak() const noexcept + { + auto peak = 0.0f; + + for (auto sample : displaySamples) + peak = yup::jmax (peak, std::abs (sample)); + + return peak; + } + + /** Publishes a pending drag to the audio thread, coalescing a frame's worth of edits. */ + void commitPendingEdits() + { + if (! pendingEdit) + return; + + pendingEdit = false; + settings.harmonicGeneration.fetch_add (1); + } + + /** Drops the edited partials and returns to the selected waveform preset. */ + void revertToPreset() + { + settings.usesCustomSeries.store (false); + pendingEdit = true; + + refresh(); + + if (onPartialsChanged != nullptr) + onPartialsChanged(); + } + + //============================================================================== + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + + if (editingPartials) + paintPartials (g, bounds.reduced (contentInset)); + else + paintWaveform (g, bounds.reduced (contentInset)); + } + + void mouseDown (const yup::MouseEvent& event) override { applyEdit (event); } + + void mouseDrag (const yup::MouseEvent& event) override { applyEdit (event); } + +private: + //============================================================================== + /** Draws one period of the reconstructed series. */ + void paintWaveform (yup::Graphics& g, yup::Rectangle bounds) + { + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + + path.clear(); + path.reserveSpace (displayResolution); + + for (int index = 0; index < displayResolution; ++index) + { + const auto x = bounds.getX() + bounds.getWidth() * static_cast (index) + / static_cast (displayResolution - 1); + + const auto y = bounds.getCenterY() - displaySamples[static_cast (index)] * bounds.getHeight() * 0.45f; + + if (index == 0) + path.moveTo (x, y); + else + path.lineTo (x, y); + } + + g.setStrokeColor (SynthTheme::accent.withAlpha (0.35f)); + g.setStrokeWidth (4.0f); + g.setFeather (6.0f); + g.strokePath (path); + + g.setFeather (0.0f); + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + } + + /** Draws the editable magnitude of every harmonic. */ + void paintPartials (yup::Graphics& g, yup::Rectangle bounds) + { + const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); + + for (int index = 0; index < SynthExample::editableHarmonics; ++index) + { + const auto magnitude = yup::jlimit (0.0f, 1.0f, static_cast (displaySeries.getMagnitude (index + 1))); + const auto height = yup::jmax (1.0f, magnitude * bounds.getHeight()); + const auto x = bounds.getX() + barWidth * static_cast (index); + + const yup::Rectangle bar { x + barGap, bounds.getBottom() - height, yup::jmax (1.0f, barWidth - barGap * 2.0f), height }; + + g.setFillColor (magnitude > 0.0f ? SynthTheme::accent : SynthTheme::panelBorder); + g.fillRect (bar); + } + } + + //============================================================================== + /** Turns a mouse position into the magnitude of one harmonic. */ + void applyEdit (const yup::MouseEvent& event) + { + if (! editingPartials) + return; + + const auto bounds = getLocalBounds().reduced (contentInset); + + if (bounds.getWidth() <= 0.0f || bounds.getHeight() <= 0.0f) + return; + + // Editing a preset copies its partials in first, so the drag starts from the + // shape that is on screen instead of from silence. + if (! settings.usesCustomSeries.load()) + { + settings.seedHarmonicsFrom (displaySeries); + settings.usesCustomSeries.store (true); + } + + const auto position = event.getPosition(); + const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); + const auto index = yup::jlimit (0, + SynthExample::editableHarmonics - 1, + static_cast ((position.getX() - bounds.getX()) / barWidth)); + + const auto magnitude = yup::jlimit (0.0f, 1.0f, (bounds.getBottom() - position.getY()) / bounds.getHeight()); + + settings.harmonics[static_cast (index)].store (magnitude); + pendingEdit = true; + + refresh(); + + if (onPartialsChanged != nullptr) + onPartialsChanged(); + } + + //============================================================================== + static constexpr int displayResolution = 256; + static constexpr float contentInset = 6.0f; + static constexpr float barGap = 1.0f; + + SynthOscillatorSettings& settings; + const SynthOscillatorResources& resources; + + SynthSpectrumDerivation preview; + yup::FourierSeries displaySeries; + std::vector displaySamples; + yup::Path path; + + bool editingPartials = false; + bool pendingEdit = false; +}; + +//============================================================================== +/** Draws the most recent block of rendered audio as a waveform. */ +class Oscilloscope : public yup::Component +{ +public: + Oscilloscope() + : Component ("Oscilloscope") + { + } + + /** Copies the samples to display. Called from the message thread. */ + void setRenderData (const std::vector& data) + { + renderData = data; + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + + if (renderData.empty()) + return; + + const auto pointCount = yup::jmin (512, static_cast (renderData.size())); + const auto xSize = bounds.getWidth() / static_cast (yup::jmax (1, pointCount - 1)); + + path.clear(); + path.reserveSpace (pointCount); + path.moveTo (bounds.getX(), bounds.getCenterY() - renderData[0] * bounds.getHeight() * 0.45f); + + for (int i = 1; i < pointCount; ++i) + { + const auto sample = static_cast (i) * (renderData.size() - 1) / static_cast (pointCount - 1); + path.lineTo (bounds.getX() + static_cast (i) * xSize, + bounds.getCenterY() - renderData[sample] * bounds.getHeight() * 0.45f); + } + + filledPath = path.createStrokePolygon (4.0f); + + g.setFillColor (SynthTheme::accent.withAlpha (0.5f)); + g.setFeather (8.0f); + g.fillPath (filledPath); + + g.setFeather (4.0f); + g.fillPath (filledPath); + + g.setFeather (0.0f); + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + } + +private: + std::vector renderData; + yup::Path path; + yup::Path filledPath; +}; + +//============================================================================== +/** @internal Spreads controls evenly across a row. */ +inline void layoutControlsInRow (yup::Rectangle area, const std::vector& controls) +{ + if (controls.empty()) + return; + + const auto width = area.getWidth() / static_cast (controls.size()); + + for (auto* control : controls) + control->setBounds (area.removeFromLeft (width).reduced (3.0f, 0.0f)); +} + +//============================================================================== +/** Draws the shape the amplitude envelope traces, with a point at every breakpoint. */ +class EnvelopeDisplay : public yup::Component +{ +public: + /** Updates the drawn shape. Called from the message thread. */ + void setValues (const SynthEnvelopeValues& newValues) + { + values = newValues; + repaint(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + + const auto area = bounds.reduced (8.0f); + + if (area.getWidth() <= 0.0f || area.getHeight() <= 0.0f) + return; + + // The sustain stage has no duration of its own, so it is given a fixed share of + // the width and the timed stages share what is left. + const auto totalSeconds = yup::jmax (1.0e-4f, values.delay + values.attack + values.hold + values.decay + values.release); + const auto timedWidth = area.getWidth() * (1.0f - sustainShare); + const auto secondsToPixels = timedWidth / totalSeconds; + + const auto levelToY = [area] (float level) + { + return area.getBottom() - yup::jlimit (0.0f, 1.0f, level) * area.getHeight(); + }; + + constexpr int numPoints = 7; + + const yup::Point points[numPoints] = { + { area.getX(), levelToY (0.0f) }, + { area.getX() + values.delay * secondsToPixels, levelToY (0.0f) }, + { area.getX() + (values.delay + values.attack) * secondsToPixels, levelToY (1.0f) }, + { area.getX() + (values.delay + values.attack + values.hold) * secondsToPixels, levelToY (1.0f) }, + { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels, levelToY (values.sustain) }, + { area.getX() + (values.delay + values.attack + values.hold + values.decay) * secondsToPixels + area.getWidth() * sustainShare, levelToY (values.sustain) }, + { area.getRight(), levelToY (0.0f) } + }; + + path.clear(); + path.reserveSpace (numPoints + 2); + path.moveTo (points[0]); + + for (int index = 1; index < numPoints; ++index) + path.lineTo (points[index]); + + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + + for (int index = 1; index < numPoints - 1; ++index) + { + g.setFillColor (SynthTheme::accent); + g.fillEllipse (yup::Rectangle (points[index].getX() - pointRadius, + points[index].getY() - pointRadius, + pointRadius * 2.0f, + pointRadius * 2.0f)); + } + } + +private: + static constexpr float sustainShare = 0.22f; + static constexpr float pointRadius = 3.0f; + + SynthEnvelopeValues values; + yup::Path path; +}; + +//============================================================================== +/** The editing surface of one envelope. + + @see SynthEnvelopeSettings +*/ +class SynthEnvelopePanel : public yup::Component +{ +public: + SynthEnvelopePanel (const yup::String& panelTitle, SynthEnvelopeSettings& settingsToEdit, const yup::Font& font) + : settings (settingsToEdit) + , delayKnob ("DELAY", 0.0, 2.0, 0.001, 0.0, font) + , attackKnob ("ATTACK", 0.001, 4.0, 0.001, 0.005, font) + , holdKnob ("HOLD", 0.0, 2.0, 0.001, 0.0, font) + , decayKnob ("DECAY", 0.001, 4.0, 0.001, 0.35, font) + , sustainKnob ("SUSTAIN", 0.0, 1.0, 0.001, 0.7, font) + , releaseKnob ("RELEASE", 0.001, 8.0, 0.001, 0.35, font) + { + titleLabel.setText (panelTitle, yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + addAndMakeVisible (display); + + for (auto* knob : { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }) + addAndMakeVisible (*knob); + + for (auto* knob : { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &releaseKnob }) + knob->formatValue = SynthFormat::milliseconds; + + sustainKnob.formatValue = SynthFormat::percent; + + delayKnob.onChange = [this] (double value) { settings.delay = static_cast (value); refreshDisplay(); }; + attackKnob.onChange = [this] (double value) { settings.attack = static_cast (value); refreshDisplay(); }; + holdKnob.onChange = [this] (double value) { settings.hold = static_cast (value); refreshDisplay(); }; + decayKnob.onChange = [this] (double value) { settings.decay = static_cast (value); refreshDisplay(); }; + sustainKnob.onChange = [this] (double value) { settings.sustain = static_cast (value); refreshDisplay(); }; + releaseKnob.onChange = [this] (double value) { settings.release = static_cast (value); refreshDisplay(); }; + + refresh(); + } + + /** Reads the settings back into the knobs and the drawn shape. */ + void refresh() + { + delayKnob.getSlider().setValue (settings.delay.load(), yup::dontSendNotification); + attackKnob.getSlider().setValue (settings.attack.load(), yup::dontSendNotification); + holdKnob.getSlider().setValue (settings.hold.load(), yup::dontSendNotification); + decayKnob.getSlider().setValue (settings.decay.load(), yup::dontSendNotification); + sustainKnob.getSlider().setValue (settings.sustain.load(), yup::dontSendNotification); + releaseKnob.getSlider().setValue (settings.release.load(), yup::dontSendNotification); + + refreshDisplay(); + } + + void resized() override + { + auto bounds = getLocalBounds().reduced (panelInset); + + titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + bounds.removeFromTop (spacing); + + auto knobArea = bounds.removeFromBottom (knobRowHeight); + bounds.removeFromBottom (spacing); + + display.setBounds (bounds); + + layoutControlsInRow (knobArea, { &delayKnob, &attackKnob, &holdKnob, &decayKnob, &sustainKnob, &releaseKnob }); + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); + } + +private: + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 16.0f; + static constexpr float knobRowHeight = 58.0f; + static constexpr float spacing = 6.0f; + + void refreshDisplay() { display.setValues (settings.read()); } + + SynthEnvelopeSettings& settings; + + yup::Label titleLabel; + EnvelopeDisplay display; + + KnobControl delayKnob; + KnobControl attackKnob; + KnobControl holdKnob; + KnobControl decayKnob; + KnobControl sustainKnob; + KnobControl releaseKnob; +}; + +//============================================================================== +/** Draws one cycle of an LFO shape with a marker at the engine's current phase. */ +class LFODisplay : public yup::Component +{ +public: + /** Updates the drawn shape. Called from the message thread. */ + void setValues (const SynthLFOValues& newValues) + { + values = newValues; + repaint(); + } + + /** Moves the marker. Called every frame from the message thread. */ + void setPhase (float newPhase) + { + if (std::abs (newPhase - phase) < 1.0e-3f) + return; + + phase = newPhase; + repaint(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, 4.0f); + + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + + const auto area = bounds.reduced (8.0f); + + if (area.getWidth() <= 0.0f || area.getHeight() <= 0.0f) + return; + + g.strokeLine (area.getX(), area.getCenterY(), area.getRight(), area.getCenterY()); + + // One period at one sample per point: the shape is read the way the engine reads it. + yup::LFO shape; + shape.prepare (static_cast (points)); + shape.setFrequency (1.0f); + shape.setShape (values.shape); + shape.setPhaseOffset (values.phase); + + path.clear(); + path.reserveSpace (points + 1); + + for (int index = 0; index <= points; ++index) + { + const auto x = area.getX() + area.getWidth() * static_cast (index) / static_cast (points); + const auto y = area.getCenterY() - shape.processSample() * area.getHeight() * 0.45f; + + if (index == 0) + path.moveTo (x, y); + else + path.lineTo (x, y); + } + + g.setStrokeColor (SynthTheme::accent); + g.setStrokeWidth (1.5f); + g.strokePath (path); + + shape.reset (phase); + + const auto markerX = area.getX() + area.getWidth() * phase; + const auto markerY = area.getCenterY() - shape.getValue() * area.getHeight() * 0.45f; + + g.setFillColor (SynthTheme::textPrimary); + g.fillEllipse (yup::Rectangle (markerX - 3.0f, markerY - 3.0f, 6.0f, 6.0f)); + } + +private: + static constexpr int points = 128; + + SynthLFOValues values; + float phase = 0.0f; + yup::Path path; +}; + +//============================================================================== +/** Shape, rate and phase of one LFO, with its display. + + @see SynthLFOSettings +*/ +class SynthLFOPanel : public yup::Component +{ +public: + SynthLFOPanel (const yup::String& panelTitle, SynthLFOSettings& settingsToEdit, const yup::Font& font) + : settings (settingsToEdit) + , shapeChoice ("SHAPE", getSynthLFOShapeNames(), font) + , rateKnob ("RATE", 0.01, 20.0, 0.01, 1.0, font) + , phaseKnob ("PHASE", 0.0, 1.0, 0.001, 0.0, font) + { + titleLabel.setText (panelTitle, yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + addAndMakeVisible (display); + addAndMakeVisible (shapeChoice); + addAndMakeVisible (rateKnob); + addAndMakeVisible (phaseKnob); + + rateKnob.getSlider().setSkewFactorFromMidpoint (1.0); + rateKnob.formatValue = SynthFormat::hertz; + phaseKnob.formatValue = SynthFormat::percent; + + shapeChoice.onChange = [this] (int id) { settings.shape = id - 1; refreshDisplay(); }; + rateKnob.onChange = [this] (double value) { settings.rate = static_cast (value); refreshDisplay(); }; + phaseKnob.onChange = [this] (double value) { settings.phase = static_cast (value); refreshDisplay(); }; + + refresh(); + } + + /** Reads the settings back into the widgets and the drawn shape. */ + void refresh() + { + shapeChoice.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); + rateKnob.getSlider().setValue (settings.rate.load(), yup::dontSendNotification); + phaseKnob.getSlider().setValue (settings.phase.load(), yup::dontSendNotification); + + refreshDisplay(); + } + + /** Moves the display marker to the engine's phase. */ + void setPhase (float phase) { display.setPhase (phase); } + + void resized() override + { + auto bounds = getLocalBounds().reduced (panelInset); + + titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + bounds.removeFromTop (spacing); + + auto controls = bounds.removeFromRight (controlWidth); + bounds.removeFromRight (spacing); + display.setBounds (bounds); + + shapeChoice.setBounds (controls.removeFromTop (choiceHeight)); + controls.removeFromTop (spacing); + layoutControlsInRow (controls, { &rateKnob, &phaseKnob }); + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); + } + +private: + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 16.0f; + static constexpr float choiceHeight = 36.0f; + static constexpr float controlWidth = 130.0f; + static constexpr float spacing = 6.0f; + + void refreshDisplay() { display.setValues (settings.read()); } + + SynthLFOSettings& settings; + + yup::Label titleLabel; + LFODisplay display; + ChoiceControl shapeChoice; + KnobControl rateKnob; + KnobControl phaseKnob; +}; + +//============================================================================== +/** Type, cutoff, resonance, drive and keytracking of the per-voice filter. + + @see SynthFilterSettings +*/ +class SynthFilterPanel : public yup::Component +{ +public: + SynthFilterPanel (SynthFilterSettings& settingsToEdit, const yup::Font& font) + : settings (settingsToEdit) + , typeChoice ("TYPE", getSynthFilterTypeNames(), font) + , cutoffKnob ("CUTOFF", 20.0, 20000.0, 1.0, 8000.0, font) + , resonanceKnob ("RESO", 0.0, 1.0, 0.001, 0.2, font) + , driveKnob ("DRIVE", 0.0, 1.0, 0.001, 0.0, font) + , keytrackKnob ("KEYTRACK", 0.0, 1.0, 0.001, 0.0, font) + { + titleLabel.setText ("FILTER", yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + addAndMakeVisible (typeChoice); + + for (auto* knob : { &cutoffKnob, &resonanceKnob, &driveKnob, &keytrackKnob }) + addAndMakeVisible (*knob); + + cutoffKnob.getSlider().setSkewFactorFromMidpoint (1000.0); + cutoffKnob.formatValue = SynthFormat::hertz; + resonanceKnob.formatValue = SynthFormat::percent; + driveKnob.formatValue = SynthFormat::percent; + keytrackKnob.formatValue = SynthFormat::percent; + + typeChoice.onChange = [this] (int id) { settings.type = id - 1; }; + cutoffKnob.onChange = [this] (double value) { settings.cutoff = static_cast (value); }; + resonanceKnob.onChange = [this] (double value) { settings.resonance = static_cast (value); }; + driveKnob.onChange = [this] (double value) { settings.drive = static_cast (value); }; + keytrackKnob.onChange = [this] (double value) { settings.keytrack = static_cast (value); }; + + refresh(); + } + + /** Reads the settings back into the widgets. */ + void refresh() + { + typeChoice.getComboBox().setSelectedId (settings.type.load() + 1, yup::dontSendNotification); + cutoffKnob.getSlider().setValue (settings.cutoff.load(), yup::dontSendNotification); + resonanceKnob.getSlider().setValue (settings.resonance.load(), yup::dontSendNotification); + driveKnob.getSlider().setValue (settings.drive.load(), yup::dontSendNotification); + keytrackKnob.getSlider().setValue (settings.keytrack.load(), yup::dontSendNotification); + } + + void resized() override + { + auto bounds = getLocalBounds().reduced (panelInset); + + titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + bounds.removeFromTop (spacing); + + typeChoice.setBounds (bounds.removeFromTop (choiceHeight)); + bounds.removeFromTop (spacing); + + layoutControlsInRow (bounds, { &cutoffKnob, &resonanceKnob, &driveKnob, &keytrackKnob }); + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); + } + +private: + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 16.0f; + static constexpr float choiceHeight = 36.0f; + static constexpr float spacing = 6.0f; + + SynthFilterSettings& settings; + + yup::Label titleLabel; + ChoiceControl typeChoice; + KnobControl cutoffKnob; + KnobControl resonanceKnob; + KnobControl driveKnob; + KnobControl keytrackKnob; +}; + +//============================================================================== +/** The editing surface of one oscillator, writing straight into the voice settings. + + Every widget is wired to a single atomic setting, and refresh() copies the + settings back into the widgets for changes coming from somewhere else, such as + the randomize button. + + @see SynthOscillatorSettings, WaveformEditor +*/ +class SynthOscillatorPanel : public yup::Component +{ +public: + SynthOscillatorPanel (const yup::String& panelTitle, + SynthOscillatorSettings& settingsToEdit, + const SynthOscillatorResources& resources, + const yup::Font& font) + : settings (settingsToEdit) + , editor (settingsToEdit, resources) + , waveformChoice ("WAVEFORM", getSynthWaveformNames(), font) + , syncModeChoice ("SYNC", getSynthSyncModeNames(), font) + , levelKnob ("LEVEL", 0.0, 1.0, 0.001, 0.5, font) + , octaveKnob ("OCTAVE", -3.0, 3.0, 1.0, 0.0, font) + , detuneKnob ("CENTS", -100.0, 100.0, 1.0, 0.0, font) + , ridgesKnob ("RIDGES", 0.25, 8.0, 0.01, 1.5, font) + , colorKnob ("COLOR", 0.0, 1.0, 0.001, 0.0, font) + , dispersionKnob ("DISPERSION", 0.01, 0.99, 0.001, 0.5, font) + , squeezeKnob ("SQUEEZE", 0.0, 0.5, 0.001, 0.0, font) + , squashKnob ("SQUASH", 0.1, 4.0, 0.01, 1.0, font) + , tiltKnob ("TILT", -4.0, 4.0, 0.01, 0.0, font) + , oddEvenKnob ("ODD/EVEN", 0.0, 1.0, 0.001, 0.5, font) + , formantKnob ("FORMANT", -4.0, 4.0, 0.01, 0.0, font) + , formantPositionKnob ("F.POS", 0.0, 7.0, 0.01, 2.0, font) + , scatterKnob ("SCATTER", 0.0, 1.0, 0.001, 0.0, font) + , syncRatioKnob ("SYNC RATIO", 1.0, 8.0, 0.01, 1.5, font) + , unisonKnob ("UNISON", 1.0, static_cast (SynthExample::maxUnisonVoices), 1.0, 1.0, font) + , unisonDetuneKnob ("U.DETUNE", 0.0, 1.0, 0.001, 0.2, font) + , spreadKnob ("SPREAD", 0.0, 1.0, 0.001, 0.6, font) + { + titleLabel.setText (panelTitle, yup::dontSendNotification); + titleLabel.setFont (font.withHeight (12.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); + + partialsButton.setButtonText ("PARTIALS"); + partialsButton.setColor (yup::ToggleButton::Style::backgroundColorId, SynthTheme::displayBackground); + partialsButton.setColor (yup::ToggleButton::Style::backgroundToggledColorId, SynthTheme::accentDim); + partialsButton.setColor (yup::ToggleButton::Style::textColorId, SynthTheme::textSecondary); + partialsButton.setColor (yup::ToggleButton::Style::textToggledColorId, SynthTheme::textPrimary); + partialsButton.setColor (yup::ToggleButton::Style::borderColorId, SynthTheme::panelBorder); + partialsButton.setColor (yup::ToggleButton::Style::borderToggledColorId, SynthTheme::accent); + partialsButton.onClick = [this] { editor.setEditingPartials (partialsButton.getToggleState()); }; + addAndMakeVisible (partialsButton); + + resetButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::displayBackground); + resetButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textSecondary); + resetButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); + resetButton.onClick = [this] { editor.revertToPreset(); }; + addAndMakeVisible (resetButton); + + addAndMakeVisible (editor); + + for (auto* choice : { &waveformChoice, &syncModeChoice }) + addAndMakeVisible (*choice); + + for (auto* knob : { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob, + &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, + &formantPositionKnob, &scatterKnob, &syncRatioKnob, + &unisonKnob, &unisonDetuneKnob, &spreadKnob }) + addAndMakeVisible (*knob); + + for (auto* knob : { &levelKnob, &colorKnob, &squeezeKnob, &oddEvenKnob, &scatterKnob, &unisonDetuneKnob, &spreadKnob }) + knob->formatValue = SynthFormat::percent; + + octaveKnob.formatValue = SynthFormat::octaves; + detuneKnob.formatValue = SynthFormat::cents; + ridgesKnob.formatValue = SynthFormat::ratio; + syncRatioKnob.formatValue = SynthFormat::ratio; + unisonKnob.formatValue = SynthFormat::count; + + // Picking a preset drops any edited partials, otherwise the oscillator would keep + // playing the edited shape while the combo box claims something else. + waveformChoice.onChange = [this] (int id) + { + settings.waveform = id - 1; + editor.revertToPreset(); + }; + + syncModeChoice.onChange = [this] (int id) + { + settings.syncMode = id - 1; + updateSyncAvailability(); + editor.refresh(); + }; + + levelKnob.onChange = [this] (double value) { settings.level = static_cast (value); }; + octaveKnob.onChange = [this] (double value) { settings.octave = static_cast (value); }; + detuneKnob.onChange = [this] (double value) { settings.detuneSemitones = static_cast (value * 0.01); }; + ridgesKnob.onChange = [this] (double value) { settings.ridgeSpacing = static_cast (value); editor.refresh(); }; + colorKnob.onChange = [this] (double value) { settings.color = static_cast (value); editor.refresh(); }; + dispersionKnob.onChange = [this] (double value) { settings.dispersion = static_cast (value); editor.refresh(); }; + squeezeKnob.onChange = [this] (double value) { settings.squeeze = static_cast (value); editor.refresh(); }; + squashKnob.onChange = [this] (double value) { settings.squash = static_cast (value); editor.refresh(); }; + tiltKnob.onChange = [this] (double value) { settings.tilt = static_cast (value); editor.refresh(); }; + oddEvenKnob.onChange = [this] (double value) { settings.oddEven = static_cast (value); editor.refresh(); }; + formantKnob.onChange = [this] (double value) { settings.formant = static_cast (value); editor.refresh(); }; + formantPositionKnob.onChange = [this] (double value) { settings.formantPosition = static_cast (value); editor.refresh(); }; + scatterKnob.onChange = [this] (double value) { settings.scatter = static_cast (value); editor.refresh(); }; + syncRatioKnob.onChange = [this] (double value) { settings.syncRatio = static_cast (value); editor.refresh(); }; + unisonKnob.onChange = [this] (double value) { settings.unisonVoices = static_cast (value); }; + unisonDetuneKnob.onChange = [this] (double value) { settings.unisonDetune = static_cast (value); }; + spreadKnob.onChange = [this] (double value) { settings.unisonSpread = static_cast (value); }; + + refresh(); + } + + /** Reads the settings back into the widgets. */ + void refresh() + { + waveformChoice.getComboBox().setSelectedId (settings.waveform.load() + 1, yup::dontSendNotification); + syncModeChoice.getComboBox().setSelectedId (settings.syncMode.load() + 1, yup::dontSendNotification); + + levelKnob.getSlider().setValue (settings.level.load(), yup::dontSendNotification); + octaveKnob.getSlider().setValue (settings.octave.load(), yup::dontSendNotification); + detuneKnob.getSlider().setValue (settings.detuneSemitones.load() * 100.0, yup::dontSendNotification); + ridgesKnob.getSlider().setValue (settings.ridgeSpacing.load(), yup::dontSendNotification); + colorKnob.getSlider().setValue (settings.color.load(), yup::dontSendNotification); + dispersionKnob.getSlider().setValue (settings.dispersion.load(), yup::dontSendNotification); + squeezeKnob.getSlider().setValue (settings.squeeze.load(), yup::dontSendNotification); + squashKnob.getSlider().setValue (settings.squash.load(), yup::dontSendNotification); + tiltKnob.getSlider().setValue (settings.tilt.load(), yup::dontSendNotification); + oddEvenKnob.getSlider().setValue (settings.oddEven.load(), yup::dontSendNotification); + formantKnob.getSlider().setValue (settings.formant.load(), yup::dontSendNotification); + formantPositionKnob.getSlider().setValue (settings.formantPosition.load(), yup::dontSendNotification); + scatterKnob.getSlider().setValue (settings.scatter.load(), yup::dontSendNotification); + syncRatioKnob.getSlider().setValue (settings.syncRatio.load(), yup::dontSendNotification); + unisonKnob.getSlider().setValue (settings.unisonVoices.load(), yup::dontSendNotification); + unisonDetuneKnob.getSlider().setValue (settings.unisonDetune.load(), yup::dontSendNotification); + spreadKnob.getSlider().setValue (settings.unisonSpread.load(), yup::dontSendNotification); + + updateSyncAvailability(); + + editor.refresh(); + } + + /** Publishes a frame's worth of partial edits to the audio thread. */ + void commitPendingEdits() { editor.commitPendingEdits(); } + + void resized() override + { + auto bounds = getLocalBounds().reduced (panelInset); + + auto header = bounds.removeFromTop (headerHeight); + resetButton.setBounds (header.removeFromRight (buttonWidth)); + header.removeFromRight (spacing); + partialsButton.setBounds (header.removeFromRight (buttonWidth)); + titleLabel.setBounds (header); + + bounds.removeFromTop (spacing); + + auto knobArea = bounds.removeFromBottom (knobRowHeight * 3.0f + spacing * 2.0f); + bounds.removeFromBottom (spacing); + + auto choiceArea = bounds.removeFromBottom (choiceRowHeight); + bounds.removeFromBottom (spacing); + + editor.setBounds (bounds); + + layoutControlsInRow (choiceArea, { &waveformChoice, &syncModeChoice }); + + layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), + { &levelKnob, &octaveKnob, &detuneKnob, &ridgesKnob, &colorKnob, &dispersionKnob }); + + knobArea.removeFromTop (spacing); + + layoutControlsInRow (knobArea.removeFromTop (knobRowHeight), + { &squeezeKnob, &squashKnob, &tiltKnob, &oddEvenKnob, &formantKnob, &formantPositionKnob }); + + knobArea.removeFromTop (spacing); + + layoutControlsInRow (knobArea, + { &scatterKnob, &syncRatioKnob, &unisonKnob, &unisonDetuneKnob, &spreadKnob }); + } + + void paint (yup::Graphics& g) override + { + paintSynthPanel (g, getLocalBounds()); + } + +private: + //============================================================================== + /** Greys out the ratio knob while no sync mode reads it. */ + void updateSyncAvailability() + { + syncRatioKnob.setEnabled (static_cast (settings.syncMode.load()) != yup::SyncMode::none); + } + + //============================================================================== + static constexpr float panelInset = 8.0f; + static constexpr float headerHeight = 18.0f; + static constexpr float choiceRowHeight = 36.0f; + static constexpr float knobRowHeight = 58.0f; + static constexpr float buttonWidth = 68.0f; + static constexpr float spacing = 6.0f; + + SynthOscillatorSettings& settings; + + yup::Label titleLabel; + yup::ToggleButton partialsButton; + yup::TextButton resetButton { "RESET" }; + WaveformEditor editor; + + ChoiceControl waveformChoice; + ChoiceControl syncModeChoice; + + KnobControl levelKnob; + KnobControl octaveKnob; + KnobControl detuneKnob; + KnobControl ridgesKnob; + KnobControl colorKnob; + KnobControl dispersionKnob; + KnobControl squeezeKnob; + KnobControl squashKnob; + KnobControl tiltKnob; + KnobControl oddEvenKnob; + KnobControl formantKnob; + KnobControl formantPositionKnob; + KnobControl scatterKnob; + KnobControl syncRatioKnob; + KnobControl unisonKnob; + KnobControl unisonDetuneKnob; + KnobControl spreadKnob; +}; diff --git a/examples/graphics/source/examples/audio/SynthSettings.h b/examples/graphics/source/examples/audio/SynthSettings.h new file mode 100644 index 000000000..6e84ecf69 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -0,0 +1,577 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include +#include +#include + +//============================================================================== +/** Sizing shared by the polyphonic engine and its user interface. */ +namespace SynthExample +{ +constexpr int voiceCount = 8; +constexpr int oscillatorCount = 2; +constexpr int maxHarmonics = 128; +constexpr int maxBlockSize = 2048; +constexpr int envelopeCount = 2; +constexpr int lfoCount = 2; +constexpr int modulationSlots = 8; + +/** Samples between control updates: modulation, filter targets and local spectra. */ +constexpr int controlChunk = 128; + +/** Unison slots per oscillator, counting the one running the selected algorithm. */ +constexpr int maxUnisonVoices = 5; + +/** Harmonics the partial editor exposes, a subset of the maxHarmonics the engine renders. */ +constexpr int editableHarmonics = 32; + +/** Harmonics the waveform display sums, capped well below maxHarmonics to keep repaints cheap. */ +constexpr int displayHarmonics = 64; + +constexpr double levelRampSeconds = 0.01; +} // namespace SynthExample + +/** @internal Item names for yup::Waveform, index aligned with the enumeration. */ +inline yup::StringArray getSynthWaveformNames() +{ + return { "Sine", "Cosine", "Sawtooth", "Square", "Triangle", "Pulse" }; +} + +/** @internal Item names for yup::SyncMode, index aligned with the enumeration. */ +inline yup::StringArray getSynthSyncModeNames() +{ + return { "None", "Hard", "Mirrored", "Pulsar" }; +} + +//============================================================================== +/** A plain snapshot of the amplitude envelope's controls. */ +struct SynthEnvelopeValues +{ + float delay = 0.0f; + float attack = 0.005f; + float hold = 0.0f; + float decay = 0.35f; + float sustain = 0.7f; + float release = 0.35f; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. */ +struct SynthEnvelopeSettings +{ + std::atomic delay { 0.0f }; + std::atomic attack { 0.005f }; + std::atomic hold { 0.0f }; + std::atomic decay { 0.35f }; + std::atomic sustain { 0.7f }; + std::atomic release { 0.35f }; + + /** Takes a snapshot for one block of audio. */ + SynthEnvelopeValues read() const noexcept + { + return { delay.load(), attack.load(), hold.load(), decay.load(), sustain.load(), release.load() }; + } +}; + +//============================================================================== +/** A plain snapshot of one oscillator's controls. + + The audio thread takes one snapshot per block and compares it with the values it + applied last time, so a control that did not move never costs a spectral + transform or a table render. + + The edited partials are deliberately not copied here. They live behind + harmonicGeneration, so a block only pays for them when the editor actually moved. +*/ +struct SynthOscillatorValues +{ + yup::Waveform waveform = yup::Waveform::sawtooth; + yup::SyncMode syncMode = yup::SyncMode::none; + float syncRatio = 1.5f; + float level = 0.5f; + int octave = 0; + float detuneSemitones = 0.0f; + float ridgeSpacing = 1.5f; + float color = 0.0f; + float dispersion = 0.5f; + float squeeze = 0.0f; + float squash = 1.0f; + float tilt = 0.0f; + float oddEven = 0.5f; + float formant = 0.0f; + float formantPosition = 2.0f; + float scatter = 0.0f; + int unisonVoices = 1; + float unisonDetune = 0.2f; + float unisonSpread = 0.6f; + float harmonicScale = 1.0f; + bool usesCustomSeries = false; + int harmonicGeneration = 0; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. + + The partial editor writes magnitudes continuously while the mouse is down but bumps + harmonicGeneration at most once per user interface frame. The audio thread rebuilds + its series only when that counter moves, which keeps a drag from forcing an inverse + FFT per mouse event on every sounding voice. + + @see SynthOscillator, WaveformEditor +*/ +struct SynthOscillatorSettings +{ + SynthOscillatorSettings() + { + for (auto& harmonic : harmonics) + harmonic.store (0.0f); + } + + std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; + std::atomic syncMode { static_cast (yup::SyncMode::none) }; + std::atomic syncRatio { 1.5f }; + std::atomic level { 0.5f }; + std::atomic octave { 0 }; + std::atomic detuneSemitones { 0.0f }; + std::atomic ridgeSpacing { 1.5f }; + std::atomic color { 0.0f }; + std::atomic dispersion { 0.5f }; + std::atomic squeeze { 0.0f }; + std::atomic squash { 1.0f }; + std::atomic tilt { 0.0f }; + std::atomic oddEven { 0.5f }; + std::atomic formant { 0.0f }; + std::atomic formantPosition { 2.0f }; + std::atomic scatter { 0.0f }; + std::atomic unisonVoices { 1 }; + std::atomic unisonDetune { 0.2f }; + std::atomic unisonSpread { 0.6f }; + + std::array, SynthExample::editableHarmonics> harmonics; + std::atomic harmonicScale { 1.0f }; + std::atomic usesCustomSeries { false }; + std::atomic harmonicGeneration { 0 }; + + /** Takes a snapshot for one block of audio. */ + SynthOscillatorValues read() const noexcept + { + return { static_cast (waveform.load()), + static_cast (syncMode.load()), + syncRatio.load(), + level.load(), + octave.load(), + detuneSemitones.load(), + ridgeSpacing.load(), + color.load(), + dispersion.load(), + squeeze.load(), + squash.load(), + tilt.load(), + oddEven.load(), + formant.load(), + formantPosition.load(), + scatter.load(), + unisonVoices.load(), + unisonDetune.load(), + unisonSpread.load(), + harmonicScale.load(), + usesCustomSeries.load(), + harmonicGeneration.load() }; + } + + /** Rebuilds a prepared series from the edited magnitudes, without allocating. + + The editor works in magnitudes only and writes them as sine coefficients, the + same convention yup::FourierSeries::setWaveform uses for its sawtooth, square + and triangle presets. + + Nothing stops the editor from asking for every harmonic at once, which would sum + to many times full scale, so the caller passes the scale that brings the + reconstruction back to a peak of one. WaveformEditor measures it while it redraws + and publishes it with the same generation bump; the audio thread passes it back + in here rather than measuring anything itself. + + @param series The prepared series to overwrite + @param scale The factor to apply to every coefficient + */ + void copyHarmonicsInto (yup::FourierSeries& series, float scale) const noexcept + { + series.clear(); + + const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + { + const auto magnitude = harmonics[static_cast (harmonic - 1)].load() * scale; + + series.setHarmonic (harmonic, 0.0, static_cast (magnitude)); + } + } + + /** Seeds the edited magnitudes from a series, so editing starts at the visible shape. */ + void seedHarmonicsFrom (const yup::FourierSeries& series) noexcept + { + const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); + + for (int harmonic = 1; harmonic <= count; ++harmonic) + harmonics[static_cast (harmonic - 1)].store (static_cast (series.getMagnitude (harmonic))); + + for (int harmonic = count; harmonic < SynthExample::editableHarmonics; ++harmonic) + harmonics[static_cast (harmonic)].store (0.0f); + } +}; + +//============================================================================== +/** The response the per-voice filter selects, Off skipping the stage entirely. */ +enum class SynthFilterType +{ + off, + lowpass, + highpass, + bandpass, + notch, + allpass, + peak +}; + +/** @internal Item names for SynthFilterType, index aligned with the enumeration. */ +inline yup::StringArray getSynthFilterTypeNames() +{ + return { "Off", "Lowpass", "Highpass", "Bandpass", "Notch", "Allpass", "Peak" }; +} + +/** Maps a type onto the yup::FilterMode the VAStateVariableFilter reads. */ +inline yup::FilterModeType toFilterMode (SynthFilterType type) noexcept +{ + switch (type) + { + case SynthFilterType::highpass: return yup::FilterMode::highpass; + case SynthFilterType::bandpass: return yup::FilterMode::bandpassCsg; + case SynthFilterType::notch: return yup::FilterMode::bandstop; + case SynthFilterType::allpass: return yup::FilterMode::allpass; + case SynthFilterType::peak: return yup::FilterMode::peak; + case SynthFilterType::off: + case SynthFilterType::lowpass: break; + } + + return yup::FilterMode::lowpass; +} + +/** A plain snapshot of the filter section's controls. */ +struct SynthFilterValues +{ + SynthFilterType type = SynthFilterType::lowpass; + float cutoff = 8000.0f; + float resonance = 0.2f; + float drive = 0.0f; + float keytrack = 0.0f; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. */ +struct SynthFilterSettings +{ + std::atomic type { static_cast (SynthFilterType::lowpass) }; + std::atomic cutoff { 8000.0f }; + std::atomic resonance { 0.2f }; + std::atomic drive { 0.0f }; + std::atomic keytrack { 0.0f }; + + /** Takes a snapshot for one block of audio. */ + SynthFilterValues read() const noexcept + { + return { static_cast (type.load()), cutoff.load(), resonance.load(), drive.load(), keytrack.load() }; + } +}; + +//============================================================================== +/** @internal Item names for yup::LFO::Shape, index aligned with the enumeration. */ +inline yup::StringArray getSynthLFOShapeNames() +{ + return { "Sine", "Triangle", "Sawtooth", "Square", "S&H" }; +} + +/** A plain snapshot of one LFO's controls. */ +struct SynthLFOValues +{ + yup::LFO::Shape shape = yup::LFO::Shape::sine; + float rate = 1.0f; + float phase = 0.0f; +}; + +/** The same controls, edited from the message thread while the audio thread reads them. */ +struct SynthLFOSettings +{ + std::atomic shape { static_cast (yup::LFO::Shape::sine) }; + std::atomic rate { 1.0f }; + std::atomic phase { 0.0f }; + + /** Takes a snapshot for one block of audio. */ + SynthLFOValues read() const noexcept + { + return { static_cast::Shape> (shape.load()), rate.load(), phase.load() }; + } +}; + +//============================================================================== +/** What can drive a modulation route. */ +enum class SynthModulationSource +{ + env1, /**< The amplitude envelope, unipolar and per voice. */ + env2, /**< The free envelope, unipolar and per voice. */ + lfo1, /**< Global and bipolar. */ + lfo2 +}; + +/** @internal Item names for SynthModulationSource, index aligned with the enumeration. */ +inline yup::StringArray getSynthModulationSourceNames() +{ + return { "ENV 1", "ENV 2", "LFO 1", "LFO 2" }; +} + +/** Every parameter the matrix can reach; the oscillator block repeats per oscillator. */ +enum class SynthModulationDestination +{ + none, + osc1Level, osc1Cents, osc1Ridges, osc1Color, osc1Dispersion, osc1Squeeze, osc1Squash, osc1Tilt, + osc1OddEven, osc1Formant, osc1FormantPosition, osc1Scatter, osc1SyncRatio, osc1UnisonDetune, osc1UnisonSpread, + osc2Level, osc2Cents, osc2Ridges, osc2Color, osc2Dispersion, osc2Squeeze, osc2Squash, osc2Tilt, + osc2OddEven, osc2Formant, osc2FormantPosition, osc2Scatter, osc2SyncRatio, osc2UnisonDetune, osc2UnisonSpread, + filterCutoff, filterResonance, filterDrive, + count +}; + +/** Destinations per oscillator in SynthModulationDestination. */ +constexpr int synthOscillatorDestinations = 15; + +/** @internal Item names for SynthModulationDestination, index aligned with the enumeration. */ +inline yup::StringArray getSynthModulationDestinationNames() +{ + yup::StringArray names { "-" }; + + for (int oscillator = 1; oscillator <= SynthExample::oscillatorCount; ++oscillator) + { + const auto prefix = yup::String ("OSC ") + yup::String (oscillator) + " "; + + for (const auto* name : { "Level", "Cents", "Ridges", "Color", "Dispersion", "Squeeze", "Squash", "Tilt", + "Odd/Even", "Formant", "F.Pos", "Scatter", "Sync Ratio", "U.Detune", "Spread" }) + names.add (prefix + name); + } + + names.add ("Filter Cutoff"); + names.add ("Filter Resonance"); + names.add ("Filter Drive"); + + return names; +} + +/** Returns which oscillator a destination belongs to, or -1 for the filter and none. */ +constexpr int getDestinationOscillator (SynthModulationDestination destination) noexcept +{ + const auto index = static_cast (destination) - 1; + + if (index < 0 || index >= SynthExample::oscillatorCount * synthOscillatorDestinations) + return -1; + + return index / synthOscillatorDestinations; +} + +/** True for the parameters that change the derived spectrum rather than how it is played. */ +constexpr bool isSpectrumDestination (SynthModulationDestination destination) noexcept +{ + if (getDestinationOscillator (destination) < 0) + return false; + + const auto field = (static_cast (destination) - 1) % synthOscillatorDestinations; + + return field >= 2 && field <= 12; +} + +/** The knob range of a destination, shared by the panel and the modulation mapping. */ +struct SynthParameterRange +{ + float minimum = 0.0f; + float maximum = 1.0f; + bool logarithmic = false; + + /** Applies a normalized amount, one full range per unit, and clamps. */ + float modulate (float base, float amount) const noexcept + { + if (logarithmic) + return yup::jlimit (minimum, maximum, base * std::exp2 (amount * std::log2 (maximum / minimum))); + + return yup::jlimit (minimum, maximum, base + amount * (maximum - minimum)); + } +}; + +/** Returns the range a destination's knob spans. */ +inline SynthParameterRange getDestinationRange (SynthModulationDestination destination) noexcept +{ + switch (destination) + { + case SynthModulationDestination::filterCutoff: return { 20.0f, 20000.0f, true }; + case SynthModulationDestination::filterResonance: return { 0.0f, 1.0f }; + case SynthModulationDestination::filterDrive: return { 0.0f, 1.0f }; + case SynthModulationDestination::none: + case SynthModulationDestination::count: return {}; + default: break; + } + + switch ((static_cast (destination) - 1) % synthOscillatorDestinations) + { + case 0: return { 0.0f, 1.0f }; // level + case 1: return { -1.0f, 1.0f }; // cents, stored as semitones + case 2: return { 0.25f, 8.0f }; // ridges + case 3: return { 0.0f, 1.0f }; // color + case 4: return { 0.01f, 0.99f }; // dispersion + case 5: return { 0.0f, 0.5f }; // squeeze + case 6: return { 0.1f, 4.0f }; // squash + case 7: return { -4.0f, 4.0f }; // tilt + case 8: return { 0.0f, 1.0f }; // odd/even + case 9: return { -4.0f, 4.0f }; // formant + case 10: return { 0.0f, 7.0f }; // formant position + case 11: return { 0.0f, 1.0f }; // scatter + case 12: return { 1.0f, 8.0f }; // sync ratio + case 13: return { 0.0f, 1.0f }; // unison detune + case 14: return { 0.0f, 1.0f }; // unison spread + default: return {}; + } +} + +/** One routing of the matrix. */ +struct SynthModulationRoute +{ + SynthModulationSource source = SynthModulationSource::env1; + SynthModulationDestination destination = SynthModulationDestination::none; + float depth = 0.0f; +}; + +/** A snapshot of every routing. */ +struct SynthModulationValues +{ + std::array routes; + + /** True when an envelope is routed with depth into a spectrum parameter of this oscillator. */ + bool hasVoiceSpectrumRoute (int oscillator) const noexcept + { + for (const auto& route : routes) + { + const auto perVoice = route.source == SynthModulationSource::env1 || route.source == SynthModulationSource::env2; + + if (perVoice && route.depth != 0.0f && isSpectrumDestination (route.destination) + && getDestinationOscillator (route.destination) == oscillator) + return true; + } + + return false; + } +}; + +/** The routings, edited from the message thread while the audio thread reads them. */ +struct SynthModulationSettings +{ + struct Slot + { + std::atomic source { static_cast (SynthModulationSource::env1) }; + std::atomic destination { static_cast (SynthModulationDestination::none) }; + std::atomic depth { 0.0f }; + }; + + std::array slots; + + /** Takes a snapshot for one block of audio. */ + SynthModulationValues read() const noexcept + { + SynthModulationValues values; + + for (std::size_t index = 0; index < slots.size(); ++index) + values.routes[index] = { static_cast (slots[index].source.load()), + static_cast (slots[index].destination.load()), + slots[index].depth.load() }; + + return values; + } +}; + +//============================================================================== +/** Everything a voice plays from, after a modulation layer has been applied. */ +struct SynthPatchValues +{ + std::array oscillators; + SynthFilterValues filter; +}; + +/** Returns the field a destination modulates, or nullptr for none. */ +inline float* getDestinationField (SynthPatchValues& patch, SynthModulationDestination destination) noexcept +{ + switch (destination) + { + case SynthModulationDestination::filterCutoff: return &patch.filter.cutoff; + case SynthModulationDestination::filterResonance: return &patch.filter.resonance; + case SynthModulationDestination::filterDrive: return &patch.filter.drive; + default: break; + } + + const auto oscillator = getDestinationOscillator (destination); + + if (oscillator < 0) + return nullptr; + + auto& values = patch.oscillators[static_cast (oscillator)]; + + switch ((static_cast (destination) - 1) % synthOscillatorDestinations) + { + case 0: return &values.level; + case 1: return &values.detuneSemitones; + case 2: return &values.ridgeSpacing; + case 3: return &values.color; + case 4: return &values.dispersion; + case 5: return &values.squeeze; + case 6: return &values.squash; + case 7: return &values.tilt; + case 8: return &values.oddEven; + case 9: return &values.formant; + case 10: return &values.formantPosition; + case 11: return &values.scatter; + case 12: return &values.syncRatio; + case 13: return &values.unisonDetune; + case 14: return &values.unisonSpread; + default: return nullptr; + } +} + +/** Adds every route whose source has a value to the patch: one full knob range per unit of depth times source. */ +inline void applyModulation (SynthPatchValues& patch, + const SynthModulationValues& modulation, + const std::array& sourceValues) noexcept +{ + for (const auto& route : modulation.routes) + { + const auto source = sourceValues[static_cast (route.source)]; + + if (route.depth == 0.0f || source == 0.0f) + continue; + + if (auto* field = getDestinationField (patch, route.destination)) + *field = getDestinationRange (route.destination).modulate (*field, route.depth * source); + } +} diff --git a/modules/yup_dsp/filters/yup_VAStateVariableFilter.h b/modules/yup_dsp/filters/yup_VAStateVariableFilter.h new file mode 100644 index 000000000..1158801bc --- /dev/null +++ b/modules/yup_dsp/filters/yup_VAStateVariableFilter.h @@ -0,0 +1,348 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + Topology preserving transform state variable filter with eight simultaneous outputs. + + A port of Zavalishin's *The Art of VA Filter Design* State Variable Filter using + Topology Preserving Transform. The trapezoidal integrators give a bilinear transform + with the cutoff prewarped, and the zero-delay feedback loop is solved for the highpass + path, so every other output follows from it in one pass. + + Compared with StateVariableFilter this adds the unity gain bandpass, band shelf, + allpass and lowpass-minus-highpass outputs, a resonance control in 0..1 and an + unclamped Q. Modes map onto FilterMode as follows: + + | FilterMode | output | + |---------------|------------------------------------------| + | lowpass | LP | + | highpass | HP | + | bandpassCsg | BP, peak gain Q | + | bandpassCpg | 2 R BP, unity peak gain | + | bandstop | input - 2 R BP | + | allpass | input - 4 R BP | + | peak | input + K 2 R BP, K from the shelf gain | + + The LP - HP output has no FilterMode and is reachable through Outputs::peak. + + @see StateVariableFilter, FilterBase +*/ +template +class VAStateVariableFilter : public FilterBase +{ +public: + //============================================================================== + /** Every output of one processing step. */ + struct Outputs + { + SampleType lowpass = 0; + SampleType highpass = 0; + SampleType bandpass = 0; /**< Constant skirt gain bandpass, peak gain Q. */ + SampleType unityGainBandpass = 0; /**< Bandpass with unity gain at the cutoff. */ + SampleType bandShelf = 0; /**< Input plus the shelf gain times the unity bandpass. */ + SampleType notch = 0; + SampleType allpass = 0; + SampleType peak = 0; /**< Lowpass minus highpass. */ + }; + + /** Largest resonance resonanceToQ() accepts, so that Q stays finite. */ + static constexpr CoeffType maxResonance = static_cast (0.999); + + //============================================================================== + /** Creates a lowpass at 1 kHz with a resonance of 0.5. */ + VAStateVariableFilter() + { + setParameters (FilterMode::lowpass, static_cast (1000), resonanceToQ (static_cast (0.5)), CoeffType (0), 44100.0); + } + + /** Creates a filter in the given mode at 1 kHz with a resonance of 0.5. */ + explicit VAStateVariableFilter (FilterModeType initialMode) + { + setParameters (initialMode, static_cast (1000), resonanceToQ (static_cast (0.5)), CoeffType (0), 44100.0); + } + + //============================================================================== + /** Converts a resonance in 0..1 to Q as 1 / (2 (1 - resonance)), clamped at maxResonance. */ + static CoeffType resonanceToQ (CoeffType resonance) noexcept + { + const auto clamped = jlimit (CoeffType (0), maxResonance, resonance); + + return CoeffType (1) / (CoeffType (2) * (CoeffType (1) - clamped)); + } + + //============================================================================== + /** + Sets every parameter at once. + + @param mode The output to select; composite modes resolve to a supported one. + @param cutoffHz Cutoff in Hz, clamped below Nyquist. + @param q Quality factor; the damping is R = 1 / (2 Q). + @param shelfGainDb Gain of the band shelf in dB; 0 dB bypasses it. + @param sampleRate Sample rate in Hz. + */ + void setParameters (FilterModeType mode, CoeffType cutoffHz, CoeffType q, CoeffType shelfGainDb, double sampleRate) noexcept + { + mode = resolveFilterMode (mode, getSupportedModes()); + + if (filterMode != mode + || ! approximatelyEqual (cutoffFrequency, cutoffHz) + || ! approximatelyEqual (qFactor, q) + || ! approximatelyEqual (shelfGainDecibels, shelfGainDb) + || ! approximatelyEqual (this->sampleRate, sampleRate)) + { + filterMode = mode; + cutoffFrequency = cutoffHz; + qFactor = q; + shelfGainDecibels = shelfGainDb; + this->sampleRate = sampleRate; + + updateCoefficients(); + } + } + + /** Selects the output; composite modes resolve to a supported one. */ + void setMode (FilterModeType mode) noexcept + { + filterMode = resolveFilterMode (mode, getSupportedModes()); + } + + /** Sets the cutoff in Hz. */ + void setCutoffFrequency (CoeffType cutoffHz) noexcept + { + if (approximatelyEqual (cutoffFrequency, cutoffHz)) + return; + + cutoffFrequency = cutoffHz; + updateCoefficients(); + } + + /** Sets the cutoff as a MIDI note number, 440 Hz at 69. */ + void setCutoffPitch (CoeffType midiNote) noexcept + { + setCutoffFrequency (static_cast (440) * std::exp2 ((midiNote - static_cast (69)) / static_cast (12))); + } + + /** Sets the quality factor. */ + void setQ (CoeffType q) noexcept + { + if (approximatelyEqual (qFactor, q)) + return; + + qFactor = q; + updateCoefficients(); + } + + /** Sets the resonance in 0..1, see resonanceToQ(). */ + void setResonance (CoeffType resonance) noexcept + { + setQ (resonanceToQ (resonance)); + } + + /** Sets the gain of the band shelf in dB. Only the peak mode reads it. */ + void setShelfGain (CoeffType shelfGainDb) noexcept + { + if (approximatelyEqual (shelfGainDecibels, shelfGainDb)) + return; + + shelfGainDecibels = shelfGainDb; + updateCoefficients(); + } + + /** Returns the selected mode. */ + FilterModeType getMode() const noexcept { return filterMode; } + + /** Returns the cutoff in Hz. */ + CoeffType getCutoffFrequency() const noexcept { return cutoffFrequency; } + + /** Returns the quality factor. */ + CoeffType getQ() const noexcept { return qFactor; } + + /** Returns the band shelf gain in dB. */ + CoeffType getShelfGain() const noexcept { return shelfGainDecibels; } + + //============================================================================== + /** @internal */ + FilterModeType getSupportedModes() const noexcept override + { + return FilterMode::lowpass | FilterMode::highpass | FilterMode::bandpassCsg | FilterMode::bandpassCpg + | FilterMode::bandstop | FilterMode::allpass | FilterMode::peak; + } + + /** @internal */ + void reset() noexcept override + { + s1 = CoeffType (0); + s2 = CoeffType (0); + } + + /** @internal */ + void prepare (double sampleRate, int maximumBlockSize) override + { + this->sampleRate = sampleRate; + this->maximumBlockSize = maximumBlockSize; + + updateCoefficients(); + reset(); + } + + //============================================================================== + /** Runs one step and returns every output. */ + Outputs processAllOutputs (SampleType inputSample) noexcept + { + const auto input = static_cast (inputSample); + + const auto hp = (input - (CoeffType (2) * r + g) * s1 - s2) * h; + const auto bp = g * hp + s1; + const auto lp = g * bp + s2; + const auto ubp = CoeffType (2) * r * bp; + + s1 = g * hp + bp; + s2 = g * bp + lp; + + Outputs outputs; + outputs.lowpass = static_cast (lp); + outputs.highpass = static_cast (hp); + outputs.bandpass = static_cast (bp); + outputs.unityGainBandpass = static_cast (ubp); + outputs.bandShelf = static_cast (input + k * ubp); + outputs.notch = static_cast (input - ubp); + outputs.allpass = static_cast (input - CoeffType (2) * ubp); + outputs.peak = static_cast (lp - hp); + return outputs; + } + + /** @internal */ + SampleType processSample (SampleType inputSample) noexcept override + { + return select (processAllOutputs (inputSample)); + } + + /** @internal */ + void processBlock (const SampleType* inputBuffer, SampleType* outputBuffer, int numSamples) noexcept override + { + for (int i = 0; i < numSamples; ++i) + outputBuffer[i] = select (processAllOutputs (inputBuffer[i])); + } + + //============================================================================== + /** @internal */ + Complex getComplexResponse (CoeffType frequency) const override + { + const auto omega = frequencyToAngular (frequency, static_cast (this->sampleRate)); + const Complex s (CoeffType (0), std::tan (omega / CoeffType (2)) / g); + const auto s2 = s * s; + const auto twoRs = Complex (CoeffType (2) * r) * s; + const auto one = Complex (CoeffType (1)); + const auto denominator = s2 + twoRs + one; + + if (filterMode.test (FilterMode::lowpass)) + return one / denominator; + + if (filterMode.test (FilterMode::highpass)) + return s2 / denominator; + + if (filterMode.test (FilterMode::bandpassCsg)) + return s / denominator; + + if (filterMode.test (FilterMode::bandpassCpg)) + return twoRs / denominator; + + if (filterMode.test (FilterMode::bandstop)) + return (s2 + one) / denominator; + + if (filterMode.test (FilterMode::allpass)) + return (s2 - twoRs + one) / denominator; + + if (filterMode.test (FilterMode::peak)) + return one + Complex (k) * twoRs / denominator; + + return one; + } + +private: + //============================================================================== + SampleType select (const Outputs& outputs) const noexcept + { + if (filterMode.test (FilterMode::lowpass)) + return outputs.lowpass; + + if (filterMode.test (FilterMode::highpass)) + return outputs.highpass; + + if (filterMode.test (FilterMode::bandpassCsg)) + return outputs.bandpass; + + if (filterMode.test (FilterMode::bandpassCpg)) + return outputs.unityGainBandpass; + + if (filterMode.test (FilterMode::bandstop)) + return outputs.notch; + + if (filterMode.test (FilterMode::allpass)) + return outputs.allpass; + + if (filterMode.test (FilterMode::peak)) + return outputs.bandShelf; + + return outputs.lowpass; + } + + void updateCoefficients() noexcept + { + const auto rate = static_cast (jmax (1.0, this->sampleRate)); + const auto cutoff = jlimit (static_cast (1e-3), rate * static_cast (0.49), cutoffFrequency); + const auto q = jmax (static_cast (1e-3), qFactor); + + g = std::tan (MathConstants::pi * cutoff / rate); + r = CoeffType (1) / (CoeffType (2) * q); + k = Decibels::decibelsToGain (shelfGainDecibels) - CoeffType (1); + h = CoeffType (1) / (CoeffType (1) + CoeffType (2) * r * g + g * g); + } + + //============================================================================== + FilterModeType filterMode = FilterMode::lowpass; + CoeffType cutoffFrequency = static_cast (1000); + CoeffType qFactor = CoeffType (1); + CoeffType shelfGainDecibels = CoeffType (0); + + CoeffType g = CoeffType (0); + CoeffType r = CoeffType (1); + CoeffType k = CoeffType (0); + CoeffType h = CoeffType (1); + CoeffType s1 = CoeffType (0); + CoeffType s2 = CoeffType (0); + + //============================================================================== + YUP_LEAK_DETECTOR (VAStateVariableFilter) +}; + +//============================================================================== +/** Type aliases for convenience */ +using VAStateVariableFilterFloat = VAStateVariableFilter; // float samples, double coefficients (default) +using VAStateVariableFilterDouble = VAStateVariableFilter; // double samples, double coefficients (default) + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_LFO.h b/modules/yup_dsp/oscillators/yup_LFO.h new file mode 100644 index 000000000..ef7e62039 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_LFO.h @@ -0,0 +1,214 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** + A low frequency oscillator for control signals. + + Produces a bipolar waveform in [-1, 1] from a phase accumulator: sine, triangle, + rising sawtooth, square, or a sample-and-hold that draws a new value every time + the phase wraps. It is not bandlimited, which is what a control signal wants, + and nothing here allocates. + + A control-rate consumer calls getValue() for the value at the top of a block and + skip() to advance by the block length. An audio-rate consumer uses processSample() + or processBlock(). + + @code + yup::LFO lfo; + lfo.prepare (48000.0); + lfo.setShape (yup::LFO::Shape::triangle); + lfo.setFrequency (2.0f); + + const auto depth = lfo.getValue(); // value at the start of this block + lfo.skip (numSamples); // advance to the next block + @endcode + + @see WavetableOscillator +*/ +template +class LFO +{ +public: + //============================================================================== + /** The waveform read from the phase. */ + enum class Shape + { + sine, + triangle, /**< -1 at phase 0, +1 at phase 0.5. */ + sawtooth, /**< Rising, -1 at phase 0. */ + square, /**< +1 for the first half period. */ + sampleAndHold /**< A pseudo-random value held for one period. */ + }; + + //============================================================================== + /** Sets the sample rate and restarts from phase zero. */ + void prepare (double newSampleRate) noexcept + { + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + updateIncrement(); + reset(); + } + + /** Restarts at a phase in periods and reseeds the sample-and-hold generator. */ + void reset (FloatType newPhase = FloatType (0)) noexcept + { + phase = wrap (newPhase); + state = seed; + held = nextRandom(); + } + + //============================================================================== + /** Sets the rate in Hz. Negative rates are clamped to zero, which freezes the phase. */ + void setFrequency (FloatType hz) noexcept + { + frequency = jmax (FloatType (0), hz); + updateIncrement(); + } + + /** Returns the rate in Hz. */ + FloatType getFrequency() const noexcept { return frequency; } + + /** Selects the waveform. */ + void setShape (Shape newShape) noexcept { shape = newShape; } + + /** Returns the waveform. */ + Shape getShape() const noexcept { return shape; } + + /** Shifts where the waveform is read, in periods. Does not move the phase itself. */ + void setPhaseOffset (FloatType periods) noexcept { phaseOffset = wrap (periods); } + + /** Returns the read offset in periods. */ + FloatType getPhaseOffset() const noexcept { return phaseOffset; } + + /** Seeds the sample-and-hold generator and draws the first value from it. */ + void setSeed (uint32 newSeed) noexcept + { + seed = newSeed; + state = seed; + held = nextRandom(); + } + + //============================================================================== + /** Returns the value at the current phase without advancing. */ + FloatType getValue() const noexcept + { + const auto p = wrap (phase + phaseOffset); + + switch (shape) + { + case Shape::sine: + return std::sin (MathConstants::twoPi * p); + + case Shape::triangle: + return FloatType (1) - FloatType (4) * std::abs (p - FloatType (0.5)); + + case Shape::sawtooth: + return FloatType (2) * p - FloatType (1); + + case Shape::square: + return p < FloatType (0.5) ? FloatType (1) : FloatType (-1); + + case Shape::sampleAndHold: + return held; + } + + return FloatType (0); + } + + /** Returns the current value, then advances one sample. */ + FloatType processSample() noexcept + { + const auto value = getValue(); + + advance (1); + + return value; + } + + /** Writes consecutive samples. */ + void processBlock (FloatType* output, int numSamples) noexcept + { + for (int i = 0; i < numSamples; ++i) + output[i] = processSample(); + } + + /** Advances by a number of samples and returns the value there. */ + FloatType skip (int numSamples) noexcept + { + advance (numSamples); + + return getValue(); + } + + /** Returns the phase in periods, without the offset. */ + FloatType getPhase() const noexcept { return phase; } + +private: + //============================================================================== + static FloatType wrap (FloatType value) noexcept + { + return value - std::floor (value); + } + + void updateIncrement() noexcept + { + increment = static_cast (frequency / sampleRate); + } + + /** Advances the phase; a wrap during the step draws one new sample-and-hold value. */ + void advance (int numSamples) noexcept + { + const auto next = phase + increment * static_cast (numSamples); + const auto wrapped = std::floor (next); + + phase = next - wrapped; + + if (wrapped > FloatType (0)) + held = nextRandom(); + } + + FloatType nextRandom() noexcept + { + state = state * 1664525u + 1013904223u; + + return static_cast (state >> 8) / static_cast (8388608.0) - FloatType (1); + } + + //============================================================================== + double sampleRate = 44100.0; + FloatType frequency = FloatType (1); + FloatType increment = FloatType (1) / FloatType (44100); + FloatType phase = FloatType (0); + FloatType phaseOffset = FloatType (0); + Shape shape = Shape::sine; + uint32 seed = 0x9e3779b9u; + uint32 state = 0x9e3779b9u; + FloatType held = FloatType (0); +}; + +} // namespace yup diff --git a/modules/yup_dsp/oscillators/yup_PrismSpectrum.h b/modules/yup_dsp/oscillators/yup_PrismSpectrum.h index 05a1acccb..4677ae711 100644 --- a/modules/yup_dsp/oscillators/yup_PrismSpectrum.h +++ b/modules/yup_dsp/oscillators/yup_PrismSpectrum.h @@ -139,7 +139,7 @@ class PrismSpectrum // A fixed generator rather than a shared one, so a given harmonic always gets // the same offset: scatter has to be a stable property of the shape, otherwise // re-deriving it every block would sound like noise rather than like a timbre. - std::uint32_t state = 0x9e3779b9u; + uint32 state = 0x9e3779b9u; for (std::size_t index = 0; index < count; ++index) { diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 46631d750..3629778e4 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -149,6 +149,7 @@ #include "oscillators/yup_SyncOscillator.h" #include "oscillators/yup_WaveformBank.h" #include "oscillators/yup_PrismSpectrum.h" +#include "oscillators/yup_LFO.h" // Onset detection #include "onsets/yup_FilterBank.h" @@ -191,6 +192,7 @@ #include "filters/yup_RbjFilter.h" #include "filters/yup_ZoelzerFilter.h" #include "filters/yup_StateVariableFilter.h" +#include "filters/yup_VAStateVariableFilter.h" #include "filters/yup_ButterworthFilter.h" #include "filters/yup_LinkwitzRileyFilter.h" #include "filters/yup_DirectFIR.h" diff --git a/tests/yup_audio_formats/yup_AudioFormatWriter.cpp b/tests/yup_audio_formats/yup_AudioFormatWriter.cpp index 364ee58de..9571cbe6e 100644 --- a/tests/yup_audio_formats/yup_AudioFormatWriter.cpp +++ b/tests/yup_audio_formats/yup_AudioFormatWriter.cpp @@ -195,7 +195,7 @@ TEST (AudioFormatWriterTests, WriteHelperEncodesIntegerAndFloatFormats) const std::array sourceValues { -1.0f, -0.5f, 0.0f, 1.0f }; { - std::array destination {}; + std::array destination {}; AudioFormatWriter::WriteHelper::writeInt8 (destination.data(), sourceValues.data(), static_cast (sourceValues.size())); EXPECT_EQ (1, destination[0]); @@ -205,17 +205,17 @@ TEST (AudioFormatWriterTests, WriteHelperEncodesIntegerAndFloatFormats) } { - std::array destination {}; + std::array destination {}; const std::array values { 0.25f, 1.0f }; AudioFormatWriter::WriteHelper::writeInt16 (destination.data(), values.data(), static_cast (values.size()), true); - EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[0] * 32767.0f))), destination[0]); - EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[1] * 32767.0f))), destination[1]); + EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[0] * 32767.0f))), destination[0]); + EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[1] * 32767.0f))), destination[1]); } { - std::array destination {}; + std::array destination {}; const std::array values { 0.25f, 1.0f }; AudioFormatWriter::WriteHelper::writeInt24 (destination.data(), values.data(), static_cast (values.size()), true); @@ -229,12 +229,12 @@ TEST (AudioFormatWriterTests, WriteHelperEncodesIntegerAndFloatFormats) } { - std::array destination {}; + std::array destination {}; const std::array values { 0.25f }; AudioFormatWriter::WriteHelper::writeInt32 (destination.data(), values.data(), static_cast (values.size()), true); - EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[0] * 2147483647.0f))), destination[0]); + EXPECT_EQ (ByteOrder::swapIfBigEndian (static_cast (static_cast (values[0] * 2147483647.0f))), destination[0]); } { @@ -243,8 +243,8 @@ TEST (AudioFormatWriterTests, WriteHelperEncodesIntegerAndFloatFormats) AudioFormatWriter::WriteHelper::writeFloat32 (destination.data(), values.data(), static_cast (values.size()), true); - EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[0])), std::bit_cast (destination[0])); - EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[1])), std::bit_cast (destination[1])); + EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[0])), std::bit_cast (destination[0])); + EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[1])), std::bit_cast (destination[1])); } { @@ -253,7 +253,7 @@ TEST (AudioFormatWriterTests, WriteHelperEncodesIntegerAndFloatFormats) AudioFormatWriter::WriteHelper::writeFloat64 (destination.data(), values.data(), static_cast (values.size()), true); - EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[0])), std::bit_cast (destination[0])); - EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[1])), std::bit_cast (destination[1])); + EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[0])), std::bit_cast (destination[0])); + EXPECT_EQ (std::bit_cast (ByteOrder::swapIfBigEndian (values[1])), std::bit_cast (destination[1])); } } \ No newline at end of file diff --git a/tests/yup_dsp/yup_LFO.cpp b/tests/yup_dsp/yup_LFO.cpp new file mode 100644 index 000000000..534c70d16 --- /dev/null +++ b/tests/yup_dsp/yup_LFO.cpp @@ -0,0 +1,186 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +//============================================================================== +class LFOTests : public ::testing::Test +{ +protected: + static constexpr double sampleRate = 48000.0; + + /** 3 kHz at 48 kHz is a 16-sample period with an exactly representable increment. */ + static constexpr double frequency = 3000.0; + static constexpr int period = 16; + + void SetUp() override + { + lfo.prepare (sampleRate); + lfo.setFrequency (frequency); + } + + LFO lfo; +}; + +//============================================================================== +TEST_F (LFOTests, SineStartsAtZeroAndPeaksAtQuarterPeriod) +{ + lfo.setShape (LFO::Shape::sine); + + EXPECT_NEAR (0.0, lfo.getValue(), 1e-12); + EXPECT_NEAR (1.0, lfo.skip (period / 4), 1e-12); + EXPECT_NEAR (0.0, lfo.skip (period / 4), 1e-12); + EXPECT_NEAR (-1.0, lfo.skip (period / 4), 1e-12); +} + +TEST_F (LFOTests, PeriodMatchesFrequency) +{ + lfo.setShape (LFO::Shape::sawtooth); + + lfo.skip (period * 10); + + EXPECT_NEAR (0.0, lfo.getPhase(), 1e-12); + EXPECT_NEAR (-1.0, lfo.getValue(), 1e-12); +} + +TEST_F (LFOTests, TriangleIsBipolarAndSymmetric) +{ + lfo.setShape (LFO::Shape::triangle); + + EXPECT_NEAR (-1.0, lfo.getValue(), 1e-12); + EXPECT_NEAR (0.0, lfo.skip (period / 4), 1e-12); + EXPECT_NEAR (1.0, lfo.skip (period / 4), 1e-12); + EXPECT_NEAR (0.0, lfo.skip (period / 4), 1e-12); +} + +TEST_F (LFOTests, SquareIsHighForFirstHalf) +{ + lfo.setShape (LFO::Shape::square); + + EXPECT_DOUBLE_EQ (1.0, lfo.getValue()); + EXPECT_DOUBLE_EQ (1.0, lfo.skip (period / 2 - 1)); + EXPECT_DOUBLE_EQ (-1.0, lfo.skip (1)); + EXPECT_DOUBLE_EQ (-1.0, lfo.skip (period / 2 - 1)); + EXPECT_DOUBLE_EQ (1.0, lfo.skip (1)); +} + +TEST_F (LFOTests, SampleAndHoldHoldsWithinACycleAndChangesAcrossWraps) +{ + lfo.setShape (LFO::Shape::sampleAndHold); + + const auto first = lfo.getValue(); + + EXPECT_GE (first, -1.0); + EXPECT_LE (first, 1.0); + EXPECT_DOUBLE_EQ (first, lfo.skip (5)); + EXPECT_DOUBLE_EQ (first, lfo.skip (5)); + + const auto second = lfo.skip (period); + + EXPECT_NE (first, second); + EXPECT_DOUBLE_EQ (second, lfo.skip (3)); +} + +TEST_F (LFOTests, SampleAndHoldIsDeterministicPerSeed) +{ + LFO a, b; + + a.prepare (sampleRate); + b.prepare (sampleRate); + a.setShape (LFO::Shape::sampleAndHold); + b.setShape (LFO::Shape::sampleAndHold); + a.setFrequency (frequency); + b.setFrequency (frequency); + a.setSeed (7u); + b.setSeed (7u); + + EXPECT_DOUBLE_EQ (a.getValue(), b.getValue()); + + for (int i = 0; i < 5; ++i) + EXPECT_DOUBLE_EQ (a.skip (period), b.skip (period)); +} + +TEST_F (LFOTests, PhaseOffsetShiftsTheWaveform) +{ + lfo.setShape (LFO::Shape::sine); + lfo.setPhaseOffset (0.25); + + EXPECT_NEAR (1.0, lfo.getValue(), 1e-12); + EXPECT_NEAR (0.0, lfo.getPhase(), 1e-12); + EXPECT_NEAR (0.25, lfo.getPhaseOffset(), 1e-12); +} + +TEST_F (LFOTests, SkipEqualsRepeatedProcessSample) +{ + LFO stepped; + stepped.prepare (sampleRate); + stepped.setFrequency (frequency); + stepped.setShape (LFO::Shape::triangle); + lfo.setShape (LFO::Shape::triangle); + + for (int i = 0; i < 333; ++i) + stepped.processSample(); + + EXPECT_NEAR (stepped.getValue(), lfo.skip (333), 1e-9); +} + +TEST_F (LFOTests, ProcessBlockWritesConsecutiveSamples) +{ + LFO stepped; + stepped.prepare (sampleRate); + stepped.setFrequency (frequency); + + std::vector block (64); + lfo.processBlock (block.data(), 64); + + for (auto value : block) + EXPECT_NEAR (stepped.processSample(), value, 1e-12); +} + +TEST_F (LFOTests, ResetRestartsThePhase) +{ + lfo.skip (77); + lfo.reset (0.5); + + EXPECT_NEAR (0.5, lfo.getPhase(), 1e-12); +} + +TEST_F (LFOTests, ZeroFrequencyHolds) +{ + lfo.setFrequency (0.0); + lfo.setShape (LFO::Shape::sawtooth); + + EXPECT_NEAR (-1.0, lfo.skip (1000), 1e-12); + EXPECT_DOUBLE_EQ (0.0, lfo.getFrequency()); +} + +TEST_F (LFOTests, NegativeFrequencyIsClampedToZero) +{ + lfo.setFrequency (-5.0); + + EXPECT_DOUBLE_EQ (0.0, lfo.getFrequency()); +} diff --git a/tests/yup_dsp/yup_VAStateVariableFilter.cpp b/tests/yup_dsp/yup_VAStateVariableFilter.cpp new file mode 100644 index 000000000..013f1d763 --- /dev/null +++ b/tests/yup_dsp/yup_VAStateVariableFilter.cpp @@ -0,0 +1,290 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +//============================================================================== +class VAStateVariableFilterTests : public ::testing::Test +{ +protected: + static constexpr double sampleRate = 48000.0; + static constexpr int blockSize = 256; + + /** A frequency low enough to stand in for DC in the analytic response. */ + static constexpr double nearDc = 0.1; + + /** Sampled sines miss the true peak by up to 1 - cos (pi / 48) at 1 kHz. */ + static constexpr double sinePeakTolerance = 5e-3; + + void SetUp() override + { + filter.prepare (sampleRate, blockSize); + } + + /** Feeds a sine for a while and returns the steady-state peak amplitude. */ + static double measureSineGain (VAStateVariableFilter& f, double frequency) + { + f.reset(); + + const int settle = 48000; + const int measure = 4800; + auto peak = 0.0; + + for (int i = 0; i < settle + measure; ++i) + { + const auto x = std::sin (MathConstants::twoPi * frequency * i / sampleRate); + const auto y = f.processSample (x); + + if (i >= settle) + peak = std::max (peak, std::abs (y)); + } + + return peak; + } + + VAStateVariableFilter filter; +}; + +//============================================================================== +TEST_F (VAStateVariableFilterTests, DefaultsToLowpassAtOneKilohertz) +{ + EXPECT_TRUE (filter.getMode().test (FilterMode::lowpass)); + EXPECT_DOUBLE_EQ (1000.0, filter.getCutoffFrequency()); +} + +TEST_F (VAStateVariableFilterTests, ResonanceMapsToQ) +{ + EXPECT_NEAR (0.5, VAStateVariableFilter::resonanceToQ (0.0), 1e-12); + EXPECT_NEAR (1.0, VAStateVariableFilter::resonanceToQ (0.5), 1e-12); + EXPECT_NEAR (5.0, VAStateVariableFilter::resonanceToQ (0.9), 1e-12); + EXPECT_LT (VAStateVariableFilter::resonanceToQ (1.0), 1000.0); + + filter.setResonance (0.5); + EXPECT_NEAR (1.0, filter.getQ(), 1e-12); +} + +TEST_F (VAStateVariableFilterTests, CutoffPitchFollowsMidi) +{ + filter.setCutoffPitch (69.0); + EXPECT_NEAR (440.0, filter.getCutoffFrequency(), 1e-9); + + filter.setCutoffPitch (81.0); + EXPECT_NEAR (880.0, filter.getCutoffFrequency(), 1e-9); +} + +TEST_F (VAStateVariableFilterTests, SupportsSevenModes) +{ + const auto modes = filter.getSupportedModes(); + + EXPECT_TRUE (modes.test (FilterMode::lowpass)); + EXPECT_TRUE (modes.test (FilterMode::highpass)); + EXPECT_TRUE (modes.test (FilterMode::bandpassCsg)); + EXPECT_TRUE (modes.test (FilterMode::bandpassCpg)); + EXPECT_TRUE (modes.test (FilterMode::bandstop)); + EXPECT_TRUE (modes.test (FilterMode::allpass)); + EXPECT_TRUE (modes.test (FilterMode::peak)); + EXPECT_FALSE (modes.test (FilterMode::lowshelf)); + EXPECT_FALSE (modes.test (FilterMode::highshelf)); +} + +TEST_F (VAStateVariableFilterTests, CompositeBandpassResolvesToConstantSkirtGain) +{ + filter.setMode (FilterMode::bandpass); + EXPECT_TRUE (filter.getMode().test (FilterMode::bandpassCsg)); +} + +TEST_F (VAStateVariableFilterTests, LowpassPassesDcAndRejectsHighFrequencies) +{ + filter.setParameters (FilterMode::lowpass, 1000.0, 0.707, 0.0, sampleRate); + + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (nearDc), 1e-6); + EXPECT_LT (filter.getMagnitudeResponse (20000.0), 0.01); + EXPECT_NEAR (1.0, measureSineGain (filter, 20.0), sinePeakTolerance); + EXPECT_LT (measureSineGain (filter, 16000.0), 0.01); +} + +TEST_F (VAStateVariableFilterTests, HighpassRejectsDc) +{ + filter.setParameters (FilterMode::highpass, 1000.0, 0.707, 0.0, sampleRate); + + EXPECT_LT (filter.getMagnitudeResponse (nearDc), 1e-5); + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (20000.0), 1e-2); + EXPECT_LT (measureSineGain (filter, 10.0), 1e-3); +} + +TEST_F (VAStateVariableFilterTests, UnityGainBandpassIsUnityAtCutoff) +{ + filter.setParameters (FilterMode::bandpassCpg, 1000.0, 4.0, 0.0, sampleRate); + + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (1000.0), 1e-6); + EXPECT_NEAR (1.0, measureSineGain (filter, 1000.0), sinePeakTolerance); +} + +TEST_F (VAStateVariableFilterTests, ConstantSkirtBandpassPeaksAtQ) +{ + filter.setParameters (FilterMode::bandpassCsg, 1000.0, 4.0, 0.0, sampleRate); + + EXPECT_NEAR (4.0, filter.getMagnitudeResponse (1000.0), 1e-6); + EXPECT_NEAR (4.0, measureSineGain (filter, 1000.0), 4.0 * sinePeakTolerance); +} + +TEST_F (VAStateVariableFilterTests, NotchRejectsCutoff) +{ + filter.setParameters (FilterMode::bandstop, 1000.0, 2.0, 0.0, sampleRate); + + EXPECT_LT (filter.getMagnitudeResponse (1000.0), 1e-9); + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (nearDc), 1e-6); + EXPECT_LT (measureSineGain (filter, 1000.0), sinePeakTolerance); +} + +TEST_F (VAStateVariableFilterTests, AllpassHasUnityMagnitudeEverywhere) +{ + filter.setParameters (FilterMode::allpass, 1000.0, 0.707, 0.0, sampleRate); + + for (auto frequency : { 10.0, 500.0, 1000.0, 4000.0, 20000.0 }) + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (frequency), 1e-9); + + EXPECT_NEAR (1.0, measureSineGain (filter, 1000.0), sinePeakTolerance); +} + +TEST_F (VAStateVariableFilterTests, PeakShelfBoostsByGainAtCutoff) +{ + filter.setParameters (FilterMode::peak, 1000.0, 2.0, 12.0, sampleRate); + + const auto expected = Decibels::decibelsToGain (12.0); + + EXPECT_NEAR (expected, filter.getMagnitudeResponse (1000.0), 1e-6); + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (nearDc), 1e-3); + EXPECT_NEAR (expected, measureSineGain (filter, 1000.0), expected * sinePeakTolerance); +} + +TEST_F (VAStateVariableFilterTests, ZeroShelfGainIsBypass) +{ + filter.setParameters (FilterMode::peak, 1000.0, 2.0, 0.0, sampleRate); + + for (auto frequency : { 10.0, 1000.0, 10000.0 }) + EXPECT_NEAR (1.0, filter.getMagnitudeResponse (frequency), 1e-9); +} + +TEST_F (VAStateVariableFilterTests, AllOutputsAgreeWithModeSelection) +{ + using Filter = VAStateVariableFilter; + + Filter selected; + Filter all; + + selected.prepare (sampleRate, blockSize); + all.prepare (sampleRate, blockSize); + + const struct + { + FilterModeType mode; + double Filter::Outputs::*output; + } pairs[] = { + { FilterMode::lowpass, &Filter::Outputs::lowpass }, + { FilterMode::highpass, &Filter::Outputs::highpass }, + { FilterMode::bandpassCsg, &Filter::Outputs::bandpass }, + { FilterMode::bandpassCpg, &Filter::Outputs::unityGainBandpass }, + { FilterMode::bandstop, &Filter::Outputs::notch }, + { FilterMode::allpass, &Filter::Outputs::allpass }, + { FilterMode::peak, &Filter::Outputs::bandShelf }, + }; + + for (const auto& pair : pairs) + { + selected.setParameters (pair.mode, 2000.0, 1.5, 6.0, sampleRate); + all.setParameters (pair.mode, 2000.0, 1.5, 6.0, sampleRate); + selected.reset(); + all.reset(); + + for (int i = 0; i < 64; ++i) + { + const auto x = (i == 0) ? 1.0 : 0.0; + + EXPECT_DOUBLE_EQ (selected.processSample (x), all.processAllOutputs (x).*(pair.output)); + } + } +} + +TEST_F (VAStateVariableFilterTests, PeakOutputIsLowpassMinusHighpass) +{ + filter.setParameters (FilterMode::lowpass, 2000.0, 1.0, 0.0, sampleRate); + + for (int i = 0; i < 64; ++i) + { + const auto outputs = filter.processAllOutputs (i == 0 ? 1.0 : 0.0); + + EXPECT_NEAR (outputs.lowpass - outputs.highpass, outputs.peak, 1e-12); + } +} + +TEST_F (VAStateVariableFilterTests, ProcessBlockMatchesProcessSample) +{ + VAStateVariableFilter blockFilter; + VAStateVariableFilter sampleFilter; + + blockFilter.prepare (sampleRate, blockSize); + sampleFilter.prepare (sampleRate, blockSize); + blockFilter.setParameters (FilterMode::bandstop, 3000.0f, 3.0f, 0.0f, sampleRate); + sampleFilter.setParameters (FilterMode::bandstop, 3000.0f, 3.0f, 0.0f, sampleRate); + + std::vector input (blockSize), output (blockSize); + + for (int i = 0; i < blockSize; ++i) + input[static_cast (i)] = std::sin (0.37f * static_cast (i)) * 0.5f; + + blockFilter.processBlock (input.data(), output.data(), blockSize); + + for (int i = 0; i < blockSize; ++i) + EXPECT_NEAR (sampleFilter.processSample (input[static_cast (i)]), output[static_cast (i)], 1e-6f); +} + +TEST_F (VAStateVariableFilterTests, ResetClearsState) +{ + filter.setParameters (FilterMode::lowpass, 500.0, 5.0, 0.0, sampleRate); + + for (int i = 0; i < 100; ++i) + filter.processSample (1.0); + + filter.reset(); + + EXPECT_DOUBLE_EQ (0.0, filter.processAllOutputs (0.0).lowpass); +} + +TEST_F (VAStateVariableFilterTests, AliasesCompile) +{ + VAStateVariableFilterFloat asFloat; + VAStateVariableFilterDouble asDouble; + + asFloat.prepare (sampleRate, blockSize); + asDouble.prepare (sampleRate, blockSize); + + EXPECT_FLOAT_EQ (0.0f, asFloat.processSample (0.0f)); + EXPECT_DOUBLE_EQ (0.0, asDouble.processSample (0.0)); +} From c6eab912d05dd5449cd68d7888932988cb0b3287 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 22 Sep 2026 14:57:16 +0200 Subject: [PATCH 12/37] More synth fun --- CHANGELOG.md | 2 +- examples/graphics/source/examples/Audio.h | 77 +++++++++++++-- .../source/examples/audio/SynthEngine.h | 96 +++++++++++-------- .../source/examples/audio/SynthPanels.h | 34 +++++-- .../source/examples/audio/SynthSettings.h | 20 ++-- 5 files changed, 163 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0074f4ed1..f5459af50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `ModulatedOscillator::Parameters` moved to namespace scope as `ModulatedOscillatorParameters` (still aliased as the nested `Parameters`), and the modulation maths moved into `detail::ModulatedOscillatorVoice` so oscillators can compose over it without duplicating the phase map, sync and BLEP/BLAMP corrections - Added a waveform selector (sine, triangle, saw, square) and 16× / 32× sweep oversampling modes to the spectrum analyzer example; the non-sine shapes are naive, so the sweep oversampling modes show their aliasing suppression. The oversampled sweeps now generate at a multiple of the device rate and decimate straight to it through `SincOversampler::beginGeneration()` instead of passing through the radius-8 resampler, whose 54 dB stopband was setting the alias floor regardless of the oversampling factor -- Reduced the graphics synthesizer example to a single Prism oscillator per slot, deriving each slot's spectrum once per block rather than once per voice so the shape controls can be modulated, and exposing squeeze, squash, tilt, odd/even, formant and scatter alongside ridges, color and dispersion. The sync modes moved onto Prism: the shaped series goes through `SyncSpectralResampler` at the slot, driven by the sync mode chooser and a new sync ratio knob, so unison satellites and the waveform preview follow it for free; the Modulated algorithm, its FM knobs and the algorithm chooser are gone from the example. The example then grew a per-voice `VAStateVariableFilter` stage with drive and keytracking, a second envelope, two global `LFO`s with animated displays, and an eight-slot modulation matrix on a second page: LFO routings are applied once per block at the slot, envelope routings per voice, and a voice derives a private spectrum only while an envelope is routed into one of its spectrum parameters. Knob captions show the value while dragging. The example is split into `examples/graphics/source/examples/audio/` (settings, engine, panels, modulation page) +- Reduced the graphics synthesizer example to a single Prism oscillator per slot, deriving each slot's spectrum once per block rather than once per voice so the shape controls can be modulated, and exposing squeeze, squash, tilt, odd/even, formant and scatter alongside ridges, color and dispersion. The sync modes moved onto Prism: the shaped series goes through `SyncSpectralResampler` at the slot, driven by the sync mode chooser and a new sync ratio knob, so unison satellites and the waveform preview follow it for free; the Modulated algorithm, its FM knobs and the algorithm chooser are gone from the example. The example then grew a per-voice `VAStateVariableFilter` stage with drive and keytracking, a second envelope, two per-voice `LFO`s (retriggered per note or free running) with displays following the newest voice, and an eight-slot modulation matrix on a second page: every source is per voice, and a voice derives a private spectrum only while a route reaches one of its spectrum parameters. Knob captions show the value while dragging, and Randomize now covers every voice property (oscillators, filter, envelopes, LFOs and a few live routes) while leaving volume, voice mode, glide and MIDI input alone. The example is split into `examples/graphics/source/examples/audio/` (settings, engine, panels, modulation page) - Improved the graphics synthesizer example with a spectral Prism oscillator, octave and cents controls, poly/mono/legato modes, portamento, rendered voice-stealing tails, a revised instrument layout, and audio-load/overrun metering. Reduced callback and scope overhead, removed output waveshaping, and added regression coverage. - Added shared `WaveformBank` and oversampled `ModulatedOscillator` with through-zero FM, PM, phase distortion and fractional hard sync. Added direct generation to `SincOversampler`; fused spectral SIMD accumulation, amortized additive phasor trigonometry, and corrected sync bandwidth refresh and Nyquist boundaries. diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index f555c52e8..7640090e2 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -154,7 +154,7 @@ class AudioExample randomizeButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::panelBackground); randomizeButton.setColor (yup::TextButton::Style::textColorId, SynthTheme::textPrimary); randomizeButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); - randomizeButton.onClick = [this] { randomizeOscillators(); }; + randomizeButton.onClick = [this] { randomizeVoice(); }; addAndMakeVisible (randomizeButton); clearButton.setColor (yup::TextButton::Style::backgroundColorId, SynthTheme::panelBackground); @@ -245,7 +245,7 @@ class AudioExample { auto bounds = mainPage.getLocalBounds(); - keyboardComponent.setBounds (bounds.removeFromBottom (mainPage.proportionOfHeight (0.19f))); + keyboardComponent.setBounds (bounds.removeFromBottom (yup::jmin (keyboardHeight, mainPage.proportionOfHeight (0.12f)))); bounds.removeFromBottom (spacing); auto performance = bounds.removeFromBottom (58.0f); @@ -257,9 +257,9 @@ class AudioExample loadLabel.setBounds (performance); bounds.removeFromBottom (spacing); - const auto rowHeight = yup::jmin (150.0f, bounds.getHeight() * 0.26f); - - auto lfoRow = bounds.removeFromBottom (rowHeight); + // The oscillator panels take whatever the fixed-height rows below leave, and + // their waveform editors need most of it. + auto lfoRow = bounds.removeFromBottom (lfoRowHeight); const auto lfoWidth = (lfoRow.getWidth() - spacing * 2.0f) * 0.3f; lfoPanels[0]->setBounds (lfoRow.removeFromLeft (lfoWidth)); lfoRow.removeFromLeft (spacing); @@ -268,7 +268,7 @@ class AudioExample oscilloscope.setBounds (lfoRow); bounds.removeFromBottom (spacing); - auto shapingRow = bounds.removeFromBottom (rowHeight); + auto shapingRow = bounds.removeFromBottom (yup::jmin (shapingRowHeight, bounds.getHeight() * 0.3f)); const auto shapingWidth = (shapingRow.getWidth() - spacing * 2.0f) / 3.0f; filterPanel->setBounds (shapingRow.removeFromLeft (shapingWidth)); shapingRow.removeFromLeft (spacing); @@ -464,7 +464,8 @@ class AudioExample } //============================================================================== - void randomizeOscillators() + /** Randomizes everything a voice is made of; volume, voice mode, glide and MIDI input stay. */ + void randomizeVoice() { auto& random = yup::Random::getSystemRandom(); @@ -507,6 +508,65 @@ class AudioExample filter.drive = random.nextBool() ? 0.0f : random.nextFloat() * 0.6f; filter.keytrack = random.nextBool() ? 0.0f : 1.0f; filterPanel->refresh(); + + // The amplitude envelope keeps a short attack more often than not, so the patch + // still speaks when played; the modulation envelope is free to be slow. + for (int index = 0; index < SynthExample::envelopeCount; ++index) + { + auto& envelope = synth.getEnvelopeSettings (index); + const auto slow = index > 0 || random.nextInt (4) == 0; + + envelope.delay = random.nextInt (4) == 0 ? random.nextFloat() * 0.3f : 0.0f; + envelope.attack = 0.003f + random.nextFloat() * (slow ? 1.5f : 0.15f); + envelope.hold = random.nextBool() ? 0.0f : random.nextFloat() * 0.3f; + envelope.decay = 0.05f + random.nextFloat() * 1.5f; + envelope.sustain = random.nextFloat(); + envelope.release = 0.05f + random.nextFloat() * 1.5f; + + envelopePanels[static_cast (index)]->refresh(); + } + + for (int index = 0; index < SynthExample::lfoCount; ++index) + { + auto& lfo = synth.getLFOSettings (index); + + lfo.shape = random.nextInt (5); + lfo.rate = 0.1f * std::exp2 (random.nextFloat() * 6.0f); + lfo.phase = random.nextBool() ? 0.0f : random.nextFloat(); + lfo.retrigger = random.nextBool(); + + lfoPanels[static_cast (index)]->refresh(); + } + + // A few live routes, never to the oscillator levels, so a random patch cannot + // fall silent; the rest of the slots are cleared. + auto& modulation = synth.getModulationSettings(); + const auto liveRoutes = 1 + random.nextInt (4); + + for (int index = 0; index < SynthExample::modulationSlots; ++index) + { + auto& slot = modulation.slots[static_cast (index)]; + + if (index >= liveRoutes) + { + slot.destination = static_cast (SynthModulationDestination::none); + slot.depth = 0.0f; + continue; + } + + auto destination = SynthModulationDestination::none; + + do + { + destination = static_cast (1 + random.nextInt (static_cast (SynthModulationDestination::count) - 1)); + } while (destination == SynthModulationDestination::osc1Level || destination == SynthModulationDestination::osc2Level); + + slot.source = random.nextInt (4); + slot.destination = static_cast (destination); + slot.depth = (random.nextFloat() - 0.5f) * (random.nextBool() ? 2.0f : 1.0f); + } + + modulationPage->refresh(); } //============================================================================== @@ -516,6 +576,9 @@ class AudioExample static constexpr float headerHeight = 44.0f; static constexpr float spacing = 8.0f; static constexpr float pageButtonWidth = 68.0f; + static constexpr float keyboardHeight = 72.0f; + static constexpr float lfoRowHeight = 104.0f; + static constexpr float shapingRowHeight = 150.0f; //============================================================================== yup::AudioDeviceManager deviceManager; diff --git a/examples/graphics/source/examples/audio/SynthEngine.h b/examples/graphics/source/examples/audio/SynthEngine.h index bc1479eb2..37afe1f15 100644 --- a/examples/graphics/source/examples/audio/SynthEngine.h +++ b/examples/graphics/source/examples/audio/SynthEngine.h @@ -409,11 +409,11 @@ class SynthSpectrumDerivation //============================================================================== /** The series every voice of one oscillator slot plays, rebuilt once per block. - A slot's spectrum does not vary per voice unless an envelope is routed into it, - so it is derived once here from the LFO-modulated values rather than inside each - voice. Voices publish nothing back; they compare getGeneration() and re-render - their own table when it moves, which keeps yup::WavetableOscillator's crossfade - doing the smoothing. Runs on the audio thread before any voice reads it. + A slot's spectrum does not vary per voice unless something is routed into it, so + it is derived once here rather than inside each voice. Voices publish nothing back; + they compare getGeneration() and re-render their own table when it moves, which + keeps yup::WavetableOscillator's crossfade doing the smoothing. Runs on the audio + thread before any voice reads it. @see SynthSpectrumDerivation, SynthOscillator */ @@ -447,7 +447,7 @@ class SynthOscillatorSlot prepare() allocates the backends; renderBlock() is allocation-free and only re-renders a table when its series moved. The series normally comes from the - shared slot; while an envelope is routed into this oscillator's spectrum it is + shared slot; while a modulation is routed into this oscillator's spectrum it is derived here instead, from the voice's own values. Unison is built from bare yup::WavetableOscillator satellites playing the same @@ -503,7 +503,7 @@ class SynthOscillator The buffers are overwritten rather than added to, so the caller does not have to clear them first. - @param deriveLocally True while an envelope modulates this oscillator's spectrum, + @param deriveLocally True while a modulation reaches this oscillator's spectrum, in which case the series is derived here from these values rather than taken from the slot. */ @@ -725,16 +725,17 @@ class SynthFilterStage }; //============================================================================== -/** What the engine derived for this block, read by every voice while it renders. +/** The settings snapshot for this block, read by every voice while it renders. Written at the top of HarmonicSynthEngine::renderNextBlock, before any voice runs, on the same thread, so no publication handshake is needed. */ struct SynthBlockContext { - SynthPatchValues patch; /**< Settings with the LFO routings applied. */ - SynthModulationValues modulation; /**< The routes, for the voice's envelope layer. */ + SynthPatchValues patch; + SynthModulationValues modulation; std::array envelopes; + std::array lfos; }; //============================================================================== @@ -747,7 +748,7 @@ class SynthSound : public yup::SynthesiserSound }; //============================================================================== -/** A polyphonic voice: two oscillators into a filter, shaped by two envelopes. */ +/** A polyphonic voice: two oscillators into a filter, with its own envelopes and LFOs. */ class SynthVoice : public yup::SynthesiserVoice { public: @@ -774,6 +775,9 @@ class SynthVoice : public yup::SynthesiserVoice filter.prepare (sampleRate); envelope.prepare (sampleRate); modulationEnvelope.prepare (sampleRate); + + for (auto& lfo : lfos) + lfo.prepare (sampleRate); playbackRate = sampleRate; hasPlayed = false; preserveNote = false; @@ -832,6 +836,10 @@ class SynthVoice : public yup::SynthesiserVoice modulationEnvelope.setParameters (context.envelopes[1]); modulationEnvelope.noteOn(); filter.reset(); + + for (std::size_t index = 0; index < lfos.size(); ++index) + if (context.lfos[index].retrigger) + lfos[index].reset(); } preserveNote = false; @@ -878,6 +886,9 @@ class SynthVoice : public yup::SynthesiserVoice /** Includes a recycled voice's short continuation in the activity meter. */ bool isSounding() const noexcept { return isVoiceActive() || tailPosition < tailLength; } + /** Returns the phase of one of this voice's LFOs, for the display. */ + float getLFOPhase (int lfoIndex) const noexcept { return lfos[static_cast (lfoIndex)].getPhase(); } + void controllerMoved (int, int) override {} //============================================================================== @@ -908,12 +919,22 @@ class SynthVoice : public yup::SynthesiserVoice if (isVoiceActive()) { - // The engine applied the LFO routings for this block; the envelopes are per - // voice, so their routings are applied here on top, once per control chunk. + // Every source is per voice, so the routings are applied here, once per + // control chunk, from the values each source holds at the top of the chunk. + for (std::size_t index = 0; index < lfos.size(); ++index) + { + lfos[index].setShape (context.lfos[index].shape); + lfos[index].setFrequency (context.lfos[index].rate); + lfos[index].setPhaseOffset (context.lfos[index].phase); + } + auto patch = context.patch; - const std::array sources { envelope.getLevel(), modulationEnvelope.getLevel(), 0.0f, 0.0f }; + const std::array sources { envelope.getLevel(), modulationEnvelope.getLevel(), lfos[0].getValue(), lfos[1].getValue() }; applyModulation (patch, context.modulation, sources); + for (auto& lfo : lfos) + lfo.skip (numSamples); + const auto note = pitch.skip (numSamples) + bend.skip (numSamples); const auto frequency = midiNoteToFrequency (note); for (int index = 0; index < SynthExample::oscillatorCount; ++index) @@ -923,7 +944,7 @@ class SynthVoice : public yup::SynthesiserVoice auto& level = levels[slot]; const auto detuned = frequency * std::exp2 (values.octave + values.detuneSemitones / 12.0); oscillators[slot].renderBlock (oscLeft.data(), oscRight.data(), numSamples, values, detuned, - context.modulation.hasVoiceSpectrumRoute (index)); + context.modulation.hasSpectrumRoute (index)); level.setTargetValue (values.level); for (int sample = 0; sample < numSamples; ++sample) @@ -980,6 +1001,7 @@ class SynthVoice : public yup::SynthesiserVoice SynthEnvelope envelope; SynthEnvelope modulationEnvelope; + std::array, SynthExample::lfoCount> lfos; SynthFilterStage filter; std::vector oscLeft; @@ -1044,9 +1066,6 @@ class HarmonicSynthEngine : public yup::Synthesiser setCurrentPlaybackSampleRate (sampleRate); activeVoices.store (0); - for (auto& lfo : lfos) - lfo.prepare (sampleRate); - for (int index = 0; index < ownedVoices.size(); ++index) ownedVoices[index]->prepare (sampleRate, maxBlockSize); } @@ -1068,25 +1087,6 @@ class HarmonicSynthEngine : public yup::Synthesiser allNotesOff (0, true); mode = requestedMode; } - // The LFOs are global, so their routings are applied once here and every voice - // of a slot plays the same spectrum; the envelope routings are per voice and are - // applied by each voice on top of this. - std::array sources { 0.0f, 0.0f, 0.0f, 0.0f }; - - for (std::size_t index = 0; index < lfos.size(); ++index) - { - const auto values = lfoSettings[index].read(); - auto& lfo = lfos[index]; - - lfo.setShape (values.shape); - lfo.setFrequency (values.rate); - lfo.setPhaseOffset (values.phase); - - sources[2 + index] = lfo.getValue(); - lfoPhases[index].store (lfo.getPhase()); - lfo.skip (count); - } - for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) context.patch.oscillators[slot] = settings[slot].read(); @@ -1096,16 +1096,33 @@ class HarmonicSynthEngine : public yup::Synthesiser for (std::size_t index = 0; index < envelopeSettings.size(); ++index) context.envelopes[index] = envelopeSettings[index].read(); - applyModulation (context.patch, context.modulation, sources); + for (std::size_t index = 0; index < lfoSettings.size(); ++index) + context.lfos[index] = lfoSettings[index].read(); + // Every voice of a slot plays the same spectrum unless a modulation is routed + // into it, so it is derived once here rather than once per voice. for (std::size_t slot = 0; slot < oscillatorSlots.size(); ++slot) oscillatorSlots[slot].update (context.patch.oscillators[slot], settings[slot], resources); yup::Synthesiser::renderNextBlock (output, midi, start, count); + int active = 0; + const SynthVoice* newest = nullptr; + for (auto* voice : ownedVoices) + { active += voice->isSounding() ? 1 : 0; + + if (voice->isVoiceActive() && (newest == nullptr || newest->wasStartedBefore (*voice))) + newest = voice; + } + activeVoices.store (active); + + // The displays follow the LFOs of the most recent note. + if (newest != nullptr) + for (int index = 0; index < SynthExample::lfoCount; ++index) + lfoPhases[static_cast (index)].store (newest->getLFOPhase (index)); } void noteOn (int channel, int note, float velocity) override @@ -1191,7 +1208,7 @@ class HarmonicSynthEngine : public yup::Synthesiser /** Returns the routings edited by the modulation page. */ SynthModulationSettings& getModulationSettings() noexcept { return modulationSettings; } - /** Returns the phase of an LFO at the top of the last block, for its display. */ + /** Returns the phase of an LFO in the most recently started voice, for its display. */ float getLFOPhase (int lfoIndex) const noexcept { return lfoPhases[static_cast (lfoIndex)].load(); @@ -1268,7 +1285,6 @@ class HarmonicSynthEngine : public yup::Synthesiser SynthFilterSettings filterSettings; std::array lfoSettings; SynthModulationSettings modulationSettings; - std::array, SynthExample::lfoCount> lfos; std::array, SynthExample::lfoCount> lfoPhases {}; SynthBlockContext context; yup::ReferenceCountedArray ownedVoices; diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 3c22c6a61..e46c206c8 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -843,7 +843,7 @@ class LFODisplay : public yup::Component }; //============================================================================== -/** Shape, rate and phase of one LFO, with its display. +/** Shape, rate, phase and retrigger of one LFO, with its display. @see SynthLFOSettings */ @@ -861,6 +861,16 @@ class SynthLFOPanel : public yup::Component titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); addAndMakeVisible (titleLabel); + retriggerButton.setButtonText ("RETRIG"); + retriggerButton.setColor (yup::ToggleButton::Style::backgroundColorId, SynthTheme::displayBackground); + retriggerButton.setColor (yup::ToggleButton::Style::backgroundToggledColorId, SynthTheme::accentDim); + retriggerButton.setColor (yup::ToggleButton::Style::textColorId, SynthTheme::textSecondary); + retriggerButton.setColor (yup::ToggleButton::Style::textToggledColorId, SynthTheme::textPrimary); + retriggerButton.setColor (yup::ToggleButton::Style::borderColorId, SynthTheme::panelBorder); + retriggerButton.setColor (yup::ToggleButton::Style::borderToggledColorId, SynthTheme::accent); + retriggerButton.onClick = [this] { settings.retrigger = retriggerButton.getToggleState(); }; + addAndMakeVisible (retriggerButton); + addAndMakeVisible (display); addAndMakeVisible (shapeChoice); addAndMakeVisible (rateKnob); @@ -883,6 +893,7 @@ class SynthLFOPanel : public yup::Component shapeChoice.getComboBox().setSelectedId (settings.shape.load() + 1, yup::dontSendNotification); rateKnob.getSlider().setValue (settings.rate.load(), yup::dontSendNotification); phaseKnob.getSlider().setValue (settings.phase.load(), yup::dontSendNotification); + retriggerButton.setToggleState (settings.retrigger.load(), yup::dontSendNotification); refreshDisplay(); } @@ -894,16 +905,20 @@ class SynthLFOPanel : public yup::Component { auto bounds = getLocalBounds().reduced (panelInset); - titleLabel.setBounds (bounds.removeFromTop (headerHeight)); + auto header = bounds.removeFromTop (headerHeight); + retriggerButton.setBounds (header.removeFromRight (buttonWidth)); + titleLabel.setBounds (header); bounds.removeFromTop (spacing); - auto controls = bounds.removeFromRight (controlWidth); + // Everything sits in one row so the panel stays short: display, shape, rate, phase. + auto knobs = bounds.removeFromRight (knobWidth * 2.0f); + bounds.removeFromRight (spacing); + auto choice = bounds.removeFromRight (choiceWidth); bounds.removeFromRight (spacing); display.setBounds (bounds); - shapeChoice.setBounds (controls.removeFromTop (choiceHeight)); - controls.removeFromTop (spacing); - layoutControlsInRow (controls, { &rateKnob, &phaseKnob }); + shapeChoice.setBounds (choice.withSizeKeepingCenter (choice.getWidth(), yup::jmin (choice.getHeight(), choiceHeight))); + layoutControlsInRow (knobs, { &rateKnob, &phaseKnob }); } void paint (yup::Graphics& g) override @@ -913,9 +928,11 @@ class SynthLFOPanel : public yup::Component private: static constexpr float panelInset = 8.0f; - static constexpr float headerHeight = 16.0f; + static constexpr float headerHeight = 18.0f; static constexpr float choiceHeight = 36.0f; - static constexpr float controlWidth = 130.0f; + static constexpr float choiceWidth = 90.0f; + static constexpr float knobWidth = 58.0f; + static constexpr float buttonWidth = 68.0f; static constexpr float spacing = 6.0f; void refreshDisplay() { display.setValues (settings.read()); } @@ -923,6 +940,7 @@ class SynthLFOPanel : public yup::Component SynthLFOSettings& settings; yup::Label titleLabel; + yup::ToggleButton retriggerButton; LFODisplay display; ChoiceControl shapeChoice; KnobControl rateKnob; diff --git a/examples/graphics/source/examples/audio/SynthSettings.h b/examples/graphics/source/examples/audio/SynthSettings.h index 6e84ecf69..7e7061972 100644 --- a/examples/graphics/source/examples/audio/SynthSettings.h +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -316,6 +316,7 @@ struct SynthLFOValues yup::LFO::Shape shape = yup::LFO::Shape::sine; float rate = 1.0f; float phase = 0.0f; + bool retrigger = true; /**< Restart from the phase offset on every note, or run freely. */ }; /** The same controls, edited from the message thread while the audio thread reads them. */ @@ -324,11 +325,12 @@ struct SynthLFOSettings std::atomic shape { static_cast (yup::LFO::Shape::sine) }; std::atomic rate { 1.0f }; std::atomic phase { 0.0f }; + std::atomic retrigger { true }; /** Takes a snapshot for one block of audio. */ SynthLFOValues read() const noexcept { - return { static_cast::Shape> (shape.load()), rate.load(), phase.load() }; + return { static_cast::Shape> (shape.load()), rate.load(), phase.load(), retrigger.load() }; } }; @@ -336,9 +338,9 @@ struct SynthLFOSettings /** What can drive a modulation route. */ enum class SynthModulationSource { - env1, /**< The amplitude envelope, unipolar and per voice. */ - env2, /**< The free envelope, unipolar and per voice. */ - lfo1, /**< Global and bipolar. */ + env1, /**< The amplitude envelope, unipolar. */ + env2, /**< The free envelope, unipolar. */ + lfo1, /**< Bipolar. */ lfo2 }; @@ -470,14 +472,12 @@ struct SynthModulationValues { std::array routes; - /** True when an envelope is routed with depth into a spectrum parameter of this oscillator. */ - bool hasVoiceSpectrumRoute (int oscillator) const noexcept + /** True when something is routed with depth into a spectrum parameter of this oscillator. */ + bool hasSpectrumRoute (int oscillator) const noexcept { for (const auto& route : routes) { - const auto perVoice = route.source == SynthModulationSource::env1 || route.source == SynthModulationSource::env2; - - if (perVoice && route.depth != 0.0f && isSpectrumDestination (route.destination) + if (route.depth != 0.0f && isSpectrumDestination (route.destination) && getDestinationOscillator (route.destination) == oscillator) return true; } @@ -513,7 +513,7 @@ struct SynthModulationSettings }; //============================================================================== -/** Everything a voice plays from, after a modulation layer has been applied. */ +/** Everything a voice plays from, before or after modulation has been applied. */ struct SynthPatchValues { std::array oscillators; From 76c7c176ce812d08fd79e1b187affe4ccb3976dd Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 23 Sep 2026 09:02:01 +0200 Subject: [PATCH 13/37] More fun waveform display --- CHANGELOG.md | 1 + docs/dsp/oscillators.md | 2 +- examples/graphics/source/examples/Audio.h | 2 + .../source/examples/audio/SynthPanels.h | 241 +++++++++++++++++- .../oscillators/yup_ModulatedOscillator.h | 4 +- 5 files changed, 241 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5459af50..b45dd6fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -427,6 +427,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Examples +- The graphics synthesizer example's oscillator displays now draw the waveform the voices play with a `yup_rhi` fragment shader: a raymarched teal ridge landscape whose far ridge is one period of it, animating slowly while shown. The pipeline is compiled once and shared by both displays, and the vector display remains the fallback without a GPU. - `WidgetsDemo` example (`examples/graphics/source/examples/Widgets.h`): the placeholder image button is now a working `ImageHitTestButton` demonstrating `Component::hitTest` - it draws `data/logo.png` and samples the image's alpha at the hit point, so only the logo's opaque pixels are clickable and the transparent ones fall through to what is behind. The hover highlight goes through the same test, so moving the pointer over a transparent region inside the button's bounds drops it. - `SpinningCubeDemo` example (`examples/graphics`): rewritten to the new RHI shape — `GpuFrame` + `GpuCanvas::beginDraw` + `GpuRenderPass` for both the indexed cube draw and the separable two-pass blur (H+V sharing one `GpuFrame`), `isGpuAvailable()` capability probe, and live GLSL editing via `GpuPipeline::compileFromGlsl`. The default Lottie animation is now played back per-frame into an offscreen `GpuCanvas` (2D path) and sampled by the cube's fragment shader so the animation is texture-mapped onto every cube face. - `AIDemo` example (`examples/graphics/source/examples/AI.h`): interactive demo for all four LLM providers (OpenAI Chat, OpenAI Responses, Anthropic, Gemini) with model and API key configuration, system prompt editing, streaming and non-streaming completion, tool calling, MCP server integration, and embedded text generation. diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index 1391eb52f..b968e921b 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -57,7 +57,7 @@ Three details of the published method are worth knowing before using it: | `SyncSpectralResampler` | The synchronization transform itself: follower coefficients plus a period ratio and a `SyncMode` in, synchronized coefficients out. | | `AdditiveOscillator` | Exact additive synthesis of a series at a fundamental, Nyquist limited, evaluated a SIMD width of harmonics at a time. | | `WavetableOscillator` | Renders a series into a single-cycle table with an inverse FFT and plays it back with 4-point Hermite interpolation, crossfading between renders. | -| `SyncOscillator` | The synth-ready facade: a follower series, a mode, a ratio and a pitch in, audio out, with both synthesis backends selectable at runtime. | +| `SyncOscillator` | The synth-ready facade: a follower series, a mode, a ratio and a pitch in, audio out. | Both oscillators are usable on their own as plain bandlimited oscillators; the resampler is usable on its own for analysis or visualization, and the series can be diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 7640090e2..2056d5aea 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -101,6 +101,7 @@ class AudioExample yup::String ("OSC ") + yup::String (index + 1), synth.getOscillatorSettings (index), synth.getResources(), + waveformShader, font.withHeight (10.0f)); mainPage.addAndMakeVisible (*panel); @@ -610,6 +611,7 @@ class AudioExample yup::Label loadLabel; SynthPage mainPage; + std::shared_ptr waveformShader = std::make_shared(); std::array, SynthExample::oscillatorCount> oscillatorPanels; std::unique_ptr filterPanel; std::array, SynthExample::envelopeCount> envelopePanels; diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index e46c206c8..84bcff0a6 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -243,6 +243,192 @@ inline float evaluateFourierSeries (const yup::FourierSeries& series, do return static_cast (value); } +//============================================================================== +/** Renders a raymarched ridge landscape shaped by one period of a waveform. + + The dominant displacement of the landscape is the waveform itself, so the ridges + follow the shape the oscillator plays, with a few faint octaves on top as shimmer. + + Compiling the GLSL costs tens of milliseconds, so one instance is shared by every + waveform display and it compiles once. A failed compile is remembered rather than + retried, and render() then returns nullptr so the caller can draw without it. +*/ +class SynthWaveformShader +{ +public: + /** The number of waveform samples render() expects. */ + static constexpr int sampleCount = 256; + + /** Renders the landscape into a target owned by the caller. + + @param context The context the display paints with, providing the GPU device + @param target The caller's target, recreated here whenever the size changes + @param width The width of the landscape, in logical units + @param height The height of the landscape, in logical units + @param samples One period of the waveform, sampleCount values in the range -1 to 1 + @param time The animation time, in seconds + + @returns The rendered landscape, or nullptr when no GPU path is available. + */ + yup::GpuTexture::Ptr render (yup::GraphicsContext& context, + yup::GpuTarget::Ptr& target, + int width, + int height, + const std::vector& samples, + float time) + { + jassert (samples.size() == static_cast (sampleCount)); + + if (width < 2 || height < 2 || ! ensurePipeline (context)) + return nullptr; + + if (target == nullptr || target->getWidth() != width || target->getHeight() != height) + target = yup::GpuTarget::create (device, width, height); + + if (target == nullptr) + return nullptr; + + const Params params { time, static_cast (width), static_cast (height), 0.0f }; + + auto frame = yup::GpuFrame::begin (device); + if (! frame.isValid()) + return nullptr; + + auto pass = target->beginRenderPass (frame, { true, SynthTheme::displayBackground }); + if (! pass.isValid()) + return nullptr; + + pass.setPipeline (pipeline); + pass.setUniformBuffer (0, 0, ¶ms, sizeof (params)); + pass.setUniformBuffer (0, 1, samples.data(), samples.size() * sizeof (float)); + + if (! pass.draw (3) || ! pass.finish() || ! frame.submit()) + return nullptr; + + return target->asTexture(); + } + +private: + struct alignas (16) Params + { + float time; + float width; + float height; + float pad; + }; + + bool ensurePipeline (yup::GraphicsContext& context) + { + if (pipeline != nullptr) + return true; + + if (compileAttempted || ! context.isGpuAvailable()) + return false; + + compileAttempted = true; + device = context.getGpuDevice(); + + yup::GpuPipelineOptions options; + options.colorTargets.emplace_back().blendEnabled = false; + + auto result = yup::GpuPipeline::compileFromGlsl (device, vertexSource, yup::String::fromUTF8 (fragmentSource), options); + if (result.failed()) + { + yup::Logger::outputDebugString ("SynthWaveformShader: shader compile failed: " + result.getErrorMessage()); + return false; + } + + pipeline = result.getValue(); + return true; + } + + static constexpr char vertexSource[] = R"glsl(#version 450 +void main() { + float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; + float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; + gl_Position = vec4(x, y, 0.0, 1.0); +} +)glsl"; + + // Shadertoy's y-up pixel space, since RHI targets read top-left-origin everywhere. + static constexpr char fragmentSource[] = R"glsl(#version 450 +layout(set = 0, binding = 0) uniform Params +{ + float time; + float width; + float height; + float pad; +} u; + +layout(set = 0, binding = 1) uniform Samples +{ + vec4 samples[64]; +} waveform; + +layout(location = 0) out vec4 fragColor; + +const vec3 accent = vec3(0.447, 0.918, 0.824); +const float focal = 2.8; +const vec3 background = vec3(0.055, 0.067, 0.078); + +float fetchSample(int index) +{ + return waveform.samples[index >> 2][index & 3]; +} + +float wave(float phase) +{ + float position = phase * 255.0; + int i0 = int(position); + int i1 = min(i0 + 1, 255); + return mix(fetchSample(i0), fetchSample(i1), position - float(i0)); +} + +void main() +{ + vec2 resolution = vec2(u.width, u.height); + vec2 I = vec2(gl_FragCoord.x, u.height - gl_FragCoord.y); + + // The ridge on the far wall, four units away, spans one period across the width. + vec3 direction = normalize(vec3(I + I - resolution, -u.height * focal)); + float frequency = u.height * focal / (8.0 * u.width); + + float scroll = 0.5 + u.time * 0.01; + float hue = u.time * 0.15; + + vec3 color = vec3(0.0); + float z = 0.0; + + for (int i = 0; i < 90; ++i) + { + vec3 p = z * direction + vec3(0.0, 1.0, 1.0); + + float r = max(-p.y, 0.0); + p.y += r + r; + + p.y -= wave(fract(p.x * frequency + scroll)); + + for (float octave = 2.0; octave < 30.0; octave += octave) + p.y += 0.12 * cos(p.x * octave + 0.6 * u.time * cos(octave) + z) / octave; + + float plane = p.z + 3.0; + float d = (0.1 * r + abs(p.y - 1.0) / (1.0 + r + r + r * r) + max(plane, -plane * 0.1)) / 8.0; + z += d; + + float phase = z * 0.5 + hue; + vec3 tone = accent * (cos(phase) + 1.3) + vec3(0.0, 0.15, 0.08) * cos(phase + 2.0); + color += tone / max(d * z, 1.0e-4); + } + + fragColor = vec4(max(tanh(color / 900.0), background), 1.0); +} +)glsl"; + + yup::GpuDevice::Ptr device; + yup::GpuPipeline::Ptr pipeline; + bool compileAttempted = false; +}; + //============================================================================== /** The waveform of one oscillator, either drawn or edited a partial at a time. @@ -261,9 +447,11 @@ class WaveformEditor : public yup::Component { public: WaveformEditor (SynthOscillatorSettings& settingsToEdit, - const SynthOscillatorResources& sharedResources) + const SynthOscillatorResources& sharedResources, + std::shared_ptr sharedShader) : settings (settingsToEdit) , resources (sharedResources) + , shader (std::move (sharedShader)) { preview.prepare(); displaySeries.resize (SynthExample::maxHarmonics); @@ -379,26 +567,60 @@ class WaveformEditor : public yup::Component void paint (yup::Graphics& g) override { const auto bounds = getLocalBounds(); + const auto landscape = editingPartials ? nullptr : renderLandscape (g); - g.setFillColor (SynthTheme::displayBackground); - g.fillRoundedRect (bounds, 4.0f); + if (landscape != nullptr) + { + const auto state = g.saveState(); + + // setClipPath works in top-level coordinates, unlike the drawing calls. + yup::Path clip; + clip.addRoundedRectangle (getBoundsRelativeToTopLevelComponent(), cornerRadius); + g.setClipPath (clip); + g.drawTexture (landscape, bounds); + } + else + { + g.setFillColor (SynthTheme::displayBackground); + g.fillRoundedRect (bounds, cornerRadius); + } g.setStrokeColor (SynthTheme::panelBorder); g.setStrokeWidth (1.0f); - g.strokeRoundedRect (bounds.reduced (0.5f), 4.0f); + g.strokeRoundedRect (bounds.reduced (0.5f), cornerRadius); if (editingPartials) paintPartials (g, bounds.reduced (contentInset)); - else + else if (landscape == nullptr) paintWaveform (g, bounds.reduced (contentInset)); } + void refreshDisplay (double lastFrameTimeSeconds) override + { + if (editingPartials || ! isShowing()) + return; + + animationTime += static_cast (lastFrameTimeSeconds); + repaint(); + } + void mouseDown (const yup::MouseEvent& event) override { applyEdit (event); } void mouseDrag (const yup::MouseEvent& event) override { applyEdit (event); } private: //============================================================================== + /** Renders the shader landscape behind the waveform, or returns nullptr without a GPU. */ + yup::GpuTexture::Ptr renderLandscape (yup::Graphics& g) + { + return shader->render (g.getGraphicsContext(), + landscapeTarget, + yup::roundToInt (getWidth()), + yup::roundToInt (getHeight()), + displaySamples, + animationTime); + } + /** Draws one period of the reconstructed series. */ void paintWaveform (yup::Graphics& g, yup::Rectangle bounds) { @@ -491,10 +713,16 @@ class WaveformEditor : public yup::Component //============================================================================== static constexpr int displayResolution = 256; static constexpr float contentInset = 6.0f; + static constexpr float cornerRadius = 4.0f; static constexpr float barGap = 1.0f; + static_assert (displayResolution == SynthWaveformShader::sampleCount); + SynthOscillatorSettings& settings; const SynthOscillatorResources& resources; + std::shared_ptr shader; + yup::GpuTarget::Ptr landscapeTarget; + float animationTime = 0.0f; SynthSpectrumDerivation preview; yup::FourierSeries displaySeries; @@ -1047,9 +1275,10 @@ class SynthOscillatorPanel : public yup::Component SynthOscillatorPanel (const yup::String& panelTitle, SynthOscillatorSettings& settingsToEdit, const SynthOscillatorResources& resources, + std::shared_ptr waveformShader, const yup::Font& font) : settings (settingsToEdit) - , editor (settingsToEdit, resources) + , editor (settingsToEdit, resources, std::move (waveformShader)) , waveformChoice ("WAVEFORM", getSynthWaveformNames(), font) , syncModeChoice ("SYNC", getSynthSyncModeNames(), font) , levelKnob ("LEVEL", 0.0, 1.0, 0.001, 0.5, font) diff --git a/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h index d042a231f..a3899a2af 100644 --- a/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h +++ b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h @@ -30,7 +30,7 @@ namespace yup All fields must be finite. Shared by ModulatedOscillator and by any oscillator composed over detail::ModulatedOscillatorVoice. - @see ModulatedOscillator, PrismOscillator + @see ModulatedOscillator, PrismSpectrum */ struct ModulatedOscillatorParameters { @@ -61,7 +61,7 @@ namespace detail @tparam SampleType Output precision. @tparam CoeffType Waveform coefficient precision. - @see ModulatedOscillator, PrismOscillator + @see ModulatedOscillator, PrismSpectrum */ template class ModulatedOscillatorVoice From ab30770008605460410e92c3314f556059bbe910 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 23 Sep 2026 12:05:28 +0200 Subject: [PATCH 14/37] Allow drawing waveforms --- CHANGELOG.md | 1 + .../source/examples/audio/SynthPanels.h | 202 +++++++++++++++--- .../source/examples/audio/SynthSettings.h | 72 +++++-- 3 files changed, 229 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b45dd6fc9..4a2e95a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -428,6 +428,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Examples - The graphics synthesizer example's oscillator displays now draw the waveform the voices play with a `yup_rhi` fragment shader: a raymarched teal ridge landscape whose far ridge is one period of it, animating slowly while shown. The pipeline is compiled once and shared by both displays, and the vector display remains the fallback without a GPU. +- The graphics synthesizer example's oscillator display can be drawn on freehand. The stroke is analyzed into 64 phase-preserving partials, which the PARTIALS view then edits. - `WidgetsDemo` example (`examples/graphics/source/examples/Widgets.h`): the placeholder image button is now a working `ImageHitTestButton` demonstrating `Component::hitTest` - it draws `data/logo.png` and samples the image's alpha at the hit point, so only the logo's opaque pixels are clickable and the transparent ones fall through to what is behind. The hover highlight goes through the same test, so moving the pointer over a transparent region inside the button's bounds drops it. - `SpinningCubeDemo` example (`examples/graphics`): rewritten to the new RHI shape — `GpuFrame` + `GpuCanvas::beginDraw` + `GpuRenderPass` for both the indexed cube draw and the separable two-pass blur (H+V sharing one `GpuFrame`), `isGpuAvailable()` capability probe, and live GLSL editing via `GpuPipeline::compileFromGlsl`. The default Lottie animation is now played back per-frame into an offscreen `GpuCanvas` (2D path) and sampled by the cube's fragment shader so the animation is texture-mapped onto every cube face. - `AIDemo` example (`examples/graphics/source/examples/AI.h`): interactive demo for all four LLM providers (OpenAI Chat, OpenAI Responses, Anthropic, Gemini) with model and API key configuration, system prompt editing, streaming and non-streaming completion, tool calling, MCP server integration, and embedded text generation. diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 84bcff0a6..147bd2d30 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -26,6 +26,7 @@ #include #include #include +#include #include //============================================================================== @@ -432,9 +433,10 @@ void main() //============================================================================== /** The waveform of one oscillator, either drawn or edited a partial at a time. - In drawing mode the component reconstructs the series and shows one period of it. - In editing mode it shows the magnitude of each harmonic as a bar that can be - dragged, which is what actually defines the waveform the oscillator renders. + In drawing mode the component shows one period of the reconstructed series, and a + drag draws that period freehand: the stroke is analyzed back into partials, phase + included, which become the oscillator's custom series. In editing mode it shows the + magnitude of each harmonic as a bar that can be dragged. Dragging writes straight into the settings, but the generation counter the audio thread watches is only bumped by commitPendingEdits(), once per user interface @@ -456,6 +458,8 @@ class WaveformEditor : public yup::Component preview.prepare(); displaySeries.resize (SynthExample::maxHarmonics); displaySamples.assign (displayResolution, 0.0f); + drawnCycle.assign (displayResolution, 0.0f); + drawnSeries.resize (SynthExample::editableHarmonics); refresh(); } @@ -504,12 +508,13 @@ class WaveformEditor : public yup::Component // Normalize whatever is actually drawn. The shaper preserves the coefficient sum // rather than the peak, and a sum bounds a peak from well above - three times over // for a sawtooth - so scaling the derived waveform by the source's peak would draw - // it clean outside the display. + // it clean outside the display. During a stroke it follows the stroke's own peak + // instead, so the curve stays under the pointer. const auto peak = measurePeak(); if (peak > 1.0e-6f) { - const auto scale = 1.0f / peak; + const auto scale = (lastDrawnIndex.has_value() ? measurePeak (drawnCycle) : 1.0f) / peak; for (auto& sample : displaySamples) sample *= scale; @@ -533,12 +538,7 @@ class WaveformEditor : public yup::Component /** Returns the largest magnitude currently in the display buffer. */ float measurePeak() const noexcept { - auto peak = 0.0f; - - for (auto sample : displaySamples) - peak = yup::jmax (peak, std::abs (sample)); - - return peak; + return measurePeak (displaySamples); } /** Publishes a pending drag to the audio thread, coalescing a frame's worth of edits. */ @@ -555,12 +555,8 @@ class WaveformEditor : public yup::Component void revertToPreset() { settings.usesCustomSeries.store (false); - pendingEdit = true; - refresh(); - - if (onPartialsChanged != nullptr) - onPartialsChanged(); + publishEdit(); } //============================================================================== @@ -593,6 +589,8 @@ class WaveformEditor : public yup::Component paintPartials (g, bounds.reduced (contentInset)); else if (landscape == nullptr) paintWaveform (g, bounds.reduced (contentInset)); + else + paintDrawnCycle (g, bounds.reduced (contentInset)); } void refreshDisplay (double lastFrameTimeSeconds) override @@ -604,9 +602,35 @@ class WaveformEditor : public yup::Component repaint(); } - void mouseDown (const yup::MouseEvent& event) override { applyEdit (event); } + void mouseDown (const yup::MouseEvent& event) override + { + if (editingPartials) + { + applyEdit (event); + return; + } + + seedDrawnCycle(); + lastDrawnIndex.reset(); + applyDraw (event); + } + + void mouseDrag (const yup::MouseEvent& event) override + { + if (editingPartials) + applyEdit (event); + else + applyDraw (event); + } + + void mouseUp (const yup::MouseEvent&) override + { + if (! lastDrawnIndex.has_value()) + return; - void mouseDrag (const yup::MouseEvent& event) override { applyEdit (event); } + lastDrawnIndex.reset(); + refresh(); + } private: //============================================================================== @@ -621,6 +645,44 @@ class WaveformEditor : public yup::Component animationTime); } + /** Returns the largest magnitude in a buffer of samples. */ + static float measurePeak (const std::vector& samples) noexcept + { + auto peak = 0.0f; + + for (auto sample : samples) + peak = yup::jmax (peak, std::abs (sample)); + + return peak; + } + + /** Draws the raw stroke while one is in progress, so the pointer always has it underneath. */ + void paintDrawnCycle (yup::Graphics& g, yup::Rectangle bounds) + { + if (! lastDrawnIndex.has_value()) + return; + + drawnPath.clear(); + drawnPath.reserveSpace (displayResolution); + + for (int index = 0; index < displayResolution; ++index) + { + const auto x = bounds.getX() + bounds.getWidth() * static_cast (index) + / static_cast (displayResolution); + + const auto y = bounds.getCenterY() - drawnCycle[static_cast (index)] * bounds.getHeight() * 0.45f; + + if (index == 0) + drawnPath.moveTo (x, y); + else + drawnPath.lineTo (x, y); + } + + g.setStrokeColor (SynthTheme::accent.withAlpha (0.25f)); + g.setStrokeWidth (1.0f); + g.strokePath (drawnPath); + } + /** Draws one period of the reconstructed series. */ void paintWaveform (yup::Graphics& g, yup::Rectangle bounds) { @@ -628,6 +690,8 @@ class WaveformEditor : public yup::Component g.setStrokeWidth (1.0f); g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + paintDrawnCycle (g, bounds); + path.clear(); path.reserveSpace (displayResolution); @@ -674,25 +738,98 @@ class WaveformEditor : public yup::Component } //============================================================================== - /** Turns a mouse position into the magnitude of one harmonic. */ - void applyEdit (const yup::MouseEvent& event) + /** Editing a preset copies its partials in first, so the edit starts from the shape on screen. */ + void beginCustomEdit() + { + if (settings.usesCustomSeries.load()) + return; + + settings.seedHarmonicsFrom (displaySeries); + settings.usesCustomSeries.store (true); + } + + /** Marks the edit for the next commit and brings the display and the panel along. */ + void publishEdit() + { + pendingEdit = true; + + refresh(); + + if (onPartialsChanged != nullptr) + onPartialsChanged(); + } + + /** Starts a stroke from the source waveform, normalized as the display shows it. */ + void seedDrawnCycle() { - if (! editingPartials) + for (int index = 0; index < displayResolution; ++index) + { + const auto phase = static_cast (index) / static_cast (displayResolution); + + drawnCycle[static_cast (index)] = + evaluateFourierSeries (displaySeries, phase, SynthExample::displayHarmonics); + } + + const auto peak = measurePeak (drawnCycle); + + if (peak <= 1.0e-6f) return; + for (auto& sample : drawnCycle) + sample /= peak; + } + + /** Draws the stroke into the cycle and analyzes the cycle into the custom series. */ + void applyDraw (const yup::MouseEvent& event) + { const auto bounds = getLocalBounds().reduced (contentInset); if (bounds.getWidth() <= 0.0f || bounds.getHeight() <= 0.0f) return; - // Editing a preset copies its partials in first, so the drag starts from the - // shape that is on screen instead of from silence. - if (! settings.usesCustomSeries.load()) + beginCustomEdit(); + + const auto position = event.getPosition(); + const auto index = yup::jlimit (0, + displayResolution - 1, + static_cast (std::floor ((position.getX() - bounds.getX()) / bounds.getWidth() + * static_cast (displayResolution)))); + + const auto value = yup::jlimit (-1.0f, 1.0f, (bounds.getCenterY() - position.getY()) / (bounds.getHeight() * 0.45f)); + + // Fills every sample the pointer skipped since the last event, so a fast drag leaves no gaps. + const auto first = lastDrawnIndex.value_or (index); + const auto firstValue = drawnCycle[static_cast (first)]; + const auto steps = std::abs (index - first); + const auto direction = index >= first ? 1 : -1; + + for (int step = 1; step < steps; ++step) { - settings.seedHarmonicsFrom (displaySeries); - settings.usesCustomSeries.store (true); + const auto amount = static_cast (step) / static_cast (steps); + + drawnCycle[static_cast (first + step * direction)] = firstValue + (value - firstValue) * amount; } + drawnCycle[static_cast (index)] = value; + lastDrawnIndex = index; + + // The custom series has no DC term, so the drawn cycle's offset is simply dropped. + drawnSeries.setFromCycle (yup::Span (drawnCycle)); + settings.seedHarmonicsFrom (drawnSeries); + + publishEdit(); + } + + /** Turns a mouse position into the magnitude of one harmonic. */ + void applyEdit (const yup::MouseEvent& event) + { + const auto bounds = getLocalBounds().reduced (contentInset); + + if (bounds.getWidth() <= 0.0f || bounds.getHeight() <= 0.0f) + return; + + beginCustomEdit(); + const auto position = event.getPosition(); const auto barWidth = bounds.getWidth() / static_cast (SynthExample::editableHarmonics); const auto index = yup::jlimit (0, @@ -701,13 +838,9 @@ class WaveformEditor : public yup::Component const auto magnitude = yup::jlimit (0.0f, 1.0f, (bounds.getBottom() - position.getY()) / bounds.getHeight()); - settings.harmonics[static_cast (index)].store (magnitude); - pendingEdit = true; - - refresh(); + settings.setHarmonicMagnitude (index, magnitude); - if (onPartialsChanged != nullptr) - onPartialsChanged(); + publishEdit(); } //============================================================================== @@ -729,6 +862,11 @@ class WaveformEditor : public yup::Component std::vector displaySamples; yup::Path path; + std::vector drawnCycle; + yup::Path drawnPath; + yup::FourierSeries drawnSeries; + std::optional lastDrawnIndex; + bool editingPartials = false; bool pendingEdit = false; }; diff --git a/examples/graphics/source/examples/audio/SynthSettings.h b/examples/graphics/source/examples/audio/SynthSettings.h index 7e7061972..4938c32b5 100644 --- a/examples/graphics/source/examples/audio/SynthSettings.h +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -45,7 +45,7 @@ constexpr int controlChunk = 128; constexpr int maxUnisonVoices = 5; /** Harmonics the partial editor exposes, a subset of the maxHarmonics the engine renders. */ -constexpr int editableHarmonics = 32; +constexpr int editableHarmonics = 64; /** Harmonics the waveform display sums, capped well below maxHarmonics to keep repaints cheap. */ constexpr int displayHarmonics = 64; @@ -132,7 +132,7 @@ struct SynthOscillatorValues /** The same controls, edited from the message thread while the audio thread reads them. - The partial editor writes magnitudes continuously while the mouse is down but bumps + The waveform editor writes partials continuously while the mouse is down but bumps harmonicGeneration at most once per user interface frame. The audio thread rebuilds its series only when that counter moves, which keeps a drag from forcing an inverse FFT per mouse event on every sounding voice. @@ -143,8 +143,11 @@ struct SynthOscillatorSettings { SynthOscillatorSettings() { - for (auto& harmonic : harmonics) - harmonic.store (0.0f); + for (auto& cosine : harmonicCosines) + cosine.store (0.0f); + + for (auto& sine : harmonicSines) + sine.store (0.0f); } std::atomic waveform { static_cast (yup::Waveform::sawtooth) }; @@ -167,7 +170,8 @@ struct SynthOscillatorSettings std::atomic unisonDetune { 0.2f }; std::atomic unisonSpread { 0.6f }; - std::array, SynthExample::editableHarmonics> harmonics; + std::array, SynthExample::editableHarmonics> harmonicCosines; + std::array, SynthExample::editableHarmonics> harmonicSines; std::atomic harmonicScale { 1.0f }; std::atomic usesCustomSeries { false }; std::atomic harmonicGeneration { 0 }; @@ -199,11 +203,10 @@ struct SynthOscillatorSettings harmonicGeneration.load() }; } - /** Rebuilds a prepared series from the edited magnitudes, without allocating. + /** Rebuilds a prepared series from the edited partials, without allocating. - The editor works in magnitudes only and writes them as sine coefficients, the - same convention yup::FourierSeries::setWaveform uses for its sawtooth, square - and triangle presets. + Every partial keeps both its cosine and sine coefficient, so a drawn cycle comes + back with the phase it was drawn with rather than as a sum of sines. Nothing stops the editor from asking for every harmonic at once, which would sum to many times full scale, so the caller passes the scale that brings the @@ -222,22 +225,63 @@ struct SynthOscillatorSettings for (int harmonic = 1; harmonic <= count; ++harmonic) { - const auto magnitude = harmonics[static_cast (harmonic - 1)].load() * scale; + const auto index = static_cast (harmonic - 1); - series.setHarmonic (harmonic, 0.0, static_cast (magnitude)); + series.setHarmonic (harmonic, + static_cast (harmonicCosines[index].load() * scale), + static_cast (harmonicSines[index].load() * scale)); } } - /** Seeds the edited magnitudes from a series, so editing starts at the visible shape. */ + /** Seeds the edited partials from a series, so editing starts at the visible shape. */ void seedHarmonicsFrom (const yup::FourierSeries& series) noexcept { const auto count = yup::jmin (SynthExample::editableHarmonics, series.getNumHarmonics()); for (int harmonic = 1; harmonic <= count; ++harmonic) - harmonics[static_cast (harmonic - 1)].store (static_cast (series.getMagnitude (harmonic))); + { + const auto index = static_cast (harmonic - 1); + + harmonicCosines[index].store (static_cast (series.getCosine (harmonic))); + harmonicSines[index].store (static_cast (series.getSine (harmonic))); + } for (int harmonic = count; harmonic < SynthExample::editableHarmonics; ++harmonic) - harmonics[static_cast (harmonic)].store (0.0f); + { + harmonicCosines[static_cast (harmonic)].store (0.0f); + harmonicSines[static_cast (harmonic)].store (0.0f); + } + } + + /** Sets the magnitude of one partial while keeping its phase. + + A partial that is currently silent has no phase to keep, so it starts as a sine. + + @param index The zero based partial, 0 being the fundamental + @param magnitude The new magnitude + */ + void setHarmonicMagnitude (int index, float magnitude) noexcept + { + jassert (yup::isPositiveAndBelow (index, SynthExample::editableHarmonics)); + + auto& cosine = harmonicCosines[static_cast (index)]; + auto& sine = harmonicSines[static_cast (index)]; + + const auto currentCosine = cosine.load(); + const auto currentSine = sine.load(); + const auto currentMagnitude = std::hypot (currentCosine, currentSine); + + if (currentMagnitude < 1.0e-6f) + { + cosine.store (0.0f); + sine.store (magnitude); + return; + } + + const auto scale = magnitude / currentMagnitude; + + cosine.store (currentCosine * scale); + sine.store (currentSine * scale); } }; From 650d377bcd7d59a6c2a8048cb49714373d7e5f3c Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 11:03:14 +0200 Subject: [PATCH 15/37] More fixes to tests --- CMakeLists.txt | 1 + tests/yup_dsp/yup_HalfbandOversampler.cpp | 9 +-- tests/yup_dsp/yup_ModulatedOscillator.cpp | 2 +- tests/yup_dsp/yup_SincOversampler.cpp | 10 ++-- tests/yup_dsp/yup_SyncOscillator.cpp | 67 +++++------------------ 5 files changed, 25 insertions(+), 64 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a5c886131..848a05b3a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ _yup_get_project_version_string (${CMAKE_CURRENT_LIST_DIR}/modules yup_version) _yup_message (STATUS "Building project version ${yup_version}") set (CMAKE_CXX_SCAN_FOR_MODULES 0) +set (CMAKE_EXPORT_COMPILE_COMMANDS ON) if (YUP_PLATFORM_MAC) set (CMAKE_OSX_DEPLOYMENT_TARGET 11.0) diff --git a/tests/yup_dsp/yup_HalfbandOversampler.cpp b/tests/yup_dsp/yup_HalfbandOversampler.cpp index 7b1207ed1..02f418860 100644 --- a/tests/yup_dsp/yup_HalfbandOversampler.cpp +++ b/tests/yup_dsp/yup_HalfbandOversampler.cpp @@ -298,11 +298,12 @@ TEST_F (HalfbandOversamplerTest, AliasesUseTheHalfbandDesign) HalfbandOversampler16xDouble i; HalfbandOversampler32xDouble j; - for (auto* os : { &a, &b, &c, &d, &e }) - os->prepare (44100.0, 1, 64); + const auto prepareAll = [] (auto&... oversamplers) + { + (oversamplers.prepare (44100.0, 1, 64), ...); + }; - for (auto* os : { &f, &g, &h, &i, &j }) - os->prepare (44100.0, 1, 64); + prepareAll (a, b, c, d, e, f, g, h, i, j); EXPECT_EQ (HalfbandFilterType::linearPhaseFIR, a.getDesign().filterType); EXPECT_GT (e.getLatencyInSamples(), 0); diff --git a/tests/yup_dsp/yup_ModulatedOscillator.cpp b/tests/yup_dsp/yup_ModulatedOscillator.cpp index 830506234..c9816f417 100644 --- a/tests/yup_dsp/yup_ModulatedOscillator.cpp +++ b/tests/yup_dsp/yup_ModulatedOscillator.cpp @@ -343,7 +343,7 @@ TEST_F (ModulatedOscillatorTests, ConservativeBandwidthMatchesAnIndependentlyFil return p; })); ASSERT_TRUE (reference.processModulatedBlock (expected.data(), count, controls)); - for (int i = 2 * oscillator.getLatencyInSamples(); i < count; ++i) + for (int i = 2 * bounded.getLatencyInSamples(); i < count; ++i) EXPECT_NEAR (expected[static_cast (i)], actual[static_cast (i)], 1.0e-5); } diff --git a/tests/yup_dsp/yup_SincOversampler.cpp b/tests/yup_dsp/yup_SincOversampler.cpp index 1e4e56f01..048cb773a 100644 --- a/tests/yup_dsp/yup_SincOversampler.cpp +++ b/tests/yup_dsp/yup_SincOversampler.cpp @@ -699,10 +699,10 @@ TEST_F (SincOversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) TEST_F (SincOversamplerAccuracyTest, UpsampledImageIsRejected) { // 0.25 fs tone: image at 0.75 fs sits deep in the interpolator's stopband. - EXPECT_LT (upsampledWorstImageDb<4, 16> (256), -80.0); + EXPECT_LT ((upsampledWorstImageDb<4, 16> (256)), -80.0); // 0.4 fs tone: image at 0.6 fs sits at the edge of the transition band. - EXPECT_LT (upsampledWorstImageDb<4, 16> (410), -70.0); + EXPECT_LT ((upsampledWorstImageDb<4, 16> (410)), -70.0); } TEST_F (SincOversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadius16) @@ -732,13 +732,13 @@ TEST_F (SincOversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithR TEST_F (SincOversamplerAccuracyTest, RoundTripPassbandIsFlat) { - EXPECT_LT (roundTripAccuracy<4, 16> (1000.0 / sampleRate).maxError, 0.005); - EXPECT_LT (roundTripAccuracy<4, 16> (0.3).maxError, 0.005); + EXPECT_LT ((roundTripAccuracy<4, 16> (1000.0 / sampleRate).maxError), 0.005); + EXPECT_LT ((roundTripAccuracy<4, 16> (0.3).maxError), 0.005); } TEST_F (SincOversamplerAccuracyTest, RoundTripSineSNR) { - EXPECT_GT (roundTripAccuracy<4, 16> (0.1).snrDb, 80.0); + EXPECT_GT ((roundTripAccuracy<4, 16> (0.1).snrDb), 80.0); } } // namespace yup::test diff --git a/tests/yup_dsp/yup_SyncOscillator.cpp b/tests/yup_dsp/yup_SyncOscillator.cpp index ed8472434..cb727f08f 100644 --- a/tests/yup_dsp/yup_SyncOscillator.cpp +++ b/tests/yup_dsp/yup_SyncOscillator.cpp @@ -89,15 +89,22 @@ class SyncOscillatorTests : public ::testing::Test oscillator.update(); } + static void completeRenderCrossfade (SyncOscillator& oscillator) + { + std::vector scratch (512); + oscillator.processBlock (scratch.data(), 512); + oscillator.setPhase (0.0); + } + static constexpr double testSampleRate = 48000.0; static constexpr int testMaxHarmonics = 256; }; -TEST_F (SyncOscillatorTests, AdditiveBackendMatchesManualPipeline) +TEST_F (SyncOscillatorTests, ApproximatesTheManualAdditivePipeline) { SyncOscillator oscillator; configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); - oscillator.setSynthesis (SyncOscillator::Synthesis::additive); + completeRenderCrossfade (oscillator); FourierSeries follower (testMaxHarmonics); follower.setWaveform (Waveform::sawtooth); @@ -119,27 +126,7 @@ TEST_F (SyncOscillatorTests, AdditiveBackendMatchesManualPipeline) reference.setFrequency (440.0); for (int i = 0; i < 512; ++i) - EXPECT_NEAR (reference.processSample(), oscillator.processSample(), 1e-12) << i; -} - -TEST_F (SyncOscillatorTests, WavetableBackendApproximatesTheAdditiveOne) -{ - SyncOscillator oscillator; - configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); - - std::vector wavetableBuffer (512); - oscillator.processBlock (wavetableBuffer.data(), 512); - oscillator.setPhase (0.0); - oscillator.processBlock (wavetableBuffer.data(), 512); - - oscillator.setSynthesis (SyncOscillator::Synthesis::additive); - oscillator.setPhase (0.0); - - std::vector additiveBuffer (512); - oscillator.processBlock (additiveBuffer.data(), 512); - - for (int i = 0; i < 512; ++i) - EXPECT_NEAR (additiveBuffer[static_cast (i)], wavetableBuffer[static_cast (i)], 1e-3) << i; + EXPECT_NEAR (reference.processSample(), oscillator.processSample(), 1e-3) << i; } TEST_F (SyncOscillatorTests, MirroredRunsAtHalfTheLeaderFrequency) @@ -156,8 +143,8 @@ TEST_F (SyncOscillatorTests, MirroredRunsAtHalfTheLeaderFrequency) reference.setSyncMode (SyncMode::mirrored); reference.setFollowerRatio (1.375); reference.setFrequency (440.0); - reference.setSynthesis (SyncOscillator::Synthesis::additive); reference.update(); + completeRenderCrossfade (reference); // The mirrored output is periodic with twice the leader period, so it equals an // additive oscillator running the synced series at half the leader pitch. @@ -181,7 +168,7 @@ TEST_F (SyncOscillatorTests, MirroredRunsAtHalfTheLeaderFrequency) additive.setFrequency (220.0); for (int i = 0; i < 512; ++i) - EXPECT_NEAR (additive.processSample(), reference.processSample(), 1e-12) << i; + EXPECT_NEAR (additive.processSample(), reference.processSample(), 1e-3) << i; } TEST_F (SyncOscillatorTests, UpdateWithNothingDirtyLeavesTheOutputUnchanged) @@ -200,27 +187,6 @@ TEST_F (SyncOscillatorTests, UpdateWithNothingDirtyLeavesTheOutputUnchanged) EXPECT_EQ (updatedOnce.processSample(), updatedTwice.processSample()) << i; } -TEST_F (SyncOscillatorTests, SwitchingSynthesisKeepsThePhase) -{ - SyncOscillator oscillator; - configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); - - for (int i = 0; i < 100; ++i) - oscillator.processSample(); - - const auto phase = oscillator.getPhase(); - - oscillator.setSynthesis (SyncOscillator::Synthesis::additive); - EXPECT_NEAR (phase, oscillator.getPhase(), 1e-12); - - oscillator.processSample(); - - const auto additivePhase = oscillator.getPhase(); - - oscillator.setSynthesis (SyncOscillator::Synthesis::wavetable); - EXPECT_NEAR (additivePhase, oscillator.getPhase(), 1e-12); -} - TEST_F (SyncOscillatorTests, FollowerFrequencySetsTheRatio) { SyncOscillator oscillator; @@ -239,7 +205,6 @@ TEST_F (SyncOscillatorTests, FollowerFrequencySetsTheRatio) TEST_F (SyncOscillatorTests, LowerPitchRestoresPreviouslyOmittedHarmonics) { SyncOscillator oscillator; - oscillator.setSynthesis (SyncOscillator::Synthesis::additive); oscillator.prepare (testSampleRate, 64); oscillator.setFollowerSeries (FourierSeries::create (Waveform::sine, 1)); oscillator.setSyncMode (SyncMode::hard); @@ -300,11 +265,6 @@ TEST_F (SyncOscillatorTests, HardSyncedSawtoothIsAliasFree) oscillator.processBlock (buffer.data(), fftSize); const auto wavetableEnergy = offHarmonicEnergyDb (buffer, harmonicBin, guardBins); - oscillator.setSynthesis (SyncOscillator::Synthesis::additive); - oscillator.setPhase (0.0); - oscillator.processBlock (buffer.data(), fftSize); - const auto additiveEnergy = offHarmonicEnergyDb (buffer, harmonicBin, guardBins); - // A naive phase-reset sawtooth at the follower pitch is not alias free, and the // same measurement catches it. std::vector naive (fftSize); @@ -320,7 +280,6 @@ TEST_F (SyncOscillatorTests, HardSyncedSawtoothIsAliasFree) const auto naiveEnergy = offHarmonicEnergyDb (naive, harmonicBin, guardBins); - EXPECT_LT (additiveEnergy, -80.0) << "additive backend"; - EXPECT_LT (wavetableEnergy, -60.0) << "wavetable backend"; + EXPECT_LT (wavetableEnergy, -60.0) << "wavetable"; EXPECT_GT (naiveEnergy, -40.0) << "naive reference"; } From fd9af6ef778d40e97513e32a60159bc4786e3c0a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 11:45:36 +0200 Subject: [PATCH 16/37] Avoid infer folder from being tracked --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e2e66fb04..31c430ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ build/* cmake-build* out/* +infer-out/* # Ides/Agents __pycache__/ From 1ce74517225fcf86a7862527633b9a22a252b5df Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 11:55:48 +0200 Subject: [PATCH 17/37] More fixes --- .../runtime/yup_YdspAudioGraph.cpp | 16 +- .../runtime/yup_YdspGraphInternal.h | 48 +- tests/yup_dsp/module.cpp | 1 - tests/yup_dsp/yup_Oversampler.cpp | 428 ------------------ 4 files changed, 30 insertions(+), 463 deletions(-) delete mode 100644 tests/yup_dsp/yup_Oversampler.cpp diff --git a/modules/yup_dsp_jit/runtime/yup_YdspAudioGraph.cpp b/modules/yup_dsp_jit/runtime/yup_YdspAudioGraph.cpp index 578c3700a..ac0da1b5d 100644 --- a/modules/yup_dsp_jit/runtime/yup_YdspAudioGraph.cpp +++ b/modules/yup_dsp_jit/runtime/yup_YdspAudioGraph.cpp @@ -84,7 +84,6 @@ extern "C" void EMSCRIPTEN_KEEPALIVE ydspCommitOutputEventWasm (YdspOutputEventQ // YdspAudioGraph YdspAudioGraph::YdspAudioGraph() = default; - YdspAudioGraph::~YdspAudioGraph() = default; YdspAudioGraph::YdspAudioGraph (YdspAudioGraph&&) noexcept = default; @@ -314,17 +313,17 @@ Result YdspAudioGraph::prepare (double sampleRate, int maxBlockSize, int maxEven switch (factor) { case 2: - node.oversampler2x = std::make_unique>(); + node.oversampler2x = std::make_unique>(); node.oversampler2x->prepare (sampleRate, channels, maxBlockSize); break; case 4: - node.oversampler4x = std::make_unique>(); + node.oversampler4x = std::make_unique>(); node.oversampler4x->prepare (sampleRate, channels, maxBlockSize); break; case 8: - node.oversampler8x = std::make_unique>(); + node.oversampler8x = std::make_unique>(); node.oversampler8x->prepare (sampleRate, channels, maxBlockSize); break; @@ -603,7 +602,7 @@ void YdspAudioGraph::reset() //============================================================================== -void YdspAudioGraph::setMpeZoneLayout (const yup::MPEZoneLayout& layout) +void YdspAudioGraph::setMpeZoneLayout (const MPEZoneLayout& layout) { if (pimpl == nullptr) return; @@ -613,7 +612,7 @@ void YdspAudioGraph::setMpeZoneLayout (const yup::MPEZoneLayout& layout) for (auto& instrument : pimpl->mpeInstruments) instrument->setZoneLayout (layout); - pimpl->setExpressionTrackingMode (yup::MPEInstrument::lastNotePlayedOnChannel); + pimpl->setExpressionTrackingMode (MPEInstrument::lastNotePlayedOnChannel); } void YdspAudioGraph::setLegacyMidiMode (int pitchbendRangeSemitones) @@ -629,7 +628,7 @@ void YdspAudioGraph::setLegacyMidiMode (int pitchbendRangeSemitones) instrument->setLegacyModePitchbendRange (pitchbendRangeSemitones); } - pimpl->setExpressionTrackingMode (yup::MPEInstrument::allNotesOnChannel); + pimpl->setExpressionTrackingMode (MPEInstrument::allNotesOnChannel); } //============================================================================== @@ -780,7 +779,7 @@ YdspProcessResult YdspAudioGraph::process (const YdspProcessRequest& request) const auto eventInputIndex = static_cast (s); auto& instrument = *pimpl->mpeInstruments[static_cast (eventInputIndex)]; - for (const yup::MidiMessageMetadata metadata : *midiIn) + for (const MidiMessageMetadata metadata : *midiIn) { const auto message = metadata.getMessage(); @@ -803,6 +802,7 @@ YdspProcessResult YdspAudioGraph::process (const YdspProcessRequest& request) const auto right = node.pendingAutomation[b].sampleOffset; return left != right ? left < right : a < b; }); + std::sort (node.pendingAllSoundOffOffsets.begin(), node.pendingAllSoundOffOffsets.end()); } diff --git a/modules/yup_dsp_jit/runtime/yup_YdspGraphInternal.h b/modules/yup_dsp_jit/runtime/yup_YdspGraphInternal.h index 657055f9f..3301aec5d 100644 --- a/modules/yup_dsp_jit/runtime/yup_YdspGraphInternal.h +++ b/modules/yup_dsp_jit/runtime/yup_YdspGraphInternal.h @@ -360,18 +360,18 @@ struct YdspAudioGraph::Pimpl int rateMultiplier = 1; int rateDivider = 1; - std::unique_ptr> oversampler2x; - std::unique_ptr> oversampler4x; - std::unique_ptr> oversampler8x; + std::unique_ptr> oversampler2x; + std::unique_ptr> oversampler4x; + std::unique_ptr> oversampler8x; std::vector oversampleInputBuf; std::vector oversampleInPtrs; std::vector oversampleOutPtrs; std::vector oversampleZeroBuf; // inputless generators only std::vector oversampleZeroInPtrs; - std::unique_ptr> decimator2x, interpolator2x; - std::unique_ptr> decimator4x, interpolator4x; - std::unique_ptr> decimator8x, interpolator8x; + std::unique_ptr> decimator2x, interpolator2x; + std::unique_ptr> decimator4x, interpolator4x; + std::unique_ptr> decimator8x, interpolator8x; std::vector decimInputBuf; std::vector decimInPtrs; std::vector decimOutputBuf; @@ -432,7 +432,7 @@ struct YdspAudioGraph::Pimpl //========================================================================== // MIDI and MPE ingestion. - struct EventIngest : public yup::MPEInstrument::Listener + struct EventIngest : public MPEInstrument::Listener { EventIngest (Pimpl& owner, int eventInputIndex) noexcept : owner (owner) @@ -440,11 +440,11 @@ struct YdspAudioGraph::Pimpl { } - void noteAdded (yup::MPENote note) override; - void noteReleased (yup::MPENote note) override; - void notePitchbendChanged (yup::MPENote note) override; - void notePressureChanged (yup::MPENote note) override; - void noteTimbreChanged (yup::MPENote note) override; + void noteAdded (MPENote note) override; + void noteReleased (MPENote note) override; + void notePitchbendChanged (MPENote note) override; + void notePressureChanged (MPENote note) override; + void noteTimbreChanged (MPENote note) override; Pimpl& owner; int eventInputIndex = 0; // the graph event input this instrument feeds @@ -452,20 +452,20 @@ struct YdspAudioGraph::Pimpl void ensureEventInputs(); - void setExpressionTrackingMode (yup::MPEInstrument::TrackingMode mode); + void setExpressionTrackingMode (MPEInstrument::TrackingMode mode); /** The number of simultaneously playing notes tracked without allocating. */ static constexpr int maxTrackedNotes = 128; std::vector eventInputNames; std::vector eventIngests; - std::vector> mpeInstruments; + std::vector> mpeInstruments; // Outer index = graph input event port (matches eventInputNames). Built // from explicit `graphInput -> node.inputEvent;` connections only - a // graph input event has no implicit broadcast to same-named node inputs. std::vector> graphInputRouting; - yup::MPEInstrument::TrackingMode expressionTrackingMode = yup::MPEInstrument::allNotesOnChannel; + MPEInstrument::TrackingMode expressionTrackingMode = MPEInstrument::allNotesOnChannel; struct GroupEventTarget { @@ -628,7 +628,7 @@ struct YdspAudioGraph::Pimpl //========================================================================== // Event ingestion and routing. - void ingestChannelMessage (const yup::MidiMessage& message, int eventInputIndex); + void ingestChannelMessage (const MidiMessage& message, int eventInputIndex); void scheduleAllSoundOff (int eventInputIndex); void routeEvent (YdspEventShape shape, uint16_t noteId, const YdspEventPayload& payload, int sampleOffset, int eventInputIndex); void dispatchEventToNode (Node& node, YdspEventShape shape, uint16_t noteId, const YdspEventPayload& payload, int sampleOffset, int eventInputIndex); @@ -639,8 +639,8 @@ struct YdspAudioGraph::Pimpl // destinations (another node, the graph boundary, or next block's carry // queue), once per node per block. - void drainOutputEvents (Node& node, int srcNodeIndex, int blockSize, yup::MidiBuffer* midiOut); - void deliverResolvedEvent (int dstNode, int dstEventInputIndex, int srcNodeIndex, int64_t shapeTag, const YdspEventContext& fields, int sampleOffset, int blockSize, yup::MidiBuffer* midiOut); + void drainOutputEvents (Node& node, int srcNodeIndex, int blockSize, MidiBuffer* midiOut); + void deliverResolvedEvent (int dstNode, int dstEventInputIndex, int srcNodeIndex, int64_t shapeTag, const YdspEventContext& fields, int sampleOffset, int blockSize, MidiBuffer* midiOut); //========================================================================== // Voice allocation (resolve note events into per-voice pending calls). @@ -693,11 +693,7 @@ struct YdspAudioGraph::Pimpl return; } - // The analyzer rejects summing fan-in on any non-float32 stream, so a - // non-float type here is a programming error, not a stream the host - // passed: refuse rather than reinterpreting int bytes as float32. jassert (type == YdspValueType::float32Type); - if (type != YdspValueType::float32Type) return; @@ -777,7 +773,7 @@ struct YdspAudioGraph::Pimpl Span inputs, Span outputs, int blockSize, - yup::MidiBuffer* midiOut); + MidiBuffer* midiOut); //========================================================================== // Sample-accurate sub-block execution. @@ -833,7 +829,7 @@ struct YdspAudioGraph::Pimpl // Oversampled node execution template - void runOversampledKernel (yup::Oversampler& oversampler, Node& node, YdspKernelContext& ctx, int blockSize) + void runOversampledKernel (SincOversampler& oversampler, Node& node, YdspKernelContext& ctx, int blockSize) { const auto osBlockSize = blockSize * Factor; @@ -882,8 +878,8 @@ struct YdspAudioGraph::Pimpl // Undersampled node execution template - void runUndersampledKernel (yup::Oversampler& decimator, - yup::Oversampler& interpolator, + void runUndersampledKernel (SincOversampler& decimator, + SincOversampler& interpolator, Node& node, YdspKernelContext& ctx, int blockSize) diff --git a/tests/yup_dsp/module.cpp b/tests/yup_dsp/module.cpp index b764644f7..e204ecebe 100644 --- a/tests/yup_dsp/module.cpp +++ b/tests/yup_dsp/module.cpp @@ -42,7 +42,6 @@ #include "yup_ModulatedOscillator.cpp" #include "yup_NoiseGenerators.cpp" #include "yup_OnsetDetector.cpp" -#include "yup_Oversampler.cpp" #include "yup_PartitionedConvolver.cpp" #include "yup_PrismSpectrum.cpp" #include "yup_RbjFilter.cpp" diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_Oversampler.cpp deleted file mode 100644 index 84490ab35..000000000 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ /dev/null @@ -1,428 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include - -#include - -#include -#include -#include - -namespace yup::test -{ - -//============================================================================== -class OversamplerTest : public ::testing::Test -{ -protected: - static constexpr double sampleRate = 44100.0; - static constexpr int maxChannels = 2; - static constexpr int blockSize = 256; - - void SetUp() override - { - os2x.prepare (sampleRate, maxChannels, blockSize); - os4x.prepare (sampleRate, maxChannels, blockSize); - } - - float calculateRMS (const float* data, int numSamples) const - { - float sum = 0.0f; - for (int i = 0; i < numSamples; ++i) - sum += data[i] * data[i]; - return std::sqrt (sum / static_cast (numSamples)); - } - - void fillSine (std::vector& buf, float frequency, float amplitude = 1.0f) const - { - for (std::size_t i = 0; i < buf.size(); ++i) - { - buf[i] = amplitude * std::sin (MathConstants::twoPi * frequency * static_cast (i) / static_cast (sampleRate)); - } - } - - void fillDC (std::vector& buf, float value) const - { - std::fill (buf.begin(), buf.end(), value); - } - - Oversampler os2x; - Oversampler os4x; -}; - -//============================================================================== -TEST_F (OversamplerTest, DefaultConstructionDoesNotCrash) -{ - Oversampler os; - EXPECT_EQ (os.getOversampledNumSamples(), 0); - EXPECT_EQ (os.getLatencyInSamples(), 16); - EXPECT_EQ (os.getOversampledChannelData (0), nullptr); -} - -TEST_F (OversamplerTest, PrepareAllocatesOversampledBuffer) -{ - EXPECT_EQ (os2x.getOversampledNumSamples(), 0); - - std::vector ch0 (blockSize, 0.0f); - const float* inputPtrs[] = { ch0.data() }; - os2x.upsample (inputPtrs, 1, blockSize); - - EXPECT_EQ (os2x.getOversampledNumSamples(), blockSize * 2); - EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); -} - -TEST_F (OversamplerTest, LatencyReturnsCorrectValue) -{ - EXPECT_EQ (os2x.getLatencyInSamples(), 16); // 2 * SincRadius = 2 * 8 - EXPECT_EQ (os4x.getLatencyInSamples(), 16); -} - -TEST_F (OversamplerTest, ResetClearsOversampledSize) -{ - std::vector ch0 (blockSize, 1.0f); - const float* inputPtrs[] = { ch0.data() }; - os2x.upsample (inputPtrs, 1, blockSize); - - ASSERT_EQ (os2x.getOversampledNumSamples(), blockSize * 2); - - os2x.reset(); - EXPECT_EQ (os2x.getOversampledNumSamples(), 0); -} - -TEST_F (OversamplerTest, UpsampleDCSignalHasCorrectMagnitude) -{ - constexpr float dcValue = 0.5f; - std::vector ch0 (blockSize, dcValue); - const float* inputPtrs[] = { ch0.data() }; - - // Warm up the filter (several blocks to flush transient) - for (int b = 0; b < 5; ++b) - os2x.upsample (inputPtrs, 1, blockSize); - - const float* outData = os2x.getOversampledChannelData (0); - ASSERT_NE (outData, nullptr); - - // Second half of the block should be at steady state - const int halfSize = blockSize; // = blockSize * 2 / 2 - float rms = calculateRMS (outData + halfSize, halfSize); - EXPECT_NEAR (rms, dcValue, 0.05f); -} - -TEST_F (OversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) -{ - constexpr int shortBlockSize = 64; - std::vector ch0 (shortBlockSize, 0.0f); - const float* inputPtrs[] = { ch0.data() }; - os2x.upsample (inputPtrs, 1, shortBlockSize); - - int callbackChannels = 0; - int callbackSamples = 0; - os2x.processOversampledBlock ([&] (auto& buf) - { - callbackChannels = buf.getNumChannels(); - callbackSamples = buf.getNumSamples(); - }); - - EXPECT_EQ (callbackChannels, 1); - EXPECT_EQ (callbackSamples, shortBlockSize * 2); -} - -TEST_F (OversamplerTest, ProcessOversampledBlockReceivesEmptyBufferWithoutPendingBlock) -{ - int callbackChannels = -1; - int callbackSamples = -1; - - os2x.processOversampledBlock ([&] (auto& buf) - { - callbackChannels = buf.getNumChannels(); - callbackSamples = buf.getNumSamples(); - }); - - EXPECT_EQ (callbackChannels, 0); - EXPECT_EQ (callbackSamples, 0); -} - -TEST_F (OversamplerTest, DownsampleConsumesPendingOversampledBlock) -{ - std::vector ch0 (blockSize, 0.0f); - std::vector output (blockSize, 0.0f); - const float* inputPtrs[] = { ch0.data() }; - float* outputPtrs[] = { output.data() }; - - os2x.upsample (inputPtrs, 1, blockSize); - ASSERT_EQ (os2x.getOversampledNumSamples(), blockSize * 2); - - os2x.downsample (outputPtrs, 1, blockSize); - - EXPECT_EQ (os2x.getOversampledNumSamples(), 0); - EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); -} - -TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) -{ - constexpr float dcValue = 0.5f; - std::vector input (blockSize, dcValue); - std::vector output (blockSize, 0.0f); - const float* inputPtrs[] = { input.data() }; - float* outputPtrs[] = { output.data() }; - - for (int b = 0; b < 10; ++b) - { - os2x.upsample (inputPtrs, 1, blockSize); - os2x.downsample (outputPtrs, 1, blockSize); - } - - EXPECT_NEAR (calculateRMS (output.data(), blockSize), dcValue, 0.02f); -} - -TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesLowFrequencySine) -{ - constexpr float frequency = 440.0f; // A4 - well below Nyquist/4 - std::vector input (blockSize); - fillSine (input, frequency); - - std::vector output (blockSize, 0.0f); - const float* inPtrs[] = { input.data() }; - float* outPtrs[] = { output.data() }; - - // Warm up to flush latency - for (int b = 0; b < 10; ++b) - { - os2x.upsample (inPtrs, 1, blockSize); - os2x.downsample (outPtrs, 1, blockSize); - } - - // Compare RMS energy - should be preserved - float rmsIn = calculateRMS (input.data(), blockSize); - float rmsOut = calculateRMS (output.data(), blockSize); - EXPECT_NEAR (rmsOut, rmsIn, rmsIn * 0.1f); // within 10% -} - -TEST_F (OversamplerTest, DecimationFiltersOversampledDomainHighFrequency) -{ - // Upsample silence to get a clean oversampled buffer - std::vector silence (blockSize, 0.0f); - const float* silPtrs[] = { silence.data() }; - - for (int b = 0; b < 5; ++b) - os2x.upsample (silPtrs, 1, blockSize); - - const double oversampledRate = sampleRate * 2.0; - const double highFreq = sampleRate * 0.6; // above original Nyquist (22050 Hz) - float* oversampledData = os2x.getOversampledChannelData (0); - const int oversampledLen = os2x.getOversampledNumSamples(); - constexpr float injectedAmplitude = 0.5f; - - for (int i = 0; i < oversampledLen; ++i) - oversampledData[i] += injectedAmplitude * static_cast (std::sin (MathConstants::twoPi * highFreq * static_cast (i) / oversampledRate)); - - std::vector output (blockSize, 0.0f); - float* outPtrs[] = { output.data() }; - os2x.downsample (outPtrs, 1, blockSize); - - float rmsOut = calculateRMS (output.data(), blockSize); - - // The anti-aliasing filter should substantially reduce the injected tone - EXPECT_LT (rmsOut, injectedAmplitude * 0.5f); -} - -TEST_F (OversamplerTest, OversampledChannelDataNotNullAfterUpsample) -{ - std::vector ch0 (blockSize, 0.0f); - const float* inputPtrs[] = { ch0.data() }; - os2x.upsample (inputPtrs, 1, blockSize); - - EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); - EXPECT_EQ (os2x.getOversampledChannelData (1), nullptr); // channel 1 not prepared - EXPECT_EQ (os2x.getOversampledChannelData (-1), nullptr); // invalid index -} - -TEST_F (OversamplerTest, FourXOversamplerHasCorrectOutputSize) -{ - std::vector ch0 (blockSize, 0.0f); - const float* inputPtrs[] = { ch0.data() }; - os4x.upsample (inputPtrs, 1, blockSize); - - EXPECT_EQ (os4x.getOversampledNumSamples(), blockSize * 4); -} - -TEST_F (OversamplerTest, OversampledChannelDataIsAvailableAfterPrepare) -{ - EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); - EXPECT_NE (os2x.getOversampledChannelData (1), nullptr); - - EXPECT_EQ (os2x.getOversampledChannelData (maxChannels), nullptr); - EXPECT_EQ (os2x.getOversampledChannelData (-1), nullptr); - - EXPECT_EQ (os2x.getOversampledNumSamples(), 0); -} - -TEST_F (OversamplerTest, DownsampleWithoutAPrecedingUpsampleFiltersHighFrequencies) -{ - Oversampler decimator; - decimator.prepare (sampleRate, 1, blockSize); - - const double highRate = sampleRate * 2.0; - const double aboveNyquist = sampleRate * 0.6; // above the decimated Nyquist - constexpr float amplitude = 0.5f; - - std::vector decimated (blockSize, 0.0f); - float* outPtrs[] = { decimated.data() }; - - // A few blocks so the filter history settles past its start-up transient. - for (int block = 0; block < 5; ++block) - { - auto* buffer = decimator.getOversampledChannelData (0); - ASSERT_NE (buffer, nullptr); - - for (int i = 0; i < blockSize * 2; ++i) - { - const auto n = static_cast (block * blockSize * 2 + i); - buffer[i] = amplitude * static_cast (std::sin (MathConstants::twoPi * aboveNyquist * n / highRate)); - } - - decimator.downsample (outPtrs, 1, blockSize); - } - - EXPECT_LT (calculateRMS (decimated.data(), blockSize), amplitude * 0.5f); -} - -TEST_F (OversamplerTest, DecimateThenInterpolateRoundTripPreservesLowFrequency) -{ - constexpr int factor = 2; - constexpr int baseBlock = blockSize / factor; - - Oversampler decimator; - Oversampler interpolator; - - decimator.prepare (sampleRate / factor, 1, baseBlock); - interpolator.prepare (sampleRate / factor, 1, baseBlock); - - constexpr int blockCount = 6; - constexpr float frequency = 300.0f; // well inside the decimated passband - - std::vector input (static_cast (blockSize * blockCount)); - fillSine (input, frequency); - - std::vector decimated (baseBlock, 0.0f); - float* decimatedPtrs[] = { decimated.data() }; - const float* interpolatedIn[] = { decimated.data() }; - - std::vector output (blockSize, 0.0f); - - for (int block = 0; block < blockCount; ++block) - { - auto* highRate = decimator.getOversampledChannelData (0); - ASSERT_NE (highRate, nullptr); - - std::copy (input.data() + block * blockSize, input.data() + (block + 1) * blockSize, highRate); - - decimator.downsample (decimatedPtrs, 1, baseBlock); - interpolator.upsample (interpolatedIn, 1, baseBlock); - - const auto* interpolated = interpolator.getOversampledChannelData (0); - ASSERT_NE (interpolated, nullptr); - - std::copy (interpolated, interpolated + blockSize, output.data()); - } - - const auto latency = decimator.getLatencyInSamples() * factor; - const auto* expected = input.data() + (blockCount - 1) * blockSize - latency; - - double error = 0.0; - double reference = 0.0; - - for (int i = 0; i < blockSize; ++i) - { - const auto diff = static_cast (output[static_cast (i)]) - static_cast (expected[i]); - error += diff * diff; - reference += static_cast (expected[i]) * static_cast (expected[i]); - } - - ASSERT_GT (reference, 1.0); // would pass vacuously on silence - EXPECT_LT (std::sqrt (error / reference), 0.1); -} - -TEST_F (OversamplerTest, DecimateFirstIsContinuousAcrossBlockBoundaries) -{ - constexpr int factor = 2; - constexpr int baseBlock = blockSize / factor; - constexpr int blockCount = 6; - constexpr float frequency = 500.0f; - - Oversampler decimator; - decimator.prepare (sampleRate / factor, 1, baseBlock); - - std::vector input (static_cast (blockSize * blockCount)); - fillSine (input, frequency); - - std::vector decimated (static_cast (baseBlock * blockCount), 0.0f); - - for (int block = 0; block < blockCount; ++block) - { - auto* highRate = decimator.getOversampledChannelData (0); - ASSERT_NE (highRate, nullptr); - - std::copy (input.data() + block * blockSize, input.data() + (block + 1) * blockSize, highRate); - - float* outPtrs[] = { decimated.data() + block * baseBlock }; - decimator.downsample (outPtrs, 1, baseBlock); - } - - // Skip the first two blocks: the filter is still ramping up from silence. - float largestInterior = 0.0f; - float largestAtBoundary = 0.0f; - - for (int i = 2 * baseBlock + 1; i < baseBlock * blockCount; ++i) - { - const auto step = std::fabs (decimated[static_cast (i)] - decimated[static_cast (i - 1)]); - - if (i % baseBlock == 0) - largestAtBoundary = std::max (largestAtBoundary, step); - else - largestInterior = std::max (largestInterior, step); - } - - ASSERT_GT (largestInterior, 0.0f); // would pass vacuously on silence - EXPECT_LT (largestAtBoundary, largestInterior * 1.5f) - << "boundary step " << largestAtBoundary << " against interior " << largestInterior; -} - -//============================================================================== -TEST (OversamplerTypeAliasTest, TypeAliasesCompile) -{ - Oversampler2xFloat a; - Oversampler4xFloat b; - Oversampler8xFloat c; - Oversampler2xDouble d; - Oversampler4xDouble e; - - a.prepare (44100.0, 1, 64); - b.prepare (44100.0, 1, 64); - c.prepare (44100.0, 1, 64); - d.prepare (44100.0, 1, 64); - e.prepare (44100.0, 1, 64); - - SUCCEED(); -} - -} // namespace yup::test From c28aae44135a5b12c398f53b742da06dd891fb68 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 12:08:16 +0200 Subject: [PATCH 18/37] Fix oversampling and warnings --- .../yup_dsp/resampling/yup_SincOversampler.h | 27 ++++---- tests/yup_dsp_jit/yup_YdspLatencyTests.cpp | 69 +++++++++++++++++++ .../sleef_library/sleef_library_common.c | 14 ++++ thirdparty/sleef_library/sleef_library_dp.c | 14 ++++ .../sleef_library/sleef_library_simddp.c | 14 ++++ .../sleef_library/sleef_library_simdsp.c | 14 ++++ thirdparty/sleef_library/sleef_library_sp.c | 14 ++++ .../sleef_library/sleef_library_tables.c | 14 ++++ 8 files changed, 165 insertions(+), 15 deletions(-) diff --git a/modules/yup_dsp/resampling/yup_SincOversampler.h b/modules/yup_dsp/resampling/yup_SincOversampler.h index e232fc76e..2b19bc60a 100644 --- a/modules/yup_dsp/resampling/yup_SincOversampler.h +++ b/modules/yup_dsp/resampling/yup_SincOversampler.h @@ -216,10 +216,9 @@ class SincOversampler filters keep separate history, so one instance serves either direction. @param output Array of write pointers, one per channel. - @param numChannels Number of channels to write (must match the numChannels - passed to the preceding upsample() or beginGeneration() call). - @param numSamples Number of output samples per channel (must match the numSamples - passed to the preceding upsample() or beginGeneration() call). + @param numChannels Number of channels to write. + @param numSamples Number of output samples per channel; the oversampled + buffer is read as numSamples * OversampleFactor samples. */ void downsample (SampleType* const* output, int numChannels, int numSamples) noexcept { @@ -231,19 +230,19 @@ class SincOversampler jassert (numChannels <= xDecim.getNumChannels()); jassert (numChannels <= oversampledBuffer.getNumChannels()); jassert (interpolatedSize <= oversampledBuffer.getNumSamples()); - jassert (interpolatedSize + SincRadius * OversampleFactor <= xDecim.getNumSamples()); + jassert (interpolatedSize + decimationHistory <= xDecim.getNumSamples()); for (int ch = 0; ch < numChannels; ++ch) { auto* history = xDecim.getWritePointer (ch); - FloatVectorOperations::copy (history + decimationHistory, oversampledBuffer.getReadPointer (ch), currentOversampledSize); + FloatVectorOperations::copy (history + decimationHistory, oversampledBuffer.getReadPointer (ch), interpolatedSize); auto* out = output[ch]; for (int k = 0; k < numSamples; ++k) out[k] = dotProduct (decimationTaps.data(), history + k * OversampleFactor, static_cast (decimationTapCount)); - std::copy (history + currentOversampledSize, history + currentOversampledSize + decimationHistory, history); + std::copy (history + interpolatedSize, history + interpolatedSize + decimationHistory, history); } currentOversampledSize = 0; @@ -285,10 +284,9 @@ class SincOversampler caller hands high-rate audio to downsample() without upsampling first. @param channel Zero-based channel index. - @return Pointer to getOversampledNumSamples() contiguous samples, - or nullptr if the channel index is out of range, prepare() - has not been called, or the channel was not processed by - the most recent upsample() or beginGeneration() call. + @return Pointer to the channel's oversampled samples, or nullptr + if the channel index is out of range or prepare() has not + been called. */ forcedinline SampleType* getOversampledChannelData (int channel) noexcept { @@ -302,10 +300,9 @@ class SincOversampler Returns a read-only pointer to the data for a single oversampled channel. @param channel Zero-based channel index. - @return Pointer to getOversampledNumSamples() contiguous samples, - or nullptr if the channel index is out of range or the - channel was not processed by the most recent upsample() - or beginGeneration() call. + @return Pointer to the channel's oversampled samples, or nullptr + if the channel index is out of range or prepare() has not + been called. */ const forcedinline SampleType* getOversampledChannelData (int channel) const noexcept { diff --git a/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp b/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp index 0fc543f5c..a69425a62 100644 --- a/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp +++ b/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp @@ -606,6 +606,75 @@ TEST (YdspLatencyTests, CompensatesAnUndersampledBranchAgainstADryOne) EXPECT_LT (residual, reference * 0.15) << "residual RMS " << residual << " against input RMS " << reference; } +TEST (YdspLatencyTests, UndersampledStereoNodeKeepsEachChannelOnItsOwnSignal) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Pair { input stream a; input stream b; output stream c; output stream d; process { c = a; d = b; } } + graph G { + input stream l; + input stream r; + output stream yl; + output stream yr; + node slow = Pair / 4; + connection { l -> slow.a; r -> slow.b; slow.c -> yl; slow.d -> yr; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto expectedLatency = latencyOversamplerDelay * 4 + 3; + ASSERT_EQ (expectedLatency, graph.getLatencySamples()); + + constexpr int blockSize = 250; // not a multiple of 4, so the decimated block size varies + constexpr int blockCount = 8; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const std::vector> inputs { + latencySine (blockSize * blockCount, 300.0, sampleRate), + latencySine (blockSize * blockCount, 700.0, sampleRate) + }; + + std::vector> outputs (2, std::vector (static_cast (blockSize), 0.0f)); + + for (int block = 0; block < blockCount; ++block) + { + const auto offset = static_cast (block * blockSize); + + std::vector inputBuffers; + for (const auto& channel : inputs) + inputBuffers.emplace_back (Span (channel.data() + offset, static_cast (blockSize))); + + std::vector outputBuffers; + for (auto& channel : outputs) + outputBuffers.emplace_back (Span (channel.data(), channel.size())); + + graph.process (YdspProcessRequest { inputBuffers, outputBuffers, blockSize }); + } + + for (size_t ch = 0; ch < inputs.size(); ++ch) + { + const auto* expected = inputs[ch].data() + blockSize * (blockCount - 1) - expectedLatency; + + double error = 0.0; + double reference = 0.0; + + for (int i = 0; i < blockSize; ++i) + { + const auto diff = static_cast (outputs[ch][static_cast (i)]) - static_cast (expected[i]); + error += diff * diff; + reference += static_cast (expected[i]) * static_cast (expected[i]); + } + + ASSERT_GT (reference, 1.0) << "channel " << ch; + EXPECT_LT (std::sqrt (error / reference), 0.1) << "channel " << ch << " did not reproduce its own input"; + } +} + TEST (YdspLatencyTests, ARateChangedKernelReportsItsOwnSampleRate) { YdspCompiler compiler; diff --git a/thirdparty/sleef_library/sleef_library_common.c b/thirdparty/sleef_library/sleef_library_common.c index a6f2c3198..735a5fd80 100644 --- a/thirdparty/sleef_library/sleef_library_common.c +++ b/thirdparty/sleef_library/sleef_library_common.c @@ -23,4 +23,18 @@ // Sleef_currentTimeMicros, Sleef_getCpuIdString), own translation unit as in // SLEEF's build, which compiles upstream/src/common/common.c into libsleef. +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/common/common.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif diff --git a/thirdparty/sleef_library/sleef_library_dp.c b/thirdparty/sleef_library/sleef_library_dp.c index b4872680e..1640adf10 100644 --- a/thirdparty/sleef_library/sleef_library_dp.c +++ b/thirdparty/sleef_library/sleef_library_dp.c @@ -34,4 +34,18 @@ #define NDEBUG #endif +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/libm/sleefdp.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif diff --git a/thirdparty/sleef_library/sleef_library_simddp.c b/thirdparty/sleef_library/sleef_library_simddp.c index 23d2ac1fd..4afb650d5 100644 --- a/thirdparty/sleef_library/sleef_library_simddp.c +++ b/thirdparty/sleef_library/sleef_library_simddp.c @@ -42,4 +42,18 @@ #error "sleef_library supports x86-64 (SSE2) and AArch64 (ASIMD) only" #endif +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/libm/sleefsimddp.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif diff --git a/thirdparty/sleef_library/sleef_library_simdsp.c b/thirdparty/sleef_library/sleef_library_simdsp.c index 128191ddf..b752b675a 100644 --- a/thirdparty/sleef_library/sleef_library_simdsp.c +++ b/thirdparty/sleef_library/sleef_library_simdsp.c @@ -42,4 +42,18 @@ #error "sleef_library supports x86-64 (SSE2) and AArch64 (ASIMD) only" #endif +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/libm/sleefsimdsp.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif diff --git a/thirdparty/sleef_library/sleef_library_sp.c b/thirdparty/sleef_library/sleef_library_sp.c index 17793e487..6482938cb 100644 --- a/thirdparty/sleef_library/sleef_library_sp.c +++ b/thirdparty/sleef_library/sleef_library_sp.c @@ -33,4 +33,18 @@ #define NDEBUG #endif +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/libm/sleefsp.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif diff --git a/thirdparty/sleef_library/sleef_library_tables.c b/thirdparty/sleef_library/sleef_library_tables.c index 4367aa4bd..0c06df3d8 100644 --- a/thirdparty/sleef_library/sleef_library_tables.c +++ b/thirdparty/sleef_library/sleef_library_tables.c @@ -32,4 +32,18 @@ // Sleef_rempitabsp/Sleef_rempitabdp lookup tables shared by the scalar and SIMD // translation units (each declares them extern). +#if __clang__ + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wattributes" +#elif __GNUC__ + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wattributes" +#endif + #include "upstream/src/libm/rempitab.c" + +#if __clang__ + #pragma clang diagnostic pop +#elif __GNUC__ + #pragma GCC diagnostic pop +#endif From 78b0f3a37c3012e061adf8ec14d24e8ed3306296 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 14:24:28 +0200 Subject: [PATCH 19/37] Fix python deps --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index e8d132ef2..5a4f00e18 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,7 +36,7 @@ environment = { "CMAKE_GENERATOR" = "Visual Studio 17 2022", "CMAKE_CXX_STANDARD [tool.cibuildwheel.linux] before-build = [ """dnf install -y zlib-devel openssl-devel freetype-devel fontconfig-devel freeglut-devel alsa-lib-devel mesa-libGL-devel \ - xorg-x11-proto-devel xorg-x11-proto-devel libcurl-devel libpng-devel libX11-devel libXcursor-devel libXrandr-devel \ + xorg-x11-proto-devel xorg-x11-proto-devel libcurl-devel libdecor-devel libpng-devel libX11-devel libXcursor-devel libXrandr-devel \ libXinerama-devel libXrender-devel libXcomposite-devel libXinerama-devel libXcursor-devel xorg-x11-server-Xvfb \ gtk3-devel webkit2gtk3-devel wget""", ] From c08ce15b37144f8435be03746fdbfffdcdffbb3a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 15:24:17 +0200 Subject: [PATCH 20/37] More fixes --- CHANGELOG.md | 2 + codecov.yml | 1 + examples/graphics/source/examples/Audio.h | 125 +++++++++++++++--- .../source/examples/audio/SynthEngine.h | 9 +- .../examples/audio/SynthModulationPage.h | 2 + .../source/examples/audio/SynthPanels.h | 13 +- .../source/examples/audio/SynthSettings.h | 7 +- 7 files changed, 129 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8582db58..fe5f3063c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased +- Graphics synthesizer example: PRISM logo and larger buttons in the header, LFO / scope columns aligned with filter / envelopes, and pitch bend and mod wheels beside the keyboard. The mod wheel is a new `MOD WHEEL` source in the modulation matrix. + - YDSP backend (emscripten): mint kernel handles from a module-wide counter instead of a JS-realm-local one, so a realm that runs a graph can no longer find another realm's kernel under the same key and silently invoke the wrong module. - YDSP VS Code extension: audition the active patch through `yup_dsp_compiler run` from a Patch Player sidebar view (transport, workspace patch list, audio/MIDI device selects, sample rate, block size, test note), with a single pinned player per window, a status bar, a dedicated playback output channel and an opt-in follow-active-patch mode. diff --git a/codecov.yml b/codecov.yml index 8abaa9be1..d1200a15f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,6 +15,7 @@ coverage: threshold: 5% base: auto flags: + - yup_ai - yup_animation - yup_audio_basics - yup_audio_devices diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 2056d5aea..a3cf18b2f 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -31,13 +31,39 @@ #include #include #include +#include #include +//============================================================================== +/** The PRISM logo, with the viewBox cropped to the drawing so it fills the header slot. */ +inline constexpr const char* synthLogoSvg = R"svg( + + + + + + + + + + + + + + +)svg"; + //============================================================================== /** A page of the instrument: paints nothing and hands clicks on its background back. */ class SynthPage : public yup::Component { public: + /** Construct a page. */ + SynthPage() + { + setOpaque (false); + } + /** Called when the page itself, not one of its children, is clicked. */ std::function onMouseDown; @@ -57,6 +83,8 @@ class AudioExample AudioExample() : Component ("AudioExample") , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) + , pitchWheelComponent (keyboardState, "PitchWheel") + , modWheelComponent (keyboardState, "ModWheel") { audioDeviceError = deviceManager.initialiseWithDefaultDevices (0, 2); @@ -72,17 +100,45 @@ class AudioExample keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::blackKeyPressedColorId, SynthTheme::accentDim); keyboardComponent.setColor (yup::MidiKeyboardComponent::Style::keyOutlineColorId, SynthTheme::panelBorder); mainPage.addAndMakeVisible (keyboardComponent); - keyboardComponent.setVisible (false); + + // Like the keyboard, the wheels follow keyboardState, which the audio callback + // updates from the hardware input too; their own moves go in through the collector. + pitchWheelComponent.onValueChanged = [this] (double value) + { + sendWheelMessage (yup::MidiMessage::pitchWheel (1, 8192 + static_cast (value * 8191.0))); + }; + modWheelComponent.onValueChanged = [this] (double value) + { + sendWheelMessage (yup::MidiMessage::controllerEvent (1, 1, static_cast (value * 127.0))); + }; + + const auto addWheel = [this] (auto& wheel) + { + using Style = typename std::decay_t::Style; + + wheel.setColor (Style::bodyTopColorId, SynthTheme::panelBackground); + wheel.setColor (Style::bodyBottomColorId, SynthTheme::displayBackground); + wheel.setColor (Style::outlineColorId, SynthTheme::panelBorder); + wheel.setColor (Style::gripColorId, SynthTheme::accentDim); + wheel.setColor (Style::gripOverColorId, SynthTheme::accent); + wheel.setColor (Style::gripDownColorId, SynthTheme::accent); + wheel.setClickingGrabFocus (false); + mainPage.addAndMakeVisible (wheel); + }; + addWheel (pitchWheelComponent); + addWheel (modWheelComponent); + + logo.parseSVG (synthLogoSvg); const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); titleLabel.setText ("P R I S M / SPECTRAL SYNTH", yup::dontSendNotification); - titleLabel.setFont (font.withHeight (17.0f)); + titleLabel.setFont (font.withHeight (20.0f)); titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); addAndMakeVisible (titleLabel); subtitleLabel.setText ("Sculpt harmonics. Scatter phases. Play the spectrum.", yup::dontSendNotification); - subtitleLabel.setFont (font.withHeight (11.0f)); + subtitleLabel.setFont (font.withHeight (12.0f)); subtitleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); addAndMakeVisible (subtitleLabel); @@ -220,18 +276,23 @@ class AudioExample volumeKnob->setBounds (header.removeFromRight (64.0f)); header.removeFromRight (spacing); - clearButton.setBounds (header.removeFromRight (110.0f).reduced (0.0f, 14.0f)); + clearButton.setBounds (header.removeFromRight (actionButtonWidth).reduced (0.0f, buttonInset)); header.removeFromRight (spacing); - randomizeButton.setBounds (header.removeFromRight (110.0f).reduced (0.0f, 14.0f)); + randomizeButton.setBounds (header.removeFromRight (actionButtonWidth).reduced (0.0f, buttonInset)); header.removeFromRight (spacing * 2.0f); voiceLabel.setBounds (header.removeFromRight (110.0f)); header.removeFromRight (spacing); - modulationPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, 14.0f)); + modulationPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, buttonInset)); header.removeFromRight (spacing); - mainPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, 14.0f)); + mainPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, buttonInset)); - titleLabel.setBounds (header.removeFromTop (header.getHeight() * 0.5f)); - subtitleLabel.setBounds (header); + const auto logoHeight = header.getHeight() - 8.0f; + logoArea = header.removeFromLeft (logoHeight * logoAspect).withSizeKeepingCenter (logoHeight * logoAspect, logoHeight); + header.removeFromLeft (spacing * 1.5f); + + const auto textArea = header.reduced (0.0f, 6.0f); + titleLabel.setBounds (textArea.withHeight (textArea.getHeight() * 0.55f)); + subtitleLabel.setBounds (textArea.withTrimmedTop (textArea.getHeight() * 0.55f)); bounds.removeFromTop (spacing); @@ -246,7 +307,12 @@ class AudioExample { auto bounds = mainPage.getLocalBounds(); - keyboardComponent.setBounds (bounds.removeFromBottom (yup::jmin (keyboardHeight, mainPage.proportionOfHeight (0.12f)))); + auto keyboardRow = bounds.removeFromBottom (yup::jmin (keyboardHeight, mainPage.proportionOfHeight (0.12f))); + pitchWheelComponent.setBounds (keyboardRow.removeFromLeft (wheelWidth).reduced (0.0f, 4.0f)); + keyboardRow.removeFromLeft (spacing); + modWheelComponent.setBounds (keyboardRow.removeFromLeft (wheelWidth).reduced (0.0f, 4.0f)); + keyboardRow.removeFromLeft (spacing * 2.0f); + keyboardComponent.setBounds (keyboardRow); bounds.removeFromBottom (spacing); auto performance = bounds.removeFromBottom (58.0f); @@ -259,21 +325,21 @@ class AudioExample bounds.removeFromBottom (spacing); // The oscillator panels take whatever the fixed-height rows below leave, and - // their waveform editors need most of it. + // their waveform editors need most of it. The LFO and shaping rows share columns. + const auto columnWidth = (bounds.getWidth() - spacing * 2.0f) / 3.0f; + auto lfoRow = bounds.removeFromBottom (lfoRowHeight); - const auto lfoWidth = (lfoRow.getWidth() - spacing * 2.0f) * 0.3f; - lfoPanels[0]->setBounds (lfoRow.removeFromLeft (lfoWidth)); + lfoPanels[0]->setBounds (lfoRow.removeFromLeft (columnWidth)); lfoRow.removeFromLeft (spacing); - lfoPanels[1]->setBounds (lfoRow.removeFromLeft (lfoWidth)); + lfoPanels[1]->setBounds (lfoRow.removeFromLeft (columnWidth)); lfoRow.removeFromLeft (spacing); oscilloscope.setBounds (lfoRow); bounds.removeFromBottom (spacing); auto shapingRow = bounds.removeFromBottom (yup::jmin (shapingRowHeight, bounds.getHeight() * 0.3f)); - const auto shapingWidth = (shapingRow.getWidth() - spacing * 2.0f) / 3.0f; - filterPanel->setBounds (shapingRow.removeFromLeft (shapingWidth)); + filterPanel->setBounds (shapingRow.removeFromLeft (columnWidth)); shapingRow.removeFromLeft (spacing); - envelopePanels[0]->setBounds (shapingRow.removeFromLeft (shapingWidth)); + envelopePanels[0]->setBounds (shapingRow.removeFromLeft (columnWidth)); shapingRow.removeFromLeft (spacing); envelopePanels[1]->setBounds (shapingRow); bounds.removeFromBottom (spacing); @@ -299,6 +365,8 @@ class AudioExample { g.setFillColor (SynthTheme::windowBackground); g.fillAll(); + + logo.paint (g, logoArea); } void mouseDown (const yup::MouseEvent&) override @@ -464,6 +532,17 @@ class AudioExample midiInputIdentifier.clear(); } + /** Queues a message from one of the on-screen wheels, timestamped as the collector expects. */ + void sendWheelMessage (yup::MidiMessage message) + { + // The collector is only reset, and so only ready, once an audio device has started. + if (deviceManager.getCurrentAudioDevice() == nullptr) + return; + + message.setTimeStamp (yup::Time::getMillisecondCounterHiRes() * 0.001); + midiCollector.addMessageToQueue (message); + } + //============================================================================== /** Randomizes everything a voice is made of; volume, voice mode, glide and MIDI input stay. */ void randomizeVoice() @@ -574,9 +653,13 @@ class AudioExample static constexpr std::size_t midiQueueBytes = 2048; static constexpr float outerInset = 10.0f; - static constexpr float headerHeight = 44.0f; + static constexpr float headerHeight = 60.0f; static constexpr float spacing = 8.0f; - static constexpr float pageButtonWidth = 68.0f; + static constexpr float buttonInset = 12.0f; + static constexpr float pageButtonWidth = 76.0f; + static constexpr float actionButtonWidth = 120.0f; + static constexpr float logoAspect = 236.0f / 122.0f; + static constexpr float wheelWidth = 28.0f; static constexpr float keyboardHeight = 72.0f; static constexpr float lfoRowHeight = 104.0f; static constexpr float shapingRowHeight = 150.0f; @@ -588,6 +671,8 @@ class AudioExample // MIDI keyboard components yup::MidiKeyboardState keyboardState; yup::MidiKeyboardComponent keyboardComponent; + yup::PitchWheelComponent pitchWheelComponent; + yup::ModWheelComponent modWheelComponent; yup::MidiMessageCollector midiCollector; yup::String midiInputIdentifier; yup::String audioDeviceError; @@ -605,6 +690,8 @@ class AudioExample yup::AudioProcessLoadMeasurer loadMeasurer; // UI Components + yup::Drawable logo; + yup::Rectangle logoArea; yup::Label titleLabel; yup::Label subtitleLabel; yup::Label voiceLabel; diff --git a/examples/graphics/source/examples/audio/SynthEngine.h b/examples/graphics/source/examples/audio/SynthEngine.h index 37afe1f15..95a5cf2f9 100644 --- a/examples/graphics/source/examples/audio/SynthEngine.h +++ b/examples/graphics/source/examples/audio/SynthEngine.h @@ -736,6 +736,7 @@ struct SynthBlockContext SynthModulationValues modulation; std::array envelopes; std::array lfos; + float modWheel = 0.0f; /**< The last controller 1 value, 0 to 1, kept across blocks. */ }; //============================================================================== @@ -919,7 +920,7 @@ class SynthVoice : public yup::SynthesiserVoice if (isVoiceActive()) { - // Every source is per voice, so the routings are applied here, once per + // The envelopes and LFOs are per voice, so the routings are applied here, once per // control chunk, from the values each source holds at the top of the chunk. for (std::size_t index = 0; index < lfos.size(); ++index) { @@ -929,7 +930,7 @@ class SynthVoice : public yup::SynthesiserVoice } auto patch = context.patch; - const std::array sources { envelope.getLevel(), modulationEnvelope.getLevel(), lfos[0].getValue(), lfos[1].getValue() }; + const std::array sources { envelope.getLevel(), modulationEnvelope.getLevel(), lfos[0].getValue(), lfos[1].getValue(), context.modWheel }; applyModulation (patch, context.modulation, sources); for (auto& lfo : lfos) @@ -1166,6 +1167,10 @@ class HarmonicSynthEngine : public yup::Synthesiser void handleController (int channel, int controller, int value) override { + // Kept on the engine rather than per voice, so notes started after the wheel moved see it too. + if (controller == 1) + context.modWheel = static_cast (value) / 127.0f; + if (mode != SynthPlayMode::poly && controller == 64) { sustain[static_cast (channel - 1)] = value >= 64; diff --git a/examples/graphics/source/examples/audio/SynthModulationPage.h b/examples/graphics/source/examples/audio/SynthModulationPage.h index 57144a943..9c803c12b 100644 --- a/examples/graphics/source/examples/audio/SynthModulationPage.h +++ b/examples/graphics/source/examples/audio/SynthModulationPage.h @@ -37,6 +37,8 @@ class SynthModulationRow : public yup::Component , destinationChoice ("DESTINATION", getSynthModulationDestinationNames(), font) , depthKnob ("DEPTH", -1.0, 1.0, 0.01, 0.0, font) { + setOpaque (false); + addAndMakeVisible (sourceChoice); addAndMakeVisible (destinationChoice); addAndMakeVisible (depthKnob); diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 147bd2d30..02a735d68 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -122,6 +122,7 @@ class KnobControl : public yup::Component label.setText (caption, yup::dontSendNotification); label.setFont (font); label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + label.setJustification (yup::Justification::center); addAndMakeVisible (label); } @@ -135,13 +136,13 @@ class KnobControl : public yup::Component void resized() override { - auto bounds = getLocalBounds(); - - label.setBounds (bounds.removeFromBottom (captionHeight)); - - const auto size = yup::jmin (bounds.getWidth(), bounds.getHeight()); + const auto bounds = getLocalBounds(); + const auto size = yup::jmax (0.0f, yup::jmin (bounds.getWidth(), bounds.getHeight() - captionHeight)); - slider.setBounds (bounds.withSizeKeepingCenter (size, size)); + // The knob and its caption are centered as one block, so the caption sits right under the knob. + auto block = bounds.withSizeKeepingCenter (bounds.getWidth(), size + captionHeight); + slider.setBounds (block.removeFromTop (size).withSizeKeepingCenter (size, size)); + label.setBounds (block); } private: diff --git a/examples/graphics/source/examples/audio/SynthSettings.h b/examples/graphics/source/examples/audio/SynthSettings.h index 4938c32b5..50e302e2a 100644 --- a/examples/graphics/source/examples/audio/SynthSettings.h +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -385,13 +385,14 @@ enum class SynthModulationSource env1, /**< The amplitude envelope, unipolar. */ env2, /**< The free envelope, unipolar. */ lfo1, /**< Bipolar. */ - lfo2 + lfo2, + modWheel /**< MIDI controller 1, unipolar. */ }; /** @internal Item names for SynthModulationSource, index aligned with the enumeration. */ inline yup::StringArray getSynthModulationSourceNames() { - return { "ENV 1", "ENV 2", "LFO 1", "LFO 2" }; + return { "ENV 1", "ENV 2", "LFO 1", "LFO 2", "MOD WHEEL" }; } /** Every parameter the matrix can reach; the oscillator block repeats per oscillator. */ @@ -606,7 +607,7 @@ inline float* getDestinationField (SynthPatchValues& patch, SynthModulationDesti /** Adds every route whose source has a value to the patch: one full knob range per unit of depth times source. */ inline void applyModulation (SynthPatchValues& patch, const SynthModulationValues& modulation, - const std::array& sourceValues) noexcept + const std::array& sourceValues) noexcept { for (const auto& route : modulation.routes) { From fb735526a03f0e579a15ef65806f9fda31f78282 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 15:48:24 +0200 Subject: [PATCH 21/37] More tweaks --- CHANGELOG.md | 3 +- docs/dsp/oscillators.md | 2 +- .../source/examples/audio/SynthEngine.h | 38 +++++++++--- .../examples/audio/SynthModulationPage.h | 10 +-- .../source/examples/audio/SynthPanels.h | 10 ++- .../source/examples/audio/SynthSettings.h | 2 +- .../oscillators/yup_SyncSpectralResampler.h | 62 +++++-------------- tests/yup_dsp/yup_SyncSpectralResampler.cpp | 2 +- 8 files changed, 62 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6269818b3..2202e89bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased -- Graphics synthesizer example: PRISM logo and larger buttons in the header, LFO / scope columns aligned with filter / envelopes, and pitch bend and mod wheels beside the keyboard. The mod wheel is a new `MOD WHEEL` source in the modulation matrix. +- `SyncSpectralResampler`: the per-harmonic accumulation goes through `FloatVectorOperations` again instead of a hand-written `SIMDRegister` loop, which was many times slower in debug builds. The graphics synthesizer example caps a per-voice synced series at the note's Nyquist harmonic count. +- Graphics synthesizer example: PRISM logo and larger buttons in the header, LFO / scope columns aligned with filter / envelopes, and pitch bend and mod wheels beside the keyboard. The mod wheel is a new `MOD WHEEL` source in the modulation matrix. The matrix now has 16 slots. - CMake: retry failed upstream module and validation tool downloads, verify `sha256` after download, and fail at configure time when an upstream archive extracts nothing instead of later with missing headers. - YDSP backend (emscripten): mint kernel handles from a module-wide counter instead of a JS-realm-local one, so a realm that runs a graph can no longer find another realm's kernel under the same key and silently invoke the wrong module. diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md index b968e921b..cb9409685 100644 --- a/docs/dsp/oscillators.md +++ b/docs/dsp/oscillators.md @@ -107,7 +107,7 @@ void processBlock (double* output, int numSamples) | operation | cost | |---|---| -| `SyncSpectralResampler::transform` | `O (N_in * N_out)` SIMD multiply-accumulates, `O (N_in)` transcendental calls. | +| `SyncSpectralResampler::transform` | `O (N_in * N_out)` vectorized multiply-accumulates (`FloatVectorOperations`), `O (N_in)` transcendental calls. | | `AdditiveOscillator::processSample` | SIMD harmonic accumulation; two fundamental-phasor trig calls every 64 samples and two rotation trig calls per frequency change. | | `WavetableOscillator::render` | One inverse FFT of the oversampled table. | | `WavetableOscillator::processSample` | One Hermite table read (two while crossfading). | diff --git a/examples/graphics/source/examples/audio/SynthEngine.h b/examples/graphics/source/examples/audio/SynthEngine.h index 95a5cf2f9..48d08e31b 100644 --- a/examples/graphics/source/examples/audio/SynthEngine.h +++ b/examples/graphics/source/examples/audio/SynthEngine.h @@ -291,11 +291,22 @@ class SynthSpectrumDerivation hasApplied = false; } - /** Rebuilds the series if anything it depends on moved. Returns true when it did. */ + /** Rebuilds the series if anything it depends on moved. Returns true when it did. + + @param numOutputHarmonics How many harmonics of the synced series are needed, -1 for + all of them. Only a growing count forces a rebuild: a table + played higher drops the extra harmonics by itself. An eighth + more is computed, like WavetableOscillator::needsRender(), so a + falling pitch does not rebuild on every block. + */ bool update (const SynthOscillatorValues& values, const SynthOscillatorSettings& settings, - const SynthOscillatorResources& resources) noexcept + const SynthOscillatorResources& resources, + int numOutputHarmonics = -1) noexcept { + const auto neededHarmonics = numOutputHarmonics < 0 ? SynthExample::maxHarmonics + : yup::jmin (numOutputHarmonics, SynthExample::maxHarmonics); + const auto partialsChanged = ! hasApplied || applied.usesCustomSeries != values.usesCustomSeries || applied.harmonicGeneration != values.harmonicGeneration @@ -320,13 +331,15 @@ class SynthSpectrumDerivation || applied.formantPosition != values.formantPosition || applied.scatter != values.scatter; + const auto bandwidthGrew = values.syncMode != yup::SyncMode::none && neededHarmonics > appliedOutputHarmonics; + applied = values; hasApplied = true; if (sourceChanged) sourcePeak = measurePeak (source); - if (! (sourceChanged || shapeChanged)) + if (! (sourceChanged || shapeChanged || bandwidthGrew)) return false; spectrum.process (source, shapedSeries, toPrismShape (values), values.color); @@ -337,7 +350,10 @@ class SynthSpectrumDerivation } else { - resampler.transform (shapedSeries, static_cast (values.syncRatio), values.syncMode, syncedSeries); + const auto outputHarmonics = yup::jmin (SynthExample::maxHarmonics, neededHarmonics + yup::jmax (1, neededHarmonics / 8)); + + resampler.transform (shapedSeries, static_cast (values.syncRatio), values.syncMode, syncedSeries, outputHarmonics); + appliedOutputHarmonics = outputHarmonics; derived = &syncedSeries; } @@ -403,6 +419,7 @@ class SynthSpectrumDerivation yup::FourierSeries syncedSeries; yup::FourierSeries* derived = &shapedSeries; SynthOscillatorValues applied; + int appliedOutputHarmonics = 0; bool hasApplied = false; }; @@ -465,7 +482,7 @@ class SynthOscillator const SynthOscillatorSettings& oscillatorSettings, const SynthOscillatorResources& oscillatorResources) { - const auto sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; slot = &sharedSlot; settings = &oscillatorSettings; @@ -525,7 +542,11 @@ class SynthOscillator const auto centreIndex = (slotCount - 1) / 2; const auto slotGain = 1.0f / static_cast (slotCount); - applySeries (values, deriveLocally); + // A locally synced series only needs what the lowest unison slot can play below Nyquist. + const auto lowest = yup::jmin (detunedFrequency (played, values, 0, slotCount), + detunedFrequency (played, values, slotCount - 1, slotCount)); + + applySeries (values, deriveLocally, yup::getNyquistHarmonicLimit (lowest, sampleRate, SynthExample::maxHarmonics)); renderTable (wavetable, numSamples, detunedFrequency (played, values, centreIndex, slotCount)); accumulateSlot (left, right, numSamples, slotOffset (centreIndex, slotCount) * values.unisonSpread, slotGain); @@ -591,7 +612,7 @@ class SynthOscillator } /** Hands the right series to every table when it moved since the last block. */ - void applySeries (const SynthOscillatorValues& values, bool deriveLocally) noexcept + void applySeries (const SynthOscillatorValues& values, bool deriveLocally, int numLocalHarmonics) noexcept { if (deriveLocally) { @@ -601,7 +622,7 @@ class SynthOscillator usingLocalSeries = true; } - if (localDerivation.update (values, *settings, *resources)) + if (localDerivation.update (values, *settings, *resources, numLocalHarmonics)) setSeries (localDerivation.getSeries()); return; @@ -634,6 +655,7 @@ class SynthOscillator const SynthOscillatorSettings* settings = nullptr; const SynthOscillatorResources* resources = nullptr; std::vector slotBuffer; + double sampleRate = 44100.0; int appliedSeriesGeneration = -1; bool usingLocalSeries = false; }; diff --git a/examples/graphics/source/examples/audio/SynthModulationPage.h b/examples/graphics/source/examples/audio/SynthModulationPage.h index 9c803c12b..9eba0aead 100644 --- a/examples/graphics/source/examples/audio/SynthModulationPage.h +++ b/examples/graphics/source/examples/audio/SynthModulationPage.h @@ -33,8 +33,8 @@ class SynthModulationRow : public yup::Component public: SynthModulationRow (SynthModulationSettings::Slot& slotToEdit, const yup::Font& font) : slot (slotToEdit) - , sourceChoice ("SOURCE", getSynthModulationSourceNames(), font) - , destinationChoice ("DESTINATION", getSynthModulationDestinationNames(), font) + , sourceChoice ({}, getSynthModulationSourceNames(), font) + , destinationChoice ({}, getSynthModulationDestinationNames(), font) , depthKnob ("DEPTH", -1.0, 1.0, 0.01, 0.0, font) { setOpaque (false); @@ -66,9 +66,9 @@ class SynthModulationRow : public yup::Component depthKnob.setBounds (bounds.removeFromRight (knobWidth)); bounds.removeFromRight (spacing); - sourceChoice.setBounds (bounds.removeFromLeft (bounds.getWidth() * 0.35f).reduced (0.0f, 8.0f)); + sourceChoice.setBounds (bounds.removeFromLeft (bounds.getWidth() * 0.35f).reduced (0.0f, 4.0f)); bounds.removeFromLeft (spacing); - destinationChoice.setBounds (bounds.reduced (0.0f, 8.0f)); + destinationChoice.setBounds (bounds.reduced (0.0f, 4.0f)); } private: @@ -83,7 +83,7 @@ class SynthModulationRow : public yup::Component }; //============================================================================== -/** The modulation matrix: eight routings in a panel. +/** The modulation matrix: sixteen routings in a panel. @see SynthModulationSettings */ diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 02a735d68..8a9e88de0 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -168,7 +168,7 @@ class KnobControl : public yup::Component }; //============================================================================== -/** A combo box with its caption above it. */ +/** A combo box with its caption above it; an empty caption leaves the whole height to the box. */ class ChoiceControl : public yup::Component { public: @@ -179,7 +179,9 @@ class ChoiceControl : public yup::Component label.setText (caption, yup::dontSendNotification); label.setFont (font); label.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); - addAndMakeVisible (label); + + if (caption.isNotEmpty()) + addAndMakeVisible (label); comboBox.addItemList (items, 1); comboBox.setTextWhenNothingSelected ("-"); @@ -204,7 +206,9 @@ class ChoiceControl : public yup::Component { auto bounds = getLocalBounds(); - label.setBounds (bounds.removeFromTop (captionHeight)); + if (label.getText().isNotEmpty()) + label.setBounds (bounds.removeFromTop (captionHeight)); + comboBox.setBounds (bounds); } diff --git a/examples/graphics/source/examples/audio/SynthSettings.h b/examples/graphics/source/examples/audio/SynthSettings.h index 50e302e2a..1efbf0ce6 100644 --- a/examples/graphics/source/examples/audio/SynthSettings.h +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -36,7 +36,7 @@ constexpr int maxHarmonics = 128; constexpr int maxBlockSize = 2048; constexpr int envelopeCount = 2; constexpr int lfoCount = 2; -constexpr int modulationSlots = 8; +constexpr int modulationSlots = 16; /** Samples between control updates: modulation, filter targets and local spectra. */ constexpr int controlChunk = 128; diff --git a/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h index 66d217c7c..55f51a663 100644 --- a/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h +++ b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h @@ -61,10 +61,9 @@ enum class SyncMode bandwidth must therefore be chosen independently of the follower bandwidth. The transform costs O (N_in * N_out) multiply-accumulates with O (N_in) - transcendental calls, using FloatVectorOperations and, where helpful, - SIMDRegister. It is allocation-free once prepare() has been called, so it can - run on the audio thread, but a synthesizer normally calls it once per block - instead of once per sample. + transcendental calls, using FloatVectorOperations. It is allocation-free once + prepare() has been called, so it can run on the audio thread, but a + synthesizer normally calls it once per block instead of once per sample. The absolute phase of the output follows the paper's pre-rotation: relative to the raw time-domain definitions above, the output is the same waveform delayed @@ -103,7 +102,6 @@ class SyncSpectralResampler triggerCosine.assign (count, CoeffType (0)); inverseArgument.assign (count, CoeffType (0)); weight.assign (count, CoeffType (0)); - nonResonant.assign (count, CoeffType (1)); resonantIndices.reserve (count); resonantHarmonics.reserve (count); @@ -222,7 +220,6 @@ class SyncSpectralResampler resonantIndices.clear(); resonantHarmonics.clear(); - FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); for (int k = 1; k <= numFollower; ++k) { @@ -239,7 +236,6 @@ class SyncSpectralResampler if (std::abs (argument - rounded) < getResonanceEpsilon()) { - nonResonant[index] = CoeffType (0); resonantIndices.push_back (static_cast (index)); resonantHarmonics.push_back (static_cast (rounded)); } @@ -284,7 +280,6 @@ class SyncSpectralResampler resonantIndices.clear(); resonantHarmonics.clear(); - FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); for (int k = 1; k <= numFollower; ++k) { @@ -302,7 +297,6 @@ class SyncSpectralResampler if (std::abs (argument - rounded) < getResonanceEpsilon()) { - nonResonant[index] = CoeffType (0); resonantIndices.push_back (static_cast (index)); resonantHarmonics.push_back (static_cast (rounded)); } @@ -363,8 +357,6 @@ class SyncSpectralResampler secondWeight[index] = sign * static_cast (k) * followerSine[index]; } - FloatVectorOperations::fill (nonResonant.data(), CoeffType (1), numFollower); - // The paper's pulsar transform drops the follower's DC term. output.setDC (CoeffType (0)); @@ -377,14 +369,13 @@ class SyncSpectralResampler && rounded <= static_cast (numFollower); const auto resonantIndex = isResonant ? static_cast (rounded) - 1 : 0; + resonantIndices.clear(); + if (isResonant) - nonResonant[resonantIndex] = CoeffType (0); + resonantIndices.push_back (static_cast (resonantIndex)); const auto sums = accumulateWeights (q * q, numFollower); - if (isResonant) - nonResonant[resonantIndex] = CoeffType (1); - const auto sine = std::sin (pi * q); auto a = (CoeffType (2) * q * sine / (pi * ratio)) * sums[0]; @@ -400,40 +391,18 @@ class SyncSpectralResampler } } - std::array accumulateWeights (CoeffType squared, int count) const noexcept + /** Returns the dot products of both weight vectors with 1 / (squared - argumentSquared), skipping resonantIndices. */ + std::array accumulateWeights (CoeffType squared, int count) noexcept { - constexpr int lanes = std::is_same_v ? 8 : 4; - using Register = SIMDRegister; - - const auto one = Register::broadcast (CoeffType (1)); - const auto argument = Register::broadcast (squared); - auto first = Register::zero(); - auto second = Register::zero(); - int k = 0; - - for (; k + lanes <= count; k += lanes) - { - const auto mask = Register::loadUnaligned (nonResonant.data() + k); - const auto denominator = (argument - Register::loadUnaligned (argumentSquared.data() + k)) * mask + (one - mask); - const auto reciprocal = mask / denominator; - first = first.mulAdd (Register::loadUnaligned (firstWeight.data() + k), reciprocal); - second = second.mulAdd (Register::loadUnaligned (secondWeight.data() + k), reciprocal); - } - - std::array result { first.sum(), second.sum() }; + FloatVectorOperations::fill (weight.data(), squared, count); + FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), count); + FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), count); - for (; k < count; ++k) - { - const auto index = static_cast (k); - if (nonResonant[index] == CoeffType (0)) - continue; - - const auto reciprocal = CoeffType (1) / (squared - argumentSquared[index]); - result[0] += firstWeight[index] * reciprocal; - result[1] += secondWeight[index] * reciprocal; - } + for (const auto index : resonantIndices) + weight[static_cast (index)] = CoeffType (0); - return result; + return { FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), count), + FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), count) }; } //============================================================================== @@ -445,7 +414,6 @@ class SyncSpectralResampler std::vector triggerCosine; std::vector inverseArgument; std::vector weight; - std::vector nonResonant; std::vector resonantIndices; std::vector resonantHarmonics; }; diff --git a/tests/yup_dsp/yup_SyncSpectralResampler.cpp b/tests/yup_dsp/yup_SyncSpectralResampler.cpp index 0d340e28a..40775babf 100644 --- a/tests/yup_dsp/yup_SyncSpectralResampler.cpp +++ b/tests/yup_dsp/yup_SyncSpectralResampler.cpp @@ -545,7 +545,7 @@ TEST_F (SyncSpectralResamplerTests, RepeatedTransformsDoNotAccumulateState) EXPECT_EQ (first, output.getSine (3)); } -TEST_F (SyncSpectralResamplerTests, FusedAccumulationMatchesScalarWithPartialSIMDLanes) +TEST_F (SyncSpectralResamplerTests, MatchesTheScalarReferenceForShortFollowers) { for (const auto count : { 1, 3, 5, 13 }) { From 9eefc1ecd8f03a5b5218bb62f8999a87cc22e31b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 16:00:27 +0200 Subject: [PATCH 22/37] I like it --- examples/graphics/source/examples/audio/SynthPanels.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 8a9e88de0..bb0b85d8f 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -574,9 +574,10 @@ class WaveformEditor : public yup::Component { const auto state = g.saveState(); - // setClipPath works in top-level coordinates, unlike the drawing calls. + // setClipPath works in top-level coordinates, unlike the drawing calls. The clip + // follows the border's centerline so the texture's antialiased edge stays under it. yup::Path clip; - clip.addRoundedRectangle (getBoundsRelativeToTopLevelComponent(), cornerRadius); + clip.addRoundedRectangle (getBoundsRelativeToTopLevelComponent().reduced (0.5f), cornerRadius); g.setClipPath (clip); g.drawTexture (landscape, bounds); } From 05cb1e150f6b1886c817fe77733e4dfcde31aefd Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 16:23:11 +0200 Subject: [PATCH 23/37] Improved demos --- examples/graphics/CMakeLists.txt | 20 +++++++++++++++----- examples/graphics/source/main.cpp | 24 +++++++++++++----------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 17ab51f02..12ebfed1e 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -29,10 +29,11 @@ project (${target_name} VERSION ${target_version}) # Set to "ALL" (default) to build the full demo browser with every example. Set to a single demo: # cmake -DYUP_EXAMPLE_GRAPHICS_DEMO=SpinningCube ... set (all_demo_ids - AI Artboard Audio AudioFile Clipboard ColorLab ComponentEffects ComputeParticles - Convolution Crossover CodeEditor FileChooser FilterDemo GpuAudio Images LayoutFonts - Lottie OffscreenRender OpaqueDemo PaintProfiler Paths PopupMenu ScrollBar Sliders - SpectrumAnalyzer SpinningCube Svg TextEditor VariableFonts Widgets YdspSynths Python) + AI Artboard ArtboardLayout Audio AudioFile Clipboard ColorLab Component3D ComponentEffects ComputeParticles + Convolution Crossover CodeEditor DragAndDrop FileChooser Filter FluidSimulation GpuAudio Images + Layout LayoutFonts Lottie OffscreenRender Opaque PaintProfiler Paths Pbr PopupMenu ScrollBar Sliders + SpectrumAnalyzer SpinningCube Svg TextEditor ToastNotification TouchTrails VariableFonts + Widgets YdspSynths Python) set (YUP_EXAMPLE_GRAPHICS_DEMO "ALL" CACHE STRING "Graphics demo to build: ALL, or one of ${all_demo_ids}") @@ -58,12 +59,13 @@ endforeach() # Which of the resources/modules below are actually needed by the selected demo(s). set (need_rive_files OFF) set (need_lottie_files OFF) +set (need_svg_files OFF) set (need_audio_files OFF) set (need_synth_files OFF) set (need_shader_bundle OFF) set (need_shader_transpiler OFF) -set (rive_demos "ALL;Artboard") +set (rive_demos "ALL;Artboard;ArtboardLayout") if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST rive_demos) set (need_rive_files ON) endif() @@ -73,6 +75,11 @@ if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST lottie_demos) set (need_lottie_files ON) endif() +set (svg_demos "ALL;Svg") +if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST svg_demos) + set (need_svg_files ON) +endif() + set (audio_demos "ALL;ConvolutionDemo;CrossoverDemo;GpuAudio;SpectrumAnalyzer;YdspSynths") if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST audio_demos) set (need_audio_files ON) @@ -111,6 +118,9 @@ if (YUP_PLATFORM_MOBILE OR YUP_PLATFORM_EMSCRIPTEN) if (need_lottie_files) list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/lottie@data/lottie") endif() + if (need_svg_files) + list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/svg@data/svg") + endif() if (need_audio_files) list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/audio@data/audio") endif() diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index f757b5680..9aeea5a72 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -67,12 +67,12 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) //============================================================================== -#if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard -#include "examples/Artboard.h" -#endif #if YUP_EXAMPLE_GRAPHICS_DEMO_AI #include "examples/AI.h" #endif +#if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard || YUP_EXAMPLE_GRAPHICS_DEMO_ArtboardLayout +#include "examples/Artboard.h" +#endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Audio #include "examples/Audio.h" #endif @@ -109,7 +109,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_FileChooser #include "examples/FileChooser.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_FilterDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Filter #include "examples/FilterDemo.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_FluidSimulation @@ -133,7 +133,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_OffscreenRender #include "examples/OffscreenRenderDemo.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_OpaqueDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Opaque #include "examples/OpaqueDemo.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PaintProfiler @@ -142,7 +142,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_Paths #include "examples/Paths.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_PbrDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Pbr #include "examples/PbrDemo.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PopupMenu @@ -166,7 +166,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_TextEditor #include "examples/TextEditor.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotificationDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotification #include "examples/ToastNotificationDemo.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_TouchTrails @@ -263,6 +263,8 @@ class CustomWindow #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard addDemo ("Artboard", [] { return std::make_unique(); }); +#endif +#if YUP_EXAMPLE_GRAPHICS_DEMO_ArtboardLayout addDemo ("Artboard Layout", [] { return std::make_unique(); }); #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Audio @@ -301,7 +303,7 @@ class CustomWindow #if YUP_EXAMPLE_GRAPHICS_DEMO_FileChooser addDemo ("File Chooser", [] { return std::make_unique(); }); #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_FilterDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Filter addDemo ("Filter Demo", [] { return std::make_unique(); }); #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_FluidSimulation @@ -325,7 +327,7 @@ class CustomWindow #if YUP_EXAMPLE_GRAPHICS_DEMO_OffscreenRender addDemo ("Offscreen Render", [] { return std::make_unique(); }); #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_OpaqueDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Opaque addDemo ("Opaque Demo", [] { return std::make_unique(); }); #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PaintProfiler @@ -334,7 +336,7 @@ class CustomWindow #if YUP_EXAMPLE_GRAPHICS_DEMO_Paths addDemo ("Paths", [] { return std::make_unique(); }); #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_PbrDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_Pbr addDemo ("PBR IBL", [] { return std::make_unique(); }); #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PopupMenu @@ -358,7 +360,7 @@ class CustomWindow #if YUP_EXAMPLE_GRAPHICS_DEMO_TextEditor addDemo ("Text Editor", [] { return std::make_unique(); }); #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotificationDemo +#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotification addDemo ("Toast Notifications", [] { return std::make_unique(); }); #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_TouchTrails From 5620c5e51eedb9944a9f1b464678c5a43d99b258 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 16:40:22 +0200 Subject: [PATCH 24/37] Fix setClipPath --- CHANGELOG.md | 1 + docs/graphics/graphics-class.md | 5 +- docs/ui/component-basics.md | 4 +- .../graphics/source/examples/ScrollBarDemo.h | 2 +- .../source/examples/audio/SynthPanels.h | 5 +- .../renderer/yup_AnimationRenderer.cpp | 51 ++++--------- .../yup_graphics/drawables/yup_Drawable.cpp | 51 ++----------- .../yup_graphics/graphics/yup_Graphics.cpp | 18 ++++- modules/yup_graphics/graphics/yup_Graphics.h | 31 +++++--- modules/yup_gui/component/yup_Component.cpp | 2 + .../themes/theme_v1/yup_ThemeVersion1.cpp | 25 +------ tests/yup_graphics/yup_Graphics.cpp | 72 +++++++++++++++++++ tests/yup_gui/yup_Component.cpp | 52 ++++++++++++++ 13 files changed, 195 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2202e89bb..4fab18b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased +- **Behavior change** `Graphics::setClipPath`: the clip is now in local coordinates like every draw call, including the drawing area offset, and `getClipPath` returns it in the current local space. Code that mapped the clip by `getTransform().translated (getDrawingArea().getTopLeft())` or by top-level bounds must drop that compensation. Clips on components with an offset parent (for example `Drawable` `` clips) now land where they are drawn. - `SyncSpectralResampler`: the per-harmonic accumulation goes through `FloatVectorOperations` again instead of a hand-written `SIMDRegister` loop, which was many times slower in debug builds. The graphics synthesizer example caps a per-voice synced series at the note's Nyquist harmonic count. - Graphics synthesizer example: PRISM logo and larger buttons in the header, LFO / scope columns aligned with filter / envelopes, and pitch bend and mod wheels beside the keyboard. The mod wheel is a new `MOD WHEEL` source in the modulation matrix. The matrix now has 16 slots. - CMake: retry failed upstream module and validation tool downloads, verify `sha256` after download, and fail at configure time when an upstream archive extracts nothing instead of later with missing headers. diff --git a/docs/graphics/graphics-class.md b/docs/graphics/graphics-class.md index 1136984d7..cc424dfce 100644 --- a/docs/graphics/graphics-class.md +++ b/docs/graphics/graphics-class.md @@ -81,7 +81,10 @@ g.setFeather (2.0f); // soft edge falloff - **Drawing area** - the rectangle drawing is offset into and clipped against (`setDrawingArea` / `getDrawingArea`). `fillAll()` fills this area. - **Clip path** - a rectangle or `Path` that constrains drawing - (`setClipPath` / `getClipPath`). + (`setClipPath` / `getClipPath`). It is given in the current local coordinates, + like any draw call, and is fixed when set: changing the transform or drawing + area afterwards does not move it. Each clip intersects with the ones already in + effect until the state is restored. ```cpp g.addTransform (AffineTransform::translation (10.0f, 10.0f)); diff --git a/docs/ui/component-basics.md b/docs/ui/component-basics.md index 6bba50a49..6ad5ccd67 100644 --- a/docs/ui/component-basics.md +++ b/docs/ui/component-basics.md @@ -440,8 +440,8 @@ Inside `paint()` the mapping from local coordinates to the render target is `g.getTransform().translated (g.getDrawingArea().getTopLeft())`: the linear part of the composed transform lives in the transform and the translation in the drawing area, so untransformed components see an identity transform exactly as -before. Keep this in mind when clipping: `Graphics::setClipPath()` applies only -the transform, not the drawing area offset. +before. Clips set with `Graphics::setClipPath()` use the same local coordinates +as every draw call. A parent can also present its children through a custom projection by overriding `getChildPointFromLocal()` / `getLocalPointFromChild()`, which every input path diff --git a/examples/graphics/source/examples/ScrollBarDemo.h b/examples/graphics/source/examples/ScrollBarDemo.h index 09b00cb23..35dba70ca 100644 --- a/examples/graphics/source/examples/ScrollBarDemo.h +++ b/examples/graphics/source/examples/ScrollBarDemo.h @@ -123,7 +123,7 @@ class ScrollBarDemo : public yup::Component // Clip path ? auto bounds = getBounds(); - g.setClipPath (yup::Rectangle (getLeft() + visibleLeft, getTop() + visibleTop, viewportWidth, viewportHeight)); + g.setClipPath (yup::Rectangle (visibleLeft, visibleTop, viewportWidth, viewportHeight)); // Draw canvas background (only visible portion) g.setFillColor (yup::Color (0xff2a2a2a)); diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index bb0b85d8f..60d749538 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -574,10 +574,9 @@ class WaveformEditor : public yup::Component { const auto state = g.saveState(); - // setClipPath works in top-level coordinates, unlike the drawing calls. The clip - // follows the border's centerline so the texture's antialiased edge stays under it. + // The clip follows the border's centerline so the texture's antialiased edge stays under it. yup::Path clip; - clip.addRoundedRectangle (getBoundsRelativeToTopLevelComponent().reduced (0.5f), cornerRadius); + clip.addRoundedRectangle (bounds.reduced (0.5f), cornerRadius); g.setClipPath (clip); g.drawTexture (landscape, bounds); } diff --git a/modules/yup_animation/renderer/yup_AnimationRenderer.cpp b/modules/yup_animation/renderer/yup_AnimationRenderer.cpp index 5e15d0fd8..9f4cc9492 100644 --- a/modules/yup_animation/renderer/yup_AnimationRenderer.cpp +++ b/modules/yup_animation/renderer/yup_AnimationRenderer.cpp @@ -99,22 +99,10 @@ void countPrecompReferences (const AnimationComposition& comp, } } -/** Pushes @p clipPath as the clip in effect, interpreting it in the current - transform. Callers must have saved the Graphics state, which owns the clip - until it is restored. */ -void pushClipPath (Graphics& g, const Path& clipPath) -{ - const auto savedTransform = g.getTransform(); - - g.setTransform (AffineTransform::identity()); - g.setClipPath (clipPath); - g.setTransform (savedTransform); -} - /** Culls everything drawn until the caller's saved Graphics state is restored. */ void pushEmptyClip (Graphics& g) { - pushClipPath (g, Path()); + g.setClipPath (Path()); } /** Clips @p clipRect, given in composition space, into the current transform and @@ -130,20 +118,7 @@ void pushEmptyClip (Graphics& g) */ bool applyViewportClip (Graphics& g, Rectangle clipRect) { - if (clipRect.getWidth() <= 0.0f || clipRect.getHeight() <= 0.0f) - { - pushEmptyClip (g); - return false; - } - - const auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - - Path viewportClip; - viewportClip.addRectangle (clipRect); - auto transformedViewportClip = viewportClip.transformed (clipTransform); - - const auto viewportBounds = transformedViewportClip.getBounds(); - if (transformedViewportClip.isEmpty() || viewportBounds.getWidth() <= 0.0f || viewportBounds.getHeight() <= 0.0f) + if (clipRect.getWidth() <= 0.0f || clipRect.getHeight() <= 0.0f || g.getTransform().getDeterminant() == 0.0f) { pushEmptyClip (g); return false; @@ -155,17 +130,17 @@ bool applyViewportClip (Graphics& g, Rectangle clipRect) const auto currentBounds = currentClipPath.getBounds(); if (currentBounds.getWidth() <= 0.0f || currentBounds.getHeight() <= 0.0f - || ! currentBounds.intersects (viewportBounds)) + || ! currentBounds.intersects (clipRect)) { pushEmptyClip (g); return false; } - if (viewportBounds.contains (currentBounds)) + if (clipRect.contains (currentBounds)) return true; } - pushClipPath (g, transformedViewportClip); + g.setClipPath (clipRect); return true; } @@ -181,16 +156,16 @@ bool applyClipPathInCurrentTransform (Graphics& g, const Path& clipPath, bool al if (clipPath.isEmpty() && ! allowEmpty) return true; - const auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - auto transformedClipPath = clipPath.transformed (clipTransform); - - const auto clipBounds = transformedClipPath.getBounds(); - if (transformedClipPath.isEmpty() || clipBounds.getWidth() <= 0.0f || clipBounds.getHeight() <= 0.0f) + const auto clipBounds = clipPath.getBounds(); + if (clipPath.isEmpty() || clipBounds.getWidth() <= 0.0f || clipBounds.getHeight() <= 0.0f + || g.getTransform().getDeterminant() == 0.0f) { pushEmptyClip (g); return false; } + auto effectiveClipPath = clipPath; + const auto currentClipPath = g.getClipPath(); if (! currentClipPath.isEmpty()) { @@ -203,13 +178,13 @@ bool applyClipPathInCurrentTransform (Graphics& g, const Path& clipPath, bool al return false; } - transformedClipPath = currentClipPath.combinedWith (transformedClipPath, Path::BooleanOperation::Intersect); + effectiveClipPath = currentClipPath.combinedWith (clipPath, Path::BooleanOperation::Intersect); } - if (transformedClipPath.isEmpty()) + if (effectiveClipPath.isEmpty()) return false; - pushClipPath (g, transformedClipPath); + g.setClipPath (effectiveClipPath); return true; } diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index 86a125a48..7b0514b79 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -547,19 +547,6 @@ void Drawable::paintElement (Graphics& g, } } - const auto setViewportClip = [&g] (const Rectangle& viewportBounds) - { - Path viewportClip; - viewportClip.addRectangle (viewportBounds); - auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - auto transformedViewportClip = viewportClip.transformed (clipTransform); - - const auto savedClipTransform = g.getTransform(); - g.setTransform (AffineTransform::identity()); - g.setClipPath (transformedViewportClip); - g.setTransform (savedClipTransform); - }; - if (element.viewBox && (element.viewportBounds || element.viewportSize)) { auto viewport = element.viewportBounds != std::nullopt @@ -569,7 +556,7 @@ void Drawable::paintElement (Graphics& g, auto viewportTransform = calculateTransformForTarget (*element.viewBox, viewport, element.preserveAspectRatioFitting, element.preserveAspectRatioJustification); if (element.tagName == "svg" && element.viewportBounds) { - setViewportClip (*element.viewportBounds); + g.setClipPath (*element.viewportBounds); viewportTransform = viewportTransform.followedBy (AffineTransform::translation (element.viewportBounds->getX(), element.viewportBounds->getY())); } @@ -578,7 +565,7 @@ void Drawable::paintElement (Graphics& g, } else if (element.tagName == "svg" && element.viewportBounds) { - setViewportClip (*element.viewportBounds); + g.setClipPath (*element.viewportBounds); auto viewportTransform = AffineTransform::translation (element.viewportBounds->getX(), element.viewportBounds->getY()); g.setTransform (viewportTransform.followedBy (g.getTransform())); } @@ -653,17 +640,6 @@ void Drawable::paintElement (Graphics& g, const auto clipBounds = clipObjectBounds.value_or (Rectangle()); - const auto setClipPath = [&] (const Path& shape) - { - auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - auto transformedClipPath = shape.transformed (clipTransform); - - const auto savedClipTransform = g.getTransform(); - g.setTransform (AffineTransform::identity()); - g.setClipPath (transformedClipPath); - g.setTransform (savedClipTransform); - }; - // If the clipPath itself has a nested clip-path, apply it first (intersection) if (clipPath->clipPathUrl) { @@ -672,7 +648,7 @@ void Drawable::paintElement (Graphics& g, auto nestedClipShape = buildClipShape (*nestedClipPath, clipBounds); if (! nestedClipShape.isEmpty()) { - setClipPath (nestedClipShape); + g.setClipPath (nestedClipShape); hasClipping = true; } } @@ -682,7 +658,7 @@ void Drawable::paintElement (Graphics& g, auto clipShape = buildClipShape (*clipPath, clipBounds); if (! clipShape.isEmpty()) { - setClipPath (clipShape); + g.setClipPath (clipShape); hasClipping = true; } } @@ -735,15 +711,7 @@ void Drawable::paintElement (Graphics& g, } if (! combinedMaskPath.isEmpty()) - { - auto maskClipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - auto transformedMaskPath = combinedMaskPath.transformed (maskClipTransform); - - const auto savedMaskTransform = g.getTransform(); - g.setTransform (AffineTransform::identity()); - g.setClipPath (transformedMaskPath); - g.setTransform (savedMaskTransform); - } + g.setClipPath (combinedMaskPath); } } @@ -1216,14 +1184,7 @@ void Drawable::paintPatternFill (Graphics& g, const auto savedState = g.saveState(); // Clip rendering to the filled shape - { - auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); - auto transformedShape = shape.transformed (clipTransform); - const auto savedClipTransform = g.getTransform(); - g.setTransform (AffineTransform::identity()); - g.setClipPath (transformedShape); - g.setTransform (savedClipTransform); - } + g.setClipPath (shape); if (! pattern.patternTransform.isIdentity()) g.addTransform (pattern.patternTransform); diff --git a/modules/yup_graphics/graphics/yup_Graphics.cpp b/modules/yup_graphics/graphics/yup_Graphics.cpp index 58fae5aca..7943c42cd 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.cpp +++ b/modules/yup_graphics/graphics/yup_Graphics.cpp @@ -707,17 +707,31 @@ void Graphics::setClipPath (const Path& clipPath) auto& options = currentRenderOptions(); options.clipPath = clipPath; + options.clipTransform = options.getTransform(); auto renderPath = rive::make_rcp(); renderPath->fillRule (clipPath.isUsingNonZeroWinding() ? rive::FillRule::nonZero : rive::FillRule::evenOdd); - renderPath->addRenderPath (clipPath.getRenderPath(), options.getLocalTransform().toMat2D()); + renderPath->addRenderPath (clipPath.getRenderPath(), options.clipTransform.toMat2D()); renderer.clipPath (renderPath.get()); } Path Graphics::getClipPath() const { - return currentRenderOptions().clipPath; + const auto& options = currentRenderOptions(); + + if (options.clipPath.isEmpty()) + return {}; + + const auto transform = options.getTransform(); + + if (transform == options.clipTransform) + return options.clipPath; + + if (transform.getDeterminant() == 0.0f) + return {}; + + return options.clipPath.transformed (options.clipTransform.followedBy (transform.inverted())); } //============================================================================== diff --git a/modules/yup_graphics/graphics/yup_Graphics.h b/modules/yup_graphics/graphics/yup_Graphics.h index de91f64d6..1004c05a9 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.h +++ b/modules/yup_graphics/graphics/yup_Graphics.h @@ -376,19 +376,35 @@ class YUP_API Graphics //============================================================================== /** Sets the clip path for subsequent drawing operations. - @param clipRect The rectangle to clip to. + The rectangle is in the current local coordinates, exactly like the coordinates passed to + the drawing calls. See setClipPath (const Path&) for the details. + + @param clipRect The rectangle to clip to, in local coordinates. */ void setClipPath (const Rectangle& clipRect); /** Sets the clip path for subsequent drawing operations. - @param clipPath The path to clip to. + The path is in the current local coordinates, exactly like the coordinates passed to the + drawing calls: it is mapped by the current transform, the drawing area offset and the + context scale at the time of the call. Changing the transform or the drawing area + afterwards does not move the clip. + + The new clip intersects with any clip already in effect, until the state is restored + (see saveState()). + + @param clipPath The path to clip to, in local coordinates. */ void setClipPath (const Path& clipPath); - /** Retrieves the current clip path. + /** Retrieves the last clip path set with setClipPath(). - @return The current clip path. + The path is returned in the current local coordinates, so if the transform or the drawing + area changed since the clip was set, the path is mapped into the new space. It is only the + last clip set, not the intersection of all the clips currently in effect. + + @return The last clip path set, or an empty path if the current transform is not + invertible. */ Path getClipPath() const; @@ -729,12 +745,6 @@ class YUP_API Graphics return drawingArea; } - AffineTransform getLocalTransform() const noexcept - { - return transform - .scaled (scale); - } - AffineTransform getTransform() const noexcept { return transform @@ -769,6 +779,7 @@ class YUP_API Graphics Rectangle drawingArea; AffineTransform transform; Path clipPath; + AffineTransform clipTransform; BlendMode blendMode = BlendMode::SrcOver; float opacity = 1.0f; bool isCurrentFillColor = true; diff --git a/modules/yup_gui/component/yup_Component.cpp b/modules/yup_gui/component/yup_Component.cpp index eccd2e4f7..c1a71e2ff 100644 --- a/modules/yup_gui/component/yup_Component.cpp +++ b/modules/yup_gui/component/yup_Component.cpp @@ -1710,6 +1710,8 @@ void Component::applyPaintState (Graphics& g, const RectangleList& clipRe { const auto toTopLevel = getTransformToTopLevelComponent(); + // The clip region is in top-level coordinates, while the parent's drawing area is still set + g.setDrawingArea ({}); g.setTransform (AffineTransform::identity()); if (! options.unclippedRendering) diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 2690c584a..ff6508261 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -45,23 +45,6 @@ extern const std::size_t FontAwesome7Font_size; //============================================================================== -/** Clips to a path given in the local coordinates of the component being painted. - - Graphics::setClipPath() only applies the linear part of the current transform, while the - component position lives in the drawing area, so the path is mapped to the target here. -*/ -void setLocalClipPath (Graphics& g, const Path& localPath) -{ - const auto savedTransform = g.getTransform(); - const auto localToTarget = savedTransform.translated (g.getDrawingArea().getTopLeft()); - - g.setTransform (AffineTransform::identity()); - g.setClipPath (localPath.transformed (localToTarget)); - g.setTransform (savedTransform); -} - -//============================================================================== - struct SliderColors { Color background; @@ -462,9 +445,7 @@ void paintCodeEditor (Graphics& g, const ApplicationTheme& theme, const CodeEdit } auto clipState = g.saveState(); - Path textClipPath; - textClipPath.addRectangle (textArea); - setLocalClipPath (g, textClipPath); + g.setClipPath (textArea); // Selection if (editor.hasSelection()) @@ -987,7 +968,7 @@ void paintProgressBar (Graphics& g, const ApplicationTheme& theme, const Progres Path clipPath; clipPath.addRoundedRectangle (progressBar.getLocalBounds(), cornerSize); - setLocalClipPath (g, clipPath); + g.setClipPath (clipPath); // Build two separate paths for alternating solid color shades Path stripesLight; @@ -1024,7 +1005,7 @@ void paintProgressBar (Graphics& g, const ApplicationTheme& theme, const Progres Path clipPath; clipPath.addRoundedRectangle (progressBar.getLocalBounds(), cornerSize); - setLocalClipPath (g, clipPath); + g.setClipPath (clipPath); // Draw the filled bar auto filledBounds = bounds.withWidth (filledWidth); diff --git a/tests/yup_graphics/yup_Graphics.cpp b/tests/yup_graphics/yup_Graphics.cpp index 1748f3dcc..961aacff0 100644 --- a/tests/yup_graphics/yup_Graphics.cpp +++ b/tests/yup_graphics/yup_Graphics.cpp @@ -39,6 +39,15 @@ class GraphicsTest : public ::testing::Test graphics = std::make_unique (*context, *renderer); } + static void expectBoundsNear (const Path& path, const Rectangle& expected) + { + const auto bounds = path.getBounds(); + EXPECT_NEAR (bounds.getX(), expected.getX(), 1.0e-3f); + EXPECT_NEAR (bounds.getY(), expected.getY(), 1.0e-3f); + EXPECT_NEAR (bounds.getWidth(), expected.getWidth(), 1.0e-3f); + EXPECT_NEAR (bounds.getHeight(), expected.getHeight(), 1.0e-3f); + } + std::unique_ptr context; std::unique_ptr renderer; std::unique_ptr graphics; @@ -967,6 +976,69 @@ TEST_F (GraphicsTest, GetClipPath_ReturnsSetPathClip) EXPECT_FALSE (clip.isEmpty()); } +TEST_F (GraphicsTest, GetClipPath_RoundTripsInLocalCoordinates) +{ + graphics->setDrawingArea ({ 30.0f, 40.0f, 100.0f, 100.0f }); + graphics->setTransform (AffineTransform::rotation (0.3f).scaled (1.5f)); + graphics->setClipPath (Rectangle (10.0f, 20.0f, 50.0f, 60.0f)); + + expectBoundsNear (graphics->getClipPath(), { 10.0f, 20.0f, 50.0f, 60.0f }); +} + +TEST_F (GraphicsTest, GetClipPath_RemapsWhenTransformChangesAfterClipping) +{ + graphics->setDrawingArea ({ 30.0f, 40.0f, 100.0f, 100.0f }); + graphics->setClipPath (Rectangle (0.0f, 0.0f, 100.0f, 100.0f)); + graphics->addTransform (AffineTransform::translation (10.0f, 10.0f)); + + expectBoundsNear (graphics->getClipPath(), { -10.0f, -10.0f, 100.0f, 100.0f }); +} + +TEST_F (GraphicsTest, GetClipPath_RemapsWhenDrawingAreaChangesAfterClipping) +{ + graphics->setDrawingArea ({ 0.0f, 0.0f, 200.0f, 200.0f }); + graphics->setClipPath (Rectangle (0.0f, 0.0f, 100.0f, 100.0f)); + graphics->setDrawingArea ({ 20.0f, 30.0f, 200.0f, 200.0f }); + + expectBoundsNear (graphics->getClipPath(), { -20.0f, -30.0f, 100.0f, 100.0f }); +} + +TEST_F (GraphicsTest, GetClipPath_RespectsContextScale) +{ + Graphics scaledGraphics (*context, *renderer, 2.0f); + scaledGraphics.setDrawingArea ({ 10.0f, 10.0f, 100.0f, 100.0f }); + scaledGraphics.setClipPath (Rectangle (0.0f, 0.0f, 100.0f, 100.0f)); + + expectBoundsNear (scaledGraphics.getClipPath(), { 0.0f, 0.0f, 100.0f, 100.0f }); + + scaledGraphics.setDrawingArea ({ 20.0f, 20.0f, 100.0f, 100.0f }); + + expectBoundsNear (scaledGraphics.getClipPath(), { -10.0f, -10.0f, 100.0f, 100.0f }); +} + +TEST_F (GraphicsTest, GetClipPath_RestoredWithSavedState) +{ + graphics->setClipPath (Rectangle (0.0f, 0.0f, 100.0f, 100.0f)); + + { + const auto savedState = graphics->saveState(); + graphics->setDrawingArea ({ 5.0f, 5.0f, 50.0f, 50.0f }); + graphics->setClipPath (Rectangle (10.0f, 10.0f, 20.0f, 20.0f)); + + expectBoundsNear (graphics->getClipPath(), { 10.0f, 10.0f, 20.0f, 20.0f }); + } + + expectBoundsNear (graphics->getClipPath(), { 0.0f, 0.0f, 100.0f, 100.0f }); +} + +TEST_F (GraphicsTest, GetClipPath_SingularTransformReturnsEmpty) +{ + graphics->setClipPath (Rectangle (0.0f, 0.0f, 100.0f, 100.0f)); + graphics->setTransform (AffineTransform::scaling (0.0f, 1.0f)); + + EXPECT_TRUE (graphics->getClipPath().isEmpty()); +} + // ============================================================================= // setStrokeMiterLimit // ============================================================================= diff --git a/tests/yup_gui/yup_Component.cpp b/tests/yup_gui/yup_Component.cpp index 3032416fe..82fcf1d24 100644 --- a/tests/yup_gui/yup_Component.cpp +++ b/tests/yup_gui/yup_Component.cpp @@ -2886,6 +2886,21 @@ class ComponentRepaintRegionTest : public ::testing::Test return *children.back(); } + struct ClipRecordingComponent : Component + { + void paint (Graphics& g) override { clipBounds = g.getClipPath().getBounds(); } + + Rectangle clipBounds; + }; + + static void expectRectNear (const Rectangle& actual, const Rectangle& expected) + { + EXPECT_NEAR (actual.getX(), expected.getX(), 1.0e-3f); + EXPECT_NEAR (actual.getY(), expected.getY(), 1.0e-3f); + EXPECT_NEAR (actual.getWidth(), expected.getWidth(), 1.0e-3f); + EXPECT_NEAR (actual.getHeight(), expected.getHeight(), 1.0e-3f); + } + static RectangleList region (std::initializer_list> rects) { RectangleList result; @@ -3019,6 +3034,43 @@ TEST_F (ComponentRepaintRegionTest, ANonOpaqueChildDoesNotHideTheParent) EXPECT_EQ (1, root->paintCount); } +TEST_F (ComponentRepaintRegionTest, NestedChildSeesClipInLocalCoordinates) +{ + CountingComponent parent; + parent.setBounds (5.0f, 5.0f, 200.0f, 200.0f); + parent.setVisible (true); + root->addChildComponent (parent); + + ClipRecordingComponent child; + child.setBounds (10.0f, 20.0f, 40.0f, 30.0f); + child.setVisible (true); + parent.addChildComponent (child); + + Graphics g (*context, *renderer, 1.0f); + ComponentHelper::triggerPaint (*root, g, root->getLocalBounds(), false); + + expectRectNear (child.clipBounds, { 0.0f, 0.0f, 40.0f, 30.0f }); +} + +TEST_F (ComponentRepaintRegionTest, TransformedChildSeesClipInLocalCoordinates) +{ + CountingComponent parent; + parent.setBounds (5.0f, 5.0f, 200.0f, 200.0f); + parent.setVisible (true); + root->addChildComponent (parent); + + ClipRecordingComponent child; + child.setBounds (10.0f, 20.0f, 40.0f, 30.0f); + child.setTransform (AffineTransform::scaling (2.0f)); + child.setVisible (true); + parent.addChildComponent (child); + + Graphics g (*context, *renderer, 1.0f); + ComponentHelper::triggerPaint (*root, g, root->getLocalBounds(), false); + + expectRectNear (child.clipBounds, { 0.0f, 0.0f, 40.0f, 30.0f }); +} + // ============================================================================= // Notifications and state the platform layer drives // ============================================================================= From 3397f0ff6723b91947eaf8518ef5a56084642da5 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 16:50:13 +0200 Subject: [PATCH 25/37] Reordered demos --- examples/graphics/source/examples/Artboard.h | 20 --------- .../graphics/source/examples/ArtboardLayout.h | 44 ++++++++++++++++++ .../examples/{AudioFileDemo.h => AudioFile.h} | 0 .../examples/{ClipboardDemo.h => Clipboard.h} | 0 .../{Component3DDemo.h => Component3D.h} | 0 ...ponentEffectsDemo.h => ComponentEffects.h} | 0 ...puteParticlesDemo.h => ComputeParticles.h} | 0 .../{ConvolutionDemo.h => Convolution.h} | 0 .../examples/{CrossoverDemo.h => Crossover.h} | 0 .../{DragAndDropDemo.h => DragAndDrop.h} | 0 .../examples/{FilterDemo.h => Filter.h} | 0 ...luidSimulationDemo.h => FluidSimulation.h} | 0 ...oProcessingDemo.h => GpuAudioProcessing.h} | 0 .../examples/{LottieDemo.h => Lottie.h} | 0 ...ffscreenRenderDemo.h => OffscreenRender.h} | 0 .../examples/{OpaqueDemo.h => Opaque.h} | 0 .../{PaintProfilerDemo.h => PaintProfiler.h} | 0 .../source/examples/{PbrDemo.h => Pbr.h} | 0 .../examples/{ScrollBarDemo.h => ScrollBar.h} | 0 .../examples/{SliderDemo.h => Slider.h} | 0 .../{SpinningCubeDemo.h => SpinningCube.h} | 0 ...NotificationDemo.h => ToastNotification.h} | 0 examples/graphics/source/main.cpp | 45 ++++++++++--------- 23 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 examples/graphics/source/examples/ArtboardLayout.h rename examples/graphics/source/examples/{AudioFileDemo.h => AudioFile.h} (100%) rename examples/graphics/source/examples/{ClipboardDemo.h => Clipboard.h} (100%) rename examples/graphics/source/examples/{Component3DDemo.h => Component3D.h} (100%) rename examples/graphics/source/examples/{ComponentEffectsDemo.h => ComponentEffects.h} (100%) rename examples/graphics/source/examples/{ComputeParticlesDemo.h => ComputeParticles.h} (100%) rename examples/graphics/source/examples/{ConvolutionDemo.h => Convolution.h} (100%) rename examples/graphics/source/examples/{CrossoverDemo.h => Crossover.h} (100%) rename examples/graphics/source/examples/{DragAndDropDemo.h => DragAndDrop.h} (100%) rename examples/graphics/source/examples/{FilterDemo.h => Filter.h} (100%) rename examples/graphics/source/examples/{FluidSimulationDemo.h => FluidSimulation.h} (100%) rename examples/graphics/source/examples/{GpuAudioProcessingDemo.h => GpuAudioProcessing.h} (100%) rename examples/graphics/source/examples/{LottieDemo.h => Lottie.h} (100%) rename examples/graphics/source/examples/{OffscreenRenderDemo.h => OffscreenRender.h} (100%) rename examples/graphics/source/examples/{OpaqueDemo.h => Opaque.h} (100%) rename examples/graphics/source/examples/{PaintProfilerDemo.h => PaintProfiler.h} (100%) rename examples/graphics/source/examples/{PbrDemo.h => Pbr.h} (100%) rename examples/graphics/source/examples/{ScrollBarDemo.h => ScrollBar.h} (100%) rename examples/graphics/source/examples/{SliderDemo.h => Slider.h} (100%) rename examples/graphics/source/examples/{SpinningCubeDemo.h => SpinningCube.h} (100%) rename examples/graphics/source/examples/{ToastNotificationDemo.h => ToastNotification.h} (100%) diff --git a/examples/graphics/source/examples/Artboard.h b/examples/graphics/source/examples/Artboard.h index 01c2205a1..f05e4fc58 100644 --- a/examples/graphics/source/examples/Artboard.h +++ b/examples/graphics/source/examples/Artboard.h @@ -514,23 +514,3 @@ class ArtboardDemo : public ArtboardDemoBase bool dropHighlighted = false; }; - -//============================================================================== - -class ArtboardLayoutDemo : public ArtboardDemoBase -{ -public: - ArtboardLayoutDemo() - : ArtboardDemoBase ("data/rive/layout-ui.riv", "keyboard_slot", 8, true) - { - } - -private: - std::unique_ptr createTrackedComponent() override - { - auto keyboardArtboard = std::make_unique ("keyboardArtboard"); - keyboardArtboard->setFile (loadedArtboardFile, "Keyboard"); - keyboardArtboard->setFitting (yup::Fitting::fill); - return keyboardArtboard; - } -}; diff --git a/examples/graphics/source/examples/ArtboardLayout.h b/examples/graphics/source/examples/ArtboardLayout.h new file mode 100644 index 000000000..a6301e10d --- /dev/null +++ b/examples/graphics/source/examples/ArtboardLayout.h @@ -0,0 +1,44 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include "Artboard.h" + +//============================================================================== + +class ArtboardLayoutDemo : public ArtboardDemoBase +{ +public: + ArtboardLayoutDemo() + : ArtboardDemoBase ("data/rive/layout-ui.riv", "keyboard_slot", 8, true) + { + } + +private: + std::unique_ptr createTrackedComponent() override + { + auto keyboardArtboard = std::make_unique ("keyboardArtboard"); + keyboardArtboard->setFile (loadedArtboardFile, "Keyboard"); + keyboardArtboard->setFitting (yup::Fitting::fill); + return keyboardArtboard; + } +}; diff --git a/examples/graphics/source/examples/AudioFileDemo.h b/examples/graphics/source/examples/AudioFile.h similarity index 100% rename from examples/graphics/source/examples/AudioFileDemo.h rename to examples/graphics/source/examples/AudioFile.h diff --git a/examples/graphics/source/examples/ClipboardDemo.h b/examples/graphics/source/examples/Clipboard.h similarity index 100% rename from examples/graphics/source/examples/ClipboardDemo.h rename to examples/graphics/source/examples/Clipboard.h diff --git a/examples/graphics/source/examples/Component3DDemo.h b/examples/graphics/source/examples/Component3D.h similarity index 100% rename from examples/graphics/source/examples/Component3DDemo.h rename to examples/graphics/source/examples/Component3D.h diff --git a/examples/graphics/source/examples/ComponentEffectsDemo.h b/examples/graphics/source/examples/ComponentEffects.h similarity index 100% rename from examples/graphics/source/examples/ComponentEffectsDemo.h rename to examples/graphics/source/examples/ComponentEffects.h diff --git a/examples/graphics/source/examples/ComputeParticlesDemo.h b/examples/graphics/source/examples/ComputeParticles.h similarity index 100% rename from examples/graphics/source/examples/ComputeParticlesDemo.h rename to examples/graphics/source/examples/ComputeParticles.h diff --git a/examples/graphics/source/examples/ConvolutionDemo.h b/examples/graphics/source/examples/Convolution.h similarity index 100% rename from examples/graphics/source/examples/ConvolutionDemo.h rename to examples/graphics/source/examples/Convolution.h diff --git a/examples/graphics/source/examples/CrossoverDemo.h b/examples/graphics/source/examples/Crossover.h similarity index 100% rename from examples/graphics/source/examples/CrossoverDemo.h rename to examples/graphics/source/examples/Crossover.h diff --git a/examples/graphics/source/examples/DragAndDropDemo.h b/examples/graphics/source/examples/DragAndDrop.h similarity index 100% rename from examples/graphics/source/examples/DragAndDropDemo.h rename to examples/graphics/source/examples/DragAndDrop.h diff --git a/examples/graphics/source/examples/FilterDemo.h b/examples/graphics/source/examples/Filter.h similarity index 100% rename from examples/graphics/source/examples/FilterDemo.h rename to examples/graphics/source/examples/Filter.h diff --git a/examples/graphics/source/examples/FluidSimulationDemo.h b/examples/graphics/source/examples/FluidSimulation.h similarity index 100% rename from examples/graphics/source/examples/FluidSimulationDemo.h rename to examples/graphics/source/examples/FluidSimulation.h diff --git a/examples/graphics/source/examples/GpuAudioProcessingDemo.h b/examples/graphics/source/examples/GpuAudioProcessing.h similarity index 100% rename from examples/graphics/source/examples/GpuAudioProcessingDemo.h rename to examples/graphics/source/examples/GpuAudioProcessing.h diff --git a/examples/graphics/source/examples/LottieDemo.h b/examples/graphics/source/examples/Lottie.h similarity index 100% rename from examples/graphics/source/examples/LottieDemo.h rename to examples/graphics/source/examples/Lottie.h diff --git a/examples/graphics/source/examples/OffscreenRenderDemo.h b/examples/graphics/source/examples/OffscreenRender.h similarity index 100% rename from examples/graphics/source/examples/OffscreenRenderDemo.h rename to examples/graphics/source/examples/OffscreenRender.h diff --git a/examples/graphics/source/examples/OpaqueDemo.h b/examples/graphics/source/examples/Opaque.h similarity index 100% rename from examples/graphics/source/examples/OpaqueDemo.h rename to examples/graphics/source/examples/Opaque.h diff --git a/examples/graphics/source/examples/PaintProfilerDemo.h b/examples/graphics/source/examples/PaintProfiler.h similarity index 100% rename from examples/graphics/source/examples/PaintProfilerDemo.h rename to examples/graphics/source/examples/PaintProfiler.h diff --git a/examples/graphics/source/examples/PbrDemo.h b/examples/graphics/source/examples/Pbr.h similarity index 100% rename from examples/graphics/source/examples/PbrDemo.h rename to examples/graphics/source/examples/Pbr.h diff --git a/examples/graphics/source/examples/ScrollBarDemo.h b/examples/graphics/source/examples/ScrollBar.h similarity index 100% rename from examples/graphics/source/examples/ScrollBarDemo.h rename to examples/graphics/source/examples/ScrollBar.h diff --git a/examples/graphics/source/examples/SliderDemo.h b/examples/graphics/source/examples/Slider.h similarity index 100% rename from examples/graphics/source/examples/SliderDemo.h rename to examples/graphics/source/examples/Slider.h diff --git a/examples/graphics/source/examples/SpinningCubeDemo.h b/examples/graphics/source/examples/SpinningCube.h similarity index 100% rename from examples/graphics/source/examples/SpinningCubeDemo.h rename to examples/graphics/source/examples/SpinningCube.h diff --git a/examples/graphics/source/examples/ToastNotificationDemo.h b/examples/graphics/source/examples/ToastNotification.h similarity index 100% rename from examples/graphics/source/examples/ToastNotificationDemo.h rename to examples/graphics/source/examples/ToastNotification.h diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index 9aeea5a72..2a10e4e88 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -70,53 +70,56 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_AI #include "examples/AI.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard || YUP_EXAMPLE_GRAPHICS_DEMO_ArtboardLayout +#if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard #include "examples/Artboard.h" #endif +#if YUP_EXAMPLE_GRAPHICS_DEMO_ArtboardLayout +#include "examples/ArtboardLayout.h" +#endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Audio #include "examples/Audio.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_AudioFile -#include "examples/AudioFileDemo.h" +#include "examples/AudioFile.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Clipboard -#include "examples/ClipboardDemo.h" +#include "examples/Clipboard.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_ColorLab #include "examples/ColorLab.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Component3D -#include "examples/Component3DDemo.h" +#include "examples/Component3D.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_ComponentEffects -#include "examples/ComponentEffectsDemo.h" +#include "examples/ComponentEffects.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_ComputeParticles -#include "examples/ComputeParticlesDemo.h" +#include "examples/ComputeParticles.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Convolution -#include "examples/ConvolutionDemo.h" +#include "examples/Convolution.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Crossover -#include "examples/CrossoverDemo.h" +#include "examples/Crossover.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_CodeEditor #include "examples/CodeEditor.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_DragAndDrop -#include "examples/DragAndDropDemo.h" +#include "examples/DragAndDrop.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_FileChooser #include "examples/FileChooser.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Filter -#include "examples/FilterDemo.h" +#include "examples/Filter.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_FluidSimulation -#include "examples/FluidSimulationDemo.h" +#include "examples/FluidSimulation.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_GpuAudio -#include "examples/GpuAudioProcessingDemo.h" +#include "examples/GpuAudioProcessing.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Images #include "examples/Images.h" @@ -128,37 +131,37 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #include "examples/LayoutFonts.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Lottie -#include "examples/LottieDemo.h" +#include "examples/Lottie.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_OffscreenRender -#include "examples/OffscreenRenderDemo.h" +#include "examples/OffscreenRender.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Opaque -#include "examples/OpaqueDemo.h" +#include "examples/Opaque.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PaintProfiler -#include "examples/PaintProfilerDemo.h" +#include "examples/PaintProfiler.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Paths #include "examples/Paths.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Pbr -#include "examples/PbrDemo.h" +#include "examples/Pbr.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_PopupMenu #include "examples/PopupMenu.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_ScrollBar -#include "examples/ScrollBarDemo.h" +#include "examples/ScrollBar.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Sliders -#include "examples/SliderDemo.h" +#include "examples/Slider.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_SpectrumAnalyzer #include "examples/SpectrumAnalyzer.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_SpinningCube -#include "examples/SpinningCubeDemo.h" +#include "examples/SpinningCube.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Svg #include "examples/Svg.h" @@ -167,7 +170,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #include "examples/TextEditor.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotification -#include "examples/ToastNotificationDemo.h" +#include "examples/ToastNotification.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_TouchTrails #include "examples/TouchTrails.h" From f3e8d459fa1f90b7f3a7304283965280e32c4b17 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 17:17:09 +0200 Subject: [PATCH 26/37] Fix SystemStats --- CHANGELOG.md | 1 + docs/core/system-and-app.md | 4 +++ .../yup_core/native/yup_BasicNativeHeaders.h | 2 ++ .../native/yup_SystemStats_android.cpp | 23 ++++++++---- .../yup_core/native/yup_SystemStats_apple.mm | 33 ++++++++--------- .../yup_core/native/yup_SystemStats_linux.cpp | 16 +++++++-- .../yup_core/native/yup_SystemStats_wasm.cpp | 13 +++++++ .../native/yup_SystemStats_windows.cpp | 12 +++++++ modules/yup_core/system/yup_SystemStats.h | 13 +++++-- .../bindings/yup_YupCore_bindings.cpp | 5 +++ python/pyproject.toml | 2 +- tests/yup_core/yup_SystemStats.cpp | 35 ++++++++++++++++--- 12 files changed, 123 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fab18b31..acfcf8aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased +- **Behavior change** `SystemStats`: `isOperatingSystem64Bit` reports the OS instead of the build (32-bit builds on 64-bit Linux, Android, Windows on ARM; iOS now true; best-effort host probe on WebAssembly). Added `MacOS_15`, `MacOS_26` and `MacOS_27`. The macOS name reads `macOS `, the Android version is `Build.VERSION.RELEASE` and the device description drops the serial, the Linux version is the kernel release, and `WebBrowser` no longer aliases the `MacOSX` bit (compare it with `==`, or test the family with `& WASM`). - **Behavior change** `Graphics::setClipPath`: the clip is now in local coordinates like every draw call, including the drawing area offset, and `getClipPath` returns it in the current local space. Code that mapped the clip by `getTransform().translated (getDrawingArea().getTopLeft())` or by top-level bounds must drop that compensation. Clips on components with an offset parent (for example `Drawable` `` clips) now land where they are drawn. - `SyncSpectralResampler`: the per-harmonic accumulation goes through `FloatVectorOperations` again instead of a hand-written `SIMDRegister` loop, which was many times slower in debug builds. The graphics synthesizer example caps a per-voice synced series at the note's Nyquist harmonic count. - Graphics synthesizer example: PRISM logo and larger buttons in the header, LFO / scope columns aligned with filter / envelopes, and pitch bend and mod wheels beside the keyboard. The mod wheel is a new `MOD WHEEL` source in the modulation matrix. The matrix now has 16 slots. diff --git a/docs/core/system-and-app.md b/docs/core/system-and-app.md index 1e31bfffd..8a010526f 100644 --- a/docs/core/system-and-app.md +++ b/docs/core/system-and-app.md @@ -26,6 +26,10 @@ String yup = SystemStats::getYUPVersion(); String path = SystemStats::getEnvironmentVariable ("PATH", {}); ``` +`isOperatingSystem64Bit()` reports the bitness of the OS, not of the build, so a +32-bit binary on a 64-bit OS returns true. On WebAssembly it is a best-effort guess +about the host. + `getUniqueDeviceID()` returns a stable per-device identifier, and the language/region getters (`getUserLanguage`, `getUserRegion`, `getDisplayLanguage`) support localization. diff --git a/modules/yup_core/native/yup_BasicNativeHeaders.h b/modules/yup_core/native/yup_BasicNativeHeaders.h index 546b6c550..ea6f62219 100644 --- a/modules/yup_core/native/yup_BasicNativeHeaders.h +++ b/modules/yup_core/native/yup_BasicNativeHeaders.h @@ -216,6 +216,7 @@ #include #include #include +#include #include #include #include @@ -253,6 +254,7 @@ #include #include #include +#include #include #include #include diff --git a/modules/yup_core/native/yup_SystemStats_android.cpp b/modules/yup_core/native/yup_SystemStats_android.cpp index 47009a283..b8e706d81 100644 --- a/modules/yup_core/native/yup_SystemStats_android.cpp +++ b/modules/yup_core/native/yup_SystemStats_android.cpp @@ -91,10 +91,10 @@ static String getLocaleValue (bool isRegion) return yupString (LocalRef ((jstring) stringResult)); } -static String getAndroidOsBuildValue (const char* fieldName) +static String getAndroidOsBuildValue (jclass buildClass, const char* fieldName) { return yupString (LocalRef ((jstring) getEnv()->GetStaticObjectField ( - AndroidBuild, getEnv()->GetStaticFieldID (AndroidBuild, fieldName, "Ljava/lang/String;")))); + buildClass, getEnv()->GetStaticFieldID (buildClass, fieldName, "Ljava/lang/String;")))); } } // namespace AndroidStatsHelpers @@ -111,18 +111,17 @@ String SystemStats::getOperatingSystemName() String SystemStats::getOperatingSystemVersionString() { - return AndroidStatsHelpers::getSystemProperty ("os.version"); + return AndroidStatsHelpers::getAndroidOsBuildValue (AndroidBuildVersion, "RELEASE"); } String SystemStats::getDeviceDescription() { - return AndroidStatsHelpers::getAndroidOsBuildValue ("MODEL") - + "-" + AndroidStatsHelpers::getAndroidOsBuildValue ("SERIAL"); + return AndroidStatsHelpers::getAndroidOsBuildValue (AndroidBuild, "MODEL"); } String SystemStats::getDeviceManufacturer() { - return AndroidStatsHelpers::getAndroidOsBuildValue ("MANUFACTURER"); + return AndroidStatsHelpers::getAndroidOsBuildValue (AndroidBuild, "MANUFACTURER"); } bool SystemStats::isOperatingSystem64Bit() @@ -130,7 +129,17 @@ bool SystemStats::isOperatingSystem64Bit() #if YUP_64BIT return true; #else - return false; + // A 64-bit kernel may still run a 32-bit only userland, so ask for the supported ABIs + static const bool result = [] + { + auto* env = getEnv(); + const auto fieldId = env->GetStaticFieldID (AndroidBuild, "SUPPORTED_64_BIT_ABIS", "[Ljava/lang/String;"); + const LocalRef abis ((jobjectArray) env->GetStaticObjectField (AndroidBuild, fieldId)); + + return abis != nullptr && env->GetArrayLength (abis.get()) > 0; + }(); + + return result; #endif } diff --git a/modules/yup_core/native/yup_SystemStats_apple.mm b/modules/yup_core/native/yup_SystemStats_apple.mm index e731c8a1e..f2610247a 100644 --- a/modules/yup_core/native/yup_SystemStats_apple.mm +++ b/modules/yup_core/native/yup_SystemStats_apple.mm @@ -113,19 +113,9 @@ static String getOSXVersion() { YUP_AUTORELEASEPOOL { - const auto* dict = [] - { - const String systemVersionPlist("/System/Library/CoreServices/SystemVersion.plist"); - - if (@available(macOS 10.13, *)) - { - NSError* error = nullptr; - return [NSDictionary dictionaryWithContentsOfURL:createNSURLFromFile(systemVersionPlist) - error:&error]; - } - - return [NSDictionary dictionaryWithContentsOfFile:yupStringToNS(systemVersionPlist)]; - }(); + NSError* error = nullptr; + const auto* dict = [NSDictionary dictionaryWithContentsOfURL:createNSURLFromFile("/System/Library/CoreServices/SystemVersion.plist") + error:&error]; if (dict != nullptr) return nsStringToYup([dict objectForKey:nsStringLiteral("ProductVersion")]); @@ -163,9 +153,18 @@ static String getOSXVersion() return MacOS_13; case 14: return MacOS_14; + case 15: + return MacOS_15; + case 16: // Tahoe as reported to binaries linked against a pre-26 SDK + case 26: + return MacOS_26; + case 27: + return MacOS_27; } - return MacOSX; + // Unknown future release: add it to the enum, but keep ordering checks working meanwhile + jassert(major < 16); + return major >= 16 ? MacOS_27 : MacOSX; #endif } @@ -174,7 +173,7 @@ static String getOSXVersion() #if YUP_IOS return "iOS " + nsStringToYup([[UIDevice currentDevice] systemVersion]); #else - return "Mac OSX " + getOSXVersion(); + return "macOS " + getOSXVersion(); #endif } @@ -219,11 +218,7 @@ static String getOSXVersion() bool SystemStats::isOperatingSystem64Bit() { -#if YUP_IOS - return false; -#else return true; -#endif } int SystemStats::getMemorySizeInMegabytes() diff --git a/modules/yup_core/native/yup_SystemStats_linux.cpp b/modules/yup_core/native/yup_SystemStats_linux.cpp index 11ec6c6ef..c67cdf5d6 100644 --- a/modules/yup_core/native/yup_SystemStats_linux.cpp +++ b/modules/yup_core/native/yup_SystemStats_linux.cpp @@ -78,7 +78,12 @@ String SystemStats::getOperatingSystemName() String SystemStats::getOperatingSystemVersionString() { - return "Unknown"; + struct utsname info; + + if (uname (&info) != 0) + return {}; + + return String::fromUTF8 (info.release); } bool SystemStats::isOperatingSystem64Bit() @@ -86,8 +91,13 @@ bool SystemStats::isOperatingSystem64Bit() #if YUP_64BIT return true; #else - //xxx not sure how to find this out?.. - return false; + struct utsname info; + + if (uname (&info) != 0) + return false; + + const String machine (info.machine); + return machine.contains ("64") || machine == "s390x"; #endif } diff --git a/modules/yup_core/native/yup_SystemStats_wasm.cpp b/modules/yup_core/native/yup_SystemStats_wasm.cpp index d94dbc0fb..7849ab4e3 100644 --- a/modules/yup_core/native/yup_SystemStats_wasm.cpp +++ b/modules/yup_core/native/yup_SystemStats_wasm.cpp @@ -90,6 +90,19 @@ String SystemStats::getOperatingSystemVersionString() bool SystemStats::isOperatingSystem64Bit() { +#if YUP_EMSCRIPTEN + const int hostIs64Bit = EM_ASM_INT ({ + if ((typeof process !== 'undefined') && process.arch) + return /64|s390x/.test (process.arch) ? 1 : 0; + if ((typeof navigator !== 'undefined') && navigator.userAgent) + return /Win64|WOW64|x86_64|x64|amd64|aarch64|arm64|Macintosh|iPhone|iPad/i.test (navigator.userAgent) ? 1 : -1; + return -1; + }); + + if (hostIs64Bit >= 0) + return hostIs64Bit != 0; +#endif + return sizeof (void*) == 8; } diff --git a/modules/yup_core/native/yup_SystemStats_windows.cpp b/modules/yup_core/native/yup_SystemStats_windows.cpp index b22c92430..17ab4c593 100644 --- a/modules/yup_core/native/yup_SystemStats_windows.cpp +++ b/modules/yup_core/native/yup_SystemStats_windows.cpp @@ -377,6 +377,7 @@ bool SystemStats::isOperatingSystem64Bit() return true; #else typedef BOOL (WINAPI * LPFN_ISWOW64PROCESS) (HANDLE, PBOOL); + typedef BOOL (WINAPI * LPFN_ISWOW64PROCESS2) (HANDLE, USHORT*, USHORT*); const auto moduleHandle = GetModuleHandleA ("kernel32"); @@ -386,6 +387,17 @@ bool SystemStats::isOperatingSystem64Bit() return false; } + // IsWow64Process2 (Windows 10 1511+) also detects x86/ARM32 processes on ARM64, + // where IsWow64Process returns FALSE + if (auto fnIsWow64Process2 = (LPFN_ISWOW64PROCESS2) GetProcAddress (moduleHandle, "IsWow64Process2")) + { + USHORT processMachine = IMAGE_FILE_MACHINE_UNKNOWN; + USHORT nativeMachine = IMAGE_FILE_MACHINE_UNKNOWN; + + return fnIsWow64Process2 (GetCurrentProcess(), &processMachine, &nativeMachine) + && processMachine != IMAGE_FILE_MACHINE_UNKNOWN; + } + LPFN_ISWOW64PROCESS fnIsWow64Process = (LPFN_ISWOW64PROCESS) GetProcAddress (moduleHandle, "IsWow64Process"); BOOL isWow64 = FALSE; diff --git a/modules/yup_core/system/yup_SystemStats.h b/modules/yup_core/system/yup_SystemStats.h index 379b5a293..0319e1ebf 100644 --- a/modules/yup_core/system/yup_SystemStats.h +++ b/modules/yup_core/system/yup_SystemStats.h @@ -70,7 +70,7 @@ class YUP_API SystemStats final iOS = 0x1000, WASM = 0x2000, - WebBrowser = WASM | 0x0100, + WebBrowser = WASM | 1, MacOSX_10_7 = MacOSX | 7, MacOSX_10_8 = MacOSX | 8, @@ -85,6 +85,9 @@ class YUP_API SystemStats final MacOS_12 = MacOSX | 17, MacOS_13 = MacOSX | 18, MacOS_14 = MacOSX | 19, + MacOS_15 = MacOSX | 20, + MacOS_26 = MacOSX | 21, + MacOS_27 = MacOSX | 22, Win2000 = Windows | 1, WinXP = Windows | 2, @@ -117,7 +120,13 @@ class YUP_API SystemStats final */ static String getOperatingSystemVersionString(); - /** Returns true if the OS is 64-bit, or false for a 32-bit OS. */ + /** Returns true if the operating system is 64-bit, or false for a 32-bit OS. + + This reports the bitness of the OS, not of the running binary: a 32-bit build + running on a 64-bit OS returns true. On Linux it reports the kernel architecture. + On WebAssembly it is a best-effort guess about the host, falling back to the wasm + memory model when the host can't be inspected. + */ static bool isOperatingSystem64Bit(); //============================================================================== diff --git a/modules/yup_python/bindings/yup_YupCore_bindings.cpp b/modules/yup_python/bindings/yup_YupCore_bindings.cpp index ac13b2537..b08a37862 100644 --- a/modules/yup_python/bindings/yup_YupCore_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupCore_bindings.cpp @@ -2711,6 +2711,7 @@ void registerYupCoreBindings (py::module_& m) .value ("Android", SystemStats::OperatingSystemType::Android) .value ("iOS", SystemStats::OperatingSystemType::iOS) .value ("WASM", SystemStats::OperatingSystemType::WASM) + .value ("WebBrowser", SystemStats::OperatingSystemType::WebBrowser) .value ("MacOSX_10_7", SystemStats::OperatingSystemType::MacOSX_10_7) .value ("MacOSX_10_8", SystemStats::OperatingSystemType::MacOSX_10_8) .value ("MacOSX_10_9", SystemStats::OperatingSystemType::MacOSX_10_9) @@ -2723,6 +2724,10 @@ void registerYupCoreBindings (py::module_& m) .value ("MacOS_11", SystemStats::OperatingSystemType::MacOS_11) .value ("MacOS_12", SystemStats::OperatingSystemType::MacOS_12) .value ("MacOS_13", SystemStats::OperatingSystemType::MacOS_13) + .value ("MacOS_14", SystemStats::OperatingSystemType::MacOS_14) + .value ("MacOS_15", SystemStats::OperatingSystemType::MacOS_15) + .value ("MacOS_26", SystemStats::OperatingSystemType::MacOS_26) + .value ("MacOS_27", SystemStats::OperatingSystemType::MacOS_27) .value ("Win2000", SystemStats::OperatingSystemType::Win2000) .value ("WinXP", SystemStats::OperatingSystemType::WinXP) .value ("WinVista", SystemStats::OperatingSystemType::WinVista) diff --git a/python/pyproject.toml b/python/pyproject.toml index 5a4f00e18..e8d132ef2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,7 +36,7 @@ environment = { "CMAKE_GENERATOR" = "Visual Studio 17 2022", "CMAKE_CXX_STANDARD [tool.cibuildwheel.linux] before-build = [ """dnf install -y zlib-devel openssl-devel freetype-devel fontconfig-devel freeglut-devel alsa-lib-devel mesa-libGL-devel \ - xorg-x11-proto-devel xorg-x11-proto-devel libcurl-devel libdecor-devel libpng-devel libX11-devel libXcursor-devel libXrandr-devel \ + xorg-x11-proto-devel xorg-x11-proto-devel libcurl-devel libpng-devel libX11-devel libXcursor-devel libXrandr-devel \ libXinerama-devel libXrender-devel libXcomposite-devel libXinerama-devel libXcursor-devel xorg-x11-server-Xvfb \ gtk3-devel webkit2gtk3-devel wget""", ] diff --git a/tests/yup_core/yup_SystemStats.cpp b/tests/yup_core/yup_SystemStats.cpp index d58d3ef1f..13ae82c9e 100644 --- a/tests/yup_core/yup_SystemStats.cpp +++ b/tests/yup_core/yup_SystemStats.cpp @@ -46,7 +46,7 @@ TEST (SystemStats, OperatingSystemType) #elif YUP_IOS EXPECT_TRUE (systemType & SystemStats::OperatingSystemType::iOS); #elif YUP_EMSCRIPTEN - EXPECT_TRUE (systemType & SystemStats::OperatingSystemType::WebBrowser); + EXPECT_EQ (systemType, SystemStats::OperatingSystemType::WebBrowser); #elif YUP_WASM EXPECT_TRUE (systemType & SystemStats::OperatingSystemType::WASM); #else @@ -54,19 +54,46 @@ TEST (SystemStats, OperatingSystemType) #endif } +TEST (SystemStatsTests, WebBrowserDoesNotAliasOtherFamilies) +{ + EXPECT_NE (SystemStats::WebBrowser & SystemStats::WASM, 0); + EXPECT_EQ (SystemStats::WebBrowser & SystemStats::MacOSX, 0); +} + TEST (SystemStatsTests, GetOperatingSystemName) { String osName = SystemStats::getOperatingSystemName(); EXPECT_FALSE (osName.isEmpty()); } +#if ! YUP_WASM +TEST (SystemStatsTests, GetOperatingSystemVersionString) +{ + const auto version = SystemStats::getOperatingSystemVersionString(); + EXPECT_TRUE (version.isNotEmpty()); + EXPECT_NE (version, "Unknown"); +} +#endif + +#if YUP_MAC +TEST (SystemStatsTests, MacOSTypeAndNameAreVersioned) +{ + EXPECT_GE (SystemStats::getOperatingSystemType(), SystemStats::MacOS_11); + EXPECT_TRUE (SystemStats::getOperatingSystemName().startsWith ("macOS ")); +} +#endif + TEST (SystemStatsTests, IsOperatingSystem64Bit) { - bool is64Bit = SystemStats::isOperatingSystem64Bit(); + [[maybe_unused]] const bool is64Bit = SystemStats::isOperatingSystem64Bit(); + +#if YUP_MAC || YUP_IOS + EXPECT_TRUE (is64Bit); +#elif ! YUP_WASM + // A 32-bit build may run on either a 32 or a 64-bit OS, only a 64-bit build is conclusive if constexpr (sizeof (void*) == 8) EXPECT_TRUE (is64Bit); - else - EXPECT_FALSE (is64Bit); +#endif } TEST (SystemStatsTests, GetEnvironmentVariable) From 589dc22080aad950a0cc0e193b55ec1694e6fc1f Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 25 Sep 2026 17:26:28 +0200 Subject: [PATCH 27/37] Fix single demos --- examples/graphics/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 12ebfed1e..7e6d3967f 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -95,7 +95,7 @@ if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST shader_bundle_demos) set (need_shader_bundle ON) endif() -set (shader_transpiler_demos "ALL;SpinningCube;GpuAudio;ComputeParticles") +set (shader_transpiler_demos "ALL;Audio;Component3D;SpinningCube;GpuAudio;ComputeParticles") if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST shader_transpiler_demos) set (need_shader_transpiler ON) endif() From 04fe0a6c65ab49b19eecc3fd0a10d04d01220e91 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 17:01:53 +0200 Subject: [PATCH 28/37] Reworked shading --- CHANGELOG.md | 2 + cmake/yup_dsp_compiler.cmake | 4 +- cmake/yup_shader_bundler.cmake | 110 +++- cmake/yup_utilities.cmake | 10 +- docs/_static/images/yup_3d_components.png | Bin 0 -> 143033 bytes docs/_static/images/yup_prism_synth.jpg | Bin 0 -> 252178 bytes docs/graphics/rhi/offline-shaders.md | 43 +- docs/ui/component-effects.md | 3 +- examples/graphics/CMakeLists.txt | 248 ++++---- .../graphics/data/shaders/component3d.frag | 10 + .../graphics/data/shaders/component3d.vert | 13 + .../graphics/data/shaders/effect_blur.frag | 23 + .../graphics/data/shaders/effect_crt.frag | 19 + .../graphics/data/shaders/effect_edge.frag | 22 + .../data/shaders/effect_pixelate.frag | 13 + .../graphics/data/shaders/effect_sharpen.frag | 21 + .../graphics/data/shaders/effect_wave.frag | 15 + .../data/shaders/fluid_advect_dye.frag | 44 ++ .../data/shaders/fluid_advect_velocity.frag | 41 ++ .../data/shaders/fluid_bloom_prefilter.frag | 26 + .../graphics/data/shaders/fluid_blur4.frag | 28 + .../graphics/data/shaders/fluid_clear.frag | 14 + .../graphics/data/shaders/fluid_curl.frag | 30 + .../graphics/data/shaders/fluid_display.frag | 76 +++ .../data/shaders/fluid_encode_scalar.glsl | 14 + .../data/shaders/fluid_encode_vel.glsl | 14 + .../data/shaders/fluid_fullscreen.vert | 12 + .../data/shaders/fluid_gradient_subtract.frag | 33 ++ .../graphics/data/shaders/fluid_pressure.frag | 60 ++ .../data/shaders/fluid_splat_dye.frag | 28 + .../data/shaders/fluid_splat_velocity.frag | 30 + examples/graphics/data/shaders/fluid_suv.glsl | 4 + .../data/shaders/fluid_vorticity.frag | 41 ++ .../graphics/data/shaders/fullscreen.vert | 8 + .../graphics/data/shaders/particles_draw.frag | 20 + .../graphics/data/shaders/particles_draw.vert | 16 + .../data/shaders/particles_update.comp | 116 ++++ .../graphics/data/shaders/pbr_background.frag | 32 + examples/graphics/data/shaders/pbr_bake.glsl | 73 +++ examples/graphics/data/shaders/pbr_brdf.frag | 79 +++ .../graphics/data/shaders/pbr_fullscreen.vert | 10 + .../graphics/data/shaders/pbr_irradiance.frag | 29 + .../graphics/data/shaders/pbr_prefilter.frag | 30 + examples/graphics/data/shaders/pbr_scene.frag | 134 +++++ examples/graphics/data/shaders/pbr_scene.vert | 89 +++ examples/graphics/data/shaders/pbr_sky.frag | 8 + .../graphics/data/shaders/synth_waveform.frag | 73 +++ .../graphics/source/examples/Component3D.h | 26 +- .../source/examples/ComponentEffects.h | 163 +---- .../source/examples/ComputeParticles.h | 196 +----- .../source/examples/FluidSimulation.h | 559 +----------------- examples/graphics/source/examples/Pbr.h | 520 +--------------- .../source/examples/audio/SynthPanels.h | 88 +-- examples/graphics/source/main.cpp | 39 +- .../yup_rhi/rhi/yup_GpuComputePipeline.cpp | 2 +- tests/yup_rhi/yup_GpuDevice.cpp | 37 ++ thirdparty/opus_library/opus_library.c | 5 + website/src/index.html | 30 +- 58 files changed, 1772 insertions(+), 1661 deletions(-) create mode 100644 docs/_static/images/yup_3d_components.png create mode 100644 docs/_static/images/yup_prism_synth.jpg create mode 100644 examples/graphics/data/shaders/component3d.frag create mode 100644 examples/graphics/data/shaders/component3d.vert create mode 100644 examples/graphics/data/shaders/effect_blur.frag create mode 100644 examples/graphics/data/shaders/effect_crt.frag create mode 100644 examples/graphics/data/shaders/effect_edge.frag create mode 100644 examples/graphics/data/shaders/effect_pixelate.frag create mode 100644 examples/graphics/data/shaders/effect_sharpen.frag create mode 100644 examples/graphics/data/shaders/effect_wave.frag create mode 100644 examples/graphics/data/shaders/fluid_advect_dye.frag create mode 100644 examples/graphics/data/shaders/fluid_advect_velocity.frag create mode 100644 examples/graphics/data/shaders/fluid_bloom_prefilter.frag create mode 100644 examples/graphics/data/shaders/fluid_blur4.frag create mode 100644 examples/graphics/data/shaders/fluid_clear.frag create mode 100644 examples/graphics/data/shaders/fluid_curl.frag create mode 100644 examples/graphics/data/shaders/fluid_display.frag create mode 100644 examples/graphics/data/shaders/fluid_encode_scalar.glsl create mode 100644 examples/graphics/data/shaders/fluid_encode_vel.glsl create mode 100644 examples/graphics/data/shaders/fluid_fullscreen.vert create mode 100644 examples/graphics/data/shaders/fluid_gradient_subtract.frag create mode 100644 examples/graphics/data/shaders/fluid_pressure.frag create mode 100644 examples/graphics/data/shaders/fluid_splat_dye.frag create mode 100644 examples/graphics/data/shaders/fluid_splat_velocity.frag create mode 100644 examples/graphics/data/shaders/fluid_suv.glsl create mode 100644 examples/graphics/data/shaders/fluid_vorticity.frag create mode 100644 examples/graphics/data/shaders/fullscreen.vert create mode 100644 examples/graphics/data/shaders/particles_draw.frag create mode 100644 examples/graphics/data/shaders/particles_draw.vert create mode 100644 examples/graphics/data/shaders/particles_update.comp create mode 100644 examples/graphics/data/shaders/pbr_background.frag create mode 100644 examples/graphics/data/shaders/pbr_bake.glsl create mode 100644 examples/graphics/data/shaders/pbr_brdf.frag create mode 100644 examples/graphics/data/shaders/pbr_fullscreen.vert create mode 100644 examples/graphics/data/shaders/pbr_irradiance.frag create mode 100644 examples/graphics/data/shaders/pbr_prefilter.frag create mode 100644 examples/graphics/data/shaders/pbr_scene.frag create mode 100644 examples/graphics/data/shaders/pbr_scene.vert create mode 100644 examples/graphics/data/shaders/pbr_sky.frag create mode 100644 examples/graphics/data/shaders/synth_waveform.frag diff --git a/CHANGELOG.md b/CHANGELOG.md index acfcf8aca..5843ebf36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased +- `GpuComputePipeline::compileFromBundle` resolves the `main0` kernel SPIRV-Cross emits for a GLSL `main` on Metal, like `GpuPipeline::compileFromBundle` already did, instead of failing with "Metal compute function not found: main". +- CMake: `yup_add_shader_bundle` accepts a `COMPUTE` stage, and with `BUNDLE_RESOURCE` ships the `.ysl` through `BUNDLE_RESOURCES` instead of embedding it. Bundles are only regenerated when the tool, the arguments or an input (stages and the new `DEPENDS`) changed. The graphics example declares the modules, data and precompiled shaders of each demo, so a single-demo build (`YUP_EXAMPLE_GRAPHICS_DEMO`) only links what that demo uses. Only the SpinningCube and GpuAudio demos, which edit shaders live, still need the shader transpiler. - **Behavior change** `SystemStats`: `isOperatingSystem64Bit` reports the OS instead of the build (32-bit builds on 64-bit Linux, Android, Windows on ARM; iOS now true; best-effort host probe on WebAssembly). Added `MacOS_15`, `MacOS_26` and `MacOS_27`. The macOS name reads `macOS `, the Android version is `Build.VERSION.RELEASE` and the device description drops the serial, the Linux version is the kernel release, and `WebBrowser` no longer aliases the `MacOSX` bit (compare it with `==`, or test the family with `& WASM`). - **Behavior change** `Graphics::setClipPath`: the clip is now in local coordinates like every draw call, including the drawing area offset, and `getClipPath` returns it in the current local space. Code that mapped the clip by `getTransform().translated (getDrawingArea().getTopLeft())` or by top-level bounds must drop that compensation. Clips on components with an offset parent (for example `Drawable` `` clips) now land where they are drawn. - `SyncSpectralResampler`: the per-harmonic accumulation goes through `FloatVectorOperations` again instead of a hand-written `SIMDRegister` loop, which was many times slower in debug builds. The graphics synthesizer example caps a per-voice synced series at the note's Nyquist harmonic count. diff --git a/cmake/yup_dsp_compiler.cmake b/cmake/yup_dsp_compiler.cmake index 4ebbc24d7..cc75fb9f9 100644 --- a/cmake/yup_dsp_compiler.cmake +++ b/cmake/yup_dsp_compiler.cmake @@ -49,7 +49,7 @@ function (yup_add_ydsp_bundle library_name) set (multi_value_args TARGETS OPTIONS) cmake_parse_arguments (YUP_ARG "" "${one_value_args}" "${multi_value_args}" ${ARGN}) if (NOT YUP_ARG_SOURCE) - message (FATAL_ERROR "yup_add_ydsp_bundle: SOURCE argument is required") + _yup_message (FATAL_ERROR "yup_add_ydsp_bundle: SOURCE argument is required") endif() _yup_set_default (YUP_ARG_OUTPUT_NAME "${library_name}") @@ -60,7 +60,7 @@ function (yup_add_ydsp_bundle library_name) set (bundle_path "${CMAKE_CURRENT_BINARY_DIR}/${YUP_ARG_OUTPUT_NAME}.ydsb") if (NOT EXISTS "${source_path}") - message (FATAL_ERROR "yup_add_ydsp_bundle: source file not found: ${source_path}") + _yup_message (FATAL_ERROR "yup_add_ydsp_bundle: source file not found: ${source_path}") endif() set (target_arguments) foreach (target IN LISTS YUP_ARG_TARGETS) diff --git a/cmake/yup_shader_bundler.cmake b/cmake/yup_shader_bundler.cmake index bf5d2f149..6d8f14af2 100644 --- a/cmake/yup_shader_bundler.cmake +++ b/cmake/yup_shader_bundler.cmake @@ -65,30 +65,38 @@ function (_yup_build_shader_bundler_tool output_variable) list (LENGTH candidate_exes num_candidates) if (num_candidates EQUAL 0) - message (FATAL_ERROR "Failed to locate built shader bundler tool in ${tool_build_dir}") + _yup_message (FATAL_ERROR "Failed to locate built shader bundler tool in ${tool_build_dir}") endif() list (GET candidate_exes 0 tool_exe) + file (SHA256 "${tool_exe}" tool_hash) set_property (GLOBAL PROPERTY YUP_SHADER_BUNDLER_EXECUTABLE "${tool_exe}") + set_property (GLOBAL PROPERTY YUP_SHADER_BUNDLER_SHA256 "${tool_hash}") _yup_message (STATUS " * shader bundler executable: ${tool_exe}") set (${output_variable} "${tool_exe}" PARENT_SCOPE) endfunction() #============================================================================== -# Compiles a vertex/fragment GLSL shader pair into a .ysl bundle (at configure -# time) and embeds it into an OBJECT library that can be linked into a target. +# Compiles a vertex/fragment GLSL shader pair, or a compute shader, into a .ysl +# bundle (at configure time) and embeds it into an OBJECT library that can be +# linked into a target. The bundle is only regenerated when the tool, the +# arguments or the content of a stage or DEPENDS file changed. # # Usage: # yup_add_shader_bundle ( # VERT # FRAG +# | COMPUTE # [OUTPUT_NAME ] # default: # [RESOURCE_NAME ] # default: # [NAMESPACE ] # default: yup # [ENTRY ] # default: main # [GLSL_VERSION ] # default: 450 +# [BUNDLE_RESOURCE ] # don't embed, see below +# [BUNDLE_DESTINATION ] # default: .ysl +# [DEPENDS ...] # extra inputs, e.g. #included files # [OPTIONS ...]) # extra flags forwarded to yup_shader_bundler # # OPTIONS forwards any additional yup_shader_bundler flags verbatim, e.g. @@ -99,19 +107,24 @@ endfunction() # extern const uint8_t _data[]; # extern const size_t _size; # The bytes can be loaded at runtime with ShaderBundle::loadFromData(). +# +# With BUNDLE_RESOURCE no library is created: the .ysl is left in +# CMAKE_CURRENT_BINARY_DIR and is set to "@", +# ready to be passed to the BUNDLE_RESOURCES of yup_standalone_app. Load it at +# runtime with ShaderBundle::loadFromFile(). function (yup_add_shader_bundle library_name) set (options "") - set (one_value_args VERT FRAG OUTPUT_NAME RESOURCE_NAME NAMESPACE ENTRY GLSL_VERSION) - set (multi_value_args OPTIONS) + set (one_value_args VERT FRAG COMPUTE OUTPUT_NAME RESOURCE_NAME NAMESPACE ENTRY GLSL_VERSION BUNDLE_RESOURCE BUNDLE_DESTINATION) + set (multi_value_args OPTIONS DEPENDS) cmake_parse_arguments (YUP_ARG "${options}" "${one_value_args}" "${multi_value_args}" ${ARGN}) - if (NOT YUP_ARG_VERT) - message (FATAL_ERROR "yup_add_shader_bundle: VERT argument is required") + if (YUP_ARG_COMPUTE AND (YUP_ARG_VERT OR YUP_ARG_FRAG)) + _yup_message (FATAL_ERROR "yup_add_shader_bundle: COMPUTE can't be combined with VERT or FRAG") endif() - if (NOT YUP_ARG_FRAG) - message (FATAL_ERROR "yup_add_shader_bundle: FRAG argument is required") + if (NOT YUP_ARG_COMPUTE AND NOT (YUP_ARG_VERT AND YUP_ARG_FRAG)) + _yup_message (FATAL_ERROR "yup_add_shader_bundle: either VERT and FRAG, or COMPUTE, are required") endif() _yup_set_default (YUP_ARG_OUTPUT_NAME "${library_name}") @@ -119,33 +132,82 @@ function (yup_add_shader_bundle library_name) _yup_set_default (YUP_ARG_NAMESPACE "yup") _yup_set_default (YUP_ARG_ENTRY "main") _yup_set_default (YUP_ARG_GLSL_VERSION "450") - - get_filename_component (vert_path "${YUP_ARG_VERT}" ABSOLUTE) - get_filename_component (frag_path "${YUP_ARG_FRAG}" ABSOLUTE) - - if (NOT EXISTS "${vert_path}") - message (FATAL_ERROR "yup_add_shader_bundle: vertex shader not found: ${vert_path}") - endif() - if (NOT EXISTS "${frag_path}") - message (FATAL_ERROR "yup_add_shader_bundle: fragment shader not found: ${frag_path}") - endif() + _yup_set_default (YUP_ARG_BUNDLE_DESTINATION "${YUP_ARG_OUTPUT_NAME}.ysl") + + set (stage_args "") + set (stage_paths "") + set (stage_arg_names VERT FRAG COMPUTE) + set (stage_names vertex fragment compute) + foreach (stage_arg stage IN ZIP_LISTS stage_arg_names stage_names) + if (NOT YUP_ARG_${stage_arg}) + continue() + endif() + + get_filename_component (stage_path "${YUP_ARG_${stage_arg}}" ABSOLUTE) + if (NOT EXISTS "${stage_path}") + _yup_message (FATAL_ERROR "yup_add_shader_bundle: ${stage} shader not found: ${stage_path}") + endif() + + list (APPEND stage_args --stage ${stage} "${stage_path}") + list (APPEND stage_paths "${stage_path}") + endforeach() + + set (depend_paths "") + foreach (depend IN LISTS YUP_ARG_DEPENDS) + get_filename_component (depend_path "${depend}" ABSOLUTE) + if (NOT EXISTS "${depend_path}") + _yup_message (FATAL_ERROR "yup_add_shader_bundle: dependency not found: ${depend_path}") + endif() + + list (APPEND depend_paths "${depend_path}") + endforeach() + + # ==== Re-run the configure step when an input changes + set_property (DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${stage_paths} ${depend_paths}) # ==== Ensure the host tool is available (built and cached once) _yup_build_shader_bundler_tool (shader_bundler_exe) - # ==== Generate the .ysl bundle at configure time + # ==== Generate the .ysl bundle at configure time, unless tool, arguments and inputs are unchanged set (bundle_path "${CMAKE_CURRENT_BINARY_DIR}/${YUP_ARG_OUTPUT_NAME}.ysl") + set (bundle_key_path "${bundle_path}.sha256") - _yup_message (STATUS "Generating shader bundle ${bundle_path}") - _yup_execute_process_or_fail ( + set (bundler_command "${shader_bundler_exe}" - --vert "${vert_path}" - --frag "${frag_path}" + ${stage_args} --output "${bundle_path}" --entry "${YUP_ARG_ENTRY}" --glsl-version "${YUP_ARG_GLSL_VERSION}" ${YUP_ARG_OPTIONS}) + get_property (bundle_key GLOBAL PROPERTY YUP_SHADER_BUNDLER_SHA256) + string (APPEND bundle_key "${bundler_command}") + foreach (input_path IN LISTS stage_paths depend_paths) + file (SHA256 "${input_path}" input_hash) + string (APPEND bundle_key "${input_path}=${input_hash}") + endforeach() + string (SHA256 bundle_key "${bundle_key}") + + set (previous_bundle_key "") + if (EXISTS "${bundle_path}" AND EXISTS "${bundle_key_path}") + file (READ "${bundle_key_path}" previous_bundle_key) + endif() + + if (bundle_key STREQUAL previous_bundle_key) + _yup_message (STATUS "Shader bundle ${bundle_path} is up to date") + else() + _yup_message (STATUS "Generating shader bundle ${bundle_path}") + file (REMOVE "${bundle_key_path}") + _yup_execute_process_or_fail (${bundler_command}) + file (WRITE "${bundle_key_path}" "${bundle_key}") + endif() + + # ==== Hand the bundle over to BUNDLE_RESOURCES instead of embedding it + if (YUP_ARG_BUNDLE_RESOURCE) + set (${YUP_ARG_BUNDLE_RESOURCE} "${bundle_path}@${YUP_ARG_BUNDLE_DESTINATION}" PARENT_SCOPE) + return() + endif() + # ==== Embed the generated bundle into an object library yup_add_embedded_binary_resources ( ${library_name} diff --git a/cmake/yup_utilities.cmake b/cmake/yup_utilities.cmake index 81e217244..d82dfdebd 100644 --- a/cmake/yup_utilities.cmake +++ b/cmake/yup_utilities.cmake @@ -298,7 +298,7 @@ endfunction() function (_yup_merge_plist original_plist subset_xml_string output_plist) if (NOT EXISTS "${original_plist}") - message (FATAL_ERROR "Original plist file does not exist: ${original_plist}") + _yup_message (FATAL_ERROR "Original plist file does not exist: ${original_plist}") endif() file (COPY "${original_plist}" DESTINATION "${output_plist}") @@ -312,7 +312,7 @@ function (_yup_merge_plist original_plist subset_xml_string output_plist) ERROR_VARIABLE error_message) if (NOT result EQUAL 0) - message (FATAL_ERROR "Failed to merge plist: ${error_message}") + _yup_message (FATAL_ERROR "Failed to merge plist: ${error_message}") endif() file (REMOVE "${temp_plist}") @@ -329,7 +329,7 @@ function (_yup_execute_process_or_fail) if (NOT result EQUAL 0) _yup_join_list_with_separator ("${ARGN}" " " "" "" command_string) - message (FATAL_ERROR "Failed to execute command '${command_string}': ${error_message}") + _yup_message (FATAL_ERROR "Failed to execute command '${command_string}': ${error_message}") endif() endfunction() @@ -342,7 +342,7 @@ function (_yup_download_file url file_path expected_sha256) foreach (attempt RANGE 1 ${max_attempts}) if (attempt GREATER 1) math (EXPR retry_delay "(${attempt} - 1) * 5") - message (STATUS "Download of ${url} failed (${error_message}), retrying in ${retry_delay}s (attempt ${attempt}/${max_attempts})") + _yup_message (STATUS "Download of ${url} failed (${error_message}), retrying in ${retry_delay}s (attempt ${attempt}/${max_attempts})") execute_process (COMMAND "${CMAKE_COMMAND}" -E sleep ${retry_delay}) endif() @@ -366,7 +366,7 @@ function (_yup_download_file url file_path expected_sha256) file (REMOVE "${file_path}") endforeach() - message (FATAL_ERROR "Failed to download ${url} after ${max_attempts} attempts: ${error_message}") + _yup_message (FATAL_ERROR "Failed to download ${url} after ${max_attempts} attempts: ${error_message}") endfunction() #============================================================================== diff --git a/docs/_static/images/yup_3d_components.png b/docs/_static/images/yup_3d_components.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca025bf20a60f42c8641ba7fd5495524db8bacc GIT binary patch literal 143033 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%0AUQh2ewH0@K_~qT>8Fw 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%0Duqct-k}Mdk{`UQ%&Q#K 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

@c<-awBp%$rQnhP%j+i@ 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 SsN+Y#Kj|9^60fft-Ti-QYzGwp literal 0 HcmV?d00001 diff --git a/docs/_static/images/yup_prism_synth.jpg b/docs/_static/images/yup_prism_synth.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4cddf59cd80c44ef528ea02a3ccb60719f89bd1 GIT binary patch literal 252178 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%0Ado83UrA{zA@cN<>e|V(a5~*9BVZM&E9N? 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ÿOZvqEU)B({^;sgkp{NZW^Dw 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�QONvos{4qdPJ>FLuc|4dHmSjO{38c9dVWR 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 z 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 qnwQFd^XFgh_b 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

dAG*~D8DHWx13A}h)ww%l4ZSUHnP&8 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 Bh@t=h literal 0 HcmV?d00001 diff --git a/docs/graphics/rhi/offline-shaders.md b/docs/graphics/rhi/offline-shaders.md index dd42694ac..5e79bff4c 100644 --- a/docs/graphics/rhi/offline-shaders.md +++ b/docs/graphics/rhi/offline-shaders.md @@ -32,26 +32,43 @@ outer build is cross-compiling (Android, iOS, WebAssembly). yup_add_shader_bundle( VERT FRAG + | COMPUTE [OUTPUT_NAME ] # default: [RESOURCE_NAME ] # default: [NAMESPACE ] # default: yup [ENTRY ] # default: main [GLSL_VERSION ] # default: 450 + [BUNDLE_RESOURCE ] # ship the .ysl as a file instead of embedding it + [BUNDLE_DESTINATION ] # default: .ysl + [DEPENDS ...] # extra inputs, e.g. #included files [OPTIONS ...]) # extra flags forwarded to yup_shader_bundler ``` | Argument | Default | Description | |---|---|---| | `` | *(required)* | Name of the generated `OBJECT` library (first positional argument). | -| `VERT` | *(required)* | Path to the vertex shader source. | -| `FRAG` | *(required)* | Path to the fragment shader source. | +| `VERT` | *(required without `COMPUTE`)* | Path to the vertex shader source. | +| `FRAG` | *(required without `COMPUTE`)* | Path to the fragment shader source. | +| `COMPUTE` | - | Path to a compute shader source, for a compute-only bundle. | | `OUTPUT_NAME` | `` | Base name for the `.ysl` bundle and generated header. | | `RESOURCE_NAME` | `` | Symbol base name of the embedded byte array. | | `NAMESPACE` | `yup` | C++ namespace wrapping the generated symbols. | | `ENTRY` | `main` | Shader entry-point name. | | `GLSL_VERSION` | `450` | GLSL version passed to the compiler. | +| `BUNDLE_RESOURCE` | - | Don't embed: set this variable to `@` for `BUNDLE_RESOURCES` (see [below](#shipping-the-bundle-as-a-file)). | +| `BUNDLE_DESTINATION` | `.ysl` | Path of the bundle inside the application bundle. | +| `DEPENDS` | - | Extra input files, such as the ones pulled in with `#include`. | | `OPTIONS` | - | Extra flags forwarded verbatim to `yup_shader_bundler` (see [below](#the-yup_shader_bundler-tool)). | +Editing a stage or `DEPENDS` file re-runs the configure step. The bundle is only +regenerated when the content of those files, the arguments or the +`yup_shader_bundler` binary changed: a key of all three is stored next to the +`.ysl` as `.ysl.sha256`. + +Stages can share code with `#include "file.glsl"`: enable +`#extension GL_GOOGLE_include_directive : require` after `#version`, pass the +include directory with `OPTIONS -I

` and list the included files in `DEPENDS`. + ### Example ```cmake @@ -78,6 +95,28 @@ extern const uint8_t ShaderBundleFile_data[]; extern const std::size_t ShaderBundleFile_size; ``` +### Shipping the bundle as a file + +Embedding keeps every bundle in the binary. With `BUNDLE_RESOURCE` no library is +created: the `.ysl` stays in `CMAKE_CURRENT_BINARY_DIR`, and the variable receives +a `source@destination` pair ready for the `BUNDLE_RESOURCES` of +`yup_standalone_app`: + +```cmake +yup_add_shader_bundle(particles + COMPUTE ${CMAKE_CURRENT_LIST_DIR}/shaders/particles.comp + BUNDLE_RESOURCE particles_resource + BUNDLE_DESTINATION data/shaders/particles.ysl) + +yup_standalone_app( + # ... + BUNDLE_RESOURCES + ${particles_resource}) +``` + +Load it with `ShaderBundle::loadFromFile`. Bundled resources aren't copied on +Windows and Linux, so there read the `.ysl` from the build tree. + ## Loading and compiling at runtime Deserialize the embedded bytes into a `ShaderBundle`, then compile a pipeline diff --git a/docs/ui/component-effects.md b/docs/ui/component-effects.md index b27be6b6d..74dea970a 100644 --- a/docs/ui/component-effects.md +++ b/docs/ui/component-effects.md @@ -130,7 +130,8 @@ myComponent.setComponentEffect (nullptr); The [graphics example](../../examples/graphics/) demonstrates several reusable effect patterns implemented as `ComponentEffect` subclasses. Each lives in -`examples/graphics/source/examples/ComponentEffectsDemo.h`: +`examples/graphics/source/examples/ComponentEffects.h`, with its fragment shader +precompiled from `examples/graphics/data/shaders/effect_*.frag`: | Effect | Shader | Parameter | |---|---|---| diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 7e6d3967f..03f89c79e 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -25,79 +25,118 @@ set (target_version "2.0.0") project (${target_name} VERSION ${target_version}) +# ==== Declare the demos and what each one brings on top of the base modules: +# MODULES extra modules to link (their dependencies are resolved automatically) +# RESOURCES folders of data/ to bundle on mobile and WebAssembly +# SHADERS bundles precompiled from data/shaders: .comp, or .vert + .frag; +# : takes the vertex stage from .vert instead, and +# data/shaders/*.glsl can be #included +# LIVE_SHADERS links the GLSL transpiler, for demos compiling shaders at runtime +set (all_demo_ids "") +macro (graphics_demo demo_id) + cmake_parse_arguments (DEMO "LIVE_SHADERS" "" "MODULES;RESOURCES;SHADERS" ${ARGN}) + list (APPEND all_demo_ids ${demo_id}) + set (demo_${demo_id}_modules ${DEMO_MODULES}) + set (demo_${demo_id}_resources ${DEMO_RESOURCES}) + set (demo_${demo_id}_shaders ${DEMO_SHADERS}) + set (demo_${demo_id}_live_shaders ${DEMO_LIVE_SHADERS}) +endmacro() + +set (base_modules yup::yup_core yup::yup_events yup::yup_graphics yup::yup_gui libpng) +set (audio_modules yup::yup_audio_gui pffft_library) + +graphics_demo (AI MODULES yup::yup_ai) +graphics_demo (Artboard RESOURCES rive) +graphics_demo (ArtboardLayout RESOURCES rive) +graphics_demo (Audio MODULES ${audio_modules} SHADERS synth_waveform:fullscreen) +graphics_demo (AudioFile MODULES ${audio_modules} bungee_library dr_libs flac_library hmp3_library libvorbis opus_library) +graphics_demo (Clipboard) +graphics_demo (ColorLab) +graphics_demo (Component3D SHADERS component3d) +graphics_demo (ComponentEffects SHADERS + effect_blur:fullscreen effect_crt:fullscreen effect_edge:fullscreen + effect_pixelate:fullscreen effect_sharpen:fullscreen effect_wave:fullscreen) +graphics_demo (ComputeParticles SHADERS particles_update particles_draw) +graphics_demo (Convolution MODULES ${audio_modules} dr_libs flac_library RESOURCES audio) +graphics_demo (Crossover MODULES ${audio_modules} dr_libs RESOURCES audio) +graphics_demo (CodeEditor) +graphics_demo (DragAndDrop) +graphics_demo (FileChooser) +graphics_demo (Filter MODULES ${audio_modules}) +graphics_demo (FluidSimulation SHADERS + fluid_advect_dye:fluid_fullscreen fluid_advect_velocity:fluid_fullscreen fluid_bloom_prefilter:fluid_fullscreen + fluid_blur4:fluid_fullscreen fluid_clear:fluid_fullscreen fluid_curl:fluid_fullscreen + fluid_display:fluid_fullscreen fluid_gradient_subtract:fluid_fullscreen fluid_pressure:fluid_fullscreen + fluid_splat_dye:fluid_fullscreen fluid_splat_velocity:fluid_fullscreen fluid_vorticity:fluid_fullscreen) +graphics_demo (GpuAudio MODULES ${audio_modules} dr_libs RESOURCES audio LIVE_SHADERS) +graphics_demo (Images MODULES libgif libjpeg libtiff libwebp) +graphics_demo (Layout) +graphics_demo (LayoutFonts) +graphics_demo (Lottie MODULES yup::yup_animation RESOURCES lottie) +graphics_demo (OffscreenRender) +graphics_demo (Opaque) +graphics_demo (PaintProfiler) +graphics_demo (Paths) +graphics_demo (Pbr SHADERS + pbr_background:pbr_scene pbr_brdf:pbr_fullscreen pbr_irradiance:pbr_fullscreen + pbr_prefilter:pbr_fullscreen pbr_scene pbr_sky:pbr_fullscreen) +graphics_demo (PopupMenu) +graphics_demo (ScrollBar) +graphics_demo (Sliders) +graphics_demo (SpectrumAnalyzer MODULES ${audio_modules} dr_libs RESOURCES audio) +graphics_demo (SpinningCube MODULES yup::yup_animation RESOURCES lottie LIVE_SHADERS) +graphics_demo (Svg RESOURCES svg) +graphics_demo (TextEditor) +graphics_demo (ToastNotification) +graphics_demo (TouchTrails) +graphics_demo (VariableFonts) +graphics_demo (Widgets) +if (YUP_PLATFORM_DESKTOP OR YUP_PLATFORM_EMSCRIPTEN) + graphics_demo (YdspSynths MODULES yup::yup_dsp_jit ${audio_modules} dr_libs RESOURCES audio synths) +endif() +if (YUP_PLATFORM_DESKTOP AND NOT YUP_PLATFORM_WINDOWS AND NOT YUP_PLATFORM_EMSCRIPTEN) + graphics_demo (Python MODULES yup::yup_python) +endif() + # ==== Select which demo(s) to build # Set to "ALL" (default) to build the full demo browser with every example. Set to a single demo: # cmake -DYUP_EXAMPLE_GRAPHICS_DEMO=SpinningCube ... -set (all_demo_ids - AI Artboard ArtboardLayout Audio AudioFile Clipboard ColorLab Component3D ComponentEffects ComputeParticles - Convolution Crossover CodeEditor DragAndDrop FileChooser Filter FluidSimulation GpuAudio Images - Layout LayoutFonts Lottie OffscreenRender Opaque PaintProfiler Paths Pbr PopupMenu ScrollBar Sliders - SpectrumAnalyzer SpinningCube Svg TextEditor ToastNotification TouchTrails VariableFonts - Widgets YdspSynths Python) - set (YUP_EXAMPLE_GRAPHICS_DEMO "ALL" CACHE STRING "Graphics demo to build: ALL, or one of ${all_demo_ids}") -if (NOT YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "ALL" AND NOT YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST all_demo_ids) - message (FATAL_ERROR "YUP_EXAMPLE_GRAPHICS_DEMO must be ALL or one of: ${all_demo_ids}") -endif() -if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "YdspSynths" AND YUP_PLATFORM_IOS) - message (FATAL_ERROR "YUP_EXAMPLE_GRAPHICS_DEMO=YdspSynths requires yup_dsp_jit, not available on this platform") -endif() -if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "Python" AND (YUP_PLATFORM_WINDOWS OR YUP_PLATFORM_EMSCRIPTEN)) - message (FATAL_ERROR "YUP_EXAMPLE_GRAPHICS_DEMO=Python requires yup_python, not available on this platform") +if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "ALL") + set (selected_demo_ids ${all_demo_ids}) +elseif (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST all_demo_ids) + set (selected_demo_ids ${YUP_EXAMPLE_GRAPHICS_DEMO}) +else() + message (FATAL_ERROR "YUP_EXAMPLE_GRAPHICS_DEMO must be ALL or one of the demos available on this platform: ${all_demo_ids}") endif() set (demo_definitions "") +set (demo_modules "") +set (demo_resources "") +set (demo_shaders "") +set (demo_live_shaders OFF) foreach (demo_id IN LISTS all_demo_ids) - if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "ALL" OR YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL demo_id) - list (APPEND demo_definitions "YUP_EXAMPLE_GRAPHICS_DEMO_${demo_id}=1") - else() + if (NOT demo_id IN_LIST selected_demo_ids) list (APPEND demo_definitions "YUP_EXAMPLE_GRAPHICS_DEMO_${demo_id}=0") + continue() endif() -endforeach() - -# Which of the resources/modules below are actually needed by the selected demo(s). -set (need_rive_files OFF) -set (need_lottie_files OFF) -set (need_svg_files OFF) -set (need_audio_files OFF) -set (need_synth_files OFF) -set (need_shader_bundle OFF) -set (need_shader_transpiler OFF) - -set (rive_demos "ALL;Artboard;ArtboardLayout") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST rive_demos) - set (need_rive_files ON) -endif() - -set (lottie_demos "ALL;Lottie;SpinningCube") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST lottie_demos) - set (need_lottie_files ON) -endif() - -set (svg_demos "ALL;Svg") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST svg_demos) - set (need_svg_files ON) -endif() - -set (audio_demos "ALL;ConvolutionDemo;CrossoverDemo;GpuAudio;SpectrumAnalyzer;YdspSynths") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST audio_demos) - set (need_audio_files ON) -endif() -set (synth_demos "ALL;YdspSynths") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST synth_demos) - set (need_synth_files ON) -endif() + list (APPEND demo_definitions "YUP_EXAMPLE_GRAPHICS_DEMO_${demo_id}=1") + list (APPEND demo_modules ${demo_${demo_id}_modules}) + list (APPEND demo_resources ${demo_${demo_id}_resources}) + list (APPEND demo_shaders ${demo_${demo_id}_shaders}) + if (demo_${demo_id}_live_shaders) + set (demo_live_shaders ON) + endif() +endforeach() -set (shader_bundle_demos "ALL;SpinningCube") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST shader_bundle_demos) - set (need_shader_bundle ON) -endif() +list (REMOVE_DUPLICATES demo_modules) +list (REMOVE_DUPLICATES demo_resources) +list (REMOVE_DUPLICATES demo_shaders) -set (shader_transpiler_demos "ALL;Audio;Component3D;SpinningCube;GpuAudio;ComputeParticles") -if (YUP_EXAMPLE_GRAPHICS_DEMO IN_LIST shader_transpiler_demos) - set (need_shader_transpiler ON) +if (demo_live_shaders) + list (APPEND demo_modules glslang spirv_cross spirv_tools) endif() # ==== Prepare Android build @@ -112,25 +151,40 @@ endif() set (bundle_resources "") if (YUP_PLATFORM_MOBILE OR YUP_PLATFORM_EMSCRIPTEN) list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/logo.png@data/logo.png") - if (need_rive_files) - list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/rive@data/rive") - endif() - if (need_lottie_files) - list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/lottie@data/lottie") - endif() - if (need_svg_files) - list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/svg@data/svg") - endif() - if (need_audio_files) - list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/audio@data/audio") + foreach (resource IN LISTS demo_resources) + list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/${resource}@data/${resource}") + endforeach() +endif() + +# ==== Precompile shaders, bundled like the data files and read from the build tree on desktop +# - see loadShaderBundle() +set (shaders_dir "${CMAKE_CURRENT_LIST_DIR}/data/shaders") +file (GLOB shader_includes "${shaders_dir}/*.glsl") +foreach (shader IN LISTS demo_shaders) + string (REPLACE ":" ";" shader_parts "${shader}") + list (GET shader_parts 0 shader_name) + list (GET shader_parts -1 vertex_name) + + if (EXISTS "${shaders_dir}/${shader_name}.comp") + set (shader_stages COMPUTE "${shaders_dir}/${shader_name}.comp") + else() + set (shader_stages VERT "${shaders_dir}/${vertex_name}.vert" FRAG "${shaders_dir}/${shader_name}.frag") endif() - if (need_synth_files) - list (APPEND bundle_resources "${CMAKE_CURRENT_LIST_DIR}/data/synths@data/synths") + + yup_add_shader_bundle (${shader_name} + ${shader_stages} + BUNDLE_RESOURCE shader_resource + BUNDLE_DESTINATION "data/shaders/${shader_name}.ysl" + DEPENDS ${shader_includes} + OPTIONS "-I${shaders_dir}") + + if (YUP_PLATFORM_MOBILE OR YUP_PLATFORM_EMSCRIPTEN) + list (APPEND bundle_resources "${shader_resource}") endif() -endif() +endforeach() # ==== Embed shaders -if (need_shader_bundle) +if ("SpinningCube" IN_LIST selected_demo_ids) yup_add_shader_bundle ( "${target_name}_binary_shaders" VERT ${CMAKE_CURRENT_LIST_DIR}/data/shaders/cube.vert @@ -144,21 +198,9 @@ if (need_shader_bundle) endif() # ==== Prepare target -set (additional_modules "") -set (additional_definitions "${demo_definitions}") -if (YUP_PLATFORM_DESKTOP OR YUP_PLATFORM_EMSCRIPTEN) - if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "ALL" OR YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "YdspSynths") - list (APPEND additional_modules yup::yup_dsp_jit) - endif() - if (NOT YUP_PLATFORM_WINDOWS AND NOT YUP_PLATFORM_EMSCRIPTEN) - if (YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "ALL" OR YUP_EXAMPLE_GRAPHICS_DEMO STREQUAL "Python") - list (APPEND additional_modules yup::yup_python) - endif() - endif() -endif() -if (need_shader_transpiler) - list (APPEND additional_modules glslang spirv_cross spirv_tools) -endif() +set (additional_definitions + ${demo_definitions} + "YUP_EXAMPLE_GRAPHICS_SHADERS_PATH=\"${CMAKE_CURRENT_BINARY_DIR}\"") yup_standalone_app ( TARGET_NAME ${target_name} @@ -173,32 +215,8 @@ yup_standalone_app ( BUNDLE_RESOURCES ${bundle_resources} MODULES - yup::yup_core - yup::yup_audio_basics - yup::yup_audio_devices - yup::yup_dsp - yup::yup_events - yup::yup_graphics - yup::yup_animation - yup::yup_gui - yup::yup_audio_gui - yup::yup_audio_processors - yup::yup_audio_formats - yup::yup_shading - yup::yup_ai - bungee_library - pffft_library - opus_library - flac_library - hmp3_library - dr_libs - libvorbis - libpng - libwebp - libjpeg - libgif - libtiff - ${additional_modules} + ${base_modules} + ${demo_modules} ${link_libraries}) # ==== Prepare sources diff --git a/examples/graphics/data/shaders/component3d.frag b/examples/graphics/data/shaders/component3d.frag new file mode 100644 index 000000000..335403095 --- /dev/null +++ b/examples/graphics/data/shaders/component3d.frag @@ -0,0 +1,10 @@ +#version 450 + +layout(location = 0) in vec2 v_uv; +layout(set = 0, binding = 1) uniform texture2D u_tex; +layout(set = 0, binding = 2) uniform sampler u_samp; +layout(location = 0) out vec4 fragColor; + +void main() { + fragColor = vec4(texture(sampler2D(u_tex, u_samp), v_uv).rgb, 1.0); +} diff --git a/examples/graphics/data/shaders/component3d.vert b/examples/graphics/data/shaders/component3d.vert new file mode 100644 index 000000000..7fa67f276 --- /dev/null +++ b/examples/graphics/data/shaders/component3d.vert @@ -0,0 +1,13 @@ +#version 450 + +// Only applies the MVP matrix, so the CPU picking in the MeshSurfaceMapper matches the GPU +// rasterization exactly. Texture coordinates have v pointing down, like the panel. +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec2 a_uv; +layout(set = 0, binding = 0) uniform Uniforms { mat4 modelViewProjection; } u; +layout(location = 0) out vec2 v_uv; + +void main() { + gl_Position = u.modelViewProjection * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/examples/graphics/data/shaders/effect_blur.frag b/examples/graphics/data/shaders/effect_blur.frag new file mode 100644 index 000000000..0140debb0 --- /dev/null +++ b/examples/graphics/data/shaders/effect_blur.frag @@ -0,0 +1,23 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float s,r,rx,ry,dx,dy,pad0,pad1; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.rx, p.ry); + if (p.s <= 0.0001) { fragColor = texture(sampler2D(u_tex,u_samp), uv); return; } + int r = int(clamp(p.r, 1.0, 128.0)); + vec2 step = vec2(p.dx, p.dy) / vec2(p.rx, p.ry); + float inv2s2 = 0.5 / (p.s * p.s); + vec4 sum = texture(sampler2D(u_tex,u_samp), uv); + float wsum = 1.0; + for (int i = 1; i <= r; ++i) { + float w = exp(-float(i*i) * inv2s2); + vec2 off = step * float(i); + sum += texture(sampler2D(u_tex,u_samp), uv + off) * w; + sum += texture(sampler2D(u_tex,u_samp), uv - off) * w; + wsum += 2.0 * w; + } + fragColor = sum / wsum; +} diff --git a/examples/graphics/data/shaders/effect_crt.frag b/examples/graphics/data/shaders/effect_crt.frag new file mode 100644 index 000000000..085730c70 --- /dev/null +++ b/examples/graphics/data/shaders/effect_crt.frag @@ -0,0 +1,19 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float intensity,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); + vec4 col = texture(sampler2D(u_tex, u_samp), uv); + // Scanlines + float scanline = sin(uv.y * p.resY * 1.2) * 0.5 + 0.5; + col.rgb *= 1.0 - (1.0 - scanline) * p.intensity * 0.6; + // Vignette + vec2 v = uv - 0.5; + col.rgb *= 1.0 - dot(v, v) * p.intensity * 0.8; + // Slight green tint + col.rgb *= vec3(0.95, 1.05, 0.9); + fragColor = col; +} diff --git a/examples/graphics/data/shaders/effect_edge.frag b/examples/graphics/data/shaders/effect_edge.frag new file mode 100644 index 000000000..4f7e493d9 --- /dev/null +++ b/examples/graphics/data/shaders/effect_edge.frag @@ -0,0 +1,22 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float thr,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); + vec2 t = 1.0 / vec2(p.resX, p.resY); + vec4 tl = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,-1)*t); + vec4 top = texture(sampler2D(u_tex,u_samp), uv + vec2(0,-1)*t); + vec4 tr = texture(sampler2D(u_tex,u_samp), uv + vec2(1,-1)*t); + vec4 lf = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,0)*t); + vec4 rt = texture(sampler2D(u_tex,u_samp), uv + vec2(1,0)*t); + vec4 bl = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,1)*t); + vec4 bm = texture(sampler2D(u_tex,u_samp), uv + vec2(0,1)*t); + vec4 br = texture(sampler2D(u_tex,u_samp), uv + vec2(1,1)*t); + vec3 h = -tl.rgb - 2.0*top.rgb - tr.rgb + bl.rgb + 2.0*bm.rgb + br.rgb; + vec3 v = -tl.rgb - 2.0*lf.rgb + tr.rgb - bl.rgb + 2.0*rt.rgb + br.rgb; + float edge = length(h) + length(v) > p.thr ? 1.0 : 0.0; + fragColor = vec4(vec3(edge), 1.0); +} diff --git a/examples/graphics/data/shaders/effect_pixelate.frag b/examples/graphics/data/shaders/effect_pixelate.frag new file mode 100644 index 000000000..397feb11f --- /dev/null +++ b/examples/graphics/data/shaders/effect_pixelate.frag @@ -0,0 +1,13 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float bs,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); + float bs = max(1.0, p.bs); + vec2 block = floor(uv * vec2(p.resX, p.resY) / bs) * bs; + vec2 sampleUV = (block + 0.5 * bs) / vec2(p.resX, p.resY); + fragColor = texture(sampler2D(u_tex, u_samp), sampleUV); +} diff --git a/examples/graphics/data/shaders/effect_sharpen.frag b/examples/graphics/data/shaders/effect_sharpen.frag new file mode 100644 index 000000000..3a23e10e3 --- /dev/null +++ b/examples/graphics/data/shaders/effect_sharpen.frag @@ -0,0 +1,21 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float str,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); + vec2 t = 1.0 / vec2(p.resX, p.resY); + vec4 c = texture(sampler2D(u_tex,u_samp), uv); + vec4 bl = c - 0.25 * ( + texture(sampler2D(u_tex,u_samp), uv + vec2(-1,-1)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2( 0,-1)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2( 1,-1)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2(-1, 0)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2( 1, 0)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2(-1, 1)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2( 0, 1)*t) + + texture(sampler2D(u_tex,u_samp), uv + vec2( 1, 1)*t)) * 0.125; + fragColor = mix(c, c + bl * p.str, 0.8); +} diff --git a/examples/graphics/data/shaders/effect_wave.frag b/examples/graphics/data/shaders/effect_wave.frag new file mode 100644 index 000000000..d8b886b1c --- /dev/null +++ b/examples/graphics/data/shaders/effect_wave.frag @@ -0,0 +1,15 @@ +#version 450 + +layout(set=0,binding=0) uniform texture2D u_tex; +layout(set=0,binding=1) uniform sampler u_samp; +layout(set=0,binding=2) uniform Params { float amp,freq,time,resX,resY,pad0,pad1,pad2; } p; +layout(location=0) out vec4 fragColor; +void main() { + vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); + float aspect = p.resX / p.resY; + vec2 center = uv - 0.5; + float dist = length(center * vec2(aspect, 1.0)); + float offset = sin(dist * p.freq - p.time) * p.amp * 0.003; + vec2 sampleUV = uv + normalize(center + 0.001) * offset; + fragColor = texture(sampler2D(u_tex, u_samp), sampleUV); +} diff --git a/examples/graphics/data/shaders/fluid_advect_dye.frag b/examples/graphics/data/shaders/fluid_advect_dye.frag new file mode 100644 index 000000000..0b5512df1 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_advect_dye.frag @@ -0,0 +1,44 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Semi-Lagrangian advection of the dye field (velocity sampled encoded). +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float velSizeX; float velSizeY; + float srcSizeX; float srcSizeY; + float dt; float dissipation; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(set = 0, binding = 3) uniform texture2D uTex1; +layout(set = 0, binding = 4) uniform sampler uSamp1; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_suv.glsl" + +vec2 sampleVel(vec2 uv) { + vec2 dims = vec2(u.velSizeX, u.velSizeY); + vec2 st = uv * dims - 0.5; + vec2 b = floor(st); + vec2 f = st - b; + vec2 t00 = (clamp(b, vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t10 = (clamp(b + vec2(1.0, 0.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t01 = (clamp(b + vec2(0.0, 1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t11 = (clamp(b + vec2(1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 v00 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t00))); + vec2 v10 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t10))); + vec2 v01 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t01))); + vec2 v11 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t11))); + return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y); +} +void main() { + vec2 uv = vUv; + vec2 vel = sampleVel(uv); + vec2 coord = uv - u.dt * vel * vec2(1.0 / u.velSizeX, 1.0 / u.velSizeY); + vec4 result = texture(sampler2D(uTex0, uSamp0), suv(coord)); + result /= 1.0 + u.dissipation * u.dt; + fragColor = vec4(result.rgb, 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_advect_velocity.frag b/examples/graphics/data/shaders/fluid_advect_velocity.frag new file mode 100644 index 000000000..570fa5dee --- /dev/null +++ b/examples/graphics/data/shaders/fluid_advect_velocity.frag @@ -0,0 +1,41 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Semi-Lagrangian advection of an encoded field (velocity self-advection). +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float dt; float dissipation; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_suv.glsl" + +vec2 sampleEncoded(vec2 uv) { + vec2 dims = vec2(u.sizeX, u.sizeY); + vec2 st = uv * dims - 0.5; + vec2 b = floor(st); + vec2 f = st - b; + vec2 t00 = (clamp(b, vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t10 = (clamp(b + vec2(1.0, 0.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t01 = (clamp(b + vec2(0.0, 1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 t11 = (clamp(b + vec2(1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; + vec2 v00 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t00))); + vec2 v10 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t10))); + vec2 v01 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t01))); + vec2 v11 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t11))); + return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y); +} +void main() { + vec2 uv = vUv; + vec2 vel = sampleEncoded(uv); + vec2 coord = uv - u.dt * vel * vec2(1.0 / u.sizeX, 1.0 / u.sizeY); + vec2 result = sampleEncoded(coord); + result /= 1.0 + u.dissipation * u.dt; + fragColor = encodeVel(result); +} diff --git a/examples/graphics/data/shaders/fluid_bloom_prefilter.frag b/examples/graphics/data/shaders/fluid_bloom_prefilter.frag new file mode 100644 index 000000000..1284328f3 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_bloom_prefilter.frag @@ -0,0 +1,26 @@ +#version 450 + +// Bloom prefilter - keeps only the bright parts of the dye (original curve). +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float threshold; + float curve0; float curve1; float curve2; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; + float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +vec2 suv(vec2 uv) { + return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); +} +void main() { + vec3 c = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; + float br = max(c.r, max(c.g, c.b)); + float rq = clamp(br - u.curve0, 0.0, u.curve1); + rq = u.curve2 * rq * rq; + c *= max(rq, br - u.threshold) / max(br, 0.0001); + fragColor = vec4(c, 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_blur4.frag b/examples/graphics/data/shaders/fluid_blur4.frag new file mode 100644 index 000000000..9c9aad868 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_blur4.frag @@ -0,0 +1,28 @@ +#version 450 + +// 4-tap blur - smooths the small bloom surface between two ping-pong buffers. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float srcSizeX; float srcSizeY; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; + float pad12; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +vec2 suv(vec2 uv) { + return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); +} +void main() { + vec2 texel = vec2(1.0 / u.srcSizeX, 1.0 / u.srcSizeY); + + vec4 sum = vec4(0.0); + sum += texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))); + sum += texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))); + sum += texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))); + sum += texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))); + sum *= 0.25; + fragColor = sum; +} diff --git a/examples/graphics/data/shaders/fluid_clear.frag b/examples/graphics/data/shaders/fluid_clear.frag new file mode 100644 index 000000000..a0d218e04 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_clear.frag @@ -0,0 +1,14 @@ +#version 450 + +// Clears a surface to a flat color (initialisation only). +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float colorR; float colorG; float colorB; float colorA; + float pad0; float pad1; float pad2; float pad3; + float pad4; float pad5; float pad6; float pad7; + float pad8; float pad9; float pad10; float pad11; +} u; +layout(location = 0) out vec4 fragColor; +void main() { + fragColor = vec4(u.colorR, u.colorG, u.colorB, u.colorA); +} diff --git a/examples/graphics/data/shaders/fluid_curl.frag b/examples/graphics/data/shaders/fluid_curl.frag new file mode 100644 index 000000000..1aa8df68c --- /dev/null +++ b/examples/graphics/data/shaders/fluid_curl.frag @@ -0,0 +1,30 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Curl of the velocity field (vorticity magnitude), stored as a scalar. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; + float pad12; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_encode_scalar.glsl" +#include "fluid_suv.glsl" + +void main() { + vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); + + float L = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0)))).y; + float R = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0)))).y; + float T = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y)))).x; + float B = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y)))).x; + + float vorticity = R - L - T + B; + fragColor = vec4(encodeScalar(0.5 * vorticity), 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_display.frag b/examples/graphics/data/shaders/fluid_display.frag new file mode 100644 index 000000000..43164e07c --- /dev/null +++ b/examples/graphics/data/shaders/fluid_display.frag @@ -0,0 +1,76 @@ +#version 450 + +// Final composite: shading, bloom add (gamma'd), ordered dither. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float dyeSizeX; float dyeSizeY; + float shadingF; float bloomF; + float bloomIntensity; + float backR; float backG; float backB; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(set = 0, binding = 3) uniform texture2D uTex1; +layout(set = 0, binding = 4) uniform sampler uSamp1; +layout(location = 0) out vec4 fragColor; + +vec3 linearToGamma(vec3 color) { + color = max(color, vec3(0.0)); + return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0.0)); +} + +const float kDither[16] = float[16]( + 0.0, 8.0, 2.0, 10.0, + 12.0, 4.0, 14.0, 6.0, + 3.0, 11.0, 1.0, 9.0, + 15.0, 7.0, 13.0, 5.0 +); + +vec2 suv(vec2 uv) { + return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); +} +void main() { + vec3 c = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; + + if (u.shadingF > 0.5) + { + vec2 texel = vec2(1.0 / u.dyeSizeX, 1.0 / u.dyeSizeY); + + vec3 lc = texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb; + vec3 rc = texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb; + vec3 tc = texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb; + vec3 bc = texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb; + + float dx = length(rc) - length(lc); + float dy = length(tc) - length(bc); + + vec3 n = normalize(vec3(dx, dy, length(texel))); + vec3 l = vec3(0.0, 0.0, 1.0); + + float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0); + c *= diffuse; + } + + if (u.bloomF > 0.5) + { + // Sample the small bloom surface with hardware linear filtering: the + // smooth upscale hides the 8-bit steps of the bloom buffer. + vec3 bloom = texture(sampler2D(uTex1, uSamp1), suv(vUv)).rgb * u.bloomIntensity; + bloom = linearToGamma(bloom); + c += bloom; + } + + float alpha = max(c.r, max(c.g, c.b)); + vec3 outC = c + vec3(u.backR, u.backG, u.backB) * (1.0 - alpha); + + // Ordered (Bayer) dithering hides the 8-bit quantization of the surfaces on + // slow fades, where gamma-expanded steps would otherwise band. Amplitude is + // half an LSB, so no visible grain. + ivec2 pc = ivec2(vUv * vec2(u.dyeSizeX, u.dyeSizeY)) & ivec2(3); + float dither = (kDither[pc.y * 4 + pc.x] + 0.5) / 16.0 - 0.5; + outC += vec3(dither) / 255.0; + + fragColor = vec4(outC, 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_encode_scalar.glsl b/examples/graphics/data/shaders/fluid_encode_scalar.glsl new file mode 100644 index 000000000..75dee8a70 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_encode_scalar.glsl @@ -0,0 +1,14 @@ +// Packs a scalar field into the 8-bit channels of a surface. +vec3 encodeScalar(float v) { + float t = clamp((v + 2048.0) * (1.0 / 4096.0), 0.0, 1.0); + float x = floor(t * 16777215.0 + 0.5); + float b0 = mod(x, 256.0); + float b1 = mod(floor(x / 256.0), 256.0); + float b2 = floor(x / 65536.0); + return vec3(b0, b1, b2) / 255.0; +} +float decodeScalar(vec3 c) { + vec3 b = floor(c * vec3(255.0) + vec3(0.5)); + float x = b.x + b.y * 256.0 + b.z * 65536.0; + return x * (4096.0 / 16777215.0) - 2048.0; +} diff --git a/examples/graphics/data/shaders/fluid_encode_vel.glsl b/examples/graphics/data/shaders/fluid_encode_vel.glsl new file mode 100644 index 000000000..0a138ad03 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_encode_vel.glsl @@ -0,0 +1,14 @@ +// Packs the velocity field into the 8-bit channels of a surface. +vec4 encodeVel(vec2 v) { + vec2 t = clamp((v + vec2(1000.0)) * vec2(0.0005), vec2(0.0), vec2(1.0)); + vec2 x = floor(t * vec2(65535.0) + vec2(0.5)); + vec2 hi = floor(x / vec2(256.0)); + vec2 lo = x - hi * vec2(256.0); + return vec4(lo.x, hi.x, lo.y, hi.y) / 255.0; +} +vec2 decodeVel(vec4 c) { + vec2 lo = floor(vec2(c.r, c.b) * vec2(255.0) + vec2(0.5)); + vec2 hi = floor(vec2(c.g, c.a) * vec2(255.0) + vec2(0.5)); + vec2 x = hi * vec2(256.0) + lo; + return x * vec2(2000.0 / 65535.0) - vec2(1000.0); +} diff --git a/examples/graphics/data/shaders/fluid_fullscreen.vert b/examples/graphics/data/shaders/fluid_fullscreen.vert new file mode 100644 index 000000000..9535f60d5 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_fullscreen.vert @@ -0,0 +1,12 @@ +#version 450 + +// Fullscreen triangle generated from gl_VertexIndex, without vertex buffers. vUv is the +// logical (0..1)^2 coordinate of each pixel. +layout(location = 0) out vec2 vUv; +void main() { + uint idx = gl_VertexIndex; + vec2 pos = vec2(float((idx & 1u) << 2u) - 1.0, + float((idx & 2u) << 1u) - 1.0); + vUv = pos * 0.5 + 0.5; + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_gradient_subtract.frag b/examples/graphics/data/shaders/fluid_gradient_subtract.frag new file mode 100644 index 000000000..cb136901a --- /dev/null +++ b/examples/graphics/data/shaders/fluid_gradient_subtract.frag @@ -0,0 +1,33 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Gradient subtraction: projects the velocity onto a divergence-free field. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; + float pad12; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(set = 0, binding = 3) uniform texture2D uTex1; +layout(set = 0, binding = 4) uniform sampler uSamp1; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_encode_scalar.glsl" +#include "fluid_suv.glsl" + +void main() { + vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); + + float L = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb); + float R = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb); + float T = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb); + float B = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb); + + vec2 velocity = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vUv))); + velocity -= vec2(R - L, T - B); + fragColor = encodeVel(velocity); +} diff --git a/examples/graphics/data/shaders/fluid_pressure.frag b/examples/graphics/data/shaders/fluid_pressure.frag new file mode 100644 index 000000000..7827b7909 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_pressure.frag @@ -0,0 +1,60 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// One Jacobi pressure iteration. +// The divergence is computed inline from the velocity field (which is +// unchanged during the solve), and an inputScale folds the per-frame +// PRESSURE damping (the original's separate "clear" pass) into the first +// iteration, so the whole pressure stage is a single shader. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float inputScale; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; + float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(set = 0, binding = 3) uniform texture2D uTex1; +layout(set = 0, binding = 4) uniform sampler uSamp1; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_encode_scalar.glsl" +#include "fluid_suv.glsl" + +void main() { + vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); + + // Divergence of the velocity field at this texel (mirror boundaries). + vec2 vL = vUv - vec2(texel.x, 0.0); + vec2 vR = vUv + vec2(texel.x, 0.0); + vec2 vT = vUv + vec2(0.0, texel.y); + vec2 vB = vUv - vec2(0.0, texel.y); + + vec2 Cv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vUv))); + + float Lv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vL))).x; + float Rv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vR))).x; + float Tv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vT))).y; + float Bv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vB))).y; + + if (vL.x < 0.0) Lv = -Cv.x; + if (vR.x > 1.0) Rv = -Cv.x; + if (vT.y > 1.0) Tv = -Cv.y; + if (vB.y < 0.0) Bv = -Cv.y; + + float divergence = 0.5 * (Rv - Lv + Tv - Bv); + + // Pressure neighbours, damped by inputScale (PRESSURE on the first + // iteration, 1 afterwards). + float s = u.inputScale; + float L = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb); + float R = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb); + float T = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb); + float B = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb); + + float pressure = (L + R + B + T - divergence) * 0.25; + fragColor = vec4(encodeScalar(pressure), 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_splat_dye.frag b/examples/graphics/data/shaders/fluid_splat_dye.frag new file mode 100644 index 000000000..e5dcb77c4 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_splat_dye.frag @@ -0,0 +1,28 @@ +#version 450 + +// Dye splat: adds a radial color blob to the dye field. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float aspectRatio; + float radius; + float pointX; float pointY; + float colorR; float colorG; float colorB; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +vec2 suv(vec2 uv) { + return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); +} +void main() { + vec2 p = vUv - vec2(u.pointX, u.pointY); + p.x *= u.aspectRatio; + float falloff = exp(-dot(p, p) / max(u.radius, 0.000001)); + + vec3 base = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; + fragColor = vec4(base + falloff * vec3(u.colorR, u.colorG, u.colorB), 1.0); +} diff --git a/examples/graphics/data/shaders/fluid_splat_velocity.frag b/examples/graphics/data/shaders/fluid_splat_velocity.frag new file mode 100644 index 000000000..57e246970 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_splat_velocity.frag @@ -0,0 +1,30 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Velocity splat: adds a radial velocity impulse to the (encoded) velocity field. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float aspectRatio; + float radius; + float pointX; float pointY; + float colorR; float colorG; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; float pad6; + float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_suv.glsl" + +void main() { + vec2 p = vUv - vec2(u.pointX, u.pointY); + p.x *= u.aspectRatio; + float falloff = exp(-dot(p, p) / max(u.radius, 0.000001)); + + vec2 vel = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv))); + vel += falloff * vec2(u.colorR, u.colorG); + fragColor = encodeVel(vel); +} diff --git a/examples/graphics/data/shaders/fluid_suv.glsl b/examples/graphics/data/shaders/fluid_suv.glsl new file mode 100644 index 000000000..c5636cd25 --- /dev/null +++ b/examples/graphics/data/shaders/fluid_suv.glsl @@ -0,0 +1,4 @@ +// Maps a logical sample coordinate to the texture space of the backend. +vec2 suv(vec2 uv) { + return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); +} diff --git a/examples/graphics/data/shaders/fluid_vorticity.frag b/examples/graphics/data/shaders/fluid_vorticity.frag new file mode 100644 index 000000000..669e35d3e --- /dev/null +++ b/examples/graphics/data/shaders/fluid_vorticity.frag @@ -0,0 +1,41 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +// Vorticity confinement: sharpens swirls by adding force along the curl gradient. +layout(location = 0) in vec2 vUv; +layout(set = 0, binding = 0) uniform Params { + float sizeX; float sizeY; + float curlStrength; + float dt; + float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; + float pad6; float pad7; float pad8; float pad9; float pad10; float flipY; +} u; +layout(set = 0, binding = 1) uniform texture2D uTex0; +layout(set = 0, binding = 2) uniform sampler uSamp0; +layout(set = 0, binding = 3) uniform texture2D uTex1; +layout(set = 0, binding = 4) uniform sampler uSamp1; +layout(location = 0) out vec4 fragColor; + +#include "fluid_encode_vel.glsl" +#include "fluid_encode_scalar.glsl" +#include "fluid_suv.glsl" + +void main() { + vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); + + float L = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv - vec2(texel.x, 0.0))).rgb); + float R = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv + vec2(texel.x, 0.0))).rgb); + float T = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv + vec2(0.0, texel.y))).rgb); + float B = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv - vec2(0.0, texel.y))).rgb); + float C = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv)).rgb); + + vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); + force /= length(force) + 0.0001; + force *= u.curlStrength * C; + force.y *= -1.0; + + vec2 velocity = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv))); + velocity += force * u.dt; + velocity = clamp(velocity, vec2(-1000.0), vec2(1000.0)); + fragColor = encodeVel(velocity); +} diff --git a/examples/graphics/data/shaders/fullscreen.vert b/examples/graphics/data/shaders/fullscreen.vert new file mode 100644 index 000000000..3dfce56bc --- /dev/null +++ b/examples/graphics/data/shaders/fullscreen.vert @@ -0,0 +1,8 @@ +#version 450 + +// Fullscreen triangle generated from the vertex index, without vertex buffers. +void main() { + float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; + float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; + gl_Position = vec4(x, y, 0.0, 1.0); +} diff --git a/examples/graphics/data/shaders/particles_draw.frag b/examples/graphics/data/shaders/particles_draw.frag new file mode 100644 index 000000000..b83ba4bac --- /dev/null +++ b/examples/graphics/data/shaders/particles_draw.frag @@ -0,0 +1,20 @@ +#version 450 + +// GLSL 450 fragment shader: hard opaque circle, no blending. +// +// vOffset is the raw quad corner offset in [-0.5, 0.5]. +// Multiply by 2 to normalise to [-1, 1] for a correct +// unit-circle distance test that works with any viewport aspect. +layout(location = 0) in vec4 vColor; +layout(location = 1) in vec2 vOffset; + +layout(location = 0) out vec4 outColor; + +void main() { + // Raw offset is always [-0.5, 0.5] on both axes regardless of + // viewport aspect compensation. Normalise to [-1, 1] for a true circle. + float dist = length(vOffset * 2.0); + if (dist > 1.0) + discard; + outColor = vColor; +} diff --git a/examples/graphics/data/shaders/particles_draw.vert b/examples/graphics/data/shaders/particles_draw.vert new file mode 100644 index 000000000..13721d9ea --- /dev/null +++ b/examples/graphics/data/shaders/particles_draw.vert @@ -0,0 +1,16 @@ +#version 450 + +// GLSL 450 vertex shader: expands each vertex into a clip-space quad corner. +layout(location = 0) in vec2 aCenter; +layout(location = 1) in vec2 aOffset; +layout(location = 2) in vec4 aColor; +layout(location = 3) in vec2 aSize; + +layout(location = 0) out vec4 vColor; +layout(location = 1) out vec2 vOffset; + +void main() { + gl_Position = vec4(aCenter + aOffset * aSize, 0.0, 1.0); + vColor = aColor; + vOffset = aOffset; +} diff --git a/examples/graphics/data/shaders/particles_update.comp b/examples/graphics/data/shaders/particles_update.comp new file mode 100644 index 000000000..b5f73e511 --- /dev/null +++ b/examples/graphics/data/shaders/particles_update.comp @@ -0,0 +1,116 @@ +#version 450 + +// GLSL 450 compute shader: particle physics simulation. +// +// Each particle occupies 12 floats (48 bytes in std430): +// offset 0: posX, posY +// offset 2: velX, velY +// offset 4: colR, colG, colB, colA +// offset 8: lifetime +// offset 9: age +// offset 10-11: padding (unused) +// +// Bindings: UBO at (0,0), SSBO at (0,1) - matching Metal's +// declaration-order buffer indexing. +layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in; + +layout(std140, set = 0, binding = 0) uniform Params { + float deltaTime; + float gravity; + float restitution; + float particleCountF; + float simLeft; + float simRight; + float simBottom; + float simTop; +} params; + +layout(std430, set = 0, binding = 1) buffer ParticleBuffer { + float data[]; +}; + +// Simple hash function for pseudo-random numbers per particle. +uint wangHash(uint seed) { + seed = (seed ^ 61u) ^ (seed >> 16u); + seed *= 9u; + seed = seed ^ (seed >> 4u); + seed *= 0x27d4eb2du; + seed = seed ^ (seed >> 15u); + return seed; +} + +void main() { + uint idx = gl_GlobalInvocationID.x; + uint particleCount = uint(params.particleCountF); + if (idx >= particleCount) + return; + + uint base = idx * 12u; + + // Read particle state from the flat array. + vec2 pos = vec2(data[base + 0u], data[base + 1u]); + vec2 vel = vec2(data[base + 2u], data[base + 3u]); + vec4 col = vec4(data[base + 4u], data[base + 5u], data[base + 6u], data[base + 7u]); + float lifetime = data[base + 8u]; + float age = data[base + 9u]; + + // Age the particle. + age += params.deltaTime; + + // Respawn when lifetime expires - explode outward from the centre. + if (age >= lifetime) { + float angle = float(wangHash(idx * 2u + uint(age * 1000.0))) / float(0xFFFFFFFFu) * 6.283185307; + float speed = float(wangHash(idx * 3u)) / float(0xFFFFFFFFu) * 1.8 + 0.2; + + pos = vec2(0.0, params.simBottom + 1.4); + vel = vec2(cos(angle), sin(angle)) * speed; + age = 0.0; + lifetime = float(wangHash(idx * 5u)) / float(0xFFFFFFFFu) * 1.5 + 0.3; + + // Random saturated color. + uint cr = wangHash(idx * 7u); + uint cg = wangHash(idx * 11u); + uint cb = wangHash(idx * 13u); + col = vec4( + float(cr & 0xFFu) / 255.0, + float(cg & 0xFFu) / 255.0, + float(cb & 0xFFu) / 255.0, + 1.0 + ); + } + + // Apply gravity. + vel.y -= params.gravity * params.deltaTime; + + // Integrate position. + pos += vel * params.deltaTime; + + // Bounce off ground. + if (pos.y < params.simBottom) { + pos.y = params.simBottom; + vel.y = abs(vel.y) * params.restitution; + vel.x *= 0.92; + } + + // Bounce off side walls (viewport edges). + if (pos.x < params.simLeft) { + vel.x = abs(vel.x) * 0.7; + pos.x = params.simLeft; + } + if (pos.x > params.simRight) { + vel.x = -abs(vel.x) * 0.7; + pos.x = params.simRight; + } + + // Write back to the flat array. + data[base + 0u] = pos.x; + data[base + 1u] = pos.y; + data[base + 2u] = vel.x; + data[base + 3u] = vel.y; + data[base + 4u] = col.r; + data[base + 5u] = col.g; + data[base + 6u] = col.b; + data[base + 7u] = col.a; + data[base + 8u] = lifetime; + data[base + 9u] = age; +} diff --git a/examples/graphics/data/shaders/pbr_background.frag b/examples/graphics/data/shaders/pbr_background.frag new file mode 100644 index 000000000..7b7ca5922 --- /dev/null +++ b/examples/graphics/data/shaders/pbr_background.frag @@ -0,0 +1,32 @@ +#version 450 + +layout(location = 0) in vec3 v_worldPosition; +layout(location = 3) in vec3 v_cameraPosition; + +layout(set = 0, binding = 0) uniform Scene { + vec4 camera; + vec4 material; + vec4 light; +} u; + +layout(set = 0, binding = 1) uniform textureCube u_environment; +layout(set = 0, binding = 2) uniform sampler u_samp; + +layout(location = 0) out vec4 fragColor; + +vec3 acesTonemap(vec3 x) { + const float a = 2.51; + const float b = 0.03; + const float c = 2.43; + const float d = 0.59; + const float e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +void main() { + vec3 direction = normalize(v_worldPosition - v_cameraPosition); + vec3 color = textureLod(samplerCube(u_environment, u_samp), direction, 0.0).rgb; + + color = acesTonemap(color * u.material.x); + fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_bake.glsl b/examples/graphics/data/shaders/pbr_bake.glsl new file mode 100644 index 000000000..9f2741a43 --- /dev/null +++ b/examples/graphics/data/shaders/pbr_bake.glsl @@ -0,0 +1,73 @@ +// Shared prelude for the cube bakes: the Bake block, the face parameterisation +// and the analytic sky. Included by each bake fragment shader. +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 fragColor; +layout(set = 0, binding = 0) uniform Bake { + int face; + float roughness; + float flipY; + float envSize; + float sunIntensity; +} u; + +const float PI = 3.14159265359; + +// Maps a face index plus a [0,1] texel coordinate onto the direction the cube +// map hardware associates with that texel, matching the GL/Metal/D3D layout. +vec3 faceDirection(int face, vec2 uv) { + vec2 c = uv * 2.0 - 1.0; + if (face == 0) return normalize(vec3( 1.0, -c.y, -c.x)); + if (face == 1) return normalize(vec3(-1.0, -c.y, c.x)); + if (face == 2) return normalize(vec3( c.x, 1.0, c.y)); + if (face == 3) return normalize(vec3( c.x, -1.0, -c.y)); + if (face == 4) return normalize(vec3( c.x, -c.y, 1.0)); + return normalize(vec3(-c.x, -c.y, -1.0)); +} + +vec2 faceUV() { + return vec2(v_uv.x, u.flipY > 0.5 ? 1.0 - v_uv.y : v_uv.y); +} + +vec3 proceduralSky(vec3 d) { + vec3 sunDir = normalize(vec3(0.35, 0.28, -0.90)); + float up = clamp(d.y * 0.5 + 0.5, 0.0, 1.0); + vec3 sky = mix(vec3(0.58, 0.68, 0.84), vec3(0.10, 0.22, 0.55), pow(up, 0.6)); + float horizon = 1.0 - smoothstep(-0.15, 0.45, d.y); // warm haze band near the horizon + sky += vec3(0.24, 0.13, 0.05) * horizon * 0.55; + vec3 ground = vec3(0.16, 0.14, 0.12); + vec3 col = mix(ground, sky, smoothstep(-0.06, 0.10, d.y)); + float cosSun = clamp(dot(d, sunDir), 0.0, 1.0); + // The sun disk and its halo scale with u.sunIntensity so the slider + // re-bakes the whole environment (and its IBL derivatives) coherently. + col += vec3(1.7, 1.45, 1.15) * u.sunIntensity * pow(cosSun, 600.0); + col += vec3(0.16, 0.12, 0.07) * u.sunIntensity * pow(cosSun, 10.0); + return col; +} + +// Hammersley low-discrepancy sequence, used by the GGX importance sampler. +float radicalInverse(uint bits) { + bits = (bits << 16u) | (bits >> 16u); + bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); + bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); + bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); + bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); + return float(bits) * 2.3283064365386963e-10; +} + +vec2 hammersley(uint i, uint n) { + return vec2(float(i) / float(n), radicalInverse(i)); +} + +vec3 importanceSampleGGX(vec2 xi, vec3 n, float roughness) { + float a = roughness * roughness; + float phi = 2.0 * PI * xi.x; + float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); + float sinTheta = sqrt(1.0 - cosTheta * cosTheta); + + vec3 h = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + + vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + vec3 tangent = normalize(cross(up, n)); + vec3 bitangent = cross(n, tangent); + return normalize(tangent * h.x + bitangent * h.y + n * h.z); +} diff --git a/examples/graphics/data/shaders/pbr_brdf.frag b/examples/graphics/data/shaders/pbr_brdf.frag new file mode 100644 index 000000000..14abc549f --- /dev/null +++ b/examples/graphics/data/shaders/pbr_brdf.frag @@ -0,0 +1,79 @@ +#version 450 + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 fragColor; +layout(set = 0, binding = 0) uniform Bake { + int face; + float roughness; + float flipY; + float envSize; + float sunIntensity; +} u; + +const float PI = 3.14159265359; +const uint kSampleCount = 512u; + +float radicalInverse(uint bits) { + bits = (bits << 16u) | (bits >> 16u); + bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); + bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); + bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); + bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); + return float(bits) * 2.3283064365386963e-10; +} + +vec3 importanceSampleGGX(vec2 xi, vec3 n, float roughness) { + float a = roughness * roughness; + float phi = 2.0 * PI * xi.x; + float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); + float sinTheta = sqrt(1.0 - cosTheta * cosTheta); + + vec3 h = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + + vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + vec3 tangent = normalize(cross(up, n)); + vec3 bitangent = cross(n, tangent); + return normalize(tangent * h.x + bitangent * h.y + n * h.z); +} + +// Smith geometry term with the IBL (rather than direct-lighting) k. +float geometrySmith(float nDotV, float nDotL, float roughness) { + float k = (roughness * roughness) * 0.5; + float ggxV = nDotV / (nDotV * (1.0 - k) + k); + float ggxL = nDotL / (nDotL * (1.0 - k) + k); + return ggxV * ggxL; +} + +void main() { + vec2 uv = vec2(v_uv.x, u.flipY > 0.5 ? 1.0 - v_uv.y : v_uv.y); + + float nDotV = max(uv.x, 0.001); + float roughness = max(uv.y, 0.001); + + vec3 v = vec3(sqrt(1.0 - nDotV * nDotV), 0.0, nDotV); + vec3 n = vec3(0.0, 0.0, 1.0); + + float scale = 0.0; + float bias = 0.0; + + for (uint i = 0u; i < kSampleCount; ++i) { + vec2 xi = vec2(float(i) / float(kSampleCount), radicalInverse(i)); + vec3 h = importanceSampleGGX(xi, n, roughness); + vec3 l = normalize(2.0 * dot(v, h) * h - v); + + float nDotL = max(l.z, 0.0); + float nDotH = max(h.z, 0.0); + float vDotH = max(dot(v, h), 0.0); + + if (nDotL > 0.0) { + float g = geometrySmith(nDotV, nDotL, roughness); + float gVis = (g * vDotH) / max(nDotH * nDotV, 0.001); + float fc = pow(1.0 - vDotH, 5.0); + + scale += (1.0 - fc) * gVis; + bias += fc * gVis; + } + } + + fragColor = vec4(scale / float(kSampleCount), bias / float(kSampleCount), 0.0, 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_fullscreen.vert b/examples/graphics/data/shaders/pbr_fullscreen.vert new file mode 100644 index 000000000..1d7ab3d4d --- /dev/null +++ b/examples/graphics/data/shaders/pbr_fullscreen.vert @@ -0,0 +1,10 @@ +#version 450 + +// Fullscreen triangle from the vertex index, with a UV for the bake passes. +layout(location = 0) out vec2 v_uv; +void main() { + float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; + float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; + v_uv = vec2(x, y) * 0.5 + 0.5; + gl_Position = vec4(x, y, 0.0, 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_irradiance.frag b/examples/graphics/data/shaders/pbr_irradiance.frag new file mode 100644 index 000000000..bcb715916 --- /dev/null +++ b/examples/graphics/data/shaders/pbr_irradiance.frag @@ -0,0 +1,29 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#include "pbr_bake.glsl" + +layout(set = 0, binding = 1) uniform textureCube u_env; +layout(set = 0, binding = 2) uniform sampler u_samp; + +void main() { + vec3 n = faceDirection(u.face, faceUV()); + + vec3 up = abs(n.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(0.0, 0.0, 1.0); + vec3 right = normalize(cross(up, n)); + up = normalize(cross(n, right)); + + vec3 irradiance = vec3(0.0); + float samples = 0.0; + + for (float phi = 0.0; phi < 2.0 * PI; phi += 0.15) { + for (float theta = 0.0; theta < 0.5 * PI; theta += 0.05) { + vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); + vec3 direction = tangentSample.x * right + tangentSample.y * up + tangentSample.z * n; + irradiance += textureLod(samplerCube(u_env, u_samp), direction, 0.0).rgb * cos(theta) * sin(theta); + samples += 1.0; + } + } + + fragColor = vec4(PI * irradiance / max(samples, 1.0), 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_prefilter.frag b/examples/graphics/data/shaders/pbr_prefilter.frag new file mode 100644 index 000000000..2c2768812 --- /dev/null +++ b/examples/graphics/data/shaders/pbr_prefilter.frag @@ -0,0 +1,30 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#include "pbr_bake.glsl" + +layout(set = 0, binding = 1) uniform textureCube u_env; +layout(set = 0, binding = 2) uniform sampler u_samp; + +const uint kSampleCount = 256u; + +void main() { + vec3 n = faceDirection(u.face, faceUV()); + vec3 v = n; + + vec3 prefiltered = vec3(0.0); + float totalWeight = 0.0; + + for (uint i = 0u; i < kSampleCount; ++i) { + vec3 h = importanceSampleGGX(hammersley(i, kSampleCount), n, u.roughness); + vec3 l = normalize(2.0 * dot(v, h) * h - v); + + float nDotL = dot(n, l); + if (nDotL > 0.0) { + prefiltered += textureLod(samplerCube(u_env, u_samp), l, 0.0).rgb * nDotL; + totalWeight += nDotL; + } + } + + fragColor = vec4(prefiltered / max(totalWeight, 0.001), 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_scene.frag b/examples/graphics/data/shaders/pbr_scene.frag new file mode 100644 index 000000000..04f98b60c --- /dev/null +++ b/examples/graphics/data/shaders/pbr_scene.frag @@ -0,0 +1,134 @@ +#version 450 + +layout(location = 0) in vec3 v_worldPosition; +layout(location = 1) in vec3 v_normal; +layout(location = 2) in vec2 v_uv; +layout(location = 3) in vec3 v_cameraPosition; +layout(location = 4) in vec4 v_material; +layout(location = 5) in vec4 v_tint; +layout(location = 6) in vec4 v_extra; + +layout(set = 0, binding = 0) uniform Scene { + vec4 camera; // yaw, pitch, distance, aspect + vec4 material; // exposure, sunIntensity, prefilterMaxLod, backdropRadius (0 = sphere grid) + vec4 light; // sun direction xyz +} u; + +layout(set = 0, binding = 1) uniform textureCube u_irradiance; +layout(set = 0, binding = 2) uniform textureCube u_prefilter; +layout(set = 0, binding = 3) uniform texture2D u_brdf; +layout(set = 0, binding = 4) uniform texture2D u_albedo; +layout(set = 0, binding = 5) uniform texture2D u_normalMap; +layout(set = 0, binding = 6) uniform sampler u_samp; +layout(set = 0, binding = 7) uniform texture2D u_rma; +layout(set = 0, binding = 8) uniform sampler u_sampRepeat; + +layout(location = 0) out vec4 fragColor; + +const float PI = 3.14159265359; + +float distributionGGX(float nDotH, float roughness) { + float a = roughness * roughness; + float a2 = a * a; + float d = nDotH * nDotH * (a2 - 1.0) + 1.0; + return a2 / max(PI * d * d, 0.0001); +} + +float geometrySmithDirect(float nDotV, float nDotL, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + float ggxV = nDotV / (nDotV * (1.0 - k) + k); + float ggxL = nDotL / (nDotL * (1.0 - k) + k); + return ggxV * ggxL; +} + +vec3 acesTonemap(vec3 x) { + const float a = 2.51; + const float b = 0.03; + const float c = 2.43; + const float d = 0.59; + const float e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +void main() { + float metallic = clamp(v_material.x, 0.0, 1.0); + float baseRoughness = clamp(v_material.y, 0.06, 1.0); + float mapAmount = v_tint.a; + float aoScale = v_extra.z; + + // Analytic tangent frame for a unit sphere, perturbed by the uploaded + // normal map. + vec3 geometricNormal = normalize(v_normal); + + // cross(Y, N) collapses to zero at the poles, and normalizing that yields NaN + // shading normals in a band around them - pick a different reference axis there. + vec3 reference = abs(geometricNormal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + vec3 tangent = normalize(cross(reference, geometricNormal)); + vec3 bitangent = cross(geometricNormal, tangent); + + vec2 uv = v_uv * max(v_material.z, 0.25); + vec3 tangentNormal = texture(sampler2D(u_normalMap, u_sampRepeat), uv).xyz * 2.0 - 1.0; + tangentNormal.xy *= v_material.w; + + vec3 n = normalize(mat3(tangent, bitangent, geometricNormal) * tangentNormal); + vec3 v = normalize(v_cameraPosition - v_worldPosition); + vec3 r = reflect(-v, n); + + vec3 albedoTex = pow(texture(sampler2D(u_albedo, u_sampRepeat), uv).rgb, vec3(2.2)); + vec3 albedo = albedoTex * v_tint.rgb; + + vec3 rma = texture(sampler2D(u_rma, u_sampRepeat), uv).rgb; + float roughness = clamp(mix(baseRoughness, baseRoughness * (rma.g / 0.5), mapAmount), 0.06, 1.0); + float ao = mix(1.0, rma.b, aoScale); + + vec3 f0 = mix(vec3(0.04), albedo, metallic); + + float nDotV = max(dot(n, v), 0.0001); + + // Direct sun contribution. + vec3 l = normalize(u.light.xyz); + vec3 h = normalize(v + l); + float nDotL = max(dot(n, l), 0.0); + float nDotH = max(dot(n, h), 0.0); + float vDotH = max(dot(v, h), 0.0); + + vec3 fresnel = f0 + (vec3(1.0) - f0) * pow(1.0 - vDotH, 5.0); + float ndf = distributionGGX(nDotH, roughness); + float geometry = geometrySmithDirect(nDotV, nDotL, roughness); + + vec3 specularDirect = (ndf * geometry * fresnel) / max(4.0 * nDotV * nDotL, 0.0001); + vec3 diffuseDirect = (vec3(1.0) - fresnel) * (1.0 - metallic) * albedo / PI; + vec3 direct = (diffuseDirect + specularDirect) * nDotL * u.material.y; + + // Image-based ambient: diffuse irradiance plus the split-sum specular term. + vec3 fresnelIbl = f0 + (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - nDotV, 5.0); + vec3 kD = (vec3(1.0) - fresnelIbl) * (1.0 - metallic); + + vec3 irradiance = textureLod(samplerCube(u_irradiance, u_samp), n, 0.0).rgb; + vec3 diffuseIbl = irradiance * albedo; + + // The prefilter chain is read by explicit LOD: a mip-narrowed texture view + // would silently degrade to mip 0 on OpenGL. + vec3 prefiltered = textureLod(samplerCube(u_prefilter, u_samp), r, roughness * u.material.z).rgb; + vec2 brdf = texture(sampler2D(u_brdf, u_samp), vec2(nDotV, roughness)).rg; + vec3 specularIbl = prefiltered * (fresnelIbl * brdf.x + brdf.y); + + // Clearcoat: a glossy dielectric layer whose own, much smoother roughness + // lobe replaces the base specular reflection as the coat builds up. + float clearcoat = v_extra.x; + if (clearcoat > 0.001) + { + float ccRoughness = clamp(v_extra.y, 0.03, 0.5); + float fcc = 0.04 + 0.96 * pow(1.0 - nDotV, 5.0); + vec2 ccBrdf = texture(sampler2D(u_brdf, u_samp), vec2(nDotV, ccRoughness)).rg; + vec3 ccPrefiltered = textureLod(samplerCube(u_prefilter, u_samp), r, ccRoughness * u.material.z).rgb; + vec3 ccSpecular = ccPrefiltered * (fcc * ccBrdf.x + ccBrdf.y); + specularIbl = mix(specularIbl, ccSpecular, clearcoat); + } + + vec3 color = ao * (kD * diffuseIbl + specularIbl) + direct; + + color = acesTonemap(color * u.material.x); + fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); +} diff --git a/examples/graphics/data/shaders/pbr_scene.vert b/examples/graphics/data/shaders/pbr_scene.vert new file mode 100644 index 000000000..0da17738a --- /dev/null +++ b/examples/graphics/data/shaders/pbr_scene.vert @@ -0,0 +1,89 @@ +#version 450 + +// Shared vertex stage for the sky backdrop and the sphere grid. A non-zero +// backdropRadius in the Scene block wraps the same unit-sphere mesh around +// the camera; zero positions the instanced sphere grid. +// +// The projection maps z/w into [0, 1], which every backend treats as a +// monotonically increasing depth, so a single shader drives the depth test +// correctly on all of them. +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +// Per-instance material, fed from a per-instance vertex buffer (locations 3-5). +layout(location = 3) in vec4 a_material; // metallic, roughness, uvScale, normalStrength +layout(location = 4) in vec4 a_tint; // linear albedo / metal f0 rgb, roughness-map amount +layout(location = 5) in vec4 a_extra; // clearcoat, clearcoat roughness, ao scale, unused + +layout(set = 0, binding = 0) uniform Scene { + vec4 camera; // yaw, pitch, distance, aspect + vec4 material; // exposure, sunIntensity, prefilterMaxLod, backdropRadius (0 = sphere grid) + vec4 light; // sun direction xyz +} u; + +layout(location = 0) out vec3 v_worldPosition; +layout(location = 1) out vec3 v_normal; +layout(location = 2) out vec2 v_uv; +layout(location = 3) out vec3 v_cameraPosition; +layout(location = 4) out vec4 v_material; +layout(location = 5) out vec4 v_tint; +layout(location = 6) out vec4 v_extra; + +const int kColumns = 5; +const int kRows = 3; +const float kSpacing = 2.6; + +void main() { + float yaw = u.camera.x; + float pitch = u.camera.y; + float orbitRadius = u.camera.z; + float aspect = u.camera.w; + + vec3 cameraPosition = vec3(sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch)) * orbitRadius; + + // A non-zero backdropRadius wraps the same unit-sphere mesh around the + // camera as the sky backdrop; zero positions the instanced sphere grid. + // gl_InstanceIndex is always 0 for the backdrop, which is drawn with a + // single instance. + float backdropRadius = u.material.w; + float isBackdrop = step(0.001, backdropRadius); + + int instance = gl_InstanceIndex; + int column = instance % kColumns; + int row = instance / kColumns; + + vec2 offset = (vec2(float(column), float(row)) - vec2(float(kColumns - 1), float(kRows - 1)) * 0.5) * kSpacing; + vec3 gridPosition = a_position + vec3(offset, 0.0); + vec3 backdropPosition = cameraPosition + a_position * backdropRadius; + vec3 worldPosition = mix(gridPosition, backdropPosition, isBackdrop); + + vec3 forward = normalize(-cameraPosition); + vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward)); + vec3 up = cross(forward, right); + + vec3 toVertex = worldPosition - cameraPosition; + vec3 viewSpace = vec3(dot(toVertex, right), dot(toVertex, up), dot(toVertex, forward)); + + // z/w lands in [0, 1], which every backend treats as monotonically increasing + // depth. Keep the near plane as far out as the scene allows: a near plane of + // 0.1 against a far plane of 120 would squeeze the whole grid into a fraction + // of a percent of the depth range and z-fight. + float fov = 1.7320508; // cot(30 degrees) => 60 degree vertical field of view + float zNear = 1.0; + float zFar = 120.0; + + gl_Position = vec4(viewSpace.x * fov / aspect, + viewSpace.y * fov, + zFar * (viewSpace.z - zNear) / (zFar - zNear), + viewSpace.z); + + v_worldPosition = worldPosition; + v_normal = a_normal; + v_uv = a_uv; + v_cameraPosition = cameraPosition; + + v_material = a_material; + v_tint = a_tint; + v_extra = a_extra; +} diff --git a/examples/graphics/data/shaders/pbr_sky.frag b/examples/graphics/data/shaders/pbr_sky.frag new file mode 100644 index 000000000..836e24df8 --- /dev/null +++ b/examples/graphics/data/shaders/pbr_sky.frag @@ -0,0 +1,8 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#include "pbr_bake.glsl" + +void main() { + fragColor = vec4(proceduralSky(faceDirection(u.face, faceUV())), 1.0); +} diff --git a/examples/graphics/data/shaders/synth_waveform.frag b/examples/graphics/data/shaders/synth_waveform.frag new file mode 100644 index 000000000..15b225dba --- /dev/null +++ b/examples/graphics/data/shaders/synth_waveform.frag @@ -0,0 +1,73 @@ +#version 450 + +// Shadertoy's y-up pixel space, since RHI targets read top-left-origin everywhere. +layout(set = 0, binding = 0) uniform Params +{ + float time; + float width; + float height; + float pad; +} u; + +layout(set = 0, binding = 1) uniform Samples +{ + vec4 samples[64]; +} waveform; + +layout(location = 0) out vec4 fragColor; + +const vec3 accent = vec3(0.447, 0.918, 0.824); +const float focal = 2.8; +const vec3 background = vec3(0.055, 0.067, 0.078); + +float fetchSample(int index) +{ + return waveform.samples[index >> 2][index & 3]; +} + +float wave(float phase) +{ + float position = phase * 255.0; + int i0 = int(position); + int i1 = min(i0 + 1, 255); + return mix(fetchSample(i0), fetchSample(i1), position - float(i0)); +} + +void main() +{ + vec2 resolution = vec2(u.width, u.height); + vec2 I = vec2(gl_FragCoord.x, u.height - gl_FragCoord.y); + + // The ridge on the far wall, four units away, spans one period across the width. + vec3 direction = normalize(vec3(I + I - resolution, -u.height * focal)); + float frequency = u.height * focal / (8.0 * u.width); + + float scroll = 0.5 + u.time * 0.01; + float hue = u.time * 0.15; + + vec3 color = vec3(0.0); + float z = 0.0; + + for (int i = 0; i < 90; ++i) + { + vec3 p = z * direction + vec3(0.0, 1.0, 1.0); + + float r = max(-p.y, 0.0); + p.y += r + r; + + p.y -= wave(fract(p.x * frequency + scroll)); + + for (float octave = 2.0; octave < 30.0; octave += octave) + p.y += 0.12 * cos(p.x * octave + 0.6 * u.time * cos(octave) + z) / octave; + + float plane = p.z + 3.0; + float d = (0.1 * r + abs(p.y - 1.0) / (1.0 + r + r + r * r) + max(plane, -plane * 0.1)) / 8.0; + z += d; + + float phase = z * 0.5 + hue; + vec3 tone = accent * (cos(phase) + 1.3) + vec3(0.0, 0.15, 0.08) * cos(phase + 2.0); + color += tone / max(d * z, 1.0e-4); + } + + fragColor = vec4(max(tanh(color / 900.0), background), 1.0); +} diff --git a/examples/graphics/source/examples/Component3D.h b/examples/graphics/source/examples/Component3D.h index c446761ba..50443f116 100644 --- a/examples/graphics/source/examples/Component3D.h +++ b/examples/graphics/source/examples/Component3D.h @@ -327,30 +327,6 @@ class Component3DDemo : public yup::Component private: //============================================================================== - // The vertex shader only applies the MVP matrix, so the CPU picking in the - // MeshSurfaceMapper matches the GPU rasterization exactly. Texture coordinates - // have v pointing down, like the panel, and are sampled as they are. - static constexpr char vertexSource[] = R"glsl(#version 450 -layout(location = 0) in vec3 a_position; -layout(location = 1) in vec2 a_uv; -layout(set = 0, binding = 0) uniform Uniforms { mat4 modelViewProjection; } u; -layout(location = 0) out vec2 v_uv; -void main() { - gl_Position = u.modelViewProjection * vec4(a_position, 1.0); - v_uv = a_uv; -} -)glsl"; - - static constexpr char fragmentSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 v_uv; -layout(set = 0, binding = 1) uniform texture2D u_tex; -layout(set = 0, binding = 2) uniform sampler u_samp; -layout(location = 0) out vec4 fragColor; -void main() { - fragColor = vec4(texture(sampler2D(u_tex, u_samp), v_uv).rgb, 1.0); -} -)glsl"; - static constexpr float panelWidth = 480.0f; static constexpr float panelHeight = 300.0f; static constexpr float surfaceWidth = 3.0f; @@ -448,7 +424,7 @@ void main() { options.depthStencil.depthCompare = yup::GpuCompareFunction::less; options.depthStencil.depthWriteEnabled = true; - auto result = yup::GpuPipeline::compileFromGlsl (context.getGpuDevice(), vertexSource, fragmentSource, options); + auto result = compilePipelineFromBundle (context.getGpuDevice(), "component3d", options); if (result.failed()) { pipelineError = "Pipeline compile failed: " + result.getErrorMessage(); diff --git a/examples/graphics/source/examples/ComponentEffects.h b/examples/graphics/source/examples/ComponentEffects.h index 5d9542727..6156ab2f6 100644 --- a/examples/graphics/source/examples/ComponentEffects.h +++ b/examples/graphics/source/examples/ComponentEffects.h @@ -221,15 +221,6 @@ class ComponentEffectsDemo : public yup::Component private: //============================================================================== - /** Fullscreen-triangle vertex shader, shared by all effects. */ - static constexpr char kVertSource[] = R"glsl(#version 450 -void main() { - float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; - float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; - gl_Position = vec4(x, y, 0.0, 1.0); -} -)glsl"; - /** Uniform block shared by all effects (layout matches std140). */ struct EffectParams { @@ -239,11 +230,9 @@ void main() { //============================================================================== /** Base for the demo's shader effects. - Owns the pipeline and compiles it at most once. Compiling GLSL runs the - shader transpiler - and, on WebGPU, the GLSL→WGSL lowering on top of that - - which costs tens of milliseconds, so a failed compile is remembered - instead of retried: retrying every frame would silently cap the frame rate - rather than just falling back to an unfiltered draw. + Owns the pipeline and compiles it at most once. A failed compile is + remembered instead of retried: retrying every frame would silently cap the + frame rate rather than just falling back to an unfiltered draw. Also records the CPU time spent inside apply(), so the demo can show whether a slow frame is spent on the CPU or waiting on the GPU. @@ -275,8 +264,8 @@ void main() { } protected: - /** Returns this effect's fragment shader source. */ - virtual const char* getFragmentSource() const = 0; + /** Returns the name of this effect's shader bundle. */ + virtual const char* getShaderName() const = 0; /** Renders the effect. Only called once the pipeline compiled successfully. */ virtual void applyEffect (yup::Graphics& g, yup::GpuTexture::Ptr input, yup::Rectangle bounds) = 0; @@ -295,10 +284,7 @@ void main() { compileAttempted = true; - auto result = yup::GpuPipeline::compileFromGlsl (ctx.getGpuDevice(), - kVertSource, - yup::String::fromUTF8 (getFragmentSource()), - {}); + auto result = compilePipelineFromBundle (ctx.getGpuDevice(), getShaderName()); if (result.wasOk()) { pipeline = result.getValue(); @@ -320,7 +306,7 @@ void main() { class BlurEffect : public ShaderEffect { protected: - const char* getFragmentSource() const override { return kBlurFrag; } + const char* getShaderName() const override { return "effect_blur"; } void applyEffect (yup::Graphics& g, yup::GpuTexture::Ptr input, yup::Rectangle bounds) override { @@ -364,30 +350,6 @@ void main() { targetB = yup::GpuTarget::create (ctx.getGpuDevice(), w, h); return targetA != nullptr && targetB != nullptr; } - - static constexpr char kBlurFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float s,r,rx,ry,dx,dy,pad0,pad1; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.rx, p.ry); - if (p.s <= 0.0001) { fragColor = texture(sampler2D(u_tex,u_samp), uv); return; } - int r = int(clamp(p.r, 1.0, 128.0)); - vec2 step = vec2(p.dx, p.dy) / vec2(p.rx, p.ry); - float inv2s2 = 0.5 / (p.s * p.s); - vec4 sum = texture(sampler2D(u_tex,u_samp), uv); - float wsum = 1.0; - for (int i = 1; i <= r; ++i) { - float w = exp(-float(i*i) * inv2s2); - vec2 off = step * float(i); - sum += texture(sampler2D(u_tex,u_samp), uv + off) * w; - sum += texture(sampler2D(u_tex,u_samp), uv - off) * w; - wsum += 2.0 * w; - } - fragColor = sum / wsum; -} -)glsl"; }; //============================================================================== @@ -438,27 +400,12 @@ void main() { class PixelateEffect : public SinglePassEffect { protected: - const char* getFragmentSource() const override { return kPixelateFrag; } + const char* getShaderName() const override { return "effect_pixelate"; } EffectParams getEffectParams (const yup::GpuTexture& input) const override { return { param, (float) input.getWidth(), (float) input.getHeight(), 0, 0, 0, 0, 0 }; } - - private: - static constexpr char kPixelateFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float bs,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); - float bs = max(1.0, p.bs); - vec2 block = floor(uv * vec2(p.resX, p.resY) / bs) * bs; - vec2 sampleUV = (block + 0.5 * bs) / vec2(p.resX, p.resY); - fragColor = texture(sampler2D(u_tex, u_samp), sampleUV); -} -)glsl"; }; //============================================================================== @@ -466,36 +413,12 @@ void main() { class EdgeEffect : public SinglePassEffect { protected: - const char* getFragmentSource() const override { return kEdgeFrag; } + const char* getShaderName() const override { return "effect_edge"; } EffectParams getEffectParams (const yup::GpuTexture& input) const override { return { param * 0.05f, (float) input.getWidth(), (float) input.getHeight(), 0, 0, 0, 0, 0 }; } - - private: - static constexpr char kEdgeFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float thr,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); - vec2 t = 1.0 / vec2(p.resX, p.resY); - vec4 tl = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,-1)*t); - vec4 top = texture(sampler2D(u_tex,u_samp), uv + vec2(0,-1)*t); - vec4 tr = texture(sampler2D(u_tex,u_samp), uv + vec2(1,-1)*t); - vec4 lf = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,0)*t); - vec4 rt = texture(sampler2D(u_tex,u_samp), uv + vec2(1,0)*t); - vec4 bl = texture(sampler2D(u_tex,u_samp), uv + vec2(-1,1)*t); - vec4 bm = texture(sampler2D(u_tex,u_samp), uv + vec2(0,1)*t); - vec4 br = texture(sampler2D(u_tex,u_samp), uv + vec2(1,1)*t); - vec3 h = -tl.rgb - 2.0*top.rgb - tr.rgb + bl.rgb + 2.0*bm.rgb + br.rgb; - vec3 v = -tl.rgb - 2.0*lf.rgb + tr.rgb - bl.rgb + 2.0*rt.rgb + br.rgb; - float edge = length(h) + length(v) > p.thr ? 1.0 : 0.0; - fragColor = vec4(vec3(edge), 1.0); -} -)glsl"; }; //============================================================================== @@ -536,7 +459,7 @@ void main() { } protected: - const char* getFragmentSource() const override { return kWaveFrag; } + const char* getShaderName() const override { return "effect_wave"; } EffectParams getEffectParams (const yup::GpuTexture& input) const override { @@ -550,7 +473,7 @@ void main() { } private: - /** Mirrors the sample coordinate computed by kWaveFrag. */ + /** Mirrors the sample coordinate computed by effect_wave.frag. */ yup::Point getSampleUV (yup::Point uv, float aspect) const { const auto center = uv - yup::Point (0.5f, 0.5f); @@ -564,22 +487,6 @@ void main() { mutable std::atomic publishedAmplitude { 0.0f }; mutable std::atomic publishedTime { 0.0f }; - - static constexpr char kWaveFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float amp,freq,time,resX,resY,pad0,pad1,pad2; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); - float aspect = p.resX / p.resY; - vec2 center = uv - 0.5; - float dist = length(center * vec2(aspect, 1.0)); - float offset = sin(dist * p.freq - p.time) * p.amp * 0.003; - vec2 sampleUV = uv + normalize(center + 0.001) * offset; - fragColor = texture(sampler2D(u_tex, u_samp), sampleUV); -} -)glsl"; }; //============================================================================== @@ -590,35 +497,12 @@ void main() { SharpenEffect() { param = 4.0f; } protected: - const char* getFragmentSource() const override { return kSharpenFrag; } + const char* getShaderName() const override { return "effect_sharpen"; } EffectParams getEffectParams (const yup::GpuTexture& input) const override { return { param * 0.1f, (float) input.getWidth(), (float) input.getHeight(), 0, 0, 0, 0, 0 }; } - - private: - static constexpr char kSharpenFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float str,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); - vec2 t = 1.0 / vec2(p.resX, p.resY); - vec4 c = texture(sampler2D(u_tex,u_samp), uv); - vec4 bl = c - 0.25 * ( - texture(sampler2D(u_tex,u_samp), uv + vec2(-1,-1)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2( 0,-1)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2( 1,-1)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2(-1, 0)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2( 1, 0)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2(-1, 1)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2( 0, 1)*t) + - texture(sampler2D(u_tex,u_samp), uv + vec2( 1, 1)*t)) * 0.125; - fragColor = mix(c, c + bl * p.str, 0.8); -} -)glsl"; }; //============================================================================== @@ -629,33 +513,12 @@ void main() { CRTScanEffect() { param = 12.0f; } protected: - const char* getFragmentSource() const override { return kCRTFrag; } + const char* getShaderName() const override { return "effect_crt"; } EffectParams getEffectParams (const yup::GpuTexture& input) const override { return { param * 0.02f, (float) input.getWidth(), (float) input.getHeight(), 0, 0, 0, 0, 0 }; } - - private: - static constexpr char kCRTFrag[] = R"glsl(#version 450 -layout(set=0,binding=0) uniform texture2D u_tex; -layout(set=0,binding=1) uniform sampler u_samp; -layout(set=0,binding=2) uniform Params { float intensity,resX,resY,pad0,pad1,pad2,pad3,pad4; } p; -layout(location=0) out vec4 fragColor; -void main() { - vec2 uv = gl_FragCoord.xy / vec2(p.resX, p.resY); - vec4 col = texture(sampler2D(u_tex, u_samp), uv); - // Scanlines - float scanline = sin(uv.y * p.resY * 1.2) * 0.5 + 0.5; - col.rgb *= 1.0 - (1.0 - scanline) * p.intensity * 0.6; - // Vignette - vec2 v = uv - 0.5; - col.rgb *= 1.0 - dot(v, v) * p.intensity * 0.8; - // Slight green tint - col.rgb *= vec3(0.95, 1.05, 0.9); - fragColor = col; -} -)glsl"; }; //============================================================================== diff --git a/examples/graphics/source/examples/ComputeParticles.h b/examples/graphics/source/examples/ComputeParticles.h index f0e106b5e..f229ef489 100644 --- a/examples/graphics/source/examples/ComputeParticles.h +++ b/examples/graphics/source/examples/ComputeParticles.h @@ -35,7 +35,7 @@ Requirements: - A GpuDevice with compute shader support (Metal, D3D11, WebGPU, GL 4.3+) - - YUP_ENABLE_SHADER_TRANSPILER for online GLSL → native compilation + - The particles_update and particles_draw shader bundles, precompiled from data/shaders @see GpuComputePipeline, GpuComputePass, GpuPipeline, GpuRenderPass */ @@ -185,177 +185,6 @@ class ComputeParticlesDemo : public yup::Component 0.5f }; - //============================================================================== - /** GLSL 450 compute shader: particle physics simulation. - - Particle data is stored as a flat float array in an SSBO to avoid - struct-based layouts that can confuse the Metal transpiler's - binding reflection. Each particle occupies 12 floats (48 bytes): - offset 0: posX, posY - offset 2: velX, velY - offset 4: colR, colG, colB, colA - offset 8: lifetime - offset 9: age - offset 10-11: padding (unused) - */ - /** GLSL 450 compute shader: particle physics simulation. - - Each particle occupies 12 floats (48 bytes in std430): - offset 0: posX, posY - offset 2: velX, velY - offset 4: colR, colG, colB, colA - offset 8: lifetime - offset 9: age - offset 10-11: padding (unused) - - Bindings: UBO at (0,0), SSBO at (0,1) — matching Metal's - declaration-order buffer indexing. - */ - static constexpr const char kComputeSource[] = R"glsl(#version 450 -layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in; - -layout(std140, set = 0, binding = 0) uniform Params { - float deltaTime; - float gravity; - float restitution; - float particleCountF; - float simLeft; - float simRight; - float simBottom; - float simTop; -} params; - -layout(std430, set = 0, binding = 1) buffer ParticleBuffer { - float data[]; -}; - -// Simple hash function for pseudo-random numbers per particle. -uint wangHash(uint seed) { - seed = (seed ^ 61u) ^ (seed >> 16u); - seed *= 9u; - seed = seed ^ (seed >> 4u); - seed *= 0x27d4eb2du; - seed = seed ^ (seed >> 15u); - return seed; -} - -void main() { - uint idx = gl_GlobalInvocationID.x; - uint particleCount = uint(params.particleCountF); - if (idx >= particleCount) - return; - - uint base = idx * 12u; - - // Read particle state from the flat array. - vec2 pos = vec2(data[base + 0u], data[base + 1u]); - vec2 vel = vec2(data[base + 2u], data[base + 3u]); - vec4 col = vec4(data[base + 4u], data[base + 5u], data[base + 6u], data[base + 7u]); - float lifetime = data[base + 8u]; - float age = data[base + 9u]; - - // Age the particle. - age += params.deltaTime; - - // Respawn when lifetime expires — explode outward from the centre. - if (age >= lifetime) { - float angle = float(wangHash(idx * 2u + uint(age * 1000.0))) / float(0xFFFFFFFFu) * 6.283185307; - float speed = float(wangHash(idx * 3u)) / float(0xFFFFFFFFu) * 1.8 + 0.2; - - pos = vec2(0.0, params.simBottom + 1.4); - vel = vec2(cos(angle), sin(angle)) * speed; - age = 0.0; - lifetime = float(wangHash(idx * 5u)) / float(0xFFFFFFFFu) * 1.5 + 0.3; - - // Random saturated color. - uint cr = wangHash(idx * 7u); - uint cg = wangHash(idx * 11u); - uint cb = wangHash(idx * 13u); - col = vec4( - float(cr & 0xFFu) / 255.0, - float(cg & 0xFFu) / 255.0, - float(cb & 0xFFu) / 255.0, - 1.0 - ); - } - - // Apply gravity. - vel.y -= params.gravity * params.deltaTime; - - // Integrate position. - pos += vel * params.deltaTime; - - // Bounce off ground. - if (pos.y < params.simBottom) { - pos.y = params.simBottom; - vel.y = abs(vel.y) * params.restitution; - vel.x *= 0.92; - } - - // Bounce off side walls (viewport edges). - if (pos.x < params.simLeft) { - vel.x = abs(vel.x) * 0.7; - pos.x = params.simLeft; - } - if (pos.x > params.simRight) { - vel.x = -abs(vel.x) * 0.7; - pos.x = params.simRight; - } - - // Write back to the flat array. - data[base + 0u] = pos.x; - data[base + 1u] = pos.y; - data[base + 2u] = vel.x; - data[base + 3u] = vel.y; - data[base + 4u] = col.r; - data[base + 5u] = col.g; - data[base + 6u] = col.b; - data[base + 7u] = col.a; - data[base + 8u] = lifetime; - data[base + 9u] = age; -} -)glsl"; - - //============================================================================== - /** GLSL 450 vertex shader: expands each vertex into a clip-space quad corner. */ - static constexpr const char kRenderVertSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 aCenter; -layout(location = 1) in vec2 aOffset; -layout(location = 2) in vec4 aColor; -layout(location = 3) in vec2 aSize; - -layout(location = 0) out vec4 vColor; -layout(location = 1) out vec2 vOffset; - -void main() { - gl_Position = vec4(aCenter + aOffset * aSize, 0.0, 1.0); - vColor = aColor; - vOffset = aOffset; -} -)glsl"; - - //============================================================================== - /** GLSL 450 fragment shader: hard opaque circle, no blending. - - vOffset is the raw quad corner offset in [-0.5, 0.5]. - Multiply by 2 to normalise to [-1, 1] for a correct - unit-circle distance test that works with any viewport aspect. */ - static constexpr const char kRenderFragSource[] = R"glsl(#version 450 -layout(location = 0) in vec4 vColor; -layout(location = 1) in vec2 vOffset; - -layout(location = 0) out vec4 outColor; - -void main() { - // Raw offset is always [-0.5, 0.5] on both axes regardless of - // viewport aspect compensation. Normalise to [-1, 1] for a true circle. - float dist = length(vOffset * 2.0); - if (dist > 1.0) - discard; - outColor = vColor; -} -)glsl"; - //============================================================================== void initGpu() { @@ -371,12 +200,17 @@ void main() { return; } - // Compile the compute pipeline from GLSL. - yup::String glslSource = yup::String::fromUTF8 (kComputeSource, sizeof (kComputeSource) - 1); + auto computeBundle = loadShaderBundle ("particles_update"); + if (computeBundle.failed()) + { + statusLabel->setText ("Compute shader load failed: " + computeBundle.getErrorMessage().substring (0, 60), + yup::dontSendNotification); + YUP_DBG ("Compute shader load failed: " << computeBundle.getErrorMessage()); + return; + } -#if YUP_ENABLE_SHADER_TRANSPILER yup::GpuWorkgroupSize wgs { (uint32_t) kWorkgroupSize, 1, 1 }; - auto computeResult = yup::GpuComputePipeline::compileFromGlsl (device, glslSource, wgs); + auto computeResult = yup::GpuComputePipeline::compileFromBundle (device, computeBundle.getReference(), wgs); if (computeResult.failed()) { @@ -387,16 +221,8 @@ void main() { } computePipeline = computeResult.getValue(); -#else - statusLabel->setText ("Shader transpiler not available (YUP_ENABLE_SHADER_TRANSPILER).", yup::dontSendNotification); - YUP_DBG ("Shader transpiler not available (YUP_ENABLE_SHADER_TRANSPILER)."); - return; -#endif // Compile the render pipeline. - yup::String vertSource = yup::String::fromUTF8 (kRenderVertSource, sizeof (kRenderVertSource) - 1); - yup::String fragSource = yup::String::fromUTF8 (kRenderFragSource, sizeof (kRenderFragSource) - 1); - yup::GpuPipelineOptions pipelineOpts; // Vertex buffer layout: 4 attributes, 40-byte stride. @@ -414,7 +240,7 @@ void main() { pipelineOpts.cullMode = yup::GpuCullMode::none; pipelineOpts.colorTargets.emplace_back().blendEnabled = false; - auto renderResult = yup::GpuPipeline::compileFromGlsl (device, vertSource, fragSource, pipelineOpts); + auto renderResult = compilePipelineFromBundle (device, "particles_draw", pipelineOpts); if (renderResult.failed()) { statusLabel->setText ("Render shader compile failed: " + renderResult.getErrorMessage().substring (0, 60), diff --git a/examples/graphics/source/examples/FluidSimulation.h b/examples/graphics/source/examples/FluidSimulation.h index 89c603a4e..f2e290b34 100644 --- a/examples/graphics/source/examples/FluidSimulation.h +++ b/examples/graphics/source/examples/FluidSimulation.h @@ -325,514 +325,9 @@ class FluidSimulationDemo : public yup::Component Config config; //============================================================================== - // ---- Fullscreen-triangle vertex shader (shared by every pass) ------------- - // No vertex buffers: 3 vertices generated from gl_VertexIndex (the same - // pattern used by the blur pass in SpinningCubeDemo). vUv is the logical - // (0..1)^2 coordinate of each pixel. - - static constexpr char kFullscreenVertSource[] = R"glsl(#version 450 -layout(location = 0) out vec2 vUv; -void main() { - uint idx = gl_VertexIndex; - vec2 pos = vec2(float((idx & 1u) << 2u) - 1.0, - float((idx & 2u) << 1u) - 1.0); - vUv = pos * 0.5 + 0.5; - gl_Position = vec4(pos, 0.0, 1.0); -} -)glsl"; - - //============================================================================== - // ---- Shared GLSL snippets ---------------------------------------------------- - // kEncodeVelGlsl / kEncodeScalarGlsl pack the sim fields into the 8-bit - // channels of the surfaces (see the class doc). kSuvGlsl maps a logical - // sample coordinate to the backend's texture space. Each fragment source - // is assembled at compile time as head + codec(s) + kSuvGlsl + body. - - static constexpr char kEncodeVelGlsl[] = R"glsl( -vec4 encodeVel(vec2 v) { - vec2 t = clamp((v + vec2(1000.0)) * vec2(0.0005), vec2(0.0), vec2(1.0)); - vec2 x = floor(t * vec2(65535.0) + vec2(0.5)); - vec2 hi = floor(x / vec2(256.0)); - vec2 lo = x - hi * vec2(256.0); - return vec4(lo.x, hi.x, lo.y, hi.y) / 255.0; -} -vec2 decodeVel(vec4 c) { - vec2 lo = floor(vec2(c.r, c.b) * vec2(255.0) + vec2(0.5)); - vec2 hi = floor(vec2(c.g, c.a) * vec2(255.0) + vec2(0.5)); - vec2 x = hi * vec2(256.0) + lo; - return x * vec2(2000.0 / 65535.0) - vec2(1000.0); -} -)glsl"; - - static constexpr char kEncodeScalarGlsl[] = R"glsl( -vec3 encodeScalar(float v) { - float t = clamp((v + 2048.0) * (1.0 / 4096.0), 0.0, 1.0); - float x = floor(t * 16777215.0 + 0.5); - float b0 = mod(x, 256.0); - float b1 = mod(floor(x / 256.0), 256.0); - float b2 = floor(x / 65536.0); - return vec3(b0, b1, b2) / 255.0; -} -float decodeScalar(vec3 c) { - vec3 b = floor(c * vec3(255.0) + vec3(0.5)); - float x = b.x + b.y * 256.0 + b.z * 65536.0; - return x * (4096.0 / 16777215.0) - 2048.0; -} -)glsl"; - - static constexpr char kSuvGlsl[] = R"glsl( -vec2 suv(vec2 uv) { - return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); -} -)glsl"; - - //============================================================================== - // ---- Pass fragment shaders (ported from the original) ----------------------- - - /** Clears a surface to a flat color (initialisation only). */ - static constexpr char kClearFragSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float colorR; float colorG; float colorB; float colorA; - float pad0; float pad1; float pad2; float pad3; - float pad4; float pad5; float pad6; float pad7; - float pad8; float pad9; float pad10; float pad11; -} u; -layout(location = 0) out vec4 fragColor; -void main() { - fragColor = vec4(u.colorR, u.colorG, u.colorB, u.colorA); -} -)glsl"; - - // Velocity splat: adds a radial velocity impulse to the (encoded) velocity field. - static constexpr char kSplatVelocityFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float aspectRatio; - float radius; - float pointX; float pointY; - float colorR; float colorG; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; float pad6; - float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kSplatVelocityFragBody[] = R"glsl( -void main() { - vec2 p = vUv - vec2(u.pointX, u.pointY); - p.x *= u.aspectRatio; - float falloff = exp(-dot(p, p) / max(u.radius, 0.000001)); - - vec2 vel = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv))); - vel += falloff * vec2(u.colorR, u.colorG); - fragColor = encodeVel(vel); -} -)glsl"; - - /** Dye splat: adds a radial color blob to the dye field. */ - static constexpr char kSplatDyeFragSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float aspectRatio; - float radius; - float pointX; float pointY; - float colorR; float colorG; float colorB; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; - -vec2 suv(vec2 uv) { - return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); -} -void main() { - vec2 p = vUv - vec2(u.pointX, u.pointY); - p.x *= u.aspectRatio; - float falloff = exp(-dot(p, p) / max(u.radius, 0.000001)); - - vec3 base = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; - fragColor = vec4(base + falloff * vec3(u.colorR, u.colorG, u.colorB), 1.0); -} -)glsl"; - - /** Curl of the velocity field (vorticity magnitude), stored as a scalar. */ - static constexpr char kCurlFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; - float pad12; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kCurlFragBody[] = R"glsl( -void main() { - vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); - - float L = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0)))).y; - float R = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0)))).y; - float T = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y)))).x; - float B = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y)))).x; - - float vorticity = R - L - T + B; - fragColor = vec4(encodeScalar(0.5 * vorticity), 1.0); -} -)glsl"; - - /** Vorticity confinement: sharpens swirls by adding force along the curl gradient. */ - static constexpr char kVorticityFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float curlStrength; - float dt; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(set = 0, binding = 3) uniform texture2D uTex1; -layout(set = 0, binding = 4) uniform sampler uSamp1; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kVorticityFragBody[] = R"glsl( -void main() { - vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); - - float L = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv - vec2(texel.x, 0.0))).rgb); - float R = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv + vec2(texel.x, 0.0))).rgb); - float T = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv + vec2(0.0, texel.y))).rgb); - float B = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv - vec2(0.0, texel.y))).rgb); - float C = decodeScalar(texture(sampler2D(uTex1, uSamp1), suv(vUv)).rgb); - - vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); - force /= length(force) + 0.0001; - force *= u.curlStrength * C; - force.y *= -1.0; - - vec2 velocity = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(vUv))); - velocity += force * u.dt; - velocity = clamp(velocity, vec2(-1000.0), vec2(1000.0)); - fragColor = encodeVel(velocity); -} -)glsl"; - - /** One Jacobi pressure iteration. - The divergence is computed inline from the velocity field (which is - unchanged during the solve), and an inputScale folds the per-frame - PRESSURE damping (the original's separate "clear" pass) into the first - iteration, so the whole pressure stage is a single shader. */ - static constexpr char kPressureFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float inputScale; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; - float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(set = 0, binding = 3) uniform texture2D uTex1; -layout(set = 0, binding = 4) uniform sampler uSamp1; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kPressureFragBody[] = R"glsl( -void main() { - vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); - - // Divergence of the velocity field at this texel (mirror boundaries). - vec2 vL = vUv - vec2(texel.x, 0.0); - vec2 vR = vUv + vec2(texel.x, 0.0); - vec2 vT = vUv + vec2(0.0, texel.y); - vec2 vB = vUv - vec2(0.0, texel.y); - - vec2 Cv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vUv))); - - float Lv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vL))).x; - float Rv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vR))).x; - float Tv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vT))).y; - float Bv = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vB))).y; - - if (vL.x < 0.0) Lv = -Cv.x; - if (vR.x > 1.0) Rv = -Cv.x; - if (vT.y > 1.0) Tv = -Cv.y; - if (vB.y < 0.0) Bv = -Cv.y; - - float divergence = 0.5 * (Rv - Lv + Tv - Bv); - - // Pressure neighbours, damped by inputScale (PRESSURE on the first - // iteration, 1 afterwards). - float s = u.inputScale; - float L = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb); - float R = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb); - float T = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb); - float B = s * decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb); - - float pressure = (L + R + B + T - divergence) * 0.25; - fragColor = vec4(encodeScalar(pressure), 1.0); -} -)glsl"; - - /** Gradient subtraction: projects the velocity onto a divergence-free field. */ - static constexpr char kGradientSubtractFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; - float pad12; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(set = 0, binding = 3) uniform texture2D uTex1; -layout(set = 0, binding = 4) uniform sampler uSamp1; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kGradientSubtractFragBody[] = R"glsl( -void main() { - vec2 texel = vec2(1.0 / u.sizeX, 1.0 / u.sizeY); - - float L = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb); - float R = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb); - float T = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb); - float B = decodeScalar(texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb); - - vec2 velocity = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(vUv))); - velocity -= vec2(R - L, T - B); - fragColor = encodeVel(velocity); -} -)glsl"; - - /** Semi-Lagrangian advection of an encoded field (velocity self-advection). */ - static constexpr char kAdvectVelocityFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float sizeX; float sizeY; - float dt; float dissipation; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kAdvectVelocityFragBody[] = R"glsl( -vec2 sampleEncoded(vec2 uv) { - vec2 dims = vec2(u.sizeX, u.sizeY); - vec2 st = uv * dims - 0.5; - vec2 b = floor(st); - vec2 f = st - b; - vec2 t00 = (clamp(b, vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t10 = (clamp(b + vec2(1.0, 0.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t01 = (clamp(b + vec2(0.0, 1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t11 = (clamp(b + vec2(1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 v00 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t00))); - vec2 v10 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t10))); - vec2 v01 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t01))); - vec2 v11 = decodeVel(texture(sampler2D(uTex0, uSamp0), suv(t11))); - return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y); -} -void main() { - vec2 uv = vUv; - vec2 vel = sampleEncoded(uv); - vec2 coord = uv - u.dt * vel * vec2(1.0 / u.sizeX, 1.0 / u.sizeY); - vec2 result = sampleEncoded(coord); - result /= 1.0 + u.dissipation * u.dt; - fragColor = encodeVel(result); -} -)glsl"; - - /** Semi-Lagrangian advection of the dye field (velocity sampled encoded). */ - static constexpr char kAdvectDyeFragHead[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float velSizeX; float velSizeY; - float srcSizeX; float srcSizeY; - float dt; float dissipation; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(set = 0, binding = 3) uniform texture2D uTex1; -layout(set = 0, binding = 4) uniform sampler uSamp1; -layout(location = 0) out vec4 fragColor; -)glsl"; - - static constexpr char kAdvectDyeFragBody[] = R"glsl( -vec2 sampleVel(vec2 uv) { - vec2 dims = vec2(u.velSizeX, u.velSizeY); - vec2 st = uv * dims - 0.5; - vec2 b = floor(st); - vec2 f = st - b; - vec2 t00 = (clamp(b, vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t10 = (clamp(b + vec2(1.0, 0.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t01 = (clamp(b + vec2(0.0, 1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 t11 = (clamp(b + vec2(1.0), vec2(0.0), dims - 1.0) + vec2(0.5)) / dims; - vec2 v00 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t00))); - vec2 v10 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t10))); - vec2 v01 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t01))); - vec2 v11 = decodeVel(texture(sampler2D(uTex1, uSamp1), suv(t11))); - return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y); -} -void main() { - vec2 uv = vUv; - vec2 vel = sampleVel(uv); - vec2 coord = uv - u.dt * vel * vec2(1.0 / u.velSizeX, 1.0 / u.velSizeY); - vec4 result = texture(sampler2D(uTex0, uSamp0), suv(coord)); - result /= 1.0 + u.dissipation * u.dt; - fragColor = vec4(result.rgb, 1.0); -} -)glsl"; - - /** Bloom prefilter - keeps only the bright parts of the dye (original curve). */ - static constexpr char kBloomPrefilterFragSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float threshold; - float curve0; float curve1; float curve2; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; - float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; - -vec2 suv(vec2 uv) { - return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); -} -void main() { - vec3 c = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; - float br = max(c.r, max(c.g, c.b)); - float rq = clamp(br - u.curve0, 0.0, u.curve1); - rq = u.curve2 * rq * rq; - c *= max(rq, br - u.threshold) / max(br, 0.0001); - fragColor = vec4(c, 1.0); -} -)glsl"; - - /** 4-tap blur - smooths the small bloom surface between two ping-pong buffers. */ - static constexpr char kBlur4FragSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float srcSizeX; float srcSizeY; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float pad7; float pad8; float pad9; float pad10; float pad11; - float pad12; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(location = 0) out vec4 fragColor; - -vec2 suv(vec2 uv) { - return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); -} -void main() { - vec2 texel = vec2(1.0 / u.srcSizeX, 1.0 / u.srcSizeY); - - vec4 sum = vec4(0.0); - sum += texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))); - sum += texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))); - sum += texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))); - sum += texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))); - sum *= 0.25; - fragColor = sum; -} -)glsl"; - - /** Final composite: shading, bloom add (gamma'd), ordered dither. */ - static constexpr char kDisplayFragSource[] = R"glsl(#version 450 -layout(location = 0) in vec2 vUv; -layout(set = 0, binding = 0) uniform Params { - float dyeSizeX; float dyeSizeY; - float shadingF; float bloomF; - float bloomIntensity; - float backR; float backG; float backB; - float pad0; float pad1; float pad2; float pad3; float pad4; float pad5; - float pad6; float flipY; -} u; -layout(set = 0, binding = 1) uniform texture2D uTex0; -layout(set = 0, binding = 2) uniform sampler uSamp0; -layout(set = 0, binding = 3) uniform texture2D uTex1; -layout(set = 0, binding = 4) uniform sampler uSamp1; -layout(location = 0) out vec4 fragColor; - -vec3 linearToGamma(vec3 color) { - color = max(color, vec3(0.0)); - return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0.0)); -} - -const float kDither[16] = float[16]( - 0.0, 8.0, 2.0, 10.0, - 12.0, 4.0, 14.0, 6.0, - 3.0, 11.0, 1.0, 9.0, - 15.0, 7.0, 13.0, 5.0 -); - -vec2 suv(vec2 uv) { - return vec2(uv.x, mix(uv.y, 1.0 - uv.y, u.flipY)); -} -void main() { - vec3 c = texture(sampler2D(uTex0, uSamp0), suv(vUv)).rgb; - - if (u.shadingF > 0.5) - { - vec2 texel = vec2(1.0 / u.dyeSizeX, 1.0 / u.dyeSizeY); - - vec3 lc = texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(texel.x, 0.0))).rgb; - vec3 rc = texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(texel.x, 0.0))).rgb; - vec3 tc = texture(sampler2D(uTex0, uSamp0), suv(vUv + vec2(0.0, texel.y))).rgb; - vec3 bc = texture(sampler2D(uTex0, uSamp0), suv(vUv - vec2(0.0, texel.y))).rgb; - - float dx = length(rc) - length(lc); - float dy = length(tc) - length(bc); - - vec3 n = normalize(vec3(dx, dy, length(texel))); - vec3 l = vec3(0.0, 0.0, 1.0); - - float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0); - c *= diffuse; - } - - if (u.bloomF > 0.5) - { - // Sample the small bloom surface with hardware linear filtering: the - // smooth upscale hides the 8-bit steps of the bloom buffer. - vec3 bloom = texture(sampler2D(uTex1, uSamp1), suv(vUv)).rgb * u.bloomIntensity; - bloom = linearToGamma(bloom); - c += bloom; - } - - float alpha = max(c.r, max(c.g, c.b)); - vec3 outC = c + vec3(u.backR, u.backG, u.backB) * (1.0 - alpha); - - // Ordered (Bayer) dithering hides the 8-bit quantization of the surfaces on - // slow fades, where gamma-expanded steps would otherwise band. Amplitude is - // half an LSB, so no visible grain. - ivec2 pc = ivec2(vUv * vec2(u.dyeSizeX, u.dyeSizeY)) & ivec2(3); - float dither = (kDither[pc.y * 4 + pc.x] + 0.5) / 16.0 - 0.5; - outC += vec3(dither) / 255.0; - - fragColor = vec4(outC, 1.0); -} -)glsl"; + // ---- Shaders ------------------------------------------------------------------ + // Every pass is a fluid_*.frag bundle precompiled from data/shaders, drawn with + // the fullscreen triangle of fluid_fullscreen.vert. //============================================================================== // ---- Resource bookkeeping ---------------------------------------------------- @@ -899,7 +394,6 @@ void main() { struct CompileJob { yup::String name; - std::vector parts; yup::GpuPipeline::Ptr* target = nullptr; }; @@ -920,10 +414,6 @@ void main() { return; } -#if ! YUP_ENABLE_SHADER_TRANSPILER - statusLabel->setText ("Shader transpiler not available (YUP_ENABLE_SHADER_TRANSPILER).", yup::dontSendNotification); - return; -#else if (! capturedContext->isGpuAvailable()) { statusLabel->setText ("GPU context unavailable.", yup::dontSendNotification); @@ -932,51 +422,38 @@ void main() { } // Queue all pipeline compiles; they run a few per frame in pumpCompilation(). - auto addJob = [this] (yup::StringRef name, std::initializer_list parts, yup::GpuPipeline::Ptr& target) + auto addJob = [this] (yup::StringRef name, yup::GpuPipeline::Ptr& target) { - CompileJob job; - job.name = name; - job.parts.assign (parts.begin(), parts.end()); - job.target = ⌖ - compileJobs.push_back (std::move (job)); + compileJobs.push_back ({ name, &target }); }; - addJob ("clear", { kClearFragSource }, clearPipeline); - addJob ("splatVelocity", { kSplatVelocityFragHead, kEncodeVelGlsl, kSuvGlsl, kSplatVelocityFragBody }, splatVelocityPipeline); - addJob ("splatDye", { kSplatDyeFragSource }, splatDyePipeline); - addJob ("curl", { kCurlFragHead, kEncodeVelGlsl, kEncodeScalarGlsl, kSuvGlsl, kCurlFragBody }, curlPipeline); - addJob ("vorticity", { kVorticityFragHead, kEncodeVelGlsl, kEncodeScalarGlsl, kSuvGlsl, kVorticityFragBody }, vorticityPipeline); - addJob ("pressure", { kPressureFragHead, kEncodeVelGlsl, kEncodeScalarGlsl, kSuvGlsl, kPressureFragBody }, pressurePipeline); - addJob ("gradientSubtract", { kGradientSubtractFragHead, kEncodeVelGlsl, kEncodeScalarGlsl, kSuvGlsl, kGradientSubtractFragBody }, gradientSubtractPipeline); - addJob ("advectVelocity", { kAdvectVelocityFragHead, kEncodeVelGlsl, kSuvGlsl, kAdvectVelocityFragBody }, advectVelocityPipeline); - addJob ("advectDye", { kAdvectDyeFragHead, kEncodeVelGlsl, kSuvGlsl, kAdvectDyeFragBody }, advectDyePipeline); - addJob ("bloomPrefilter", { kBloomPrefilterFragSource }, bloomPrefilterPipeline); - addJob ("blur4", { kBlur4FragSource }, blur4Pipeline); - addJob ("display", { kDisplayFragSource }, displayPipeline); + addJob ("fluid_clear", clearPipeline); + addJob ("fluid_splat_velocity", splatVelocityPipeline); + addJob ("fluid_splat_dye", splatDyePipeline); + addJob ("fluid_curl", curlPipeline); + addJob ("fluid_vorticity", vorticityPipeline); + addJob ("fluid_pressure", pressurePipeline); + addJob ("fluid_gradient_subtract", gradientSubtractPipeline); + addJob ("fluid_advect_velocity", advectVelocityPipeline); + addJob ("fluid_advect_dye", advectDyePipeline); + addJob ("fluid_bloom_prefilter", bloomPrefilterPipeline); + addJob ("fluid_blur4", blur4Pipeline); + addJob ("fluid_display", displayPipeline); compileCursor = 0; compiling = true; statusLabel->setText ("Compiling shaders...", yup::dontSendNotification); -#endif } /** Compiles one queued job into its target pipeline member. */ void compileJob (const CompileJob& job) { - yup::String fragmentSource; - for (const char* part : job.parts) - fragmentSource += yup::String::fromUTF8 (part); - yup::GpuPipelineOptions options; options.topology = yup::GpuPrimitiveTopology::triangleList; options.cullMode = yup::GpuCullMode::none; options.colorTargets.emplace_back().blendEnabled = false; // passes overwrite every pixel - auto result = yup::GpuPipeline::compileFromGlsl ( - device, - yup::String::fromUTF8 (kFullscreenVertSource), - fragmentSource, - options); + auto result = compilePipelineFromBundle (device, job.name, options); if (result.failed()) { diff --git a/examples/graphics/source/examples/Pbr.h b/examples/graphics/source/examples/Pbr.h index 3d2f29195..34e3ad317 100644 --- a/examples/graphics/source/examples/Pbr.h +++ b/examples/graphics/source/examples/Pbr.h @@ -273,9 +273,6 @@ class PbrDemo : public yup::Component return; } -#if ! YUP_ENABLE_SHADER_TRANSPILER - statusLabel->setText ("Requires YUP_ENABLE_SHADER_TRANSPILER", yup::dontSendNotification); -#else auto device = capturedContext->getGpuDevice(); hdrFormat = device->isFormatRenderable (yup::GpuTextureFormat::rgba16float) @@ -303,7 +300,6 @@ class PbrDemo : public yup::Component + (hdrFormat == yup::GpuTextureFormat::rgba16float ? "rgba16float" : "rgba8unorm") + ", 15 materials, clearcoat, ACES", yup::dontSendNotification); -#endif } bool createGeometry (yup::GpuDevice::Ptr device) @@ -581,15 +577,11 @@ class PbrDemo : public yup::Component // ---- Pipelines ----------------------------------------------------------- -#if YUP_ENABLE_SHADER_TRANSPILER bool compilePipelines (yup::GpuDevice::Ptr device) { - auto compile = [&] (const char* name, - const yup::String& vertexSource, - const yup::String& fragmentSource, - const yup::GpuPipelineOptions& options) -> yup::GpuPipeline::Ptr + auto compile = [&] (const char* name, yup::StringRef shaderName, const yup::GpuPipelineOptions& options) -> yup::GpuPipeline::Ptr { - auto result = yup::GpuPipeline::compileFromGlsl (device, vertexSource, fragmentSource, options); + auto result = compilePipelineFromBundle (device, shaderName, options); if (result.failed()) { @@ -601,17 +593,13 @@ class PbrDemo : public yup::Component return result.getReference(); }; - const auto fullscreenVert = yup::String::fromUTF8 (kFullscreenVert, sizeof (kFullscreenVert) - 1); - const auto faceHelpers = yup::String::fromUTF8 (kFaceHelpers, sizeof (kFaceHelpers) - 1); - const auto sceneVert = yup::String::fromUTF8 (kSceneVert, sizeof (kSceneVert) - 1); + skyPipeline = compile ("Sky bake", "pbr_sky", bakePipelineOptions (hdrFormat)); + irradiancePipeline = compile ("Irradiance", "pbr_irradiance", bakePipelineOptions (hdrFormat)); + prefilterPipeline = compile ("Prefilter", "pbr_prefilter", bakePipelineOptions (hdrFormat)); + brdfPipeline = compile ("BRDF LUT", "pbr_brdf", bakePipelineOptions (brdfFormat)); - skyPipeline = compile ("Sky bake", fullscreenVert, faceHelpers + kSkyFrag, bakePipelineOptions (hdrFormat)); - irradiancePipeline = compile ("Irradiance", fullscreenVert, faceHelpers + kIrradianceFrag, bakePipelineOptions (hdrFormat)); - prefilterPipeline = compile ("Prefilter", fullscreenVert, faceHelpers + kPrefilterFrag, bakePipelineOptions (hdrFormat)); - brdfPipeline = compile ("BRDF LUT", fullscreenVert, yup::String::fromUTF8 (kBrdfFrag, sizeof (kBrdfFrag) - 1), bakePipelineOptions (brdfFormat)); - - backgroundPipeline = compile ("Background", sceneVert, yup::String::fromUTF8 (kBackgroundFrag, sizeof (kBackgroundFrag) - 1), backgroundPipelineOptions()); - scenePipeline = compile ("Scene", sceneVert, yup::String::fromUTF8 (kSceneFrag, sizeof (kSceneFrag) - 1), scenePipelineOptions()); + backgroundPipeline = compile ("Background", "pbr_background", backgroundPipelineOptions()); + scenePipeline = compile ("Scene", "pbr_scene", scenePipelineOptions()); return skyPipeline != nullptr && irradiancePipeline != nullptr @@ -620,9 +608,6 @@ class PbrDemo : public yup::Component && backgroundPipeline != nullptr && scenePipeline != nullptr; } -#else - bool compilePipelines (yup::GpuDevice::Ptr) { return false; } -#endif static yup::GpuPipelineOptions bakePipelineOptions (yup::GpuTextureFormat format) { @@ -830,495 +815,6 @@ class PbrDemo : public yup::Component pass.finish(); } - //============================================================================== - // ---- Shaders ------------------------------------------------------------- - - /** Fullscreen triangle from the vertex index, with a UV for the bake passes. */ - static constexpr char kFullscreenVert[] = R"glsl(#version 450 -layout(location = 0) out vec2 v_uv; -void main() { - float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; - float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; - v_uv = vec2(x, y) * 0.5 + 0.5; - gl_Position = vec4(x, y, 0.0, 1.0); -} -)glsl"; - - /** Shared prelude for the cube bakes: the Bake block, the face parameterisation - and the analytic sky. Concatenated in front of each bake fragment body. */ - static constexpr char kFaceHelpers[] = R"glsl(#version 450 -layout(location = 0) in vec2 v_uv; -layout(location = 0) out vec4 fragColor; -layout(set = 0, binding = 0) uniform Bake { - int face; - float roughness; - float flipY; - float envSize; - float sunIntensity; -} u; - -const float PI = 3.14159265359; - -// Maps a face index plus a [0,1] texel coordinate onto the direction the cube -// map hardware associates with that texel, matching the GL/Metal/D3D layout. -vec3 faceDirection(int face, vec2 uv) { - vec2 c = uv * 2.0 - 1.0; - if (face == 0) return normalize(vec3( 1.0, -c.y, -c.x)); - if (face == 1) return normalize(vec3(-1.0, -c.y, c.x)); - if (face == 2) return normalize(vec3( c.x, 1.0, c.y)); - if (face == 3) return normalize(vec3( c.x, -1.0, -c.y)); - if (face == 4) return normalize(vec3( c.x, -c.y, 1.0)); - return normalize(vec3(-c.x, -c.y, -1.0)); -} - -vec2 faceUV() { - return vec2(v_uv.x, u.flipY > 0.5 ? 1.0 - v_uv.y : v_uv.y); -} - -vec3 proceduralSky(vec3 d) { - vec3 sunDir = normalize(vec3(0.35, 0.28, -0.90)); - float up = clamp(d.y * 0.5 + 0.5, 0.0, 1.0); - vec3 sky = mix(vec3(0.58, 0.68, 0.84), vec3(0.10, 0.22, 0.55), pow(up, 0.6)); - float horizon = 1.0 - smoothstep(-0.15, 0.45, d.y); // warm haze band near the horizon - sky += vec3(0.24, 0.13, 0.05) * horizon * 0.55; - vec3 ground = vec3(0.16, 0.14, 0.12); - vec3 col = mix(ground, sky, smoothstep(-0.06, 0.10, d.y)); - float cosSun = clamp(dot(d, sunDir), 0.0, 1.0); - // The sun disk and its halo scale with u.sunIntensity so the slider - // re-bakes the whole environment (and its IBL derivatives) coherently. - col += vec3(1.7, 1.45, 1.15) * u.sunIntensity * pow(cosSun, 600.0); - col += vec3(0.16, 0.12, 0.07) * u.sunIntensity * pow(cosSun, 10.0); - return col; -} - -// Hammersley low-discrepancy sequence, used by the GGX importance sampler. -float radicalInverse(uint bits) { - bits = (bits << 16u) | (bits >> 16u); - bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); - bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); - bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); - bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); - return float(bits) * 2.3283064365386963e-10; -} - -vec2 hammersley(uint i, uint n) { - return vec2(float(i) / float(n), radicalInverse(i)); -} - -vec3 importanceSampleGGX(vec2 xi, vec3 n, float roughness) { - float a = roughness * roughness; - float phi = 2.0 * PI * xi.x; - float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); - float sinTheta = sqrt(1.0 - cosTheta * cosTheta); - - vec3 h = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); - - vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); - vec3 tangent = normalize(cross(up, n)); - vec3 bitangent = cross(n, tangent); - return normalize(tangent * h.x + bitangent * h.y + n * h.z); -} -)glsl"; - - static constexpr char kSkyFrag[] = R"glsl( -void main() { - fragColor = vec4(proceduralSky(faceDirection(u.face, faceUV())), 1.0); -} -)glsl"; - - static constexpr char kIrradianceFrag[] = R"glsl( -layout(set = 0, binding = 1) uniform textureCube u_env; -layout(set = 0, binding = 2) uniform sampler u_samp; - -void main() { - vec3 n = faceDirection(u.face, faceUV()); - - vec3 up = abs(n.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(0.0, 0.0, 1.0); - vec3 right = normalize(cross(up, n)); - up = normalize(cross(n, right)); - - vec3 irradiance = vec3(0.0); - float samples = 0.0; - - for (float phi = 0.0; phi < 2.0 * PI; phi += 0.15) { - for (float theta = 0.0; theta < 0.5 * PI; theta += 0.05) { - vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); - vec3 direction = tangentSample.x * right + tangentSample.y * up + tangentSample.z * n; - irradiance += textureLod(samplerCube(u_env, u_samp), direction, 0.0).rgb * cos(theta) * sin(theta); - samples += 1.0; - } - } - - fragColor = vec4(PI * irradiance / max(samples, 1.0), 1.0); -} -)glsl"; - - static constexpr char kPrefilterFrag[] = R"glsl( -layout(set = 0, binding = 1) uniform textureCube u_env; -layout(set = 0, binding = 2) uniform sampler u_samp; - -const uint kSampleCount = 256u; - -void main() { - vec3 n = faceDirection(u.face, faceUV()); - vec3 v = n; - - vec3 prefiltered = vec3(0.0); - float totalWeight = 0.0; - - for (uint i = 0u; i < kSampleCount; ++i) { - vec3 h = importanceSampleGGX(hammersley(i, kSampleCount), n, u.roughness); - vec3 l = normalize(2.0 * dot(v, h) * h - v); - - float nDotL = dot(n, l); - if (nDotL > 0.0) { - prefiltered += textureLod(samplerCube(u_env, u_samp), l, 0.0).rgb * nDotL; - totalWeight += nDotL; - } - } - - fragColor = vec4(prefiltered / max(totalWeight, 0.001), 1.0); -} -)glsl"; - - static constexpr char kBrdfFrag[] = R"glsl(#version 450 -layout(location = 0) in vec2 v_uv; -layout(location = 0) out vec4 fragColor; -layout(set = 0, binding = 0) uniform Bake { - int face; - float roughness; - float flipY; - float envSize; - float sunIntensity; -} u; - -const float PI = 3.14159265359; -const uint kSampleCount = 512u; - -float radicalInverse(uint bits) { - bits = (bits << 16u) | (bits >> 16u); - bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); - bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); - bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); - bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); - return float(bits) * 2.3283064365386963e-10; -} - -vec3 importanceSampleGGX(vec2 xi, vec3 n, float roughness) { - float a = roughness * roughness; - float phi = 2.0 * PI * xi.x; - float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)); - float sinTheta = sqrt(1.0 - cosTheta * cosTheta); - - vec3 h = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); - - vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); - vec3 tangent = normalize(cross(up, n)); - vec3 bitangent = cross(n, tangent); - return normalize(tangent * h.x + bitangent * h.y + n * h.z); -} - -// Smith geometry term with the IBL (rather than direct-lighting) k. -float geometrySmith(float nDotV, float nDotL, float roughness) { - float k = (roughness * roughness) * 0.5; - float ggxV = nDotV / (nDotV * (1.0 - k) + k); - float ggxL = nDotL / (nDotL * (1.0 - k) + k); - return ggxV * ggxL; -} - -void main() { - vec2 uv = vec2(v_uv.x, u.flipY > 0.5 ? 1.0 - v_uv.y : v_uv.y); - - float nDotV = max(uv.x, 0.001); - float roughness = max(uv.y, 0.001); - - vec3 v = vec3(sqrt(1.0 - nDotV * nDotV), 0.0, nDotV); - vec3 n = vec3(0.0, 0.0, 1.0); - - float scale = 0.0; - float bias = 0.0; - - for (uint i = 0u; i < kSampleCount; ++i) { - vec2 xi = vec2(float(i) / float(kSampleCount), radicalInverse(i)); - vec3 h = importanceSampleGGX(xi, n, roughness); - vec3 l = normalize(2.0 * dot(v, h) * h - v); - - float nDotL = max(l.z, 0.0); - float nDotH = max(h.z, 0.0); - float vDotH = max(dot(v, h), 0.0); - - if (nDotL > 0.0) { - float g = geometrySmith(nDotV, nDotL, roughness); - float gVis = (g * vDotH) / max(nDotH * nDotV, 0.001); - float fc = pow(1.0 - vDotH, 5.0); - - scale += (1.0 - fc) * gVis; - bias += fc * gVis; - } - } - - fragColor = vec4(scale / float(kSampleCount), bias / float(kSampleCount), 0.0, 1.0); -} -)glsl"; - - /** Shared vertex stage for the sky backdrop and the sphere grid. A non-zero - backdropRadius in the Scene block wraps the same unit-sphere mesh around - the camera; zero positions the instanced sphere grid. - - The projection maps z/w into [0, 1], which every backend treats as a - monotonically increasing depth, so a single shader drives the depth test - correctly on all of them. */ - static constexpr char kSceneVert[] = R"glsl(#version 450 -layout(location = 0) in vec3 a_position; -layout(location = 1) in vec3 a_normal; -layout(location = 2) in vec2 a_uv; - -// Per-instance material, fed from a per-instance vertex buffer (locations 3-5). -layout(location = 3) in vec4 a_material; // metallic, roughness, uvScale, normalStrength -layout(location = 4) in vec4 a_tint; // linear albedo / metal f0 rgb, roughness-map amount -layout(location = 5) in vec4 a_extra; // clearcoat, clearcoat roughness, ao scale, unused - -layout(set = 0, binding = 0) uniform Scene { - vec4 camera; // yaw, pitch, distance, aspect - vec4 material; // exposure, sunIntensity, prefilterMaxLod, backdropRadius (0 = sphere grid) - vec4 light; // sun direction xyz -} u; - -layout(location = 0) out vec3 v_worldPosition; -layout(location = 1) out vec3 v_normal; -layout(location = 2) out vec2 v_uv; -layout(location = 3) out vec3 v_cameraPosition; -layout(location = 4) out vec4 v_material; -layout(location = 5) out vec4 v_tint; -layout(location = 6) out vec4 v_extra; - -const int kColumns = 5; -const int kRows = 3; -const float kSpacing = 2.6; - -void main() { - float yaw = u.camera.x; - float pitch = u.camera.y; - float orbitRadius = u.camera.z; - float aspect = u.camera.w; - - vec3 cameraPosition = vec3(sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch)) * orbitRadius; - - // A non-zero backdropRadius wraps the same unit-sphere mesh around the - // camera as the sky backdrop; zero positions the instanced sphere grid. - // gl_InstanceIndex is always 0 for the backdrop, which is drawn with a - // single instance. - float backdropRadius = u.material.w; - float isBackdrop = step(0.001, backdropRadius); - - int instance = gl_InstanceIndex; - int column = instance % kColumns; - int row = instance / kColumns; - - vec2 offset = (vec2(float(column), float(row)) - vec2(float(kColumns - 1), float(kRows - 1)) * 0.5) * kSpacing; - vec3 gridPosition = a_position + vec3(offset, 0.0); - vec3 backdropPosition = cameraPosition + a_position * backdropRadius; - vec3 worldPosition = mix(gridPosition, backdropPosition, isBackdrop); - - vec3 forward = normalize(-cameraPosition); - vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward)); - vec3 up = cross(forward, right); - - vec3 toVertex = worldPosition - cameraPosition; - vec3 viewSpace = vec3(dot(toVertex, right), dot(toVertex, up), dot(toVertex, forward)); - - // z/w lands in [0, 1], which every backend treats as monotonically increasing - // depth. Keep the near plane as far out as the scene allows: a near plane of - // 0.1 against a far plane of 120 would squeeze the whole grid into a fraction - // of a percent of the depth range and z-fight. - float fov = 1.7320508; // cot(30 degrees) => 60 degree vertical field of view - float zNear = 1.0; - float zFar = 120.0; - - gl_Position = vec4(viewSpace.x * fov / aspect, - viewSpace.y * fov, - zFar * (viewSpace.z - zNear) / (zFar - zNear), - viewSpace.z); - - v_worldPosition = worldPosition; - v_normal = a_normal; - v_uv = a_uv; - v_cameraPosition = cameraPosition; - - v_material = a_material; - v_tint = a_tint; - v_extra = a_extra; -} -)glsl"; - - static constexpr char kBackgroundFrag[] = R"glsl(#version 450 -layout(location = 0) in vec3 v_worldPosition; -layout(location = 3) in vec3 v_cameraPosition; - -layout(set = 0, binding = 0) uniform Scene { - vec4 camera; - vec4 material; - vec4 light; -} u; - -layout(set = 0, binding = 1) uniform textureCube u_environment; -layout(set = 0, binding = 2) uniform sampler u_samp; - -layout(location = 0) out vec4 fragColor; - -vec3 acesTonemap(vec3 x) { - const float a = 2.51; - const float b = 0.03; - const float c = 2.43; - const float d = 0.59; - const float e = 0.14; - return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); -} - -void main() { - vec3 direction = normalize(v_worldPosition - v_cameraPosition); - vec3 color = textureLod(samplerCube(u_environment, u_samp), direction, 0.0).rgb; - - color = acesTonemap(color * u.material.x); - fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); -} -)glsl"; - - static constexpr char kSceneFrag[] = R"glsl(#version 450 -layout(location = 0) in vec3 v_worldPosition; -layout(location = 1) in vec3 v_normal; -layout(location = 2) in vec2 v_uv; -layout(location = 3) in vec3 v_cameraPosition; -layout(location = 4) in vec4 v_material; -layout(location = 5) in vec4 v_tint; -layout(location = 6) in vec4 v_extra; - -layout(set = 0, binding = 0) uniform Scene { - vec4 camera; // yaw, pitch, distance, aspect - vec4 material; // exposure, sunIntensity, prefilterMaxLod, backdropRadius (0 = sphere grid) - vec4 light; // sun direction xyz -} u; - -layout(set = 0, binding = 1) uniform textureCube u_irradiance; -layout(set = 0, binding = 2) uniform textureCube u_prefilter; -layout(set = 0, binding = 3) uniform texture2D u_brdf; -layout(set = 0, binding = 4) uniform texture2D u_albedo; -layout(set = 0, binding = 5) uniform texture2D u_normalMap; -layout(set = 0, binding = 6) uniform sampler u_samp; -layout(set = 0, binding = 7) uniform texture2D u_rma; -layout(set = 0, binding = 8) uniform sampler u_sampRepeat; - -layout(location = 0) out vec4 fragColor; - -const float PI = 3.14159265359; - -float distributionGGX(float nDotH, float roughness) { - float a = roughness * roughness; - float a2 = a * a; - float d = nDotH * nDotH * (a2 - 1.0) + 1.0; - return a2 / max(PI * d * d, 0.0001); -} - -float geometrySmithDirect(float nDotV, float nDotL, float roughness) { - float r = roughness + 1.0; - float k = (r * r) / 8.0; - float ggxV = nDotV / (nDotV * (1.0 - k) + k); - float ggxL = nDotL / (nDotL * (1.0 - k) + k); - return ggxV * ggxL; -} - -vec3 acesTonemap(vec3 x) { - const float a = 2.51; - const float b = 0.03; - const float c = 2.43; - const float d = 0.59; - const float e = 0.14; - return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); -} - -void main() { - float metallic = clamp(v_material.x, 0.0, 1.0); - float baseRoughness = clamp(v_material.y, 0.06, 1.0); - float mapAmount = v_tint.a; - float aoScale = v_extra.z; - - // Analytic tangent frame for a unit sphere, perturbed by the uploaded - // normal map. - vec3 geometricNormal = normalize(v_normal); - - // cross(Y, N) collapses to zero at the poles, and normalizing that yields NaN - // shading normals in a band around them - pick a different reference axis there. - vec3 reference = abs(geometricNormal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); - vec3 tangent = normalize(cross(reference, geometricNormal)); - vec3 bitangent = cross(geometricNormal, tangent); - - vec2 uv = v_uv * max(v_material.z, 0.25); - vec3 tangentNormal = texture(sampler2D(u_normalMap, u_sampRepeat), uv).xyz * 2.0 - 1.0; - tangentNormal.xy *= v_material.w; - - vec3 n = normalize(mat3(tangent, bitangent, geometricNormal) * tangentNormal); - vec3 v = normalize(v_cameraPosition - v_worldPosition); - vec3 r = reflect(-v, n); - - vec3 albedoTex = pow(texture(sampler2D(u_albedo, u_sampRepeat), uv).rgb, vec3(2.2)); - vec3 albedo = albedoTex * v_tint.rgb; - - vec3 rma = texture(sampler2D(u_rma, u_sampRepeat), uv).rgb; - float roughness = clamp(mix(baseRoughness, baseRoughness * (rma.g / 0.5), mapAmount), 0.06, 1.0); - float ao = mix(1.0, rma.b, aoScale); - - vec3 f0 = mix(vec3(0.04), albedo, metallic); - - float nDotV = max(dot(n, v), 0.0001); - - // Direct sun contribution. - vec3 l = normalize(u.light.xyz); - vec3 h = normalize(v + l); - float nDotL = max(dot(n, l), 0.0); - float nDotH = max(dot(n, h), 0.0); - float vDotH = max(dot(v, h), 0.0); - - vec3 fresnel = f0 + (vec3(1.0) - f0) * pow(1.0 - vDotH, 5.0); - float ndf = distributionGGX(nDotH, roughness); - float geometry = geometrySmithDirect(nDotV, nDotL, roughness); - - vec3 specularDirect = (ndf * geometry * fresnel) / max(4.0 * nDotV * nDotL, 0.0001); - vec3 diffuseDirect = (vec3(1.0) - fresnel) * (1.0 - metallic) * albedo / PI; - vec3 direct = (diffuseDirect + specularDirect) * nDotL * u.material.y; - - // Image-based ambient: diffuse irradiance plus the split-sum specular term. - vec3 fresnelIbl = f0 + (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - nDotV, 5.0); - vec3 kD = (vec3(1.0) - fresnelIbl) * (1.0 - metallic); - - vec3 irradiance = textureLod(samplerCube(u_irradiance, u_samp), n, 0.0).rgb; - vec3 diffuseIbl = irradiance * albedo; - - // The prefilter chain is read by explicit LOD: a mip-narrowed texture view - // would silently degrade to mip 0 on OpenGL. - vec3 prefiltered = textureLod(samplerCube(u_prefilter, u_samp), r, roughness * u.material.z).rgb; - vec2 brdf = texture(sampler2D(u_brdf, u_samp), vec2(nDotV, roughness)).rg; - vec3 specularIbl = prefiltered * (fresnelIbl * brdf.x + brdf.y); - - // Clearcoat: a glossy dielectric layer whose own, much smoother roughness - // lobe replaces the base specular reflection as the coat builds up. - float clearcoat = v_extra.x; - if (clearcoat > 0.001) - { - float ccRoughness = clamp(v_extra.y, 0.03, 0.5); - float fcc = 0.04 + 0.96 * pow(1.0 - nDotV, 5.0); - vec2 ccBrdf = texture(sampler2D(u_brdf, u_samp), vec2(nDotV, ccRoughness)).rg; - vec3 ccPrefiltered = textureLod(samplerCube(u_prefilter, u_samp), r, ccRoughness * u.material.z).rgb; - vec3 ccSpecular = ccPrefiltered * (fcc * ccBrdf.x + ccBrdf.y); - specularIbl = mix(specularIbl, ccSpecular, clearcoat); - } - - vec3 color = ao * (kD * diffuseIbl + specularIbl) + direct; - - color = acesTonemap(color * u.material.x); - fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); -} -)glsl"; - //============================================================================== yup::GraphicsContext* capturedContext = nullptr; bool pipelinesReady = false; diff --git a/examples/graphics/source/examples/audio/SynthPanels.h b/examples/graphics/source/examples/audio/SynthPanels.h index 60d749538..abd06a9c2 100644 --- a/examples/graphics/source/examples/audio/SynthPanels.h +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -255,8 +255,8 @@ inline float evaluateFourierSeries (const yup::FourierSeries& series, do The dominant displacement of the landscape is the waveform itself, so the ridges follow the shape the oscillator plays, with a few faint octaves on top as shimmer. - Compiling the GLSL costs tens of milliseconds, so one instance is shared by every - waveform display and it compiles once. A failed compile is remembered rather than + Compiling the pipeline isn't free, so one instance is shared by every waveform + display and it compiles once. A failed compile is remembered rather than retried, and render() then returns nullptr so the caller can draw without it. */ class SynthWaveformShader @@ -337,7 +337,7 @@ class SynthWaveformShader yup::GpuPipelineOptions options; options.colorTargets.emplace_back().blendEnabled = false; - auto result = yup::GpuPipeline::compileFromGlsl (device, vertexSource, yup::String::fromUTF8 (fragmentSource), options); + auto result = compilePipelineFromBundle (device, "synth_waveform", options); if (result.failed()) { yup::Logger::outputDebugString ("SynthWaveformShader: shader compile failed: " + result.getErrorMessage()); @@ -348,88 +348,6 @@ class SynthWaveformShader return true; } - static constexpr char vertexSource[] = R"glsl(#version 450 -void main() { - float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; - float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; - gl_Position = vec4(x, y, 0.0, 1.0); -} -)glsl"; - - // Shadertoy's y-up pixel space, since RHI targets read top-left-origin everywhere. - static constexpr char fragmentSource[] = R"glsl(#version 450 -layout(set = 0, binding = 0) uniform Params -{ - float time; - float width; - float height; - float pad; -} u; - -layout(set = 0, binding = 1) uniform Samples -{ - vec4 samples[64]; -} waveform; - -layout(location = 0) out vec4 fragColor; - -const vec3 accent = vec3(0.447, 0.918, 0.824); -const float focal = 2.8; -const vec3 background = vec3(0.055, 0.067, 0.078); - -float fetchSample(int index) -{ - return waveform.samples[index >> 2][index & 3]; -} - -float wave(float phase) -{ - float position = phase * 255.0; - int i0 = int(position); - int i1 = min(i0 + 1, 255); - return mix(fetchSample(i0), fetchSample(i1), position - float(i0)); -} - -void main() -{ - vec2 resolution = vec2(u.width, u.height); - vec2 I = vec2(gl_FragCoord.x, u.height - gl_FragCoord.y); - - // The ridge on the far wall, four units away, spans one period across the width. - vec3 direction = normalize(vec3(I + I - resolution, -u.height * focal)); - float frequency = u.height * focal / (8.0 * u.width); - - float scroll = 0.5 + u.time * 0.01; - float hue = u.time * 0.15; - - vec3 color = vec3(0.0); - float z = 0.0; - - for (int i = 0; i < 90; ++i) - { - vec3 p = z * direction + vec3(0.0, 1.0, 1.0); - - float r = max(-p.y, 0.0); - p.y += r + r; - - p.y -= wave(fract(p.x * frequency + scroll)); - - for (float octave = 2.0; octave < 30.0; octave += octave) - p.y += 0.12 * cos(p.x * octave + 0.6 * u.time * cos(octave) + z) / octave; - - float plane = p.z + 3.0; - float d = (0.1 * r + abs(p.y - 1.0) / (1.0 + r + r + r * r) + max(plane, -plane * 0.1)) / 8.0; - z += d; - - float phase = z * 0.5 + hue; - vec3 tone = accent * (cos(phase) + 1.3) + vec3(0.0, 0.15, 0.08) * cos(phase + 2.0); - color += tone / max(d * z, 1.0e-4); - } - - fragColor = vec4(max(tanh(color / 900.0), background), 1.0); -} -)glsl"; - yup::GpuDevice::Ptr device; yup::GpuPipeline::Ptr pipeline; bool compileAttempted = false; diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index 2a10e4e88..473382e83 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -20,13 +20,24 @@ */ #include -#include #include #include -#include #include +#if YUP_MODULE_AVAILABLE_yup_audio_devices +#include +#endif +#if YUP_MODULE_AVAILABLE_yup_dsp +#include +#endif +#if YUP_MODULE_AVAILABLE_yup_audio_gui #include +#endif +#if YUP_MODULE_AVAILABLE_yup_animation +#include +#endif +#if YUP_MODULE_AVAILABLE_yup_ai #include +#endif #if YUP_MODULE_AVAILABLE_yup_python #include #endif @@ -65,6 +76,30 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) return basePath; } +/** Loads a shader bundle precompiled by the SHADERS of a demo in CMakeLists.txt. */ +inline yup::ResultValue loadShaderBundle (yup::StringRef name) +{ +#if YUP_WASM || YUP_MOBILE + const auto shadersPath = getAssetPath ("data/shaders"); +#else + const auto shadersPath = yup::File (YUP_EXAMPLE_GRAPHICS_SHADERS_PATH); +#endif + + return yup::ShaderBundle::loadFromFile (shadersPath.getChildFile (yup::String (name) + ".ysl")); +} + +/** Compiles a render pipeline from a shader bundle loaded with loadShaderBundle(). */ +inline yup::ResultValue compilePipelineFromBundle (yup::GpuDevice::Ptr device, + yup::StringRef name, + const yup::GpuPipelineOptions& options = {}) +{ + auto bundle = loadShaderBundle (name); + if (bundle.failed()) + return yup::makeResultValueFail ("Shader bundle " + yup::String (name) + " failed to load: " + bundle.getErrorMessage()); + + return yup::GpuPipeline::compileFromBundle (std::move (device), bundle.getReference(), options); +} + //============================================================================== #if YUP_EXAMPLE_GRAPHICS_DEMO_AI diff --git a/modules/yup_rhi/rhi/yup_GpuComputePipeline.cpp b/modules/yup_rhi/rhi/yup_GpuComputePipeline.cpp index 3a8ab5d09..0a575b0c9 100644 --- a/modules/yup_rhi/rhi/yup_GpuComputePipeline.cpp +++ b/modules/yup_rhi/rhi/yup_GpuComputePipeline.cpp @@ -115,7 +115,7 @@ ResultValue GpuComputePipeline::compileFromBundle (GpuD GpuShaderSource source; source.language = targetLang; source.code = gpuShaderSourceBytes (shader->source); - source.entryPoint = shader->entryPoint; + source.entryPoint = (targetLang == GpuShaderLanguage::msl && shader->entryPoint == "main") ? String ("main0") : shader->entryPoint; GpuWorkgroupSize wgs = workgroupSize; if (wgs.x == 1 && wgs.y == 1 && wgs.z == 1) diff --git a/tests/yup_rhi/yup_GpuDevice.cpp b/tests/yup_rhi/yup_GpuDevice.cpp index 53bfdea6c..3b79a3b8b 100644 --- a/tests/yup_rhi/yup_GpuDevice.cpp +++ b/tests/yup_rhi/yup_GpuDevice.cpp @@ -292,6 +292,43 @@ TEST_F (GpuDeviceErrorTests, ComputePipelineCompileFromGlslOnHeadlessFails) } #endif // YUP_ENABLE_SHADER_TRANSPILER +#if YUP_APPLE +// --------------------------------------------------------------------------- +// GpuComputePipeline — Metal +// --------------------------------------------------------------------------- + +class GpuComputePipelineMetalTests : public ::testing::Test +{ +protected: + void SetUp() override + { + device = GpuDevice::create (GpuPlatform::Metal, {}); + if (device == nullptr || ! device->isComputeAvailable()) + GTEST_SKIP() << "No Metal compute device available"; + } + + GpuDevice::Ptr device; +}; + +TEST_F (GpuComputePipelineMetalTests, CompileFromBundleResolvesRenamedMainEntryPoint) +{ + // SPIRV-Cross names the MSL kernel main0, while the bundle keeps the GLSL entry point name. + ShaderInfo info; + info.stage = ShaderStage::compute; + info.language = ShaderLanguage::msl; + info.entryPoint = "main"; + info.source = "#include \n" + "using namespace metal;\n" + "kernel void main0 (uint3 id [[thread_position_in_grid]]) {}\n"; + + ShaderBundle bundle; + bundle.addShader (std::move (info)); + + auto result = GpuComputePipeline::compileFromBundle (device, bundle, GpuWorkgroupSize { 8, 1, 1 }); + EXPECT_TRUE (result.wasOk()) << result.getErrorMessage(); +} +#endif // YUP_APPLE + // --------------------------------------------------------------------------- // GpuComputePass — invalid (headless) pass setter no-ops // --------------------------------------------------------------------------- diff --git a/thirdparty/opus_library/opus_library.c b/thirdparty/opus_library/opus_library.c index b230d7d7a..3d672852a 100644 --- a/thirdparty/opus_library/opus_library.c +++ b/thirdparty/opus_library/opus_library.c @@ -41,7 +41,12 @@ #include #include #include + +// Its "mdct.h" can resolve to libvorbis/lib/mdct.h when both modules share a target: rename vorbis' private typedef +#define mdct_lookup yup_vorbis_mdct_lookup #include +#undef mdct_lookup + #include #include #include diff --git a/website/src/index.html b/website/src/index.html index bf1e851e4..64afd6a1b 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -32,16 +32,19 @@

- YUP! audio graph editor + YUP! prism spectral synth
- - - - - - + + + + + + + +
From 91c765406d38708fc69ac3ccc7e14017eeacb083 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 18:31:42 +0200 Subject: [PATCH 29/37] Fix paths to assets --- examples/graphics/CMakeLists.txt | 4 ++-- examples/graphics/source/examples/Clipboard.h | 8 +------ .../graphics/source/examples/Convolution.h | 21 ++----------------- examples/graphics/source/examples/Crossover.h | 10 +-------- .../source/examples/GpuAudioProcessing.h | 6 ++---- .../source/examples/SpectrumAnalyzer.h | 8 +------ examples/graphics/source/examples/Svg.h | 9 ++------ 7 files changed, 11 insertions(+), 55 deletions(-) diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 03f89c79e..49044a050 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -27,7 +27,7 @@ project (${target_name} VERSION ${target_version}) # ==== Declare the demos and what each one brings on top of the base modules: # MODULES extra modules to link (their dependencies are resolved automatically) -# RESOURCES folders of data/ to bundle on mobile and WebAssembly +# RESOURCES folders or files of data/ to bundle on mobile and WebAssembly # SHADERS bundles precompiled from data/shaders: .comp, or .vert + .frag; # : takes the vertex stage from .vert instead, and # data/shaders/*.glsl can be #included @@ -85,7 +85,7 @@ graphics_demo (ScrollBar) graphics_demo (Sliders) graphics_demo (SpectrumAnalyzer MODULES ${audio_modules} dr_libs RESOURCES audio) graphics_demo (SpinningCube MODULES yup::yup_animation RESOURCES lottie LIVE_SHADERS) -graphics_demo (Svg RESOURCES svg) +graphics_demo (Svg RESOURCES svg RobotoFlex-VariableFont.ttf) graphics_demo (TextEditor) graphics_demo (ToastNotification) graphics_demo (TouchTrails) diff --git a/examples/graphics/source/examples/Clipboard.h b/examples/graphics/source/examples/Clipboard.h index 75c4d06fa..f3a7c2411 100644 --- a/examples/graphics/source/examples/Clipboard.h +++ b/examples/graphics/source/examples/Clipboard.h @@ -282,13 +282,7 @@ class ClipboardDemo : public yup::Component private: void loadImageAsset() { - auto basePath = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getChildFile ("data") - .getChildFile ("logo.png"); - - if (basePath.loadFileAsData (rawPngData)) + if (getAssetPath ("data/logo.png").loadFileAsData (rawPngData)) updateStatus ("Logo image loaded from disk."); else updateStatus ("Could not load logo.png."); diff --git a/examples/graphics/source/examples/Convolution.h b/examples/graphics/source/examples/Convolution.h index 09f09bc0b..c1bf61178 100644 --- a/examples/graphics/source/examples/Convolution.h +++ b/examples/graphics/source/examples/Convolution.h @@ -214,15 +214,7 @@ class ConvolutionDemo void loadAudioFile() { // Create the path to the audio file - auto dataDir = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getParentDirectory() - .getChildFile ("data"); - - yup::File audioFile = dataDir - .getChildFile ("audio") - .getChildFile ("break_boomblastic_92bpm.flac"); + yup::File audioFile = getAssetPath ("data/audio/break_boomblastic_92bpm.flac"); if (! audioFile.existsAsFile()) { std::cerr << "Could not find audio/break_boomblastic_92bpm.flac" << std::endl; @@ -252,16 +244,7 @@ class ConvolutionDemo void loadDefaultImpulseResponse() { // Create the path to the default impulse response file - auto dataDir = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getParentDirectory() - .getChildFile ("data"); - - yup::File irFile = dataDir - .getChildFile ("audio") - .getChildFile ("ir_e112_g12_dyn_us_6v6.wav"); - loadImpulseResponseFromFile (irFile); + loadImpulseResponseFromFile (getAssetPath ("data/audio/ir_e112_g12_dyn_us_6v6.wav")); } void loadImpulseResponseFromFile (const yup::File& file) diff --git a/examples/graphics/source/examples/Crossover.h b/examples/graphics/source/examples/Crossover.h index 302cc355b..64bd60202 100644 --- a/examples/graphics/source/examples/Crossover.h +++ b/examples/graphics/source/examples/Crossover.h @@ -239,15 +239,7 @@ class CrossoverDemo : public yup::Component void loadAudioFile() { // Create the path to the audio file - auto dataDir = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getParentDirectory() - .getChildFile ("data"); - - yup::File audioFile = dataDir - .getChildFile ("audio") - .getChildFile ("break_boomblastic_92bpm.mp3"); + yup::File audioFile = getAssetPath ("data/audio/break_boomblastic_92bpm.mp3"); if (! audioFile.existsAsFile()) { std::cerr << "Could not find audio/break_boomblastic_92bpm.mp3" << std::endl; diff --git a/examples/graphics/source/examples/GpuAudioProcessing.h b/examples/graphics/source/examples/GpuAudioProcessing.h index 1cd7bbe7b..95894c975 100644 --- a/examples/graphics/source/examples/GpuAudioProcessing.h +++ b/examples/graphics/source/examples/GpuAudioProcessing.h @@ -78,7 +78,7 @@ class GpuPeakMeterComponent : public yup::Component Requirements: - A GpuDevice with compute shader support (Metal, D3D11, WebGPU, GL 4.3+) - YUP_ENABLE_SHADER_TRANSPILER for online GLSL→native compilation - - An audio file at examples/graphics/data/break_boomblastic_92bpm.mp3 + - An audio file at examples/graphics/data/audio/break_boomblastic_92bpm.mp3 */ class GpuAudioProcessingDemo : public yup::Component , public yup::AudioIODeviceCallback @@ -379,9 +379,7 @@ class GpuAudioProcessingDemo : public yup::Component void loadAudioFile() { - auto dataDir = yup::File (__FILE__).getParentDirectory().getParentDirectory().getParentDirectory().getChildFile ("data"); - - yup::File audioFile = dataDir.getChildFile ("break_boomblastic_92bpm.mp3"); + yup::File audioFile = getAssetPath ("data/audio/break_boomblastic_92bpm.mp3"); if (! audioFile.existsAsFile()) return; diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index 6e5c0e480..f700540c4 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -1120,13 +1120,7 @@ class SpectrumAnalyzerDemo { formatManager.registerDefaultFormats(); - auto dataDir = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getParentDirectory() - .getChildFile ("data"); - - auto audioFile = dataDir.getChildFile ("break_boomblastic_92bpm.mp3"); + auto audioFile = getAssetPath ("data/audio/break_boomblastic_92bpm.mp3"); if (audioFile.existsAsFile()) filePlayer.load (formatManager, audioFile); } diff --git a/examples/graphics/source/examples/Svg.h b/examples/graphics/source/examples/Svg.h index 88aabbecc..d84e5ec02 100644 --- a/examples/graphics/source/examples/Svg.h +++ b/examples/graphics/source/examples/Svg.h @@ -53,14 +53,9 @@ class SvgDemo : public yup::Component private: void updateListOfSvgFiles() { - yup::File riveBasePath = yup::File (__FILE__) - .getParentDirectory() - .getParentDirectory() - .getParentDirectory(); + dataDirectory = getAssetPath ("data"); - dataDirectory = riveBasePath.getChildFile ("data"); - - auto files = riveBasePath.getChildFile ("data/svg").findChildFiles (yup::File::findFiles, false, "*.svg"); + auto files = dataDirectory.getChildFile ("svg").findChildFiles (yup::File::findFiles, false, "*.svg"); if (files.isEmpty()) return; From b865bce972564a68887468ed724738575468c68f Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 18:55:47 +0200 Subject: [PATCH 30/37] Improve demo generation --- cmake/platforms/emscripten/shell.html | 10 +++++----- justfile | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/cmake/platforms/emscripten/shell.html b/cmake/platforms/emscripten/shell.html index 47a8db1fb..89cc43b55 100644 --- a/cmake/platforms/emscripten/shell.html +++ b/cmake/platforms/emscripten/shell.html @@ -91,8 +91,8 @@ } .brand svg { - width: 20px; - height: 20px; + width: 24px; + height: 24px; filter: drop-shadow(0 0 8px rgba(10, 132, 255, 0.55)); } @@ -418,9 +418,9 @@
- YUP!
diff --git a/justfile b/justfile index e48cebaed..df03b2406 100644 --- a/justfile +++ b/justfile @@ -166,3 +166,24 @@ rive_shaders_update: cp -R thirdparty/rive/source/renderer/shaders/out/generated/* thirdparty/rive/source/renderer/generated/shaders/ rm -Rf thirdparty/rive/source/renderer/shaders/out .venv/bin/deactivate + +[doc("update the example graphics demo")] +update_emscripten_example NAME DEST DEMOPATH: + sed -i '' -e 's/YUP_EXAMPLE_GRAPHICS_DEMO:STRING=.*/YUP_EXAMPLE_GRAPHICS_DEMO:STRING={{NAME}}/g' build/emscripten/CMakeCache.txt + @just emscripten Release example_graphics + cp -R build/emscripten/examples/graphics/Release/* "{{DEMOPATH}}/{{DEST}}" + +[doc("update the example graphics demos")] +update_emscripten_examples DEMOPATH="../yup-demos/demos": + @just emscripten Release example_graphics + @just update_emscripten_example Component3D component-3d {{DEMOPATH}} + @just update_emscripten_example ComponentEffects component-effects {{DEMOPATH}} + @just update_emscripten_example Filter filter {{DEMOPATH}} + @just update_emscripten_example FluidSimulation fluid-simulation {{DEMOPATH}} + @just update_emscripten_example Lottie lottie {{DEMOPATH}} + @just update_emscripten_example Pbr pbr {{DEMOPATH}} + @just update_emscripten_example SpectrumAnalyzer spectrum-analyzer {{DEMOPATH}} + @just update_emscripten_example Svg svg {{DEMOPATH}} + @just update_emscripten_example TouchTrails touch-trails {{DEMOPATH}} + @just update_emscripten_example Widgets widgets {{DEMOPATH}} + @just update_emscripten_example YdspSynths ydsp-synths {{DEMOPATH}} From 21a2c1cf78146179714f526ee040ad00def5f09f Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 19:29:13 +0200 Subject: [PATCH 31/37] Improving showcases --- justfile | 6 +++ website/package-lock.json | 34 +++++++++++++ website/package.json | 3 ++ website/src/index.html | 12 ++--- website/src/main.js | 6 +++ website/src/showcase.html | 100 ++++++++++---------------------------- website/src/style.css | 4 ++ 7 files changed, 85 insertions(+), 80 deletions(-) diff --git a/justfile b/justfile index df03b2406..8d02b25b7 100644 --- a/justfile +++ b/justfile @@ -167,6 +167,12 @@ rive_shaders_update: rm -Rf thirdparty/rive/source/renderer/shaders/out .venv/bin/deactivate +[doc("develop website")] +[working-directory: 'website'] +website: + npm install + npm run dev + [doc("update the example graphics demo")] update_emscripten_example NAME DEST DEMOPATH: sed -i '' -e 's/YUP_EXAMPLE_GRAPHICS_DEMO:STRING=.*/YUP_EXAMPLE_GRAPHICS_DEMO:STRING={{NAME}}/g' build/emscripten/CMakeCache.txt diff --git a/website/package-lock.json b/website/package-lock.json index fccb697cf..49f9d13b4 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "yup-website", "version": "0.0.0", + "dependencies": { + "lenis": "^1.3.26" + }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", "shiki": "^3.0.0", @@ -1547,6 +1550,37 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/lenis": { + "version": "1.3.26", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz", + "integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/website/package.json b/website/package.json index 778c88a34..da593a2a8 100644 --- a/website/package.json +++ b/website/package.json @@ -8,6 +8,9 @@ "build": "vite build", "preview": "vite preview" }, + "dependencies": { + "lenis": "^1.3.26" + }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", "shiki": "^3.0.0", diff --git a/website/src/index.html b/website/src/index.html index 64afd6a1b..c6fe28e67 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -46,22 +46,22 @@

The YUP audio graph editor - + Physically based rendering through the YUP RHI - + Realtime spectrum analyzer - + The Ghostscript tiger rendered from SVG - + GPU fluid simulation with compute shaders - + Lottie animation playback - + Interactive components mapped onto a curved 3D surface diff --git a/website/src/main.js b/website/src/main.js index 0d5ae45c5..9793f28d5 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -1,3 +1,9 @@ +import Lenis from "lenis"; +import "lenis/dist/lenis.css"; + +// Eases wheel ticks into continuous scrolling; touch stays native and code blocks keep their own horizontal scroll. +new Lenis({ autoRaf: true, anchors: true, allowNestedScroll: true }); + const navToggle = document.getElementById("nav-toggle"); const navMenu = document.getElementById("nav-menu"); diff --git a/website/src/showcase.html b/website/src/showcase.html index dccad0b5e..8ab113c58 100644 --- a/website/src/showcase.html +++ b/website/src/showcase.html @@ -14,7 +14,7 @@

Showcase

Rendered by YUP, every pixel

-

Everything below comes from the example apps in the repository.

+

Everything below comes from the example apps in the repository. The ones marked live demo run right in your browser.

@@ -38,38 +38,38 @@

Graphics and GPU

Rive artboards -
Rive artboards
+
Rive artboardsSource
- + Lottie playback -
Lottie playback
+
Lottie playbackLive demoSource
RHI spinning cube -
RHI spinning cube
+
RHI spinning cubeSource
- + Physically based rendering -
Physically based rendering
+
Physically based renderingLive demoSource
- + GPU fluid simulation -
GPU fluid simulation
+
GPU fluid simulationLive demoSource
Variable fonts -
Variable fonts
+
Variable fontsSource
@@ -82,26 +82,26 @@

Interface

Color picker -
Color picker
+
Color pickerSource
- - Gradient editor -
Gradient editor
+
+ Components in 3D +
Components in 3DLive demoSource
- + Multitouch trails -
Multitouch trails
+
Multitouch trailsLive demoSource
Paint profiler -
Paint profiler
+
Paint profilerSource
@@ -112,28 +112,10 @@

SVG

@@ -144,40 +126,22 @@

DSP

@@ -190,26 +154,14 @@

Audio

Audio graph editor -
Audio graph editor
-
-
-
- - Plugin host -
Plugin host
-
-
-
- - Oscilloscope -
Oscilloscope
+
Audio graph editorSource
Waveform view -
Waveform view
+
Waveform viewSource
diff --git a/website/src/style.css b/website/src/style.css index 569830c5f..ea3de677f 100644 --- a/website/src/style.css +++ b/website/src/style.css @@ -64,6 +64,10 @@ @apply border-glow/35 bg-glow/8 text-glow-soft; } + .chip-link { + @apply transition hover:border-glow/60 hover:text-ink; + } + .chip-wip { @apply border-dashed text-muted/80; } From 4e906209cfeba097546c9482da8acfa8a21f8928 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 19:29:28 +0200 Subject: [PATCH 32/37] Fix windowing redraw on emscripten --- modules/yup_gui/native/yup_Windowing_sdl.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/yup_gui/native/yup_Windowing_sdl.cpp b/modules/yup_gui/native/yup_Windowing_sdl.cpp index 22d56b33a..806b1a278 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl.cpp +++ b/modules/yup_gui/native/yup_Windowing_sdl.cpp @@ -192,7 +192,11 @@ SDLComponentNative::SDLComponentNative (Component& component, } SDL_GL_MakeCurrent (window, windowContext); + + // On Emscripten SDL maps the swap interval onto the main loop timing, which must stay on requestAnimationFrame +#if ! YUP_EMSCRIPTEN SDL_GL_SetSwapInterval (vsyncEnabled ? SDL_WINDOW_SURFACE_VSYNC_ADAPTIVE : SDL_WINDOW_SURFACE_VSYNC_DISABLED); +#endif #if ! YUP_EMSCRIPTEN SDL_GL_SetAttribute (SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); From 6f2f5c47e593c29e2cbc893e63a253afba148f92 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 23:28:00 +0200 Subject: [PATCH 33/37] More website niceties --- docs/_static/images/yup_widgets.png | Bin 0 -> 139654 bytes website/src/index.html | 30 ++++++++++++++++------------ website/src/showcase.html | 6 ++++++ 3 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 docs/_static/images/yup_widgets.png diff --git a/docs/_static/images/yup_widgets.png b/docs/_static/images/yup_widgets.png new file mode 100644 index 0000000000000000000000000000000000000000..b6ef6c02fc3932e01258ac116679e44184178fa7 GIT binary patch literal 139654 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 literal 0 HcmV?d00001 diff --git a/website/src/index.html b/website/src/index.html index c6fe28e67..88e3e6bf9 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -46,35 +46,39 @@

The YUP audio graph editor - - Physically based rendering through the YUP RHI - Realtime spectrum analyzer - - The Ghostscript tiger rendered from SVG + + Physically based rendering through the YUP RHI GPU fluid simulation with compute shaders + + Interactive components mapped onto a curved 3D surface + + + Interactive Rive artboard + Lottie animation playback - - Interactive components mapped onto a curved 3D surface + + The Ghostscript tiger rendered from SVG
- - - - - - + + + + + + +
diff --git a/website/src/showcase.html b/website/src/showcase.html index 8ab113c58..8037117db 100644 --- a/website/src/showcase.html +++ b/website/src/showcase.html @@ -85,6 +85,12 @@

Interface

Color pickerSource
+
+ + Transformable widgets panel + +
Transformable widgetsLive demoSource
+
Components in 3D From c058c72eb730ac25d517fb320aaaaceb2bad9da3 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 26 Sep 2026 23:49:50 +0200 Subject: [PATCH 34/37] Improved phrasing --- website/src/index.html | 56 ++++++++++++++++++++++---------- website/src/partials/footer.html | 4 ++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/website/src/index.html b/website/src/index.html index 88e3e6bf9..3f5a20866 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -2,8 +2,8 @@ - YUP! The modern framework for realtime audio and GPU-native creative software - + YUP! The GPU-first framework for realtime audio and graphics + @@ -13,12 +13,12 @@
- Early-stage and moving fast + Moving fast, built in the open

- Realtime audio and GPU-native graphics, one C++20 codebase + Realtime audio and graphics, GPU-first by design

- YUP builds native applications, audio tools and audio plugins for desktop, mobile and the web. Permissively licensed foundations meet modern vector rendering through the open source Rive renderer. + YUP fuses a complete audio stack with a modern GPU renderer in one C++20 codebase. Build native applications, audio tools and plugins for desktop, mobile and the web, on permissive foundations that keep your work yours.

@@ -130,22 +130,43 @@

Built for software that has to sound and look right, in
-

Permissive by default

-

ISC licensed project code, with dependencies chosen for liberal licensing or public-domain availability. Ship closed or open, no fees.

+

Free to create, free to sell

+

ISC licensed code and liberally licensed dependencies. Ship closed or open, sell what you build and keep what you earn. No fees, no royalties, no one to report to.

-

GPU-native rendering

-

Vector graphics on the Rive renderer over Metal, Direct3D, OpenGL, WebGL and WebGPU, plus a low-level RHI for compute and custom shaders.

+

GPU-first, not bolted on

+

Every pixel goes through the GPU from day one: vector graphics on the Rive renderer over Metal, Direct3D, OpenGL, WebGL and WebGPU, plus a low-level RHI for compute and custom shaders.

Audio-first stack

-

Devices, MIDI, file formats, DSP, an audio graph, plugin hosting and plugin client wrappers all live in the same framework.

+

Devices, MIDI, file formats, DSP, an audio graph, plugin hosting and plugin wrappers, living in the same framework as the graphics they drive.

+
+
+
+ +
+

Modern and moving fast

+

C++20 and the standard library throughout, with frequent iterations and an API that evolves alongside the platforms it runs on.

+
+
+
+ +
+

No lock-in

+

Plain CMake, open plugin standards like CLAP alongside VST3 and AU, and no accounts, license keys or proprietary tooling. Your code goes wherever you take it.

+
+
@@ -307,7 +328,7 @@

A whole app in a screenful of code

Pick your path

Free for every kind of project

-

No tiers, no seats, no revenue caps. The same ISC license covers apps, plugins and the web.

+

No tiers, no seats, no revenue caps, no royalties. One ISC license covers apps, plugins and the web, and what you build is yours to sell.

@@ -360,20 +381,20 @@

Web

FAQ

Questions, answered

-

Anything else? Ask on Discord or open an issue on GitHub.

+

Anything else? Ask on Discord, start a thread in GitHub Discussions, or write to support@yup.audio for dedicated support.

Is YUP ready for production?+ -

YUP is under active early-stage development and APIs may change. It is usable for experimentation, examples, prototypes and contributors comfortable with a fast-moving framework. Graphics and GUI, DSP, the audio graph and CLAP/VST3 plugins are the areas most ready for feedback.

+

YUP is early-stage and moving fast, so APIs may still change. It is already usable for experimentation, examples, prototypes and contributors comfortable with a fast-moving framework. Graphics and GUI, DSP, the audio graph and CLAP/VST3 plugins are the areas most ready for feedback.

How does YUP relate to JUCE?+ -

YUP started from the ISC licensed JUCE7 modules and has evolved since, with its own rendering, GUI, DSP, audio graph and plugin layers. Some APIs look familiar, but do not assume they are identical.

+

YUP started from the ISC licensed JUCE7 modules and has since gone its own way, with its own rendering, GUI, DSP, audio graph and plugin layers. Some APIs look familiar, but do not assume they are identical.

What does the license allow?+ -

Project code is ISC licensed: use, copy, modify and distribute for any purpose, commercial or not, keeping the copyright notice. Dependencies are chosen for liberal licensing or public-domain availability.

+

Project code is ISC licensed: use, copy, modify, distribute and sell for any purpose, commercial or not, keeping the copyright notice. No fees, no royalties, no revenue thresholds. Dependencies are chosen for liberal licensing or public-domain availability.

Which plugin formats can I build and host?+ @@ -398,11 +419,12 @@

Questions, answered

-

Turn your next idea into realtime software

-

Clone the repository, build the examples, and have a GPU-rendered audio app running in minutes.

+

Build your next idea, shape what comes next

+

Clone the repository and have a GPU-rendered audio app running in minutes. The roadmap is planned in the open, so bring your ideas, your code and your feedback.

diff --git a/website/src/partials/footer.html b/website/src/partials/footer.html index 9e8ec5320..6f4b7a2b3 100644 --- a/website/src/partials/footer.html +++ b/website/src/partials/footer.html @@ -6,7 +6,7 @@ YUP!

- The modern C++20 framework for realtime audio and GPU-native creative software. One codebase for desktop, mobile and the web. + The GPU-first C++20 framework for realtime audio and graphics. One codebase for desktop, mobile and the web, yours to build on and sell.

ISC licensed @@ -40,7 +40,9 @@

Community

  • GitHub
  • Issues
  • +
  • Discussions
  • Discord
  • +
  • Support
  • From 0bc81365cc36faffff403f43e5020654f8ccfa46 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sun, 27 Sep 2026 00:02:02 +0200 Subject: [PATCH 35/37] More showcases --- docs/_static/images/yup_component_effects.jpg | Bin 0 -> 241815 bytes website/src/index.html | 10 +++++----- website/src/modules.html | 4 ++-- website/src/showcase.html | 6 ++++++ 4 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 docs/_static/images/yup_component_effects.jpg diff --git a/docs/_static/images/yup_component_effects.jpg b/docs/_static/images/yup_component_effects.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d44a1547d9ecccc0b016da8f3f9902d458eff9f8 GIT binary patch literal 241815 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êl@V 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�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 from pixels to plugins

    yup_graphics · yup_rhi · yup_shading

    Graphics, RHI and shaders

    -

    2D drawing, fonts with variable axes, SVG and Lottie on top of Rive. Drop down to the RHI for compute, render pipelines, PBR and fluid simulations.

    -
    +

    2D drawing, fonts with variable axes, SVG and Lottie on top of Rive. Drop down to the RHI for compute and render pipelines, render 3D into components or map live components onto 3D surfaces. Write shaders once: a cross-platform transpiler and the .ysl bundle format compile them for every backend, offline or at runtime.

    +
    PBR rendering through the YUP RHI GPU fluid simulation - SVG tiger rendered by YUP
    -
    -

    yup_gui

    +
    +

    yup_gui

    GUI toolkit

    Components, windowing, layout, text editing, popup menus, drag and drop, multitouch and 3D components, all painted on the GPU.

    + YUP widgets panel

    yup_dsp · yup_dsp_jit

    diff --git a/website/src/modules.html b/website/src/modules.html index 8101f68a9..5e3b5b243 100644 --- a/website/src/modules.html +++ b/website/src/modules.html @@ -93,7 +93,7 @@

    Graphics

    yup_shading

    -

    Shader-source containers, transpilation and the .ysl shader-bundle format consumed by the RHI for offline compilation.

    +

    Shader-source containers, a cross-platform shader transpiler and the .ysl shader-bundle format consumed by the RHI, for offline or runtime compilation.

    yup_core
    Docs diff --git a/website/src/showcase.html b/website/src/showcase.html index 8037117db..e6c2b4b8d 100644 --- a/website/src/showcase.html +++ b/website/src/showcase.html @@ -97,6 +97,12 @@

    Interface

    Components in 3DLive demoSource
    +
    + + Shader effects on components + +
    Component effectsLive demoSource
    +
    Multitouch trails From cb27819857e095d789eea26baf544ac88f724ff2 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sun, 27 Sep 2026 00:13:13 +0200 Subject: [PATCH 36/37] More website tweaks --- website/README.md | 4 +++ website/src/index.html | 2 +- website/src/partials/footer.html | 2 +- website/src/partials/header.html | 2 +- website/src/showcase.html | 21 ++++------- website/vite.config.js | 61 +++++++++++++++++++++++++++++++- 6 files changed, 73 insertions(+), 19 deletions(-) diff --git a/website/README.md b/website/README.md index 9149cf560..b23df9fdb 100644 --- a/website/README.md +++ b/website/README.md @@ -22,6 +22,10 @@ Everything lives in `src/`, which is the Vite root: Screenshots and the logo are referenced straight from `../docs/_static/images` and `../logo.svg`, so the site never duplicates them. Vite hashes them into `dist/` on build. +## SEO + +Each page only declares its `` and `<meta name="description">`. On top of those, the `yup-partials` plugin in `vite.config.js` adds the canonical link and the Open Graph and Twitter card tags, plus schema.org JSON-LD on the home page. On build it also writes `sitemap.xml` (from the `pages` list), `robots.txt` and `og.jpg`, the social card image, which is copied from `docs/_static/images`. All absolute URLs come from `siteUrl` in `vite.config.js`. Add new pages to `pages` so they end up in the sitemap. + ## Deployment `.github/workflows/deploy_website.yml` builds the site and publishes it to GitHub Pages on every push to `main` that touches `website/`, `docs/_static/images/` or `logo.svg`. It also runs on manual dispatch. diff --git a/website/src/index.html b/website/src/index.html index b2bd0d6e8..c9ae3ac90 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -381,7 +381,7 @@ <h3 class="text-lg font-semibold">Web</h3> <div> <p class="eyebrow">FAQ</p> <h2 class="heading mt-3">Questions, answered</h2> - <p class="lead mt-4">Anything else? Ask on <a class="text-glow-soft hover:text-ink" href="https://discord.gg/E6pSdcj4R" target="_blank" rel="noopener">Discord</a>, start a thread in <a class="text-glow-soft hover:text-ink" href="https://github.com/kunitoki/yup/discussions" target="_blank" rel="noopener">GitHub Discussions</a>, or write to <a class="text-glow-soft hover:text-ink" href="mailto:support@yup.audio">support@yup.audio</a> for dedicated support.</p> + <p class="lead mt-4">Anything else? Ask on <a class="text-glow-soft hover:text-ink" href="https://discord.gg/UnqExfjhQX" target="_blank" rel="noopener">Discord</a>, start a thread in <a class="text-glow-soft hover:text-ink" href="https://github.com/kunitoki/yup/discussions" target="_blank" rel="noopener">GitHub Discussions</a>, or write to <a class="text-glow-soft hover:text-ink" href="mailto:support@yup.audio">support@yup.audio</a> for dedicated support.</p> </div> <div class="divide-y divide-edge rounded-2xl border border-edge bg-surface/60 lg:col-span-2"> <details class="faq group p-6" open> diff --git a/website/src/partials/footer.html b/website/src/partials/footer.html index 6f4b7a2b3..2f61bba53 100644 --- a/website/src/partials/footer.html +++ b/website/src/partials/footer.html @@ -41,7 +41,7 @@ <h3 class="font-mono text-xs uppercase tracking-[0.12em] text-ink">Community</h3 <li><a class="hover:text-ink" href="https://github.com/kunitoki/yup" target="_blank" rel="noopener">GitHub</a></li> <li><a class="hover:text-ink" href="https://github.com/kunitoki/yup/issues" target="_blank" rel="noopener">Issues</a></li> <li><a class="hover:text-ink" href="https://github.com/kunitoki/yup/discussions" target="_blank" rel="noopener">Discussions</a></li> - <li><a class="hover:text-ink" href="https://discord.gg/E6pSdcj4R" target="_blank" rel="noopener">Discord</a></li> + <li><a class="hover:text-ink" href="https://discord.gg/UnqExfjhQX" target="_blank" rel="noopener">Discord</a></li> <li><a class="hover:text-ink" href="mailto:support@yup.audio">Support</a></li> </ul> </div> diff --git a/website/src/partials/header.html b/website/src/partials/header.html index 9af2ed7a1..296e03b0d 100644 --- a/website/src/partials/header.html +++ b/website/src/partials/header.html @@ -14,7 +14,7 @@ </div> <div class="flex items-center gap-2"> - <a class="btn btn-ghost hidden sm:inline-flex" href="https://discord.gg/E6pSdcj4R" target="_blank" rel="noopener">Discord</a> + <a class="btn btn-ghost hidden sm:inline-flex" href="https://discord.gg/UnqExfjhQX" target="_blank" rel="noopener">Discord</a> <a class="btn btn-primary" href="https://github.com/kunitoki/yup" target="_blank" rel="noopener"> <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.42-2.7 5.4-5.26 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z" /></svg> GitHub diff --git a/website/src/showcase.html b/website/src/showcase.html index e6c2b4b8d..f68856a79 100644 --- a/website/src/showcase.html +++ b/website/src/showcase.html @@ -24,7 +24,6 @@ <h1 class="mx-auto mt-4 max-w-3xl text-4xl font-semibold tracking-tight text-bal <nav class="segmented flex-wrap" aria-label="Gallery sections"> <a class="seg inline-flex items-center" href="#graphics">Graphics and GPU</a> <a class="seg inline-flex items-center" href="#ui">Interface</a> - <a class="seg inline-flex items-center" href="#svg">SVG</a> <a class="seg inline-flex items-center" href="#dsp">DSP</a> <a class="seg inline-flex items-center" href="#audio">Audio</a> </nav> @@ -47,6 +46,12 @@ <h2 class="text-2xl font-semibold">Graphics and GPU</h2> </a> <figcaption class="flex items-center justify-between gap-3 p-4 text-sm"><span>Lottie playback</span><span class="flex gap-2"><a class="chip chip-on chip-link" href="https://kunitoki.github.io/yup-demos/demos/lottie" target="_blank" rel="noopener">Live demo</a><a class="chip chip-link" href="https://github.com/kunitoki/yup/blob/main/examples/graphics/source/examples/LottieDemo.h" target="_blank" rel="noopener">Source</a></span></figcaption> </figure> + <figure class="card spotlight group"> + <a href="https://kunitoki.github.io/yup-demos/demos/svg" target="_blank" rel="noopener"> + <img class="aspect-[4/3] w-full border-b border-edge object-cover transition duration-500 group-hover:scale-[1.02]" src="../../docs/_static/images/yup_svg_tiger.jpg" alt="SVG carousel" loading="lazy" /> + </a> + <figcaption class="flex items-center justify-between gap-3 p-4 text-sm"><span>SVG carousel</span><span class="flex gap-2"><a class="chip chip-on chip-link" href="https://kunitoki.github.io/yup-demos/demos/svg" target="_blank" rel="noopener">Live demo</a><a class="chip chip-link" href="https://github.com/kunitoki/yup/blob/main/examples/graphics/source/examples/Svg.h" target="_blank" rel="noopener">Source</a></span></figcaption> + </figure> <figure class="card spotlight group"> <a href="https://github.com/kunitoki/yup/blob/main/examples/graphics/source/examples/SpinningCubeDemo.h" target="_blank" rel="noopener"> <img class="aspect-[4/3] w-full border-b border-edge object-cover transition duration-500 group-hover:scale-[1.02]" src="../../docs/_static/images/yup_rhi_cube.jpg" alt="RHI spinning cube" loading="lazy" /> @@ -117,20 +122,6 @@ <h2 class="text-2xl font-semibold">Interface</h2> </figure> </div> </section> - <section id="svg" class="mt-20 scroll-mt-24"> - <div class="flex items-baseline justify-between gap-4 border-b border-edge pb-3"> - <h2 class="text-2xl font-semibold">SVG</h2> - <p class="hidden text-sm text-muted sm:block">Complex vector artwork, parsed and drawn on the GPU.</p> - </div> - <div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> - <figure class="card spotlight group"> - <a href="https://kunitoki.github.io/yup-demos/demos/svg" target="_blank" rel="noopener"> - <img class="aspect-[4/3] w-full border-b border-edge object-cover transition duration-500 group-hover:scale-[1.02]" src="../../docs/_static/images/yup_svg_tiger.jpg" alt="Ghostscript tiger" loading="lazy" /> - </a> - <figcaption class="flex items-center justify-between gap-3 p-4 text-sm"><span>Ghostscript tiger</span><span class="flex gap-2"><a class="chip chip-on chip-link" href="https://kunitoki.github.io/yup-demos/demos/svg" target="_blank" rel="noopener">Live demo</a><a class="chip chip-link" href="https://github.com/kunitoki/yup/blob/main/examples/graphics/source/examples/Svg.h" target="_blank" rel="noopener">Source</a></span></figcaption> - </figure> - </div> - </section> <section id="dsp" class="mt-20 scroll-mt-24"> <div class="flex items-baseline justify-between gap-4 border-b border-edge pb-3"> <h2 class="text-2xl font-semibold">DSP</h2> diff --git a/website/vite.config.js b/website/vite.config.js index 0bc2d0a0c..cadd8ac66 100644 --- a/website/vite.config.js +++ b/website/vite.config.js @@ -8,6 +8,10 @@ const root = resolve(import.meta.dirname, "src"); const partialsDir = resolve(root, "partials"); const pages = ["index", "modules", "showcase", "get-started"]; +// Production origin, used for canonical links, social cards, the sitemap and robots.txt. +const siteUrl = "https://yup.audio"; +const socialImage = resolve(import.meta.dirname, "../docs/_static/images/yup_prism_synth.jpg"); + // Syntax colors tuned to the site palette. const codeTheme = { name: "yup", @@ -29,6 +33,48 @@ const highlighter = createHighlighter({ themes: [codeTheme], langs: Object.value const readPartial = (path) => readFileSync(resolve(partialsDir, path), "utf8"); +const pageUrl = (page) => (page === "index.html" ? `${siteUrl}/` : `${siteUrl}/${page}`); + +// Canonical, Open Graph and Twitter tags built from the page's own <title> and description, +// plus schema.org data for the home page. +function seoTags(page, html) { + const title = html.match(/<title>(.*?)<\/title>/s)[1]; + const description = html.match(/<meta name="description" content="(.*?)"/s)[1]; + const url = pageUrl(page); + + const tags = [ + `<link rel="canonical" href="${url}" />`, + `<meta property="og:type" content="website" />`, + `<meta property="og:site_name" content="YUP!" />`, + `<meta property="og:title" content="${title}" />`, + `<meta property="og:description" content="${description}" />`, + `<meta property="og:url" content="${url}" />`, + `<meta property="og:image" content="${siteUrl}/og.jpg" />`, + `<meta name="twitter:card" content="summary_large_image" />`, + ]; + + if (page === "index.html") { + const graph = { + "@context": "https://schema.org", + "@graph": [ + { "@type": "WebSite", name: "YUP", url }, + { + "@type": "SoftwareSourceCode", + name: "YUP", + description, + url, + codeRepository: "https://github.com/kunitoki/yup", + programmingLanguage: "C++", + license: "https://opensource.org/license/isc-license-txt", + }, + ], + }; + tags.push(`<script type="application/ld+json">${JSON.stringify(graph)}</script>`); + } + + return tags.join("\n"); +} + // Expands <!-- @name --> into partials/<name>.html and <!-- @code path --> into a // highlighted <pre> of partials/<path>, then marks the current page's nav link. function partials() { @@ -56,7 +102,7 @@ function partials() { const page = basename(ctx.path) || "index.html"; const shiki = await highlighter; - return html + const expanded = html .replace(/<!-- @(\w+) -->/g, (_, name) => readPartial(`${name}.html`)) .replace(/<!-- @code (\S+) -->/g, (_, path) => shiki.codeToHtml(readPartial(path).trimEnd(), { @@ -65,8 +111,21 @@ function partials() { transformers: [{ pre(node) { this.addClassToHast(node, "code"); } }], })) .replaceAll(`data-nav href="./${page}"`, `data-nav aria-current="page" href="./${page}"`); + + return expanded.replace("</head>", `${seoTags(page, expanded)}\n</head>`); }, }, + generateBundle() { + const urls = pages.map((p) => ` <url><loc>${pageUrl(`${p}.html`)}</loc></url>`).join("\n"); + + this.emitFile({ + type: "asset", + fileName: "sitemap.xml", + source: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`, + }); + this.emitFile({ type: "asset", fileName: "robots.txt", source: `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n` }); + this.emitFile({ type: "asset", fileName: "og.jpg", source: readFileSync(socialImage) }); + }, }; } From 1815e6512c15385811d512d5bc96201ed2631f42 Mon Sep 17 00:00:00 2001 From: kunitoki <kunitoki@gmail.com> Date: Sun, 27 Sep 2026 00:59:32 +0200 Subject: [PATCH 37/37] More niceties --- .github/workflows/deploy_website.yml | 3 +- website/README.md | 16 ++- website/src/404.html | 74 +++++++++++ website/src/compare.html | 185 +++++++++++++++++++++++++++ website/src/index.html | 49 +++++-- website/src/main.js | 112 +++++++++++++++- website/src/partials/footer.html | 1 + website/src/partials/header.html | 22 +++- website/src/style.css | 89 +++++++++++++ website/vite.config.js | 114 +++++++++++++++-- 10 files changed, 635 insertions(+), 30 deletions(-) create mode 100644 website/src/404.html create mode 100644 website/src/compare.html diff --git a/.github/workflows/deploy_website.yml b/.github/workflows/deploy_website.yml index 2b9a1263e..0fac6626b 100644 --- a/.github/workflows/deploy_website.yml +++ b/.github/workflows/deploy_website.yml @@ -5,7 +5,8 @@ on: paths: - "**/workflows/deploy_website.yml" - "website/**" - - "docs/_static/images/**" + - "docs/**" + - "modules/*/yup_*.h" - "logo.svg" branches: - main diff --git a/website/README.md b/website/README.md index b23df9fdb..ab9c7a7c0 100644 --- a/website/README.md +++ b/website/README.md @@ -14,11 +14,21 @@ npm run preview # serves dist/ Everything lives in `src/`, which is the Vite root: -- `src/index.html`, `src/modules.html`, `src/showcase.html`, `src/get-started.html` - one file per page, all listed in `vite.config.js`. +- `src/index.html`, `src/modules.html`, `src/showcase.html`, `src/compare.html`, `src/get-started.html` - one file per page, all listed in `pages` in `vite.config.js`. +- `src/404.html` - served by GitHub Pages for any missing path. It sets `<base href="/">` so its assets resolve at any depth, and it is built but kept out of the sitemap. - `src/partials/` - shared `head`, `header` and `footer`, inlined where a page contains `<!-- @name -->`. - `src/partials/snippets/` - plain code samples (`.cpp`, `.cmake`, `.sh`), inlined and highlighted with Shiki where a page contains `<!-- @code snippets/<file> -->`. Edit them as normal source files; the dev server reloads on save. - `src/style.css` - Tailwind entry point and theme tokens (palette from `cmake/platforms/emscripten/shell.html`). -- `src/main.js` - mobile nav, card spotlight, code tabs, copy buttons and the module filter. +- `src/main.js` - mobile nav, card spotlight, code tabs, copy buttons, the module filter and the docs search dialog. + +## Build-time data + +- `<!-- @count modules -->` becomes the number of `modules/yup_*` folders. `<!-- @count <name> -->` counts the `<li>` items or `chip-on` chips inside the page element marked `data-count="<name>"`, so a stat always matches the list it summarizes. +- Docs search (the header button, `Cmd/Ctrl+K` or `/`) reads `virtual:docs-index`, which the `yup-partials` plugin builds from the h1-h3 sections of `docs/**/*.md` and which loads only when the dialog first opens. Links point to the matching section ids on yup.readthedocs.io. Restart the dev server after editing the docs. + +## Analytics + +Set `goatCounterCode` in `vite.config.js` to your GoatCounter site code to add the cookie-free [GoatCounter](https://www.goatcounter.com/) script to every page. Elements with `data-goatcounter-click="<name>"` (the "Run live" and GitHub buttons) are counted as events. While the code is empty, no script is added. Screenshots and the logo are referenced straight from `../docs/_static/images` and `../logo.svg`, so the site never duplicates them. Vite hashes them into `dist/` on build. @@ -28,4 +38,4 @@ Each page only declares its `<title>` and `<meta name="description">`. On top of ## Deployment -`.github/workflows/deploy_website.yml` builds the site and publishes it to GitHub Pages on every push to `main` that touches `website/`, `docs/_static/images/` or `logo.svg`. It also runs on manual dispatch. +`.github/workflows/deploy_website.yml` builds the site and publishes it to GitHub Pages on every push to `main` that touches `website/`, `docs/` (screenshots and the search index), a module header in `modules/*/` (the module count) or `logo.svg`. It also runs on manual dispatch. diff --git a/website/src/404.html b/website/src/404.html new file mode 100644 index 000000000..d66807e91 --- /dev/null +++ b/website/src/404.html @@ -0,0 +1,74 @@ +<!doctype html> +<html lang="en"> + <head> + <!-- GitHub Pages serves this page for any missing path, so relative URLs resolve from the root. --> + <base href="/" /> + <!-- @head --> + <title>Page not found - YUP! + + + + + +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/website/src/compare.html b/website/src/compare.html new file mode 100644 index 000000000..7c21a0f1e --- /dev/null +++ b/website/src/compare.html @@ -0,0 +1,185 @@ + + + + + YUP compared with JUCE, iPlug2, DPF, Visage and Qt - YUP! + + + + + +
    +
    +
    +
    +

    Compare

    +

    YUP next to the alternatives

    +

    How YUP lines up against the frameworks people usually weigh it against, taken from each project's own documentation. Every framework here is good at something, so pick the one that fits your project.

    +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FeatureYUPJUCE 9iPlug2DPFVisageQt 6
    LicenseISCAGPLv3 or commercialzlib-likeISCMITLGPLv3, GPLv3 (some modules) or commercial
    Closed-source commercial useFree, no revenue capFree up to $20k revenue, then $40 or $175 per user per month (or perpetual)Free, no revenue capFree, no revenue capFree, no revenue capFree under LGPLv3 terms, otherwise a commercial license
    Builds pluginsCLAP, VST3 (Windows, macOS), AUv2, AUv3 (macOS), standalone. Linux, AAX and LV2 in progressVST, VST3, AU, AUv3, AAX, LV2, standalone. CLAP through the third-party clap-juce-extensionsCLAP, VST2, VST3, AUv2, AUv3, AAX, WAM, standaloneCLAP, VST2, VST3, AUv2, LV2, LADSPA, DSSI, JACK standaloneNo, UI library only (ships a CLAP example)No, not a plugin framework
    Hosts pluginsCLAP, VST3, AUv2VST, VST3, AU, AUv3, LV2NoNoNoNo
    UI renderingGPU by default: Metal, Direct3D 11, OpenGL, OpenGL ES, WebGL2, WebGPU. Vulkan in progressCoreGraphics, Direct2D or software, optional OpenGLNanoVG or Skia on OpenGL or Metal, GPU by defaultOpenGL by default, OpenGL ES, Cairo, WebViewGPU only, through bgfx: Direct3D 11/12, Metal, Vulkan, WebGLQt Quick on OpenGL, Vulkan, Direct3D 11/12 or Metal. Widgets in software
    PlatformsWindows, macOS, Linux, iOS, Android, WasmWindows, macOS, Linux, iOS, AndroidWindows, macOS, iOS, visionOS, WebWindows, macOS, LinuxWindows, macOS, Linux, WebWindows, macOS, Linux, iOS, Android, Wasm, embedded
    Runs in the browserYes: Emscripten with WebGPU or WebGL2, AudioWorklet and Web MIDINo official targetYes: Emscripten, packaged as Web Audio ModulesEmscripten target in the build filesYes: Emscripten with WebGL (UI only, no audio)Yes: Emscripten with WebGL
    Official Python bindingsYes, yup_pythonNo (third-party popsicle)NoNoNoYes, Qt for Python
    AI in the frameworkLLM clients, tools, embeddings, MCP client and serverNoNoNoNoNo, AI features are in the tooling (Qt Creator)
    LanguageC++20C++17C++17C++11C++17C++17
    Build systemCMakeCMake or ProjucerCMake, Xcode and Visual Studio projects, MakeMake or CMakeCMakeCMake
    +
    +

    Checked September 2026 against JUCE 9.0, the iPlug2, DPF and Visage main branches and the Qt 6.11 documentation. Spotted something out of date? Open an issue.

    +
    +
    + +
    +
    +
    +

    Where YUP stands out

    +
      +
    • +One permissive license for everything. Unlike JUCE and Qt, there are no revenue tiers or copyleft terms to track.
    • +
    • +A GPU renderer on every platform, including WebGPU, plus a low-level RHI for compute and custom shaders.
    • +
    • +Plugin building and plugin hosting in the same framework, with native CLAP.
    • +
    • +The same app on desktop, mobile and the web, with Python bindings and AI clients built in.
    • +
    +
    +
    +

    When another choice fits better

    +
      +
    • -You need Linux, AAX or LV2 plugins today: JUCE and DPF (Linux, LV2) and iPlug2 (AAX) ship them now.
    • +
    • -You need stable APIs and a large ecosystem: YUP is early-stage and its APIs may still change.
    • +
    • -You only need a GPU UI on top of your own audio code: Visage is a focused, MIT licensed UI library.
    • +
    • -You are building a general desktop or embedded UI without audio: Qt covers far more of that ground.
    • +
    +
    +
    + +
    +
    + + + + diff --git a/website/src/index.html b/website/src/index.html index c9ae3ac90..cc2482ec5 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -25,8 +25,13 @@

    + + + Run live + See the showcase

    +

    Run live opens the YDSP Synth Lab in your browser. Play it with your computer keyboard or a MIDI controller, then edit a patch and hear it recompile.

    @@ -89,30 +94,48 @@

    One codebase, every CI platform

    -
      -
    • Windows
    • -
    • macOS
    • -
    • Linux
    • -
    • iOS
    • -
    • Android
    • -
    • Wasm
    • +
        +
      • + + Windows +
      • +
      • + + macOS +
      • +
      • + + Linux +
      • +
      • + + iOS +
      • +
      • + + Android +
      • +
      • + + Wasm +
      Platforms
      -
      6
      +
      Modules
      -
      21
      +
      GPU backends
      -
      6
      +
      Plugin formats
      -
      4
      +
    @@ -242,7 +265,7 @@

    Native where it matters

    Rendering

    -
    +
    Metal Direct3D 11 OpenGL 4.2 @@ -268,7 +291,7 @@

    Audio I/O

    Plugin formats

    -
    +
    CLAP VST3 AUv2 diff --git a/website/src/main.js b/website/src/main.js index 9793f28d5..83e919e18 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -2,7 +2,7 @@ import Lenis from "lenis"; import "lenis/dist/lenis.css"; // Eases wheel ticks into continuous scrolling; touch stays native and code blocks keep their own horizontal scroll. -new Lenis({ autoRaf: true, anchors: true, allowNestedScroll: true }); +const lenis = new Lenis({ autoRaf: true, anchors: true, allowNestedScroll: true }); const navToggle = document.getElementById("nav-toggle"); const navMenu = document.getElementById("nav-menu"); @@ -59,6 +59,116 @@ moduleFilter?.addEventListener("input", () => { }); }); +const searchDialog = document.getElementById("search"); +const searchInput = document.getElementById("search-input"); +const searchResults = document.getElementById("search-results"); +const searchMore = document.getElementById("search-more"); +let searchIndex; +let activeHit = 0; + +const element = (tag, className, text) => Object.assign(document.createElement(tag), { className, textContent: text }); + +const hits = () => [...searchResults.querySelectorAll("a")]; + +const setActiveHit = (index) => { + const all = hits(); + activeHit = Math.max(0, Math.min(index, all.length - 1)); + all.forEach((hit, i) => hit.setAttribute("aria-selected", String(i === activeHit))); + all[activeHit]?.scrollIntoView({ block: "nearest" }); +}; + +// Every term must appear somewhere in the entry; matches in the heading rank first. +const renderSearch = () => { + const query = searchInput.value.trim(); + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + searchMore.href = `https://yup.readthedocs.io/en/latest/search.html?q=${encodeURIComponent(query)}`; + + if (!searchIndex || terms.length === 0) { + searchResults.replaceChildren(); + return; + } + + const ranked = searchIndex + .filter((entry) => terms.every((term) => entry.haystack.includes(term))) + .map((entry) => ({ entry, score: terms.filter((term) => entry.heading.includes(term)).length })) + .sort((a, b) => b.score - a.score || a.entry.h.length - b.entry.h.length) + .slice(0, 12); + + if (ranked.length === 0) { + const empty = element("li", "px-3 py-6 text-center text-sm text-muted", `No sections match "${query}".`); + empty.setAttribute("role", "none"); + searchResults.replaceChildren(empty); + return; + } + + searchResults.replaceChildren(...ranked.map(({ entry }) => { + const hit = Object.assign(element("a", "search-hit"), { href: entry.u, target: "_blank", rel: "noopener" }); + hit.setAttribute("role", "option"); + hit.append(element("span", "block text-sm font-medium text-ink", entry.h)); + if (entry.p !== entry.h) + hit.append(element("span", "block font-mono text-[11px] text-glow-soft", entry.p)); + hit.append(element("span", "mt-1 block text-xs leading-relaxed text-muted", entry.t)); + + const item = document.createElement("li"); + item.setAttribute("role", "none"); + item.append(hit); + return item; + })); + setActiveHit(0); +}; + +const openSearch = async () => { + if (searchDialog.open) + return; + + searchDialog.showModal(); + lenis.stop(); + + searchIndex ??= (await import("virtual:docs-index")).default.map((entry) => ({ + ...entry, + heading: entry.h.toLowerCase(), + haystack: `${entry.h} ${entry.p} ${entry.k} ${entry.t}`.toLowerCase(), + })); + renderSearch(); +}; + +if (!/Mac|iPhone|iPad/.test(navigator.platform)) + document.querySelectorAll("[data-search-key]").forEach((key) => (key.textContent = "Ctrl K")); + +document.querySelectorAll("[data-search-open]").forEach((button) => button.addEventListener("click", openSearch)); + +document.addEventListener("keydown", (e) => { + const typing = e.target.closest?.("input, textarea, [contenteditable]"); + if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { + e.preventDefault(); + openSearch(); + } +}); + +searchInput.addEventListener("input", renderSearch); + +searchInput.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setActiveHit(activeHit + (e.key === "ArrowDown" ? 1 : -1)); + } else if (e.key === "Enter") { + hits()[activeHit]?.click(); + } +}); + +searchResults.addEventListener("click", (e) => { + if (e.target.closest("a")) + searchDialog.close(); +}); + +// Clicks on the backdrop land on the dialog itself. +searchDialog.addEventListener("click", (e) => { + if (e.target === searchDialog) + searchDialog.close(); +}); + +searchDialog.addEventListener("close", () => lenis.start()); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; document.querySelectorAll("[data-slideshow]").forEach((show) => { diff --git a/website/src/partials/footer.html b/website/src/partials/footer.html index 2f61bba53..639eec183 100644 --- a/website/src/partials/footer.html +++ b/website/src/partials/footer.html @@ -20,6 +20,7 @@

    Framework

  • Modules
  • Showcase
  • +
  • Compare
  • Get Started
  • Changelog
  • diff --git a/website/src/partials/header.html b/website/src/partials/header.html index 296e03b0d..a0a520367 100644 --- a/website/src/partials/header.html +++ b/website/src/partials/header.html @@ -5,23 +5,39 @@ YUP! -
    + + +
    + + + esc +
    +
      + Search everything on yup.readthedocs.io +
      diff --git a/website/src/style.css b/website/src/style.css index ea3de677f..b005911f1 100644 --- a/website/src/style.css +++ b/website/src/style.css @@ -169,6 +169,91 @@ filter: drop-shadow(0 0 8px rgb(10 132 255 / 0.55)); } + .search-dialog { + @apply mx-auto mt-[12vh] w-[min(40rem,calc(100%-2rem))] max-w-none overflow-hidden rounded-2xl border border-edge bg-surface p-0 text-ink; + box-shadow: 0 0 0 1px rgb(255 255 255 / 0.04), 0 40px 90px -30px rgb(10 132 255 / 0.45); + } + + .search-dialog::backdrop { + background: rgb(7 9 14 / 0.7); + backdrop-filter: blur(4px); + } + + .search-hit { + @apply block rounded-xl px-3 py-2.5 transition hover:bg-white/[0.03]; + } + + .search-hit[aria-selected="true"] { + @apply bg-glow/10; + box-shadow: inset 0 0 0 1px rgb(10 132 255 / 0.35); + } + + .compare th, + .compare td { + @apply border-b border-edge px-4 py-3.5 align-top leading-relaxed; + } + + .compare tbody tr:last-child > * { + @apply border-b-0; + } + + .compare thead th { + @apply font-mono text-xs uppercase tracking-[0.1em] text-ink; + } + + .compare tbody th { + @apply font-medium text-ink; + } + + .compare td { + @apply text-muted; + } + + .compare .compare-yup { + @apply bg-glow/8 text-ink; + } + + .compare thead .compare-yup { + @apply text-glow-soft; + } + + /* Orbiting logo from the Wasm shell's boot loader, used on the 404 page. */ + .orbit { + @apply size-40 rounded-full transition-transform hover:scale-105 active:scale-95; + } + + .orbit .track { + fill: none; + stroke: var(--color-edge); + stroke-width: 1.5; + } + + .orbit .arc { + fill: none; + stroke: var(--color-glow); + stroke-width: 2; + stroke-linecap: round; + stroke-dasharray: 22 78; + filter: drop-shadow(0 0 5px rgb(10 132 255 / 0.7)); + animation: orbit-trace 2.8s linear infinite; + } + + .orbit .dart { + fill: var(--color-glow); + filter: drop-shadow(0 0 10px rgb(10 132 255 / 0.6)); + } + + .orbit .dot { + fill: var(--color-glow-soft); + filter: drop-shadow(0 0 7px var(--color-glow)); + } + + @keyframes orbit-trace { + to { + stroke-dashoffset: -100; + } + } + details.faq summary::-webkit-details-marker { display: none; } @@ -187,4 +272,8 @@ transition-duration: 0.001ms !important; scroll-behavior: auto !important; } + + .orbit .dot { + display: none; + } } diff --git a/website/vite.config.js b/website/vite.config.js index cadd8ac66..28ff4d740 100644 --- a/website/vite.config.js +++ b/website/vite.config.js @@ -1,16 +1,23 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { basename, extname, resolve } from "node:path"; import { defineConfig, normalizePath } from "vite"; import tailwindcss from "@tailwindcss/vite"; import { createHighlighter } from "shiki"; +const repoRoot = resolve(import.meta.dirname, ".."); const root = resolve(import.meta.dirname, "src"); const partialsDir = resolve(root, "partials"); -const pages = ["index", "modules", "showcase", "get-started"]; +const pages = ["index", "modules", "showcase", "compare", "get-started"]; // Production origin, used for canonical links, social cards, the sitemap and robots.txt. const siteUrl = "https://yup.audio"; -const socialImage = resolve(import.meta.dirname, "../docs/_static/images/yup_prism_synth.jpg"); +const socialImage = resolve(repoRoot, "docs/_static/images/yup_prism_synth.jpg"); + +// GoatCounter site code (https://.goatcounter.com). Analytics stay off while it is empty. +const goatCounterCode = ""; + +const docsDir = resolve(repoRoot, "docs"); +const docsUrl = "https://yup.readthedocs.io/en/latest"; // Syntax colors tuned to the site palette. const codeTheme = { @@ -38,6 +45,9 @@ const pageUrl = (page) => (page === "index.html" ? `${siteUrl}/` : `${siteUrl}/$ // Canonical, Open Graph and Twitter tags built from the page's own and description, // plus schema.org data for the home page. function seoTags(page, html) { + if (page === "404.html") + return ""; + const title = html.match(/<title>(.*?)<\/title>/s)[1]; const description = html.match(/<meta name="description" content="(.*?)"/s)[1]; const url = pageUrl(page); @@ -75,10 +85,84 @@ function seoTags(page, html) { return tags.join("\n"); } -// Expands <!-- @name --> into partials/<name>.html and <!-- @code path --> into a -// highlighted <pre> of partials/<path>, then marks the current page's nav link. +function analyticsTag() { + if (!goatCounterCode) + return ""; + + return `<script data-goatcounter="https://${goatCounterCode}.goatcounter.com/count" async src="https://gc.zgo.at/count.js"></script>`; +} + +const moduleCount = () => readdirSync(resolve(repoRoot, "modules"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("yup_")).length; + +// Counts the list items or enabled chips inside the page's data-count="<name>" element, +// so a stat can never disagree with the list it summarizes. +function countItems(html, name) { + const block = html.match(new RegExp(`data-count="${name}"[^>]*>([\\s\\S]*?)</(?:div|ul)>`))[1]; + return block.match(/<li|chip-on/g).length; +} + +// Same rules as docutils' make_id, so links land on the section ids Sphinx emits. +const sectionId = (text) => text.toLowerCase().normalize("NFKD").replace(/[^\x00-\x7f]/g, "") + .replace(/[^a-z0-9]+/g, "-").replace(/^[-0-9]+|-+$/g, ""); + +const plainText = (markdown) => markdown.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*]|<[^>]+>/g, "").trim(); + +// One entry per h1-h3 section of the Sphinx docs: page title, heading, link, inline code +// identifiers and the start of the section text. Loaded lazily by the search dialog. +function docsSearchIndex() { + const entries = []; + + for (const file of readdirSync(docsDir, { recursive: true })) { + const path = normalizePath(file); + if (!path.endsWith(".md") || /^(_|superpowers\/|demos\/)/.test(path)) + continue; + + const pageUrl = `${docsUrl}/${path.replace(/\.md$/, ".html")}`; + const ids = new Set(); + let title = ""; + let fence = ""; + let entry; + + for (const line of readFileSync(resolve(docsDir, file), "utf8").split("\n")) { + const fenceMark = line.match(/^\s*(`{3,}|~{3,})/)?.[1]; + if (fenceMark && (!fence || fenceMark.startsWith(fence))) { + fence = fence ? "" : fenceMark; + continue; + } + + const heading = fence ? null : line.match(/^(#{1,3})\s+(.+?)(?:\s+#+)?\s*$/); + if (heading) { + const text = plainText(heading[2]); + const id = sectionId(text); + const unique = heading[1].length > 1 && id && !ids.has(id); + ids.add(id); + title ||= text; + entry = { p: title, h: text, u: unique ? `${pageUrl}#${id}` : pageUrl, k: new Set(), t: "" }; + entries.push(entry); + continue; + } + + if (!entry || fence || /^\s*(:::|\(.+\)=|\||<!--)/.test(line)) + continue; + + for (const [, code] of line.matchAll(/`([\w:.]+)`/g)) + entry.k.add(code); + + if (entry.t.length < 200) + entry.t = `${entry.t} ${plainText(line.replace(/^\s*([-*+]|\d+\.)\s+/, ""))}`.trim(); + } + } + + return entries.map((e) => ({ ...e, k: [...e.k].join(" "), t: e.t.length > 200 ? `${e.t.slice(0, 200)}...` : e.t })); +} + +// Expands <!-- @name --> into partials/<name>.html, <!-- @code path --> into a highlighted +// <pre> of partials/<path> and <!-- @count name --> into a number derived from the repository +// or the page, then marks the current page's nav link. Also serves the docs search index as +// the virtual:docs-index module. function partials() { - const repo = normalizePath(resolve(import.meta.dirname, "..")).replace(/^\//, ""); + const repo = normalizePath(repoRoot).replace(/^\//, ""); return { name: "yup-partials", @@ -96,6 +180,14 @@ function partials() { server.ws.send({ type: "full-reload" }); }); }, + resolveId(id) { + if (id === "virtual:docs-index") + return "\0virtual:docs-index"; + }, + load(id) { + if (id === "\0virtual:docs-index") + return `export default ${JSON.stringify(docsSearchIndex())};`; + }, transformIndexHtml: { order: "pre", async handler(html, ctx) { @@ -112,7 +204,10 @@ function partials() { })) .replaceAll(`data-nav href="./${page}"`, `data-nav aria-current="page" href="./${page}"`); - return expanded.replace("</head>", `${seoTags(page, expanded)}\n</head>`); + const counted = expanded.replace(/<!-- @count (\w+) -->/g, (_, name) => + name === "modules" ? moduleCount() : countItems(expanded, name)); + + return counted.replace("</head>", `${seoTags(page, counted)}\n${analyticsTag()}\n</head>`); }, }, generateBundle() { @@ -135,13 +230,14 @@ export default defineConfig({ plugins: [tailwindcss(), partials()], server: { // Screenshots are imported straight from the repository docs/. - fs: { allow: [resolve(import.meta.dirname, "..")] }, + fs: { allow: [repoRoot] }, }, build: { outDir: resolve(import.meta.dirname, "dist"), emptyOutDir: true, rollupOptions: { - input: Object.fromEntries(pages.map((p) => [p, resolve(root, `${p}.html`)])), + // 404.html is served by GitHub Pages for unknown paths and stays out of the sitemap. + input: Object.fromEntries([...pages, "404"].map((p) => [p, resolve(root, `${p}.html`)])), }, }, });