1
0

Analyser with updates via metering path

This commit is contained in:
Geraint 2025-07-01 11:55:12 +01:00
parent a1b9153bdc
commit 6fcdd0a158
11 changed files with 624 additions and 187 deletions

12
CMakeLists.txt Normal file
View File

@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.24)
add_library(signalsmith-basics INTERFACE)
target_include_directories(signalsmith-basics INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/modules
)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/modules/linear)
target_link_libraries(signalsmith-basics INTERFACE
signalsmith-linear
)

View File

@ -4,6 +4,12 @@ Released under the Boost Software License (see LICENSE.txt) */
#include "stfx/stfx-library.h" #include "stfx/stfx-library.h"
#include "dsp/curves.h"
#include "linear/stft.h"
#include "linear/linear.h"
#include <vector>
namespace signalsmith { namespace basics { namespace signalsmith { namespace basics {
template<class BaseEffect> template<class BaseEffect>
@ -15,22 +21,45 @@ using AnalyserDouble = stfx::LibraryEffect<double, AnalyserSTFX>;
template<class BaseEffect> template<class BaseEffect>
struct AnalyserSTFX : public BaseEffect { struct AnalyserSTFX : public BaseEffect {
using typename BaseEffect::Sample; using typename BaseEffect::Sample;
using Complex = std::complex<Sample>;
using typename BaseEffect::ParamRange; using typename BaseEffect::ParamRange;
using typename BaseEffect::ParamStepped; using typename BaseEffect::ParamStepped;
static constexpr Sample stftBlockMs = 30, stftIntervalMs = 5;
ParamRange barkResolution = 10;
template<class Storage> template<class Storage>
void state(Storage &storage) { void state(Storage &storage) {
storage.info("[Basics] Analyser", "A Bark-scale spectrum analyser"); storage.info("[Basics] Analyser", "A Bark-scale spectrum analyser");
storage.version(0); storage.version(0);
storage.range("barkResolution", barkResolution)
.info("res.", "in Bark scale")
.range(1, 10, 25)
.unit("", 0);
if (storage.extra()) {
storage("spectrum", spectrum);
}
} }
template<class Config> template<class Config>
void configureSTFX(Config &config) { void configureSTFX(Config &config) {
config.outputChannels = config.inputChannels; sampleRate = config.sampleRate;
channels = config.outputChannels = config.inputChannels;
config.auxInputs = config.auxOutputs = {}; config.auxInputs = config.auxOutputs = {};
stft.configure(channels, 0, stftBlockMs*0.001*sampleRate, stftIntervalMs*0.001*sampleRate);
subRate = sampleRate/stft.defaultInterval();
bands = spectrum.resize(channels, barkResolution, sampleRate);
updateBands();
tmp.resize(stft.defaultInterval());
} }
void reset() {} void reset() {
spectrum.reset();
}
template<class Io, class Config, class Block> template<class Io, class Config, class Block>
void processSTFX(Io &io, Config &config, Block &block) { void processSTFX(Io &io, Config &config, Block &block) {
@ -42,7 +71,167 @@ struct AnalyserSTFX : public BaseEffect {
} }
} }
bool processedSpectrum = false;
size_t index = 0;
while (index < block.length) {
size_t remaining = stft.defaultInterval() - stft.samplesSinceAnalysis();
size_t consume = std::min<size_t>(remaining, block.length - index);
// copy input
for (size_t c = 0; c < config.inputChannels; ++c) {
auto &input = io.input[c];
for (size_t i = 0; i < consume; ++i) {
tmp[i] = input[index + i];
}
stft.writeInput(c, consume, tmp.data());
}
stft.moveInput(consume);
if (remaining == consume) {
stft.analyse();
stftStep();
processedSpectrum = true;
}
index += consume;
}
if (processedSpectrum && block.wantsMeters()) {
for (size_t c = 0; c < channels; ++c) {
size_t offset = c*bands;
auto state2 = linear.wrap(state2Real.data() + offset, state2Imag.data() + offset, bands);
linear.wrap(spectrum.energy[c]) = state2.norm();
}
}
} }
template<class Storage>
void meterState(Storage &storage) {
storage("spectrum", spectrum);
}
private:
signalsmith::linear::DynamicSTFT<Sample> stft;
signalsmith::linear::Linear linear;
std::vector<Sample> tmp;
double prevBarkResolution = -1;
Sample sampleRate, subRate;
size_t channels = 0;
size_t bands = 0;
std::vector<Sample> inputBin;
std::vector<Sample> bandInputReal, bandInputImag;
std::vector<Sample> state1Real, state1Imag;
std::vector<Sample> state2Real, state2Imag;
std::vector<Sample> twistReal, twistImag, inputGain;
void updateBands() {
linear.reserve<Sample>(bands);
bandInputReal.resize(bands*channels);
bandInputImag.resize(bands*channels);
state1Real.resize(bands*channels);
state1Imag.resize(bands*channels);
linear.wrap(state1Real) = 0;
linear.wrap(state1Imag) = 0;
state2Real.resize(bands*channels);
state2Imag.resize(bands*channels);
linear.wrap(state2Real) = 0;
linear.wrap(state2Imag) = 0;
inputBin.resize(0);
twistReal.resize(0);
twistImag.resize(0);
for (size_t b = 0; b < bands; ++b) {
Sample hz = spectrum.hz[b];
Sample bwHz = spectrum.bwHz[b];
inputBin.push_back(stft.freqToBin(hz/sampleRate));
Sample freq = Sample(2*M_PI/subRate)*hz;
Sample bw = Sample(2*M_PI/subRate)*bwHz;
Complex twist = std::exp(Complex{-bw, freq});
twistReal.push_back(twist.real());
twistImag.push_back(twist.imag());
}
inputGain.resize(bands);
linear.wrap(inputGain) = 1 - linear.wrap(twistReal, twistImag).abs();
}
void stftStep() {
if (barkResolution != prevBarkResolution) {
prevBarkResolution = barkResolution;
bands = spectrum.resize(channels, barkResolution, sampleRate);
updateBands();
}
// Load inputs from the spectrum
Sample scalingFactor = Sample(1)/stft.defaultInterval();
for (size_t c = 0; c < channels; ++c) {
auto *spectrum = stft.spectrum(c);
auto *inputR = bandInputReal.data() + bands*c;
auto *inputI = bandInputImag.data() + bands*c;
for (size_t b = 0; b < bands; ++b) {
Sample index = std::min<Sample>(inputBin[b], stft.bands() - Sample(1.001));
size_t indexLow = std::floor(index);
Sample indexFrac = index - std::floor(index);
Complex v = spectrum[indexLow] + (spectrum[indexLow + 1] - spectrum[indexLow])*indexFrac;
v *= scalingFactor;
inputR[b] = v.real();
inputI[b] = v.imag();
}
}
auto twist = linear.wrap(twistReal, twistImag);
auto gain = linear.wrap(inputGain);
for (size_t c = 0; c < channels; ++c) {
size_t offset = c*bands;
auto state1 = linear.wrap(state1Real.data() + offset, state1Imag.data() + offset, bands);
auto state2 = linear.wrap(state2Real.data() + offset, state2Imag.data() + offset, bands);
auto input = linear.wrap(bandInputReal.data() + offset, bandInputImag.data() + offset, bands);
state1 = state1*twist + input*gain;
state2 = state2*twist + state1*gain;
}
}
struct Spectrum {
bool bandsChanged = false;
std::vector<Sample> hz, bwHz;
std::vector<std::vector<Sample>> energy;
size_t resize(size_t channels, Sample barkResolution, Sample sampleRate) {
bandsChanged = true;
hz.resize(0);
bwHz.resize(0);
auto barkScale = signalsmith::curves::Reciprocal<Sample>::barkScale();
Sample barkStep = 1/barkResolution, barkEnd = barkScale.inverse(sampleRate/2);
for (Sample bark = barkScale.inverse(0); bark < barkEnd; bark += barkStep) {
hz.push_back(barkScale(bark));
bwHz.push_back(barkScale.dx(bark)*barkStep);
}
hz.push_back(sampleRate/2);
bwHz.push_back(barkScale.dx(barkEnd)*barkStep);
energy.resize(channels);
for (auto &e : energy) e.resize(hz.size());
return hz.size();
}
void reset() {
for (auto &e : energy) {
for (auto &v : e) v = 0;
}
}
template<class Storage>
void state(Storage &storage) {
if (bandsChanged) {
bandsChanged = false;
storage.extra("$type", "Spectrum");
storage("hz", hz);
}
storage("energy", energy);
}
} spectrum;
}; };
}} // namespace }} // namespace

