initial concept

This commit is contained in:
Boof2015
2026-03-21 01:08:26 -04:00
parent 6af9d0f8f8
commit ecf13c2857
47 changed files with 15009 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build outputs
out/
dist/
native/build/
# Electron
*.log
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
plugins: [react()]
}
})
+42
View File
@@ -0,0 +1,42 @@
{
"targets": [
{
"target_name": "visualizer_dsp",
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"],
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math"],
"sources": [
"src/main.cpp",
"src/oscilloscope.cpp",
"src/spectrum.cpp",
"src/vectorscope.cpp",
"src/dsp_utils.cpp"
],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")",
"src"
],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
"conditions": [
["OS=='mac'", {
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"CLANG_CXX_LIBRARY": "libc++",
"MACOSX_DEPLOYMENT_TARGET": "10.15"
}
}],
["OS=='win'", {
"msvs_settings": {
"VCCLCompilerTool": {
"ExceptionHandling": 1,
"AdditionalOptions": ["/O2"]
}
}
}],
["OS=='linux'", {
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"]
}]
]
}
]
}
+433
View File
@@ -0,0 +1,433 @@
#define _USE_MATH_DEFINES
#include "dsp_utils.h"
#include <cstring>
#include <cmath>
#include <algorithm>
namespace DSP {
// FFT Implementation
FFT::FFT(size_t size) : size_(size) {
// Precompute twiddle factors
twiddles_.resize(size / 2);
for (size_t i = 0; i < size / 2; i++) {
float angle = -2.0f * M_PI * i / size;
twiddles_[i] = std::complex<float>(cosf(angle), sinf(angle));
}
buffer_.resize(size);
scratch_.resize(size);
}
void FFT::bitReverse(std::complex<float>* data) {
size_t n = size_;
for (size_t i = 1, j = 0; i < n; i++) {
size_t bit = n >> 1;
while (j & bit) {
j ^= bit;
bit >>= 1;
}
j ^= bit;
if (i < j) {
std::swap(data[i], data[j]);
}
}
}
void FFT::forward(const float* input, std::complex<float>* output) {
// Copy input to internal buffer
for (size_t i = 0; i < size_; i++) {
buffer_[i] = std::complex<float>(input[i], 0.0f);
}
bitReverse(buffer_.data());
// Cooley-Tukey FFT
for (size_t len = 2; len <= size_; len *= 2) {
size_t halfLen = len / 2;
size_t step = size_ / len;
for (size_t i = 0; i < size_; i += len) {
for (size_t j = 0; j < halfLen; j++) {
std::complex<float> t = twiddles_[j * step] * buffer_[i + j + halfLen];
buffer_[i + j + halfLen] = buffer_[i + j] - t;
buffer_[i + j] = buffer_[i + j] + t;
}
}
}
memcpy(output, buffer_.data(), size_ * sizeof(std::complex<float>));
}
void FFT::forward(const float* input, float* magnitudes) {
// Use scratch buffer for complex output to avoid allocation
forward(input, scratch_.data());
// Calculate magnitudes (only first half is useful)
// Scale by 2/N for correct magnitude
float scale = 2.0f / size_;
for (size_t i = 0; i < size_ / 2; i++) {
magnitudes[i] = std::abs(scratch_[i]) * scale;
}
}
// BiquadFilter Implementation
BiquadFilter::BiquadFilter()
: b0_(1), b1_(0), b2_(0), a1_(0), a2_(0)
, x1_(0), x2_(0), y1_(0), y2_(0) {}
void BiquadFilter::setLowpass(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) {
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_ = alpha / a0;
b1_ = 0.0f;
b2_ = -alpha / a0;
a1_ = -2.0f * cosOmega / a0;
a2_ = (1.0f - alpha) / a0;
}
void BiquadFilter::setHighShelf(float frequency, float sampleRate, float gainDB, float Q) {
float A = powf(10.0f, gainDB / 40.0f); // sqrt(10^(dB/20))
float omega = 2.0f * M_PI * frequency / sampleRate;
float sinOmega = sinf(omega);
float cosOmega = cosf(omega);
float alpha = sinOmega / (2.0f * Q);
float a0 = (A + 1.0f) - (A - 1.0f) * cosOmega + 2.0f * sqrtf(A) * alpha;
b0_ = A * ((A + 1.0f) + (A - 1.0f) * cosOmega + 2.0f * sqrtf(A) * alpha) / a0;
b1_ = -2.0f * A * ((A - 1.0f) + (A + 1.0f) * cosOmega) / a0;
b2_ = A * ((A + 1.0f) + (A - 1.0f) * cosOmega - 2.0f * sqrtf(A) * alpha) / a0;
a1_ = 2.0f * ((A - 1.0f) - (A + 1.0f) * cosOmega) / a0;
a2_ = ((A + 1.0f) - (A - 1.0f) * cosOmega - 2.0f * sqrtf(A) * alpha) / a0;
}
float BiquadFilter::process(float input) {
float output = b0_ * input + b1_ * x1_ + b2_ * x2_ - a1_ * y1_ - a2_ * y2_;
x2_ = x1_;
x1_ = input;
y2_ = y1_;
y1_ = output;
// Denormal protection
if (std::abs(y1_) < 1e-20f) y1_ = 0.0f;
if (std::abs(y2_) < 1e-20f) y2_ = 0.0f;
return output;
}
void BiquadFilter::reset() {
x1_ = x2_ = y1_ = y2_ = 0.0f;
}
void BiquadFilter::processBuffer(const float* input, float* output, size_t length, bool bidirectional) {
reset();
// Forward pass
for (size_t i = 0; i < length; i++) {
output[i] = process(input[i]);
}
if (bidirectional) {
// Backward pass for zero phase delay
reset();
for (int i = length - 1; i >= 0; i--) {
output[i] = process(output[i]);
}
}
}
// FIRFilter Implementation
FIRFilter::FIRFilter() : idx_(0), order_(0) {}
// Modified Bessel function of the first kind, order 0 (I0)
// Approximation from Abramowitz and Stegun
double FIRFilter::besselI0(double x) {
double ax = std::abs(x);
if (ax <= 3.75) {
double y = (x / 3.75);
y *= y;
return 1.0 + y * (3.5156229 + y * (3.0899424 + y * (1.2067492 +
y * (0.2659732 + y * (0.0360768 + y * 0.0045813)))));
} else {
double y = 3.75 / ax;
return (std::exp(ax) / std::sqrt(ax)) * (0.39894228 +
y * (0.01328592 + y * (0.00225319 + y * (-0.00157565 +
y * (0.00916281 + y * (-0.02057706 + y * (0.02635537 +
y * (-0.01647633 + y * 0.00392377))))))));
}
}
std::vector<float> FIRFilter::kaiserWindow(size_t length, float beta) {
std::vector<float> window(length);
if (length == 0) return window;
const double denom = besselI0(static_cast<double>(beta));
const double M = static_cast<double>(length - 1);
for (size_t n = 0; n < length; ++n) {
double ratio = (M == 0.0) ? 0.0 : (2.0 * static_cast<double>(n) / M - 1.0);
double val = besselI0(static_cast<double>(beta) *
std::sqrt(std::max(0.0, 1.0 - ratio * ratio))) / denom;
window[n] = static_cast<float>(val);
}
return window;
}
void FIRFilter::designBandpass(float centerFreq, float bandwidth, float sampleRate, float sidelobeAtten) {
// Kaiser beta from sidelobe attenuation
float beta = sidelobeAtten < 21.0f ? 0.0f
: sidelobeAtten < 50.0f ? 0.5842f * powf(sidelobeAtten - 21.0f, 0.4f) +
0.07886f * (sidelobeAtten - 21.0f)
: 0.1102f * (sidelobeAtten - 8.7f);
// Normalized frequencies
float wc1 = 2.0f * static_cast<float>(M_PI) * (centerFreq - bandwidth / 2.0f) / sampleRate;
float wc2 = 2.0f * static_cast<float>(M_PI) * (centerFreq + bandwidth / 2.0f) / sampleRate;
wc1 = std::max(wc1, 0.001f);
wc2 = std::min(wc2, static_cast<float>(M_PI) - 0.001f);
// Calculate filter order
float deltaF = (wc2 - wc1) / static_cast<float>(M_PI);
int order = static_cast<int>((sidelobeAtten - 8) / (2.285 * deltaF * M_PI));
order = std::clamp(order, 1, 512);
order_ = static_cast<size_t>(order);
size_t len = order + 1;
size_t centerTap = len / 2;
// Ideal bandpass impulse response
std::vector<float> ideal(len);
for (size_t i = 0; i < len; ++i) {
if (i == centerTap) {
ideal[i] = (wc2 - wc1) / static_cast<float>(M_PI);
} else {
float n = static_cast<float>(static_cast<int>(i) - static_cast<int>(centerTap));
ideal[i] = (sinf(wc2 * n) - sinf(wc1 * n)) / (static_cast<float>(M_PI) * n);
}
}
// Apply Kaiser window
std::vector<float> window = kaiserWindow(len, beta);
coeffs_.resize(len);
for (size_t i = 0; i < len; ++i) {
coeffs_[i] = ideal[i] * window[i];
}
// Normalize to unity gain at center frequency
float centerOmega = 2.0f * static_cast<float>(M_PI) * centerFreq / sampleRate;
float response = 0.0f;
for (size_t i = 0; i < len; ++i) {
response += coeffs_[i] * cosf(centerOmega *
(static_cast<float>(i) - static_cast<float>(centerTap)));
}
if (std::abs(response) > 1e-6f) {
float scale = 1.0f / response;
for (float& coeff : coeffs_) {
coeff *= scale;
}
}
// Reset delay line
delay_.resize(len, 0.0f);
idx_ = 0;
}
float FIRFilter::process(float input) {
if (coeffs_.empty()) return input;
size_t nTaps = coeffs_.size();
idx_ %= nTaps;
delay_[idx_] = input;
float out = 0.0f;
size_t firstLen = nTaps - idx_;
// Process first segment [idx_ .. end]
for (size_t i = 0; i < firstLen; ++i) {
out += coeffs_[i] * delay_[idx_ + i];
}
// Process second segment [0 .. idx_-1]
for (size_t i = 0; i < idx_; ++i) {
out += coeffs_[firstLen + i] * delay_[i];
}
idx_ = (idx_ + 1) % nTaps;
return out;
}
void FIRFilter::reset() {
std::fill(delay_.begin(), delay_.end(), 0.0f);
idx_ = 0;
}
// Pitch detection using autocorrelation
float detectPitch(const float* data, size_t length, float sampleRate, float minFreq, float maxFreq) {
int minPeriod = static_cast<int>(sampleRate / maxFreq);
int maxPeriod = static_cast<int>(sampleRate / minFreq);
maxPeriod = std::min(maxPeriod, static_cast<int>(length / 2));
if (maxPeriod <= minPeriod) return 0.0f;
float bestCorrelation = -1.0f;
int bestPeriod = 0;
// Use a simplified autocorrelation: only compute for lags in range
for (int period = minPeriod; period < maxPeriod; period++) {
float correlation = 0.0f;
float energy1 = 0.0f;
float energy2 = 0.0f;
// Use fewer samples for performance, but enough for accuracy
int samples = std::min(static_cast<int>(length) - period, 512);
for (int i = 0; i < samples; i++) {
correlation += data[i] * data[i + period];
energy1 += data[i] * data[i];
energy2 += data[i + period] * data[i + period];
}
// Normalized correlation
if (energy1 > 1e-9f && energy2 > 1e-9f) {
float norm = sqrtf(energy1 * energy2);
correlation /= norm;
if (correlation > bestCorrelation) {
bestCorrelation = correlation;
bestPeriod = period;
}
}
}
// Threshold for valid pitch
if (bestCorrelation < 0.5f || bestPeriod == 0) {
return 0.0f; // No confident pitch found
}
// Parabolic interpolation for sub-sample accuracy could be added here
// but basic integer period is often enough for visual stabilization
return sampleRate / bestPeriod;
}
// FFT-based pitch detection (more stable than autocorrelation)
float detectPitchFFT(const float* data, size_t length, float sampleRate, float minFreq, float maxFreq) {
// Use power-of-2 FFT size
size_t fftSize = 2048;
if (length < fftSize) {
fftSize = 1024;
if (length < fftSize) {
fftSize = 512;
}
}
FFT fft(fftSize);
std::vector<float> magnitudes(fftSize / 2);
// Apply Hann window and run FFT
std::vector<float> windowed(fftSize, 0.0f);
size_t copyLen = std::min(length, fftSize);
for (size_t i = 0; i < copyLen; i++) {
float win = 0.5f * (1.0f - cosf(2.0f * static_cast<float>(M_PI) * i / fftSize));
windowed[i] = data[i] * win;
}
fft.forward(windowed.data(), magnitudes.data());
// Find peak in frequency range
int minBin = std::max(1, static_cast<int>(minFreq * fftSize / sampleRate));
int maxBin = std::min(static_cast<int>(fftSize / 2 - 1), static_cast<int>(maxFreq * fftSize / sampleRate));
if (minBin >= maxBin) {
return 0.0f;
}
float peakMag = 0.0f;
int peakBin = minBin;
for (int i = minBin; i <= maxBin; i++) {
if (magnitudes[i] > peakMag) {
peakMag = magnitudes[i];
peakBin = i;
}
}
// Check if peak is significant (avoid noise)
if (peakMag < 1e-6f) {
return 0.0f;
}
// Quadratic interpolation for sub-bin accuracy
if (peakBin > 0 && peakBin < static_cast<int>(fftSize / 2) - 1) {
float y1 = magnitudes[peakBin - 1];
float y2 = magnitudes[peakBin];
float y3 = magnitudes[peakBin + 1];
float denom = y1 - 2.0f * y2 + y3;
if (std::abs(denom) > 1e-9f) {
float offset = 0.5f * (y1 - y3) / denom;
offset = std::clamp(offset, -0.5f, 0.5f);
return (static_cast<float>(peakBin) + offset) * sampleRate / static_cast<float>(fftSize);
}
}
return static_cast<float>(peakBin) * sampleRate / static_cast<float>(fftSize);
}
// Find zero-crossing trigger point (sub-sample precision)
// searches in [searchStart, searchEnd)
// Finds the STRONGEST (steepest slope) rising zero crossing for consistency
float findTriggerPoint(const float* data, size_t length, int searchStart, int searchEnd) {
searchStart = std::max(1, searchStart); // Need i-1
searchEnd = std::min(static_cast<int>(length), searchEnd);
if (searchStart >= searchEnd) return -1.0f;
// Find the zero crossing with the steepest positive slope
float bestSlope = 0.0f;
int bestIdx = -1;
for (int i = searchStart; i < searchEnd; i++) {
float prev = data[i - 1];
float curr = data[i];
// Rising zero crossing: prev < 0 and curr >= 0
if (prev < 0.0f && curr >= 0.0f) {
float slope = curr - prev; // Always positive for rising crossing
if (slope > bestSlope) {
bestSlope = slope;
bestIdx = i;
}
}
}
if (bestIdx < 0) return -1.0f;
// Linear interpolation for sub-sample precision
float prev = data[bestIdx - 1];
float curr = data[bestIdx];
float t = -prev / (curr - prev);
return static_cast<float>(bestIdx - 1) + t;
}
// Calculate RMS
float calculateRMS(const float* data, size_t length) {
if (length == 0) return 0.0f;
float sum = 0.0f;
for (size_t i = 0; i < length; i++) {
sum += data[i] * data[i];
}
return sqrtf(sum / length);
}
} // namespace DSP
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#define _USE_MATH_DEFINES
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#include <vector>
#include <complex>
#include <algorithm>
namespace DSP {
// Simple FFT implementation (Cooley-Tukey radix-2)
class FFT {
public:
explicit FFT(size_t size);
void forward(const float* input, float* magnitudes);
void forward(const float* input, std::complex<float>* output);
size_t getSize() const { return size_; }
private:
size_t size_;
std::vector<std::complex<float>> twiddles_;
std::vector<std::complex<float>> buffer_; // Reuse buffer to avoid allocations
std::vector<std::complex<float>> scratch_; // Scratch buffer if needed
void bitReverse(std::complex<float>* data);
};
// Biquad filter for lowpass/bandpass
class BiquadFilter {
public:
BiquadFilter();
void setLowpass(float frequency, float sampleRate, float Q = 0.707f);
void setBandpass(float frequency, float sampleRate, float Q = 2.0f);
void setHighShelf(float frequency, float sampleRate, float gainDB, float Q = 0.707f);
float process(float input);
void reset();
// Process entire buffer (bidirectional for zero phase)
void processBuffer(const float* input, float* output, size_t length, bool bidirectional = true);
private:
float b0_, b1_, b2_;
float a1_, a2_;
float x1_, x2_;
float y1_, y2_;
};
// Linear-phase FIR filter for stable trigger detection
// Uses Kaiser-windowed bandpass design for consistent zero crossings
class FIRFilter {
public:
FIRFilter();
// Design Kaiser-windowed bandpass filter centered on frequency
void designBandpass(float centerFreq, float bandwidth, float sampleRate, float sidelobeAtten = 60.0f);
// Process single sample
float process(float input);
// Get filter delay (for phase compensation)
size_t getDelay() const { return order_ / 2; }
// Reset filter state
void reset();
private:
std::vector<float> coeffs_;
std::vector<float> delay_;
size_t idx_;
size_t order_;
// Kaiser window helpers
static std::vector<float> kaiserWindow(size_t length, float beta);
static double besselI0(double x);
};
// Pitch detection using autocorrelation
float detectPitch(const float* data, size_t length, float sampleRate, float minFreq = 40.0f, float maxFreq = 2000.0f);
// FFT-based pitch detection (more stable than autocorrelation)
float detectPitchFFT(const float* data, size_t length, float sampleRate, float minFreq = 40.0f, float maxFreq = 2000.0f);
// Find zero-crossing trigger point with hysteresis/hold-off (sub-sample precision)
float findTriggerPoint(const float* data, size_t length, int searchStart, int searchEnd);
// Calculate RMS
float calculateRMS(const float* data, size_t length);
} // namespace DSP
+300
View File
@@ -0,0 +1,300 @@
#include <napi.h>
#include <cstring>
#include "oscilloscope.h"
#include "spectrum.h"
#include "vectorscope.h"
// Global instances
static Visualizer::Oscilloscope oscilloscope;
static Visualizer::Spectrum spectrum(2048);
static Visualizer::Vectorscope vectorscope;
// ============== Oscilloscope ==============
Napi::Value OscilloscopeSetSampleRate(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected sample rate").ThrowAsJavaScriptException();
return env.Null();
}
oscilloscope.setSampleRate(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value OscilloscopeSetPitchLock(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBoolean()) {
Napi::TypeError::New(env, "Expected boolean").ThrowAsJavaScriptException();
return env.Null();
}
oscilloscope.setPitchLock(info[0].As<Napi::Boolean>().Value());
return env.Undefined();
}
Napi::Value OscilloscopeSetDisplaySamples(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected number").ThrowAsJavaScriptException();
return env.Null();
}
oscilloscope.setDisplaySamples(info[0].As<Napi::Number>().Int32Value());
return env.Undefined();
}
Napi::Value OscilloscopePushSamples(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 audioData = info[0].As<Napi::Float32Array>();
oscilloscope.pushSamples(audioData.Data(), audioData.ElementLength());
return env.Undefined();
}
Napi::Value OscilloscopeProcessContinuous(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
auto result = oscilloscope.process();
Napi::Object obj = Napi::Object::New(env);
obj.Set("triggerIndex", Napi::Number::New(env, result.triggerIndex));
obj.Set("samplesToShow", Napi::Number::New(env, result.samplesToShow));
obj.Set("detectedPitch", Napi::Number::New(env, result.detectedPitch));
obj.Set("writePos", Napi::Number::New(env, static_cast<double>(oscilloscope.getWritePos())));
return obj;
}
Napi::Value OscilloscopeProcess(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 audioData = info[0].As<Napi::Float32Array>();
auto result = oscilloscope.processSnapshot(audioData.Data(), audioData.ElementLength());
Napi::Object obj = Napi::Object::New(env);
obj.Set("triggerIndex", Napi::Number::New(env, result.triggerIndex));
obj.Set("samplesToShow", Napi::Number::New(env, result.samplesToShow));
obj.Set("detectedPitch", Napi::Number::New(env, result.detectedPitch));
obj.Set("writePos", Napi::Number::New(env, static_cast<double>(oscilloscope.getWritePos())));
return obj;
}
Napi::Value OscilloscopeGetWritePos(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), static_cast<double>(oscilloscope.getWritePos()));
}
Napi::Value OscilloscopeGetSamples(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
Napi::TypeError::New(env, "Expected startPos (float) and count").ThrowAsJavaScriptException();
return env.Null();
}
float startPos = info[0].As<Napi::Number>().FloatValue();
size_t count = static_cast<size_t>(info[1].As<Napi::Number>().Uint32Value());
Napi::Float32Array output = Napi::Float32Array::New(env, count);
oscilloscope.getSamplesInterpolated(output.Data(), startPos, count);
return output;
}
Napi::Value OscilloscopeReset(const Napi::CallbackInfo& info) {
oscilloscope.reset();
return info.Env().Undefined();
}
// ============== Spectrum ==============
Napi::Value SpectrumSetFFTSize(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected FFT size").ThrowAsJavaScriptException();
return env.Null();
}
spectrum.setFFTSize(info[0].As<Napi::Number>().Uint32Value());
return env.Undefined();
}
Napi::Value SpectrumGetFFTSize(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), spectrum.getFFTSize());
}
Napi::Value SpectrumSetSampleRate(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected sample rate").ThrowAsJavaScriptException();
return env.Null();
}
spectrum.setSampleRate(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value SpectrumSetSmoothing(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected smoothing value").ThrowAsJavaScriptException();
return env.Null();
}
spectrum.setSmoothing(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value SpectrumProcess(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 audioData = info[0].As<Napi::Float32Array>();
const auto& magnitudes = spectrum.process(audioData.Data(), audioData.ElementLength());
Napi::Float32Array result = Napi::Float32Array::New(env, magnitudes.size());
memcpy(result.Data(), magnitudes.data(), magnitudes.size() * sizeof(float));
return result;
}
Napi::Value SpectrumBinToFrequency(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected bin number").ThrowAsJavaScriptException();
return env.Null();
}
return Napi::Number::New(env, spectrum.binToFrequency(info[0].As<Napi::Number>().Int32Value()));
}
Napi::Value SpectrumReset(const Napi::CallbackInfo& info) {
spectrum.reset();
return info.Env().Undefined();
}
// ============== Vectorscope ==============
Napi::Value VectorscopeSetSampleRate(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected sample rate").ThrowAsJavaScriptException();
return env.Null();
}
vectorscope.setSampleRate(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value VectorscopePushSamples(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.pushSamples(leftData.Data(), rightData.Data(), length);
return env.Undefined();
}
Napi::Value VectorscopeGetPoints(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();
}
size_t maxPoints = static_cast<size_t>(info[0].As<Napi::Number>().Uint32Value());
Napi::Float32Array xArray = Napi::Float32Array::New(env, maxPoints);
Napi::Float32Array yArray = Napi::Float32Array::New(env, maxPoints);
size_t actual = vectorscope.getPoints(xArray.Data(), yArray.Data(), maxPoints);
Napi::Object result = Napi::Object::New(env);
if (actual < maxPoints) {
Napi::Float32Array xTrimmed = Napi::Float32Array::New(env, actual);
Napi::Float32Array yTrimmed = Napi::Float32Array::New(env, actual);
memcpy(xTrimmed.Data(), xArray.Data(), actual * sizeof(float));
memcpy(yTrimmed.Data(), yArray.Data(), actual * sizeof(float));
result.Set("x", xTrimmed);
result.Set("y", yTrimmed);
} else {
result.Set("x", xArray);
result.Set("y", yArray);
}
result.Set("count", Napi::Number::New(env, static_cast<double>(actual)));
return result;
}
Napi::Value VectorscopeSetBufferSize(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected buffer size").ThrowAsJavaScriptException();
return env.Null();
}
vectorscope.setBufferSize(info[0].As<Napi::Number>().Uint32Value());
return env.Undefined();
}
Napi::Value VectorscopeGetBufferSize(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), vectorscope.getBufferSize());
}
Napi::Value VectorscopeProcess(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());
const auto& points = vectorscope.process(leftData.Data(), rightData.Data(), length);
Napi::Float32Array xArray = Napi::Float32Array::New(env, points.size());
Napi::Float32Array yArray = Napi::Float32Array::New(env, points.size());
for (size_t i = 0; i < points.size(); i++) {
xArray[i] = points[i].x;
yArray[i] = points[i].y;
}
Napi::Object result = Napi::Object::New(env);
result.Set("x", xArray);
result.Set("y", yArray);
return result;
}
Napi::Value VectorscopeReset(const Napi::CallbackInfo& info) {
vectorscope.reset();
return info.Env().Undefined();
}
// ============== Module Init ==============
Napi::Object Init(Napi::Env env, Napi::Object exports) {
// Oscilloscope
Napi::Object oscExports = Napi::Object::New(env);
oscExports.Set("setSampleRate", Napi::Function::New(env, OscilloscopeSetSampleRate));
oscExports.Set("setPitchLock", Napi::Function::New(env, OscilloscopeSetPitchLock));
oscExports.Set("setDisplaySamples", Napi::Function::New(env, OscilloscopeSetDisplaySamples));
oscExports.Set("process", Napi::Function::New(env, OscilloscopeProcess));
oscExports.Set("pushSamples", Napi::Function::New(env, OscilloscopePushSamples));
oscExports.Set("processContinuous", Napi::Function::New(env, OscilloscopeProcessContinuous));
oscExports.Set("getWritePos", Napi::Function::New(env, OscilloscopeGetWritePos));
oscExports.Set("getSamples", Napi::Function::New(env, OscilloscopeGetSamples));
oscExports.Set("reset", Napi::Function::New(env, OscilloscopeReset));
exports.Set("oscilloscope", oscExports);
// Spectrum
Napi::Object specExports = Napi::Object::New(env);
specExports.Set("setFFTSize", Napi::Function::New(env, SpectrumSetFFTSize));
specExports.Set("getFFTSize", Napi::Function::New(env, SpectrumGetFFTSize));
specExports.Set("setSampleRate", Napi::Function::New(env, SpectrumSetSampleRate));
specExports.Set("setSmoothing", Napi::Function::New(env, SpectrumSetSmoothing));
specExports.Set("process", Napi::Function::New(env, SpectrumProcess));
specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency));
specExports.Set("reset", Napi::Function::New(env, SpectrumReset));
exports.Set("spectrum", specExports);
// Vectorscope
Napi::Object vecExports = Napi::Object::New(env);
vecExports.Set("setSampleRate", Napi::Function::New(env, VectorscopeSetSampleRate));
vecExports.Set("pushSamples", Napi::Function::New(env, VectorscopePushSamples));
vecExports.Set("getPoints", Napi::Function::New(env, VectorscopeGetPoints));
vecExports.Set("setBufferSize", Napi::Function::New(env, VectorscopeSetBufferSize));
vecExports.Set("getBufferSize", Napi::Function::New(env, VectorscopeGetBufferSize));
vecExports.Set("process", Napi::Function::New(env, VectorscopeProcess));
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
exports.Set("vectorscope", vecExports);
return exports;
}
NODE_API_MODULE(visualizer_dsp, Init)
+331
View File
@@ -0,0 +1,331 @@
#include "oscilloscope.h"
#include <algorithm>
#include <cmath>
namespace Visualizer {
Oscilloscope::Oscilloscope()
: sampleRate_(48000.0f)
, pitchLock_(true)
, displaySamples_(2048)
, writePos_(0)
, lastFilterPitch_(200.0f)
, lastTrigger_(0)
, smoothedPitch_(200.0f)
, pitchSamplesProcessed_(0) {
// Initialize circular buffers
circularBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
filteredBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
// Initialize FIR bandpass filter centered at 200Hz with 10% bandwidth (20Hz)
// Tight bandwidth removes harmonics, leaving only ONE rising zero crossing per period
bandpassFilter_.designBandpass(200.0f, 20.0f, sampleRate_, 60.0f);
// Initialize high shelf for pitch analysis (-3dB at 400Hz, Q=0.71)
// Reduces high frequency interference with pitch detection
pitchAnalysisShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
// Initialize analysis and render buffers
displayBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
visualBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
// Initialize display filters (high shelf + cascaded lowpass for steep rolloff)
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
displayLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
displayLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
// Initialize pitch detection lowpass (cascaded for steep slope)
pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
}
void Oscilloscope::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate;
// Redesign filter with new sample rate (10% bandwidth)
float bandwidth = lastFilterPitch_ * 0.1f;
bandpassFilter_.designBandpass(lastFilterPitch_, bandwidth, sampleRate_, 60.0f);
// Update high shelf for new sample rate
pitchAnalysisShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
// Update display filters
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
displayLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
displayLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
// Update pitch detection lowpass
pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
}
void Oscilloscope::setPitchLock(bool enabled) {
pitchLock_ = enabled;
}
void Oscilloscope::setDisplaySamples(int samples) {
displaySamples_ = std::clamp(samples, 64, static_cast<int>(OSCILLOSCOPE_BUFFER_SIZE - 1));
}
// Push samples into circular buffer (called from AudioWorklet)
void Oscilloscope::pushSamples(const float* samples, size_t count) {
for (size_t i = 0; i < count; i++) {
// Store raw sample
circularBuffer_[writePos_] = samples[i];
// Apply FIR bandpass filter and store filtered sample
// Linear-phase filter provides consistent zero crossings
filteredBuffer_[writePos_] = bandpassFilter_.process(samples[i]);
// Tracking path: cascaded lowpass only
float displaySample = displayLowpass1_.process(samples[i]);
displaySample = displayLowpass2_.process(displaySample);
displayBuffer_[writePos_] = displaySample;
// Visual path: high shelf on top of tracking sample
float visualSample = displayShelf_.process(displaySample);
visualBuffer_[writePos_] = visualSample;
writePos_ = (writePos_ + 1) % OSCILLOSCOPE_BUFFER_SIZE;
}
}
// Update filtered buffer from circular buffer (for backwards compatibility)
void Oscilloscope::updateFiltered() {
// This is called when using snapshot mode - filter is applied in pushSamples for continuous mode
}
// Find trigger by searching BACKWARDS from target position
// With tight bandpass filter (10% bandwidth), there's only ONE rising zero crossing per period
// So we simply take the FIRST valid crossing found - no phase tracking needed
float Oscilloscope::findTriggerBackwards(size_t target, size_t range) {
float periodSamples = sampleRate_ / smoothedPitch_;
// Search backwards from target to find FIRST rising zero crossing
for (size_t i = 0; i < range && i < OSCILLOSCOPE_BUFFER_SIZE; i++) {
size_t pos = (target + OSCILLOSCOPE_BUFFER_SIZE - i) % OSCILLOSCOPE_BUFFER_SIZE;
size_t prev = (pos + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE;
float prevVal = filteredBuffer_[prev];
float currVal = filteredBuffer_[pos];
// Rising zero crossing
if (prevVal < 0.0f && currVal >= 0.0f) {
// Check signal amplitude (look ahead ~1/4 period)
size_t lookAhead = std::clamp(
static_cast<size_t>(periodSamples / 4.0f),
static_cast<size_t>(4),
static_cast<size_t>(256)
);
float peakAfter = 0.0f;
for (size_t j = 0; j < lookAhead; j++) {
size_t checkPos = (pos + j) % OSCILLOSCOPE_BUFFER_SIZE;
float val = std::abs(filteredBuffer_[checkPos]);
if (val > peakAfter) peakAfter = val;
}
// Only accept if signal has significant amplitude
if (peakAfter > 0.01f) {
// Sub-sample interpolation for smooth rendering
float t = -prevVal / (currVal - prevVal);
return static_cast<float>(prev) + t;
}
}
}
return -1.0f; // No crossing found
}
// Process using circular buffer (continuous capture mode)
OscilloscopeResult Oscilloscope::process() {
OscilloscopeResult result;
result.triggerIndex = 0;
result.samplesToShow = displaySamples_;
result.detectedPitch = smoothedPitch_;
if (!pitchLock_) {
return result;
}
// Detect pitch from recent samples in circular buffer
// Use RAW buffer for pitch detection (filtered buffer may attenuate the fundamental)
// Use last 2048 samples for pitch detection
std::vector<float> recentSamples(2048);
for (size_t i = 0; i < 2048; i++) {
size_t idx = (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - 2048 + i) % OSCILLOSCOPE_BUFFER_SIZE;
recentSamples[i] = displayBuffer_[idx]; // Use RAW samples, not filtered
}
// Apply high shelf filter to reduce HF interference with pitch detection
pitchAnalysisShelf_.reset();
for (size_t i = 0; i < 2048; i++) {
recentSamples[i] = pitchAnalysisShelf_.process(recentSamples[i]);
}
// Apply cascaded lowpass for steep HF rejection
pitchLowpass1_.reset();
pitchLowpass2_.reset();
for (size_t i = 0; i < 2048; i++) {
recentSamples[i] = pitchLowpass1_.process(recentSamples[i]);
recentSamples[i] = pitchLowpass2_.process(recentSamples[i]);
}
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
if (newPitch > 0.0f) {
pitchSamplesProcessed_++;
// Adaptive smoothing: fast convergence initially, then conservative
// First ~20 frames: use 0.5/0.5 for quick lock-on
// After warmup: use 0.95/0.05 for stable tracking
float smoothingOld = (pitchSamplesProcessed_ < 20) ? 0.5f : 0.95f;
float smoothingNew = 1.0f - smoothingOld;
smoothedPitch_ = smoothedPitch_ * smoothingOld + newPitch * smoothingNew;
// Redesign FIR bandpass filter if pitch changed significantly (>10%)
// This keeps the filter centered on the fundamental for stable trigger
if (std::abs(smoothedPitch_ - lastFilterPitch_) / lastFilterPitch_ > 0.1f) {
float bandwidth = smoothedPitch_ * 0.1f; // 10% of center freq (tight = single zero crossing)
bandpassFilter_.designBandpass(smoothedPitch_, bandwidth, sampleRate_, 60.0f);
lastFilterPitch_ = smoothedPitch_;
}
}
result.detectedPitch = smoothedPitch_;
// Calculate target position for trigger search
// We search backwards from (writePos - displaySamples - firDelay) to find a rising zero crossing
float periodSamples = sampleRate_ / smoothedPitch_;
size_t samples = static_cast<size_t>(displaySamples_);
size_t firDelay = bandpassFilter_.getDelay();
// Target: look back from current write position by display window size AND FIR delay
// This ensures we're searching in the correct region where filtered data is valid
size_t target = (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples - firDelay) % OSCILLOSCOPE_BUFFER_SIZE;
// Search range: 4 periods for robust detection
size_t range = static_cast<size_t>(periodSamples * 4.0f);
// Find zero crossing by searching backwards from target
float zeroCross = findTriggerBackwards(target, range);
// LEFT-ANCHORED TRIGGER (MiniMeters style):
// The zero crossing IS the left edge of display
// Waveform starts at rising edge and extends rightward
if (zeroCross >= 0.0f) {
// Apply FIR filter delay compensation
// The filtered signal is delayed by order/2 samples relative to raw signal
size_t firDelay = bandpassFilter_.getDelay();
// The trigger index is where we start reading raw samples for display
// Compensate for filter delay so trigger aligns with raw audio
result.triggerIndex = zeroCross - static_cast<float>(firDelay);
// Wrap if negative
while (result.triggerIndex < 0) {
result.triggerIndex += OSCILLOSCOPE_BUFFER_SIZE;
}
} else {
// No crossing found - use target as fallback
result.triggerIndex = static_cast<float>(target);
}
return result;
}
// Legacy snapshot processing (for backwards compatibility)
OscilloscopeResult Oscilloscope::processSnapshot(const float* audioData, size_t length) {
OscilloscopeResult result;
result.triggerIndex = 0;
result.samplesToShow = std::min(displaySamples_, static_cast<int>(length));
result.detectedPitch = smoothedPitch_;
if (!pitchLock_ || length == 0) {
return result;
}
// Push samples to circular buffer
pushSamples(audioData, length);
// Use the new continuous process method
return process();
}
// Get samples from circular buffer starting at position (integer version)
// Returns filtered samples for display (high shelf + lowpass applied)
void Oscilloscope::getSamples(float* output, size_t startPos, size_t count) const {
for (size_t i = 0; i < count; i++) {
size_t idx = (startPos + i) % OSCILLOSCOPE_BUFFER_SIZE;
output[i] = visualBuffer_[idx]; // Visual-only filtered signal
}
}
// Get samples with sub-sample interpolation (float start position)
// Uses Catmull-Rom spline for smooth rendering at sub-pixel precision
// This preserves the high-precision trigger position from zero-crossing detection
void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t count) const {
for (size_t i = 0; i < count; i++) {
float pos = startPos + static_cast<float>(i);
// Wrap position to buffer bounds
while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE;
while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE;
size_t idx = static_cast<size_t>(pos) % OSCILLOSCOPE_BUFFER_SIZE;
float frac = pos - std::floor(pos);
if (frac < 0.0001f) {
// No interpolation needed - exact sample position
output[i] = visualBuffer_[idx];
} else {
// Cubic (Catmull-Rom) interpolation for smooth sub-sample rendering
// This eliminates pixel-level ghosting/jitter from truncated trigger positions
size_t i0 = (idx + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE;
size_t i1 = idx;
size_t i2 = (idx + 1) % OSCILLOSCOPE_BUFFER_SIZE;
size_t i3 = (idx + 2) % OSCILLOSCOPE_BUFFER_SIZE;
float y0 = visualBuffer_[i0];
float y1 = visualBuffer_[i1];
float y2 = visualBuffer_[i2];
float y3 = visualBuffer_[i3];
// Catmull-Rom spline coefficients
float t = frac;
float t2 = t * t;
float t3 = t2 * t;
output[i] = 0.5f * (
(2.0f * y1) +
(-y0 + y2) * t +
(2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * t2 +
(-y0 + 3.0f * y1 - 3.0f * y2 + y3) * t3
);
}
}
}
void Oscilloscope::reset() {
writePos_ = 0;
lastTrigger_ = 0.0f;
smoothedPitch_ = 200.0f;
lastFilterPitch_ = 200.0f;
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
// Redesign filter to default 200Hz (reset() only clears delay line, not coefficients)
bandpassFilter_.designBandpass(200.0f, 20.0f, sampleRate_, 60.0f);
pitchAnalysisShelf_.reset();
// Reset display and pitch detection filters
displayShelf_.reset();
displayLowpass1_.reset();
displayLowpass2_.reset();
pitchLowpass1_.reset();
pitchLowpass2_.reset();
// Clear buffers
std::fill(circularBuffer_.begin(), circularBuffer_.end(), 0.0f);
std::fill(filteredBuffer_.begin(), filteredBuffer_.end(), 0.0f);
std::fill(displayBuffer_.begin(), displayBuffer_.end(), 0.0f);
std::fill(visualBuffer_.begin(), visualBuffer_.end(), 0.0f);
}
} // namespace Visualizer
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "dsp_utils.h"
#include <vector>
#include <cstdint>
namespace Visualizer {
struct OscilloscopeResult {
float triggerIndex;
int samplesToShow;
float detectedPitch;
};
// Circular buffer size (same as pulse-visualizer)
constexpr size_t OSCILLOSCOPE_BUFFER_SIZE = 32768;
class Oscilloscope {
public:
Oscilloscope();
// Configuration
void setSampleRate(float sampleRate);
void setPitchLock(bool enabled);
void setDisplaySamples(int samples);
// Push samples into circular buffer (continuous capture)
void pushSamples(const float* samples, size_t count);
// Process and find trigger point (uses circular buffer)
OscilloscopeResult process();
// Legacy: Process snapshot (for backwards compatibility)
OscilloscopeResult processSnapshot(const float* audioData, size_t length);
// Get current write position
size_t getWritePos() const { return writePos_; }
// Get samples from circular buffer (for rendering)
void getSamples(float* output, size_t startPos, size_t count) const;
// Get samples with sub-sample interpolation (preserves trigger precision)
void getSamplesInterpolated(float* output, float startPos, size_t count) const;
// Reset state
void reset();
private:
float sampleRate_;
bool pitchLock_;
int displaySamples_;
// Circular buffer for continuous audio
std::vector<float> circularBuffer_;
std::vector<float> filteredBuffer_;
size_t writePos_;
// Linear-phase FIR bandpass filter for stable trigger detection
DSP::FIRFilter bandpassFilter_;
float lastFilterPitch_; // Track pitch for filter redesign
// High shelf filter to reduce HF before pitch detection
DSP::BiquadFilter pitchAnalysisShelf_;
// Display filtering (high shelf + steep lowpass)
DSP::BiquadFilter displayShelf_; // High shelf for display
DSP::BiquadFilter displayLowpass1_; // First stage of cascaded lowpass
DSP::BiquadFilter displayLowpass2_; // Second stage (4th order total = 24dB/oct)
std::vector<float> displayBuffer_; // Lowpass filtered samples for tracking
std::vector<float> visualBuffer_; // Visual-only samples (display shelf applied)
// Pitch detection lowpass (after existing high shelf)
DSP::BiquadFilter pitchLowpass1_; // First stage
DSP::BiquadFilter pitchLowpass2_; // Second stage
float lastTrigger_;
float smoothedPitch_;
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
// Internal helpers
void updateFiltered();
float findTriggerBackwards(size_t target, size_t range);
};
} // namespace Visualizer
+132
View File
@@ -0,0 +1,132 @@
#define _USE_MATH_DEFINES
#include "spectrum.h"
#include <cmath>
#include <algorithm>
#include <cstring>
namespace Visualizer {
Spectrum::Spectrum(size_t fftSize)
: fftSize_(fftSize)
, sampleRate_(44100.0f)
, smoothing_(0.9f)
, bufferedSamples_(0) {
fft_ = std::make_unique<DSP::FFT>(fftSize);
historyBuffer_.resize(fftSize, 0.0f);
windowedInput_.resize(fftSize);
magnitudes_.resize(fftSize / 2);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.resize(fftSize / 2, -100.0f);
}
void Spectrum::setFFTSize(size_t size) {
if (size != fftSize_) {
fftSize_ = size;
fft_ = std::make_unique<DSP::FFT>(size);
historyBuffer_.assign(size, 0.0f);
windowedInput_.resize(size);
magnitudes_.resize(size / 2);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.resize(size / 2, -100.0f);
bufferedSamples_ = 0;
}
}
void Spectrum::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate;
}
void Spectrum::setSmoothing(float smoothing) {
smoothing_ = std::clamp(smoothing, 0.0f, 0.99f);
}
void Spectrum::applyWindow(const float* input, float* output, size_t length) {
if (length <= 1) {
if (length == 1) {
output[0] = input[0];
}
return;
}
// Hann window
for (size_t i = 0; i < length; i++) {
float window = 0.5f * (1.0f - cosf(2.0f * M_PI * i / (length - 1)));
output[i] = input[i] * window;
}
}
void Spectrum::pushSamples(const float* input, size_t length) {
if (length == 0 || fftSize_ == 0) {
return;
}
// Keep only the most recent fftSize_ samples.
if (length >= fftSize_) {
std::memcpy(historyBuffer_.data(), input + (length - fftSize_), fftSize_ * sizeof(float));
bufferedSamples_ = fftSize_;
return;
}
const size_t keep = fftSize_ - length;
std::move(historyBuffer_.begin() + length, historyBuffer_.end(), historyBuffer_.begin());
std::memcpy(historyBuffer_.data() + keep, input, length * sizeof(float));
bufferedSamples_ = std::min(fftSize_, bufferedSamples_ + length);
}
const std::vector<float>& Spectrum::process(const float* audioData, size_t length) {
if (audioData != nullptr && length > 0) {
pushSamples(audioData, length);
}
if (historyBuffer_.empty() || magnitudes_.empty()) {
return smoothedMagnitudes_;
}
// Always analyze a full FFT frame from the rolling buffer.
applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_);
// Perform FFT
fft_->forward(windowedInput_.data(), magnitudes_.data());
// Convert to dB and apply smoothing
for (size_t i = 0; i < magnitudes_.size(); i++) {
float mag = magnitudes_[i];
// Convert to dB
// Add epsilon to avoid log(0)
float db = 20.0f * log10f(std::max(mag, 1e-10f));
// Compensate Hann window coherent gain (about -6 dB).
db += 6.0f;
// Clamp to a stable display range.
db = std::clamp(db, -120.0f, 12.0f);
if (bufferedSamples_ < fftSize_) {
smoothedMagnitudes_[i] = db;
continue;
}
// Apply temporal smoothing only (no bin-to-bin averaging).
smoothedMagnitudes_[i] = smoothing_ * smoothedMagnitudes_[i] + (1.0f - smoothing_) * db;
// Safety check
if (!std::isfinite(smoothedMagnitudes_[i])) {
smoothedMagnitudes_[i] = -100.0f;
}
}
return smoothedMagnitudes_;
}
float Spectrum::binToFrequency(int bin) const {
return bin * sampleRate_ / fftSize_;
}
void Spectrum::reset() {
std::fill(historyBuffer_.begin(), historyBuffer_.end(), 0.0f);
std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f);
bufferedSamples_ = 0;
}
} // namespace Visualizer
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "dsp_utils.h"
#include <vector>
#include <memory>
namespace Visualizer {
class Spectrum {
public:
explicit Spectrum(size_t fftSize = 2048);
// Configuration
void setFFTSize(size_t size);
size_t getFFTSize() const { return fftSize_; }
void setSampleRate(float sampleRate);
void setSmoothing(float smoothing); // 0.0 - 1.0
// Process audio and get spectrum data
// Returns magnitude data (size = fftSize / 2)
const std::vector<float>& process(const float* audioData, size_t length);
// Get frequency for a given bin
float binToFrequency(int bin) const;
// Reset state
void reset();
private:
size_t fftSize_;
float sampleRate_;
float smoothing_;
std::unique_ptr<DSP::FFT> fft_;
std::vector<float> historyBuffer_;
std::vector<float> windowedInput_;
std::vector<float> magnitudes_;
std::vector<float> smoothedMagnitudes_;
size_t bufferedSamples_;
void applyWindow(const float* input, float* output, size_t length);
void pushSamples(const float* input, size_t length);
};
} // namespace Visualizer
+110
View File
@@ -0,0 +1,110 @@
#include "vectorscope.h"
#include <algorithm>
#include <cmath>
namespace Visualizer {
Vectorscope::Vectorscope()
: sampleRate_(48000.0f)
, bufferSize_(1024)
, writePos_(0)
, validSamples_(0) {
leftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
rightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
points_.reserve(1024);
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
// Two stages per channel = 4th order = 24 dB/oct rolloff
// Removes HF noise that causes erratic Lissajous motion
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
}
void Vectorscope::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate;
// Redesign all filters with new sample rate
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
}
void Vectorscope::setBufferSize(size_t size) {
bufferSize_ = size;
points_.reserve(size);
}
void Vectorscope::pushSamples(
const float* leftChannel,
const float* rightChannel,
size_t length
) {
for (size_t i = 0; i < length; i++) {
// Apply cascaded lowpass filtering
float filteredL = leftLowpass1_.process(leftChannel[i]);
filteredL = leftLowpass2_.process(filteredL);
float filteredR = rightLowpass1_.process(rightChannel[i]);
filteredR = rightLowpass2_.process(filteredR);
leftBuffer_[writePos_] = filteredL;
rightBuffer_[writePos_] = filteredR;
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
validSamples_++;
}
}
}
size_t Vectorscope::getPoints(float* xOut, float* yOut, size_t maxPoints) const {
size_t count = std::min(maxPoints, validSamples_);
// Read the most recent `count` samples from the circular buffer
for (size_t i = 0; i < count; i++) {
size_t idx = (writePos_ + VECTORSCOPE_BUFFER_SIZE - count + i) % VECTORSCOPE_BUFFER_SIZE;
xOut[i] = rightBuffer_[idx]; // X = Right (standard Lissajous)
yOut[i] = leftBuffer_[idx]; // Y = Left
}
return count;
}
// Legacy process method (routes through new pipeline)
const std::vector<VectorscopePoint>& Vectorscope::process(
const float* leftChannel,
const float* rightChannel,
size_t length
) {
// Push through the filtering pipeline
pushSamples(leftChannel, rightChannel, length);
// Build legacy output from buffer
points_.clear();
size_t count = std::min(length, validSamples_);
for (size_t i = 0; i < count; i++) {
size_t idx = (writePos_ + VECTORSCOPE_BUFFER_SIZE - count + i) % VECTORSCOPE_BUFFER_SIZE;
VectorscopePoint p;
p.x = rightBuffer_[idx];
p.y = leftBuffer_[idx];
points_.push_back(p);
}
return points_;
}
void Vectorscope::reset() {
writePos_ = 0;
validSamples_ = 0;
std::fill(leftBuffer_.begin(), leftBuffer_.end(), 0.0f);
std::fill(rightBuffer_.begin(), rightBuffer_.end(), 0.0f);
leftLowpass1_.reset();
leftLowpass2_.reset();
rightLowpass1_.reset();
rightLowpass2_.reset();
points_.clear();
}
} // namespace Visualizer
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include "dsp_utils.h"
#include <vector>
#include <cstddef>
namespace Visualizer {
struct VectorscopePoint {
float x; // Right channel
float y; // Left channel
};
// Circular buffer size (~170ms at 48kHz)
constexpr size_t VECTORSCOPE_BUFFER_SIZE = 8192;
class Vectorscope {
public:
Vectorscope();
// Configuration
void setSampleRate(float sampleRate);
void setBufferSize(size_t size); // Legacy, kept for compat
size_t getBufferSize() const { return bufferSize_; }
// Push stereo samples into circular buffer (called per worklet chunk)
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
// Get the most recent N points for rendering (from circular buffer)
// Returns count of valid points written to output arrays
size_t getPoints(float* xOut, float* yOut, size_t maxPoints) const;
// Get number of valid samples in buffer
size_t getValidSamples() const { return validSamples_; }
// Legacy process (kept for backwards compatibility)
const std::vector<VectorscopePoint>& process(
const float* leftChannel,
const float* rightChannel,
size_t length
);
// Reset state
void reset();
private:
float sampleRate_;
size_t bufferSize_; // Legacy
size_t writePos_;
size_t validSamples_;
// Circular buffers for filtered L/R
std::vector<float> leftBuffer_;
std::vector<float> rightBuffer_;
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
DSP::BiquadFilter leftLowpass1_;
DSP::BiquadFilter leftLowpass2_;
DSP::BiquadFilter rightLowpass1_;
DSP::BiquadFilter rightLowpass2_;
// Legacy
std::vector<VectorscopePoint> points_;
};
} // namespace Visualizer
+7938
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
{
"name": "prism",
"version": "0.1.0",
"description": "Open-source audio metering and visualization tool",
"main": "./out/main/index.js",
"scripts": {
"dev": "env -u ELECTRON_RUN_AS_NODE electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"build:native": "cd native && node-gyp rebuild",
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
"postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'",
"dist": "npm run build && electron-builder --publish never",
"dist:mac": "npm run build && electron-builder --mac --publish never",
"dist:win": "npm run build && electron-builder --win --publish never",
"dist:linux": "npm run build && electron-builder --linux --publish never"
},
"repository": {
"type": "git",
"url": "https://github.com/Boof2015/prism"
},
"author": {
"name": "Boof2015",
"email": "contact@novaml.ai"
},
"license": "GPL-3.0-only",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"node-addon-api": "^8.5.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zustand": "^5.0.10"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"electron": "^40.0.0",
"electron-builder": "^26.0.0",
"electron-vite": "^5.0.0",
"node-gyp": "^10.3.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"build": {
"appId": "com.astra.prism",
"productName": "Prism",
"directories": {
"output": "dist"
},
"files": [
"out/**/*",
"native/build/Release/*.node"
],
"extraResources": [
{
"from": "native/build/Release/",
"to": "native/",
"filter": ["*.node"]
}
],
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.music",
"hardenedRuntime": true,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.inherit.plist"
},
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] },
{ "target": "portable", "arch": ["x64"] }
]
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Audio"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
import { join } from 'path'
let mainWindow: BrowserWindow | null = null
const WINDOW_DEFAULTS = {
width: 900,
height: 180,
minWidth: 400,
minHeight: 100,
}
function createWindow(): void {
mainWindow = new BrowserWindow({
...WINDOW_DEFAULTS,
frame: false,
transparent: false,
backgroundColor: '#000000',
alwaysOnTop: true,
autoHideMenuBar: true,
resizable: true,
maximizable: false,
fullscreenable: false,
title: 'Prism',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Load the renderer
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// Auto-grant media (microphone) permission for audio capture
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'screen') {
callback(true)
} else {
callback(false)
}
})
}
// IPC handlers
function setupIPC(): void {
ipcMain.on('window:minimize', () => {
mainWindow?.minimize()
})
ipcMain.on('window:close', () => {
mainWindow?.close()
})
ipcMain.on('window:toggle-always-on-top', () => {
if (!mainWindow) return
const current = mainWindow.isAlwaysOnTop()
mainWindow.setAlwaysOnTop(!current)
mainWindow.webContents.send('window:always-on-top-changed', !current)
})
ipcMain.handle('window:is-always-on-top', () => {
return mainWindow?.isAlwaysOnTop() ?? true
})
ipcMain.handle('audio:get-desktop-sources', async () => {
const sources = await desktopCapturer.getSources({ types: ['screen'] })
return sources.map((s) => ({ id: s.id, name: s.name }))
})
ipcMain.on('window:expand-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const [width, height] = mainWindow.getSize()
const [minW] = mainWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
mainWindow.setSize(width, height + panelHeight, true)
})
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const [width, height] = mainWindow.getSize()
const [minW] = mainWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height - panelHeight), true)
})
}
app.whenReady().then(() => {
setupPermissions()
setupIPC()
createWindow()
})
app.on('window-all-closed', () => {
app.quit()
})
+32
View File
@@ -0,0 +1,32 @@
import { contextBridge, ipcRenderer } from 'electron'
// Expose Electron API to renderer
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
minimize: () => ipcRenderer.send('window:minimize'),
close: () => ipcRenderer.send('window:close'),
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => {
const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop)
ipcRenderer.on('window:always-on-top-changed', handler)
return () => ipcRenderer.removeListener('window:always-on-top-changed', handler)
},
})
// Native DSP module — load if available, gracefully degrade if not
let visualizerDSP: unknown = null
try {
const isDev = process.env.NODE_ENV === 'development'
const modulePath = isDev
? require('path').join(__dirname, '../../native/build/Release/visualizer_dsp.node')
: require('path').join(process.resourcesPath!, 'native/visualizer_dsp.node')
visualizerDSP = require(modulePath)
} catch {
console.warn('Native DSP module not available — using JS fallback')
}
contextBridge.exposeInMainWorld('visualizerAPI', visualizerDSP)
+122
View File
@@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { useAudioStore } from './stores/audioStore'
import Strip from './components/Strip'
export default function App(): JSX.Element {
const {
devices,
selectedDeviceId,
captureMode,
isCapturing,
refreshDevices,
selectDevice,
setCaptureMode,
startCapture,
stopCapture,
} = useAudioStore()
// Enumerate devices on mount
useEffect(() => {
refreshDevices()
navigator.mediaDevices.addEventListener('devicechange', refreshDevices)
return () => navigator.mediaDevices.removeEventListener('devicechange', refreshDevices)
}, [refreshDevices])
const handleSourceChange = (e: React.ChangeEvent<HTMLSelectElement>): void => {
const value = e.target.value
if (value === '__system__') {
setCaptureMode('system')
} else {
selectDevice(value)
}
}
const handleToggleCapture = (): void => {
if (isCapturing) {
stopCapture()
} else {
startCapture()
}
}
return (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Scope strip — fills all available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<Strip />
</div>
{/* Temporary source picker bar — will be replaced by Toolbar + Settings in Phase 6 */}
<div
style={{
height: '32px',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 8px',
backgroundColor: 'var(--bg-secondary)',
borderTop: '1px solid var(--glass-border)',
flexShrink: 0,
}}
>
{/* Signal indicator */}
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: isCapturing ? '#22c55e' : '#71717a',
boxShadow: isCapturing ? '0 0 6px rgba(34, 197, 94, 0.4)' : 'none',
transition: 'all 150ms',
flexShrink: 0,
}}
/>
<select
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={handleSourceChange}
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
border: '1px solid var(--glass-border)',
borderRadius: '3px',
padding: '2px 6px',
fontSize: '10px',
fontFamily: 'Inter, sans-serif',
outline: 'none',
flex: 1,
minWidth: 0,
}}
>
<option value="__system__">System Audio</option>
<optgroup label="Audio Devices">
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
<button
onClick={handleToggleCapture}
style={{
backgroundColor: isCapturing ? '#dc2626' : 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: '3px',
padding: '2px 10px',
fontSize: '9px',
fontFamily: 'JetBrains Mono, monospace',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
flexShrink: 0,
}}
>
{isCapturing ? 'Stop' : 'Start'}
</button>
</div>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
/**
* AudioCapture — captures system audio output via Electron's desktopCapturer,
* with fallback to getUserMedia for virtual audio devices (BlackHole, etc).
* Feeds captured samples into AudioRouter for distribution to visualizers.
*/
import { audioRouter } from './AudioRouter'
export type CaptureMode = 'system' | 'device'
class AudioCapture {
private audioContext: AudioContext | null = null
private stream: MediaStream | null = null
private sourceNode: MediaStreamAudioSourceNode | null = null
private workletNode: AudioWorkletNode | null = null
private selectedDeviceId: string | null = null
private captureMode: CaptureMode = 'system'
/**
* Start capturing system audio output via desktopCapturer (ScreenCaptureKit on macOS 13+).
* This captures all system audio without needing BlackHole or any virtual device.
*/
async startSystemAudio(): Promise<void> {
this.stop()
// Get a screen source ID from the main process
const sources = await window.electronAPI.getDesktopSources()
if (!sources.length) {
throw new Error('No desktop sources available for system audio capture')
}
// Create AudioContext
this.audioContext = new AudioContext()
await this.audioContext.audioWorklet.addModule('./capture-worklet.js')
// Request system audio via desktop capturer — must include video (Chromium requirement),
// but we immediately discard the video track
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: 'desktop',
},
} as unknown as MediaTrackConstraints,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sources[0].id,
},
} as unknown as MediaTrackConstraints,
})
// Drop the video track immediately — we only need audio
this.stream.getVideoTracks().forEach((track) => track.stop())
this.wireUpStream()
this.captureMode = 'system'
}
/**
* Start capturing from a specific audio input device (e.g. BlackHole, microphone).
* Fallback for when system audio capture isn't available.
*/
async startDevice(deviceId?: string): Promise<void> {
this.stop()
const targetDeviceId = deviceId ?? this.selectedDeviceId
this.audioContext = new AudioContext()
await this.audioContext.audioWorklet.addModule('./capture-worklet.js')
const constraints: MediaStreamConstraints = {
audio: {
...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}),
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 2,
} as MediaTrackConstraints,
}
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
this.wireUpStream()
this.captureMode = 'device'
if (targetDeviceId) {
this.selectedDeviceId = targetDeviceId
}
}
/**
* Start capture — uses system audio by default, falls back to device capture.
*/
async start(deviceId?: string): Promise<void> {
if (deviceId) {
return this.startDevice(deviceId)
}
try {
await this.startSystemAudio()
} catch (err) {
console.warn('System audio capture failed, falling back to device capture:', err)
await this.startDevice(deviceId)
}
}
private wireUpStream(): void {
if (!this.audioContext || !this.stream) return
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream)
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: 2,
})
this.workletNode.port.onmessage = (event: MessageEvent<{ left: Float32Array; right: Float32Array }>) => {
audioRouter.ingestChunk(event.data.left, event.data.right)
}
this.sourceNode.connect(this.workletNode)
audioRouter.setSampleRate(this.audioContext.sampleRate)
audioRouter.setCapturing(true)
}
stop(): void {
audioRouter.setCapturing(false)
audioRouter.reset()
if (this.workletNode) {
this.workletNode.disconnect()
this.workletNode.port.onmessage = null
this.workletNode = null
}
if (this.sourceNode) {
this.sourceNode.disconnect()
this.sourceNode = null
}
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop())
this.stream = null
}
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
}
async listDevices(): Promise<MediaDeviceInfo[]> {
const devices = await navigator.mediaDevices.enumerateDevices()
return devices.filter((d) => d.kind === 'audioinput')
}
getSelectedDeviceId(): string | null {
return this.selectedDeviceId
}
setSelectedDeviceId(id: string | null): void {
this.selectedDeviceId = id
}
getCaptureMode(): CaptureMode {
return this.captureMode
}
getSampleRate(): number {
return this.audioContext?.sampleRate ?? 48000
}
}
export const audioCapture = new AudioCapture()
+165
View File
@@ -0,0 +1,165 @@
/**
* AudioRouter — distributes captured audio samples to per-scope pending buffers.
* Pattern extracted from Astra's AudioEngine (lines 196-202, 470-561, 2997-3043).
*/
const MAX_PENDING_CHUNKS = 20
const MAX_PENDING_SPECTRUM_CHUNKS = 96
const MAX_PENDING_VECTORSCOPE_CHUNKS = 20
class AudioRouter {
private pendingOscilloscopeSamples: Float32Array[] = []
private pendingSpectrumSamples: Float32Array[] = []
private pendingSpectrogramSamples: Float32Array[] = []
private pendingVectorscopeSamples: { left: Float32Array; right: Float32Array }[] = []
private pendingVUMeterSamples: { left: Float32Array; right: Float32Array }[] = []
private pendingLUFSMeterSamples: { left: Float32Array; right: Float32Array }[] = []
private pendingWaveformSamples: Float32Array[] = []
private _sampleRate = 48000
private _capturing = false
setSampleRate(rate: number): void {
this._sampleRate = rate
}
getSampleRate(): number {
return this._sampleRate
}
setCapturing(capturing: boolean): void {
this._capturing = capturing
}
isCapturing(): boolean {
return this._capturing
}
ingestChunk(left: Float32Array, right: Float32Array): void {
// Compute mono
const len = Math.min(left.length, right.length)
const mono = new Float32Array(len)
for (let i = 0; i < len; i++) {
mono[i] = (left[i] + right[i]) / 2
}
// Oscilloscope — uses left channel
if (this.pendingOscilloscopeSamples.length >= MAX_PENDING_CHUNKS) {
this.pendingOscilloscopeSamples = this.pendingOscilloscopeSamples.slice(
-Math.floor(MAX_PENDING_CHUNKS / 2)
)
}
this.pendingOscilloscopeSamples.push(new Float32Array(left))
// Spectrum — uses mono
if (this.pendingSpectrumSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
this.pendingSpectrumSamples = this.pendingSpectrumSamples.slice(
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
)
}
this.pendingSpectrumSamples.push(mono)
// Spectrogram — uses mono
if (this.pendingSpectrogramSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
this.pendingSpectrogramSamples = this.pendingSpectrogramSamples.slice(
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
)
}
this.pendingSpectrogramSamples.push(mono)
// Vectorscope — uses stereo
if (this.pendingVectorscopeSamples.length >= MAX_PENDING_VECTORSCOPE_CHUNKS) {
this.pendingVectorscopeSamples = this.pendingVectorscopeSamples.slice(
-Math.floor(MAX_PENDING_VECTORSCOPE_CHUNKS / 2)
)
}
this.pendingVectorscopeSamples.push({
left: new Float32Array(left),
right: new Float32Array(right),
})
// VU Meter — uses stereo
if (this.pendingVUMeterSamples.length >= MAX_PENDING_VECTORSCOPE_CHUNKS) {
this.pendingVUMeterSamples = this.pendingVUMeterSamples.slice(
-Math.floor(MAX_PENDING_VECTORSCOPE_CHUNKS / 2)
)
}
this.pendingVUMeterSamples.push({
left: new Float32Array(left),
right: new Float32Array(right),
})
// LUFS Meter — uses stereo
if (this.pendingLUFSMeterSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
this.pendingLUFSMeterSamples = this.pendingLUFSMeterSamples.slice(
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
)
}
this.pendingLUFSMeterSamples.push({
left: new Float32Array(left),
right: new Float32Array(right),
})
// Waveform — uses left channel
if (this.pendingWaveformSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
this.pendingWaveformSamples = this.pendingWaveformSamples.slice(
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
)
}
this.pendingWaveformSamples.push(new Float32Array(left))
}
flushPendingOscilloscopeSamples(): Float32Array[] {
const samples = this.pendingOscilloscopeSamples
this.pendingOscilloscopeSamples = []
return samples
}
flushPendingSpectrumSamples(): Float32Array[] {
const samples = this.pendingSpectrumSamples
this.pendingSpectrumSamples = []
return samples
}
flushPendingSpectrogramSamples(): Float32Array[] {
const samples = this.pendingSpectrogramSamples
this.pendingSpectrogramSamples = []
return samples
}
flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] {
const samples = this.pendingVectorscopeSamples
this.pendingVectorscopeSamples = []
return samples
}
flushPendingVUMeterSamples(): { left: Float32Array; right: Float32Array }[] {
const samples = this.pendingVUMeterSamples
this.pendingVUMeterSamples = []
return samples
}
flushPendingLUFSMeterSamples(): { left: Float32Array; right: Float32Array }[] {
const samples = this.pendingLUFSMeterSamples
this.pendingLUFSMeterSamples = []
return samples
}
flushPendingWaveformSamples(): Float32Array[] {
const samples = this.pendingWaveformSamples
this.pendingWaveformSamples = []
return samples
}
reset(): void {
this.pendingOscilloscopeSamples = []
this.pendingSpectrumSamples = []
this.pendingSpectrogramSamples = []
this.pendingVectorscopeSamples = []
this.pendingVUMeterSamples = []
this.pendingLUFSMeterSamples = []
this.pendingWaveformSamples = []
}
}
export const audioRouter = new AudioRouter()
+142
View File
@@ -0,0 +1,142 @@
// Native visualizer DSP module loader
// This loads the native C++ addon for high-performance audio visualization
import type { VisualizerDSP, OscilloscopeResult, VectorscopeResult, VectorscopePointsResult } from './visualizer-dsp'
let nativeModule: VisualizerDSP | null = null
let loadError: Error | null = null
// Try to load the native module
// Try to load the native module from the exposed API
if (typeof window !== 'undefined' && window.visualizerAPI) {
nativeModule = window.visualizerAPI
console.log('Native visualizer DSP module loaded via preload')
} else {
console.warn('Native visualizer DSP module not available (not found in window.visualizerAPI)')
console.warn('Falling back to JavaScript implementation')
loadError = new Error('Native module not found in window.visualizerAPI')
}
// Check if native module is available
export function isNativeAvailable(): boolean {
return nativeModule !== null
}
export function getNativeLoadError(): Error | null {
return loadError
}
// Circular buffer size (must match native code)
export const OSCILLOSCOPE_BUFFER_SIZE = 32768
// Export the native module functions with type safety
export const oscilloscope = {
setSampleRate: (sampleRate: number): void => {
nativeModule?.oscilloscope.setSampleRate(sampleRate)
},
setPitchLock: (enabled: boolean): void => {
nativeModule?.oscilloscope.setPitchLock(enabled)
},
setDisplaySamples: (samples: number): void => {
nativeModule?.oscilloscope.setDisplaySamples(samples)
},
// Push samples to circular buffer (for continuous capture)
pushSamples: (samples: Float32Array): void => {
nativeModule?.oscilloscope.pushSamples(samples)
},
// Process using circular buffer (continuous mode)
processContinuous: (): OscilloscopeResult | null => {
if (!nativeModule) return null
return nativeModule.oscilloscope.processContinuous()
},
// Legacy: process snapshot (pushes to buffer and processes)
process: (audioData: Float32Array): OscilloscopeResult | null => {
if (!nativeModule) return null
return nativeModule.oscilloscope.process(audioData)
},
// Get current write position
getWritePos: (): number => {
return nativeModule?.oscilloscope.getWritePos() ?? 0
},
// Get samples from circular buffer for rendering
getSamples: (startPos: number, count: number): Float32Array | null => {
if (!nativeModule) return null
return nativeModule.oscilloscope.getSamples(startPos, count)
},
reset: (): void => {
nativeModule?.oscilloscope.reset()
}
}
export const spectrum = {
setFFTSize: (size: number): void => {
nativeModule?.spectrum.setFFTSize(size)
},
getFFTSize: (): number => {
return nativeModule?.spectrum.getFFTSize() ?? 2048
},
setSampleRate: (sampleRate: number): void => {
nativeModule?.spectrum.setSampleRate(sampleRate)
},
setSmoothing: (smoothing: number): void => {
nativeModule?.spectrum.setSmoothing(smoothing)
},
process: (audioData: Float32Array): Float32Array | null => {
if (!nativeModule) return null
return nativeModule.spectrum.process(audioData)
},
binToFrequency: (bin: number): number => {
return nativeModule?.spectrum.binToFrequency(bin) ?? 0
},
reset: (): void => {
nativeModule?.spectrum.reset()
}
}
export const vectorscope = {
setSampleRate: (sampleRate: number): void => {
nativeModule?.vectorscope.setSampleRate(sampleRate)
},
pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
nativeModule?.vectorscope.pushSamples(leftChannel, rightChannel)
},
getPoints: (maxPoints: number): VectorscopePointsResult | null => {
if (!nativeModule) return null
return nativeModule.vectorscope.getPoints(maxPoints)
},
setBufferSize: (size: number): void => {
nativeModule?.vectorscope.setBufferSize(size)
},
getBufferSize: (): number => {
return nativeModule?.vectorscope.getBufferSize() ?? 1024
},
process: (leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult | null => {
if (!nativeModule) return null
return nativeModule.vectorscope.process(leftChannel, rightChannel)
},
reset: (): void => {
nativeModule?.vectorscope.reset()
}
}
export type { OscilloscopeResult, VectorscopeResult, VectorscopePointsResult }
@@ -0,0 +1,25 @@
export const BASE_DISPLAY_SAMPLES = 2048
export const BASE_RATE_MIN = 44100
export const BASE_RATE_MAX = 48000
export const MIN_DISPLAY_SAMPLES = 64
export const MAX_DISPLAY_SAMPLES = 32767
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function getNormalizedOscilloscopeDisplaySamples(sampleRate: number): number {
const safeSampleRate = Number.isFinite(sampleRate) && sampleRate > 0
? sampleRate
: BASE_RATE_MAX
let samples = BASE_DISPLAY_SAMPLES
if (safeSampleRate < BASE_RATE_MIN) {
samples = Math.round(BASE_DISPLAY_SAMPLES * (safeSampleRate / BASE_RATE_MIN))
} else if (safeSampleRate > BASE_RATE_MAX) {
samples = Math.round(BASE_DISPLAY_SAMPLES * (safeSampleRate / BASE_RATE_MAX))
}
return clamp(samples, MIN_DISPLAY_SAMPLES, MAX_DISPLAY_SAMPLES)
}
+74
View File
@@ -0,0 +1,74 @@
// Type definitions for visualizer_dsp native addon
export interface OscilloscopeResult {
triggerIndex: number; // float for sub-sample precision (position in circular buffer)
samplesToShow: number;
detectedPitch: number;
writePos: number; // current write position in circular buffer
}
export interface VectorscopeResult {
x: Float32Array;
y: Float32Array;
}
export interface VectorscopePointsResult {
x: Float32Array;
y: Float32Array;
count: number;
}
// Circular buffer size (must match native code)
export const OSCILLOSCOPE_BUFFER_SIZE = 32768;
export interface OscilloscopeModule {
setSampleRate(sampleRate: number): void;
setPitchLock(enabled: boolean): void;
setDisplaySamples(samples: number): void;
// Push samples to circular buffer (for continuous capture)
pushSamples(samples: Float32Array): void;
// Process using circular buffer (continuous mode)
processContinuous(): OscilloscopeResult;
// Legacy: process snapshot (pushes to buffer and processes)
process(audioData: Float32Array): OscilloscopeResult;
// Get current write position in circular buffer
getWritePos(): number;
// Get samples from circular buffer for rendering
getSamples(startPos: number, count: number): Float32Array;
reset(): void;
}
export interface SpectrumModule {
setFFTSize(size: number): void;
getFFTSize(): number;
setSampleRate(sampleRate: number): void;
setSmoothing(smoothing: number): void;
process(audioData: Float32Array): Float32Array;
binToFrequency(bin: number): number;
reset(): void;
}
export interface VectorscopeModule {
setSampleRate(sampleRate: number): void;
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
getPoints(maxPoints: number): VectorscopePointsResult;
setBufferSize(size: number): void;
getBufferSize(): number;
process(leftChannel: Float32Array, rightChannel: Float32Array): VectorscopeResult;
reset(): void;
}
export interface VisualizerDSP {
oscilloscope: OscilloscopeModule;
spectrum: SpectrumModule;
vectorscope: VectorscopeModule;
}
declare const visualizerDSP: VisualizerDSP;
export default visualizerDSP;
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope } from '../visualizers/Oscilloscope'
import { Vectorscope } from '../visualizers/Vectorscope'
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
}
type Visualizer = SpectrumAnalyzer | Oscilloscope | Vectorscope
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, lineColor: string): Visualizer | null {
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, { lineColor })
case 'oscilloscope':
return new Oscilloscope(canvas, { lineColor })
case 'vectorscope':
return new Vectorscope(canvas, { lineColor })
default:
console.warn(`Scope type "${scopeKind}" not yet implemented`)
return null
}
}
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null)
// Initialize and manage visualizer lifecycle
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const viz = createVisualizer(scopeKind, canvas, lineColor)
if (!viz) return
visualizerRef.current = viz
viz.start()
return () => {
viz.dispose()
visualizerRef.current = null
}
}, [scopeKind]) // Only recreate when scope type changes
// Update lineColor without recreating
useEffect(() => {
visualizerRef.current?.setOptions({ lineColor })
}, [lineColor])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const resizeCanvas = (): void => {
const rect = container.getBoundingClientRect()
const width = Math.max(1, Math.floor(rect.width))
const height = Math.max(1, Math.floor(rect.height))
const dpr = window.devicePixelRatio || 1
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
canvas.width = Math.max(1, Math.floor(width * dpr))
canvas.height = Math.max(1, Math.floor(height * dpr))
visualizerRef.current?.resize()
}
const observer = new ResizeObserver(resizeCanvas)
observer.observe(container)
resizeCanvas() // Initial size
return () => observer.disconnect()
}, [])
return (
<div
ref={containerRef}
style={{
flex: 1,
minWidth: 0,
height: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import ScopeModule from './ScopeModule'
export default function Strip(): JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
height: '100%',
backgroundColor: 'var(--bg-primary)',
}}
>
<ScopeModule scopeKind="spectrum" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="oscilloscope" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="vectorscope" lineColor="var(--accent, #38bdf8)" />
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
/// <reference types="vite/client" />
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
declare global {
interface Window {
visualizerAPI: VisualizerDSP | null
electronAPI: {
platform: string
minimize: () => void
close: () => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getDesktopSources: () => Promise<{ id: string; name: string }[]>
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/globals.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/jetbrains-mono/400.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+20
View File
@@ -0,0 +1,20 @@
class CaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0]
if (!input || input.length === 0) return true
const left = input[0]
if (!left || left.length === 0) return true
const right = input.length > 1 ? input[1] : left
this.port.postMessage({
left: left.slice(),
right: right.slice(),
})
return true
}
}
registerProcessor('capture-processor', CaptureProcessor)
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { audioCapture, type CaptureMode } from '../audio/AudioCapture'
interface AudioState {
devices: MediaDeviceInfo[]
selectedDeviceId: string | null
captureMode: CaptureMode
isCapturing: boolean
sampleRate: number
refreshDevices: () => Promise<void>
selectDevice: (deviceId: string) => Promise<void>
setCaptureMode: (mode: CaptureMode) => void
startCapture: () => Promise<void>
stopCapture: () => void
}
export const useAudioStore = create<AudioState>((set, get) => ({
devices: [],
selectedDeviceId: null,
captureMode: 'system',
isCapturing: false,
sampleRate: 48000,
refreshDevices: async () => {
const devices = await audioCapture.listDevices()
set({ devices })
},
selectDevice: async (deviceId: string) => {
set({ selectedDeviceId: deviceId, captureMode: 'device' })
audioCapture.setSelectedDeviceId(deviceId)
// If currently capturing, restart with new device
if (get().isCapturing) {
await get().startCapture()
}
},
setCaptureMode: (mode: CaptureMode) => {
set({ captureMode: mode })
},
startCapture: async () => {
try {
const { captureMode, selectedDeviceId } = get()
if (captureMode === 'system') {
await audioCapture.startSystemAudio()
} else {
await audioCapture.startDevice(selectedDeviceId ?? undefined)
}
set({
isCapturing: true,
sampleRate: audioCapture.getSampleRate(),
captureMode: audioCapture.getCaptureMode(),
})
} catch (err) {
console.error('Failed to start audio capture:', err)
set({ isCapturing: false })
}
},
stopCapture: () => {
audioCapture.stop()
set({ isCapturing: false })
},
}))
+54
View File
@@ -0,0 +1,54 @@
@import 'tailwindcss';
:root {
--bg-primary: #000000;
--bg-secondary: #050505;
--bg-tertiary: #0a0a0a;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-highlight: rgba(255, 255, 255, 0.045);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.6);
--text-tertiary: rgba(255, 255, 255, 0.4);
--accent: #38bdf8;
--accent-hover: #7dd3fc;
--accent-glow: rgba(56, 189, 248, 0.3);
--accent-rgb: 56, 189, 248;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+467
View File
@@ -0,0 +1,467 @@
import { audioRouter } from '../audio/AudioRouter'
import type { LUFSMeterMode } from '../../types/lufsmeter'
export interface LUFSMeterDataSource {
getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
getSampleRate: () => number
isPlaying: () => boolean
}
export interface LUFSMeterOptions {
mode?: LUFSMeterMode
lineColor?: string
dataSource?: LUFSMeterDataSource
}
type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource'>>
const defaultOptions: ResolvedLUFSMeterOptions = {
mode: 'bar',
lineColor: '#38bdf8',
}
const defaultLUFSMeterDataSource: LUFSMeterDataSource = {
getPendingLUFSMeterSamples: () => audioRouter.flushPendingLUFSMeterSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
}
// ---- Constants ----
const METER_MIN_LUFS = -60
const METER_MAX_LUFS = 0
const MOMENTARY_WINDOW_S = 0.4
const SHORT_TERM_WINDOW_S = 3.0
const INTEGRATED_BLOCK_S = 0.4
const INTEGRATED_HOP_S = 0.1
const ABSOLUTE_GATE_LUFS = -70
const RELATIVE_GATE_OFFSET = -10
const TARGET_LUFS = -14
const SMOOTHING = 0.7
// ---- K-weighting filter coefficients (ITU-R BS.1770) ----
interface BiquadCoeffs {
b0: number; b1: number; b2: number
a1: number; a2: number
}
// Pre-filter (high shelf) — 48kHz
const PRE_FILTER_48K: BiquadCoeffs = {
b0: 1.53512485958697, b1: -2.69169618940638, b2: 1.19839281085285,
a1: -1.69065929318241, a2: 0.73248077421585,
}
// RLB weighting (high pass) — 48kHz
const RLB_FILTER_48K: BiquadCoeffs = {
b0: 1.0, b1: -2.0, b2: 1.0,
a1: -1.99004745483398, a2: 0.99007225036621,
}
// Pre-filter — 44.1kHz
const PRE_FILTER_44K: BiquadCoeffs = {
b0: 1.5308412300498355, b1: -2.6509799951536985, b2: 1.1690790799210956,
a1: -1.6636551132560204, a2: 0.7125954280732254,
}
// RLB — 44.1kHz
const RLB_FILTER_44K: BiquadCoeffs = {
b0: 1.0, b1: -2.0, b2: 1.0,
a1: -1.9891696736297957, a2: 0.9891990357870394,
}
function getKWeightingCoeffs(sampleRate: number): { pre: BiquadCoeffs; rlb: BiquadCoeffs } {
if (Math.abs(sampleRate - 44100) < 100) {
return { pre: PRE_FILTER_44K, rlb: RLB_FILTER_44K }
}
// Default to 48kHz (also reasonable approximation for 96kHz, etc.)
return { pre: PRE_FILTER_48K, rlb: RLB_FILTER_48K }
}
// ---- Biquad filter state ----
interface BiquadState {
x1: number; x2: number
y1: number; y2: number
}
function createBiquadState(): BiquadState {
return { x1: 0, x2: 0, y1: 0, y2: 0 }
}
function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): number {
const output = coeffs.b0 * input + coeffs.b1 * state.x1 + coeffs.b2 * state.x2
- coeffs.a1 * state.y1 - coeffs.a2 * state.y2
state.x2 = state.x1
state.x1 = input
state.y2 = state.y1
state.y1 = output
return output
}
// ---- Color utilities ----
function parseHexColor(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16) || 56,
parseInt(h.substring(2, 4), 16) || 189,
parseInt(h.substring(4, 6), 16) || 248,
]
}
// ---- LUFS Meter class ----
export class LUFSMeter {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: ResolvedLUFSMeterOptions
private dataSource: LUFSMeterDataSource
private animationId: number | null = null
private isRunning = false
// K-weighting filter state (per channel, two stages)
private preFilterL = createBiquadState()
private preFilterR = createBiquadState()
private rlbFilterL = createBiquadState()
private rlbFilterR = createBiquadState()
private currentSampleRate = 48000
private kWeightingCoeffs = getKWeightingCoeffs(48000)
// Ring buffer for K-weighted squared samples (sized for SHORT_TERM_WINDOW_S)
private ringBufferL = new Float32Array(0)
private ringBufferR = new Float32Array(0)
private ringBufferPos = 0
private ringBufferFilled = 0 // how many samples have been written total (capped at buffer size)
// Integrated loudness: accumulate 400ms block mean-squares with 100ms hop
private integratedBlockSumL = 0
private integratedBlockSumR = 0
private integratedBlockSamples = 0
private integratedHopCounter = 0
private integratedBlockLoudness: number[] = [] // LUFS per block
// Smoothed display values
private momentaryLUFS = METER_MIN_LUFS
private shortTermLUFS = METER_MIN_LUFS
private integratedLUFS = METER_MIN_LUFS
constructor(canvas: HTMLCanvasElement, options: LUFSMeterOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultLUFSMeterDataSource
this.initRingBuffer(this.dataSource.getSampleRate())
}
private initRingBuffer(sampleRate: number): void {
this.currentSampleRate = Math.max(1, sampleRate)
this.kWeightingCoeffs = getKWeightingCoeffs(this.currentSampleRate)
const bufferSize = Math.ceil(this.currentSampleRate * SHORT_TERM_WINDOW_S)
this.ringBufferL = new Float32Array(bufferSize)
this.ringBufferR = new Float32Array(bufferSize)
this.ringBufferPos = 0
this.ringBufferFilled = 0
}
private resetMeters(): void {
this.momentaryLUFS = METER_MIN_LUFS
this.shortTermLUFS = METER_MIN_LUFS
this.integratedLUFS = METER_MIN_LUFS
this.ringBufferL.fill(0)
this.ringBufferR.fill(0)
this.ringBufferPos = 0
this.ringBufferFilled = 0
this.integratedBlockSumL = 0
this.integratedBlockSumR = 0
this.integratedBlockSamples = 0
this.integratedHopCounter = 0
this.integratedBlockLoudness = []
this.preFilterL = createBiquadState()
this.preFilterR = createBiquadState()
this.rlbFilterL = createBiquadState()
this.rlbFilterR = createBiquadState()
}
setOptions(options: Partial<LUFSMeterOptions>): void {
const { dataSource, ...optionUpdates } = options
this.options = { ...this.options, ...optionUpdates }
if (dataSource) {
this.dataSource = dataSource
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
// Canvas resize handled externally
}
private processAudio(): void {
const chunks = this.dataSource.getPendingLUFSMeterSamples()
// Check if sample rate changed
const sr = this.dataSource.getSampleRate()
if (Math.abs(sr - this.currentSampleRate) > 100) {
this.initRingBuffer(sr)
this.resetMeters()
}
const playing = this.dataSource.isPlaying()
if (!playing && chunks.length === 0) {
// Decay toward silence only when truly stopped
this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
return
}
// Process any new audio chunks into the ring buffer
if (chunks.length > 0) {
const { pre, rlb } = this.kWeightingCoeffs
const bufLen = this.ringBufferL.length
const hopSamples = Math.round(this.currentSampleRate * INTEGRATED_HOP_S)
const blockSamples = Math.round(this.currentSampleRate * INTEGRATED_BLOCK_S)
for (const chunk of chunks) {
const len = Math.min(chunk.left.length, chunk.right.length)
for (let i = 0; i < len; i++) {
// Apply K-weighting: pre-filter then RLB, per channel
const kwL = applyBiquad(rlb, this.rlbFilterL, applyBiquad(pre, this.preFilterL, chunk.left[i]))
const kwR = applyBiquad(rlb, this.rlbFilterR, applyBiquad(pre, this.preFilterR, chunk.right[i]))
// Store squared K-weighted samples in ring buffer
const sqL = kwL * kwL
const sqR = kwR * kwR
this.ringBufferL[this.ringBufferPos] = sqL
this.ringBufferR[this.ringBufferPos] = sqR
this.ringBufferPos = (this.ringBufferPos + 1) % bufLen
if (this.ringBufferFilled < bufLen) this.ringBufferFilled++
// Accumulate for integrated measurement
this.integratedBlockSumL += sqL
this.integratedBlockSumR += sqR
this.integratedBlockSamples++
this.integratedHopCounter++
// Every hop interval, store a block loudness value
if (this.integratedHopCounter >= hopSamples && this.integratedBlockSamples >= blockSamples) {
const meanSqL = this.integratedBlockSumL / this.integratedBlockSamples
const meanSqR = this.integratedBlockSumR / this.integratedBlockSamples
const blockLUFS = -0.691 + 10 * Math.log10(Math.max(meanSqL + meanSqR, 1e-10))
this.integratedBlockLoudness.push(blockLUFS)
// Slide the block window: remove oldest hop worth of samples
// Approximate by keeping a running sum and subtracting the hop fraction
const hopFraction = hopSamples / this.integratedBlockSamples
this.integratedBlockSumL *= (1 - hopFraction)
this.integratedBlockSumR *= (1 - hopFraction)
this.integratedBlockSamples = Math.round(this.integratedBlockSamples * (1 - hopFraction))
this.integratedHopCounter = 0
}
}
}
}
// Always compute M/S from the ring buffer (it persists across frames)
const bufLen = this.ringBufferL.length
// Compute momentary loudness (last 400ms)
const momentarySamples = Math.min(
Math.round(this.currentSampleRate * MOMENTARY_WINDOW_S),
this.ringBufferFilled
)
if (momentarySamples > 0) {
let sumL = 0, sumR = 0
for (let i = 0; i < momentarySamples; i++) {
const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen
sumL += this.ringBufferL[idx]
sumR += this.ringBufferR[idx]
}
const rawM = -0.691 + 10 * Math.log10(Math.max(sumL / momentarySamples + sumR / momentarySamples, 1e-10))
this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + Math.max(METER_MIN_LUFS, rawM) * (1 - SMOOTHING)
}
// Compute short-term loudness (last 3s)
const shortTermSamples = Math.min(
Math.round(this.currentSampleRate * SHORT_TERM_WINDOW_S),
this.ringBufferFilled
)
if (shortTermSamples > 0) {
let sumL = 0, sumR = 0
for (let i = 0; i < shortTermSamples; i++) {
const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen
sumL += this.ringBufferL[idx]
sumR += this.ringBufferR[idx]
}
const rawS = -0.691 + 10 * Math.log10(Math.max(sumL / shortTermSamples + sumR / shortTermSamples, 1e-10))
this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + Math.max(METER_MIN_LUFS, rawS) * (1 - SMOOTHING)
}
// Compute integrated loudness with gating
this.integratedLUFS = this.computeGatedIntegratedLoudness()
}
private computeGatedIntegratedLoudness(): number {
const blocks = this.integratedBlockLoudness
if (blocks.length === 0) return METER_MIN_LUFS
// Absolute gate: remove blocks below -70 LUFS
const afterAbsolute = blocks.filter(l => l > ABSOLUTE_GATE_LUFS)
if (afterAbsolute.length === 0) return METER_MIN_LUFS
// Compute mean of blocks passing absolute gate
let sum = 0
for (const l of afterAbsolute) sum += Math.pow(10, l / 10)
const ungatedMean = 10 * Math.log10(sum / afterAbsolute.length)
// Relative gate: remove blocks below (ungatedMean - 10) LUFS
const relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET
const afterRelative = afterAbsolute.filter(l => l > relativeThreshold)
if (afterRelative.length === 0) return METER_MIN_LUFS
// Final integrated loudness
let finalSum = 0
for (const l of afterRelative) finalSum += Math.pow(10, l / 10)
return Math.max(METER_MIN_LUFS, 10 * Math.log10(finalSum / afterRelative.length))
}
private draw = (): void => {
if (!this.isRunning) return
this.processAudio()
const { canvas, ctx } = this
const width = canvas.width
const height = canvas.height
if (width <= 0 || height <= 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
ctx.clearRect(0, 0, width, height)
this.drawBars(width, height)
this.animationId = requestAnimationFrame(this.draw)
}
private drawBars(width: number, height: number): void {
const ctx = this.ctx
const [tintR, tintG, tintB] = parseHexColor(this.options.lineColor)
const dpr = window.devicePixelRatio || 1
const padding = Math.round(8 * dpr)
const labelHeight = Math.round(20 * dpr)
const readoutHeight = Math.round(18 * dpr)
const scaleWidth = Math.round(32 * dpr)
const barAreaTop = padding + labelHeight
const barAreaBottom = height - padding - readoutHeight
const barAreaHeight = Math.max(1, barAreaBottom - barAreaTop)
const barAreaWidth = width - scaleWidth - padding
const barCount = 3
const barGap = Math.round(4 * dpr)
const totalGaps = (barCount - 1) * barGap
const barWidth = Math.max(4, Math.floor((barAreaWidth - totalGaps) / barCount))
const values = [this.momentaryLUFS, this.shortTermLUFS, this.integratedLUFS]
const labels = ['M', 'S', 'I']
const dbRange = METER_MAX_LUFS - METER_MIN_LUFS
const fontSize = Math.min(Math.round(13 * dpr), Math.max(Math.round(9 * dpr), Math.round(barWidth * 0.4)))
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
for (let i = 0; i < barCount; i++) {
const x = scaleWidth + i * (barWidth + barGap)
const lufs = values[i]
const normalized = Math.max(0, Math.min(1, (lufs - METER_MIN_LUFS) / dbRange))
const barH = Math.round(normalized * barAreaHeight)
// Bar label
ctx.font = `600 ${fontSize}px "Inter", system-ui, sans-serif`
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.7)`
ctx.fillText(labels[i], x + barWidth / 2, padding)
// Bar background
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.08)`
ctx.fillRect(x, barAreaTop, barWidth, barAreaHeight)
// Bar fill — gradient from dim at bottom to bright at top
if (barH > 0) {
const gradient = ctx.createLinearGradient(0, barAreaBottom, 0, barAreaBottom - barH)
gradient.addColorStop(0, `rgba(${tintR}, ${tintG}, ${tintB}, 0.3)`)
gradient.addColorStop(0.5, `rgba(${tintR}, ${tintG}, ${tintB}, 0.6)`)
gradient.addColorStop(1, `rgba(${tintR}, ${tintG}, ${tintB}, 0.9)`)
ctx.fillStyle = gradient
ctx.fillRect(x, barAreaBottom - barH, barWidth, barH)
}
// Bright cap line at top of bar
if (barH > 1) {
ctx.fillStyle = `rgb(${tintR}, ${tintG}, ${tintB})`
ctx.fillRect(x, barAreaBottom - barH, barWidth, Math.max(1, Math.round(2 * dpr)))
}
// LUFS readout below bar
const displayLufs = lufs <= METER_MIN_LUFS + 1 ? '-∞' : lufs.toFixed(1)
ctx.font = `500 ${Math.max(Math.round(8 * dpr), fontSize - Math.round(2 * dpr))}px "JetBrains Mono", "SF Mono", monospace`
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.8)`
ctx.fillText(displayLufs, x + barWidth / 2, barAreaBottom + Math.round(4 * dpr))
}
// Target reference line (-14 LUFS)
const targetNorm = Math.max(0, Math.min(1, (TARGET_LUFS - METER_MIN_LUFS) / dbRange))
const targetY = Math.round(barAreaBottom - targetNorm * barAreaHeight)
ctx.strokeStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.25)`
ctx.lineWidth = Math.max(1, dpr)
ctx.setLineDash([Math.round(4 * dpr), Math.round(3 * dpr)])
ctx.beginPath()
ctx.moveTo(scaleWidth, targetY)
ctx.lineTo(scaleWidth + barCount * barWidth + (barCount - 1) * barGap, targetY)
ctx.stroke()
ctx.setLineDash([])
// Scale markings on left
const scaleFont = Math.max(Math.round(7 * dpr), Math.round(9 * dpr))
ctx.font = `400 ${scaleFont}px "JetBrains Mono", "SF Mono", monospace`
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.35)`
const tickValues = [-60, -48, -36, -24, -18, -14, -9, -6, -3, 0]
for (const tick of tickValues) {
const norm = (tick - METER_MIN_LUFS) / dbRange
if (norm < 0 || norm > 1) continue
const y = Math.round(barAreaBottom - norm * barAreaHeight)
ctx.fillText(`${tick}`, scaleWidth - Math.round(4 * dpr), y)
// Tick mark
ctx.fillRect(scaleWidth - Math.round(3 * dpr), y, Math.round(2 * dpr), Math.max(1, dpr))
}
}
dispose(): void {
this.stop()
}
}
+344
View File
@@ -0,0 +1,344 @@
import { audioRouter } from '../audio/AudioRouter'
import {
oscilloscope as nativeOscilloscope,
OSCILLOSCOPE_BUFFER_SIZE,
isNativeAvailable
} from '../audio/native'
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
export interface OscilloscopeOptions {
lineColor?: string
lineWidth?: number
backgroundColor?: string
showGrid?: boolean
gridColor?: string
pitchLock?: boolean
underfillEnabled?: boolean
}
const defaultOptions: Required<OscilloscopeOptions> = {
lineColor: '#00ffff',
lineWidth: 2,
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
pitchLock: true,
underfillEnabled: false
}
function parseRgbChannels(color: string): string | null {
const normalized = color.trim()
if (normalized.startsWith('#')) {
const hex = normalized.slice(1)
const expanded = hex.length === 3
? hex.split('').map((ch) => `${ch}${ch}`).join('')
: hex
if (expanded.length === 6) {
const r = Number.parseInt(expanded.slice(0, 2), 16)
const g = Number.parseInt(expanded.slice(2, 4), 16)
const b = Number.parseInt(expanded.slice(4, 6), 16)
if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
return `${r}, ${g}, ${b}`
}
}
}
const rgbMatch = /^rgba?\((.*)\)$/i.exec(normalized)
if (!rgbMatch) return null
const tokens = rgbMatch[1]
?.split(',')
.map((token) => token.trim())
.filter(Boolean) ?? []
if (tokens.length < 3) return null
const r = Number.parseFloat(tokens[0])
const g = Number.parseFloat(tokens[1])
const b = Number.parseFloat(tokens[2])
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) return null
return `${Math.max(0, Math.min(255, Math.round(r)))}, ${Math.max(0, Math.min(255, Math.round(g)))}, ${Math.max(0, Math.min(255, Math.round(b)))}`
}
function highContrastUnderfillColor(accentColor: string, alpha: number): string {
const safeAlpha = Math.max(0, Math.min(1, alpha))
const channels = parseRgbChannels(accentColor)
const nearWhite = { r: 245, g: 248, b: 252 }
const tintAmount = 0.18
if (!channels) {
return `rgba(${nearWhite.r}, ${nearWhite.g}, ${nearWhite.b}, ${safeAlpha})`
}
const [accentR, accentG, accentB] = channels
.split(',')
.map((token) => Number.parseFloat(token.trim()))
if (!Number.isFinite(accentR) || !Number.isFinite(accentG) || !Number.isFinite(accentB)) {
return `rgba(${nearWhite.r}, ${nearWhite.g}, ${nearWhite.b}, ${safeAlpha})`
}
const mix = (base: number, tint: number): number => Math.round((base * (1 - tintAmount)) + (tint * tintAmount))
const r = mix(nearWhite.r, accentR)
const g = mix(nearWhite.g, accentG)
const b = mix(nearWhite.b, accentB)
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`
}
export class Oscilloscope {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: Required<OscilloscopeOptions>
private animationId: number | null = null
private isRunning: boolean = false
private nativeInitialized: boolean = false
private samplesReceived: number = 0
private lastSampleRate: number = 0
private static readonly WARMUP_SAMPLES = 4096 // Need ~4K samples before pitch detection is reliable
constructor(canvas: HTMLCanvasElement, options: OscilloscopeOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
this.options = { ...defaultOptions, ...options }
// Initialize native module
this.initNative()
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
// Get actual sample rate from AudioEngine (defaults to 48000 if context not ready)
const sampleRate = audioRouter.getSampleRate()
this.lastSampleRate = sampleRate
nativeOscilloscope.setSampleRate(sampleRate)
nativeOscilloscope.setPitchLock(this.options.pitchLock)
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
// Note: Filter is now pitch-adaptive FIR bandpass (auto-configured in native code)
this.nativeInitialized = true
console.log(`Oscilloscope: Using native DSP with AudioWorklet (${sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
console.error('Oscilloscope: Native DSP not available!')
}
}
// Update sample rate if AudioContext changes (called from draw loop)
private updateSampleRateIfNeeded(): void {
if (!isNativeAvailable()) return
const currentRate = audioRouter.getSampleRate()
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.lastSampleRate = currentRate
nativeOscilloscope.setSampleRate(currentRate)
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(currentRate))
console.log(`Oscilloscope: Sample rate updated to ${currentRate}Hz`)
}
}
setOptions(options: Partial<OscilloscopeOptions>): void {
this.options = { ...this.options, ...options }
// Update native module settings
if (isNativeAvailable() && options.pitchLock !== undefined) {
nativeOscilloscope.setPitchLock(options.pitchLock)
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void { }
private draw = (): void => {
if (!this.isRunning) return
const { canvas, ctx, options } = this
const width = canvas.width
const height = canvas.height
const dpr = window.devicePixelRatio || 1
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
if (options.showGrid) {
this.drawGrid()
}
// Native C++ is being fed continuously by AudioWorklet via AudioEngine
if (!isNativeAvailable()) {
console.error('Oscilloscope: Native DSP required')
this.animationId = requestAnimationFrame(this.draw)
return
}
// Check if sample rate needs updating (AudioContext may have initialized after us)
this.updateSampleRateIfNeeded()
// Flush ALL pending samples to native C++ (prevents sample loss)
const pendingSamples = audioRouter.flushPendingOscilloscopeSamples()
for (const chunk of pendingSamples) {
nativeOscilloscope.pushSamples(chunk)
this.samplesReceived += chunk.length
}
// Skip pitch-locked processing during warmup period.
// Bypass mode (pitchLock=false) should render immediately using a moving window.
if (options.pitchLock && this.samplesReceived < Oscilloscope.WARMUP_SAMPLES) {
// During warmup, just show a static waveform or grid
this.animationId = requestAnimationFrame(this.draw)
return
}
// Process using circular buffer - searches backwards from writePos
const result = nativeOscilloscope.processContinuous()
if (!result) {
this.animationId = requestAnimationFrame(this.draw)
return
}
const samplesToShow = result.samplesToShow
let triggerIndex = result.triggerIndex
// In bypass mode, ignore trigger locking and follow the live write head.
// This produces free-running oscilloscope motion without touching pitch-lock behavior.
if (!options.pitchLock) {
const writePos = result.writePos
triggerIndex = writePos - samplesToShow
while (triggerIndex < 0) triggerIndex += OSCILLOSCOPE_BUFFER_SIZE
}
// Get samples from circular buffer for rendering
const renderData = nativeOscilloscope.getSamples(Math.floor(triggerIndex), samplesToShow)
if (!renderData || renderData.length === 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
// Draw waveform (data already starts at trigger point)
const sliceWidth = width / samplesToShow
const centerY = height / 2
const VISUAL_GAIN = 1.8
const points: Array<{ x: number; y: number }> = []
for (let i = 0; i < samplesToShow && i < renderData.length; i++) {
const sample = renderData[i]
const y = ((1 - sample * VISUAL_GAIN) / 2) * height
const x = i * sliceWidth
points.push({ x, y })
}
if (points.length < 2) {
this.animationId = requestAnimationFrame(this.draw)
return
}
if (options.underfillEnabled) {
ctx.beginPath()
ctx.moveTo(points[0].x, centerY)
for (const point of points) {
ctx.lineTo(point.x, point.y)
}
ctx.lineTo(points[points.length - 1].x, centerY)
ctx.closePath()
const peakAlpha = 0.28
const shoulderAlpha = peakAlpha * 0.74
const centerlineAlpha = 0.09
const fillGradient = ctx.createLinearGradient(0, 0, 0, height)
fillGradient.addColorStop(0, highContrastUnderfillColor(options.lineColor, peakAlpha))
fillGradient.addColorStop(0.44, highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(0.48, highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.5, highContrastUnderfillColor(options.lineColor, centerlineAlpha))
fillGradient.addColorStop(0.52, highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.56, highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(1, highContrastUnderfillColor(options.lineColor, peakAlpha))
ctx.fillStyle = fillGradient
ctx.fill()
}
ctx.lineWidth = options.lineWidth * dpr
ctx.strokeStyle = options.lineColor
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.stroke()
this.animationId = requestAnimationFrame(this.draw)
}
private drawGrid(): void {
const { ctx, canvas, options } = this
const width = canvas.width
const height = canvas.height
const dpr = window.devicePixelRatio || 1
ctx.strokeStyle = options.gridColor
ctx.lineWidth = dpr
ctx.beginPath()
ctx.moveTo(0, height / 2)
ctx.lineTo(width, height / 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(width / 2, 0)
ctx.lineTo(width / 2, height)
ctx.stroke()
ctx.strokeStyle = options.gridColor.replace('0.1', '0.05')
for (let i = 1; i < 4; i++) {
if (i === 2) continue
ctx.beginPath()
ctx.moveTo(0, (height / 4) * i)
ctx.lineTo(width, (height / 4) * i)
ctx.stroke()
ctx.beginPath()
ctx.moveTo((width / 4) * i, 0)
ctx.lineTo((width / 4) * i, height)
ctx.stroke()
}
}
// Reset state for new track (call on track change to re-enable fast pitch convergence)
reset(): void {
// Reset JS warmup state
this.samplesReceived = 0
// Reset native state (clears buffers, resets pitch tracking, re-enables fast smoothing)
if (isNativeAvailable()) {
nativeOscilloscope.reset()
}
}
dispose(): void {
this.stop()
// Reset native module state
if (isNativeAvailable()) {
nativeOscilloscope.reset()
}
// Reset warmup state
this.samplesReceived = 0
this.lastSampleRate = 0
}
}
+691
View File
@@ -0,0 +1,691 @@
import { audioRouter } from '../audio/AudioRouter'
import {
DEFAULT_SPECTROGRAM_CLARITY_MODE,
DEFAULT_SPECTROGRAM_SCALE_MODE,
DEFAULT_SPECTROGRAM_SCROLL_SPEED,
clampSpectrogramScrollSpeed,
isSpectrogramClarityMode,
isSpectrogramScaleMode,
type SpectrogramClarityMode,
type SpectrogramScaleMode,
} from '../../types/spectrogram'
export interface SpectrogramDataSource {
getPendingSpectrogramSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface SpectrogramOptions {
fftSize?: number
minFrequency?: number
maxFrequency?: number
minDecibels?: number
maxDecibels?: number
scrollSpeed?: number
clarityMode?: SpectrogramClarityMode
scaleMode?: SpectrogramScaleMode
colorScheme?: 'heat' | 'mono'
lineColor?: string
dataSource?: SpectrogramDataSource
}
type ResolvedSpectrogramOptions = Required<Omit<SpectrogramOptions, 'dataSource'>>
interface SpectrogramClarityProfile {
gamma: number // contrast curve exponent
sharpness: number // local peak suppression exponent (0 = off, higher = thinner lines)
tiltDb: number // dB/octave frequency compensation
}
const defaultOptions: ResolvedSpectrogramOptions = {
fftSize: 4096,
minFrequency: 20,
maxFrequency: 20000,
minDecibels: -90,
maxDecibels: -12,
scrollSpeed: DEFAULT_SPECTROGRAM_SCROLL_SPEED,
clarityMode: DEFAULT_SPECTROGRAM_CLARITY_MODE,
scaleMode: DEFAULT_SPECTROGRAM_SCALE_MODE,
colorScheme: 'heat',
lineColor: '#38bdf8',
}
const defaultSpectrogramDataSource: SpectrogramDataSource = {
getPendingSpectrogramSamples: () => audioRouter.flushPendingSpectrogramSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
}
function getClarityProfile(mode: SpectrogramClarityMode): SpectrogramClarityProfile {
switch (mode) {
case 'classic':
return { gamma: 1.4, sharpness: 0, tiltDb: 2.0 }
case 'sharp':
return { gamma: 1.5, sharpness: 2.5, tiltDb: 2.0 }
case 'sharper':
return { gamma: 1.6, sharpness: 5.0, tiltDb: 2.0 }
}
}
function resolveClarityMode(value: unknown, fallback: SpectrogramClarityMode): SpectrogramClarityMode {
return isSpectrogramClarityMode(value) ? value : fallback
}
function resolveScaleMode(value: unknown, fallback: SpectrogramScaleMode): SpectrogramScaleMode {
return isSpectrogramScaleMode(value) ? value : fallback
}
function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<SpectrogramOptions>): ResolvedSpectrogramOptions {
return {
fftSize: typeof overrides.fftSize === 'number' ? overrides.fftSize : base.fftSize,
minFrequency: typeof overrides.minFrequency === 'number' ? overrides.minFrequency : base.minFrequency,
maxFrequency: typeof overrides.maxFrequency === 'number' ? overrides.maxFrequency : base.maxFrequency,
minDecibels: typeof overrides.minDecibels === 'number' ? overrides.minDecibels : base.minDecibels,
maxDecibels: typeof overrides.maxDecibels === 'number' ? overrides.maxDecibels : base.maxDecibels,
scrollSpeed: overrides.scrollSpeed === undefined
? base.scrollSpeed
: clampSpectrogramScrollSpeed(overrides.scrollSpeed),
clarityMode: resolveClarityMode(overrides.clarityMode, base.clarityMode),
scaleMode: resolveScaleMode(overrides.scaleMode, base.scaleMode),
colorScheme: overrides.colorScheme ?? base.colorScheme,
lineColor: overrides.lineColor ?? base.lineColor,
}
}
const SLANEY_F_SP = 200 / 3
const SLANEY_MIN_LOG_HZ = 1000
const SLANEY_MIN_LOG_MEL = SLANEY_MIN_LOG_HZ / SLANEY_F_SP
const SLANEY_LOG_STEP = Math.log(6.4) / 27
function hzToMelSlaney(frequencyHz: number): number {
if (frequencyHz < SLANEY_MIN_LOG_HZ) {
return frequencyHz / SLANEY_F_SP
}
return SLANEY_MIN_LOG_MEL + (Math.log(frequencyHz / SLANEY_MIN_LOG_HZ) / SLANEY_LOG_STEP)
}
function melToHzSlaney(mel: number): number {
if (mel < SLANEY_MIN_LOG_MEL) {
return mel * SLANEY_F_SP
}
return SLANEY_MIN_LOG_HZ * Math.exp(SLANEY_LOG_STEP * (mel - SLANEY_MIN_LOG_MEL))
}
function frequencyFromScale(
scaleMode: SpectrogramScaleMode,
minFrequency: number,
maxFrequency: number,
normalizedPosition: number
): number {
switch (scaleMode) {
case 'linear':
return minFrequency + (normalizedPosition * (maxFrequency - minFrequency))
case 'log': {
const logMin = Math.log10(minFrequency)
const logMax = Math.log10(maxFrequency)
return 10 ** (logMin + (normalizedPosition * (logMax - logMin)))
}
case 'mel': {
const melMin = hzToMelSlaney(minFrequency)
const melMax = hzToMelSlaney(maxFrequency)
return melToHzSlaney(melMin + (normalizedPosition * (melMax - melMin)))
}
}
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value))
}
function fft(re: Float32Array, im: Float32Array): void {
const n = re.length
if (n <= 1) return
let j = 0
for (let i = 1; i < n; i += 1) {
let bit = n >> 1
while (j & bit) {
j ^= bit
bit >>= 1
}
j ^= bit
if (i < j) {
let tmp = re[i]
re[i] = re[j]
re[j] = tmp
tmp = im[i]
im[i] = im[j]
im[j] = tmp
}
}
for (let len = 2; len <= n; len <<= 1) {
const halfLen = len >> 1
const angle = -2 * Math.PI / len
const wRe = Math.cos(angle)
const wIm = Math.sin(angle)
for (let i = 0; i < n; i += len) {
let curRe = 1
let curIm = 0
for (let k = 0; k < halfLen; k += 1) {
const evenIdx = i + k
const oddIdx = i + k + halfLen
const tRe = curRe * re[oddIdx] - curIm * im[oddIdx]
const tIm = curRe * im[oddIdx] + curIm * re[oddIdx]
re[oddIdx] = re[evenIdx] - tRe
im[oddIdx] = im[evenIdx] - tIm
re[evenIdx] += tRe
im[evenIdx] += tIm
const nextRe = curRe * wRe - curIm * wIm
curIm = curRe * wIm + curIm * wRe
curRe = nextRe
}
}
}
}
const hannWindowCache = new Map<number, Float32Array>()
function getHannWindow(size: number): Float32Array {
let window = hannWindowCache.get(size)
if (window) return window
window = new Float32Array(size)
for (let i = 0; i < size; i += 1) {
window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)))
}
hannWindowCache.set(size, window)
return window
}
type ColorStop = {
at: number
color: [number, number, number]
}
const HEAT_STOPS: readonly ColorStop[] = [
{ at: 0, color: [0, 0, 0] },
{ at: 0.14, color: [15, 7, 33] },
{ at: 0.32, color: [61, 11, 94] },
{ at: 0.54, color: [163, 26, 121] },
{ at: 0.74, color: [255, 82, 87] },
{ at: 0.9, color: [255, 166, 63] },
{ at: 1, color: [255, 241, 209] },
]
function lerpChannel(start: number, end: number, amount: number): number {
return Math.round(start + ((end - start) * amount))
}
function buildHeatLUT(): Uint8Array {
const lut = new Uint8Array(256 * 3)
for (let index = 0; index < 256; index += 1) {
const t = index / 255
let start = HEAT_STOPS[0]
let end = HEAT_STOPS[HEAT_STOPS.length - 1]
for (let stopIndex = 0; stopIndex < HEAT_STOPS.length - 1; stopIndex += 1) {
const nextStop = HEAT_STOPS[stopIndex + 1]
if (t <= nextStop.at) {
start = HEAT_STOPS[stopIndex]
end = nextStop
break
}
}
const span = Math.max(1e-6, end.at - start.at)
const amount = Math.max(0, Math.min(1, (t - start.at) / span))
lut[index * 3] = lerpChannel(start.color[0], end.color[0], amount)
lut[index * 3 + 1] = lerpChannel(start.color[1], end.color[1], amount)
lut[index * 3 + 2] = lerpChannel(start.color[2], end.color[2], amount)
}
return lut
}
const HEAT_LUT = buildHeatLUT()
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '')
return [
Number.parseInt(normalized.substring(0, 2), 16) || 56,
Number.parseInt(normalized.substring(2, 4), 16) || 189,
Number.parseInt(normalized.substring(4, 6), 16) || 248,
]
}
// Zero-pad FFT for finer frequency resolution (visual interpolation)
const FFT_PAD_FACTOR = 4
export class Spectrogram {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: ResolvedSpectrogramOptions
private dataSource: SpectrogramDataSource
private animationId: number | null = null
private isRunning = false
private fftRe: Float32Array
private fftIm: Float32Array
private sampleBuffer: Float32Array
private sampleBufferPos = 0
private waterfallCanvas: HTMLCanvasElement
private waterfallCtx: CanvasRenderingContext2D
private rowCenterBins = new Float32Array(0)
private rowBandStartBins = new Float32Array(0)
private rowBandEndBins = new Float32Array(0)
private columnValues = new Float32Array(0)
private rawColumnValues = new Float32Array(0)
private columnImageData: ImageData | null = null
private lastWidth = 0
private lastHeight = 0
private lastFftSize = 0
private lastSampleRate = 0
private lastMinFrequency = 0
private lastMaxFrequency = 0
private lastScaleMode: SpectrogramScaleMode | null = null
constructor(canvas: HTMLCanvasElement, options: SpectrogramOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, ...optionOverrides } = options
this.options = resolveOptions(defaultOptions, optionOverrides)
this.dataSource = dataSource ?? defaultSpectrogramDataSource
const windowSize = this.options.fftSize
const paddedSize = windowSize * FFT_PAD_FACTOR
this.fftRe = new Float32Array(paddedSize)
this.fftIm = new Float32Array(paddedSize)
this.sampleBuffer = new Float32Array(windowSize)
this.waterfallCanvas = document.createElement('canvas')
this.waterfallCanvas.width = canvas.width
this.waterfallCanvas.height = canvas.height
const waterfallCtx = this.waterfallCanvas.getContext('2d')
if (!waterfallCtx) throw new Error('Could not get waterfall 2D context')
this.waterfallCtx = waterfallCtx
this.ctx.imageSmoothingEnabled = false
this.waterfallCtx.imageSmoothingEnabled = false
}
private resetDisplay(): void {
this.sampleBufferPos = 0
this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height)
}
setOptions(options: Partial<SpectrogramOptions>): void {
const { dataSource, ...optionUpdates } = options
const previousOptions = this.options
this.options = resolveOptions(previousOptions, optionUpdates)
if (dataSource) {
this.dataSource = dataSource
}
if (this.options.fftSize !== previousOptions.fftSize) {
const windowSize = this.options.fftSize
const paddedSize = windowSize * FFT_PAD_FACTOR
this.fftRe = new Float32Array(paddedSize)
this.fftIm = new Float32Array(paddedSize)
this.sampleBuffer = new Float32Array(windowSize)
this.sampleBufferPos = 0
this.lastFftSize = 0
this.resetDisplay()
} else if (this.options.scaleMode !== previousOptions.scaleMode) {
this.resetDisplay()
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
this.lastWidth = 0
this.lastHeight = 0
}
private ensureColumnBuffers(height: number): void {
if (height <= 0) return
if (this.columnValues.length === height && this.columnImageData && this.columnImageData.height === height) {
return
}
this.columnValues = new Float32Array(height)
this.rawColumnValues = new Float32Array(height)
this.columnImageData = new ImageData(1, height)
}
private shiftAndPaintColumn(values: Float32Array): void {
const width = this.waterfallCanvas.width
const height = this.waterfallCanvas.height
if (width <= 0 || height <= 0 || !this.columnImageData) return
this.paintColumnImage(values)
// Shift existing content left by 1 pixel
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
// Paint new column at right edge
this.waterfallCtx.putImageData(this.columnImageData, width - 1, 0)
}
private ensureBandMapping(): void {
const { canvas, options } = this
const width = canvas.width
const height = canvas.height
const fftSize = options.fftSize
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
const nyquist = sampleRate / 2
const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
if (
width === this.lastWidth
&& height === this.lastHeight
&& fftSize === this.lastFftSize
&& sampleRate === this.lastSampleRate
&& minFrequency === this.lastMinFrequency
&& maxFrequency === this.lastMaxFrequency
&& options.scaleMode === this.lastScaleMode
) {
return
}
this.lastWidth = width
this.lastHeight = height
this.lastFftSize = fftSize
this.lastSampleRate = sampleRate
this.lastMinFrequency = minFrequency
this.lastMaxFrequency = maxFrequency
this.lastScaleMode = options.scaleMode
const numBins = (fftSize * FFT_PAD_FACTOR) / 2
const rowSpan = Math.max(1, height - 1)
const binWidth = nyquist / numBins
this.rowCenterBins = new Float32Array(height)
this.rowBandStartBins = new Float32Array(height)
this.rowBandEndBins = new Float32Array(height)
for (let row = 0; row < height; row += 1) {
const normalizedPosition = 1 - (row / rowSpan)
const centerFrequency = frequencyFromScale(
options.scaleMode,
minFrequency,
maxFrequency,
normalizedPosition
)
const upperEdgeNormalized = row === 0
? 1
: 1 - ((row - 0.5) / rowSpan)
const lowerEdgeNormalized = row === height - 1
? 0
: 1 - ((row + 0.5) / rowSpan)
const upperEdgeFrequency = frequencyFromScale(
options.scaleMode,
minFrequency,
maxFrequency,
upperEdgeNormalized
)
const lowerEdgeFrequency = frequencyFromScale(
options.scaleMode,
minFrequency,
maxFrequency,
lowerEdgeNormalized
)
this.rowCenterBins[row] = Math.max(0, Math.min(numBins - 1, centerFrequency / binWidth))
this.rowBandStartBins[row] = Math.max(0, Math.min(numBins, lowerEdgeFrequency / binWidth))
this.rowBandEndBins[row] = Math.max(0, Math.min(numBins, upperEdgeFrequency / binWidth))
}
this.ensureColumnBuffers(height)
}
private processFFT(samples: Float32Array): Float32Array {
const windowSize = samples.length
const paddedSize = windowSize * FFT_PAD_FACTOR
const window = getHannWindow(windowSize)
// Apply window to audio samples
for (let index = 0; index < windowSize; index += 1) {
this.fftRe[index] = samples[index] * window[index]
}
// Zero-pad the rest for finer frequency interpolation
for (let index = windowSize; index < paddedSize; index += 1) {
this.fftRe[index] = 0
}
this.fftIm.fill(0)
fft(this.fftRe, this.fftIm)
const numBins = paddedSize / 2
const magnitudes = new Float32Array(numBins)
const scale = 2 / windowSize // normalize by window size, not padded size
for (let index = 0; index < numBins; index += 1) {
const re = this.fftRe[index]
const im = this.fftIm[index]
const magnitude = Math.sqrt((re * re) + (im * im)) * scale
magnitudes[index] = 20 * Math.log10(Math.max(magnitude, 1e-10))
}
return magnitudes
}
private paintColumnImage(values: Float32Array): void {
if (!this.columnImageData) return
const imageData = this.columnImageData.data
const [tintR, tintG, tintB] = this.options.colorScheme === 'mono'
? parseHexColor(this.options.lineColor)
: [0, 0, 0]
for (let row = 0; row < values.length; row += 1) {
const intensity = Math.max(0, Math.min(1, values[row]))
const lutIndex = Math.round(intensity * 255)
const dataIndex = row * 4
if (this.options.colorScheme === 'heat') {
imageData[dataIndex] = HEAT_LUT[lutIndex * 3]
imageData[dataIndex + 1] = HEAT_LUT[(lutIndex * 3) + 1]
imageData[dataIndex + 2] = HEAT_LUT[(lutIndex * 3) + 2]
} else {
imageData[dataIndex] = Math.round(tintR * intensity)
imageData[dataIndex + 1] = Math.round(tintG * intensity)
imageData[dataIndex + 2] = Math.round(tintB * intensity)
}
imageData[dataIndex + 3] = 255
}
}
private drawColumn(magnitudes: Float32Array): Float32Array {
const height = this.waterfallCanvas.height
if (height <= 0) return this.columnValues
this.ensureColumnBuffers(height)
const values = this.columnValues
const raw = this.rawColumnValues
const numBins = magnitudes.length
const clarity = getClarityProfile(this.options.clarityMode)
const minDecibels = this.options.minDecibels
const dbRange = Math.max(1e-6, this.options.maxDecibels - minDecibels)
// Compute bin width for frequency-based tilt
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
const binWidth = (sampleRate / 2) / numBins
const TILT_REFERENCE_HZ = 1000
// Pass 1: sub-bin interpolation + tilt → raw normalized values (no gamma yet)
for (let row = 0; row < height; row += 1) {
const centerBin = this.rowCenterBins[row]
// Sub-bin interpolation in dB domain
const binLo = Math.floor(centerBin)
const binHi = Math.min(binLo + 1, numBins - 1)
const frac = centerBin - binLo
const db = magnitudes[binLo] * (1 - frac) + magnitudes[binHi] * frac
// Frequency-based tilt — dB per octave from reference, scale-mode independent
const centerFreq = Math.max(1, centerBin * binWidth)
const tiltAmount = clarity.tiltDb * Math.log2(centerFreq / TILT_REFERENCE_HZ)
raw[row] = clamp01(((db + tiltAmount) - minDecibels) / dbRange)
}
// Pass 2: local peak suppression — thin spectral lines for sharp/sharper modes
const sharpness = clarity.sharpness
if (sharpness > 0) {
// Hann mainlobe = 4 original bins = 4 * FFT_PAD_FACTOR padded bins
const mainlobePaddedBins = 4 * FFT_PAD_FACTOR
// Target visual line width in pixels — suppression scales to achieve this
const TARGET_LINE_WIDTH = 3
for (let row = 0; row < height; row += 1) {
// Adaptive window: mainlobe width in pixel rows at this frequency
const bandWidthPerRow = Math.max(0.1, this.rowBandEndBins[row] - this.rowBandStartBins[row])
const mainlobePixels = mainlobePaddedBins / bandWidthPerRow
const halfWin = Math.max(2, Math.min(50, Math.round(mainlobePixels / 2)))
// Scale suppression by how wide the mainlobe is vs target width
// At low freqs (mainlobe=26px, target=3px): 8.7x stronger suppression
// At high freqs (mainlobe=2px, target=3px): 1x base suppression
const scaleFactor = Math.max(1, mainlobePixels / TARGET_LINE_WIDTH)
const effectiveSharpness = sharpness * scaleFactor
// Find local peak in neighborhood
let localMax = raw[row]
for (let d = 1; d <= halfWin; d += 1) {
if (row - d >= 0 && raw[row - d] > localMax) localMax = raw[row - d]
if (row + d < height && raw[row + d] > localMax) localMax = raw[row + d]
}
// Suppress off-peak values: peak stays bright, slopes get crushed
if (localMax > 1e-6) {
const ratio = raw[row] / localMax
raw[row] *= Math.pow(ratio, effectiveSharpness)
}
}
}
// Pass 3: apply gamma
for (let row = 0; row < height; row += 1) {
values[row] = Math.pow(raw[row], clarity.gamma)
}
return values
}
private draw = (): void => {
if (!this.isRunning) return
const width = this.canvas.width
const height = this.canvas.height
if (width <= 0 || height <= 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
// Re-set after external resize resets context state
this.ctx.imageSmoothingEnabled = false
if (this.waterfallCanvas.width !== width || this.waterfallCanvas.height !== height) {
const previousCanvas = document.createElement('canvas')
previousCanvas.width = this.waterfallCanvas.width
previousCanvas.height = this.waterfallCanvas.height
const previousCtx = previousCanvas.getContext('2d')
if (previousCtx) {
previousCtx.drawImage(this.waterfallCanvas, 0, 0)
}
this.waterfallCanvas.width = width
this.waterfallCanvas.height = height
this.waterfallCtx.imageSmoothingEnabled = false
// Anchor right edge — newest columns stay, old data crops naturally
if (previousCtx && previousCanvas.width > 0 && previousCanvas.height > 0) {
const srcX = Math.max(0, previousCanvas.width - width)
const srcW = Math.min(previousCanvas.width, width)
const dstX = Math.max(0, width - previousCanvas.width)
this.waterfallCtx.drawImage(
previousCanvas,
srcX, 0, srcW, previousCanvas.height,
dstX, 0, srcW, height
)
}
this.lastWidth = 0
}
this.ensureBandMapping()
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrogramSamples()
// Freeze waterfall in place instead of blanking
this.ctx.clearRect(0, 0, width, height)
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
this.animationId = requestAnimationFrame(this.draw)
return
}
const pendingSamples = this.dataSource.getPendingSpectrogramSamples()
const fftSize = this.options.fftSize
// Scroll speed solely controls temporal resolution (hop divisor)
const BASE_HOP_DIVISOR = 8
const effectiveHopDivisor = Math.max(2, Math.min(64, Math.round(BASE_HOP_DIVISOR * this.options.scrollSpeed)))
const hopSize = Math.max(1, Math.floor(fftSize / effectiveHopDivisor))
const overlapSamples = fftSize - hopSize
for (const chunk of pendingSamples) {
for (let index = 0; index < chunk.length; index += 1) {
this.sampleBuffer[this.sampleBufferPos] = chunk[index]
this.sampleBufferPos += 1
if (this.sampleBufferPos >= fftSize) {
const magnitudes = this.processFFT(this.sampleBuffer)
const values = this.drawColumn(magnitudes)
// Each FFT hop = exactly 1 pixel column. No accumulation, no duplication.
this.shiftAndPaintColumn(values)
this.sampleBuffer.copyWithin(0, hopSize)
this.sampleBufferPos = overlapSamples
}
}
}
this.ctx.clearRect(0, 0, width, height)
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
this.animationId = requestAnimationFrame(this.draw)
}
dispose(): void {
this.stop()
}
}
@@ -0,0 +1,514 @@
import { audioRouter } from '../audio/AudioRouter'
import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native'
import {
DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE,
DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
clampSpectrumTiltDbPerOctave,
clampSpectrumHeatmapTiltDbPerOctave,
} from '../../types/spectrum'
export interface SpectrumAnalyzerDataSource {
getPendingSpectrumSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface SpectrumAnalyzerOptions {
lineColor?: string
lineWidth?: number
fillGradient?: boolean
heatmapFill?: boolean
gradientColors?: string[] // Bottom to top
backgroundColor?: string
showGrid?: boolean
gridColor?: string
scaleType?: 'linear' | 'log'
smoothing?: number
minDecibels?: number
maxDecibels?: number
minFrequency?: number
maxFrequency?: number
tiltDbPerOctave?: number
heatmapTiltDbPerOctave?: number
tiltReferenceHz?: number
fftSize?: number
dataSource?: SpectrumAnalyzerDataSource
}
type ResolvedSpectrumAnalyzerOptions = Required<Omit<SpectrumAnalyzerOptions, 'dataSource'>>
// ---- Heat LUT for heatmap fill (same palette as Spectrogram) ----
type HeatStop = { at: number; color: [number, number, number] }
const HEAT_STOPS: readonly HeatStop[] = [
{ at: 0, color: [0, 0, 0] },
{ at: 0.14, color: [15, 7, 33] },
{ at: 0.32, color: [61, 11, 94] },
{ at: 0.54, color: [163, 26, 121] },
{ at: 0.74, color: [255, 82, 87] },
{ at: 0.9, color: [255, 166, 63] },
{ at: 1, color: [255, 241, 209] },
]
function buildHeatLUT(): Uint8Array {
const lut = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i++) {
const t = i / 255
let s = HEAT_STOPS[0], e = HEAT_STOPS[HEAT_STOPS.length - 1]
for (let si = 0; si < HEAT_STOPS.length - 1; si++) {
if (t <= HEAT_STOPS[si + 1].at) { s = HEAT_STOPS[si]; e = HEAT_STOPS[si + 1]; break }
}
const a = Math.max(0, Math.min(1, (t - s.at) / Math.max(1e-6, e.at - s.at)))
lut[i * 3] = Math.round(s.color[0] + (e.color[0] - s.color[0]) * a)
lut[i * 3 + 1] = Math.round(s.color[1] + (e.color[1] - s.color[1]) * a)
lut[i * 3 + 2] = Math.round(s.color[2] + (e.color[2] - s.color[2]) * a)
}
return lut
}
const HEAT_LUT = buildHeatLUT()
const HEATMAP_GAMMA = 1.4
const defaultOptions: ResolvedSpectrumAnalyzerOptions = {
lineColor: '#00ffff',
lineWidth: 2,
fillGradient: true,
heatmapFill: false,
gradientColors: ['rgba(0, 255, 255, 0)', 'rgba(0, 255, 255, 0.3)', 'rgba(138, 43, 226, 0.5)'],
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
scaleType: 'log',
smoothing: 0.9,
minDecibels: -90,
maxDecibels: -10,
minFrequency: 20,
maxFrequency: 20000,
tiltDbPerOctave: DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE,
heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
tiltReferenceHz: 1000,
fftSize: 2048
}
const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = {
getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
}
export class SpectrumAnalyzer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: ResolvedSpectrumAnalyzerOptions
private dataSource: SpectrumAnalyzerDataSource
private animationId: number | null = null
private isRunning: boolean = false
private nativeInitialized: boolean = false
private sampleRate: number = 48000
private lastSampleRate: number = 0
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, ...optionOverrides } = options
this.options = {
...defaultOptions,
...optionOverrides,
tiltDbPerOctave: clampSpectrumTiltDbPerOctave(
optionOverrides.tiltDbPerOctave ?? defaultOptions.tiltDbPerOctave
),
heatmapTiltDbPerOctave: clampSpectrumHeatmapTiltDbPerOctave(
optionOverrides.heatmapTiltDbPerOctave ?? defaultOptions.heatmapTiltDbPerOctave
),
}
this.dataSource = dataSource ?? defaultSpectrumDataSource
// Initialize native module
this.initNative()
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
this.lastSampleRate = this.sampleRate
nativeSpectrum.setFFTSize(this.options.fftSize)
nativeSpectrum.setSampleRate(this.sampleRate)
nativeSpectrum.setSmoothing(this.getNativeSmoothing())
this.nativeInitialized = true
console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
console.error('SpectrumAnalyzer: Native DSP not available!')
}
}
private updateSampleRateIfNeeded(): void {
if (!isNativeAvailable()) return
const currentRate = Math.max(1, this.dataSource.getSampleRate())
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.sampleRate = currentRate
this.lastSampleRate = currentRate
nativeSpectrum.setSampleRate(currentRate)
console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`)
}
}
private getNativeSmoothing(): number {
const base = Math.min(0.99, Math.max(0, this.options.smoothing))
const fftRatio = Math.max(0.5, this.options.fftSize / 2048)
return Math.min(0.99, Math.max(0, Math.pow(base, fftRatio)))
}
setOptions(options: Partial<SpectrumAnalyzerOptions>): void {
const { dataSource, ...optionUpdates } = options
const nextOptions = { ...this.options, ...optionUpdates }
if (optionUpdates.tiltDbPerOctave !== undefined) {
nextOptions.tiltDbPerOctave = clampSpectrumTiltDbPerOctave(optionUpdates.tiltDbPerOctave)
}
if (optionUpdates.heatmapTiltDbPerOctave !== undefined) {
nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave)
}
this.options = nextOptions
if (dataSource) {
this.dataSource = dataSource
}
// Update native module settings
if (isNativeAvailable()) {
if (options.fftSize !== undefined) {
nativeSpectrum.setFFTSize(options.fftSize)
}
if (options.smoothing !== undefined || options.fftSize !== undefined) {
nativeSpectrum.setSmoothing(this.getNativeSmoothing())
}
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
// Canvas resize is handled externally
}
// Linear interpolation helper
private lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
// Get interpolated value from frequency data
private getInterpolatedValue(data: Float32Array, index: number): number {
const i0 = Math.floor(index)
const i1 = Math.min(i0 + 1, data.length - 1)
const t = index - i0
return this.lerp(data[i0], data[i1], t)
}
private frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number {
if (this.options.scaleType === 'log') {
const logMin = Math.log10(minFrequency)
const logMax = Math.log10(maxFrequency)
return Math.pow(10, logMin + t * (logMax - logMin))
}
return minFrequency + t * (maxFrequency - minFrequency)
}
private getPeakInRange(data: Float32Array, startIndex: number, endIndex: number): number {
const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex))
const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex))
const lo = Math.floor(Math.min(clampedStart, clampedEnd))
const hi = Math.ceil(Math.max(clampedStart, clampedEnd))
if (hi <= lo) {
return this.getInterpolatedValue(data, clampedStart)
}
let peak = -Infinity
for (let i = lo; i <= hi; i++) {
peak = Math.max(peak, data[i])
}
return Math.max(
peak,
this.getInterpolatedValue(data, clampedStart),
this.getInterpolatedValue(data, clampedEnd)
)
}
private applyTilt(db: number, frequency: number, tiltDbPerOctave = this.options.tiltDbPerOctave): number {
const safeFreq = Math.max(1, frequency)
const reference = Math.max(1, this.options.tiltReferenceHz)
const octaves = Math.log2(safeFreq / reference)
return db + tiltDbPerOctave * octaves
}
private mergePendingSpectrumChunks(pendingSpectrum: Float32Array[]): Float32Array | null {
if (pendingSpectrum.length === 0) return null
if (pendingSpectrum.length === 1) return pendingSpectrum[0]
let totalLength = 0
for (const chunk of pendingSpectrum) totalLength += chunk.length
const monoData = new Float32Array(totalLength)
let offset = 0
for (const chunk of pendingSpectrum) {
monoData.set(chunk, offset)
offset += chunk.length
}
return monoData
}
private draw = (): void => {
if (!this.isRunning) return
const { canvas, ctx, options } = this
const width = canvas.width
const height = canvas.height
const dpr = window.devicePixelRatio || 1
if (width <= 0 || height <= 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
// Get frequency data from native FFT
if (!isNativeAvailable()) {
console.error('SpectrumAnalyzer: Native DSP required')
this.animationId = requestAnimationFrame(this.draw)
return
}
this.updateSampleRateIfNeeded()
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrumSamples()
nativeSpectrum.reset()
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
const nyquist = this.sampleRate / 2
const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
if (options.showGrid) {
this.drawGrid(minFrequency, maxFrequency)
}
this.animationId = requestAnimationFrame(this.draw)
return
}
const pendingSpectrum = this.dataSource.getPendingSpectrumSamples()
const monoData = this.mergePendingSpectrumChunks(pendingSpectrum)
if (!monoData) {
this.animationId = requestAnimationFrame(this.draw)
return
}
const nativeResult = nativeSpectrum.process(monoData)
if (!nativeResult) {
this.animationId = requestAnimationFrame(this.draw)
return
}
let frequencyData = nativeResult
const bufferLength = frequencyData.length
if (bufferLength === 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
// Clear canvas
ctx.clearRect(0, 0, width, height)
// Draw background if not transparent
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
// Draw grid
const nyquist = this.sampleRate / 2
const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
if (options.showGrid) {
this.drawGrid(minFrequency, maxFrequency)
}
// Calculate frequency mapping
const binWidth = nyquist / bufferLength
// Build one point per horizontal pixel and preserve local peaks.
const points: { x: number; y: number; heatmapIntensity: number }[] = []
const numPoints = Math.max(2, Math.floor(width))
for (let i = 0; i < numPoints; i++) {
const t0 = i / (numPoints - 1)
const t1 = Math.min(1, (i + 1) / (numPoints - 1))
const x = t0 * width
const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency)
const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency)
const centerFrequency = (frequency0 + frequency1) * 0.5
const bin0 = frequency0 / binWidth
const bin1 = frequency1 / binWidth
const centerBin = (bin0 + bin1) * 0.5
const binSpan = Math.abs(bin1 - bin0)
// Low frequencies can look stepped because each pixel maps to <1 FFT bin.
// Use sub-bin interpolation there, and keep peak-hold for wider spans.
const rawDb = binSpan <= 1
? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1))
: this.getPeakInRange(frequencyData, bin0, bin1)
const db = this.applyTilt(rawDb, centerFrequency)
const heatmapDb = this.applyTilt(rawDb, centerFrequency, options.heatmapTiltDbPerOctave)
// Normalize to 0-1 range
const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels)
const heatmapNormalized = (heatmapDb - options.minDecibels) / (options.maxDecibels - options.minDecibels)
const y = height - Math.max(0, Math.min(1, normalized)) * height
const heatmapIntensity = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA)
points.push({ x, y, heatmapIntensity })
}
// Draw filled area
if (options.heatmapFill && points.length > 0) {
// Per-column heat-colored fill — each frequency colored by its intensity
for (let i = 0; i < points.length; i++) {
const x = Math.floor(points[i].x)
const y = points[i].y
const nextX = i < points.length - 1 ? Math.floor(points[i + 1].x) : width
const colWidth = Math.max(1, nextX - x)
const fillHeight = height - y
if (fillHeight <= 0) continue
const intensity = points[i].heatmapIntensity
const li = Math.round(intensity * 255)
const r = HEAT_LUT[li * 3]
const g = HEAT_LUT[li * 3 + 1]
const b = HEAT_LUT[li * 3 + 2]
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
ctx.fillRect(x, Math.floor(y), colWidth, Math.ceil(fillHeight))
}
} else if (options.fillGradient && points.length > 0) {
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
// Complete path for fill
ctx.lineTo(width, height)
ctx.lineTo(0, height)
ctx.closePath()
// Create gradient
const gradient = ctx.createLinearGradient(0, height, 0, 0)
const colors = options.gradientColors
for (let i = 0; i < colors.length; i++) {
gradient.addColorStop(i / (colors.length - 1), colors[i])
}
ctx.fillStyle = gradient
ctx.fill()
}
// Draw the line on top
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.lineWidth = options.lineWidth * dpr
ctx.strokeStyle = options.lineColor
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
this.animationId = requestAnimationFrame(this.draw)
}
private drawGrid(minFrequency: number, maxFrequency: number): void {
const { ctx, canvas, options } = this
const width = canvas.width
const height = canvas.height
const dpr = window.devicePixelRatio || 1
ctx.strokeStyle = options.gridColor
ctx.lineWidth = dpr
// Horizontal dB lines
const dbSteps = [-80, -60, -40, -20, 0]
ctx.fillStyle = options.gridColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'left'
for (const db of dbSteps) {
const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels)
const y = height - normalized * height
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(width, y)
ctx.stroke()
ctx.fillText(`${db}dB`, 4 * dpr, y - 2 * dpr)
}
// Vertical frequency lines (log scale)
const freqSteps = [50, 100, 200, 500, 1000, 2000, 5000, 10000]
ctx.textAlign = 'center'
for (const freq of freqSteps) {
if (freq < minFrequency || freq > maxFrequency) continue
let x: number
if (options.scaleType === 'log') {
const logMin = Math.log10(minFrequency)
const logMax = Math.log10(maxFrequency)
const logFreq = Math.log10(freq)
x = ((logFreq - logMin) / (logMax - logMin)) * width
} else {
x = ((freq - minFrequency) / (maxFrequency - minFrequency)) * width
}
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
ctx.stroke()
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`
ctx.fillText(label, x, height - 4 * dpr)
}
}
dispose(): void {
this.stop()
// Reset native module state
if (isNativeAvailable()) {
nativeSpectrum.reset()
}
this.lastSampleRate = 0
}
}
+610
View File
@@ -0,0 +1,610 @@
import { audioRouter } from '../audio/AudioRouter'
import {
DEFAULT_VU_METER_ORIENTATION,
type VUMeterMode,
type VUMeterOrientation,
} from '../../types/vumeter'
export interface VUMeterDataSource {
getPendingVUMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
getSampleRate: () => number
isPlaying: () => boolean
}
export interface VUMeterOptions {
mode?: VUMeterMode
orientation?: VUMeterOrientation
lineColor?: string
dataSource?: VUMeterDataSource
}
type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource'>>
const defaultOptions: ResolvedVUMeterOptions = {
mode: 'bar',
orientation: DEFAULT_VU_METER_ORIENTATION,
lineColor: '#38bdf8',
}
const defaultVUMeterDataSource: VUMeterDataSource = {
getPendingVUMeterSamples: () => audioRouter.flushPendingVUMeterSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
}
// ---- Meter constants ----
const METER_MIN_DB = -60
const METER_MAX_DB = 0
const PEAK_HOLD_FRAMES = 45 // ~0.75s at 60fps
const PEAK_DECAY_DB_PER_FRAME = 0.3
const RMS_SMOOTHING = 0.85 // exponential smoothing factor
const CORRELATION_SMOOTHING = 0.88
// ---- Color utilities ----
function parseHexColor(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16) || 56,
parseInt(h.substring(2, 4), 16) || 189,
parseInt(h.substring(4, 6), 16) || 248,
]
}
function colorWithAlpha(r: number, g: number, b: number, a: number): string {
return `rgba(${r}, ${g}, ${b}, ${a})`
}
// ---- VU Meter class ----
export class VUMeter {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: ResolvedVUMeterOptions
private dataSource: VUMeterDataSource
private animationId: number | null = null
private isRunning = false
// Meter state
private rmsL = METER_MIN_DB
private rmsR = METER_MIN_DB
private peakL = METER_MIN_DB
private peakR = METER_MIN_DB
private peakHoldL = 0
private peakHoldR = 0
private correlation = 0
constructor(canvas: HTMLCanvasElement, options: VUMeterOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultVUMeterDataSource
}
private resetMeters(): void {
this.rmsL = METER_MIN_DB
this.rmsR = METER_MIN_DB
this.peakL = METER_MIN_DB
this.peakR = METER_MIN_DB
this.peakHoldL = 0
this.peakHoldR = 0
this.correlation = 0
}
setOptions(options: Partial<VUMeterOptions>): void {
const { dataSource, ...optionUpdates } = options
this.options = { ...this.options, ...optionUpdates }
if (dataSource) {
this.dataSource = dataSource
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
// Canvas resize handled externally
}
private processAudio(): void {
const chunks = this.dataSource.getPendingVUMeterSamples()
if (!this.dataSource.isPlaying() || chunks.length === 0) {
// Decay toward silence
this.rmsL = this.rmsL * RMS_SMOOTHING + METER_MIN_DB * (1 - RMS_SMOOTHING)
this.rmsR = this.rmsR * RMS_SMOOTHING + METER_MIN_DB * (1 - RMS_SMOOTHING)
this.correlation = this.correlation * CORRELATION_SMOOTHING
this.updatePeaks()
return
}
// Compute RMS and correlation across all chunks
let sumSqL = 0
let sumSqR = 0
let sumLR = 0
let totalSamples = 0
for (const chunk of chunks) {
const len = Math.min(chunk.left.length, chunk.right.length)
for (let i = 0; i < len; i++) {
const l = chunk.left[i]
const r = chunk.right[i]
sumSqL += l * l
sumSqR += r * r
sumLR += l * r
}
totalSamples += len
}
if (totalSamples === 0) return
const rawRmsL = Math.sqrt(sumSqL / totalSamples)
const rawRmsR = Math.sqrt(sumSqR / totalSamples)
const dbL = 20 * Math.log10(Math.max(rawRmsL, 1e-10))
const dbR = 20 * Math.log10(Math.max(rawRmsR, 1e-10))
// Smooth RMS values
this.rmsL = this.rmsL * RMS_SMOOTHING + dbL * (1 - RMS_SMOOTHING)
this.rmsR = this.rmsR * RMS_SMOOTHING + dbR * (1 - RMS_SMOOTHING)
// Compute correlation coefficient: sum(L*R) / sqrt(sum(L^2) * sum(R^2))
const denominator = Math.sqrt(sumSqL * sumSqR)
const rawCorrelation = denominator > 1e-10 ? sumLR / denominator : 0
this.correlation = this.correlation * CORRELATION_SMOOTHING + rawCorrelation * (1 - CORRELATION_SMOOTHING)
this.updatePeaks()
}
private updatePeaks(): void {
// Update peak hold for L
if (this.rmsL > this.peakL) {
this.peakL = this.rmsL
this.peakHoldL = PEAK_HOLD_FRAMES
} else if (this.peakHoldL > 0) {
this.peakHoldL--
} else {
this.peakL = Math.max(this.peakL - PEAK_DECAY_DB_PER_FRAME, METER_MIN_DB)
}
// Update peak hold for R
if (this.rmsR > this.peakR) {
this.peakR = this.rmsR
this.peakHoldR = PEAK_HOLD_FRAMES
} else if (this.peakHoldR > 0) {
this.peakHoldR--
} else {
this.peakR = Math.max(this.peakR - PEAK_DECAY_DB_PER_FRAME, METER_MIN_DB)
}
}
private dbToNormalized(db: number): number {
return Math.max(0, Math.min(1, (db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB)))
}
private drawBarMode(width: number, height: number): void {
if (this.options.orientation === 'vertical') {
this.drawVerticalBarMode(width, height)
return
}
this.drawHorizontalBarMode(width, height)
}
private drawHorizontalBarMode(width: number, height: number): void {
const ctx = this.ctx
const [cr, cg, cb] = parseHexColor(this.options.lineColor)
const meterHeight = Math.max(1, Math.floor(height * 0.28))
const corrHeight = Math.max(1, Math.floor(height * 0.16))
const gap = Math.max(2, Math.floor(height * 0.04))
const labelWidth = Math.max(24, Math.floor(width * 0.07))
const dbLabelWidth = Math.max(52, Math.floor(width * 0.1))
const barLeft = labelWidth + 4
const barRight = width - dbLabelWidth - 4
const barWidth = Math.max(1, barRight - barLeft)
// Total content height
const totalHeight = meterHeight * 2 + corrHeight + gap * 2
const topOffset = Math.max(0, Math.floor((height - totalHeight) / 2))
// ---- L meter ----
const lY = topOffset
this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.rmsL, this.peakL, cr, cg, cb)
this.drawMeterLabel(ctx, 0, lY, labelWidth, meterHeight, 'L')
this.drawDbLabel(ctx, barRight + 4, lY, dbLabelWidth, meterHeight, this.rmsL)
// ---- R meter ----
const rY = lY + meterHeight + gap
this.drawHorizontalMeterBar(ctx, barLeft, rY, barWidth, meterHeight, this.rmsR, this.peakR, cr, cg, cb)
this.drawMeterLabel(ctx, 0, rY, labelWidth, meterHeight, 'R')
this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.rmsR)
// ---- Correlation meter ----
const corrY = rY + meterHeight + gap
this.drawCorrelationBar(ctx, barLeft, corrY, barWidth, corrHeight, cr, cg, cb)
}
private drawVerticalBarMode(width: number, height: number): void {
const ctx = this.ctx
const [cr, cg, cb] = parseHexColor(this.options.lineColor)
const sidePadding = Math.max(4, Math.floor(width * 0.08))
const channelGap = Math.max(4, Math.floor(width * 0.08))
const labelHeight = Math.max(14, Math.floor(height * 0.08))
const dbHeight = Math.max(14, Math.floor(height * 0.1))
const corrHeight = Math.max(10, Math.floor(height * 0.11))
const gapY = Math.max(4, Math.floor(height * 0.03))
const maxMeterWidth = Math.max(4, Math.floor((width - channelGap) / 2))
const availableMeterWidth = Math.max(8, width - sidePadding * 2 - channelGap)
const meterWidth = Math.min(Math.max(6, Math.floor(availableMeterWidth / 2)), maxMeterWidth)
const totalMeterWidth = meterWidth * 2 + channelGap
const meterLeft = Math.max(0, Math.floor((width - totalMeterWidth) / 2))
const meterTop = gapY + labelHeight
const meterHeight = Math.max(1, height - labelHeight - dbHeight - corrHeight - gapY * 4)
const dbY = meterTop + meterHeight + gapY
const corrY = dbY + dbHeight + gapY
const corrX = Math.max(4, Math.floor(width * 0.06))
const corrWidth = Math.max(1, width - corrX * 2)
const lX = meterLeft
const rX = meterLeft + meterWidth + channelGap
this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L')
this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.rmsL, this.peakL, cr, cg, cb)
this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.rmsL)
this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R')
this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.rmsR, this.peakR, cr, cg, cb)
this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.rmsR)
this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb)
}
private drawHorizontalMeterBar(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
rmsDb: number, peakDb: number,
cr: number, cg: number, cb: number
): void {
const rmsNorm = this.dbToNormalized(rmsDb)
const peakNorm = this.dbToNormalized(peakDb)
const rmsWidth = rmsNorm * w
const hotThreshold = this.dbToNormalized(-6) * w
// Background track
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'
ctx.fillRect(x, y, w, h)
// RMS bar
if (rmsWidth > 0) {
const safeWidth = Math.min(rmsWidth, hotThreshold)
if (safeWidth > 0) {
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
ctx.fillRect(x, y, safeWidth, h)
}
if (rmsWidth > hotThreshold) {
// Hot zone: transition to warm/red
const hotWidth = rmsWidth - hotThreshold
const hotProgress = Math.min(1, hotWidth / Math.max(1, w - hotThreshold))
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
const hotG = Math.round(cg * (1 - hotProgress * 0.6))
const hotB = Math.round(cb * (1 - hotProgress * 0.7))
ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82)
ctx.fillRect(x + hotThreshold, y, hotWidth, h)
}
}
// Peak indicator line
if (peakNorm > 0.001) {
const peakX = x + peakNorm * w
const peakInHot = peakDb > -6
ctx.fillStyle = peakInHot
? 'rgba(255, 120, 80, 0.9)'
: colorWithAlpha(cr, cg, cb, 0.9)
ctx.fillRect(peakX - 1, y, 2, h)
}
// Scale ticks
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'
const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0]
for (const db of tickDbs) {
const tickX = x + this.dbToNormalized(db) * w
ctx.fillRect(tickX, y + h - 3, 1, 3)
}
}
private drawVerticalMeterBar(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
rmsDb: number, peakDb: number,
cr: number, cg: number, cb: number
): void {
const rmsNorm = this.dbToNormalized(rmsDb)
const peakNorm = this.dbToNormalized(peakDb)
const rmsHeight = rmsNorm * h
const hotThreshold = this.dbToNormalized(-6) * h
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'
ctx.fillRect(x, y, w, h)
if (rmsHeight > 0) {
const safeHeight = Math.min(rmsHeight, hotThreshold)
if (safeHeight > 0) {
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
ctx.fillRect(x, y + h - safeHeight, w, safeHeight)
}
if (rmsHeight > hotThreshold) {
const hotHeight = rmsHeight - hotThreshold
const hotProgress = Math.min(1, hotHeight / Math.max(1, h - hotThreshold))
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
const hotG = Math.round(cg * (1 - hotProgress * 0.6))
const hotB = Math.round(cb * (1 - hotProgress * 0.7))
ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82)
ctx.fillRect(x, y + h - rmsHeight, w, hotHeight)
}
}
if (peakNorm > 0.001) {
const peakY = y + h - peakNorm * h
const peakInHot = peakDb > -6
ctx.fillStyle = peakInHot
? 'rgba(255, 120, 80, 0.9)'
: colorWithAlpha(cr, cg, cb, 0.9)
ctx.fillRect(x, peakY - 1, w, 2)
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'
const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0]
for (const db of tickDbs) {
const tickY = y + h - this.dbToNormalized(db) * h
ctx.fillRect(x, tickY, w, 1)
}
}
private drawMeterLabel(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
label: string
): void {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
ctx.font = `${Math.min(22, Math.max(10, h * 0.65))}px "JetBrains Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, x + w / 2, y + h / 2)
}
private drawDbLabel(
ctx: CanvasRenderingContext2D,
x: number, y: number, _w: number, h: number,
db: number
): void {
const displayDb = Math.max(METER_MIN_DB, Math.min(0, db))
const text = displayDb <= METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}`
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'
ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace`
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(text, x, y + h / 2)
}
private drawCenteredDbLabel(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
db: number
): void {
const displayDb = Math.max(METER_MIN_DB, Math.min(0, db))
const text = displayDb <= METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}`
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'
ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, x + w / 2, y + h / 2)
}
private drawCorrelationBar(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
cr: number, cg: number, cb: number
): void {
const centerX = x + w / 2
const corr = Math.max(-1, Math.min(1, this.correlation))
// Background track
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'
ctx.fillRect(x, y, w, h)
// Center line
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'
ctx.fillRect(centerX - 0.5, y, 1, h)
// Correlation indicator
const indicatorWidth = Math.abs(corr) * (w / 2)
if (indicatorWidth > 0.5) {
if (corr >= 0) {
// Positive correlation: draw rightward from center (good)
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.6)
ctx.fillRect(centerX, y, indicatorWidth, h)
} else {
// Negative correlation: draw leftward from center (out of phase)
ctx.fillStyle = 'rgba(255, 120, 80, 0.6)'
ctx.fillRect(centerX - indicatorWidth, y, indicatorWidth, h)
}
}
// Labels
const fontSize = Math.min(18, Math.max(8, h * 0.55))
ctx.font = `${fontSize}px "JetBrains Mono", monospace`
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
ctx.textAlign = 'left'
ctx.fillText('-1', x + 2, y + h / 2)
ctx.textAlign = 'center'
ctx.fillText('Ø', centerX, y + h / 2)
ctx.textAlign = 'right'
ctx.fillText('+1', x + w - 2, y + h / 2)
}
private drawNeedleMode(width: number, height: number): void {
const ctx = this.ctx
const [cr, cg, cb] = parseHexColor(this.options.lineColor)
// Layout: two meters side by side, correlation bar below
const corrHeight = Math.max(1, Math.floor(height * 0.12))
const gap = Math.max(2, Math.floor(height * 0.03))
const meterAreaHeight = height - corrHeight - gap
const meterWidth = Math.floor(width / 2) - 2
const barLeft = Math.max(16, Math.floor(width * 0.06)) + 4
const barRight = width - Math.max(36, Math.floor(width * 0.08)) - 4
const barWidth = Math.max(1, barRight - barLeft)
// L needle
this.drawNeedleMeter(ctx, 0, 0, meterWidth, meterAreaHeight, this.rmsL, this.peakL, 'L', cr, cg, cb)
// R needle
this.drawNeedleMeter(ctx, meterWidth + 4, 0, meterWidth, meterAreaHeight, this.rmsR, this.peakR, 'R', cr, cg, cb)
// Correlation bar at bottom
this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb)
}
private drawNeedleMeter(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
rmsDb: number, peakDb: number,
label: string,
cr: number, cg: number, cb: number
): void {
const centerX = x + w / 2
const arcRadius = Math.min(w * 0.42, h * 0.65)
const arcCenterY = y + h * 0.78
// Arc background (sweep from -135° to -45°, top half)
const startAngle = Math.PI * 1.25 // 225° (bottom-left)
const endAngle = Math.PI * 1.75 // 315° (bottom-right)
// Scale arc
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(centerX, arcCenterY, arcRadius, startAngle, endAngle)
ctx.stroke()
// Scale ticks
const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0]
for (const db of tickDbs) {
const norm = this.dbToNormalized(db)
const angle = startAngle + norm * (endAngle - startAngle)
const innerR = arcRadius - 6
const outerR = arcRadius + 2
ctx.strokeStyle = db >= -6
? 'rgba(255, 120, 80, 0.3)'
: 'rgba(255, 255, 255, 0.15)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(centerX + Math.cos(angle) * innerR, arcCenterY + Math.sin(angle) * innerR)
ctx.lineTo(centerX + Math.cos(angle) * outerR, arcCenterY + Math.sin(angle) * outerR)
ctx.stroke()
}
// Needle
const rmsNorm = this.dbToNormalized(rmsDb)
const needleAngle = startAngle + rmsNorm * (endAngle - startAngle)
const needleLength = arcRadius * 0.88
ctx.strokeStyle = colorWithAlpha(cr, cg, cb, 0.9)
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(centerX, arcCenterY)
ctx.lineTo(
centerX + Math.cos(needleAngle) * needleLength,
arcCenterY + Math.sin(needleAngle) * needleLength
)
ctx.stroke()
// Needle pivot dot
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.7)
ctx.beginPath()
ctx.arc(centerX, arcCenterY, 2.5, 0, Math.PI * 2)
ctx.fill()
// Peak indicator (small dot on the arc)
const peakNorm = this.dbToNormalized(peakDb)
if (peakNorm > 0.001) {
const peakAngle = startAngle + peakNorm * (endAngle - startAngle)
const peakInHot = peakDb > -6
ctx.fillStyle = peakInHot
? 'rgba(255, 120, 80, 0.8)'
: colorWithAlpha(cr, cg, cb, 0.8)
ctx.beginPath()
ctx.arc(
centerX + Math.cos(peakAngle) * arcRadius,
arcCenterY + Math.sin(peakAngle) * arcRadius,
2.5, 0, Math.PI * 2
)
ctx.fill()
}
// Channel label
const fontSize = Math.min(22, Math.max(10, h * 0.1))
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'
ctx.font = `${fontSize}px "JetBrains Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText(label, centerX, y + 4)
// dB readout
const displayDb = Math.max(METER_MIN_DB, Math.min(0, rmsDb))
const dbText = displayDb <= METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB`
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'
ctx.font = `${Math.max(9, fontSize - 1)}px "JetBrains Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'bottom'
ctx.fillText(dbText, centerX, y + h - 2)
}
private draw = (): void => {
if (!this.isRunning) return
const { canvas, ctx, options } = this
const width = canvas.width
const height = canvas.height
if (width <= 0 || height <= 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
this.processAudio()
ctx.clearRect(0, 0, width, height)
if (options.mode === 'needle') {
this.drawNeedleMode(width, height)
} else {
this.drawBarMode(width, height)
}
this.animationId = requestAnimationFrame(this.draw)
}
dispose(): void {
this.stop()
}
}
+336
View File
@@ -0,0 +1,336 @@
import { audioRouter } from '../audio/AudioRouter'
import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native'
import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids'
import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter'
export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar'
export interface VectorscopeOptions {
lineColor?: string
lineWidth?: number
backgroundColor?: string
showGrid?: boolean
gridColor?: string
persistence?: number // 0.0 (no trail) to 1.0 (infinite trail), default 0.10
displayPoints?: number // how many points to request from native, default 4096
mode?: VectorscopeMode
multiband?: boolean
}
const defaultOptions: Required<VectorscopeOptions> = {
lineColor: '#00ffff',
lineWidth: 1.5,
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
persistence: 0.10,
displayPoints: 4096,
mode: 'lissajous',
multiband: false,
}
const BAND_ORDER = ['low', 'mid', 'high'] as const
export class Vectorscope {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private offscreenCanvas: HTMLCanvasElement
private offscreenCtx: CanvasRenderingContext2D
private options: Required<VectorscopeOptions>
private animationId: number | null = null
private isRunning: boolean = false
private nativeInitialized: boolean = false
private lastSampleRate: number = 0
private splitter: MultibandSplitter = new MultibandSplitter()
private multibandBuffer: MultibandBuffer = new MultibandBuffer()
constructor(canvas: HTMLCanvasElement, options: VectorscopeOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
this.options = { ...defaultOptions, ...options }
// Create offscreen canvas for persistence/fade
this.offscreenCanvas = document.createElement('canvas')
this.offscreenCanvas.width = canvas.width
this.offscreenCanvas.height = canvas.height
const offCtx = this.offscreenCanvas.getContext('2d')
if (!offCtx) throw new Error('Could not get offscreen 2D context')
this.offscreenCtx = offCtx
// Initialize native module if available
this.initNative()
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
const sampleRate = audioRouter.getSampleRate()
this.lastSampleRate = sampleRate
nativeVectorscope.setSampleRate(sampleRate)
this.nativeInitialized = true
console.log(`Vectorscope: Using native DSP (${sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
console.log('Vectorscope: Using JavaScript fallback')
}
}
private updateSampleRateIfNeeded(): void {
const currentRate = audioRouter.getSampleRate()
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.lastSampleRate = currentRate
if (isNativeAvailable()) {
nativeVectorscope.setSampleRate(currentRate)
}
this.splitter.configure(currentRate)
}
}
private resetDisplay(): void {
// Clear the offscreen canvas and reset native state
if (isNativeAvailable()) {
nativeVectorscope.reset()
}
this.splitter.reset()
this.multibandBuffer.reset()
this.offscreenCtx.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height)
}
setOptions(options: Partial<VectorscopeOptions>): void {
this.options = { ...this.options, ...options }
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
// Canvas resize is handled externally; offscreen will sync in draw()
}
private draw = (): void => {
if (!this.isRunning) return
const { canvas, ctx, offscreenCanvas, offscreenCtx, options } = this
const width = canvas.width
const height = canvas.height
const isPolar = options.mode === 'polar-unipolar' || options.mode === 'polar-bipolar'
const VISUAL_GAIN = isPolar ? 1.2 : 1.5
const layout = getVectorscopeLayout(width, height, options.mode)
const centerX = layout.centerX
const centerY = layout.centerY
const scale = layout.radius * VISUAL_GAIN
// Sync offscreen canvas size
if (offscreenCanvas.width !== width || offscreenCanvas.height !== height) {
offscreenCanvas.width = width
offscreenCanvas.height = height
}
// Update sample rate if changed
this.updateSampleRateIfNeeded()
// ---- PERSISTENCE FADE ----
offscreenCtx.globalCompositeOperation = 'destination-in'
offscreenCtx.fillStyle = `rgba(255, 255, 255, ${options.persistence})`
offscreenCtx.fillRect(0, 0, width, height)
offscreenCtx.globalCompositeOperation = 'source-over'
// ---- FLUSH SAMPLES ----
const pendingSamples = audioRouter.flushPendingVectorscopeSamples()
if (options.multiband) {
// Multiband path: split into 3 bands, render each with its own color
this.drawMultibandPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)
} else if (isNativeAvailable()) {
// Push all accumulated stereo chunks to native circular buffer
for (const chunk of pendingSamples) {
nativeVectorscope.pushSamples(chunk.left, chunk.right)
}
// Get filtered points from native circular buffer
const pointsResult = nativeVectorscope.getPoints(options.displayPoints)
if (pointsResult && pointsResult.count > 0) {
this.drawPoints(offscreenCtx, pointsResult.x, pointsResult.y, pointsResult.count, centerX, centerY, scale)
}
} else {
// JavaScript fallback: draw raw samples from pending chunks
this.drawFallbackPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)
}
// ---- COMPOSITE TO VISIBLE CANVAS ----
ctx.clearRect(0, 0, width, height)
// Draw background
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
// Draw grid underneath
if (options.showGrid) {
const dpr = window.devicePixelRatio || 1
drawVectorscopeGridForMode(ctx, width, height, options.gridColor, options.mode, dpr)
}
// Draw the accumulated vectorscope image on top
ctx.drawImage(offscreenCanvas, 0, 0)
this.animationId = requestAnimationFrame(this.draw)
}
private drawPoints(
ctx: CanvasRenderingContext2D,
x: Float32Array,
y: Float32Array,
count: number,
centerX: number,
centerY: number,
scale: number
): void {
const { options } = this
const mode = options.mode
const dpr = window.devicePixelRatio || 1
const dotSize = options.lineWidth * dpr
// Draw dots with age-based opacity: oldest dimmer, newest brighter
const segments = 8
const pointsPerSegment = Math.ceil(count / segments)
for (let seg = 0; seg < segments; seg++) {
const startIdx = seg * pointsPerSegment
const endIdx = Math.min((seg + 1) * pointsPerSegment, count)
if (startIdx >= count) break
// Older segments (lower seg) are dimmer
const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1))
ctx.fillStyle = options.lineColor
ctx.globalAlpha = alpha
for (let i = startIdx; i < endIdx; i++) {
// Native returns x=Right, y=Left
const point = transformPoint(y[i], x[i], mode)
if (!point) continue
const px = centerX + point.dx * scale
const py = centerY - point.dy * scale
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
}
}
ctx.globalAlpha = 1.0
}
private drawFallbackPoints(
ctx: CanvasRenderingContext2D,
pendingSamples: { left: Float32Array; right: Float32Array }[],
centerX: number,
centerY: number,
scale: number
): void {
if (pendingSamples.length === 0) return
const { options } = this
const mode = options.mode
const dpr = window.devicePixelRatio || 1
const dotSize = options.lineWidth * dpr
ctx.fillStyle = options.lineColor
ctx.globalAlpha = 0.8
for (const chunk of pendingSamples) {
for (let i = 0; i < chunk.left.length; i++) {
const point = transformPoint(chunk.left[i], chunk.right[i], mode)
if (!point) continue
const px = centerX + point.dx * scale
const py = centerY - point.dy * scale
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
}
}
ctx.globalAlpha = 1.0
}
private drawMultibandPoints(
ctx: CanvasRenderingContext2D,
pendingSamples: { left: Float32Array; right: Float32Array }[],
centerX: number,
centerY: number,
scale: number
): void {
const { options } = this
const mode = options.mode
const dpr = window.devicePixelRatio || 1
const dotSize = options.lineWidth * dpr
// Ensure splitter is configured
const sampleRate = audioRouter.getSampleRate()
if (sampleRate > 0) {
this.splitter.configure(sampleRate)
}
// Also push to native so switching back to single-color is seamless
if (isNativeAvailable()) {
for (const chunk of pendingSamples) {
nativeVectorscope.pushSamples(chunk.left, chunk.right)
}
}
// Split new samples into bands and push into circular buffer
for (const chunk of pendingSamples) {
const bands = this.splitter.split(chunk.left, chunk.right)
this.multibandBuffer.push(bands)
}
// Read all buffered points and draw with age-based opacity (same as native path)
const result = this.multibandBuffer.getPoints(options.displayPoints)
if (result.count === 0) return
const segments = 8
const pointsPerSegment = Math.ceil(result.count / segments)
for (let seg = 0; seg < segments; seg++) {
const startIdx = seg * pointsPerSegment
const endIdx = Math.min((seg + 1) * pointsPerSegment, result.count)
if (startIdx >= result.count) break
const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1))
ctx.globalAlpha = alpha
for (const band of BAND_ORDER) {
const bandData = result.bands[band]
ctx.fillStyle = BAND_COLORS[band]
for (let i = startIdx; i < endIdx; i++) {
const point = transformPoint(bandData.left[i], bandData.right[i], mode)
if (!point) continue
const px = centerX + point.dx * scale
const py = centerY - point.dy * scale
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
}
}
}
ctx.globalAlpha = 1.0
}
dispose(): void {
this.stop()
// Reset native module state
if (isNativeAvailable()) {
nativeVectorscope.reset()
}
}
}
+416
View File
@@ -0,0 +1,416 @@
import { audioRouter } from '../audio/AudioRouter'
import {
DEFAULT_WAVEFORM_GAIN_DB,
DEFAULT_WAVEFORM_SCROLL_SPEED,
clampWaveformGainDb,
clampWaveformScrollSpeed,
} from '../../types/waveform'
import { MultibandSplitter } from './multibandSplitter'
export interface WaveformDataSource {
getPendingWaveformSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface WaveformOptions {
lineColor?: string
scrollSpeed?: number
gainDb?: number
multiband?: boolean
dataSource?: WaveformDataSource
}
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource'>>
const defaultOptions: ResolvedWaveformOptions = {
lineColor: '#38bdf8',
scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED,
gainDb: DEFAULT_WAVEFORM_GAIN_DB,
multiband: false,
}
// Band colors for multiband mode — same hues as vectorscope RGB
const BAND_LOW: [number, number, number] = [255, 68, 68] // red — bass
const BAND_MID: [number, number, number] = [68, 221, 68] // green — mids
const BAND_HIGH: [number, number, number] = [68, 136, 255] // blue — highs
const MULTIBAND_WEIGHT_EMPHASIS = 2.6
const MULTIBAND_DOMINANCE_SENSITIVITY = 5
const MULTIBAND_FOCUSED_BLEND = 0.68
const MULTIBAND_FILL_ALPHA = 0.72
const MULTIBAND_EDGE_ALPHA = 1.0
const defaultWaveformDataSource: WaveformDataSource = {
getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
}
// Calibrate 1.0x to the prior 8s window at roughly 512px wide,
// while keeping scroll speed independent from panel width.
const BASE_PIXELS_PER_SECOND = 64
function parseHexColor(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16) || 56,
parseInt(h.substring(2, 4), 16) || 189,
parseInt(h.substring(4, 6), 16) || 248,
]
}
export class Waveform {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: ResolvedWaveformOptions
private dataSource: WaveformDataSource
private animationId: number | null = null
private isRunning = false
// Offscreen canvas for scrolling content
private waterfallCanvas: HTMLCanvasElement
private waterfallCtx: CanvasRenderingContext2D
// Sample accumulator for current pixel column
private columnAccumulator: Float32Array = new Float32Array(0)
private columnAccumulatorPos = 0
private samplesPerColumn = 0
private lastSampleRate = 0
// Multiband analysis
private splitter = new MultibandSplitter()
private bandLowAcc: Float32Array = new Float32Array(0)
private bandMidAcc: Float32Array = new Float32Array(0)
private bandHighAcc: Float32Array = new Float32Array(0)
constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
this.ctx.imageSmoothingEnabled = false
const { dataSource, ...optionOverrides } = options
this.options = {
...defaultOptions,
...optionOverrides,
scrollSpeed: clampWaveformScrollSpeed(optionOverrides.scrollSpeed ?? defaultOptions.scrollSpeed),
gainDb: clampWaveformGainDb(optionOverrides.gainDb ?? defaultOptions.gainDb),
multiband: optionOverrides.multiband ?? defaultOptions.multiband,
}
this.dataSource = dataSource ?? defaultWaveformDataSource
this.waterfallCanvas = document.createElement('canvas')
this.waterfallCanvas.width = canvas.width
this.waterfallCanvas.height = canvas.height
const waterfallCtx = this.waterfallCanvas.getContext('2d')
if (!waterfallCtx) throw new Error('Could not get waterfall 2D context')
this.waterfallCtx = waterfallCtx
this.waterfallCtx.imageSmoothingEnabled = false
this.recomputeSamplesPerColumn()
}
private resetDisplay(): void {
this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height)
this.columnAccumulatorPos = 0
this.splitter.reset()
}
private recomputeSamplesPerColumn(): void {
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
const pixelsPerSecond = BASE_PIXELS_PER_SECOND * this.options.scrollSpeed
const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond))
if (next !== this.samplesPerColumn) {
this.samplesPerColumn = next
this.columnAccumulator = new Float32Array(next)
this.bandLowAcc = new Float32Array(next)
this.bandMidAcc = new Float32Array(next)
this.bandHighAcc = new Float32Array(next)
this.columnAccumulatorPos = 0
}
this.lastSampleRate = sampleRate
this.splitter.configure(sampleRate)
}
setOptions(options: Partial<WaveformOptions>): void {
const { dataSource, ...optionUpdates } = options
const nextOptions: ResolvedWaveformOptions = {
...this.options,
...optionUpdates,
lineColor: optionUpdates.lineColor ?? this.options.lineColor,
scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed),
gainDb: clampWaveformGainDb(optionUpdates.gainDb ?? this.options.gainDb),
multiband: optionUpdates.multiband ?? this.options.multiband,
}
const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed
const multibandChanged = nextOptions.multiband !== this.options.multiband
this.options = nextOptions
if (dataSource) {
this.dataSource = dataSource
}
if (speedChanged) {
this.recomputeSamplesPerColumn()
this.resetDisplay()
}
if (multibandChanged) {
this.splitter.reset()
this.resetDisplay()
}
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.draw()
}
stop(): void {
this.isRunning = false
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
resize(): void {
// Resize handled in draw loop
}
private computeMinMax(): { min: number; max: number } {
let min = this.columnAccumulator[0]
let max = this.columnAccumulator[0]
for (let i = 1; i < this.columnAccumulatorPos; i++) {
const s = this.columnAccumulator[i]
if (s < min) min = s
if (s > max) max = s
}
return { min, max }
}
private computeBandColor(): [number, number, number] {
const n = this.columnAccumulatorPos
if (n === 0) return BAND_MID
// Compute RMS energy for each band
let lowSum = 0
let midSum = 0
let highSum = 0
for (let i = 0; i < n; i++) {
const l = this.bandLowAcc[i]
const m = this.bandMidAcc[i]
const h = this.bandHighAcc[i]
lowSum += l * l
midSum += m * m
highSum += h * h
}
const lowRms = Math.sqrt(lowSum / n)
const midRms = Math.sqrt(midSum / n)
const highRms = Math.sqrt(highSum / n)
const total = lowRms + midRms + highRms
if (total < 1e-10) return BAND_MID
const emphasizedWeights = [
Math.pow(lowRms / total, MULTIBAND_WEIGHT_EMPHASIS),
Math.pow(midRms / total, MULTIBAND_WEIGHT_EMPHASIS),
Math.pow(highRms / total, MULTIBAND_WEIGHT_EMPHASIS),
] as const
const emphasizedTotal = emphasizedWeights[0] + emphasizedWeights[1] + emphasizedWeights[2]
if (emphasizedTotal < 1e-10) return BAND_MID
const normalizedBands = [
{ color: BAND_LOW, weight: emphasizedWeights[0] / emphasizedTotal },
{ color: BAND_MID, weight: emphasizedWeights[1] / emphasizedTotal },
{ color: BAND_HIGH, weight: emphasizedWeights[2] / emphasizedTotal },
] as const
const blended: [number, number, number] = [
Math.round(normalizedBands[0].color[0] * normalizedBands[0].weight + normalizedBands[1].color[0] * normalizedBands[1].weight + normalizedBands[2].color[0] * normalizedBands[2].weight),
Math.round(normalizedBands[0].color[1] * normalizedBands[0].weight + normalizedBands[1].color[1] * normalizedBands[1].weight + normalizedBands[2].color[1] * normalizedBands[2].weight),
Math.round(normalizedBands[0].color[2] * normalizedBands[0].weight + normalizedBands[1].color[2] * normalizedBands[1].weight + normalizedBands[2].color[2] * normalizedBands[2].weight),
]
const sortedBands = [...normalizedBands].sort((left, right) => right.weight - left.weight)
const dominance = Math.max(0, Math.min(1, (sortedBands[0].weight - sortedBands[1].weight) * MULTIBAND_DOMINANCE_SENSITIVITY))
const dominantMix = 0.78 + (0.14 * dominance)
const secondaryMix = 1 - dominantMix
const focused: [number, number, number] = [
Math.round(sortedBands[0].color[0] * dominantMix + sortedBands[1].color[0] * secondaryMix),
Math.round(sortedBands[0].color[1] * dominantMix + sortedBands[1].color[1] * secondaryMix),
Math.round(sortedBands[0].color[2] * dominantMix + sortedBands[1].color[2] * secondaryMix),
]
const focusBlend = MULTIBAND_FOCUSED_BLEND + ((1 - MULTIBAND_FOCUSED_BLEND) * dominance)
return [
Math.round(blended[0] * (1 - focusBlend) + focused[0] * focusBlend),
Math.round(blended[1] * (1 - focusBlend) + focused[1] * focusBlend),
Math.round(blended[2] * (1 - focusBlend) + focused[2] * focusBlend),
]
}
private shiftAndPaintColumn(min: number, max: number, width: number, height: number): void {
// Shift existing content left by 1 pixel — use 'copy' to avoid
// alpha accumulation from source-over compositing on semi-transparent pixels
this.waterfallCtx.globalCompositeOperation = 'copy'
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
this.waterfallCtx.globalCompositeOperation = 'source-over'
const centerY = height / 2
const amplitudeGain = Math.pow(10, this.options.gainDb / 20)
const scaledMin = Math.max(-1, Math.min(1, min * amplitudeGain))
const scaledMax = Math.max(-1, Math.min(1, max * amplitudeGain))
const displayMargin = 0.95 // slight margin so full-scale doesn't clip at edge
const yTop = Math.round(centerY - scaledMax * centerY * displayMargin)
const yBottom = Math.round(centerY - scaledMin * centerY * displayMargin)
const lineHeight = Math.max(1, yBottom - yTop)
let r: number, g: number, b: number
if (this.options.multiband) {
;[r, g, b] = this.computeBandColor()
} else {
;[r, g, b] = parseHexColor(this.options.lineColor)
}
const fillAlpha = this.options.multiband ? MULTIBAND_FILL_ALPHA : 0.55
const edgeAlpha = this.options.multiband ? MULTIBAND_EDGE_ALPHA : 0.9
// Draw the amplitude column — brighter at the edges, dimmer in the middle
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${fillAlpha})`
this.waterfallCtx.fillRect(width - 1, yTop, 1, lineHeight)
// Bright edge pixels at min/max
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${edgeAlpha})`
this.waterfallCtx.fillRect(width - 1, yTop, 1, 1)
if (lineHeight > 1) {
this.waterfallCtx.fillRect(width - 1, yBottom - 1, 1, 1)
}
}
private drawGrid(width: number, height: number): void {
const ctx = this.ctx
const centerY = height / 2
// Center line (zero crossing)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, centerY)
ctx.lineTo(width, centerY)
ctx.stroke()
// ±0.5 guide lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)'
const quarterY = centerY * 0.5
ctx.beginPath()
ctx.moveTo(0, quarterY)
ctx.lineTo(width, quarterY)
ctx.moveTo(0, height - quarterY)
ctx.lineTo(width, height - quarterY)
ctx.stroke()
}
private draw = (): void => {
if (!this.isRunning) return
const width = this.canvas.width
const height = this.canvas.height
if (width <= 0 || height <= 0) {
this.animationId = requestAnimationFrame(this.draw)
return
}
this.ctx.imageSmoothingEnabled = false
// Handle resize: preserve existing content anchored to right edge
if (this.waterfallCanvas.width !== width || this.waterfallCanvas.height !== height) {
const previousCanvas = document.createElement('canvas')
previousCanvas.width = this.waterfallCanvas.width
previousCanvas.height = this.waterfallCanvas.height
const previousCtx = previousCanvas.getContext('2d')
if (previousCtx) {
previousCtx.drawImage(this.waterfallCanvas, 0, 0)
}
this.waterfallCanvas.width = width
this.waterfallCanvas.height = height
this.waterfallCtx.imageSmoothingEnabled = false
if (previousCtx && previousCanvas.width > 0 && previousCanvas.height > 0) {
const srcX = Math.max(0, previousCanvas.width - width)
const srcW = Math.min(previousCanvas.width, width)
const dstX = Math.max(0, width - previousCanvas.width)
this.waterfallCtx.drawImage(
previousCanvas,
srcX, 0, srcW, previousCanvas.height,
dstX, 0, srcW, height
)
}
this.recomputeSamplesPerColumn()
}
// Handle sample rate changes
const sampleRate = this.dataSource.getSampleRate()
if (Math.abs(sampleRate - this.lastSampleRate) > 100) {
this.recomputeSamplesPerColumn()
}
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingWaveformSamples() // drain
// Freeze display — show last waveform
this.ctx.clearRect(0, 0, width, height)
this.drawGrid(width, height)
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
this.animationId = requestAnimationFrame(this.draw)
return
}
const pending = this.dataSource.getPendingWaveformSamples()
const samplesPerCol = this.samplesPerColumn
const multiband = this.options.multiband
if (samplesPerCol > 0) {
for (const chunk of pending) {
// When multiband is enabled, split each chunk through the crossover filters
let lowBand: Float32Array | null = null
let midBand: Float32Array | null = null
let highBand: Float32Array | null = null
if (multiband) {
const bands = this.splitter.split(chunk, chunk)
lowBand = bands.low.left
midBand = bands.mid.left
highBand = bands.high.left
}
for (let i = 0; i < chunk.length; i++) {
this.columnAccumulator[this.columnAccumulatorPos] = chunk[i]
if (multiband && lowBand && midBand && highBand) {
this.bandLowAcc[this.columnAccumulatorPos] = lowBand[i]
this.bandMidAcc[this.columnAccumulatorPos] = midBand[i]
this.bandHighAcc[this.columnAccumulatorPos] = highBand[i]
}
this.columnAccumulatorPos++
if (this.columnAccumulatorPos >= samplesPerCol) {
const { min, max } = this.computeMinMax()
this.shiftAndPaintColumn(min, max, width, height)
this.columnAccumulatorPos = 0
}
}
}
}
this.ctx.clearRect(0, 0, width, height)
this.drawGrid(width, height)
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
this.animationId = requestAnimationFrame(this.draw)
}
dispose(): void {
this.stop()
}
}
@@ -0,0 +1,279 @@
/**
* 3-band crossover filter for multiband vectorscope coloring.
*
* Splits stereo audio into Low / Mid / High frequency bands using
* 2nd-order Butterworth biquad filters (Linkwitz-Riley style crossover).
*
* Crossover frequencies:
* Low ↔ Mid : 250 Hz
* Mid ↔ High: 4000 Hz
*
* Each band carries independent left/right channels so the vectorscope
* can render each band's stereo image in a distinct color.
*/
const LOW_MID_CROSSOVER = 250
const MID_HIGH_CROSSOVER = 4000
export const BAND_COLORS = {
low: '#ff4444', // Red — bass
mid: '#44dd44', // Green — mids
high: '#4488ff', // Blue — highs
} as const
export interface MultibandChunk {
low: { left: Float32Array; right: Float32Array }
mid: { left: Float32Array; right: Float32Array }
high: { left: Float32Array; right: Float32Array }
}
// ---------- Biquad filter ----------
class BiquadFilter {
private b0 = 1
private b1 = 0
private b2 = 0
private a1 = 0
private a2 = 0
private x1 = 0
private x2 = 0
private y1 = 0
private y2 = 0
setLowpass(freq: number, sampleRate: number): void {
const w0 = (2 * Math.PI * freq) / sampleRate
const cosw0 = Math.cos(w0)
const sinw0 = Math.sin(w0)
const alpha = sinw0 / (2 * Math.SQRT2) // Q = 1/sqrt(2) for Butterworth
const a0 = 1 + alpha
this.b0 = ((1 - cosw0) / 2) / a0
this.b1 = (1 - cosw0) / a0
this.b2 = ((1 - cosw0) / 2) / a0
this.a1 = (-2 * cosw0) / a0
this.a2 = (1 - alpha) / a0
}
setHighpass(freq: number, sampleRate: number): void {
const w0 = (2 * Math.PI * freq) / sampleRate
const cosw0 = Math.cos(w0)
const sinw0 = Math.sin(w0)
const alpha = sinw0 / (2 * Math.SQRT2)
const a0 = 1 + alpha
this.b0 = ((1 + cosw0) / 2) / a0
this.b1 = (-(1 + cosw0)) / a0
this.b2 = ((1 + cosw0) / 2) / a0
this.a1 = (-2 * cosw0) / a0
this.a2 = (1 - alpha) / a0
}
process(input: Float32Array, output: Float32Array): void {
for (let i = 0; i < input.length; i++) {
const x0 = input[i]
const y0 = this.b0 * x0 + this.b1 * this.x1 + this.b2 * this.x2
- this.a1 * this.y1 - this.a2 * this.y2
this.x2 = this.x1
this.x1 = x0
this.y2 = this.y1
this.y1 = y0
output[i] = y0
}
}
reset(): void {
this.x1 = 0
this.x2 = 0
this.y1 = 0
this.y2 = 0
}
}
// ---------- MultibandSplitter ----------
/**
* Stateful 3-band stereo crossover filter.
*
* Call `configure(sampleRate)` whenever the sample rate changes,
* then `split(left, right)` for each stereo chunk.
*/
export class MultibandSplitter {
// Low band: lowpass at LOW_MID_CROSSOVER (L + R)
private lowLpL = new BiquadFilter()
private lowLpR = new BiquadFilter()
// Mid band: highpass at LOW_MID_CROSSOVER → lowpass at MID_HIGH_CROSSOVER (L + R)
private midHpL = new BiquadFilter()
private midHpR = new BiquadFilter()
private midLpL = new BiquadFilter()
private midLpR = new BiquadFilter()
// High band: highpass at MID_HIGH_CROSSOVER (L + R)
private highHpL = new BiquadFilter()
private highHpR = new BiquadFilter()
private configuredSampleRate = 0
configure(sampleRate: number): void {
if (sampleRate === this.configuredSampleRate) return
this.configuredSampleRate = sampleRate
this.lowLpL.setLowpass(LOW_MID_CROSSOVER, sampleRate)
this.lowLpR.setLowpass(LOW_MID_CROSSOVER, sampleRate)
this.midHpL.setHighpass(LOW_MID_CROSSOVER, sampleRate)
this.midHpR.setHighpass(LOW_MID_CROSSOVER, sampleRate)
this.midLpL.setLowpass(MID_HIGH_CROSSOVER, sampleRate)
this.midLpR.setLowpass(MID_HIGH_CROSSOVER, sampleRate)
this.highHpL.setHighpass(MID_HIGH_CROSSOVER, sampleRate)
this.highHpR.setHighpass(MID_HIGH_CROSSOVER, sampleRate)
this.reset()
}
split(left: Float32Array, right: Float32Array): MultibandChunk {
const n = left.length
const lowL = new Float32Array(n)
const lowR = new Float32Array(n)
const midL = new Float32Array(n)
const midR = new Float32Array(n)
const highL = new Float32Array(n)
const highR = new Float32Array(n)
// Temp buffers for mid band (highpass then lowpass)
const midTmpL = new Float32Array(n)
const midTmpR = new Float32Array(n)
// Low band
this.lowLpL.process(left, lowL)
this.lowLpR.process(right, lowR)
// Mid band (highpass → lowpass)
this.midHpL.process(left, midTmpL)
this.midHpR.process(right, midTmpR)
this.midLpL.process(midTmpL, midL)
this.midLpR.process(midTmpR, midR)
// High band
this.highHpL.process(left, highL)
this.highHpR.process(right, highR)
return {
low: { left: lowL, right: lowR },
mid: { left: midL, right: midR },
high: { left: highL, right: highR },
}
}
reset(): void {
this.lowLpL.reset()
this.lowLpR.reset()
this.midHpL.reset()
this.midHpR.reset()
this.midLpL.reset()
this.midLpR.reset()
this.highHpL.reset()
this.highHpR.reset()
}
}
// ---------- MultibandBuffer ----------
const MULTIBAND_BUFFER_SIZE = 4096
interface BandRingBuffer {
left: Float32Array
right: Float32Array
}
/**
* Circular buffer that accumulates band-split stereo samples.
*
* Mirrors the native vectorscope's circular buffer so that each frame
* can re-draw ALL buffered points (not just newly-arrived ones),
* producing the same dense persistence effect as the single-color path.
*/
export class MultibandBuffer {
private buffers: Record<'low' | 'mid' | 'high', BandRingBuffer>
private writePos = 0
private validSamples = 0
private readonly capacity: number
constructor(capacity = MULTIBAND_BUFFER_SIZE) {
this.capacity = capacity
this.buffers = {
low: { left: new Float32Array(capacity), right: new Float32Array(capacity) },
mid: { left: new Float32Array(capacity), right: new Float32Array(capacity) },
high: { left: new Float32Array(capacity), right: new Float32Array(capacity) },
}
}
push(bands: MultibandChunk): void {
const n = bands.low.left.length
for (let i = 0; i < n; i++) {
const pos = this.writePos
this.buffers.low.left[pos] = bands.low.left[i]
this.buffers.low.right[pos] = bands.low.right[i]
this.buffers.mid.left[pos] = bands.mid.left[i]
this.buffers.mid.right[pos] = bands.mid.right[i]
this.buffers.high.left[pos] = bands.high.left[i]
this.buffers.high.right[pos] = bands.high.right[i]
this.writePos = (pos + 1) % this.capacity
if (this.validSamples < this.capacity) {
this.validSamples++
}
}
}
/**
* Returns the most recent `maxPoints` samples for each band,
* ordered oldest-first (matching the native getPoints() convention).
*/
getPoints(maxPoints: number): {
bands: Record<'low' | 'mid' | 'high', { left: Float32Array; right: Float32Array }>
count: number
} {
const count = Math.min(maxPoints, this.validSamples)
if (count === 0) {
return {
bands: {
low: { left: new Float32Array(0), right: new Float32Array(0) },
mid: { left: new Float32Array(0), right: new Float32Array(0) },
high: { left: new Float32Array(0), right: new Float32Array(0) },
},
count: 0,
}
}
const out = {
low: { left: new Float32Array(count), right: new Float32Array(count) },
mid: { left: new Float32Array(count), right: new Float32Array(count) },
high: { left: new Float32Array(count), right: new Float32Array(count) },
}
for (let i = 0; i < count; i++) {
const idx = (this.writePos + this.capacity - count + i) % this.capacity
out.low.left[i] = this.buffers.low.left[idx]
out.low.right[i] = this.buffers.low.right[idx]
out.mid.left[i] = this.buffers.mid.left[idx]
out.mid.right[i] = this.buffers.mid.right[idx]
out.high.left[i] = this.buffers.high.left[idx]
out.high.right[i] = this.buffers.high.right[idx]
}
return { bands: out, count }
}
reset(): void {
for (const band of ['low', 'mid', 'high'] as const) {
this.buffers[band].left.fill(0)
this.buffers[band].right.fill(0)
}
this.writePos = 0
this.validSamples = 0
}
}
@@ -0,0 +1,321 @@
import type { VectorscopeMode } from './Vectorscope'
const INV_SQRT2 = 1 / Math.sqrt(2)
const COS45 = Math.SQRT2 / 2 // 0.7071...
export interface VectorscopeLayout {
centerX: number
centerY: number
radius: number
}
/**
* Compute the center point and radius for a vectorscope mode.
*
* Unipolar modes place the center at the bottom of the canvas so the
* semicircle/triangle fills the full vertical space.
* Bipolar and Lissajous center in the canvas.
*/
export function getVectorscopeLayout(
width: number,
height: number,
mode: VectorscopeMode
): VectorscopeLayout {
const centerX = width / 2
const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar'
if (isUnipolar) {
// Center near the bottom; radius fills upward
const margin = height * 0.04
const centerY = height - margin
const radius = Math.min(width / 2, height - margin) * 0.88
return { centerX, centerY, radius }
}
// Bipolar / Lissajous: centered
const radius = Math.min(width, height) / 2 * 0.9
return { centerX, centerY: height / 2, radius }
}
/**
* Transform raw L/R sample values into display coordinates based on mode.
* Returns null for points filtered out by unipolar modes (mid < 0).
*
* dx/dy are in normalized space: positive dx = right, positive dy = up.
* Caller maps to canvas: canvasX = centerX + dx * scale, canvasY = centerY - dy * scale.
*
* Polar modes apply sqrt amplitude scaling so points follow the circular
* contours instead of forming diamond/linear patterns.
*/
export function transformPoint(
L: number,
R: number,
mode: VectorscopeMode
): { dx: number; dy: number } | null {
if (mode === 'lissajous') {
return { dx: R, dy: L }
}
// M/S transform (45° rotation), normalized to preserve amplitude range
const mid = (L + R) * INV_SQRT2
const side = (R - L) * INV_SQRT2
// Unipolar: filter out negative mid (anti-phase / lower half)
const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar'
if (isUnipolar && mid < 0) {
return null
}
const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar'
if (isPolar) {
// Amplitude-compressed radial scaling: pushes points toward circular contours.
// Power < 1 compresses dynamic range — lower = more circular.
// 0.5 = sqrt (mild), 0.33 = cube root (moderate), 0.25 = fourth root (strong)
const ampSq = mid * mid + side * side
if (ampSq < 1e-12) {
return { dx: 0, dy: 0 }
}
const amp = Math.sqrt(ampSq)
const scaledAmp = Math.pow(amp, 0.35)
const factor = scaledAmp / amp
return { dx: side * factor, dy: mid * factor }
}
// Linear modes: direct M/S Cartesian mapping
return { dx: side, dy: mid }
}
/**
* Draw the Lissajous grid: crosshairs + box boundary.
*/
export function drawLissajousGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.lineWidth = dpr
// Outer box
ctx.strokeRect(
centerX - radius,
centerY - radius,
radius * 2,
radius * 2
)
// Vertical crosshair
ctx.beginPath()
ctx.moveTo(centerX, centerY - radius)
ctx.lineTo(centerX, centerY + radius)
ctx.stroke()
// Horizontal crosshair
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY)
ctx.stroke()
// Diagonal guides (dimmer)
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
ctx.strokeStyle = dimColor
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY - radius)
ctx.lineTo(centerX + radius, centerY + radius)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(centerX + radius, centerY - radius)
ctx.lineTo(centerX - radius, centerY + radius)
ctx.stroke()
// Labels
ctx.fillStyle = gridColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.fillText('L', centerX, centerY - radius - 6 * dpr)
ctx.fillText('R', centerX + radius + 12 * dpr, centerY + 4 * dpr)
}
/**
* Draw the Polar (Scaled) grid: concentric circles + crosshairs.
*/
export function drawPolarGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
unipolar: boolean,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.lineWidth = dpr
// Concentric circles (or semicircles for unipolar)
const rings = [0.25, 0.5, 0.75, 1.0]
for (const scale of rings) {
ctx.beginPath()
if (unipolar) {
ctx.arc(centerX, centerY, radius * scale, Math.PI, 0, false)
} else {
ctx.arc(centerX, centerY, radius * scale, 0, Math.PI * 2)
}
ctx.stroke()
}
// Vertical crosshair (mono axis)
ctx.beginPath()
ctx.moveTo(centerX, centerY - radius)
if (unipolar) {
ctx.lineTo(centerX, centerY)
} else {
ctx.lineTo(centerX, centerY + radius)
}
ctx.stroke()
// Horizontal crosshair (side axis)
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY)
ctx.stroke()
// Diagonal guides (L and R channel axes) — dimmer
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
ctx.strokeStyle = dimColor
if (unipolar) {
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX - radius * COS45, centerY - radius * COS45)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX + radius * COS45, centerY - radius * COS45)
ctx.stroke()
} else {
ctx.beginPath()
ctx.moveTo(centerX - radius * COS45, centerY - radius * COS45)
ctx.lineTo(centerX + radius * COS45, centerY + radius * COS45)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(centerX + radius * COS45, centerY - radius * COS45)
ctx.lineTo(centerX - radius * COS45, centerY + radius * COS45)
ctx.stroke()
}
// Labels
ctx.fillStyle = gridColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.fillText('+', centerX, centerY - radius - 6 * dpr)
ctx.fillText('L', centerX - radius * COS45 - 10 * dpr, centerY - radius * COS45 - 4 * dpr)
ctx.fillText('R', centerX + radius * COS45 + 10 * dpr, centerY - radius * COS45 - 4 * dpr)
if (!unipolar) {
ctx.fillText('-', centerX, centerY + radius + 14 * dpr)
}
}
/**
* Draw the Linear grid: diamond/triangle guides.
*/
export function drawLinearGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
unipolar: boolean,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.lineWidth = dpr
const scales = [0.25, 0.5, 0.75, 1.0]
for (const scale of scales) {
const r = radius * scale
ctx.beginPath()
if (unipolar) {
ctx.moveTo(centerX, centerY - r) // top (mono)
ctx.lineTo(centerX - r, centerY) // left (L)
ctx.lineTo(centerX + r, centerY) // right (R)
ctx.closePath()
} else {
ctx.moveTo(centerX, centerY - r) // top (mono)
ctx.lineTo(centerX + r, centerY) // right (R)
ctx.lineTo(centerX, centerY + r) // bottom (anti-phase)
ctx.lineTo(centerX - r, centerY) // left (L)
ctx.closePath()
}
ctx.stroke()
}
// Vertical crosshair
ctx.beginPath()
ctx.moveTo(centerX, centerY - radius)
if (unipolar) {
ctx.lineTo(centerX, centerY)
} else {
ctx.lineTo(centerX, centerY + radius)
}
ctx.stroke()
// Horizontal crosshair
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY)
ctx.stroke()
// Labels
ctx.fillStyle = gridColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.fillText('+', centerX, centerY - radius - 6 * dpr)
ctx.fillText('L', centerX - radius - 12 * dpr, centerY + 4 * dpr)
ctx.fillText('R', centerX + radius + 12 * dpr, centerY + 4 * dpr)
if (!unipolar) {
ctx.fillText('-', centerX, centerY + radius + 14 * dpr)
}
}
/**
* Draw the appropriate grid for a given vectorscope mode.
*/
export function drawVectorscopeGridForMode(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
gridColor: string,
mode: VectorscopeMode,
dpr: number = 1
): void {
const layout = getVectorscopeLayout(width, height, mode)
switch (mode) {
case 'lissajous':
drawLissajousGrid(ctx, layout, gridColor, dpr)
break
case 'polar-unipolar':
drawPolarGrid(ctx, layout, gridColor, true, dpr)
break
case 'polar-bipolar':
drawPolarGrid(ctx, layout, gridColor, false, dpr)
break
case 'linear-unipolar':
drawLinearGrid(ctx, layout, gridColor, true, dpr)
break
case 'linear-bipolar':
drawLinearGrid(ctx, layout, gridColor, false, dpr)
break
}
}
+9
View File
@@ -0,0 +1,9 @@
export type LUFSMeterMode = 'bar'
export const LUFS_METER_MODES: readonly LUFSMeterMode[] = ['bar']
export const DEFAULT_LUFS_METER_MODE: LUFSMeterMode = 'bar'
export function isLUFSMeterMode(value: unknown): value is LUFSMeterMode {
return typeof value === 'string' && LUFS_METER_MODES.includes(value as LUFSMeterMode)
}
+11
View File
@@ -0,0 +1,11 @@
export type ScopeKind = 'spectrum' | 'oscilloscope' | 'vectorscope' | 'spectrogram' | 'vumeter' | 'lufsmeter' | 'waveform'
export const SCOPE_KINDS: ScopeKind[] = [
'spectrum',
'oscilloscope',
'vectorscope',
'spectrogram',
'vumeter',
'lufsmeter',
'waveform',
]
+38
View File
@@ -0,0 +1,38 @@
export type SpectrogramClarityMode = 'classic' | 'sharp' | 'sharper'
export type SpectrogramScaleMode = 'mel' | 'log' | 'linear'
export const SPECTROGRAM_CLARITY_MODES: readonly SpectrogramClarityMode[] = [
'classic',
'sharp',
'sharper',
]
export const SPECTROGRAM_SCALE_MODES: readonly SpectrogramScaleMode[] = [
'mel',
'log',
'linear',
]
export const DEFAULT_SPECTROGRAM_CLARITY_MODE: SpectrogramClarityMode = 'sharper'
export const DEFAULT_SPECTROGRAM_SCALE_MODE: SpectrogramScaleMode = 'log'
export const MIN_SPECTROGRAM_SCROLL_SPEED = 0.5
export const MAX_SPECTROGRAM_SCROLL_SPEED = 4
export const SPECTROGRAM_SCROLL_SPEED_STEP = 0.5
export const DEFAULT_SPECTROGRAM_SCROLL_SPEED = 2
export function isSpectrogramClarityMode(value: unknown): value is SpectrogramClarityMode {
return typeof value === 'string' && SPECTROGRAM_CLARITY_MODES.includes(value as SpectrogramClarityMode)
}
export function isSpectrogramScaleMode(value: unknown): value is SpectrogramScaleMode {
return typeof value === 'string' && SPECTROGRAM_SCALE_MODES.includes(value as SpectrogramScaleMode)
}
export function clampSpectrogramScrollSpeed(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return DEFAULT_SPECTROGRAM_SCROLL_SPEED
}
const snapped = Math.round(numeric / SPECTROGRAM_SCROLL_SPEED_STEP) * SPECTROGRAM_SCROLL_SPEED_STEP
return Math.min(MAX_SPECTROGRAM_SCROLL_SPEED, Math.max(MIN_SPECTROGRAM_SCROLL_SPEED, snapped))
}
+37
View File
@@ -0,0 +1,37 @@
export const DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE = 2.0
export const MIN_SPECTRUM_TILT_DB_PER_OCTAVE = -2.0
export const MAX_SPECTRUM_TILT_DB_PER_OCTAVE = 8.0
export const SPECTRUM_TILT_STEP = 0.1
export const DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE = 2.0
export const MIN_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE = -2.0
export const MAX_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE = 8.0
export const SPECTRUM_HEATMAP_TILT_STEP = 0.1
export function clampSpectrumTiltDbPerOctave(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE
}
const snapped = Math.round(numeric / SPECTRUM_TILT_STEP) * SPECTRUM_TILT_STEP
const rounded = Math.round(snapped * 10) / 10
return Math.min(
MAX_SPECTRUM_TILT_DB_PER_OCTAVE,
Math.max(MIN_SPECTRUM_TILT_DB_PER_OCTAVE, rounded)
)
}
export function clampSpectrumHeatmapTiltDbPerOctave(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE
}
const snapped = Math.round(numeric / SPECTRUM_HEATMAP_TILT_STEP) * SPECTRUM_HEATMAP_TILT_STEP
const rounded = Math.round(snapped * 10) / 10
return Math.min(
MAX_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
Math.max(MIN_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, rounded)
)
}
+16
View File
@@ -0,0 +1,16 @@
export type VUMeterMode = 'needle' | 'bar'
export type VUMeterOrientation = 'horizontal' | 'vertical'
export const VU_METER_MODES: readonly VUMeterMode[] = ['needle', 'bar']
export const VU_METER_ORIENTATIONS: readonly VUMeterOrientation[] = ['horizontal', 'vertical']
export const DEFAULT_VU_METER_MODE: VUMeterMode = 'bar'
export const DEFAULT_VU_METER_ORIENTATION: VUMeterOrientation = 'horizontal'
export function isVUMeterMode(value: unknown): value is VUMeterMode {
return typeof value === 'string' && VU_METER_MODES.includes(value as VUMeterMode)
}
export function isVUMeterOrientation(value: unknown): value is VUMeterOrientation {
return typeof value === 'string' && VU_METER_ORIENTATIONS.includes(value as VUMeterOrientation)
}
+29
View File
@@ -0,0 +1,29 @@
export const MIN_WAVEFORM_SCROLL_SPEED = 0.5
export const MAX_WAVEFORM_SCROLL_SPEED = 8
export const WAVEFORM_SCROLL_SPEED_STEP = 0.5
export const DEFAULT_WAVEFORM_SCROLL_SPEED = 1
export const MIN_WAVEFORM_GAIN_DB = -12
export const MAX_WAVEFORM_GAIN_DB = 18
export const WAVEFORM_GAIN_DB_STEP = 0.5
export const DEFAULT_WAVEFORM_GAIN_DB = 0
export function clampWaveformScrollSpeed(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return DEFAULT_WAVEFORM_SCROLL_SPEED
}
const snapped = Math.round(numeric / WAVEFORM_SCROLL_SPEED_STEP) * WAVEFORM_SCROLL_SPEED_STEP
return Math.min(MAX_WAVEFORM_SCROLL_SPEED, Math.max(MIN_WAVEFORM_SCROLL_SPEED, snapped))
}
export function clampWaveformGainDb(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return DEFAULT_WAVEFORM_GAIN_DB
}
const snapped = Math.round(numeric / WAVEFORM_GAIN_DB_STEP) * WAVEFORM_GAIN_DB_STEP
const rounded = Math.round(snapped * 10) / 10
return Math.min(MAX_WAVEFORM_GAIN_DB, Math.max(MIN_WAVEFORM_GAIN_DB, rounded))
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["electron-vite.config.ts"]
}