Files
prism/src/renderer/visualizers/Vectorscope.ts
T
2026-03-21 01:08:26 -04:00

337 lines
11 KiB
TypeScript

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()
}
}
}