View File

@ -1,6 +1,7 @@
cmake_minimum_required(VERSION 3.28) cmake_minimum_required(VERSION 3.28)
set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_C_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
project(plugins VERSION 1.0.0) project(plugins VERSION 1.0.0)

View File

@ -48,4 +48,5 @@ format:
dev: release-$(PLUGIN)_clap dev: release-$(PLUGIN)_clap
/Applications/REAPER.app/Contents/MacOS/REAPER REAPER/$(PLUGIN)/$(PLUGIN).RPP /Applications/REAPER.app/Contents/MacOS/REAPER REAPER/$(PLUGIN)/$(PLUGIN).RPP
wclap: emscripten-$(PLUGIN)_wclap wclap: emscripten-$(PLUGIN)_wclap
./wclap-tar.sh out/Release/$(PLUGIN).wclap out/

14
clap/wclap-tar.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env sh
name="$(basename $1)"
outDir="${2:-..}"
pushd "${outDir}"
outDir=`pwd`
popd
echo $name
cd $1
rm -f "${outDir}/${name}.tar.gz"
tar --exclude=".*" -vczf "${outDir}/${name}.tar.gz" *

@ -1 +1 @@
Subproject commit 157b448e390663e78d845ca9f8dad65140f686ad Subproject commit 4c8999385c725e619723346bf8f415010fed9c4c

