mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
initial concept
This commit is contained in:
+27
@@ -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*
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
@@ -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"]
|
||||
}]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+7938
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["electron-vite.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user