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 = { 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 private animationId: number | null = null private isRunning: boolean = false private nativeInitialized: boolean = false private samplesReceived: number = 0 private lastSampleRate: number = 0 private unsubscribeSessionChange: (() => void) | null = null 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() this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => { this.reset() }) } private initNative(): void { if (isNativeAvailable() && !this.nativeInitialized) { // Initialize with current sample rate, but set lastSampleRate to 0 so // updateSampleRateIfNeeded() always fires once the real capture rate is known. // This prevents stale-rate issues when capture starts after initialization. const sampleRate = audioRouter.getSampleRate() this.lastSampleRate = 0 nativeOscilloscope.setSampleRate(sampleRate) nativeOscilloscope.setPitchLock(this.options.pitchLock) nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate)) 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): 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() if (!audioRouter.isCapturing()) { this.animationId = requestAnimationFrame(this.draw) return } // 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() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } // Reset native module state if (isNativeAvailable()) { nativeOscilloscope.reset() } // Reset warmup state this.samplesReceived = 0 this.lastSampleRate = 0 } }