View File

@ -164,7 +164,9 @@ namespace stfx {
// per-block automation gradient rate // per-block automation gradient rate
double blockFade; double blockFade;
// we might need some additional setup on the fiand/or after a directly // we might need some additional setup on the fiand/or after a directly
bool firstBlockAfterReset = false; bool firstBlockAfterReset;
bool metersRequested;
bool &metersChecked;
template<class Param> template<class Param>
bool paramsChanging(Param &param) const { bool paramsChanging(Param &param) const {
@ -178,7 +180,7 @@ namespace stfx {
// Block length in samples // Block length in samples
int length; int length;
Block(int length, double fadeStart, double fadeStep, bool firstBlockAfterReset=false) : fadeStart(fadeStart), fadeStep(fadeStep), blockFade(1.0/length), firstBlockAfterReset(firstBlockAfterReset), length(length) {} Block(int length, double fadeStart, double fadeStep, bool firstBlockAfterReset, bool wantsMeters, bool &metersChecked) : fadeStart(fadeStart), fadeStep(fadeStep), blockFade(1.0/length), firstBlockAfterReset(firstBlockAfterReset), metersRequested(wantsMeters), metersChecked(metersChecked), length(length) {}
// Not copyable, because that's probably a mistake // Not copyable, because that's probably a mistake
Block(const Block &) = delete; Block(const Block &) = delete;
Block & operator =(const Block&) = delete; Block & operator =(const Block&) = delete;
@ -278,8 +280,9 @@ namespace stfx {
callback(0, length); callback(0, length);
} }
constexpr bool wantsMeters() const { bool wantsMeters() const {
return false; metersChecked = true;
return metersRequested;
} }
}; };
@ -358,6 +361,22 @@ namespace stfx {
template<class Presets> template<class Presets>
void presets(Presets &) {} void presets(Presets &) {}
// passes ownership of any meter values back to the audio thread
void wantsMeters(bool meters=true) {
metersReady.clear();
if (meters) {
metersRequested.test_and_set();
} else {
metersRequested.clear();
}
}
// whether the meter values can be read
bool hasMeters() const {
return metersReady.test();
}
protected:
std::atomic_flag metersRequested = ATOMIC_FLAG_INIT, metersReady = ATOMIC_FLAG_INIT;
}; };
/// Creates an effect class from an effect template, with optional extra config. /// Creates an effect class from an effect template, with optional extra config.
@ -493,7 +512,7 @@ namespace stfx {
process(buffers, buffers, blockLength); process(buffers, buffers, blockLength);
} }
/// Wraps the common `process(float** inputs, float** outputs, int length)` call into the `.process(io, config, block)`. /// Wraps the common `process(float** inputs, float** outputs, int length)` call into the `.processSTFX(io, config, block)`.
/// It actually accepts any objects which support `inputs[channel][index]`, so you could write adapters for interleaved buffers etc. /// It actually accepts any objects which support `inputs[channel][index]`, so you could write adapters for interleaved buffers etc.
template<class Inputs, class Outputs> template<class Inputs, class Outputs>
void process(Inputs &&inputs, Outputs &&outputs, int blockLength) { void process(Inputs &&inputs, Outputs &&outputs, int blockLength) {
@ -522,7 +541,8 @@ namespace stfx {
Outputs output; Outputs output;
}; };
Io io{inputs, outputs}; Io io{inputs, outputs};
Block block(blockLength, fadeRatio, fadeRatioStep, justHadReset); bool metersChecked = false;
Block block(blockLength, fadeRatio, fadeRatioStep, justHadReset, this->metersRequested.test(), metersChecked);
((EffectClass *)this)->processSTFX(io, (const Config &)config, (const Block &)block); ((EffectClass *)this)->processSTFX(io, (const Config &)config, (const Block &)block);
@ -535,6 +555,12 @@ namespace stfx {
justHadReset = false; justHadReset = false;
for (auto param : params.rangeParams) param->_libEndBlock(); for (auto param : params.rangeParams) param->_libEndBlock();
for (auto param : params.stepParams) param->_libEndBlock(); for (auto param : params.stepParams) param->_libEndBlock();
// Meters are filled - pass ownership of meter values to the main thread
if (this->metersRequested.test() && metersChecked) {
this->metersRequested.clear();
this->metersReady.test_and_set();
}
} }
}; };

