Analyser with updates via metering path
This commit is contained in:
parent
a1b9153bdc
commit
6fcdd0a158
12
CMakeLists.txt
Normal file
12
CMakeLists.txt
Normal 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
|
||||
)
|
||||
193
analyser.h
193
analyser.h
@ -4,6 +4,12 @@ Released under the Boost Software License (see LICENSE.txt) */
|
||||
|
||||
#include "stfx/stfx-library.h"
|
||||
|
||||
#include "dsp/curves.h"
|
||||
#include "linear/stft.h"
|
||||
#include "linear/linear.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace signalsmith { namespace basics {
|
||||
|
||||
template<class BaseEffect>
|
||||
@ -15,22 +21,45 @@ using AnalyserDouble = stfx::LibraryEffect<double, AnalyserSTFX>;
|
||||
template<class BaseEffect>
|
||||
struct AnalyserSTFX : public BaseEffect {
|
||||
using typename BaseEffect::Sample;
|
||||
using Complex = std::complex<Sample>;
|
||||
using typename BaseEffect::ParamRange;
|
||||
using typename BaseEffect::ParamStepped;
|
||||
|
||||
static constexpr Sample stftBlockMs = 30, stftIntervalMs = 5;
|
||||
|
||||
ParamRange barkResolution = 10;
|
||||
|
||||
template<class Storage>
|
||||
void state(Storage &storage) {
|
||||
storage.info("[Basics] Analyser", "A Bark-scale spectrum analyser");
|
||||
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>
|
||||
void configureSTFX(Config &config) {
|
||||
config.outputChannels = config.inputChannels;
|
||||
sampleRate = config.sampleRate;
|
||||
channels = config.outputChannels = config.inputChannels;
|
||||
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>
|
||||
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
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.28)
|
||||
|
||||
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
|
||||
set(CMAKE_C_VISIBILITY_PRESET hidden)
|
||||
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
|
||||
|
||||
project(plugins VERSION 1.0.0)
|
||||
|
||||
@ -48,4 +48,5 @@ format:
|
||||
dev: release-$(PLUGIN)_clap
|
||||
/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
14
clap/wclap-tar.sh
Executable 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
|
||||
@ -164,7 +164,9 @@ namespace stfx {
|
||||
// per-block automation gradient rate
|
||||
double blockFade;
|
||||
// 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>
|
||||
bool paramsChanging(Param ¶m) const {
|
||||
@ -178,7 +180,7 @@ namespace stfx {
|
||||
// Block length in samples
|
||||
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
|
||||
Block(const Block &) = delete;
|
||||
Block & operator =(const Block&) = delete;
|
||||
@ -278,8 +280,9 @@ namespace stfx {
|
||||
callback(0, length);
|
||||
}
|
||||
|
||||
constexpr bool wantsMeters() const {
|
||||
return false;
|
||||
bool wantsMeters() const {
|
||||
metersChecked = true;
|
||||
return metersRequested;
|
||||
}
|
||||
};
|
||||
|
||||
@ -358,6 +361,22 @@ namespace stfx {
|
||||
|
||||
template<class 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.
|
||||
@ -493,7 +512,7 @@ namespace stfx {
|
||||
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.
|
||||
template<class Inputs, class Outputs>
|
||||
void process(Inputs &&inputs, Outputs &&outputs, int blockLength) {
|
||||
@ -522,7 +541,8 @@ namespace stfx {
|
||||
Outputs output;
|
||||
};
|
||||
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);
|
||||
|
||||
@ -535,6 +555,12 @@ namespace stfx {
|
||||
justHadReset = false;
|
||||
for (auto param : params.rangeParams) 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -204,7 +204,7 @@ private:
|
||||
}
|
||||
|
||||
template<class Item>
|
||||
void readValue(std::vector<Item> &array) {
|
||||
void readVector(std::vector<Item> &array) {
|
||||
if (!cbor.isArray()) return;
|
||||
size_t length = 0;
|
||||
cbor = cbor.forEach([&](Cbor item, size_t index){
|
||||
@ -217,13 +217,18 @@ private:
|
||||
array.resize(length);
|
||||
}
|
||||
|
||||
template<class Item>
|
||||
void readValue(std::vector<Item> &array) {
|
||||
readVector(array);
|
||||
}
|
||||
|
||||
#define STORAGE_TYPED_ARRAY(T) \
|
||||
void readValue(std::vector<T> &array) { \
|
||||
if (cbor.isTypedArray()) { \
|
||||
array.resize(cbor.typedArrayLength()); \
|
||||
cbor.readTypedArray(array); \
|
||||
} else { \
|
||||
readValue<std::vector<T>>(array); \
|
||||
readVector<T>(array); \
|
||||
} \
|
||||
}
|
||||
STORAGE_TYPED_ARRAY(uint8_t)
|
||||
|
||||
@ -7,15 +7,15 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-areas: "header" "params";
|
||||
grid-template-rows: min-content 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
transform: scale(0.95, 1);
|
||||
}
|
||||
#params {
|
||||
grid-area: params;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-content: space-around;
|
||||
justify-content: space-evenly;
|
||||
@ -96,6 +96,27 @@
|
||||
.param-range-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>
|
||||
</head>
|
||||
<body>
|
||||
@ -128,55 +149,188 @@
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
<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>
|
||||
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 plotColours = ['#8CF', '#FC8', '#8D8', '#F9B'];
|
||||
function freqScale(width, lowHz, highHz) {
|
||||
// let a = -289.614, b = 1176.76, c = 15.3385, d = -0.552833; // Bark
|
||||
let low = lowHz/(1500 + lowHz);
|
||||
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');
|
||||
context.resetTransform();
|
||||
context.clearRect(0, 0, pixels, pixels);
|
||||
let width = canvas.offsetWidth, height = canvas.offsetHeight;
|
||||
{
|
||||
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);
|
||||
context.translate(1, 1);
|
||||
let baseHz = 100, maxHz = data.hz[data.hz.length - 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();
|
||||
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();
|
||||
if (canvas.classList.contains('plot-grid')) {
|
||||
context.beginPath();
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = '#555';
|
||||
for (let baseHz = 100; baseHz < maxHz; baseHz *= 10) {
|
||||
for (let i = 1; i < 10; ++i) {
|
||||
let hz = baseHz*i;
|
||||
let x = xScale(hz);
|
||||
context.moveTo(x, 0);
|
||||
context.lineTo(x, height);
|
||||
}
|
||||
}
|
||||
for (let db = 0; db >= -240; db -= 12) {
|
||||
let e = Math.pow(10, db/10);
|
||||
let y = yScale(e);
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
}
|
||||
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 src="cbor.min.js"></script>
|
||||
<script src="matsui-bundle.min.js"></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: "..."});
|
||||
state.trackMerges(merge => {
|
||||
window.parent.postMessage(CBOR.encode(merge), '*');
|
||||
});
|
||||
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"), '*');
|
||||
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4
stfx/ui/html/matsui-bundle.min.js
vendored
4
stfx/ui/html/matsui-bundle.min.js
vendored
File diff suppressed because one or more lines are too long
315
stfx/ui/web-ui.h
315
stfx/ui/web-ui.h
@ -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.
|
||||
*/
|
||||
|
||||
template<template<class, class...> class EffectSTFX>
|
||||
template<template<class, class...> class EffectSTFX, class SubClass>
|
||||
struct WebUIHelper {
|
||||
|
||||
template<class Effect, class SubClass>
|
||||
|
||||
template<class Effect>
|
||||
class WebBase : public Effect {
|
||||
using SuperRange = typename Effect::ParamRange;
|
||||
using SuperStepped = typename Effect::ParamStepped;
|
||||
|
||||
protected:
|
||||
struct WebParamScanner {
|
||||
struct WebParamScanner : public storage::STFXStorageScanner<WebParamScanner>{
|
||||
SubClass *effect;
|
||||
std::vector<std::string> scope;
|
||||
|
||||
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
|
||||
void info(const char *, const char *) {}
|
||||
@ -279,140 +270,184 @@ struct WebUIHelper {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
template<class Storage>
|
||||
void meterState(Storage &storage) {}
|
||||
};
|
||||
|
||||
template<class Effect, class... ExtraArgs>
|
||||
struct WebSTFX : public EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...> {
|
||||
/* TODO: without the ExtraArgs, it would be a bit neater:
|
||||
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
|
||||
}
|
||||
struct WebSTFX : public EffectSTFX<WebBase<Effect>, ExtraArgs...> {
|
||||
using Super = EffectSTFX<WebBase<Effect>, ExtraArgs...>;
|
||||
};
|
||||
};
|
||||
|
||||
// Use this instead of a plain LibraryEffect
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user