mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
rewrite multiband splitter to native
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
"src/spectrum.cpp",
|
"src/spectrum.cpp",
|
||||||
"src/spectrogram.cpp",
|
"src/spectrogram.cpp",
|
||||||
"src/vectorscope.cpp",
|
"src/vectorscope.cpp",
|
||||||
|
"src/multiband.cpp",
|
||||||
|
"src/waveform.cpp",
|
||||||
"src/vumeter.cpp",
|
"src/vumeter.cpp",
|
||||||
"src/lufsmeter.cpp",
|
"src/lufsmeter.cpp",
|
||||||
"src/dsp_utils.cpp"
|
"src/dsp_utils.cpp"
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ void BiquadFilter::setLowpass(float frequency, float sampleRate, float Q) {
|
|||||||
a2_ = (1.0f - alpha) / a0;
|
a2_ = (1.0f - alpha) / a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BiquadFilter::setHighpass(float frequency, float sampleRate, float Q) {
|
||||||
|
float omega = 2.0f * M_PI * frequency / sampleRate;
|
||||||
|
float sinOmega = sinf(omega);
|
||||||
|
float cosOmega = cosf(omega);
|
||||||
|
float alpha = sinOmega / (2.0f * Q);
|
||||||
|
|
||||||
|
float a0 = 1.0f + alpha;
|
||||||
|
b0_ = (1.0f + cosOmega) / 2.0f / a0;
|
||||||
|
b1_ = -(1.0f + cosOmega) / a0;
|
||||||
|
b2_ = (1.0f + cosOmega) / 2.0f / a0;
|
||||||
|
a1_ = -2.0f * cosOmega / a0;
|
||||||
|
a2_ = (1.0f - alpha) / a0;
|
||||||
|
}
|
||||||
|
|
||||||
void BiquadFilter::setBandpass(float frequency, float sampleRate, float Q) {
|
void BiquadFilter::setBandpass(float frequency, float sampleRate, float Q) {
|
||||||
float omega = 2.0f * M_PI * frequency / sampleRate;
|
float omega = 2.0f * M_PI * frequency / sampleRate;
|
||||||
float sinOmega = sinf(omega);
|
float sinOmega = sinf(omega);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class BiquadFilter {
|
|||||||
public:
|
public:
|
||||||
BiquadFilter();
|
BiquadFilter();
|
||||||
void setLowpass(float frequency, float sampleRate, float Q = 0.707f);
|
void setLowpass(float frequency, float sampleRate, float Q = 0.707f);
|
||||||
|
void setHighpass(float frequency, float sampleRate, float Q = 0.707f);
|
||||||
void setBandpass(float frequency, float sampleRate, float Q = 2.0f);
|
void setBandpass(float frequency, float sampleRate, float Q = 2.0f);
|
||||||
void setHighShelf(float frequency, float sampleRate, float gainDB, float Q = 0.707f);
|
void setHighShelf(float frequency, float sampleRate, float gainDB, float Q = 0.707f);
|
||||||
float process(float input);
|
float process(float input);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "spectrum.h"
|
#include "spectrum.h"
|
||||||
#include "spectrogram.h"
|
#include "spectrogram.h"
|
||||||
#include "vectorscope.h"
|
#include "vectorscope.h"
|
||||||
|
#include "waveform.h"
|
||||||
#include "vumeter.h"
|
#include "vumeter.h"
|
||||||
#include "lufsmeter.h"
|
#include "lufsmeter.h"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ static Visualizer::Oscilloscope oscilloscope;
|
|||||||
static Visualizer::Spectrum spectrum(2048);
|
static Visualizer::Spectrum spectrum(2048);
|
||||||
static Visualizer::SpectrogramAnalyzer spectrogramAnalyzer;
|
static Visualizer::SpectrogramAnalyzer spectrogramAnalyzer;
|
||||||
static Visualizer::Vectorscope vectorscope;
|
static Visualizer::Vectorscope vectorscope;
|
||||||
|
static Visualizer::WaveformMultibandAnalyzer waveform;
|
||||||
static Visualizer::VUMeterAnalyzer vuMeter;
|
static Visualizer::VUMeterAnalyzer vuMeter;
|
||||||
static Visualizer::LUFSMeterAnalyzer lufsMeter;
|
static Visualizer::LUFSMeterAnalyzer lufsMeter;
|
||||||
|
|
||||||
@@ -350,6 +352,19 @@ Napi::Value VectorscopePushSamples(const Napi::CallbackInfo& info) {
|
|||||||
return env.Undefined();
|
return env.Undefined();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Napi::Value VectorscopePushMultibandSamples(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
||||||
|
Napi::TypeError::New(env, "Expected two Float32Arrays (left, right)").ThrowAsJavaScriptException();
|
||||||
|
return env.Null();
|
||||||
|
}
|
||||||
|
Napi::Float32Array leftData = info[0].As<Napi::Float32Array>();
|
||||||
|
Napi::Float32Array rightData = info[1].As<Napi::Float32Array>();
|
||||||
|
size_t length = std::min(leftData.ElementLength(), rightData.ElementLength());
|
||||||
|
vectorscope.pushMultibandSamples(leftData.Data(), rightData.Data(), length);
|
||||||
|
return env.Undefined();
|
||||||
|
}
|
||||||
|
|
||||||
Napi::Value VectorscopeGetPoints(const Napi::CallbackInfo& info) {
|
Napi::Value VectorscopeGetPoints(const Napi::CallbackInfo& info) {
|
||||||
Napi::Env env = info.Env();
|
Napi::Env env = info.Env();
|
||||||
if (info.Length() < 1 || !info[0].IsNumber()) {
|
if (info.Length() < 1 || !info[0].IsNumber()) {
|
||||||
@@ -376,6 +391,31 @@ Napi::Value VectorscopeGetPoints(const Napi::CallbackInfo& info) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Napi::Value VectorscopeGetMultibandPoints(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (info.Length() < 1 || !info[0].IsNumber()) {
|
||||||
|
Napi::TypeError::New(env, "Expected max points count").ThrowAsJavaScriptException();
|
||||||
|
return env.Null();
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t maxPoints = static_cast<size_t>(info[0].As<Napi::Number>().Uint32Value());
|
||||||
|
Napi::Float32Array data = Napi::Float32Array::New(env, maxPoints * Visualizer::MULTIBAND_POINT_STRIDE);
|
||||||
|
const size_t actual = vectorscope.getMultibandPoints(data.Data(), maxPoints);
|
||||||
|
|
||||||
|
Napi::Object result = Napi::Object::New(env);
|
||||||
|
if (actual < maxPoints) {
|
||||||
|
Napi::Float32Array trimmed = Napi::Float32Array::New(env, actual * Visualizer::MULTIBAND_POINT_STRIDE);
|
||||||
|
if (actual > 0) {
|
||||||
|
memcpy(trimmed.Data(), data.Data(), actual * Visualizer::MULTIBAND_POINT_STRIDE * sizeof(float));
|
||||||
|
}
|
||||||
|
result.Set("data", trimmed);
|
||||||
|
} else {
|
||||||
|
result.Set("data", data);
|
||||||
|
}
|
||||||
|
result.Set("count", Napi::Number::New(env, static_cast<double>(actual)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Napi::Value VectorscopeFillPoints(const Napi::CallbackInfo& info) {
|
Napi::Value VectorscopeFillPoints(const Napi::CallbackInfo& info) {
|
||||||
Napi::Env env = info.Env();
|
Napi::Env env = info.Env();
|
||||||
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
||||||
@@ -431,6 +471,60 @@ Napi::Value VectorscopeReset(const Napi::CallbackInfo& info) {
|
|||||||
return info.Env().Undefined();
|
return info.Env().Undefined();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== Waveform ==============
|
||||||
|
|
||||||
|
Napi::Value WaveformConfigure(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
|
||||||
|
Napi::TypeError::New(env, "Expected sample rate and samples per column").ThrowAsJavaScriptException();
|
||||||
|
return env.Null();
|
||||||
|
}
|
||||||
|
|
||||||
|
const float sampleRate = info[0].As<Napi::Number>().FloatValue();
|
||||||
|
const size_t samplesPerColumn = static_cast<size_t>(info[1].As<Napi::Number>().Uint32Value());
|
||||||
|
waveform.configure(sampleRate, samplesPerColumn);
|
||||||
|
return env.Undefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
Napi::Value WaveformProcessMono(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||||
|
Napi::TypeError::New(env, "Expected Float32Array").ThrowAsJavaScriptException();
|
||||||
|
return env.Null();
|
||||||
|
}
|
||||||
|
|
||||||
|
Napi::Float32Array samples = info[0].As<Napi::Float32Array>();
|
||||||
|
const auto& summaries = waveform.processMono(samples.Data(), samples.ElementLength());
|
||||||
|
Napi::Float32Array result = Napi::Float32Array::New(env, summaries.size());
|
||||||
|
if (!summaries.empty()) {
|
||||||
|
memcpy(result.Data(), summaries.data(), summaries.size() * sizeof(float));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Napi::Value WaveformProcessStereo(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
||||||
|
Napi::TypeError::New(env, "Expected two Float32Arrays (left, right)").ThrowAsJavaScriptException();
|
||||||
|
return env.Null();
|
||||||
|
}
|
||||||
|
|
||||||
|
Napi::Float32Array leftData = info[0].As<Napi::Float32Array>();
|
||||||
|
Napi::Float32Array rightData = info[1].As<Napi::Float32Array>();
|
||||||
|
const size_t length = std::min(leftData.ElementLength(), rightData.ElementLength());
|
||||||
|
const auto& summaries = waveform.processStereo(leftData.Data(), rightData.Data(), length);
|
||||||
|
Napi::Float32Array result = Napi::Float32Array::New(env, summaries.size());
|
||||||
|
if (!summaries.empty()) {
|
||||||
|
memcpy(result.Data(), summaries.data(), summaries.size() * sizeof(float));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Napi::Value WaveformReset(const Napi::CallbackInfo& info) {
|
||||||
|
waveform.reset();
|
||||||
|
return info.Env().Undefined();
|
||||||
|
}
|
||||||
|
|
||||||
// ============== VU Meter ==============
|
// ============== VU Meter ==============
|
||||||
|
|
||||||
Napi::Value VUMeterSetSampleRate(const Napi::CallbackInfo& info) {
|
Napi::Value VUMeterSetSampleRate(const Napi::CallbackInfo& info) {
|
||||||
@@ -570,14 +664,24 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|||||||
Napi::Object vecExports = Napi::Object::New(env);
|
Napi::Object vecExports = Napi::Object::New(env);
|
||||||
vecExports.Set("setSampleRate", Napi::Function::New(env, VectorscopeSetSampleRate));
|
vecExports.Set("setSampleRate", Napi::Function::New(env, VectorscopeSetSampleRate));
|
||||||
vecExports.Set("pushSamples", Napi::Function::New(env, VectorscopePushSamples));
|
vecExports.Set("pushSamples", Napi::Function::New(env, VectorscopePushSamples));
|
||||||
|
vecExports.Set("pushMultibandSamples", Napi::Function::New(env, VectorscopePushMultibandSamples));
|
||||||
vecExports.Set("fillPoints", Napi::Function::New(env, VectorscopeFillPoints));
|
vecExports.Set("fillPoints", Napi::Function::New(env, VectorscopeFillPoints));
|
||||||
vecExports.Set("getPoints", Napi::Function::New(env, VectorscopeGetPoints));
|
vecExports.Set("getPoints", Napi::Function::New(env, VectorscopeGetPoints));
|
||||||
|
vecExports.Set("getMultibandPoints", Napi::Function::New(env, VectorscopeGetMultibandPoints));
|
||||||
vecExports.Set("setBufferSize", Napi::Function::New(env, VectorscopeSetBufferSize));
|
vecExports.Set("setBufferSize", Napi::Function::New(env, VectorscopeSetBufferSize));
|
||||||
vecExports.Set("getBufferSize", Napi::Function::New(env, VectorscopeGetBufferSize));
|
vecExports.Set("getBufferSize", Napi::Function::New(env, VectorscopeGetBufferSize));
|
||||||
vecExports.Set("process", Napi::Function::New(env, VectorscopeProcess));
|
vecExports.Set("process", Napi::Function::New(env, VectorscopeProcess));
|
||||||
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
|
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
|
||||||
exports.Set("vectorscope", vecExports);
|
exports.Set("vectorscope", vecExports);
|
||||||
|
|
||||||
|
// Waveform
|
||||||
|
Napi::Object waveformExports = Napi::Object::New(env);
|
||||||
|
waveformExports.Set("configure", Napi::Function::New(env, WaveformConfigure));
|
||||||
|
waveformExports.Set("processMono", Napi::Function::New(env, WaveformProcessMono));
|
||||||
|
waveformExports.Set("processStereo", Napi::Function::New(env, WaveformProcessStereo));
|
||||||
|
waveformExports.Set("reset", Napi::Function::New(env, WaveformReset));
|
||||||
|
exports.Set("waveform", waveformExports);
|
||||||
|
|
||||||
// VU Meter
|
// VU Meter
|
||||||
Napi::Object vuExports = Napi::Object::New(env);
|
Napi::Object vuExports = Napi::Object::New(env);
|
||||||
vuExports.Set("setSampleRate", Napi::Function::New(env, VUMeterSetSampleRate));
|
vuExports.Set("setSampleRate", Napi::Function::New(env, VUMeterSetSampleRate));
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#include "multiband.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace Visualizer {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr float MULTIBAND_FILTER_Q = 1.41421356237f;
|
||||||
|
}
|
||||||
|
|
||||||
|
MultibandSplitter::MultibandSplitter()
|
||||||
|
: configuredSampleRate_(0.0f) {
|
||||||
|
configure(48000.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultibandSplitter::configure(float sampleRate) {
|
||||||
|
const float nextSampleRate = std::max(1.0f, sampleRate);
|
||||||
|
if (nextSampleRate == configuredSampleRate_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredSampleRate_ = nextSampleRate;
|
||||||
|
|
||||||
|
lowLpL_.setLowpass(MULTIBAND_LOW_MID_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
lowLpR_.setLowpass(MULTIBAND_LOW_MID_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
|
||||||
|
midHpL_.setHighpass(MULTIBAND_LOW_MID_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
midHpR_.setHighpass(MULTIBAND_LOW_MID_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
midLpL_.setLowpass(MULTIBAND_MID_HIGH_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
midLpR_.setLowpass(MULTIBAND_MID_HIGH_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
|
||||||
|
highHpL_.setHighpass(MULTIBAND_MID_HIGH_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
highHpR_.setHighpass(MULTIBAND_MID_HIGH_CROSSOVER, configuredSampleRate_, MULTIBAND_FILTER_Q);
|
||||||
|
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
MultibandSample MultibandSplitter::process(float left, float right) {
|
||||||
|
const float midTmpL = midHpL_.process(left);
|
||||||
|
const float midTmpR = midHpR_.process(right);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lowLpL_.process(left),
|
||||||
|
lowLpR_.process(right),
|
||||||
|
midLpL_.process(midTmpL),
|
||||||
|
midLpR_.process(midTmpR),
|
||||||
|
highHpL_.process(left),
|
||||||
|
highHpR_.process(right),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultibandSplitter::reset() {
|
||||||
|
lowLpL_.reset();
|
||||||
|
lowLpR_.reset();
|
||||||
|
midHpL_.reset();
|
||||||
|
midHpR_.reset();
|
||||||
|
midLpL_.reset();
|
||||||
|
midLpR_.reset();
|
||||||
|
highHpL_.reset();
|
||||||
|
highHpR_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Visualizer
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "dsp_utils.h"
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace Visualizer {
|
||||||
|
|
||||||
|
constexpr float MULTIBAND_LOW_MID_CROSSOVER = 250.0f;
|
||||||
|
constexpr float MULTIBAND_MID_HIGH_CROSSOVER = 2500.0f;
|
||||||
|
constexpr size_t MULTIBAND_POINT_STRIDE = 6;
|
||||||
|
|
||||||
|
struct MultibandSample {
|
||||||
|
float lowL;
|
||||||
|
float lowR;
|
||||||
|
float midL;
|
||||||
|
float midR;
|
||||||
|
float highL;
|
||||||
|
float highR;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MultibandSplitter {
|
||||||
|
public:
|
||||||
|
MultibandSplitter();
|
||||||
|
|
||||||
|
void configure(float sampleRate);
|
||||||
|
MultibandSample process(float left, float right);
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
float configuredSampleRate_;
|
||||||
|
|
||||||
|
DSP::BiquadFilter lowLpL_;
|
||||||
|
DSP::BiquadFilter lowLpR_;
|
||||||
|
DSP::BiquadFilter midHpL_;
|
||||||
|
DSP::BiquadFilter midHpR_;
|
||||||
|
DSP::BiquadFilter midLpL_;
|
||||||
|
DSP::BiquadFilter midLpR_;
|
||||||
|
DSP::BiquadFilter highHpL_;
|
||||||
|
DSP::BiquadFilter highHpR_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Visualizer
|
||||||
@@ -8,10 +8,18 @@ Vectorscope::Vectorscope()
|
|||||||
: sampleRate_(48000.0f)
|
: sampleRate_(48000.0f)
|
||||||
, bufferSize_(1024)
|
, bufferSize_(1024)
|
||||||
, writePos_(0)
|
, writePos_(0)
|
||||||
, validSamples_(0) {
|
, validSamples_(0)
|
||||||
|
, multibandWritePos_(0)
|
||||||
|
, multibandValidSamples_(0) {
|
||||||
|
|
||||||
leftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
leftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
rightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
rightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
lowLeftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
lowRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
midLeftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
midRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
highLeftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
|
highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
points_.reserve(1024);
|
points_.reserve(1024);
|
||||||
|
|
||||||
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
|
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
|
||||||
@@ -21,6 +29,7 @@ Vectorscope::Vectorscope()
|
|||||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
|
multibandSplitter_.configure(sampleRate_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Vectorscope::setSampleRate(float sampleRate) {
|
void Vectorscope::setSampleRate(float sampleRate) {
|
||||||
@@ -30,6 +39,7 @@ void Vectorscope::setSampleRate(float sampleRate) {
|
|||||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||||
|
multibandSplitter_.configure(sampleRate_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Vectorscope::setBufferSize(size_t size) {
|
void Vectorscope::setBufferSize(size_t size) {
|
||||||
@@ -60,6 +70,28 @@ void Vectorscope::pushSamples(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Vectorscope::pushMultibandSamples(
|
||||||
|
const float* leftChannel,
|
||||||
|
const float* rightChannel,
|
||||||
|
size_t length
|
||||||
|
) {
|
||||||
|
for (size_t i = 0; i < length; i++) {
|
||||||
|
const MultibandSample bands = multibandSplitter_.process(leftChannel[i], rightChannel[i]);
|
||||||
|
|
||||||
|
lowLeftBuffer_[multibandWritePos_] = bands.lowL;
|
||||||
|
lowRightBuffer_[multibandWritePos_] = bands.lowR;
|
||||||
|
midLeftBuffer_[multibandWritePos_] = bands.midL;
|
||||||
|
midRightBuffer_[multibandWritePos_] = bands.midR;
|
||||||
|
highLeftBuffer_[multibandWritePos_] = bands.highL;
|
||||||
|
highRightBuffer_[multibandWritePos_] = bands.highR;
|
||||||
|
|
||||||
|
multibandWritePos_ = (multibandWritePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
|
||||||
|
if (multibandValidSamples_ < VECTORSCOPE_BUFFER_SIZE) {
|
||||||
|
multibandValidSamples_++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
size_t Vectorscope::getPoints(float* xOut, float* yOut, size_t maxPoints) const {
|
size_t Vectorscope::getPoints(float* xOut, float* yOut, size_t maxPoints) const {
|
||||||
size_t count = std::min(maxPoints, validSamples_);
|
size_t count = std::min(maxPoints, validSamples_);
|
||||||
|
|
||||||
@@ -73,6 +105,23 @@ size_t Vectorscope::getPoints(float* xOut, float* yOut, size_t maxPoints) const
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t Vectorscope::getMultibandPoints(float* output, size_t maxPoints) const {
|
||||||
|
const size_t count = std::min(maxPoints, multibandValidSamples_);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
const size_t idx = (multibandWritePos_ + VECTORSCOPE_BUFFER_SIZE - count + i) % VECTORSCOPE_BUFFER_SIZE;
|
||||||
|
const size_t base = i * MULTIBAND_POINT_STRIDE;
|
||||||
|
output[base] = lowLeftBuffer_[idx];
|
||||||
|
output[base + 1] = lowRightBuffer_[idx];
|
||||||
|
output[base + 2] = midLeftBuffer_[idx];
|
||||||
|
output[base + 3] = midRightBuffer_[idx];
|
||||||
|
output[base + 4] = highLeftBuffer_[idx];
|
||||||
|
output[base + 5] = highRightBuffer_[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy process method (routes through new pipeline)
|
// Legacy process method (routes through new pipeline)
|
||||||
const std::vector<VectorscopePoint>& Vectorscope::process(
|
const std::vector<VectorscopePoint>& Vectorscope::process(
|
||||||
const float* leftChannel,
|
const float* leftChannel,
|
||||||
@@ -98,12 +147,21 @@ const std::vector<VectorscopePoint>& Vectorscope::process(
|
|||||||
void Vectorscope::reset() {
|
void Vectorscope::reset() {
|
||||||
writePos_ = 0;
|
writePos_ = 0;
|
||||||
validSamples_ = 0;
|
validSamples_ = 0;
|
||||||
|
multibandWritePos_ = 0;
|
||||||
|
multibandValidSamples_ = 0;
|
||||||
std::fill(leftBuffer_.begin(), leftBuffer_.end(), 0.0f);
|
std::fill(leftBuffer_.begin(), leftBuffer_.end(), 0.0f);
|
||||||
std::fill(rightBuffer_.begin(), rightBuffer_.end(), 0.0f);
|
std::fill(rightBuffer_.begin(), rightBuffer_.end(), 0.0f);
|
||||||
|
std::fill(lowLeftBuffer_.begin(), lowLeftBuffer_.end(), 0.0f);
|
||||||
|
std::fill(lowRightBuffer_.begin(), lowRightBuffer_.end(), 0.0f);
|
||||||
|
std::fill(midLeftBuffer_.begin(), midLeftBuffer_.end(), 0.0f);
|
||||||
|
std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f);
|
||||||
|
std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f);
|
||||||
|
std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f);
|
||||||
leftLowpass1_.reset();
|
leftLowpass1_.reset();
|
||||||
leftLowpass2_.reset();
|
leftLowpass2_.reset();
|
||||||
rightLowpass1_.reset();
|
rightLowpass1_.reset();
|
||||||
rightLowpass2_.reset();
|
rightLowpass2_.reset();
|
||||||
|
multibandSplitter_.reset();
|
||||||
points_.clear();
|
points_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "dsp_utils.h"
|
#include "dsp_utils.h"
|
||||||
|
#include "multiband.h"
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
||||||
@@ -25,10 +26,12 @@ public:
|
|||||||
|
|
||||||
// Push stereo samples into circular buffer (called per worklet chunk)
|
// Push stereo samples into circular buffer (called per worklet chunk)
|
||||||
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
|
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
|
||||||
|
void pushMultibandSamples(const float* leftChannel, const float* rightChannel, size_t length);
|
||||||
|
|
||||||
// Get the most recent N points for rendering (from circular buffer)
|
// Get the most recent N points for rendering (from circular buffer)
|
||||||
// Returns count of valid points written to output arrays
|
// Returns count of valid points written to output arrays
|
||||||
size_t getPoints(float* xOut, float* yOut, size_t maxPoints) const;
|
size_t getPoints(float* xOut, float* yOut, size_t maxPoints) const;
|
||||||
|
size_t getMultibandPoints(float* output, size_t maxPoints) const;
|
||||||
|
|
||||||
// Get number of valid samples in buffer
|
// Get number of valid samples in buffer
|
||||||
size_t getValidSamples() const { return validSamples_; }
|
size_t getValidSamples() const { return validSamples_; }
|
||||||
@@ -52,12 +55,21 @@ private:
|
|||||||
// Circular buffers for filtered L/R
|
// Circular buffers for filtered L/R
|
||||||
std::vector<float> leftBuffer_;
|
std::vector<float> leftBuffer_;
|
||||||
std::vector<float> rightBuffer_;
|
std::vector<float> rightBuffer_;
|
||||||
|
std::vector<float> lowLeftBuffer_;
|
||||||
|
std::vector<float> lowRightBuffer_;
|
||||||
|
std::vector<float> midLeftBuffer_;
|
||||||
|
std::vector<float> midRightBuffer_;
|
||||||
|
std::vector<float> highLeftBuffer_;
|
||||||
|
std::vector<float> highRightBuffer_;
|
||||||
|
|
||||||
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
|
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
|
||||||
DSP::BiquadFilter leftLowpass1_;
|
DSP::BiquadFilter leftLowpass1_;
|
||||||
DSP::BiquadFilter leftLowpass2_;
|
DSP::BiquadFilter leftLowpass2_;
|
||||||
DSP::BiquadFilter rightLowpass1_;
|
DSP::BiquadFilter rightLowpass1_;
|
||||||
DSP::BiquadFilter rightLowpass2_;
|
DSP::BiquadFilter rightLowpass2_;
|
||||||
|
MultibandSplitter multibandSplitter_;
|
||||||
|
size_t multibandWritePos_;
|
||||||
|
size_t multibandValidSamples_;
|
||||||
|
|
||||||
// Legacy
|
// Legacy
|
||||||
std::vector<VectorscopePoint> points_;
|
std::vector<VectorscopePoint> points_;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#include "waveform.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace Visualizer {
|
||||||
|
|
||||||
|
WaveformMultibandAnalyzer::WaveformMultibandAnalyzer()
|
||||||
|
: sampleRate_(48000.0f)
|
||||||
|
, samplesPerColumn_(1)
|
||||||
|
, columnPos_(0)
|
||||||
|
, leftMin_(0.0f)
|
||||||
|
, leftMax_(0.0f)
|
||||||
|
, rightMin_(0.0f)
|
||||||
|
, rightMax_(0.0f)
|
||||||
|
, leftLowSum_(0.0f)
|
||||||
|
, leftMidSum_(0.0f)
|
||||||
|
, leftHighSum_(0.0f)
|
||||||
|
, rightLowSum_(0.0f)
|
||||||
|
, rightMidSum_(0.0f)
|
||||||
|
, rightHighSum_(0.0f) {
|
||||||
|
splitter_.configure(sampleRate_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::configure(float sampleRate, size_t samplesPerColumn) {
|
||||||
|
const float nextSampleRate = std::max(1.0f, sampleRate);
|
||||||
|
const size_t nextSamplesPerColumn = std::max<size_t>(1, samplesPerColumn);
|
||||||
|
|
||||||
|
if (nextSampleRate == sampleRate_ && nextSamplesPerColumn == samplesPerColumn_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleRate_ = nextSampleRate;
|
||||||
|
samplesPerColumn_ = nextSamplesPerColumn;
|
||||||
|
splitter_.configure(sampleRate_);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<float>& WaveformMultibandAnalyzer::processMono(const float* samples, size_t length) {
|
||||||
|
summaries_.clear();
|
||||||
|
|
||||||
|
for (size_t i = 0; i < length; i++) {
|
||||||
|
const float sample = samples[i];
|
||||||
|
const MultibandSample bands = splitter_.process(sample, sample);
|
||||||
|
accumulateLeft(sample, bands);
|
||||||
|
|
||||||
|
columnPos_++;
|
||||||
|
if (columnPos_ >= samplesPerColumn_) {
|
||||||
|
flushMonoColumn();
|
||||||
|
resetColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<float>& WaveformMultibandAnalyzer::processStereo(
|
||||||
|
const float* left,
|
||||||
|
const float* right,
|
||||||
|
size_t length
|
||||||
|
) {
|
||||||
|
summaries_.clear();
|
||||||
|
|
||||||
|
for (size_t i = 0; i < length; i++) {
|
||||||
|
const MultibandSample bands = splitter_.process(left[i], right[i]);
|
||||||
|
accumulateLeft(left[i], bands);
|
||||||
|
accumulateRight(right[i], bands);
|
||||||
|
|
||||||
|
columnPos_++;
|
||||||
|
if (columnPos_ >= samplesPerColumn_) {
|
||||||
|
flushStereoColumn();
|
||||||
|
resetColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::reset() {
|
||||||
|
splitter_.reset();
|
||||||
|
summaries_.clear();
|
||||||
|
resetColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::resetColumn() {
|
||||||
|
columnPos_ = 0;
|
||||||
|
leftMin_ = 0.0f;
|
||||||
|
leftMax_ = 0.0f;
|
||||||
|
rightMin_ = 0.0f;
|
||||||
|
rightMax_ = 0.0f;
|
||||||
|
leftLowSum_ = 0.0f;
|
||||||
|
leftMidSum_ = 0.0f;
|
||||||
|
leftHighSum_ = 0.0f;
|
||||||
|
rightLowSum_ = 0.0f;
|
||||||
|
rightMidSum_ = 0.0f;
|
||||||
|
rightHighSum_ = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::accumulateLeft(float sample, const MultibandSample& bands) {
|
||||||
|
if (columnPos_ == 0) {
|
||||||
|
leftMin_ = sample;
|
||||||
|
leftMax_ = sample;
|
||||||
|
} else {
|
||||||
|
leftMin_ = std::min(leftMin_, sample);
|
||||||
|
leftMax_ = std::max(leftMax_, sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
leftLowSum_ += bands.lowL * bands.lowL;
|
||||||
|
leftMidSum_ += bands.midL * bands.midL;
|
||||||
|
leftHighSum_ += bands.highL * bands.highL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::accumulateRight(float sample, const MultibandSample& bands) {
|
||||||
|
if (columnPos_ == 0) {
|
||||||
|
rightMin_ = sample;
|
||||||
|
rightMax_ = sample;
|
||||||
|
} else {
|
||||||
|
rightMin_ = std::min(rightMin_, sample);
|
||||||
|
rightMax_ = std::max(rightMax_, sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
rightLowSum_ += bands.lowR * bands.lowR;
|
||||||
|
rightMidSum_ += bands.midR * bands.midR;
|
||||||
|
rightHighSum_ += bands.highR * bands.highR;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::flushMonoColumn() {
|
||||||
|
summaries_.push_back(leftMin_);
|
||||||
|
summaries_.push_back(leftMax_);
|
||||||
|
summaries_.push_back(rms(leftLowSum_));
|
||||||
|
summaries_.push_back(rms(leftMidSum_));
|
||||||
|
summaries_.push_back(rms(leftHighSum_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void WaveformMultibandAnalyzer::flushStereoColumn() {
|
||||||
|
summaries_.push_back(leftMin_);
|
||||||
|
summaries_.push_back(leftMax_);
|
||||||
|
summaries_.push_back(rms(leftLowSum_));
|
||||||
|
summaries_.push_back(rms(leftMidSum_));
|
||||||
|
summaries_.push_back(rms(leftHighSum_));
|
||||||
|
summaries_.push_back(rightMin_);
|
||||||
|
summaries_.push_back(rightMax_);
|
||||||
|
summaries_.push_back(rms(rightLowSum_));
|
||||||
|
summaries_.push_back(rms(rightMidSum_));
|
||||||
|
summaries_.push_back(rms(rightHighSum_));
|
||||||
|
}
|
||||||
|
|
||||||
|
float WaveformMultibandAnalyzer::rms(float sum) const {
|
||||||
|
const size_t count = std::max<size_t>(1, columnPos_);
|
||||||
|
return std::sqrt(sum / static_cast<float>(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Visualizer
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "multiband.h"
|
||||||
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Visualizer {
|
||||||
|
|
||||||
|
constexpr size_t WAVEFORM_MONO_SUMMARY_STRIDE = 5;
|
||||||
|
constexpr size_t WAVEFORM_STEREO_SUMMARY_STRIDE = 10;
|
||||||
|
|
||||||
|
class WaveformMultibandAnalyzer {
|
||||||
|
public:
|
||||||
|
WaveformMultibandAnalyzer();
|
||||||
|
|
||||||
|
void configure(float sampleRate, size_t samplesPerColumn);
|
||||||
|
const std::vector<float>& processMono(const float* samples, size_t length);
|
||||||
|
const std::vector<float>& processStereo(const float* left, const float* right, size_t length);
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void resetColumn();
|
||||||
|
void accumulateLeft(float sample, const MultibandSample& bands);
|
||||||
|
void accumulateRight(float sample, const MultibandSample& bands);
|
||||||
|
void flushMonoColumn();
|
||||||
|
void flushStereoColumn();
|
||||||
|
float rms(float sum) const;
|
||||||
|
|
||||||
|
MultibandSplitter splitter_;
|
||||||
|
float sampleRate_;
|
||||||
|
size_t samplesPerColumn_;
|
||||||
|
size_t columnPos_;
|
||||||
|
|
||||||
|
float leftMin_;
|
||||||
|
float leftMax_;
|
||||||
|
float rightMin_;
|
||||||
|
float rightMax_;
|
||||||
|
float leftLowSum_;
|
||||||
|
float leftMidSum_;
|
||||||
|
float leftHighSum_;
|
||||||
|
float rightLowSum_;
|
||||||
|
float rightMidSum_;
|
||||||
|
float rightHighSum_;
|
||||||
|
|
||||||
|
std::vector<float> summaries_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Visualizer
|
||||||
@@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule
|
|||||||
spectrum: nativeAddonModule.spectrum,
|
spectrum: nativeAddonModule.spectrum,
|
||||||
spectrogram: nativeAddonModule.spectrogram,
|
spectrogram: nativeAddonModule.spectrogram,
|
||||||
vectorscope: nativeAddonModule.vectorscope,
|
vectorscope: nativeAddonModule.vectorscope,
|
||||||
|
waveform: nativeAddonModule.waveform,
|
||||||
vumeter: nativeAddonModule.vumeter,
|
vumeter: nativeAddonModule.vumeter,
|
||||||
lufsmeter: nativeAddonModule.lufsmeter,
|
lufsmeter: nativeAddonModule.lufsmeter,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
LUFSMeterNativeSnapshot,
|
LUFSMeterNativeSnapshot,
|
||||||
SpectrogramNativeOptions,
|
SpectrogramNativeOptions,
|
||||||
SpectrogramNativeResult,
|
SpectrogramNativeResult,
|
||||||
|
VectorscopeMultibandPointsResult,
|
||||||
VectorscopeResult,
|
VectorscopeResult,
|
||||||
VectorscopePointsResult,
|
VectorscopePointsResult,
|
||||||
VUMeterNativeSnapshot,
|
VUMeterNativeSnapshot,
|
||||||
@@ -185,6 +186,25 @@ export interface VUMeterNativeAnalyzer {
|
|||||||
isAvailable?: () => boolean
|
isAvailable?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VectorscopeNativeAnalyzer {
|
||||||
|
setSampleRate(sampleRate: number): void
|
||||||
|
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void
|
||||||
|
pushMultibandSamples?: (leftChannel: Float32Array, rightChannel: Float32Array) => void
|
||||||
|
fillPoints(xOut: Float32Array, yOut: Float32Array): number
|
||||||
|
getMultibandPoints?: (maxPoints: number) => VectorscopeMultibandPointsResult | null
|
||||||
|
reset(): void
|
||||||
|
isAvailable?: () => boolean
|
||||||
|
isMultibandAvailable?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaveformNativeAnalyzer {
|
||||||
|
configure(sampleRate: number, samplesPerColumn: number): void
|
||||||
|
processMono(samples: Float32Array): Float32Array | null
|
||||||
|
processStereo(leftChannel: Float32Array, rightChannel: Float32Array): Float32Array | null
|
||||||
|
reset(): void
|
||||||
|
isAvailable?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const spectrogram: SpectrogramNativeAnalyzer = {
|
export const spectrogram: SpectrogramNativeAnalyzer = {
|
||||||
isAvailable: (): boolean => {
|
isAvailable: (): boolean => {
|
||||||
return Boolean(nativeModule?.spectrogram)
|
return Boolean(nativeModule?.spectrogram)
|
||||||
@@ -204,17 +224,37 @@ export const spectrogram: SpectrogramNativeAnalyzer = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vectorscope = {
|
export const vectorscope: VectorscopeNativeAnalyzer & {
|
||||||
|
getPoints: (maxPoints: number) => VectorscopePointsResult | null
|
||||||
|
getBufferSize: () => number
|
||||||
|
setBufferSize: (size: number) => void
|
||||||
|
process: (leftChannel: Float32Array, rightChannel: Float32Array) => VectorscopeResult | null
|
||||||
|
} = {
|
||||||
|
isAvailable: (): boolean => {
|
||||||
|
return Boolean(nativeModule?.vectorscope)
|
||||||
|
},
|
||||||
|
|
||||||
|
isMultibandAvailable: (): boolean => {
|
||||||
|
return Boolean(
|
||||||
|
nativeModule?.vectorscope?.pushMultibandSamples
|
||||||
|
&& nativeModule?.vectorscope?.getMultibandPoints,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
setSampleRate: (sampleRate: number): void => {
|
setSampleRate: (sampleRate: number): void => {
|
||||||
nativeModule?.vectorscope.setSampleRate(sampleRate)
|
nativeModule?.vectorscope?.setSampleRate(sampleRate)
|
||||||
},
|
},
|
||||||
|
|
||||||
pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
|
pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
|
||||||
nativeModule?.vectorscope.pushSamples(leftChannel, rightChannel)
|
nativeModule?.vectorscope?.pushSamples(leftChannel, rightChannel)
|
||||||
|
},
|
||||||
|
|
||||||
|
pushMultibandSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
|
||||||
|
nativeModule?.vectorscope?.pushMultibandSamples?.(leftChannel, rightChannel)
|
||||||
},
|
},
|
||||||
|
|
||||||
fillPoints: (xOut: Float32Array, yOut: Float32Array): number => {
|
fillPoints: (xOut: Float32Array, yOut: Float32Array): number => {
|
||||||
if (!nativeModule) return 0
|
if (!nativeModule?.vectorscope) return 0
|
||||||
const result = nativeModule.vectorscope.getPoints(Math.min(xOut.length, yOut.length))
|
const result = nativeModule.vectorscope.getPoints(Math.min(xOut.length, yOut.length))
|
||||||
const count = Math.min(xOut.length, yOut.length, result.count, result.x.length, result.y.length)
|
const count = Math.min(xOut.length, yOut.length, result.count, result.x.length, result.y.length)
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -224,13 +264,18 @@ export const vectorscope = {
|
|||||||
return count
|
return count
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMultibandPoints: (maxPoints: number): VectorscopeMultibandPointsResult | null => {
|
||||||
|
if (!nativeModule?.vectorscope?.getMultibandPoints) return null
|
||||||
|
return nativeModule.vectorscope.getMultibandPoints(maxPoints)
|
||||||
|
},
|
||||||
|
|
||||||
getPoints: (maxPoints: number): VectorscopePointsResult | null => {
|
getPoints: (maxPoints: number): VectorscopePointsResult | null => {
|
||||||
if (!nativeModule) return null
|
if (!nativeModule?.vectorscope) return null
|
||||||
return nativeModule.vectorscope.getPoints(maxPoints)
|
return nativeModule.vectorscope.getPoints(maxPoints)
|
||||||
},
|
},
|
||||||
|
|
||||||
setBufferSize: (size: number): void => {
|
setBufferSize: (size: number): void => {
|
||||||
nativeModule?.vectorscope.setBufferSize(size)
|
nativeModule?.vectorscope?.setBufferSize(size)
|
||||||
},
|
},
|
||||||
|
|
||||||
getBufferSize: (): number => {
|
getBufferSize: (): number => {
|
||||||
@@ -238,15 +283,39 @@ export const vectorscope = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
process: (leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult | null => {
|
process: (leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult | null => {
|
||||||
if (!nativeModule) return null
|
if (!nativeModule?.vectorscope) return null
|
||||||
return nativeModule.vectorscope.process(leftChannel, rightChannel)
|
return nativeModule.vectorscope.process(leftChannel, rightChannel)
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: (): void => {
|
reset: (): void => {
|
||||||
nativeModule?.vectorscope.reset()
|
nativeModule?.vectorscope?.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const waveform: WaveformNativeAnalyzer = {
|
||||||
|
isAvailable: (): boolean => {
|
||||||
|
return Boolean(nativeModule?.waveform)
|
||||||
|
},
|
||||||
|
|
||||||
|
configure: (sampleRate: number, samplesPerColumn: number): void => {
|
||||||
|
nativeModule?.waveform?.configure(sampleRate, samplesPerColumn)
|
||||||
|
},
|
||||||
|
|
||||||
|
processMono: (samples: Float32Array): Float32Array | null => {
|
||||||
|
if (!nativeModule?.waveform) return null
|
||||||
|
return nativeModule.waveform.processMono(samples)
|
||||||
|
},
|
||||||
|
|
||||||
|
processStereo: (leftChannel: Float32Array, rightChannel: Float32Array): Float32Array | null => {
|
||||||
|
if (!nativeModule?.waveform) return null
|
||||||
|
return nativeModule.waveform.processStereo(leftChannel, rightChannel)
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: (): void => {
|
||||||
|
nativeModule?.waveform?.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const vumeter: VUMeterNativeAnalyzer = {
|
export const vumeter: VUMeterNativeAnalyzer = {
|
||||||
isAvailable: (): boolean => {
|
isAvailable: (): boolean => {
|
||||||
return Boolean(nativeModule?.vumeter)
|
return Boolean(nativeModule?.vumeter)
|
||||||
@@ -300,5 +369,6 @@ export type {
|
|||||||
SpectrogramNativeResult,
|
SpectrogramNativeResult,
|
||||||
VectorscopeResult,
|
VectorscopeResult,
|
||||||
VectorscopePointsResult,
|
VectorscopePointsResult,
|
||||||
|
VectorscopeMultibandPointsResult,
|
||||||
VUMeterNativeSnapshot,
|
VUMeterNativeSnapshot,
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -18,6 +18,11 @@ export interface VectorscopePointsResult {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VectorscopeMultibandPointsResult {
|
||||||
|
data: Float32Array;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpectrogramNativeOptions {
|
export interface SpectrogramNativeOptions {
|
||||||
fftSize: number;
|
fftSize: number;
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
@@ -117,14 +122,23 @@ export interface SpectrogramModule {
|
|||||||
export interface VectorscopeModule {
|
export interface VectorscopeModule {
|
||||||
setSampleRate(sampleRate: number): void;
|
setSampleRate(sampleRate: number): void;
|
||||||
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
|
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
|
||||||
|
pushMultibandSamples?: (leftChannel: Float32Array, rightChannel: Float32Array) => void;
|
||||||
fillPoints(xOut: Float32Array, yOut: Float32Array): number;
|
fillPoints(xOut: Float32Array, yOut: Float32Array): number;
|
||||||
getPoints(maxPoints: number): VectorscopePointsResult;
|
getPoints(maxPoints: number): VectorscopePointsResult;
|
||||||
|
getMultibandPoints?: (maxPoints: number) => VectorscopeMultibandPointsResult;
|
||||||
setBufferSize(size: number): void;
|
setBufferSize(size: number): void;
|
||||||
getBufferSize(): number;
|
getBufferSize(): number;
|
||||||
process(leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult;
|
process(leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult;
|
||||||
reset(): void;
|
reset(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WaveformModule {
|
||||||
|
configure(sampleRate: number, samplesPerColumn: number): void;
|
||||||
|
processMono(samples: Float32Array): Float32Array;
|
||||||
|
processStereo(leftChannel: Float32Array, rightChannel: Float32Array): Float32Array;
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LUFSMeterModule {
|
export interface LUFSMeterModule {
|
||||||
setSampleRate(sampleRate: number): void;
|
setSampleRate(sampleRate: number): void;
|
||||||
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
|
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
|
||||||
@@ -144,6 +158,7 @@ export interface VisualizerDSP {
|
|||||||
spectrum: SpectrumModule;
|
spectrum: SpectrumModule;
|
||||||
spectrogram: SpectrogramModule;
|
spectrogram: SpectrogramModule;
|
||||||
vectorscope: VectorscopeModule;
|
vectorscope: VectorscopeModule;
|
||||||
|
waveform?: WaveformModule;
|
||||||
vumeter: VUMeterModule;
|
vumeter: VUMeterModule;
|
||||||
lufsmeter: LUFSMeterModule;
|
lufsmeter: LUFSMeterModule;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { audioRouter } from '../audio/AudioRouter'
|
import { audioRouter } from '../audio/AudioRouter'
|
||||||
import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native'
|
import { vectorscope as nativeVectorscope, type VectorscopeNativeAnalyzer } from '../audio/native'
|
||||||
import {
|
import {
|
||||||
drawVectorscopeGridForMode,
|
drawVectorscopeGridForMode,
|
||||||
getVectorscopeLayout,
|
getVectorscopeLayout,
|
||||||
@@ -35,9 +35,10 @@ export interface VectorscopeOptions {
|
|||||||
multiband?: boolean
|
multiband?: boolean
|
||||||
dataSource?: VectorscopeDataSource
|
dataSource?: VectorscopeDataSource
|
||||||
frameScheduler?: FrameScheduler
|
frameScheduler?: FrameScheduler
|
||||||
|
nativeAnalyzer?: VectorscopeNativeAnalyzer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolvedVectorscopeOptions = Required<Omit<VectorscopeOptions, 'dataSource' | 'frameScheduler'>>
|
type ResolvedVectorscopeOptions = Required<Omit<VectorscopeOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
|
||||||
|
|
||||||
const defaultOptions: ResolvedVectorscopeOptions = {
|
const defaultOptions: ResolvedVectorscopeOptions = {
|
||||||
lineColor: '#00ffff',
|
lineColor: '#00ffff',
|
||||||
@@ -74,6 +75,7 @@ export class Vectorscope {
|
|||||||
private staticLayerCtx: CanvasRenderingContext2D
|
private staticLayerCtx: CanvasRenderingContext2D
|
||||||
private options: ResolvedVectorscopeOptions
|
private options: ResolvedVectorscopeOptions
|
||||||
private dataSource: VectorscopeDataSource
|
private dataSource: VectorscopeDataSource
|
||||||
|
private nativeAnalyzer: VectorscopeNativeAnalyzer | null
|
||||||
private frameLoop: VisualizerFrameLoop
|
private frameLoop: VisualizerFrameLoop
|
||||||
private nativeInitialized = false
|
private nativeInitialized = false
|
||||||
private lastSampleRate = 0
|
private lastSampleRate = 0
|
||||||
@@ -94,9 +96,10 @@ export class Vectorscope {
|
|||||||
if (!ctx) throw new Error('Could not get 2D context')
|
if (!ctx) throw new Error('Could not get 2D context')
|
||||||
this.ctx = ctx
|
this.ctx = ctx
|
||||||
|
|
||||||
const { dataSource, frameScheduler, ...optionOverrides } = options
|
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
|
||||||
this.options = { ...defaultOptions, ...optionOverrides }
|
this.options = { ...defaultOptions, ...optionOverrides }
|
||||||
this.dataSource = dataSource ?? defaultVectorscopeDataSource
|
this.dataSource = dataSource ?? defaultVectorscopeDataSource
|
||||||
|
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVectorscope : nativeAnalyzer
|
||||||
this.frameLoop = new VisualizerFrameLoop({
|
this.frameLoop = new VisualizerFrameLoop({
|
||||||
frameScheduler,
|
frameScheduler,
|
||||||
shouldRun: () => this.dataSource.isPlaying(),
|
shouldRun: () => this.dataSource.isPlaying(),
|
||||||
@@ -128,13 +131,13 @@ export class Vectorscope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initNative(): void {
|
private initNative(): void {
|
||||||
if (isNativeAvailable() && !this.nativeInitialized) {
|
if (this.isNativeAvailable() && !this.nativeInitialized) {
|
||||||
const sampleRate = this.dataSource.getSampleRate()
|
const sampleRate = this.dataSource.getSampleRate()
|
||||||
this.lastSampleRate = sampleRate
|
this.lastSampleRate = sampleRate
|
||||||
nativeVectorscope.setSampleRate(sampleRate)
|
this.nativeAnalyzer?.setSampleRate(sampleRate)
|
||||||
this.nativeInitialized = true
|
this.nativeInitialized = true
|
||||||
console.log(`Vectorscope: Using native DSP (${sampleRate}Hz)`)
|
console.log(`Vectorscope: Using native DSP (${sampleRate}Hz)`)
|
||||||
} else if (!isNativeAvailable()) {
|
} else if (!this.isNativeAvailable()) {
|
||||||
console.log('Vectorscope: Using JavaScript fallback')
|
console.log('Vectorscope: Using JavaScript fallback')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,16 +146,16 @@ export class Vectorscope {
|
|||||||
const currentRate = this.dataSource.getSampleRate()
|
const currentRate = this.dataSource.getSampleRate()
|
||||||
if (currentRate !== this.lastSampleRate && currentRate > 0) {
|
if (currentRate !== this.lastSampleRate && currentRate > 0) {
|
||||||
this.lastSampleRate = currentRate
|
this.lastSampleRate = currentRate
|
||||||
if (isNativeAvailable()) {
|
if (this.isNativeAvailable()) {
|
||||||
nativeVectorscope.setSampleRate(currentRate)
|
this.nativeAnalyzer?.setSampleRate(currentRate)
|
||||||
}
|
}
|
||||||
this.splitter.configure(currentRate)
|
this.splitter.configure(currentRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetDisplay(): void {
|
private resetDisplay(): void {
|
||||||
if (isNativeAvailable()) {
|
if (this.isNativeAvailable()) {
|
||||||
nativeVectorscope.reset()
|
this.nativeAnalyzer?.reset()
|
||||||
}
|
}
|
||||||
this.splitter.reset()
|
this.splitter.reset()
|
||||||
this.multibandBuffer.reset()
|
this.multibandBuffer.reset()
|
||||||
@@ -161,11 +164,27 @@ export class Vectorscope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOptions(options: Partial<VectorscopeOptions>): void {
|
setOptions(options: Partial<VectorscopeOptions>): void {
|
||||||
const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options
|
const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options
|
||||||
this.options = { ...this.options, ...optionUpdates }
|
const nextOptions: ResolvedVectorscopeOptions = { ...this.options, ...optionUpdates }
|
||||||
|
const multibandChanged = nextOptions.multiband !== this.options.multiband
|
||||||
|
const modeChanged = nextOptions.mode !== this.options.mode
|
||||||
|
this.options = nextOptions
|
||||||
|
let shouldResetDisplay = false
|
||||||
|
if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) {
|
||||||
|
this.nativeAnalyzer = nativeAnalyzer
|
||||||
|
this.nativeInitialized = false
|
||||||
|
this.initNative()
|
||||||
|
shouldResetDisplay = true
|
||||||
|
}
|
||||||
if (dataSource && dataSource !== this.dataSource) {
|
if (dataSource && dataSource !== this.dataSource) {
|
||||||
this.dataSource = dataSource
|
this.dataSource = dataSource
|
||||||
this.subscribeToSessionChanges()
|
this.subscribeToSessionChanges()
|
||||||
|
shouldResetDisplay = true
|
||||||
|
}
|
||||||
|
if (multibandChanged || modeChanged) {
|
||||||
|
shouldResetDisplay = true
|
||||||
|
}
|
||||||
|
if (shouldResetDisplay) {
|
||||||
this.resetDisplay()
|
this.resetDisplay()
|
||||||
}
|
}
|
||||||
this.staticLayerKey = ''
|
this.staticLayerKey = ''
|
||||||
@@ -224,11 +243,13 @@ export class Vectorscope {
|
|||||||
const pendingSamples = this.dataSource.getPendingVectorscopeSamples()
|
const pendingSamples = this.dataSource.getPendingVectorscopeSamples()
|
||||||
|
|
||||||
if (options.multiband) {
|
if (options.multiband) {
|
||||||
this.drawMultibandPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)
|
if (!this.drawNativeMultibandPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)) {
|
||||||
} else if (isNativeAvailable()) {
|
this.drawMultibandPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)
|
||||||
|
}
|
||||||
|
} else if (this.isNativeAvailable()) {
|
||||||
if (pendingSamples.length > 0) {
|
if (pendingSamples.length > 0) {
|
||||||
const { left, right } = this.concatStereoChunks(pendingSamples)
|
const { left, right } = this.concatStereoChunks(pendingSamples)
|
||||||
nativeVectorscope.pushSamples(left, right)
|
this.nativeAnalyzer?.pushSamples(left, right)
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = this.fillNativePoints(options.displayPoints)
|
const count = this.fillNativePoints(options.displayPoints)
|
||||||
@@ -243,6 +264,17 @@ export class Vectorscope {
|
|||||||
ctx.drawImage(offscreenCanvas, 0, 0)
|
ctx.drawImage(offscreenCanvas, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isNativeAvailable(): boolean {
|
||||||
|
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNativeMultibandAvailable(): boolean {
|
||||||
|
return this.isNativeAvailable()
|
||||||
|
&& Boolean(this.nativeAnalyzer?.pushMultibandSamples)
|
||||||
|
&& Boolean(this.nativeAnalyzer?.getMultibandPoints)
|
||||||
|
&& this.nativeAnalyzer?.isMultibandAvailable?.() !== false
|
||||||
|
}
|
||||||
|
|
||||||
private renderStaticLayer(): void {
|
private renderStaticLayer(): void {
|
||||||
this.ensureStaticLayer()
|
this.ensureStaticLayer()
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||||
@@ -401,6 +433,66 @@ export class Vectorscope {
|
|||||||
ctx.globalAlpha = 1.0
|
ctx.globalAlpha = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private drawNativeMultibandPoints(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pendingSamples: { left: Float32Array; right: Float32Array }[],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
scale: number
|
||||||
|
): boolean {
|
||||||
|
if (!this.isNativeMultibandAvailable()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSamples.length > 0) {
|
||||||
|
const { left, right } = this.concatStereoChunks(pendingSamples)
|
||||||
|
this.nativeAnalyzer?.pushMultibandSamples?.(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.nativeAnalyzer?.getMultibandPoints?.(this.options.displayPoints)
|
||||||
|
if (!result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.min(result.count, Math.floor(result.data.length / 6), this.options.displayPoints)
|
||||||
|
if (count === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = this.options.mode
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const dotSize = this.options.lineWidth * dpr
|
||||||
|
const segments = 8
|
||||||
|
const pointsPerSegment = Math.ceil(count / segments)
|
||||||
|
const bandOffsets: Record<(typeof BAND_ORDER)[number], [number, number]> = {
|
||||||
|
low: [0, 1],
|
||||||
|
mid: [2, 3],
|
||||||
|
high: [4, 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let seg = 0; seg < segments; seg++) {
|
||||||
|
const startIdx = seg * pointsPerSegment
|
||||||
|
const endIdx = Math.min((seg + 1) * pointsPerSegment, count)
|
||||||
|
if (startIdx >= count) break
|
||||||
|
|
||||||
|
const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1))
|
||||||
|
ctx.globalAlpha = alpha
|
||||||
|
|
||||||
|
for (const band of BAND_ORDER) {
|
||||||
|
const [leftOffset, rightOffset] = bandOffsets[band]
|
||||||
|
ctx.fillStyle = this.options.bandColors[band]
|
||||||
|
|
||||||
|
for (let i = startIdx; i < endIdx; i++) {
|
||||||
|
const offset = i * 6
|
||||||
|
this.drawProjectedDot(ctx, result.data[offset + leftOffset], result.data[offset + rightOffset], mode, centerX, centerY, scale, dotSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private ensureNativePointBuffers(displayPoints: number): void {
|
private ensureNativePointBuffers(displayPoints: number): void {
|
||||||
if (this.nativePointX.length !== displayPoints) {
|
if (this.nativePointX.length !== displayPoints) {
|
||||||
this.nativePointX = new Float32Array(displayPoints)
|
this.nativePointX = new Float32Array(displayPoints)
|
||||||
@@ -410,7 +502,7 @@ export class Vectorscope {
|
|||||||
|
|
||||||
private fillNativePoints(displayPoints: number): number {
|
private fillNativePoints(displayPoints: number): number {
|
||||||
this.ensureNativePointBuffers(displayPoints)
|
this.ensureNativePointBuffers(displayPoints)
|
||||||
return nativeVectorscope.fillPoints(this.nativePointX, this.nativePointY)
|
return this.nativeAnalyzer?.fillPoints(this.nativePointX, this.nativePointY) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureMultibandScratch(leftLength: number, rightLength: number): MultibandChunk {
|
private ensureMultibandScratch(leftLength: number, rightLength: number): MultibandChunk {
|
||||||
@@ -489,8 +581,8 @@ export class Vectorscope {
|
|||||||
this.unsubscribeSessionChange = null
|
this.unsubscribeSessionChange = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNativeAvailable()) {
|
if (this.isNativeAvailable()) {
|
||||||
nativeVectorscope.reset()
|
this.nativeAnalyzer?.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { audioRouter } from '../audio/AudioRouter'
|
import { audioRouter } from '../audio/AudioRouter'
|
||||||
|
import { waveform as nativeWaveform, type WaveformNativeAnalyzer } from '../audio/native'
|
||||||
import { resolveColorToRgb } from '../utils/color'
|
import { resolveColorToRgb } from '../utils/color'
|
||||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||||
import { FrameScheduler } from './frameScheduler'
|
import { FrameScheduler } from './frameScheduler'
|
||||||
@@ -36,9 +37,10 @@ export interface WaveformOptions {
|
|||||||
multiband?: boolean
|
multiband?: boolean
|
||||||
dataSource?: WaveformDataSource
|
dataSource?: WaveformDataSource
|
||||||
frameScheduler?: FrameScheduler
|
frameScheduler?: FrameScheduler
|
||||||
|
nativeAnalyzer?: WaveformNativeAnalyzer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource' | 'frameScheduler'>>
|
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
|
||||||
|
|
||||||
const defaultOptions: ResolvedWaveformOptions = {
|
const defaultOptions: ResolvedWaveformOptions = {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -74,6 +76,7 @@ export class Waveform {
|
|||||||
private ctx: CanvasRenderingContext2D
|
private ctx: CanvasRenderingContext2D
|
||||||
private options: ResolvedWaveformOptions
|
private options: ResolvedWaveformOptions
|
||||||
private dataSource: WaveformDataSource
|
private dataSource: WaveformDataSource
|
||||||
|
private nativeAnalyzer: WaveformNativeAnalyzer | null
|
||||||
private frameLoop: VisualizerFrameLoop
|
private frameLoop: VisualizerFrameLoop
|
||||||
|
|
||||||
private waterfallCanvas: HTMLCanvasElement
|
private waterfallCanvas: HTMLCanvasElement
|
||||||
@@ -105,7 +108,7 @@ export class Waveform {
|
|||||||
this.ctx = ctx
|
this.ctx = ctx
|
||||||
this.ctx.imageSmoothingEnabled = false
|
this.ctx.imageSmoothingEnabled = false
|
||||||
|
|
||||||
const { dataSource, frameScheduler, ...optionOverrides } = options
|
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
|
||||||
this.options = {
|
this.options = {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...optionOverrides,
|
...optionOverrides,
|
||||||
@@ -114,6 +117,7 @@ export class Waveform {
|
|||||||
multiband: optionOverrides.multiband ?? defaultOptions.multiband,
|
multiband: optionOverrides.multiband ?? defaultOptions.multiband,
|
||||||
}
|
}
|
||||||
this.dataSource = dataSource ?? defaultWaveformDataSource
|
this.dataSource = dataSource ?? defaultWaveformDataSource
|
||||||
|
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeWaveform : nativeAnalyzer
|
||||||
this.frameLoop = new VisualizerFrameLoop({
|
this.frameLoop = new VisualizerFrameLoop({
|
||||||
frameScheduler,
|
frameScheduler,
|
||||||
shouldRun: () => this.dataSource.isPlaying(),
|
shouldRun: () => this.dataSource.isPlaying(),
|
||||||
@@ -150,9 +154,18 @@ export class Waveform {
|
|||||||
this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height)
|
this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height)
|
||||||
this.columnAccumulatorPos = 0
|
this.columnAccumulatorPos = 0
|
||||||
this.splitter.reset()
|
this.splitter.reset()
|
||||||
|
this.nativeAnalyzer?.reset()
|
||||||
|
this.configureNativeAnalyzer()
|
||||||
this.invalidate()
|
this.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private configureNativeAnalyzer(): void {
|
||||||
|
if (this.nativeAnalyzer?.isAvailable?.() === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.nativeAnalyzer?.configure(this.lastSampleRate || Math.max(1, this.dataSource.getSampleRate()), this.samplesPerColumn)
|
||||||
|
}
|
||||||
|
|
||||||
private recomputeSamplesPerColumn(): void {
|
private recomputeSamplesPerColumn(): void {
|
||||||
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
|
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
|
||||||
const pixelsPerSecond = BASE_PIXELS_PER_SECOND * this.options.scrollSpeed
|
const pixelsPerSecond = BASE_PIXELS_PER_SECOND * this.options.scrollSpeed
|
||||||
@@ -171,10 +184,11 @@ export class Waveform {
|
|||||||
}
|
}
|
||||||
this.lastSampleRate = sampleRate
|
this.lastSampleRate = sampleRate
|
||||||
this.splitter.configure(sampleRate)
|
this.splitter.configure(sampleRate)
|
||||||
|
this.configureNativeAnalyzer()
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptions(options: Partial<WaveformOptions>): void {
|
setOptions(options: Partial<WaveformOptions>): void {
|
||||||
const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options
|
const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options
|
||||||
const nextOptions: ResolvedWaveformOptions = {
|
const nextOptions: ResolvedWaveformOptions = {
|
||||||
...this.options,
|
...this.options,
|
||||||
...optionUpdates,
|
...optionUpdates,
|
||||||
@@ -187,9 +201,16 @@ export class Waveform {
|
|||||||
const multibandChanged = nextOptions.multiband !== this.options.multiband
|
const multibandChanged = nextOptions.multiband !== this.options.multiband
|
||||||
const modeChanged = nextOptions.mode !== this.options.mode
|
const modeChanged = nextOptions.mode !== this.options.mode
|
||||||
const dataSourceChanged = Boolean(dataSource && dataSource !== this.dataSource)
|
const dataSourceChanged = Boolean(dataSource && dataSource !== this.dataSource)
|
||||||
|
const nativeAnalyzerChanged = nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer
|
||||||
|
|
||||||
this.options = nextOptions
|
this.options = nextOptions
|
||||||
|
|
||||||
|
if (nativeAnalyzerChanged) {
|
||||||
|
this.nativeAnalyzer = nativeAnalyzer
|
||||||
|
this.nativeAnalyzer?.reset()
|
||||||
|
this.configureNativeAnalyzer()
|
||||||
|
}
|
||||||
|
|
||||||
if (dataSourceChanged && dataSource) {
|
if (dataSourceChanged && dataSource) {
|
||||||
this.dataSource = dataSource
|
this.dataSource = dataSource
|
||||||
this.subscribeToSessionChanges()
|
this.subscribeToSessionChanges()
|
||||||
@@ -201,10 +222,12 @@ export class Waveform {
|
|||||||
|
|
||||||
if (multibandChanged || modeChanged) {
|
if (multibandChanged || modeChanged) {
|
||||||
this.splitter.reset()
|
this.splitter.reset()
|
||||||
|
this.nativeAnalyzer?.reset()
|
||||||
|
this.configureNativeAnalyzer()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.staticLayerKey = ''
|
this.staticLayerKey = ''
|
||||||
if (dataSourceChanged || speedChanged || multibandChanged || modeChanged) {
|
if (dataSourceChanged || speedChanged || multibandChanged || modeChanged || nativeAnalyzerChanged) {
|
||||||
this.resetDisplay()
|
this.resetDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +271,8 @@ export class Waveform {
|
|||||||
midBandSamples: Float32Array,
|
midBandSamples: Float32Array,
|
||||||
highBandSamples: Float32Array,
|
highBandSamples: Float32Array,
|
||||||
): [number, number, number] {
|
): [number, number, number] {
|
||||||
const lowBand = this.toBandColorTuple(this.options.bandColors.low)
|
|
||||||
const midBand = this.toBandColorTuple(this.options.bandColors.mid)
|
|
||||||
const highBand = this.toBandColorTuple(this.options.bandColors.high)
|
|
||||||
const n = this.columnAccumulatorPos
|
const n = this.columnAccumulatorPos
|
||||||
if (n === 0) return midBand
|
if (n === 0) return this.toBandColorTuple(this.options.bandColors.mid)
|
||||||
|
|
||||||
let lowSum = 0
|
let lowSum = 0
|
||||||
let midSum = 0
|
let midSum = 0
|
||||||
@@ -266,9 +286,21 @@ export class Waveform {
|
|||||||
highSum += high * high
|
highSum += high * high
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowRms = Math.sqrt(lowSum / n)
|
return this.computeBandColorFromRms(
|
||||||
const midRms = Math.sqrt(midSum / n)
|
Math.sqrt(lowSum / n),
|
||||||
const highRms = Math.sqrt(highSum / n)
|
Math.sqrt(midSum / n),
|
||||||
|
Math.sqrt(highSum / n),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeBandColorFromRms(
|
||||||
|
lowRms: number,
|
||||||
|
midRms: number,
|
||||||
|
highRms: number,
|
||||||
|
): [number, number, number] {
|
||||||
|
const lowBand = this.toBandColorTuple(this.options.bandColors.low)
|
||||||
|
const midBand = this.toBandColorTuple(this.options.bandColors.mid)
|
||||||
|
const highBand = this.toBandColorTuple(this.options.bandColors.high)
|
||||||
const total = lowRms + midRms + highRms
|
const total = lowRms + midRms + highRms
|
||||||
|
|
||||||
if (total < 1e-10) return midBand
|
if (total < 1e-10) return midBand
|
||||||
@@ -311,6 +343,15 @@ export class Waveform {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveNativeColumnColor(lowRms: number, midRms: number, highRms: number): [number, number, number] {
|
||||||
|
if (this.options.multiband) {
|
||||||
|
return this.computeBandColorFromRms(lowRms, midRms, highRms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineColor = resolveColorToRgb(this.options.lineColor)
|
||||||
|
return [lineColor.r, lineColor.g, lineColor.b]
|
||||||
|
}
|
||||||
|
|
||||||
private toBandColorTuple(color: string): [number, number, number] {
|
private toBandColorTuple(color: string): [number, number, number] {
|
||||||
const { r, g, b } = resolveColorToRgb(color)
|
const { r, g, b } = resolveColorToRgb(color)
|
||||||
return [r, g, b]
|
return [r, g, b]
|
||||||
@@ -454,6 +495,12 @@ export class Waveform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
|
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
|
||||||
|
if (this.useNativeMultiband()) {
|
||||||
|
if (this.processNativeMonoChunk(chunk, width, height)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let lowBand: Float32Array | null = null
|
let lowBand: Float32Array | null = null
|
||||||
let midBand: Float32Array | null = null
|
let midBand: Float32Array | null = null
|
||||||
let highBand: Float32Array | null = null
|
let highBand: Float32Array | null = null
|
||||||
@@ -493,6 +540,12 @@ export class Waveform {
|
|||||||
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
|
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
|
||||||
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
|
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
|
||||||
|
|
||||||
|
if (this.useNativeMultiband()) {
|
||||||
|
if (this.processNativeStereoChunk(leftSamples, rightSamples, width, height)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let lowLeft: Float32Array | null = null
|
let lowLeft: Float32Array | null = null
|
||||||
let midLeft: Float32Array | null = null
|
let midLeft: Float32Array | null = null
|
||||||
let highLeft: Float32Array | null = null
|
let highLeft: Float32Array | null = null
|
||||||
@@ -537,6 +590,47 @@ export class Waveform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private useNativeMultiband(): boolean {
|
||||||
|
return this.options.multiband && Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
private processNativeMonoChunk(chunk: Float32Array, width: number, height: number): boolean {
|
||||||
|
const summaries = this.nativeAnalyzer?.processMono(chunk)
|
||||||
|
if (!summaries) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const stride = 5
|
||||||
|
const columnCount = Math.floor(summaries.length / stride)
|
||||||
|
for (let column = 0; column < columnCount; column += 1) {
|
||||||
|
const offset = column * stride
|
||||||
|
const color = this.resolveNativeColumnColor(summaries[offset + 2], summaries[offset + 3], summaries[offset + 4])
|
||||||
|
this.shiftWaterfall()
|
||||||
|
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, height, color)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private processNativeStereoChunk(leftSamples: Float32Array, rightSamples: Float32Array, width: number, height: number): boolean {
|
||||||
|
const summaries = this.nativeAnalyzer?.processStereo(leftSamples, rightSamples)
|
||||||
|
if (!summaries) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const stride = 10
|
||||||
|
const laneHeight = height / 2
|
||||||
|
const columnCount = Math.floor(summaries.length / stride)
|
||||||
|
for (let column = 0; column < columnCount; column += 1) {
|
||||||
|
const offset = column * stride
|
||||||
|
const leftColor = this.resolveNativeColumnColor(summaries[offset + 2], summaries[offset + 3], summaries[offset + 4])
|
||||||
|
const rightColor = this.resolveNativeColumnColor(summaries[offset + 7], summaries[offset + 8], summaries[offset + 9])
|
||||||
|
this.shiftWaterfall()
|
||||||
|
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, laneHeight, leftColor)
|
||||||
|
this.paintColumn(summaries[offset + 5], summaries[offset + 6], width, laneHeight, laneHeight, rightColor)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private ensureMultibandScratch(length: number): MultibandChunk {
|
private ensureMultibandScratch(length: number): MultibandChunk {
|
||||||
if (this.multibandScratch.low.left.length < length) {
|
if (this.multibandScratch.low.left.length < length) {
|
||||||
this.multibandScratch = createMultibandChunk(length)
|
this.multibandScratch = createMultibandChunk(length)
|
||||||
@@ -613,6 +707,7 @@ export class Waveform {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop()
|
this.stop()
|
||||||
this.frameLoop.dispose()
|
this.frameLoop.dispose()
|
||||||
|
this.nativeAnalyzer?.reset()
|
||||||
if (this.unsubscribeSessionChange) {
|
if (this.unsubscribeSessionChange) {
|
||||||
this.unsubscribeSessionChange()
|
this.unsubscribeSessionChange()
|
||||||
this.unsubscribeSessionChange = null
|
this.unsubscribeSessionChange = null
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ import type {
|
|||||||
SpectrogramNativeAnalyzer,
|
SpectrogramNativeAnalyzer,
|
||||||
SpectrogramNativeOptions,
|
SpectrogramNativeOptions,
|
||||||
SpectrogramNativeResult,
|
SpectrogramNativeResult,
|
||||||
|
VectorscopeNativeAnalyzer,
|
||||||
VUMeterNativeAnalyzer,
|
VUMeterNativeAnalyzer,
|
||||||
VUMeterNativeSnapshot,
|
VUMeterNativeSnapshot,
|
||||||
|
WaveformNativeAnalyzer,
|
||||||
} from '../src/renderer/audio/native'
|
} from '../src/renderer/audio/native'
|
||||||
import {
|
import {
|
||||||
HEAT_LOW_DB,
|
HEAT_LOW_DB,
|
||||||
@@ -96,6 +98,7 @@ import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope'
|
|||||||
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
|
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
|
||||||
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
|
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
|
||||||
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
|
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
|
||||||
|
import { Waveform } from '../src/renderer/visualizers/Waveform'
|
||||||
import {
|
import {
|
||||||
VUMeter,
|
VUMeter,
|
||||||
VU_NEEDLE_FACE_HEIGHT_CSS_PX,
|
VU_NEEDLE_FACE_HEIGHT_CSS_PX,
|
||||||
@@ -4029,6 +4032,260 @@ test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => {
|
|||||||
assert.notEqual(pointTarget.low.left[0], 0)
|
assert.notEqual(pointTarget.low.left[0], 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function createFakeWaveformNativeAnalyzer(options: {
|
||||||
|
available?: boolean
|
||||||
|
monoSummary?: Float32Array
|
||||||
|
stereoSummary?: Float32Array
|
||||||
|
} = {}): WaveformNativeAnalyzer & {
|
||||||
|
configs: Array<{ sampleRate: number; samplesPerColumn: number }>
|
||||||
|
monoPushes: Float32Array[]
|
||||||
|
stereoPushes: Array<{ left: Float32Array; right: Float32Array }>
|
||||||
|
resetCount: number
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
configs: [],
|
||||||
|
monoPushes: [],
|
||||||
|
stereoPushes: [],
|
||||||
|
resetCount: 0,
|
||||||
|
isAvailable: () => options.available ?? true,
|
||||||
|
configure(sampleRate, samplesPerColumn) {
|
||||||
|
this.configs.push({ sampleRate, samplesPerColumn })
|
||||||
|
},
|
||||||
|
processMono(samples) {
|
||||||
|
this.monoPushes.push(samples)
|
||||||
|
return options.monoSummary ?? new Float32Array(0)
|
||||||
|
},
|
||||||
|
processStereo(left, right) {
|
||||||
|
this.stereoPushes.push({ left, right })
|
||||||
|
return options.stereoSummary ?? new Float32Array(0)
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.resetCount += 1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeVectorscopeNativeAnalyzer(options: {
|
||||||
|
multibandAvailable?: boolean
|
||||||
|
multibandData?: Float32Array
|
||||||
|
multibandCount?: number
|
||||||
|
} = {}): VectorscopeNativeAnalyzer & {
|
||||||
|
sampleRates: number[]
|
||||||
|
multibandPushes: Array<{ left: Float32Array; right: Float32Array }>
|
||||||
|
multibandRequests: number[]
|
||||||
|
resetCount: number
|
||||||
|
} {
|
||||||
|
const analyzer: VectorscopeNativeAnalyzer & {
|
||||||
|
sampleRates: number[]
|
||||||
|
multibandPushes: Array<{ left: Float32Array; right: Float32Array }>
|
||||||
|
multibandRequests: number[]
|
||||||
|
resetCount: number
|
||||||
|
} = {
|
||||||
|
sampleRates: [],
|
||||||
|
multibandPushes: [],
|
||||||
|
multibandRequests: [],
|
||||||
|
resetCount: 0,
|
||||||
|
isAvailable: () => true,
|
||||||
|
isMultibandAvailable: () => options.multibandAvailable ?? true,
|
||||||
|
setSampleRate(sampleRate) {
|
||||||
|
this.sampleRates.push(sampleRate)
|
||||||
|
},
|
||||||
|
pushSamples() {},
|
||||||
|
fillPoints() {
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.resetCount += 1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.multibandAvailable !== false) {
|
||||||
|
analyzer.pushMultibandSamples = (left, right) => {
|
||||||
|
analyzer.multibandPushes.push({ left, right })
|
||||||
|
}
|
||||||
|
analyzer.getMultibandPoints = (maxPoints) => {
|
||||||
|
analyzer.multibandRequests.push(maxPoints)
|
||||||
|
const data = options.multibandData ?? new Float32Array(0)
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
count: options.multibandCount ?? Math.floor(data.length / 6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return analyzer
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Waveform uses native multiband column summaries when available', () => {
|
||||||
|
const recorder = createFakeCanvasRecorder()
|
||||||
|
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||||
|
const nativeAnalyzer = createFakeWaveformNativeAnalyzer({
|
||||||
|
monoSummary: new Float32Array([-0.5, 0.5, 1, 0, 0]),
|
||||||
|
})
|
||||||
|
const dataSource = {
|
||||||
|
getPendingWaveformSamples: () => [new Float32Array([0.25])],
|
||||||
|
getPendingWaveformStereoSamples: () => [],
|
||||||
|
getSampleRate: () => 64,
|
||||||
|
isPlaying: () => true,
|
||||||
|
subscribeToSessionChanges: () => () => {},
|
||||||
|
}
|
||||||
|
const waveform = new Waveform(createFakeCanvas(recorder), {
|
||||||
|
dataSource,
|
||||||
|
multiband: true,
|
||||||
|
nativeAnalyzer,
|
||||||
|
bandColors: {
|
||||||
|
low: '#ff0000',
|
||||||
|
mid: '#00ff00',
|
||||||
|
high: '#0000ff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
;(waveform as unknown as { drawFrame: () => void }).drawFrame()
|
||||||
|
assert.equal(nativeAnalyzer.monoPushes.length, 1)
|
||||||
|
assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 1)
|
||||||
|
assert.equal(
|
||||||
|
recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(235, 20, 0, 0.72)'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
waveform.dispose()
|
||||||
|
dom.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Waveform falls back to JavaScript multiband when native is unavailable', () => {
|
||||||
|
const recorder = createFakeCanvasRecorder()
|
||||||
|
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||||
|
const nativeAnalyzer = createFakeWaveformNativeAnalyzer({ available: false })
|
||||||
|
const dataSource = {
|
||||||
|
getPendingWaveformSamples: () => [new Float32Array([0.75])],
|
||||||
|
getPendingWaveformStereoSamples: () => [],
|
||||||
|
getSampleRate: () => 64,
|
||||||
|
isPlaying: () => true,
|
||||||
|
subscribeToSessionChanges: () => () => {},
|
||||||
|
}
|
||||||
|
const waveform = new Waveform(createFakeCanvas(recorder), {
|
||||||
|
dataSource,
|
||||||
|
multiband: true,
|
||||||
|
nativeAnalyzer,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
;(waveform as unknown as { drawFrame: () => void }).drawFrame()
|
||||||
|
assert.equal(nativeAnalyzer.monoPushes.length, 0)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.width === 1), true)
|
||||||
|
} finally {
|
||||||
|
waveform.dispose()
|
||||||
|
dom.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Waveform reconfigures and resets native multiband state on option changes', () => {
|
||||||
|
const dom = installFakeCanvasDom()
|
||||||
|
const nativeAnalyzer = createFakeWaveformNativeAnalyzer()
|
||||||
|
const dataSource = {
|
||||||
|
getPendingWaveformSamples: () => [],
|
||||||
|
getPendingWaveformStereoSamples: () => [],
|
||||||
|
getSampleRate: () => 128,
|
||||||
|
isPlaying: () => true,
|
||||||
|
subscribeToSessionChanges: () => () => {},
|
||||||
|
}
|
||||||
|
const waveform = new Waveform(createFakeCanvas(), {
|
||||||
|
dataSource,
|
||||||
|
multiband: true,
|
||||||
|
nativeAnalyzer,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
nativeAnalyzer.configs.length = 0
|
||||||
|
nativeAnalyzer.resetCount = 0
|
||||||
|
waveform.setOptions({ scrollSpeed: 2 })
|
||||||
|
assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 1)
|
||||||
|
assert.equal(nativeAnalyzer.resetCount > 0, true)
|
||||||
|
|
||||||
|
waveform.setOptions({ multiband: false })
|
||||||
|
assert.equal(nativeAnalyzer.resetCount > 1, true)
|
||||||
|
} finally {
|
||||||
|
waveform.dispose()
|
||||||
|
dom.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vectorscope multiband uses native interleaved band points when available', () => {
|
||||||
|
const recorder = createFakeCanvasRecorder()
|
||||||
|
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||||
|
const nativeAnalyzer = createFakeVectorscopeNativeAnalyzer({
|
||||||
|
multibandData: new Float32Array([0.1, 0.2, 0.2, 0.1, -0.1, 0.15]),
|
||||||
|
multibandCount: 1,
|
||||||
|
})
|
||||||
|
const dataSource = {
|
||||||
|
getPendingVectorscopeSamples: () => [{ left: new Float32Array([0.2]), right: new Float32Array([0.1]) }],
|
||||||
|
getSampleRate: () => 48000,
|
||||||
|
isPlaying: () => true,
|
||||||
|
subscribeToSessionChanges: () => () => {},
|
||||||
|
}
|
||||||
|
const vectorscope = new Vectorscope(createFakeCanvas(recorder), {
|
||||||
|
dataSource,
|
||||||
|
multiband: true,
|
||||||
|
showGrid: false,
|
||||||
|
displayPoints: 1,
|
||||||
|
nativeAnalyzer,
|
||||||
|
bandColors: {
|
||||||
|
low: '#110000',
|
||||||
|
mid: '#001100',
|
||||||
|
high: '#000011',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
;(vectorscope as unknown as { drawFrame: () => void }).drawFrame()
|
||||||
|
assert.equal(nativeAnalyzer.multibandPushes.length, 1)
|
||||||
|
assert.deepEqual(nativeAnalyzer.multibandRequests, [1])
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#110000'), true)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#001100'), true)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#000011'), true)
|
||||||
|
} finally {
|
||||||
|
vectorscope.dispose()
|
||||||
|
dom.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vectorscope multiband falls back to JavaScript splitter when native multiband is missing', () => {
|
||||||
|
const recorder = createFakeCanvasRecorder()
|
||||||
|
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||||
|
const nativeAnalyzer = createFakeVectorscopeNativeAnalyzer({ multibandAvailable: false })
|
||||||
|
const dataSource = {
|
||||||
|
getPendingVectorscopeSamples: () => [createSineStereoChunk(440, 48000, 8)],
|
||||||
|
getSampleRate: () => 48000,
|
||||||
|
isPlaying: () => true,
|
||||||
|
subscribeToSessionChanges: () => () => {},
|
||||||
|
}
|
||||||
|
const vectorscope = new Vectorscope(createFakeCanvas(recorder), {
|
||||||
|
dataSource,
|
||||||
|
multiband: true,
|
||||||
|
showGrid: false,
|
||||||
|
displayPoints: 8,
|
||||||
|
nativeAnalyzer,
|
||||||
|
bandColors: {
|
||||||
|
low: '#210000',
|
||||||
|
mid: '#002100',
|
||||||
|
high: '#000021',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
;(vectorscope as unknown as { drawFrame: () => void }).drawFrame()
|
||||||
|
assert.equal(nativeAnalyzer.multibandPushes.length, 0)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#210000'), true)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#002100'), true)
|
||||||
|
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#000021'), true)
|
||||||
|
} finally {
|
||||||
|
vectorscope.dispose()
|
||||||
|
dom.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function createFakeVUMeterNativeAnalyzer(
|
function createFakeVUMeterNativeAnalyzer(
|
||||||
snapshotOverrides: Partial<VUMeterNativeSnapshot> = {},
|
snapshotOverrides: Partial<VUMeterNativeSnapshot> = {},
|
||||||
available = true,
|
available = true,
|
||||||
|
|||||||
Reference in New Issue
Block a user