View File

@ -204,7 +204,7 @@ private:
} }
template<class Item> template<class Item>
void readValue(std::vector<Item> &array) { void readVector(std::vector<Item> &array) {
if (!cbor.isArray()) return; if (!cbor.isArray()) return;
size_t length = 0; size_t length = 0;
cbor = cbor.forEach([&](Cbor item, size_t index){ cbor = cbor.forEach([&](Cbor item, size_t index){
@ -217,13 +217,18 @@ private:
array.resize(length); array.resize(length);
} }
template<class Item>
void readValue(std::vector<Item> &array) {
readVector(array);
}
#define STORAGE_TYPED_ARRAY(T) \ #define STORAGE_TYPED_ARRAY(T) \
void readValue(std::vector<T> &array) { \ void readValue(std::vector<T> &array) { \
if (cbor.isTypedArray()) { \ if (cbor.isTypedArray()) { \
array.resize(cbor.typedArrayLength()); \ array.resize(cbor.typedArrayLength()); \
cbor.readTypedArray(array); \ cbor.readTypedArray(array); \
} else { \ } else { \
readValue<std::vector<T>>(array); \ readVector<T>(array); \
} \ } \
} }
STORAGE_TYPED_ARRAY(uint8_t) STORAGE_TYPED_ARRAY(uint8_t)

View File

@ -7,15 +7,15 @@
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
display: grid; display: flex;
grid-template-areas: "header" "params"; flex-direction: column;
grid-template-rows: min-content 1fr;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100vw; width: 100vw;
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100vh;
min-height: 100vh;
max-height: 100vh; max-height: 100vh;
overflow: hidden; overflow: hidden;
@ -46,7 +46,7 @@
transform: scale(0.95, 1); transform: scale(0.95, 1);
} }
#params { #params {
grid-area: params; flex-grow: 1;
display: flex; display: flex;
align-content: space-around; align-content: space-around;
justify-content: space-evenly; justify-content: space-evenly;
@ -96,6 +96,27 @@
.param-range-name { .param-range-name {
grid-area: name; grid-area: name;
} }
#plots {
display: flex;
flex-grow: 10;
}
.plot {
display: block;
width: 100%;
flex-grow: 100;
position: relative;
background: linear-gradient(#222, #000 2rem);
}
.plot canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
</style> </style>
</head> </head>
<body> <body>
@ -128,55 +149,188 @@
</div> </div>
</label> </label>
</template> </template>
<script>
function drawDial(data, canvas) {
let scale = window.devicePixelRatio;
let pixels = canvas.offsetWidth*scale;
if (canvas.width != pixels) canvas.width = pixels;
if (canvas.height != pixels) canvas.height = pixels;
let context = canvas.getContext('2d');
context.resetTransform();
context.clearRect(0, 0, pixels, pixels);
context.scale(pixels/2, pixels/2);
context.translate(1, 1);
context.beginPath();
context.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI*2);
context.fillStyle = '#FFF';
context.fill();
context.lineWidth = 0.2;
context.lineCap = 'round';
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*0.25);
context.strokeStyle = '#0001';
context.stroke();
let rangeUnit = data.rangeUnit;
if (data.gesture) rangeUnit = data._gestureUnit;
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + rangeUnit*1.499));
context.strokeStyle = '#000';
context.stroke();
}
</script>
</section> </section>
<template @foreach>
<div class="plot" @if="${d => d.$type == 'Spectrum'}">
<canvas class="plot-grid" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
<canvas class="plot-data" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
<canvas class="plot-labels" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
</div>
</template>
<script> <script>
function drawDial(data, canvas) { let plotColours = ['#8CF', '#FC8', '#8D8', '#F9B'];
let scale = window.devicePixelRatio; function freqScale(width, lowHz, highHz) {
let pixels = canvas.offsetWidth*scale; // let a = -289.614, b = 1176.76, c = 15.3385, d = -0.552833; // Bark
if (canvas.width != pixels) canvas.width = pixels; let low = lowHz/(1500 + lowHz);
if (canvas.height != pixels) canvas.height = pixels; let high = highHz/(1500 + highHz);
let scale = width/(high - low);
return hz => {
let v = hz/(1500 + hz);
return scale*(v - low);
};
}
function drawSpectrum(data, canvas) {
let context = canvas.getContext('2d'); let context = canvas.getContext('2d');
context.resetTransform(); let width = canvas.offsetWidth, height = canvas.offsetHeight;
context.clearRect(0, 0, pixels, pixels); {
let pixelWidth = Math.round(width*window.devicePixelRatio);
let pixelHeight = Math.round(height*devicePixelRatio);
if (canvas.width != pixelWidth) canvas.width = pixelWidth;
if (canvas.height != pixelHeight) canvas.height = pixelHeight;
context.resetTransform();
context.clearRect(0, 0, pixelWidth, pixelHeight);
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
context.scale(pixels/2, pixels/2); let baseHz = 100, maxHz = data.hz[data.hz.length - 1];
context.translate(1, 1); let xScale = freqScale(width, data.hz[0], maxHz);
let yScale = e => height*Math.log10(e + 1e-30)*10/-105;
yScale = e => {
e = Math.pow(e, 0.12);
e = e*0.6/(1 - 0.6 - e + 2*e*0.6);
return height*(1 - e);
};
context.beginPath(); if (canvas.classList.contains('plot-grid')) {
context.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI*2); context.beginPath();
context.fillStyle = '#FFF'; context.lineWidth = 1;
context.fill(); context.strokeStyle = '#555';
for (let baseHz = 100; baseHz < maxHz; baseHz *= 10) {
context.lineWidth = 0.2; for (let i = 1; i < 10; ++i) {
context.lineCap = 'round'; let hz = baseHz*i;
context.beginPath(); let x = xScale(hz);
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*0.25); context.moveTo(x, 0);
context.strokeStyle = '#0001'; context.lineTo(x, height);
context.stroke(); }
}
let rangeUnit = data.rangeUnit; for (let db = 0; db >= -240; db -= 12) {
if (data.gesture) rangeUnit = data._gestureUnit; let e = Math.pow(10, db/10);
let y = yScale(e);
context.beginPath(); context.moveTo(0, y);
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + rangeUnit*1.499)); context.lineTo(width, y);
context.strokeStyle = '#000'; }
context.stroke(); context.stroke();
} else if (canvas.classList.contains('plot-data')) {
context.lineWidth = 1.5;
context.lineCap = 'round';
context.lineJoin = 'round';
let xMapped = Matsui.getRaw(data.hz).map(xScale);
data.energy.forEach((line, index) => {
context.strokeStyle = plotColours[index%plotColours.length];
context.globalAlpha = 6/(6 + index);
context.beginPath();
for (let i = 0; i < xMapped.length; ++i) {
let e = line[i];
let y = yScale(e);
context.lineTo(xMapped[i], y);
}
context.stroke();
if (1) {
context.fillStyle = plotColours[index%plotColours.length];
context.lineTo(width, height);
context.lineTo(0, height);
context.globalAlpha = 2/(10 + index);
context.fill();
}
});
} else {
context.globalAlpha = 1;
context.lineWidth = 2;
context.strokeStyle = '#0006';
context.fillStyle = '#DDD';
for (let baseHz = 100; baseHz < maxHz; baseHz *= 10) {
let text = (baseHz < 1000) ? baseHz + "Hz" : baseHz/1000 + "kHz";
context.strokeText(text, xScale(baseHz) + 2, height - 2);
context.fillText(text, xScale(baseHz) + 2, height - 2);
}
for (let db = 0; db > -105; db -= (db <= -48 ? 24 : 12)) {
let e = Math.pow(10, db/10);
let y = yScale(e);
context.strokeText(db + "dB", 2, y + 4 + 4*(db == 0));
context.fillText(db + "dB", 2, y + 4 + 4*(db == 0));
}
}
} }
</script> </script>
<script src="cbor.min.js"></script> <script src="cbor.min.js"></script>
<script src="matsui-bundle.min.js"></script> <script src="matsui-bundle.min.js"></script>
<script> <script>
Matsui.global.attributes.resize = (element, fn) => {
element.classList.add('_matsuiResize');
element._matsuiResize = fn;
};
addEventListener('resize', e => {
document.querySelectorAll('._matsuiResize').forEach(e => {
let fn = e._matsuiResize;
if (fn) fn(e, e.offsetWidth, e.offsetHeight);
});
});
let state = Matsui.replace(document.body, {name: "..."}); let state = Matsui.replace(document.body, {name: "..."});
state.trackMerges(merge => { state.trackMerges(merge => {
window.parent.postMessage(CBOR.encode(merge), '*'); window.parent.postMessage(CBOR.encode(merge), '*');
}); });
addEventListener('message', e => { addEventListener('message', e => {
state.merge(CBOR.decode(e.data)); let merge = CBOR.decode(e.data);
window.merge = merge;//console.log(merge);
state.merge(merge);
}); });
window.parent.postMessage(CBOR.encode("ready"), '*'); window.parent.postMessage(CBOR.encode("ready"), '*');
// Monitor framerate and send every 200ms
let frameMs = 100, prevFrame = Date.now(), prevSent = 100;
requestAnimationFrame(function nextFrame() {
let now = Date.now(), delta = now - prevFrame;
frameMs += (delta - frameMs)*frameMs/1000;
prevFrame = now;
prevSent += delta;
if (prevSent > 200) {
window.parent.postMessage(CBOR.encode(["meters", frameMs/1000, 0.3]));
prevSent = 0;
}
requestAnimationFrame(nextFrame);
});
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@ -80,29 +80,20 @@ This produces a template with inheritance like:
The WebBase template replaces the ParamRange/ParamStepped classes, so that the UI can be updated when they change. The outer WebSTFX class adds methods for forwarding messages between the effect and its webview. The WebBase template replaces the ParamRange/ParamStepped classes, so that the UI can be updated when they change. The outer WebSTFX class adds methods for forwarding messages between the effect and its webview.
*/ */
template<template<class, class...> class EffectSTFX> template<template<class, class...> class EffectSTFX, class SubClass>
struct WebUIHelper { struct WebUIHelper {
template<class Effect, class SubClass> template<class Effect>
class WebBase : public Effect { class WebBase : public Effect {
using SuperRange = typename Effect::ParamRange; using SuperRange = typename Effect::ParamRange;
using SuperStepped = typename Effect::ParamStepped; using SuperStepped = typename Effect::ParamStepped;
protected: protected:
struct WebParamScanner { struct WebParamScanner : public storage::STFXStorageScanner<WebParamScanner>{
SubClass *effect; SubClass *effect;
std::vector<std::string> scope; std::vector<std::string> scope;
WebParamScanner(SubClass *effect) : effect(effect) {} WebParamScanner(SubClass *effect) : effect(effect) {}
// Do nothing for most types
template<class V>
void operator()(const char *key, V &v) {
scope.emplace_back(key);
// TODO: detect objects with .state() somehow
v.state(*this);
scope.pop_back();
}
// Extra methods we add for STFX storage // Extra methods we add for STFX storage
void info(const char *, const char *) {} void info(const char *, const char *) {}
@ -279,140 +270,184 @@ struct WebUIHelper {
} }
} }
}; };
template<class Storage>
void meterState(Storage &storage) {}
}; };
template<class Effect, class... ExtraArgs> template<class Effect, class... ExtraArgs>
struct WebSTFX : public EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...> { struct WebSTFX : public EffectSTFX<WebBase<Effect>, ExtraArgs...> {
/* TODO: without the ExtraArgs, it would be a bit neater: using Super = EffectSTFX<WebBase<Effect>, ExtraArgs...>;
EffectSTFX<WebBase<Effect, WebSTFX<Effect>>>
*/
using Super = EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...>;
template<class... Args>
WebSTFX(Args... args) : Super(args...) {
typename Super::WebParamScanner scanner{this};
this->state(scanner);
};
// These are called when the parameter is changed from the web UI
std::function<void(ParamContext, double)> paramListenerRange;
std::function<void(ParamContext, bool)> paramListenerGesture;
std::function<void(ParamContext, int)> paramListenerStepped;
// This state includes all the parameters
void saveState(std::vector<unsigned char> &bytes) {
stfx::storage::STFXStorageWriter<> storage(bytes);
this->state(storage);
}
void loadState(const std::vector<unsigned char> &bytes) {
stfx::storage::STFXStorageReader<> storage(bytes);
this->state(storage);
requestEntireState();
}
struct WebMessage {
std::vector<unsigned char> bytes;
WebMessage() {
bytes.reserve(256);
}
void sent() {
readyToSend.clear();
}
private:
friend class WebSTFX;
friend struct Super::ParamRange;
friend struct Super::ParamStepped;
std::atomic_flag readyToSend = ATOMIC_FLAG_INIT;
void markReady() {
readyToSend.test_and_set();
}
};
bool hasPendingWebMessage() const {
auto &message = queue[readIndex];
return message.readyToSend.test();
}
// Poll on the main thread (and directly after any `.webReceive()`), calling `.sent()` after you've sent it, repeat until you get `nullptr`
WebMessage * getPendingWebMessage() {
auto &message = queue[readIndex];
if (!message.readyToSend.test()) return nullptr;
if (++readIndex == queue.size()) readIndex = 0;
if (resetQueue.test()) {
// Clear ("send") every message aside from this one
for (auto &m : queue) {
if (&m == &message) continue;
m.sent();
}
// Ready to start sending messages again, starting from the next index
writeIndex.store(readIndex);
resetQueue.clear();
// any messages (state updates) sent after this won't assume a *newer* state than what we serialise below
// Replace this one's message with the complete plugin state
WebStateWriter storage(message.bytes);
this->state(storage);
}
return &message;
}
// Call when the webview posts any message back
void webReceive(const void *message, size_t size) {
auto *bytes = (const unsigned char *)message;
signalsmith::cbor::TaggedCborWalker cbor(bytes, bytes + size);
if (cbor.isUtf8()) {
if (cbor.utf8View() == "ready") {
requestEntireState();
return;
}
} else if (cbor.isMap()) {
// Apply it as a merge
WebStateReader reader(cbor);
this->state(reader);
}
}
private:
friend struct Super::ParamRange;
friend struct Super::ParamStepped;
std::vector<WebMessage> queue = std::vector<WebMessage>(64); // power of 2 so that overflow doesn't mess with the modulo
size_t readIndex = 0;
std::atomic_flag resetQueue = ATOMIC_FLAG_INIT;
// Atomic because multiple threads might write
std::atomic<size_t> writeIndex = 0;
WebMessage * getEmptyMessage() {
if (resetQueue.test()) return nullptr;
auto reservedIndex = writeIndex.fetch_add(1);
auto &message = queue[reservedIndex%queue.size()];
if (message.readyToSend.test()) {
std::cerr << "Web message queue full - sending entire state instead\n";
// When our queue is full, we drop the entire thing and re-send the entire state
resetQueue.test_and_set();
return nullptr;
}
message.bytes.resize(0);
return &message;
}
void requestEntireState() {
if (auto *m = getEmptyMessage()) {
resetQueue.test_and_set();
m->markReady();
} // if this fails, then the queue is full and we're doing a reset anyway
}
}; };
}; };
// Use this instead of a plain LibraryEffect // Use this instead of a plain LibraryEffect
template<typename Sample, template<class, class...> class EffectSTFX, class... ExtraArgs> template<typename Sample, template<class, class...> class EffectSTFX, class... ExtraArgs>
using WebUILibraryEffect = LibraryEffect<Sample, WebUIHelper<EffectSTFX>::template WebSTFX, ExtraArgs...>; struct WebUILibraryEffect : public LibraryEffect<Sample, WebUIHelper<EffectSTFX, WebUILibraryEffect<Sample, EffectSTFX, ExtraArgs...>>::template WebSTFX, ExtraArgs...> {
using Super = LibraryEffect<Sample, WebUIHelper<EffectSTFX, WebUILibraryEffect<Sample, EffectSTFX, ExtraArgs...>>::template WebSTFX, ExtraArgs...>;
template<class... Args>
WebUILibraryEffect(Args... args) : Super(args...) {
typename Super::WebParamScanner scanner{this};
this->state(scanner);
};
// These are called when the parameter is changed from the web UI
std::function<void(ParamContext, double)> paramListenerRange;
std::function<void(ParamContext, bool)> paramListenerGesture;
std::function<void(ParamContext, int)> paramListenerStepped;
// This state includes all the parameters
void saveState(std::vector<unsigned char> &bytes) {
stfx::storage::STFXStorageWriter<> storage(bytes);
this->state(storage);
}
void loadState(const std::vector<unsigned char> &bytes) {
stfx::storage::STFXStorageReader<> storage(bytes);
this->state(storage);
requestEntireState();
}
struct WebMessage {
std::vector<unsigned char> bytes;
WebMessage() {
bytes.reserve(256);
}
void sent() {
readyToSend.clear();
}
private:
friend class WebUILibraryEffect;
friend struct Super::ParamRange;
friend struct Super::ParamStepped;
std::atomic_flag readyToSend = ATOMIC_FLAG_INIT;
void markReady() {
readyToSend.test_and_set();
}
};
bool hasPendingWebMessage() const {
auto &message = queue[readIndex];
return message.readyToSend.test();
}
// Poll on the main thread (and directly after any `.webReceive()`), calling `.sent()` after you've sent it, repeat until you get `nullptr`
WebMessage * getPendingWebMessage() {
auto &message = queue[readIndex];
if (!message.readyToSend.test()) return nullptr;
if (++readIndex == queue.size()) readIndex = 0;
if (resetQueue.test()) {
// Clear ("send") every message aside from this one
for (auto &m : queue) {
if (&m == &message) continue;
m.sent();
}
// Ready to start sending messages again, starting from the next index
writeIndex.store(readIndex);
resetQueue.clear();
// any messages (state updates) sent after this won't assume a *newer* state than what we serialise below
// Replace this one's message with the complete plugin state
WebStateWriter storage(message.bytes);
this->state(storage);
}
return &message;
}
// Call when the webview posts any message back
void webReceive(const void *message, size_t size) {
auto *bytes = (const unsigned char *)message;
signalsmith::cbor::TaggedCborWalker cbor(bytes, bytes + size);
if (cbor.isUtf8()) {
if (cbor.utf8View() == "ready") {
requestEntireState();
return;
}
} else if (cbor.isArray()) {
size_t length = cbor.length();
cbor = cbor.enter();
if (cbor.utf8View() == "meters" && length == 3) {
double interval = ++cbor;
double duration = ++cbor;
metersInterval.store(interval*this->config.sampleRate);
metersDuration.store(duration*this->config.sampleRate);
}
} else if (cbor.isMap()) {
// Apply it as a merge
WebStateReader reader(cbor);
this->state(reader);
}
}
// Replace `.process()` to add meter messages if they exist
template<class Buffers>
void process(Buffers &&buffers, int blockLength) {
process(buffers, buffers, blockLength);
}
template<class Inputs, class Outputs>
void process(Inputs &&inputs, Outputs &&outputs, int blockLength) {
Super::process(inputs, outputs, blockLength);
if (this->hasMeters()) {
auto *m = getEmptyMessage();
if (m) {
signalsmith::cbor::CborWriter cbor{m->bytes};
WebStateWriter storage{cbor};
this->meterState(storage);
m->markReady();
}
this->wantsMeters(false);
}
if (metersDuration.load() > 0) {
metersDuration -= blockLength;
samplesSinceMeters += blockLength;
if (samplesSinceMeters > metersInterval.load()) {
this->wantsMeters();
samplesSinceMeters = 0;
}
}
}
private:
friend struct Super::ParamRange;
friend struct Super::ParamStepped;
std::atomic<int> metersInterval = 0, metersDuration = 0;
int samplesSinceMeters = 0; // only used from `.process()`
std::vector<WebMessage> queue = std::vector<WebMessage>(64); // power of 2 so that overflow doesn't mess with the modulo
size_t readIndex = 0;
std::atomic_flag resetQueue = ATOMIC_FLAG_INIT;
// Atomic because multiple threads might write
std::atomic<size_t> writeIndex = 0;
WebMessage * getEmptyMessage() {
if (resetQueue.test()) return nullptr;
auto reservedIndex = writeIndex.fetch_add(1);
auto &message = queue[reservedIndex%queue.size()];
if (message.readyToSend.test()) {
std::cerr << "Web message queue full - sending entire state instead\n";
// When our queue is full, we drop the entire thing and re-send the entire state
resetQueue.test_and_set();
return nullptr;
}
message.bytes.resize(0);
return &message;
}
void requestEntireState() {
if (auto *m = getEmptyMessage()) {
resetQueue.test_and_set();
m->markReady();
} // if this fails, then the queue is full and we're doing a reset anyway
}};
}} // namespace }} // namespace