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/.gitignore b/.gitignore index bbfa6ed99..145e10a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ build/* cmake-build* out/* +infer-out/* # Ides/Agents __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 602a46e1e..5843ebf36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ 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. +- 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. @@ -410,6 +416,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` … `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 @@ -426,6 +433,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Audio +- 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. 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. + +- 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` @@ -434,6 +457,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 - Added `Vector3`, `Matrix4` (column-major, with perspective / orthographic / look-at factories and `rive::Mat4` converters) and `Ray` (viewport unprojection, plane and triangle intersection), plus `MeshSurfaceMapper` in the new `meshes/` folder, mapping between viewport points and texture coordinates of any triangle mesh. @@ -814,6 +839,8 @@ 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. - `Component3DDemo` presents an interactive widget panel on a curved 3D surface; `WidgetsDemo` gains draggable corner handles applying an `AffineTransform` to the widgets panel; the Wave effect in `ComponentEffectsDemo` now maps input, with widgets inside the effected area. - `Component3DDemo` renders the panel texture and its 3D scene at the display scale. - Fixed the Python demo aborting on "Run Python!": `PythonDemo` is now bound with `py::smart_holder`, matching its `Component` base. @@ -824,6 +851,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. 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/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/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/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/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/docs/_static/images/yup_3d_components.png b/docs/_static/images/yup_3d_components.png new file mode 100644 index 000000000..7ca025bf2 Binary files /dev/null and b/docs/_static/images/yup_3d_components.png differ diff --git a/docs/_static/images/yup_component_effects.jpg b/docs/_static/images/yup_component_effects.jpg new file mode 100644 index 000000000..d44a1547d Binary files /dev/null and b/docs/_static/images/yup_component_effects.jpg differ diff --git a/docs/_static/images/yup_prism_synth.jpg b/docs/_static/images/yup_prism_synth.jpg new file mode 100644 index 000000000..c4cddf59c Binary files /dev/null and b/docs/_static/images/yup_prism_synth.jpg differ diff --git a/docs/_static/images/yup_widgets.png b/docs/_static/images/yup_widgets.png new file mode 100644 index 000000000..b6ef6c02f Binary files /dev/null and b/docs/_static/images/yup_widgets.png differ 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/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 a9af9febe..842c4e2d3 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`. @@ -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`, @@ -41,10 +41,15 @@ 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 + `SyncSpectralResampler`, the `AdditiveOscillator` / `WavetableOscillator` + synthesis backends, the `SyncOscillator` facade, shared `WaveformBank` frames, + `ModulatedOscillator` for morphing, FM, PM and sync, and + `PrismSpectrum` for modulatable ridge, dispersion and exact-PWM shaping. - [YDSP language reference](yup-dsp-language.md) - the `yup_dsp_jit` module's JIT-compiled DSP language: syntax, intrinsics, the graph algebra, the realtime contract, and the public C++ API. @@ -107,6 +112,7 @@ onsets convolution-and-delay resampling time-stretching +oscillators yup-dsp-language ydsp-bundle-format ``` diff --git a/docs/dsp/oscillators.md b/docs/dsp/oscillators.md new file mode 100644 index 000000000..cb9409685 --- /dev/null +++ b/docs/dsp/oscillators.md @@ -0,0 +1,401 @@ +# Oscillators + +The `yup_dsp` oscillator classes synthesize bandlimited periodic waveforms from +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_WaveformBank.h`, +`yup_ModulatedOscillator.h`, `yup_PrismSpectrum.h`, `yup_LFO.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` | + +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()` + 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. +- **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 + +| 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. | + +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; update refreshes bandwidth +} + +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. 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)` 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). | + +## 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. + +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. + +## 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. + +`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` +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. | +| `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 +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 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. + +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 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. + +`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. + +## 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 +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 + 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) - `SincOversampler`, `HalfbandOversampler` 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`. + +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. + +## 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/docs/dsp/resampling.md b/docs/dsp/resampling.md index 275ee1b05..5f3f629fb 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 @@ -12,7 +13,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 +36,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,17 +45,20 @@ 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 +## 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 8 +yup::SincOversampler os; // 4x oversampling, sinc radius 16 os.prepare (44100.0, 2, 512); // audio thread: @@ -63,22 +67,111 @@ 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). +This single-stage design remains for arbitrary integer factors and fixed, +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 + +`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: `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× 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. 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`, + `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: `HalfbandOversampler2xFloat`, `HalfbandOversampler4xFloat`, +`HalfbandOversampler8xFloat`, `HalfbandOversampler16xFloat`, +`HalfbandOversampler32xFloat` and the `Double` variants are +`HalfbandOversampler` instantiations with the default design. ## Resampler 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/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-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/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/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/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 17ab51f02..49044a050 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -25,72 +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 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 +# 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 RobotoFlex-VariableFont.ttf) +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 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) - 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_audio_files OFF) -set (need_synth_files OFF) -set (need_shader_bundle OFF) -set (need_shader_transpiler OFF) - -set (rive_demos "ALL;Artboard") -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 (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;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 @@ -105,22 +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_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 @@ -134,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} @@ -163,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/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/Audio.h b/examples/graphics/source/examples/Audio.h index 3880da603..a3cf18b2f 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. @@ -21,615 +21,699 @@ #pragma once +#include "audio/SynthSettings.h" +#include "audio/SynthEngine.h" +#include "audio/SynthPanels.h" +#include "audio/SynthModulationPage.h" + +#include +#include +#include +#include #include -#include // For sine wave generation +#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"; -class HarmonicSineGenerator +//============================================================================== +/** A page of the instrument: paints nothing and hands clicks on its background back. */ +class SynthPage : public yup::Component { public: - HarmonicSineGenerator() - : sampleRate (44100.0) - , currentAngle (0.0) - , frequency (0.0) - , amplitude (0.0) - { - } - - void setSampleRate (double newSampleRate) + /** Construct a page. */ + SynthPage() { - sampleRate = newSampleRate; - frequency.reset (newSampleRate, 0.05); - amplitude.reset (newSampleRate, 0.02); + setOpaque (false); } - void setFrequency (double newFrequency) - { - frequency.setTargetValue ((yup::MathConstants::twoPi * newFrequency) / sampleRate); - } + /** Called when the page itself, not one of its children, is clicked. */ + std::function onMouseDown; - void setAmplitude (float newAmplitude) + void mouseDown (const yup::MouseEvent&) override { - amplitude.setTargetValue (newAmplitude); - } - - float getCurrentAmplitude() const - { - return amplitude.getCurrentValue(); - } - - float getNextSample() - { - auto sample = std::sin (currentAngle) * amplitude.getNextValue(); - - currentAngle += frequency.getNextValue(); - if (currentAngle >= yup::MathConstants::twoPi) - currentAngle -= yup::MathConstants::twoPi; - - return static_cast (sample); + if (onMouseDown != nullptr) + onMouseDown(); } - -private: - double sampleRate; - double currentAngle; - yup::SmoothedValue frequency; - yup::SmoothedValue amplitude; }; //============================================================================== - -class HarmonicSynth +class AudioExample + : public yup::Component + , public yup::AudioIODeviceCallback { public: - HarmonicSynth() - : isNoteOn (false) - , currentNote (-1) - , fundamentalFrequency (0.0) - , masterAmplitude (0.5f) + AudioExample() + : Component ("AudioExample") + , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) + , pitchWheelComponent (keyboardState, "PitchWheel") + , modWheelComponent (keyboardState, "ModWheel") { - // Initialize harmonic generators - const int numHarmonics = 16; // 4x4 grid - harmonicGenerators.resize (numHarmonics); - harmonicMultipliers.resize (numHarmonics); - harmonicAmplitudes.resize (numHarmonics); + audioDeviceError = deviceManager.initialiseWithDefaultDevices (0, 2); - for (int i = 0; i < numHarmonics; ++i) + // 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); + 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); + mainPage.addAndMakeVisible (keyboardComponent); + + // 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) { - harmonicGenerators[i] = std::make_unique(); + 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))); + }; - // 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 + 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); - harmonicAmplitudes[i] = 0.0f; // Start silent - } - } + logo.parseSVG (synthLogoSvg); - void setSampleRate (double newSampleRate) - { - for (auto& generator : harmonicGenerators) - generator->setSampleRate (newSampleRate); - } + const auto font = yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(); - void noteOn (int midiNoteNumber, float velocity) - { - currentNote = midiNoteNumber; - isNoteOn = true; + titleLabel.setText ("P R I S M / SPECTRAL SYNTH", yup::dontSendNotification); + titleLabel.setFont (font.withHeight (20.0f)); + titleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textPrimary); + addAndMakeVisible (titleLabel); - // Convert MIDI note to frequency: f = 440 * 2^((n-69)/12) - fundamentalFrequency = 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); + subtitleLabel.setText ("Sculpt harmonics. Scatter phases. Play the spectrum.", yup::dontSendNotification); + subtitleLabel.setFont (font.withHeight (12.0f)); + subtitleLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + addAndMakeVisible (subtitleLabel); - updateHarmonicFrequencies(); - updateHarmonicAmplitudes (velocity); - } + loadLabel.setFont (font.withHeight (11.0f)); + loadLabel.setColor (yup::Label::Style::textFillColorId, SynthTheme::textSecondary); + mainPage.addAndMakeVisible (loadLabel); - void noteOff (int midiNoteNumber) - { - if (currentNote == midiNoteNumber) + 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) { - isNoteOn = false; - for (auto& generator : harmonicGenerators) - generator->setAmplitude (0.0f); + auto panel = std::make_unique ( + yup::String ("OSC ") + yup::String (index + 1), + synth.getOscillatorSettings (index), + synth.getResources(), + waveformShader, + font.withHeight (10.0f)); + + mainPage.addAndMakeVisible (*panel); + oscillatorPanels[static_cast (index)] = std::move (panel); } - } - void allNotesOff() - { - isNoteOn = false; - currentNote = -1; - for (auto& generator : harmonicGenerators) - generator->setAmplitude (0.0f); - } + filterPanel = std::make_unique (synth.getFilterSettings(), font.withHeight (10.0f)); + mainPage.addAndMakeVisible (*filterPanel); - void setHarmonicAmplitude (int harmonicIndex, float amplitude) - { - if (harmonicIndex >= 0 && harmonicIndex < harmonicAmplitudes.size()) + for (int index = 0; index < SynthExample::envelopeCount; ++index) { - harmonicAmplitudes[harmonicIndex] = amplitude; - if (isNoteOn) - updateHarmonicAmplitudes (1.0f); // Use current velocity + 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); } - } - void setMasterAmplitude (float newAmplitude) - { - masterAmplitude = newAmplitude; - if (isNoteOn) - updateHarmonicAmplitudes (1.0f); - } + 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); + } - float getMasterAmplitude() const - { - return masterAmplitude; - } + modulationPage = std::make_unique (synth.getModulationSettings(), font.withHeight (10.0f)); + addChildComponent (*modulationPage); - bool isPlaying() const - { - if (! isNoteOn) - return false; + mainPage.onMouseDown = [this] { takeKeyboardFocus(); }; + addAndMakeVisible (mainPage); - for (const auto& generator : harmonicGenerators) + for (auto* button : { &mainPageButton, &modulationPageButton }) { - if (generator->getCurrentAmplitude() > 0.001f) - return true; + 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); } - return false; - } - int getCurrentNote() const - { - return currentNote; - } + 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); + randomizeButton.setColor (yup::TextButton::Style::outlineColorId, SynthTheme::panelBorder); + randomizeButton.onClick = [this] { randomizeVoice(); }; + 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.requestAllNotesOff(); + }; + addAndMakeVisible (clearButton); - float getNextSample() - { - float mixedSample = 0.0f; + 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); - for (auto& generator : harmonicGenerators) + 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) { - mixedSample += generator->getNextSample(); - } - - return mixedSample * masterAmplitude; + synth.playMode = id - 1; + glideKnob->setEnabled (id != 1); + }; + 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); + mainPage.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(); + }; + mainPage.addAndMakeVisible (*midiChoice); + renderData.resize (SynthExample::maxBlockSize); + mainPage.addAndMakeVisible (oscilloscope); } - double getHarmonicMultiplier (int index) const + ~AudioExample() override { - if (index >= 0 && index < harmonicMultipliers.size()) - return harmonicMultipliers[index]; - return 1.0; - } + closeMidiInput(); -private: - void updateHarmonicFrequencies() - { - for (size_t i = 0; i < harmonicGenerators.size(); ++i) - { - double harmonicFreq = fundamentalFrequency * harmonicMultipliers[i]; - harmonicGenerators[i]->setFrequency (harmonicFreq); - } + deviceManager.removeAudioCallback (this); + deviceManager.closeAudioDevice(); } - void updateHarmonicAmplitudes (float velocity) + void resized() override { - for (size_t i = 0; i < harmonicGenerators.size(); ++i) + auto bounds = getLocalBounds().reduced (outerInset); + + auto header = bounds.removeFromTop (headerHeight); + + volumeKnob->setBounds (header.removeFromRight (64.0f)); + header.removeFromRight (spacing); + clearButton.setBounds (header.removeFromRight (actionButtonWidth).reduced (0.0f, buttonInset)); + header.removeFromRight (spacing); + 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, buttonInset)); + header.removeFromRight (spacing); + mainPageButton.setBounds (header.removeFromRight (pageButtonWidth).reduced (0.0f, buttonInset)); + + 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); + + 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(); + + 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); + 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); + + // The oscillator panels take whatever the fixed-height rows below leave, and + // 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); + lfoPanels[0]->setBounds (lfoRow.removeFromLeft (columnWidth)); + lfoRow.removeFromLeft (spacing); + 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)); + filterPanel->setBounds (shapingRow.removeFromLeft (columnWidth)); + shapingRow.removeFromLeft (spacing); + envelopePanels[0]->setBounds (shapingRow.removeFromLeft (columnWidth)); + shapingRow.removeFromLeft (spacing); + envelopePanels[1]->setBounds (shapingRow); + bounds.removeFromBottom (spacing); + + const auto panelWidth = (bounds.getWidth() - spacing) / static_cast (SynthExample::oscillatorCount); + for (auto& panel : oscillatorPanels) { - float amplitude = harmonicAmplitudes[i] * velocity * masterAmplitude; - harmonicGenerators[i]->setAmplitude (amplitude); + panel->setBounds (bounds.removeFromLeft (panelWidth)); + bounds.removeFromLeft (spacing); } } - std::vector> harmonicGenerators; - std::vector harmonicMultipliers; - std::vector harmonicAmplitudes; - - bool isNoteOn; - int currentNote; - double fundamentalFrequency; - float masterAmplitude; -}; - -//============================================================================== - -class Oscilloscope : public yup::Component -{ -public: - Oscilloscope() - : Component ("Oscilloscope") + /** 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 setRenderData (const std::vector& data, int newReadPos) + void paint (yup::Graphics& g) override { - renderData.resize (data.size()); + g.setFillColor (SynthTheme::windowBackground); + g.fillAll(); - for (std::size_t i = 0; i < data.size(); ++i) - renderData[i] = data[i]; + logo.paint (g, logoArea); } - void paint (yup::Graphics& g) override + void mouseDown (const yup::MouseEvent&) override { - auto bounds = getLocalBounds(); - - auto backgroundColor = yup::Color (0xff101010); - g.setFillColor (backgroundColor); - g.fillAll(); + takeKeyboardFocus(); + } - auto lineColor = yup::Color (0xff4b4bff); - if (renderData.empty()) - return; + void refreshDisplay (double) override + { + if (scopeReady.load (std::memory_order_acquire)) + { + renderData.assign (scopeSamples.begin(), scopeSamples.begin() + scopeCount); + scopeReady.store (false, std::memory_order_release); + oscilloscope.setRenderData (renderData); + } - float xSize = getWidth() / float (renderData.size()); - float centerY = getHeight() * 0.5f; + if (oscilloscope.isVisible()) + oscilloscope.repaint(); - // Build the main waveform path - path.clear(); - path.reserveSpace ((int) renderData.size()); - path.moveTo (0.0f, (renderData[0] + 1.0f) * 0.5f * getHeight()); + // One generation bump per frame, however many mouse events the drag produced. + for (auto& panel : oscillatorPanels) + if (panel != nullptr) + panel->commitPendingEdits(); - for (std::size_t i = 1; i < renderData.size(); ++i) - path.lineTo (i * xSize, (renderData[i] + 1.0f) * 0.5f * getHeight()); + for (int index = 0; index < SynthExample::lfoCount; ++index) + lfoPanels[static_cast (index)]->setPhase (synth.getLFOPhase (index)); - filledPath = path.createStrokePolygon (4.0f); + const auto activeVoices = synth.getNumActiveVoices(); - g.setFillColor (lineColor); - g.setFeather (8.0f); - g.fillPath (filledPath); + 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); + } - g.setFillColor (lineColor.brighter (0.2f)); - g.setFeather (4.0f); - g.fillPath (filledPath); + void audioDeviceAboutToStart (yup::AudioIODevice* device) override + { + const auto maxBlockSize = yup::jmax (device->getDefaultBufferSize(), SynthExample::maxBlockSize); - g.setStrokeColor (lineColor.withAlpha (0.8f)); - g.setStrokeWidth (2.0f); - g.strokePath (path); + synth.prepare (device->getCurrentSampleRate(), maxBlockSize); - g.setStrokeColor (lineColor.brighter (0.3f)); - g.setStrokeWidth (1.0f); - g.strokePath (path); + renderBuffer.setSize (2, maxBlockSize, false, true, true); + loadMeasurer.reset (device->getCurrentSampleRate(), device->getDefaultBufferSize()); + midiBuffer.ensureSize (16384); + outputGain.reset (device->getCurrentSampleRate(), 0.02); + outputGain.setCurrentAndTargetValue (masterVolume.load()); - g.setStrokeColor (yup::Colors::white.withAlpha (0.9f)); - g.setStrokeWidth (0.5f); - g.strokePath (path); + midiCollector.reset (device->getCurrentSampleRate()); + midiCollector.ensureStorageAllocated (midiQueueBytes); } -private: - std::vector renderData; - yup::Path path; - yup::Path filledPath; -}; - -//============================================================================== - -class AudioExample - : public yup::Component - , public yup::AudioIODeviceCallback - , public yup::MidiKeyboardState::Listener -{ -public: - AudioExample() - : Component ("AudioExample") - , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) + void audioDeviceStopped() override { - // 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); - 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->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->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(); - - // Add harmonic control sliders (4x4 grid) - for (int i = 0; i < totalRows * totalColumns; ++i) + void audioDeviceIOCallbackWithContext (const float* const*, + int, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const yup::AudioIODeviceCallbackContext&) override + { + if (numSamples <= 0) + return; + const yup::ScopedNoDenormals noDenormals; + const yup::AudioProcessLoadMeasurer::ScopedTimer renderTimer (loadMeasurer, numSamples); + if (numSamples <= 0 || numSamples > renderBuffer.getNumSamples()) { - auto slider = sliders.add (std::make_unique (yup::Slider::RotaryVerticalDrag)); - - // 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); + for (int channel = 0; channel < numOutputChannels; ++channel) + if (outputChannelData[channel] != nullptr) + yup::FloatVectorOperations::clear (outputChannelData[channel], numSamples); - addAndMakeVisible (*label); + return; } - // 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()); - }; - 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(); - }; - addAndMakeVisible (*clearButton); + for (int channel = 0; channel < renderBuffer.getNumChannels(); ++channel) + yup::FloatVectorOperations::clear (renderBuffer.getWritePointer (channel), numSamples); - // Add volume control - volumeSlider = std::make_unique (yup::Slider::LinearHorizontal, "Volume"); + midiBuffer.clear(); - // Configure slider range and default value - volumeSlider->setRange ({ 0.0f, 1.0f }); - volumeSlider->setDefaultValue (0.5f); + // 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); - volumeSlider->onValueChanged = [this] (double value) + outputGain.setTargetValue (masterVolume.load()); + for (int sample = 0; sample < numSamples; ++sample) { - masterVolume = (float) value; - }; - volumeSlider->setValue (0.5f); // Set initial volume to 50% - addAndMakeVisible (*volumeSlider); - - // Add the oscilloscope - addAndMakeVisible (oscilloscope); + 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); + } - // Set some initial harmonic values for a nice sound - if (sliders.size() >= 4) + if (! scopeReady.load (std::memory_order_acquire)) { - 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 + scopeCount = yup::jmin (numSamples, SynthExample::maxBlockSize); + std::copy_n (renderBuffer.getReadPointer (0), scopeCount, scopeSamples.begin()); + scopeReady.store (true, std::memory_order_release); } } - ~AudioExample() override + void visibilityChanged() override { - keyboardState.removeListener (this); - deviceManager.removeAudioCallback (this); - deviceManager.closeAudioDevice(); + if (! isVisible()) + { + closeMidiInput(); + deviceManager.removeAudioCallback (this); + } + else + { + deviceManager.addAudioCallback (this); + openMidiInput(); + } } - void resized() override +private: + //============================================================================== + /** Opens the selected hardware input and reports device-open failures in the UI. */ + void openMidiInput() { - auto bounds = getLocalBounds(); - - // Title area at the top - auto titleHeight = proportionOfHeight (0.05f); - auto titleBounds = bounds.removeFromTop (titleHeight); - titleLabel->setBounds (titleBounds); - - // Subtitle area - auto subtitleHeight = proportionOfHeight (0.03f); - auto subtitleBounds = bounds.removeFromTop (subtitleHeight); - subtitleLabel->setBounds (subtitleBounds); - - // 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))); - - // 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))); - - // Reserve space for buttons area - auto buttonHeight = proportionOfHeight (0.08f); - auto buttonArea = bounds.removeFromBottom (buttonHeight); - - auto buttonWidth = buttonArea.getWidth() / 3; - if (randomizeButton != nullptr) - randomizeButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); - - if (clearButton != nullptr) - clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); - - if (volumeSlider != nullptr) - volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + if (midiInputIdentifier.isNotEmpty() || midiChoice == nullptr) + return; - // 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; + midiInputError.clear(); + const auto index = midiChoice->getComboBox().getSelectedId() - 2; + if (! yup::isPositiveAndBelow (index, midiDevices.size())) + return; - for (int i = 0; i < totalRows && i * totalColumns < sliders.size(); ++i) + const auto identifier = midiDevices[index].identifier; + deviceManager.setMidiInputDeviceEnabled (identifier, true); + if (! deviceManager.isMidiInputDeviceEnabled (identifier)) { - 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); - } + midiInputError = "Cannot open MIDI input: " + midiDevices[index].name; + return; } - - // Position note indicator at bottom left - auto noteIndicatorBounds = yup::Rectangle (10, getHeight() - 40, 200, 30); - noteIndicatorLabel->setBounds (noteIndicatorBounds); + midiInputIdentifier = identifier; + deviceManager.addMidiInputDeviceCallback (midiInputIdentifier, &midiCollector); } - void paint (yup::Graphics& g) override + /** Releases the input again, so a hidden demo does not hold the device open. */ + void closeMidiInput() { - g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray)); - g.fillAll(); + if (midiInputIdentifier.isEmpty()) + return; + + deviceManager.removeMidiInputDeviceCallback (midiInputIdentifier, &midiCollector); + deviceManager.setMidiInputDeviceEnabled (midiInputIdentifier, false); + + midiInputIdentifier.clear(); } - void mouseDown (const yup::MouseEvent& event) override + /** Queues a message from one of the on-screen wheels, timestamped as the collector expects. */ + void sendWheelMessage (yup::MidiMessage message) { - takeKeyboardFocus(); + // 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); } - void refreshDisplay (double lastFrameTimeSeconds) override + //============================================================================== + /** Randomizes everything a voice is made of; volume, voice mode, glide and MIDI input stay. */ + void randomizeVoice() { + auto& random = yup::Random::getSystemRandom(); + + for (int index = 0; index < SynthExample::oscillatorCount; ++index) { - const yup::CriticalSection::ScopedLockType sl (renderMutex); - oscilloscope.setRenderData (renderData, readPos); + auto& settings = synth.getOscillatorSettings (index); + + settings.waveform = random.nextInt (6); + settings.syncMode = random.nextInt (4); + settings.syncRatio = 1.0f + random.nextFloat() * 3.0f; + settings.level = 0.2f + random.nextFloat() * 0.8f; + 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(); + + // 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(); } - if (oscilloscope.isVisible()) - oscilloscope.repaint(); - - // Update note indicator - if (harmonicSynth.isPlaying()) + 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(); + + // 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) { - if (! noteIndicatorLabel->isVisible()) - { - noteIndicatorLabel->setVisible (true); - } - auto noteText = yup::String ("Playing Note: ") + yup::String (harmonicSynth.getCurrentNote()); - noteIndicatorLabel->setText (noteText, yup::dontSendNotification); + 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(); } - else + + for (int index = 0; index < SynthExample::lfoCount; ++index) { - noteIndicatorLabel->setVisible (false); - } - } + auto& lfo = synth.getLFOSettings (index); - // MIDI keyboard event handlers - void handleNoteOn (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override - { - harmonicSynth.noteOn (midiNoteNumber, velocity); - } + 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(); - void handleNoteOff (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override - { - harmonicSynth.noteOff (midiNoteNumber); - } + lfoPanels[static_cast (index)]->refresh(); + } - void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, - int numInputChannels, - float* const* outputChannelData, - int numOutputChannels, - int numSamples, - const yup::AudioIODeviceCallbackContext& context) override - { - for (int sample = 0; sample < numSamples; ++sample) + // 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) { - // Generate the next sample from the harmonic synth - float synthSample = harmonicSynth.getNextSample(); + auto& slot = modulation.slots[static_cast (index)]; - // Apply master volume - synthSample *= masterVolume; + if (index >= liveRoutes) + { + slot.destination = static_cast (SynthModulationDestination::none); + slot.depth = 0.0f; + continue; + } - // Apply soft limiting to prevent clipping - synthSample = std::tanh (synthSample); + auto destination = SynthModulationDestination::none; - // Output to all channels - for (int channel = 0; channel < numOutputChannels; ++channel) - outputChannelData[channel][sample] = synthSample; + do + { + destination = static_cast (1 + random.nextInt (static_cast (SynthModulationDestination::count) - 1)); + } while (destination == SynthModulationDestination::osc1Level || destination == SynthModulationDestination::osc2Level); - // Store for oscilloscope display - auto pos = readPos.fetch_add (1); - inputData[pos] = synthSample; - readPos = readPos % inputData.size(); + slot.source = random.nextInt (4); + slot.destination = static_cast (destination); + slot.depth = (random.nextFloat() - 0.5f) * (random.nextBool() ? 2.0f : 1.0f); } - const yup::CriticalSection::ScopedLockType sl (renderMutex); - std::swap (inputData, renderData); - } - - void audioDeviceAboutToStart (yup::AudioIODevice* device) override - { - inputData.resize (device->getDefaultBufferSize()); - renderData.resize (device->getDefaultBufferSize()); - readPos = 0; + modulationPage->refresh(); } - void audioDeviceStopped() override - { - } + //============================================================================== + static constexpr std::size_t midiQueueBytes = 2048; - void visibilityChanged() override - { - if (! isVisible()) - deviceManager.removeAudioCallback (this); - else - deviceManager.addAudioCallback (this); - } + static constexpr float outerInset = 10.0f; + static constexpr float headerHeight = 60.0f; + static constexpr float spacing = 8.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; -private: + //============================================================================== yup::AudioDeviceManager deviceManager; - HarmonicSynth harmonicSynth; + HarmonicSynthEngine synth; // MIDI keyboard components yup::MidiKeyboardState keyboardState; yup::MidiKeyboardComponent keyboardComponent; - + yup::PitchWheelComponent pitchWheelComponent; + yup::ModWheelComponent modWheelComponent; + 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::atomic_int readPos = 0; + std::array scopeSamples {}; + std::atomic scopeReady { false }; + int scopeCount = 0; + yup::SmoothedValue outputGain; + yup::AudioProcessLoadMeasurer loadMeasurer; // 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::unique_ptr randomizeButton; - std::unique_ptr clearButton; - std::unique_ptr volumeSlider; + yup::Drawable logo; + yup::Rectangle logoArea; + yup::Label titleLabel; + yup::Label subtitleLabel; + yup::Label voiceLabel; + 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; + 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" }; + std::unique_ptr volumeKnob; + std::unique_ptr modeChoice; + std::unique_ptr midiChoice; + std::unique_ptr glideKnob; Oscilloscope oscilloscope; - float masterVolume = 0.5f; + std::atomic masterVolume { 0.5f }; }; 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 97% rename from examples/graphics/source/examples/ClipboardDemo.h rename to examples/graphics/source/examples/Clipboard.h index 75c4d06fa..f3a7c2411 100644 --- a/examples/graphics/source/examples/ClipboardDemo.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/Component3DDemo.h b/examples/graphics/source/examples/Component3D.h similarity index 94% rename from examples/graphics/source/examples/Component3DDemo.h rename to examples/graphics/source/examples/Component3D.h index c446761ba..50443f116 100644 --- a/examples/graphics/source/examples/Component3DDemo.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/ComponentEffectsDemo.h b/examples/graphics/source/examples/ComponentEffects.h similarity index 80% rename from examples/graphics/source/examples/ComponentEffectsDemo.h rename to examples/graphics/source/examples/ComponentEffects.h index 5d9542727..6156ab2f6 100644 --- a/examples/graphics/source/examples/ComponentEffectsDemo.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/ComputeParticlesDemo.h b/examples/graphics/source/examples/ComputeParticles.h similarity index 75% rename from examples/graphics/source/examples/ComputeParticlesDemo.h rename to examples/graphics/source/examples/ComputeParticles.h index f0e106b5e..f229ef489 100644 --- a/examples/graphics/source/examples/ComputeParticlesDemo.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/ConvolutionDemo.h b/examples/graphics/source/examples/Convolution.h similarity index 95% rename from examples/graphics/source/examples/ConvolutionDemo.h rename to examples/graphics/source/examples/Convolution.h index 09f09bc0b..c1bf61178 100644 --- a/examples/graphics/source/examples/ConvolutionDemo.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/CrossoverDemo.h b/examples/graphics/source/examples/Crossover.h similarity index 97% rename from examples/graphics/source/examples/CrossoverDemo.h rename to examples/graphics/source/examples/Crossover.h index 302cc355b..64bd60202 100644 --- a/examples/graphics/source/examples/CrossoverDemo.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/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 63% rename from examples/graphics/source/examples/FluidSimulationDemo.h rename to examples/graphics/source/examples/FluidSimulation.h index 89c603a4e..f2e290b34 100644 --- a/examples/graphics/source/examples/FluidSimulationDemo.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/GpuAudioProcessingDemo.h b/examples/graphics/source/examples/GpuAudioProcessing.h similarity index 98% rename from examples/graphics/source/examples/GpuAudioProcessingDemo.h rename to examples/graphics/source/examples/GpuAudioProcessing.h index 1cd7bbe7b..95894c975 100644 --- a/examples/graphics/source/examples/GpuAudioProcessingDemo.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/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 62% rename from examples/graphics/source/examples/PbrDemo.h rename to examples/graphics/source/examples/Pbr.h index 3d2f29195..34e3ad317 100644 --- a/examples/graphics/source/examples/PbrDemo.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/ScrollBarDemo.h b/examples/graphics/source/examples/ScrollBar.h similarity index 99% rename from examples/graphics/source/examples/ScrollBarDemo.h rename to examples/graphics/source/examples/ScrollBar.h index 09b00cb23..35dba70ca 100644 --- a/examples/graphics/source/examples/ScrollBarDemo.h +++ b/examples/graphics/source/examples/ScrollBar.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/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/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index f10eff213..f700540c4 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; @@ -434,11 +490,12 @@ class SignalGenerator // Resampling state yup::ResamplerFloat resampler; - yup::Oversampler2xFloat oversampler2x; - yup::Oversampler4xFloat oversampler4x; - yup::Oversampler8xFloat oversampler8x; + yup::SincOversampler oversampler2x; + yup::SincOversampler oversampler4x; + yup::SincOversampler oversampler8x; + yup::SincOversampler oversampler16x; + yup::SincOversampler oversampler32x; std::vector sourceBuffer; - std::vector silenceBuffer; std::vector resampledBuffer; int maxOutputBlockSize = 0; int sourceBlockCapacity = 0; @@ -466,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; @@ -724,10 +779,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 +792,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 +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:" }) + 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 +1089,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 +1100,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); @@ -1046,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); } @@ -1077,23 +1145,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 +1177,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 +1188,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 +1387,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/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/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; 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/examples/audio/SynthEngine.h b/examples/graphics/source/examples/audio/SynthEngine.h new file mode 100644 index 000000000..48d08e31b --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthEngine.h @@ -0,0 +1,1320 @@ +/* + ============================================================================== + + 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. + + @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, + 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 + || 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; + + const auto bandwidthGrew = values.syncMode != yup::SyncMode::none && neededHarmonics > appliedOutputHarmonics; + + applied = values; + hasApplied = true; + + if (sourceChanged) + sourcePeak = measurePeak (source); + + if (! (sourceChanged || shapeChanged || bandwidthGrew)) + return false; + + spectrum.process (source, shapedSeries, toPrismShape (values), values.color); + + if (values.syncMode == yup::SyncMode::none) + { + derived = &shapedSeries; + } + else + { + 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; + } + + 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; + int appliedOutputHarmonics = 0; + 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 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 +*/ +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 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 + 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) + { + 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 a modulation reaches 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); + + // 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); + + 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, int numLocalHarmonics) noexcept + { + if (deriveLocally) + { + if (! usingLocalSeries) + { + localDerivation.invalidate(); + usingLocalSeries = true; + } + + if (localDerivation.update (values, *settings, *resources, numLocalHarmonics)) + 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; + double sampleRate = 44100.0; + 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; +}; + +//============================================================================== +/** 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; + SynthModulationValues modulation; + std::array envelopes; + std::array lfos; + float modWheel = 0.0f; /**< The last controller 1 value, 0 to 1, kept across blocks. */ +}; + +//============================================================================== +/** 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, with its own envelopes and LFOs. */ +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); + + for (auto& lfo : lfos) + lfo.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(); + + for (std::size_t index = 0; index < lfos.size(); ++index) + if (context.lfos[index].retrigger) + lfos[index].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; } + + /** 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 {} + + //============================================================================== + 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 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) + { + 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(), lfos[0].getValue(), lfos[1].getValue(), context.modWheel }; + 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) + { + 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.hasSpectrumRoute (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; + std::array, SynthExample::lfoCount> lfos; + 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 (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; + } + 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(); + + 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 + { + 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 + { + // 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; + 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 in the most recently started voice, 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> 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..9eba0aead --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthModulationPage.h @@ -0,0 +1,142 @@ +/* + ============================================================================== + + 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 ({}, getSynthModulationSourceNames(), font) + , destinationChoice ({}, getSynthModulationDestinationNames(), font) + , depthKnob ("DEPTH", -1.0, 1.0, 0.01, 0.0, font) + { + setOpaque (false); + + 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, 4.0f)); + bounds.removeFromLeft (spacing); + destinationChoice.setBounds (bounds.reduced (0.0f, 4.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: sixteen 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..abd06a9c2 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthPanels.h @@ -0,0 +1,1556 @@ +/* + ============================================================================== + + 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 +#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); + label.setJustification (yup::Justification::center); + 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 + { + const auto bounds = getLocalBounds(); + const auto size = yup::jmax (0.0f, yup::jmin (bounds.getWidth(), bounds.getHeight() - captionHeight)); + + // 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: + 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; an empty caption leaves the whole height to the box. */ +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); + + if (caption.isNotEmpty()) + 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(); + + if (label.getText().isNotEmpty()) + 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); +} + +//============================================================================== +/** 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 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 +{ +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 = compilePipelineFromBundle (device, "synth_waveform", options); + if (result.failed()) + { + yup::Logger::outputDebugString ("SynthWaveformShader: shader compile failed: " + result.getErrorMessage()); + return false; + } + + pipeline = result.getValue(); + return true; + } + + 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. + + 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 + 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, + std::shared_ptr sharedShader) + : settings (settingsToEdit) + , resources (sharedResources) + , shader (std::move (sharedShader)) + { + preview.prepare(); + displaySeries.resize (SynthExample::maxHarmonics); + displaySamples.assign (displayResolution, 0.0f); + drawnCycle.assign (displayResolution, 0.0f); + drawnSeries.resize (SynthExample::editableHarmonics); + + 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. 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 = (lastDrawnIndex.has_value() ? measurePeak (drawnCycle) : 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 + { + return measurePeak (displaySamples); + } + + /** 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); + + publishEdit(); + } + + //============================================================================== + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds(); + const auto landscape = editingPartials ? nullptr : renderLandscape (g); + + if (landscape != nullptr) + { + const auto state = g.saveState(); + + // The clip follows the border's centerline so the texture's antialiased edge stays under it. + yup::Path clip; + clip.addRoundedRectangle (bounds.reduced (0.5f), 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), cornerRadius); + + if (editingPartials) + 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 + { + if (editingPartials || ! isShowing()) + return; + + animationTime += static_cast (lastFrameTimeSeconds); + repaint(); + } + + 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; + + lastDrawnIndex.reset(); + refresh(); + } + +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); + } + + /** 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) + { + g.setStrokeColor (SynthTheme::panelBorder); + g.setStrokeWidth (1.0f); + g.strokeLine (bounds.getX(), bounds.getCenterY(), bounds.getRight(), bounds.getCenterY()); + + paintDrawnCycle (g, bounds); + + 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); + } + } + + //============================================================================== + /** 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() + { + 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; + + 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) + { + 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, + 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.setHarmonicMagnitude (index, magnitude); + + publishEdit(); + } + + //============================================================================== + 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; + std::vector displaySamples; + yup::Path path; + + std::vector drawnCycle; + yup::Path drawnPath; + yup::FourierSeries drawnSeries; + std::optional lastDrawnIndex; + + 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, phase and retrigger 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); + + 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); + 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); + retriggerButton.setToggleState (settings.retrigger.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); + + auto header = bounds.removeFromTop (headerHeight); + retriggerButton.setBounds (header.removeFromRight (buttonWidth)); + titleLabel.setBounds (header); + bounds.removeFromTop (spacing); + + // 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 (choice.withSizeKeepingCenter (choice.getWidth(), yup::jmin (choice.getHeight(), choiceHeight))); + layoutControlsInRow (knobs, { &rateKnob, &phaseKnob }); + } + + 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 choiceHeight = 36.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()); } + + SynthLFOSettings& settings; + + yup::Label titleLabel; + yup::ToggleButton retriggerButton; + 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, + std::shared_ptr waveformShader, + const yup::Font& font) + : settings (settingsToEdit) + , 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) + , 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..1efbf0ce6 --- /dev/null +++ b/examples/graphics/source/examples/audio/SynthSettings.h @@ -0,0 +1,622 @@ +/* + ============================================================================== + + 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 = 16; + +/** 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 = 64; + +/** 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 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. + + @see SynthOscillator, WaveformEditor +*/ +struct SynthOscillatorSettings +{ + SynthOscillatorSettings() + { + for (auto& cosine : harmonicCosines) + cosine.store (0.0f); + + for (auto& sine : harmonicSines) + sine.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> harmonicCosines; + std::array, SynthExample::editableHarmonics> harmonicSines; + 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 partials, without allocating. + + 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 + 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 index = static_cast (harmonic - 1); + + series.setHarmonic (harmonic, + static_cast (harmonicCosines[index].load() * scale), + static_cast (harmonicSines[index].load() * scale)); + } + } + + /** 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) + { + 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) + { + 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); + } +}; + +//============================================================================== +/** 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; + 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. */ +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(), retrigger.load() }; + } +}; + +//============================================================================== +/** What can drive a modulation route. */ +enum class SynthModulationSource +{ + env1, /**< The amplitude envelope, unipolar. */ + env2, /**< The free envelope, unipolar. */ + lfo1, /**< Bipolar. */ + 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", "MOD WHEEL" }; +} + +/** 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 something is routed with depth into a spectrum parameter of this oscillator. */ + bool hasSpectrumRoute (int oscillator) const noexcept + { + for (const auto& route : routes) + { + if (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, before or after modulation 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/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index f757b5680..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,58 +76,85 @@ 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 +#include "examples/AI.h" +#endif #if YUP_EXAMPLE_GRAPHICS_DEMO_Artboard #include "examples/Artboard.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_AI -#include "examples/AI.h" +#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_FilterDemo -#include "examples/FilterDemo.h" +#if YUP_EXAMPLE_GRAPHICS_DEMO_Filter +#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 +166,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_OpaqueDemo -#include "examples/OpaqueDemo.h" +#if YUP_EXAMPLE_GRAPHICS_DEMO_Opaque +#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_PbrDemo -#include "examples/PbrDemo.h" +#if YUP_EXAMPLE_GRAPHICS_DEMO_Pbr +#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" @@ -166,8 +204,8 @@ inline yup::File getAssetPath (yup::StringRef subPath = {}) #if YUP_EXAMPLE_GRAPHICS_DEMO_TextEditor #include "examples/TextEditor.h" #endif -#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotificationDemo -#include "examples/ToastNotificationDemo.h" +#if YUP_EXAMPLE_GRAPHICS_DEMO_ToastNotification +#include "examples/ToastNotification.h" #endif #if YUP_EXAMPLE_GRAPHICS_DEMO_TouchTrails #include "examples/TouchTrails.h" @@ -263,6 +301,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 +341,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 +365,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 +374,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 +398,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 diff --git a/justfile b/justfile index e48cebaed..8d02b25b7 100644 --- a/justfile +++ b/justfile @@ -166,3 +166,30 @@ 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("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 + @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}} 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_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_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_AdditiveOscillator.h b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h new file mode 100644 index 000000000..95d4e70fb --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_AdditiveOscillator.h @@ -0,0 +1,289 @@ +/* + ============================================================================== + + 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. 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). + + @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); + updateActiveHarmonics(); + updatePhaseRotation(); + + reset(); + } + + /** Resets the oscillator phase to zero. */ + void reset() noexcept + { + phase = 0.0; + samplesUntilReseed = 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(); + updatePhaseRotation(); + } + } + + /** 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)); + samplesUntilReseed = 0; + } + + /** 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 + { + 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); + } + + /** 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()); + } + + 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 stepCosine = static_cast (fundamentalCosine); + const auto stepSine = static_cast (fundamentalSine); + + 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; + 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; +}; + +//============================================================================== +/** @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..400b192ef --- /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_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_ModulatedOscillator.h b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h new file mode 100644 index 000000000..a3899a2af --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_ModulatedOscillator.h @@ -0,0 +1,374 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Controls for one internal sample interval of a modulated oscillator voice. + + All fields must be finite. Shared by ModulatedOscillator and by any oscillator + composed over detail::ModulatedOscillatorVoice. + + @see ModulatedOscillator, PrismSpectrum +*/ +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. */ +}; + +namespace detail +{ + +//============================================================================== +/** 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, PrismSpectrum +*/ +template +class ModulatedOscillatorVoice +{ +public: + using Parameters = ModulatedOscillatorParameters; + + //============================================================================== + /** Attaches a prepared waveform bank and sets the rate processSample() runs at. + + @param newInternalSampleRate Rate the voice is clocked at in Hz, positive. + @param waveforms Immutable bank; must remain alive throughout playback. + */ + void prepare (double newInternalSampleRate, const WaveformBank& waveforms) noexcept + { + jassert (newInternalSampleRate > 0.0); + + internalSampleRate = jmax (1.0, newInternalSampleRate); + bank = &waveforms; + + reset(); + } + + /** Resets phase, sync leader and pending residuals. + + @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; + } + + /** 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 rate processSample() is expected to be called at. */ + double getInternalSampleRate() const noexcept { return internalSampleRate; } + + //============================================================================== + /** Advances the voice by one internal sample and returns its corrected value. + + The controls describe the interval that follows; event interpolation assumes + they stay constant over it. A bank must have been attached first. + */ + double processSample (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 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; + + 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; + } + +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); + } + } + + //============================================================================== + const WaveformBank* bank = nullptr; + double internalSampleRate = 1.0; + double phase = 0.0; + double leaderPhase = 0.0; + double nextCorrection = 0.0; + double previousPhaseModulation = 0.0; + 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..4677ae711 --- /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. + uint32 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 new file mode 100644 index 000000000..6e11648aa --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_SyncOscillator.h @@ -0,0 +1,316 @@ +/* + ============================================================================== + + 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. Stationary additive output is + bandlimited. Parameter modulation and wavetable interpolation can introduce + additional spectral components. + + ``` + 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; 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, + 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, WavetableOscillator +*/ +template +class SyncOscillator +{ +public: + //============================================================================== + /** 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); + wavetable.prepare (sampleRate, this->maxHarmonics, crossfadeLengthInSamples); + + followerRatio = CoeffType (1); + dirty = true; + + setFrequency (leaderFrequency); + setPhase (CoeffType (0)); + + wavetable.setIncludeDC (includeDC); + + update(); + } + + /** Resets the playback phase of both backends. */ + void reset() noexcept + { + wavetable.reset(); + } + + //============================================================================== + /** + Sets the leader (note) pitch in Hz. + + The follower's pitch follows immediately as + frequency * getFundamentalScale (syncMode). Both backends are phase + continuous; call update() to restore harmonics when lowering the pitch. + */ + void setFrequency (CoeffType leaderHz) noexcept + { + leaderFrequency = jmax (leaderHz, CoeffType (0)); + + const auto outputFrequency = getOutputFrequency(); + + 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 + { + wavetable.setPhase (newPhase); + } + + /** Returns the phase of the active backend, normalized to one period. */ + CoeffType getPhase() const noexcept + { + return wavetable.getPhase(); + } + + /** Selects whether the synthesized series includes its DC coefficient. */ + void setIncludeDC (bool shouldIncludeDC) noexcept + { + if (includeDC == shouldIncludeDC) + return; + + includeDC = 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 + || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics); + } + + /** + 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 || (syncMode != SyncMode::none && getOutputHarmonicLimit() > computedHarmonics)) + { + const auto numOutputHarmonics = getOutputHarmonicLimit(); + + resampler.transform (follower, followerRatio, syncMode, synced, numOutputHarmonics); + + wavetable.setSeries (synced); + computedHarmonics = numOutputHarmonics; + + dirty = false; + } + + if (wavetable.needsRender()) + wavetable.render(); + } + + //============================================================================== + /** Produces one sample with the active backend. */ + SampleType processSample() noexcept + { + return wavetable.processSample(); + } + + /** Produces a block of samples with the active backend. */ + void processBlock (SampleType* output, int numSamples) noexcept + { + if (output == nullptr) + return; + + wavetable.processBlock (output, numSamples); + } + +private: + //============================================================================== + int getOutputHarmonicLimit() const noexcept + { + return getNyquistHarmonicLimit (getOutputFrequency(), sampleRate, maxHarmonics); + } + + SyncSpectralResampler resampler; + WavetableOscillator wavetable; + FourierSeries follower; + FourierSeries synced; + double sampleRate = 44100.0; + CoeffType leaderFrequency = static_cast (440); + CoeffType followerRatio = CoeffType (1); + SyncMode syncMode = SyncMode::none; + int maxHarmonics = 128; + int computedHarmonics = 0; + 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..55f51a663 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_SyncSpectralResampler.h @@ -0,0 +1,421 @@ +/* + ============================================================================== + + 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). 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. 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 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. 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 + { + 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); + + const auto sums = accumulateWeights (nSquared, numFollower); + + const auto sign = (n % 2 == 0) ? static_cast (-1) : static_cast (1); + + auto a = sign * (CoeffType (2) / pi) * sums[0]; + auto b = sign * (CoeffType (2 * n) / pi) * sums[1]; + + 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); + + const auto sums = accumulateWeights (nSquared, numFollower); + + const auto alternating = (n % 2 == 0) ? static_cast (1) : static_cast (-1); + + auto a = (CoeffType (2) / pi) * (sums[0] + alternating * sums[1]); + + 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; + + resonantIndices.clear(); + + if (isResonant) + resonantIndices.push_back (static_cast (resonantIndex)); + + const auto sums = accumulateWeights (q * q, numFollower); + + const auto sine = std::sin (pi * q); + + auto a = (CoeffType (2) * q * sine / (pi * ratio)) * sums[0]; + auto b = (CoeffType (2) * sine / (pi * ratio)) * sums[1]; + + if (isResonant) + { + a += followerCosine[resonantIndex] / ratio; + b += followerSine[resonantIndex] / ratio; + } + + output.setHarmonic (n, a, b); + } + } + + /** Returns the dot products of both weight vectors with 1 / (squared - argumentSquared), skipping resonantIndices. */ + std::array accumulateWeights (CoeffType squared, int count) noexcept + { + FloatVectorOperations::fill (weight.data(), squared, count); + FloatVectorOperations::subtract (weight.data(), argumentSquared.data(), count); + FloatVectorOperations::copyWithDividend (weight.data(), weight.data(), CoeffType (1), count); + + for (const auto index : resonantIndices) + weight[static_cast (index)] = CoeffType (0); + + return { FloatVectorOperations::dotProduct (firstWeight.data(), weight.data(), count), + FloatVectorOperations::dotProduct (secondWeight.data(), weight.data(), count) }; + } + + //============================================================================== + 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_WaveformBank.h b/modules/yup_dsp/oscillators/yup_WaveformBank.h new file mode 100644 index 000000000..22c45adb1 --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_WaveformBank.h @@ -0,0 +1,204 @@ +/* + ============================================================================== + + 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 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); + } + } + } + + /** 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; } + + /** 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 new file mode 100644 index 000000000..a2a523acd --- /dev/null +++ b/modules/yup_dsp/oscillators/yup_WavetableOscillator.h @@ -0,0 +1,394 @@ +/* + ============================================================================== + + 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. + + @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 (bool crossfade = true) 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); + + 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); + } + + //============================================================================== + /** 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, phase, false); + + if (crossfadePosition < CoeffType (1)) + { + const auto target = readTable (next, phase, false); + + 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 readAtPhase (double normalizedPhase, bool derivative) const noexcept + { + 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)); + + 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); + + 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); + } + + //============================================================================== + 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/resampling/yup_HalfbandOversampler.h b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h new file mode 100644 index 000000000..7a344e5ac --- /dev/null +++ b/modules/yup_dsp/resampling/yup_HalfbandOversampler.h @@ -0,0 +1,876 @@ +/* + ============================================================================== + + 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; + + /** 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; +}; + +//============================================================================== +/** + 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) 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 + 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); + design.stopbandEdge = jlimit (design.passbandEdge + 0.01, 1.0 - design.passbandEdge, design.stopbandEdge); + 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 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, passband, stopband); + interpolationDelay += static_cast (stage.halfLength) / (2 * lowRate); + } + else + { + designIirStage (stage, stopband - passband); + interpolationDelay += stage.lowFrequencyDelay / lowRate; + } + + const int lowBlock = maxBlockSize * lowRate; + + stage.interpolationInput.setSize (maxChannels, lowBlock + stage.halfLength); + stage.interpolationOutput.setSize (maxChannels, 2 * lowBlock); + 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(); + 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 2 * s.halfLength + 1; + + return 2 * static_cast (s.directAllpass.size() + s.delayedAllpass.size()) + 1; + } + +private: + //============================================================================== + struct Stage + { + 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 + 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. + // 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 center = (length - 1) / 2; + even.assign (static_cast (center + 1), 0.0); + odd.assign (static_cast (center), 0.0); + + const auto tap = [&] (int n) + { + 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 (const auto value : *branch) + sum += value; + + for (auto& value : *branch) + value *= 0.5 / sum; + } + } + + // 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 = 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 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 < center; ++j) + response += odd[static_cast (j)] * std::cos (omega * (2 * j + 1 - center)); + + worst = jmax (worst, std::abs (response)); + } + + return worst; + } + + 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); + + const auto roundLength = [halfband] (int n) + { + if (halfband) + while (n % 4 != 3) + ++n; + else if (n % 2 == 0) + ++n; + + return n; + }; + + length = roundLength (length); + std::vector even, odd; + + for (;;) + { + buildLowpassBranches (length, cutoff, beta, even, odd); + + if (worstStopbandGain (even, odd, stopband) <= stopbandGain || length >= maxFirLength) + break; + + 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.oddDecimation.clear(); + stage.oddInterpolation.clear(); + + if (! halfband) + { + 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(); + 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.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 int history = stage.halfLength; + auto* buffer = stage.interpolationInput.getWritePointer (channel); + + FloatVectorOperations::copy (buffer + history, input, count); + + 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; + + if (stage.halfband) + { + for (int m = 0; m < count; ++m) + { + *output++ = dotProduct (evenTaps, buffer + m, numEven); + *output++ = buffer[m + passThrough]; + } + } + else + { + 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); + } + + void decimateFir (Stage& stage, int channel, const SampleType* input, SampleType* output, int count) noexcept + { + const int history = stage.halfLength; + 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[history + i] = input[2 * i]; + odd[history + i] = input[2 * i + 1]; + } + + 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; + + 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 + history, even); + std::copy (odd + half, odd + half + history, 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 52% rename from modules/yup_dsp/resampling/yup_Oversampler.h rename to modules/yup_dsp/resampling/yup_SincOversampler.h index 171d08487..2b19bc60a 100644 --- a/modules/yup_dsp/resampling/yup_Oversampler.h +++ b/modules/yup_dsp/resampling/yup_SincOversampler.h @@ -28,13 +28,22 @@ 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. + 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 + (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::SincOversampler os; os.prepare (44100.0, 2, 512); // Inside your audio callback: @@ -46,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 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). + + @see HalfbandOversampler */ template -class Oversampler +class SincOversampler { public: static_assert (OversampleFactor >= 2, "OversampleFactor must be at least 2"); @@ -62,17 +77,17 @@ class Oversampler //============================================================================== /** Default constructor. Call prepare() before any processing. */ - Oversampler() = default; + SincOversampler() = default; /** Destructor. */ - ~Oversampler() = default; + ~SincOversampler() = default; //============================================================================== /** 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 +97,27 @@ class Oversampler { jassert (sampleRate > 0.0 && maxChannels > 0 && maxBlockSize > 0); - interpolationTable.configure (static_cast (sampleRate)); - interpolationTable.applyKaiserWindow (CoeffType (5)); - - decimationTable.configureWithCutoff (static_cast (sampleRate) * antiAliasCutoffRatio, - static_cast (sampleRate)); - decimationTable.applyKaiserWindow (CoeffType (5)); - - normalizeFilterGains(); - - const int maxInterpolated = maxBlockSize * OversampleFactor; + buildInterpolationTaps (static_cast (sampleRate)); + buildDecimationTaps (static_cast (sampleRate)); - 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(); + maxInputSamples = maxBlockSize; - oversampledBuffer.setSize (maxChannels, maxInterpolated, false, false, true); - oversampledBuffer.clear(); + xInterp.setSize (maxChannels, maxBlockSize + interpolationHistory); + xDecim.setSize (maxChannels, maxBlockSize * OversampleFactor + decimationHistory); + oversampledBuffer.setSize (maxChannels, maxBlockSize * OversampleFactor, false, false, true); - 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 +144,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,44 +152,55 @@ 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); } } + /** 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 > maxInputSamples) + 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. @@ -244,50 +230,19 @@ class Oversampler 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* inBuf = oversampledBuffer.getReadPointer (ch); + auto* history = xDecim.getWritePointer (ch); + FloatVectorOperations::copy (history + decimationHistory, oversampledBuffer.getReadPointer (ch), interpolatedSize); - 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); + out[k] = dotProduct (decimationTaps.data(), history + k * OversampleFactor, static_cast (decimationTapCount)); - 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); - } - - for (int i = 0; i < SincRadius * OversampleFactor; ++i) - dEndBuf.push (xBuf[static_cast (interpolatedSize + i)]); + std::copy (history + interpolatedSize, history + interpolatedSize + decimationHistory, history); } currentOversampledSize = 0; @@ -299,7 +254,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. @@ -359,9 +315,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 { @@ -380,57 +336,80 @@ 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; - YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Oversampler) + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SincOversampler) }; -//============================================================================== -/** @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 */ - } // 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 27dce5e93..ef8cef648 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 @@ -126,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/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 720518cbf..3629778e4 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 @@ -140,6 +141,16 @@ #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" +#include "oscillators/yup_WaveformBank.h" +#include "oscillators/yup_PrismSpectrum.h" +#include "oscillators/yup_LFO.h" + // Onset detection #include "onsets/yup_FilterBank.h" #include "onsets/yup_Spectrogram.h" @@ -181,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" @@ -204,5 +216,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 HalfbandOversampler) +#include "oscillators/yup_ModulatedOscillator.h" 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/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/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); 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/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/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_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_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) diff --git a/tests/yup_dsp/module.cpp b/tests/yup_dsp/module.cpp index febc9f119..e204ecebe 100644 --- a/tests/yup_dsp/module.cpp +++ b/tests/yup_dsp/module.cpp @@ -20,6 +20,7 @@ */ #include "yup_AaIirAntialiaser.cpp" +#include "yup_AdditiveOscillator.cpp" #include "yup_AnalogFilters.cpp" #include "yup_BiquadCascade.cpp" #include "yup_BiquadFilter.cpp" @@ -31,20 +32,27 @@ #include "yup_FFTProcessor.cpp" #include "yup_FilterDesigner.cpp" #include "yup_FirstOrderFilter.cpp" +#include "yup_FourierSeries.cpp" #include "yup_FractionallyAddressedDelay.cpp" +#include "yup_HalfbandOversampler.cpp" #include "yup_KMeterState.cpp" #include "yup_LevelProcessor.cpp" #include "yup_LinkwitzRileyFilter.cpp" #include "yup_LoudnessFilter.cpp" +#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" #include "yup_Resampler.cpp" #include "yup_SincTable.cpp" #include "yup_SoftClipper.cpp" #include "yup_SpectrumAnalyzerState.cpp" #include "yup_StateVariableFilter.cpp" +#include "yup_SyncOscillator.cpp" +#include "yup_SyncSpectralResampler.cpp" #include "yup_TimeStretchProcessor.cpp" +#include "yup_WaveformBank.cpp" +#include "yup_WavetableOscillator.cpp" #include "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..43884b054 --- /dev/null +++ b/tests/yup_dsp/yup_AdditiveOscillator.cpp @@ -0,0 +1,250 @@ +/* + ============================================================================== + + 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; + } +} + +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 new file mode 100644 index 000000000..9bd11ca80 --- /dev/null +++ b/tests/yup_dsp/yup_FourierSeries.cpp @@ -0,0 +1,367 @@ +/* + ============================================================================== + + 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, 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)); + 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_HalfbandOversampler.cpp b/tests/yup_dsp/yup_HalfbandOversampler.cpp new file mode 100644 index 000000000..02f418860 --- /dev/null +++ b/tests/yup_dsp/yup_HalfbandOversampler.cpp @@ -0,0 +1,666 @@ +/* + ============================================================================== + + 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>); + + HalfbandOversampler2xFloat a; + HalfbandOversampler4xFloat b; + HalfbandOversampler8xFloat c; + HalfbandOversampler16xFloat d; + HalfbandOversampler32xFloat e; + HalfbandOversampler2xDouble f; + HalfbandOversampler4xDouble g; + HalfbandOversampler8xDouble h; + HalfbandOversampler16xDouble i; + HalfbandOversampler32xDouble j; + + const auto prepareAll = [] (auto&... oversamplers) + { + (oversamplers.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); +} + +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 (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; + + 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_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_ModulatedOscillator.cpp b/tests/yup_dsp/yup_ModulatedOscillator.cpp new file mode 100644 index 000000000..c9816f417 --- /dev/null +++ b/tests/yup_dsp/yup_ModulatedOscillator.cpp @@ -0,0 +1,364 @@ +/* + ============================================================================== + + 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 = 2 * oscillator.getLatencyInSamples(); 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, 256, bank); + Oscillator::Parameters p; + p.frequency = 0.0; + p.phaseModulation = 0.25; + 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); +} + +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 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); + }; + + // 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 = [&]() + { + 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)); + + Rendering rendering; + rendering.latency = decimator.getGenerationLatencyInSamples(); + double* channels[] = { rendering.samples.data() }; + decimator.downsample (channels, 1, count); + return rendering; + }; + + const auto reference = renderReference.template operator()<64>(); + const auto coarserReference = renderReference.template operator()<32>(); + 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 t = settle; t < count - settle; ++t) + { + 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 (t + actualLatency)] - expected; + const auto naive = continuousWaveform (t / sampleRate) - expected; + correctedError += corrected * corrected; + naiveError += naive * naive; + } + + EXPECT_LT (std::sqrt (convergenceError / compared), 0.01); + EXPECT_LT (std::sqrt (correctedError / compared), 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 = 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); + 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 = 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)) + / (MathConstants::twoPi * 137.0); + 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 * bounded.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_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 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_SincOversampler.cpp b/tests/yup_dsp/yup_SincOversampler.cpp new file mode 100644 index 000000000..f4caa70b6 --- /dev/null +++ b/tests/yup_dsp/yup_SincOversampler.cpp @@ -0,0 +1,891 @@ +/* + ============================================================================== + + 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 + +namespace yup::test +{ + +//============================================================================== +class SincOversamplerTest : 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); + } + + SincOversampler os2x; + SincOversampler os4x; +}; + +//============================================================================== +TEST_F (SincOversamplerTest, DefaultConstructionDoesNotCrash) +{ + SincOversampler os; + EXPECT_EQ (os.getOversampledNumSamples(), 0); + EXPECT_EQ (os.getLatencyInSamples(), 16); + EXPECT_EQ (os.getOversampledChannelData (0), nullptr); +} + +TEST_F (SincOversamplerTest, 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 (SincOversamplerTest, LatencyReturnsCorrectValue) +{ + EXPECT_EQ (os2x.getLatencyInSamples(), 16); // 2 * SincRadius = 2 * 8 + EXPECT_EQ (os4x.getLatencyInSamples(), 16); +} + +TEST_F (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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_EQ (os2x.getOversampledChannelData (0), nullptr); +} + +TEST_F (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, DownsampleWithoutAPrecedingUpsampleFiltersHighFrequencies) +{ + SincOversampler 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 (SincOversamplerTest, DecimateThenInterpolateRoundTripPreservesLowFrequency) +{ + constexpr int factor = 2; + constexpr int baseBlock = blockSize / factor; + + SincOversampler decimator; + SincOversampler 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 (SincOversamplerTest, DecimateFirstIsContinuousAcrossBlockBoundaries) +{ + constexpr int factor = 2; + constexpr int baseBlock = blockSize / factor; + constexpr int blockCount = 6; + constexpr float frequency = 500.0f; + + SincOversampler 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) +{ + SincOversampler2xFloat a; + SincOversampler4xFloat b; + SincOversampler8xFloat c; + SincOversampler2xDouble d; + SincOversampler4xDouble 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(); +} + +TEST_F (SincOversamplerTest, 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 (SincOversamplerTest, 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 (SincOversamplerTest, 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())); +} + +//============================================================================== +class SincOversamplerAccuracyTest : 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); + + SincOversampler 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); + + SincOversampler 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; + + SincOversampler 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 (SincOversamplerAccuracyTest, BlockSizeIndependenceFloat2x) +{ + checkBlockSizeIndependence (1e-6); +} + +TEST_F (SincOversamplerAccuracyTest, BlockSizeIndependenceFloat4x) +{ + checkBlockSizeIndependence (1e-6); +} + +TEST_F (SincOversamplerAccuracyTest, BlockSizeIndependenceDouble4x) +{ + checkBlockSizeIndependence (1e-12); +} + +TEST_F (SincOversamplerAccuracyTest, 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; + + SincOversampler 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 (SincOversamplerAccuracyTest, UpsampleMatchesScalarSincReference) +{ + constexpr int radius = 8; + constexpr int factor = 4; + constexpr int total = 256; + const auto input = makeNoise (total, 3); + + SincOversampler 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 (SincOversamplerAccuracyTest, ChannelsAreIndependent) +{ + constexpr int blockSize = 100; + constexpr int total = 300; + const auto left = makeNoise (total, 1); + const auto right = makeNoise (total, 2); + + SincOversampler 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 (SincOversamplerAccuracyTest, ResetMatchesFreshInstance) +{ + constexpr int blockSize = 128; + + SincOversampler 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 (SincOversamplerAccuracyTest, GenerationDoesNotDisturbUpsampleHistory) +{ + constexpr int blockSize = 128; + const auto blockA = makeNoise (blockSize, 8); + const auto blockB = makeNoise (blockSize, 9); + + SincOversampler 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 (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); + + // 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 (SincOversamplerAccuracyTest, DecimationRejectsOversampledDomainToneWithRadius16) +{ + constexpr int factor = 2; + constexpr int blockSize = 2048; + constexpr double toneRatio = 0.6; // of the input sample rate, above the input Nyquist + + SincOversampler 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 (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 (SincOversamplerAccuracyTest, RoundTripSineSNR) +{ + 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 new file mode 100644 index 000000000..cb727f08f --- /dev/null +++ b/tests/yup_dsp/yup_SyncOscillator.cpp @@ -0,0 +1,285 @@ +/* + ============================================================================== + + 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 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, ApproximatesTheManualAdditivePipeline) +{ + SyncOscillator oscillator; + configureSyncedSawtooth (oscillator, SyncMode::hard, 1.375); + completeRenderCrossfade (oscillator); + + 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-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.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. + 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-3) << 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, 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, LowerPitchRestoresPreviouslyOmittedHarmonics) +{ + SyncOscillator oscillator; + 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; + 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); + + // 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 (wavetableEnergy, -60.0) << "wavetable"; + 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..40775babf --- /dev/null +++ b/tests/yup_dsp/yup_SyncSpectralResampler.cpp @@ -0,0 +1,571 @@ +/* + ============================================================================== + + 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 <= follower.getNumHarmonics(); ++k) + dc += rotated.cosine[static_cast (k)] * normalizedSinc (k * ratio); + } + else if (mode == SyncMode::mirrored) + { + 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); + } + + output.setDC (dc); + + for (int n = 1; n <= numOutputHarmonics; ++n) + { + double a = 0.0; + double b = 0.0; + + for (int k = 1; k <= follower.getNumHarmonics(); ++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)); +} + +TEST_F (SyncSpectralResamplerTests, MatchesTheScalarReferenceForShortFollowers) +{ + 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_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)); +} diff --git a/tests/yup_dsp/yup_WaveformBank.cpp b/tests/yup_dsp/yup_WaveformBank.cpp new file mode 100644 index 000000000..f2b577d8f --- /dev/null +++ b/tests/yup_dsp/yup_WaveformBank.cpp @@ -0,0 +1,167 @@ +/* + ============================================================================== + + 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)); +} + +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()); +} diff --git a/tests/yup_dsp/yup_WavetableOscillator.cpp b/tests/yup_dsp/yup_WavetableOscillator.cpp new file mode 100644 index 000000000..60935fb6b --- /dev/null +++ b/tests/yup_dsp/yup_WavetableOscillator.cpp @@ -0,0 +1,233 @@ +/* + ============================================================================== + + 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)); +} + +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()); +} 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/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 // ============================================================================= 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/README.md b/website/README.md index 9149cf560..ab9c7a7c0 100644 --- a/website/README.md +++ b/website/README.md @@ -14,14 +14,28 @@ 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 `` 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 ``. - `src/partials/snippets/` - plain code samples (`.cpp`, `.cmake`, `.sh`), inlined and highlighted with Shiki where a page contains ``. 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 + +- `` becomes the number of `modules/yup_*` folders. `` counts the `
  • ` items or `chip-on` chips inside the page element marked `data-count=""`, 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=""` (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. +## 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. +`.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/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/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! + + + + + +
    +
    +
    +
    + +

    Error 404

    +

    Lost in orbit

    +

    This page drifted off the signal path. It may have moved, or it never existed. Tap the logo for a proper sendoff, then head somewhere that does exist.

    +
    + Back to home + + Run live +
    +
    +
    +
    + + + + + + 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 bf1e851e4..cc2482ec5 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,60 +13,77 @@
    - 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.

    +

    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.

    - YUP! audio graph editor + YUP! prism spectral synth
    - - + + - + - + + + +
    @@ -77,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
      +
    @@ -118,22 +153,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.

    +
    +
    +
    + +
    +

    Built together, in the open

    +

    The roadmap is planned in public on GitHub Discussions. Ideas, issues and pull requests decide what ships next.

    @@ -154,17 +210,17 @@

    Everything 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

    @@ -209,7 +265,7 @@

    Native where it matters

    Rendering

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

    Audio I/O

    Plugin formats

    -
    +
    CLAP VST3 AUv2 @@ -295,7 +351,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.

    @@ -348,20 +404,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?+ @@ -386,11 +442,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/main.js b/website/src/main.js index 0d5ae45c5..83e919e18 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. +const lenis = new Lenis({ autoRaf: true, anchors: true, allowNestedScroll: true }); + const navToggle = document.getElementById("nav-toggle"); const navMenu = document.getElementById("nav-menu"); @@ -53,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/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_rhi

    -

    Low-level GPU abstraction: devices, compute and render pipelines, buffers, textures and offscreen targets. Use it for GPU compute without the 2D stack.

    +

    Low-level GPU abstraction: devices, compute and render pipelines, buffers, textures and offscreen targets, with everything needed to render 3D into components and components into 3D. Use it for GPU compute without the 2D stack.

    yup_coreyup_shadingyup_simdrive
    Docs @@ -102,7 +102,7 @@

    yup_rhi

    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/partials/footer.html b/website/src/partials/footer.html index 9e8ec5320..639eec183 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 @@ -20,6 +20,7 @@

    Framework

  • Modules
  • Showcase
  • +
  • Compare
  • Get Started
  • Changelog
  • @@ -40,7 +41,9 @@

    Community

  • GitHub
  • Issues
  • -
  • Discord
  • +
  • Discussions
  • +
  • Discord
  • +
  • Support
  • diff --git a/website/src/partials/header.html b/website/src/partials/header.html index 9af2ed7a1..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/showcase.html b/website/src/showcase.html index dccad0b5e..f68856a79 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.

      @@ -24,7 +24,6 @@

      Graphics and GPU Interface - SVG DSP Audio @@ -38,38 +37,44 @@

      Graphics and GPU

      Rive artboards -
      Rive artboards
      +
      Rive artboardsSource
      - + Lottie playback -
      Lottie playback
      +
      Lottie playbackLive demoSource
      +
      +
      + + SVG carousel + +
      SVG carouselLive 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,58 +87,38 @@

      Interface

      Color picker -
      Color picker
      -
      -
      -
      - - Gradient editor -
      Gradient editor
      -
      -
      -
      - - Multitouch trails -
      Multitouch trails
      +
      Color pickerSource
      - - Paint profiler -
      Paint profiler
      +
      + Transformable widgets panel +
      Transformable widgetsLive demoSource
      - - -
      -
      -

      SVG

      - -
      -
      @@ -144,40 +129,22 @@

      DSP

      @@ -190,26 +157,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..b005911f1 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; } @@ -165,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; } @@ -183,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 0bc2d0a0c..28ff4d740 100644 --- a/website/vite.config.js +++ b/website/vite.config.js @@ -1,12 +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(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 = { @@ -29,10 +40,129 @@ const highlighter = createHighlighter({ themes: [codeTheme], langs: Object.value const readPartial = (path) => readFileSync(resolve(partialsDir, path), "utf8"); -// Expands into partials/.html and into a -// highlighted
       of partials/, then marks the current page's nav link.
      +const pageUrl = (page) => (page === "index.html" ? `${siteUrl}/` : `${siteUrl}/${page}`);
      +
      +// 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);
      +
      +    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");
      +}
      +
      +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",
      @@ -50,13 +180,21 @@ 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) {
                       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 +203,24 @@ function partials() {
                                   transformers: [{ pre(node) { this.addClassToHast(node, "code"); } }],
                               }))
                           .replaceAll(`data-nav href="./${page}"`, `data-nav aria-current="page" href="./${page}"`);
      +
      +                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() {
      +            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) });
      +        },
           };
       }
       
      @@ -76,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`)])),
               },
           },
       });