mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
waveform VST
This commit is contained in:
@@ -62,6 +62,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
|
||||
${PRISM_NATIVE_DIR}/vectorscope.cpp
|
||||
${PRISM_NATIVE_DIR}/multiband.cpp
|
||||
${PRISM_NATIVE_DIR}/spectrogram.cpp
|
||||
${PRISM_NATIVE_DIR}/waveform.cpp
|
||||
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
|
||||
|
||||
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
|
||||
@@ -103,3 +104,4 @@ add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER
|
||||
add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1")
|
||||
add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1")
|
||||
add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1")
|
||||
add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "LUFSMeterEngine.h"
|
||||
#include "VectorscopeEngine.h"
|
||||
#include "SpectrogramEngine.h"
|
||||
#include "WaveformEngine.h"
|
||||
#include <cstring>
|
||||
|
||||
#if ! PRISM_USE_DEV_SERVER
|
||||
@@ -22,7 +23,9 @@ namespace
|
||||
|
||||
std::unique_ptr<ScopeEngine> makeEngine()
|
||||
{
|
||||
#if defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
|
||||
#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM
|
||||
return std::make_unique<WaveformEngine>();
|
||||
#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
|
||||
return std::make_unique<SpectrogramEngine>();
|
||||
#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
|
||||
return std::make_unique<VectorscopeEngine>();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include "ScopeEngine.h"
|
||||
#include "waveform.h" // reused, unmodified, from native/src
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
* Waveform engine. Runs the reused Visualizer::WaveformMultibandAnalyzer, which
|
||||
* summarizes audio into per-column min/max + 3-band RMS. Unlike the spectrogram, the
|
||||
* column width (samplesPerColumn) is a pure function of sampleRate and scrollSpeed —
|
||||
* sampleRate / (128 * scrollSpeed), the same formula the renderer uses — so the engine
|
||||
* derives it itself; no canvas round-trip. mode ('stereo' vs 'mono') selects
|
||||
* processStereo (stride 10) vs processMono (stride 5), flagged in the frame. multiband
|
||||
* is render-only (the analyzer always emits the band RMS columns use for coloring).
|
||||
*/
|
||||
class WaveformEngine : public ScopeEngine
|
||||
{
|
||||
public:
|
||||
WaveformEngine() { reconfigure(); }
|
||||
|
||||
const char* scopeId() const override { return "waveform"; }
|
||||
juce::Identifier frameEventId() const override { return frameId; }
|
||||
|
||||
void setSampleRate(double sr) override
|
||||
{
|
||||
if (sr > 0.0 && (float) sr != sampleRate)
|
||||
{
|
||||
sampleRate = (float) sr;
|
||||
reconfigure();
|
||||
}
|
||||
}
|
||||
|
||||
void configure(const juce::var& settings) override
|
||||
{
|
||||
stereo = settings.getProperty("mode", "mono").toString() == "stereo";
|
||||
const auto speed = (float) settings.getProperty("scrollSpeed", (double) scrollSpeed);
|
||||
if (speed > 0.0f)
|
||||
scrollSpeed = speed;
|
||||
reconfigure();
|
||||
}
|
||||
|
||||
void process(const float* left, const float* right, int numSamples) override
|
||||
{
|
||||
if (numSamples <= 0)
|
||||
return;
|
||||
|
||||
if (stereo)
|
||||
{
|
||||
const auto& cols = wave.processStereo(left, right, (size_t) numSamples);
|
||||
pending.insert(pending.end(), cols.begin(), cols.end());
|
||||
}
|
||||
else
|
||||
{
|
||||
if ((int) mono.size() < numSamples)
|
||||
mono.resize((size_t) numSamples);
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
|
||||
const auto& cols = wave.processMono(mono.data(), (size_t) numSamples);
|
||||
pending.insert(pending.end(), cols.begin(), cols.end());
|
||||
}
|
||||
}
|
||||
|
||||
juce::var buildFrame(double sr) override
|
||||
{
|
||||
const size_t stride = stereo ? Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE
|
||||
: Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
|
||||
auto* obj = new juce::DynamicObject();
|
||||
obj->setProperty("sampleRate", sr);
|
||||
obj->setProperty("stereo", stereo);
|
||||
obj->setProperty("columnCount", (int) (pending.size() / stride));
|
||||
if (! pending.empty())
|
||||
obj->setProperty("summaries", juce::Base64::toBase64(pending.data(), pending.size() * sizeof(float)));
|
||||
else
|
||||
obj->setProperty("summaries", juce::String());
|
||||
pending.clear();
|
||||
return juce::var(obj);
|
||||
}
|
||||
|
||||
private:
|
||||
void reconfigure()
|
||||
{
|
||||
const float pps = 128.0f * std::max(0.01f, scrollSpeed);
|
||||
samplesPerColumn = (size_t) std::max(1L, std::lround(sampleRate / pps));
|
||||
wave.configure(sampleRate, samplesPerColumn);
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
const juce::Identifier frameId { "waveformFrame" };
|
||||
Visualizer::WaveformMultibandAnalyzer wave;
|
||||
std::vector<float> mono, pending;
|
||||
float sampleRate = 48000.0f;
|
||||
float scrollSpeed = 1.0f;
|
||||
size_t samplesPerColumn = 1;
|
||||
bool stereo = false;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { WaveformNativeAnalyzer } from '../renderer/audio/native'
|
||||
|
||||
interface QueuedColumns {
|
||||
summaries: Float32Array
|
||||
stereo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in `WaveformNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The waveform DSP (per-column min/max + 3-band RMS) runs in the C++ plugin, which
|
||||
* pushes finished column summaries each frame — stride 10 in stereo mode, stride 5
|
||||
* in mono. This shim queues them and serves whichever the visualizer asks for:
|
||||
* `processStereo`/`processMono` return the queued summaries matching that mode and
|
||||
* clear the queue (so a mode switch never returns mismatched-stride data, and the
|
||||
* queue can't grow unbounded). `configure` is a no-op — the engine derives
|
||||
* samplesPerColumn itself from the host sample rate + scroll speed.
|
||||
*/
|
||||
export class BridgeWaveformAnalyzer implements WaveformNativeAnalyzer {
|
||||
private queue: QueuedColumns[] = []
|
||||
|
||||
/** Called by the bridge when the host emits a waveform frame. */
|
||||
pushFrame(summaries: Float32Array, stereo: boolean): void {
|
||||
if (summaries.length === 0) return
|
||||
this.queue.push({ summaries, stereo })
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
configure(_sampleRate: number, _samplesPerColumn: number): void {}
|
||||
|
||||
processMono(_samples: Float32Array): Float32Array | null {
|
||||
return this.drain(false)
|
||||
}
|
||||
|
||||
processStereo(_left: Float32Array, _right: Float32Array): Float32Array | null {
|
||||
return this.drain(true)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
private drain(stereo: boolean): Float32Array {
|
||||
const matching = this.queue.filter((entry) => entry.stereo === stereo)
|
||||
this.queue = []
|
||||
if (matching.length === 0) return new Float32Array(0)
|
||||
if (matching.length === 1) return matching[0].summaries
|
||||
|
||||
let total = 0
|
||||
for (const entry of matching) total += entry.summaries.length
|
||||
const out = new Float32Array(total)
|
||||
let offset = 0
|
||||
for (const entry of matching) {
|
||||
out.set(entry.summaries, offset)
|
||||
offset += entry.summaries.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,16 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
// Waveform: same sentinel trick — the visualizer calls processMono/processStereo
|
||||
// per pending chunk, which drains the C++-pushed column summaries from the bridge.
|
||||
getPendingWaveformSamples(): Float32Array[] {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
getPendingWaveformStereoSamples(): SpectrumStereoChunk[] {
|
||||
return this.sessionState.capturing ? [this.sentinelStereo] : []
|
||||
}
|
||||
|
||||
// VU + loudness meters read the C++-pushed snapshot from the bridge analyzer
|
||||
// every frame, so there are no raw samples to drain here (no sentinel needed).
|
||||
getPendingVUMeterSamples(): Array<{ left: Float32Array; right: Float32Array }> {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Waveform } from '../renderer/visualizers/Waveform'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { waveformSettingsToOptions } from './waveformOptions'
|
||||
|
||||
interface WaveformScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeWaveformAnalyzer
|
||||
settings: ScopeSettings['waveform']
|
||||
theme: ResolvedWaveformTheme
|
||||
}
|
||||
|
||||
export default function WaveformScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: WaveformScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Waveform | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new Waveform(canvas, {
|
||||
...waveformSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -535,6 +535,94 @@ export function connectSpectrogramBridge(handlers: SpectrogramBridgeHandlers): (
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform frames (event "waveformFrame"): per-column summaries (base64), stride 10
|
||||
// in stereo mode / stride 5 in mono, flagged by `stereo`.
|
||||
|
||||
export interface WaveformFrame {
|
||||
sampleRate: number
|
||||
stereo: boolean
|
||||
columnCount: number
|
||||
summaries: Float32Array
|
||||
}
|
||||
|
||||
interface WaveformFramePayload {
|
||||
sampleRate?: number
|
||||
stereo?: boolean
|
||||
columnCount?: number
|
||||
summaries?: string
|
||||
}
|
||||
|
||||
function decodeWaveformFrame(payload: unknown): WaveformFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, stereo, columnCount, summaries } = payload as WaveformFramePayload
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
stereo: Boolean(stereo),
|
||||
columnCount: typeof columnCount === 'number' ? columnCount : 0,
|
||||
summaries: typeof summaries === 'string' ? base64ToFloat32Array(summaries) : new Float32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export interface WaveformBridgeHandlers {
|
||||
onFrame: (frame: WaveformFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectWaveformBridge(handlers: WaveformBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic waveform (browser dev mode)')
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.12
|
||||
const columns = 2
|
||||
// Emit both mono (stride 5) and stereo (stride 10) so the dev mock works in
|
||||
// either mode (the analyzer serves whichever the visualizer asks for).
|
||||
const mono = new Float32Array(columns * 5)
|
||||
const stereo = new Float32Array(columns * 10)
|
||||
for (let c = 0; c < columns; c += 1) {
|
||||
const amp = 0.3 + 0.6 * Math.abs(Math.sin(phase + c * 0.4))
|
||||
const m = c * 5
|
||||
mono[m] = -amp; mono[m + 1] = amp; mono[m + 2] = amp * 0.5; mono[m + 3] = amp * 0.7; mono[m + 4] = amp * 0.3
|
||||
const s = c * 10
|
||||
stereo[s] = -amp; stereo[s + 1] = amp; stereo[s + 2] = amp * 0.5; stereo[s + 3] = amp * 0.7; stereo[s + 4] = amp * 0.3
|
||||
const ampR = amp * 0.85
|
||||
stereo[s + 5] = -ampR; stereo[s + 6] = ampR; stereo[s + 7] = ampR * 0.5; stereo[s + 8] = ampR * 0.7; stereo[s + 9] = ampR * 0.3
|
||||
}
|
||||
handlers.onFrame({ sampleRate: 48000, stereo: false, columnCount: columns, summaries: mono })
|
||||
handlers.onFrame({ sampleRate: 48000, stereo: true, columnCount: columns, summaries: stereo })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('waveformFrame', (payload) => {
|
||||
const frame = decodeWaveformFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (waveform)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }).
|
||||
|
||||
|
||||
+27
-1
@@ -9,14 +9,16 @@ import VUMeterScope from './VUMeterScope'
|
||||
import LUFSMeterScope from './LUFSMeterScope'
|
||||
import VectorscopeScope from './VectorscopeScope'
|
||||
import SpectrogramScope from './SpectrogramScope'
|
||||
import WaveformScope from './WaveformScope'
|
||||
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
|
||||
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
|
||||
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
|
||||
import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
|
||||
import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
|
||||
import { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
|
||||
import { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge } from './juceBridge'
|
||||
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge } from './juceBridge'
|
||||
|
||||
// The C++ plugin tells us which scope it is via JUCE initialisation data.
|
||||
// JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]).
|
||||
@@ -31,6 +33,30 @@ function getScopeKind(): string {
|
||||
const dataSource = new PluginWebViewDataSource()
|
||||
|
||||
function buildApp(): JSX.Element {
|
||||
if (getScopeKind() === 'waveform') {
|
||||
const analyzer = new BridgeWaveformAnalyzer()
|
||||
connectWaveformBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.pushFrame(frame.summaries, frame.stereo)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="waveform"
|
||||
renderScope={(settings, theme) => (
|
||||
<WaveformScope
|
||||
settings={settings}
|
||||
theme={theme.waveform}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'spectrogram') {
|
||||
const analyzer = new BridgeSpectrogramAnalyzer()
|
||||
connectSpectrogramBridge({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
import type { WaveformOptions } from '../renderer/visualizers/Waveform'
|
||||
|
||||
/**
|
||||
* Map Prism's waveform settings + resolved theme to Waveform options.
|
||||
* Mirrors the `waveform` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function waveformSettingsToOptions(
|
||||
settings: ScopeSettings['waveform'],
|
||||
theme: ResolvedWaveformTheme,
|
||||
): WaveformOptions {
|
||||
return {
|
||||
backgroundColor: theme.background,
|
||||
lineColor: theme.line,
|
||||
gridMajorColor: theme.guides,
|
||||
gridMinorColor: theme.guidesSecondary,
|
||||
bandColors: {
|
||||
low: theme.bandLow,
|
||||
mid: theme.bandMid,
|
||||
high: theme.bandHigh,
|
||||
},
|
||||
mode: settings.mode,
|
||||
scrollSpeed: settings.scrollSpeed,
|
||||
multiband: settings.multiband,
|
||||
}
|
||||
}
|
||||
@@ -495,7 +495,7 @@ export class Waveform {
|
||||
}
|
||||
|
||||
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
|
||||
if (this.useNativeMultiband()) {
|
||||
if (this.useNativeAnalyzer()) {
|
||||
if (this.processNativeMonoChunk(chunk, width, height)) {
|
||||
return
|
||||
}
|
||||
@@ -540,7 +540,7 @@ export class Waveform {
|
||||
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
|
||||
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
|
||||
|
||||
if (this.useNativeMultiband()) {
|
||||
if (this.useNativeAnalyzer()) {
|
||||
if (this.processNativeStereoChunk(leftSamples, rightSamples, width, height)) {
|
||||
return
|
||||
}
|
||||
@@ -590,8 +590,13 @@ export class Waveform {
|
||||
}
|
||||
}
|
||||
|
||||
private useNativeMultiband(): boolean {
|
||||
return this.options.multiband && Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||
private useNativeAnalyzer(): boolean {
|
||||
// The native analyzer computes per-column min/max (and band RMS) identically to
|
||||
// the JS sample loop, so prefer it whenever available — for plain mode it just
|
||||
// colors columns with lineColor. The JS path below remains the fallback when no
|
||||
// native analyzer is present (and is what feeds raw samples in the Electron app
|
||||
// when the addon is unavailable).
|
||||
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||
}
|
||||
|
||||
private processNativeMonoChunk(chunk: Float32Array, width: number, height: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user