import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import test from 'node:test' import { DEFAULT_VISUALIZER_TINT, colorToRgbChannels, parseColorToRgb, parseColorToRgba, resolveColorToRgb, } from '../src/renderer/utils/color' import { getHorizontalWheelScrollResult, normalizeWheelDelta, } from '../src/renderer/utils/horizontalWheelScroll' import { resolveMainWindowSettingsHeight, resolveMainWindowSettingsPanelHeight, } from '../src/renderer/mainWindowSettings' import { LOCKED_LOUDNESS_METER_WIDTH_PX, buildAnalyzerGridTemplateColumns, } from '../src/renderer/analyzerLayout' import { formatAstraTime, getAstraPlaybackProgress, } from '../src/renderer/utils/astra' import { createDefaultProfile, } from '../src/shared/profileState' import { resolveWindowCapabilities } from '../src/shared/windowCapabilities' import { clampDraggedMainWindowBounds, clampRestoredWindowBounds, raiseWindowAboveNormalPopouts, resolveExpandedMainWindowBounds, } from '../src/shared/windowGeometry' import { calculateResizedWindowBounds } from '../src/shared/windowResize' import { createDefaultTheme, resolveNativeThemeSource, resolveTheme } from '../src/shared/themeState' import { useAudioStore } from '../src/renderer/stores/audioStore' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft' import { moveDockedScopeOrder, useSettingsStore, } from '../src/renderer/stores/settingsStore' import { useThemeStore } from '../src/renderer/stores/themeStore' import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/renderer/components/BottomBar' import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { applyInputGainToStereoSamples, inputGainDbToLinear, } from '../src/renderer/audio/inputGain' import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap, WindowBounds } from '../src/types/popout' import { RESIZE_DIRECTIONS } from '../src/types/windowResize' import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, formatSpectrumPitchInfo, normalizeSpectrumPeakInfoMode, resolveSpectrumPitchInfo, type SpectrumPeakInfo, } from '../src/types/spectrum' import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource' import { VUMeterBallistics, VU_INTEGRATION_WINDOW_MS, VU_METER_MIN_DB, VU_PEAK_HOLD_MS, } from '../src/renderer/visualizers/vuMeterBallistics' import { FrameScheduler } from '../src/renderer/visualizers/frameScheduler' import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' import { NativeVisualizerTransport, type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot, SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult, VectorscopeNativeAnalyzer, VUMeterNativeAnalyzer, VUMeterNativeSnapshot, WaveformNativeAnalyzer, } from '../src/renderer/audio/native' import { HEAT_LOW_DB, HEAT_MAX_DB, HEAT_MID_DB, HEAT_MIN_DB, normalizeHeatDb, } from '../src/renderer/visualizers/heatScale' import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram' import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' import { Waveform } from '../src/renderer/visualizers/Waveform' import { VUMeter, VU_NEEDLE_FACE_HEIGHT_CSS_PX, VU_NEEDLE_FACE_WIDTH_CSS_PX, classicVuToNormalized, dbfsToClassicVu, resolveVUNeedleFaceLayout, resolveVUNeedleReadings, stereoRmsDbAverage, } from '../src/renderer/visualizers/VUMeter' import { drawVectorscopeGridForMode, getVectorscopeLayout, } from '../src/renderer/visualizers/vectorscopeGrids' import { MultibandBuffer, MultibandSplitter, createMultibandChunk, } from '../src/renderer/visualizers/multibandSplitter' import { DEFAULT_PROFILE_ID, DEFAULT_PROFILE_NAME, type Profile, } from '../src/types/profile' import type { WindowCapabilities } from '../src/types/windowCapabilities' type WindowWithRaf = typeof globalThis & Pick type FakeElectronAPI = { platform: string windowCapabilities: WindowCapabilities [key: string]: unknown } type WindowWithTimers = typeof globalThis & Pick & { electronAPI: FakeElectronAPI } type GlobalWithStorage = typeof globalThis & { localStorage?: Storage } function installFakeAnimationFrame(): { pendingCount: () => number runFrame: (timestamp?: number) => void restore: () => void } { let nextFrameId = 1 const callbacks = new Map() const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithRaf } const previousWindow = globalWithWindow.window globalWithWindow.window = { ...globalThis, requestAnimationFrame(callback: FrameRequestCallback): number { const frameId = nextFrameId nextFrameId += 1 callbacks.set(frameId, callback) return frameId }, cancelAnimationFrame(frameId: number): void { callbacks.delete(frameId) }, } as WindowWithRaf return { pendingCount: () => callbacks.size, runFrame(timestamp = 0): void { const frameCallbacks = [...callbacks.values()] callbacks.clear() for (const callback of frameCallbacks) { callback(timestamp) } }, restore(): void { if (previousWindow === undefined) { delete globalWithWindow.window return } globalWithWindow.window = previousWindow }, } } function installFakeTimeouts(hidden = false): { pendingCount: () => number nextDelay: () => number | null runNext: () => void restore: () => void setHidden: (value: boolean) => void } { let nextTimerId = 1 const timers = new Map void; delay: number }>() const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithTimers; document?: Document } const previousWindow = globalWithWindow.window const previousDocument = globalWithWindow.document const documentState = { hidden } globalWithWindow.window = { ...globalThis, electronAPI: { platform: 'darwin', windowCapabilities: resolveWindowCapabilities({ platform: 'darwin' }), }, setTimeout(callback: TimerHandler, delay?: number): number { const timerId = nextTimerId nextTimerId += 1 const run = typeof callback === 'function' ? callback as () => void : () => {} timers.set(timerId, { callback: run, delay: typeof delay === 'number' ? delay : 0, }) return timerId }, clearTimeout(timerId: number): void { timers.delete(timerId) }, } as WindowWithTimers globalWithWindow.document = documentState as Document return { pendingCount: () => timers.size, nextDelay: () => { const nextTimer = timers.values().next().value return nextTimer ? nextTimer.delay : null }, runNext(): void { const next = timers.entries().next().value as [number, { callback: () => void; delay: number }] | undefined if (!next) { return } const [timerId, timer] = next timers.delete(timerId) timer.callback() }, restore(): void { if (previousWindow === undefined) { delete globalWithWindow.window } else { globalWithWindow.window = previousWindow } if (previousDocument === undefined) { delete globalWithWindow.document } else { globalWithWindow.document = previousDocument } }, setHidden(value: boolean): void { documentState.hidden = value }, } } function assertAlmostEqual(actual: number, expected: number, tolerance: number, message: string): void { assert.ok( Math.abs(actual - expected) <= tolerance, `${message}: expected ${expected}, got ${actual}`, ) } function createFilledStereoChunk(valueL: number, valueR: number, length: number): { left: Float32Array right: Float32Array } { const left = new Float32Array(length) const right = new Float32Array(length) left.fill(valueL) right.fill(valueR) return { left, right } } function createProgramSamples(length: number): { left: Float32Array; right: Float32Array } { const left = new Float32Array(length) const right = new Float32Array(length) for (let index = 0; index < length; index += 1) { const base = Math.sin(index * 0.037) * 0.55 const accent = Math.cos(index * 0.011) * 0.15 left[index] = base right[index] = base * 0.65 + accent } return { left, right } } function createSineStereoChunk(frequencyHz: number, sampleRate: number, length: number): { left: Float32Array right: Float32Array } { const left = new Float32Array(length) const right = new Float32Array(length) for (let index = 0; index < length; index += 1) { const sample = Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate) * 0.8 left[index] = sample right[index] = sample } return { left, right } } function createCompositeStereoChunk( partials: Array<{ frequencyHz: number; amplitude: number }>, sampleRate: number, length: number, ): { left: Float32Array right: Float32Array } { const left = new Float32Array(length) const right = new Float32Array(length) for (let index = 0; index < length; index += 1) { let sample = 0 for (const partial of partials) { sample += Math.sin((2 * Math.PI * partial.frequencyHz * index) / sampleRate) * partial.amplitude } left[index] = sample right[index] = sample } return { left, right } } function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutStateMap { const poppedOutSet = new Set(poppedOutScopes) return SCOPE_KINDS.reduce((acc, kind) => { acc[kind] = { poppedOut: poppedOutSet.has(kind) } return acc }, {} as ScopePopoutStateMap) } function installFakeLocalStorage(): { getSetCount: () => number getItem: (key: string) => string | null setItem: (key: string, value: string) => void restore: () => void } { const storage = new Map() let setCount = 0 const globalWithStorage = globalThis as GlobalWithStorage const previousLocalStorage = globalWithStorage.localStorage globalWithStorage.localStorage = { getItem(key: string): string | null { return storage.get(key) ?? null }, setItem(key: string, value: string): void { setCount += 1 storage.set(key, value) }, removeItem(key: string): void { storage.delete(key) }, clear(): void { storage.clear() }, key(index: number): string | null { return [...storage.keys()][index] ?? null }, get length(): number { return storage.size }, } as Storage return { getSetCount: () => setCount, getItem(key: string): string | null { return storage.get(key) ?? null }, setItem(key: string, value: string): void { storage.set(key, value) }, restore(): void { if (previousLocalStorage === undefined) { delete globalWithStorage.localStorage return } globalWithStorage.localStorage = previousLocalStorage }, } } function installFakeElectronWindow(overrides: Record = {}): { restore: () => void } { const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithTimers } const previousWindow = globalWithWindow.window globalWithWindow.window = { ...globalThis, electronAPI: { platform: 'darwin', windowCapabilities: resolveWindowCapabilities({ platform: 'darwin' }), ...overrides, } as FakeElectronAPI, } as WindowWithTimers return { restore(): void { if (previousWindow === undefined) { delete globalWithWindow.window return } globalWithWindow.window = previousWindow }, } } function seedProfileDraftState(profile: Profile): void { useSettingsStore.setState({ scopeOrder: [...profile.scopeOrder], hiddenScopes: new Set(profile.hiddenScopes), widthWeights: { ...profile.widthWeights }, scopeSettings: JSON.parse(JSON.stringify(profile.scopeSettings)) as Profile['scopeSettings'], scopePopouts: JSON.parse(JSON.stringify(profile.scopePopouts)) as Profile['scopePopouts'], windowBounds: profile.windowBounds, profiles: { [DEFAULT_PROFILE_ID]: JSON.parse(JSON.stringify(profile)) as Profile, }, activeProfileId: DEFAULT_PROFILE_ID, savedProfileBaseline: JSON.parse(JSON.stringify(profile)) as Profile, hasUnsavedProfileChanges: false, }) } function resizeBounds( edge: (typeof RESIZE_DIRECTIONS)[number], cursor: { x: number; y: number }, minWidth = 120, minHeight = 90, ): WindowBounds { return calculateResizedWindowBounds({ edge, startBounds: { x: 100, y: 200, width: 300, height: 180, }, startCursor: { x: 0, y: 0 }, cursor, minWidth, minHeight, }) } function createFakeTransportBridge(): { bridge: NativeVisualizerTransportBridge calls: { oscilloscopePushes: Float32Array[] vectorscopePushes: Array<{ left: Float32Array; right: Float32Array }> spectrumPushes: Float32Array[] oscilloscopeResets: number vectorscopeResets: number spectrumResets: number oscilloscopeSampleRates: number[] vectorscopeSampleRates: number[] spectrumSampleRates: number[] } } { let latestSpectrumMagnitudes: Float32Array | null = null const calls = { oscilloscopePushes: [] as Float32Array[], vectorscopePushes: [] as Array<{ left: Float32Array; right: Float32Array }>, spectrumPushes: [] as Float32Array[], oscilloscopeResets: 0, vectorscopeResets: 0, spectrumResets: 0, oscilloscopeSampleRates: [] as number[], vectorscopeSampleRates: [] as number[], spectrumSampleRates: [] as number[], } return { bridge: { isAvailable: () => true, oscilloscope: { setSampleRate: (sampleRate) => { calls.oscilloscopeSampleRates.push(sampleRate) }, pushSamples: (samples) => { calls.oscilloscopePushes.push(samples) }, reset: () => { calls.oscilloscopeResets += 1 }, }, spectrum: { setSampleRate: (sampleRate) => { calls.spectrumSampleRates.push(sampleRate) }, pushSamples: (samples) => { calls.spectrumPushes.push(samples) latestSpectrumMagnitudes = new Float32Array([samples[0] ?? 0, samples[samples.length - 1] ?? 0]) }, fillMagnitudes: (output) => { if (!latestSpectrumMagnitudes) { return 0 } const count = Math.min(output.length, latestSpectrumMagnitudes.length) for (let index = 0; index < count; index += 1) { output[index] = latestSpectrumMagnitudes[index] } return count }, reset: () => { calls.spectrumResets += 1 latestSpectrumMagnitudes = null }, }, vectorscope: { setSampleRate: (sampleRate) => { calls.vectorscopeSampleRates.push(sampleRate) }, pushSamples: (left, right) => { calls.vectorscopePushes.push({ left, right }) }, reset: () => { calls.vectorscopeResets += 1 }, }, }, calls, } } function createFakeStackableWindow(alwaysOnTop = false): { getMoveTopCalls: () => number window: { isDestroyed: () => boolean isAlwaysOnTop: () => boolean moveTop: () => void } } { let moveTopCalls = 0 return { getMoveTopCalls: () => moveTopCalls, window: { isDestroyed: () => false, isAlwaysOnTop: () => alwaysOnTop, moveTop: () => { moveTopCalls += 1 }, }, } } function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8): number[] { const output = new Float32Array(size) const count = transport.fillLatestSpectrumMagnitudes(output) return Array.from(output.subarray(0, count)) } interface FakeCanvasRecorder { fillRects: Array<{ x: number; y: number; width: number; height: number; fillStyle: string }> fillTexts: Array<{ text: string; x: number; y: number; fillStyle: string; font: string }> strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }> arcs: Array<{ x: number y: number radius: number startAngle: number endAngle: number anticlockwise: boolean lineDash: number[] }> lineDashes: number[][] imageDataWrites: Array<{ x: number; y: number; width: number; height: number; data: number[] }> drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation; args: unknown[] }> } function createFakeCanvasRecorder(): FakeCanvasRecorder { return { fillRects: [], fillTexts: [], strokeRects: [], arcs: [], lineDashes: [], imageDataWrites: [], drawImageCalls: [], } } function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): CanvasRenderingContext2D { let currentLineDash: number[] = [] let currentFillStyle = '' let currentStrokeStyle = '' let currentFont = '' let currentCompositeOperation: GlobalCompositeOperation = 'source-over' const context = { clearRect() {}, fillRect(x: number, y: number, width: number, height: number) { recorder?.fillRects.push({ x, y, width, height, fillStyle: currentFillStyle }) }, fillText(text: string, x: number, y: number) { recorder?.fillTexts.push({ text: String(text), x, y, fillStyle: currentFillStyle, font: currentFont }) }, beginPath() {}, closePath() {}, fill() {}, moveTo() {}, lineTo() {}, stroke() {}, strokeRect(x: number, y: number, width: number, height: number) { recorder?.strokeRects.push({ x, y, width, height, lineDash: [...currentLineDash] }) }, arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise = false) { recorder?.arcs.push({ x, y, radius, startAngle, endAngle, anticlockwise, lineDash: [...currentLineDash] }) }, drawImage(...args: unknown[]) { recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation, args }) }, putImageData(imageData: ImageData, x: number, y: number) { recorder?.imageDataWrites.push({ x, y, width: imageData.width, height: imageData.height, data: Array.from(imageData.data) }) }, save() {}, restore() {}, translate() {}, rotate() {}, setLineDash(segments: number[]) { currentLineDash = [...segments] recorder?.lineDashes.push([...segments]) }, getLineDash() { return [...currentLineDash] }, createLinearGradient() { return { addColorStop() {}, } as CanvasGradient }, measureText(text: string) { const fontSizeMatch = currentFont.match(/(\d+(?:\.\d+)?)px/) const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 12 return { width: String(text).length * fontSize * 0.62 } as TextMetrics }, lineWidth: 1, get font() { return currentFont }, set font(value: string) { currentFont = value }, textAlign: 'left', textBaseline: 'top', lineCap: 'butt', lineJoin: 'miter', globalAlpha: 1, imageSmoothingEnabled: false, get fillStyle() { return currentFillStyle }, set fillStyle(value: string | CanvasGradient | CanvasPattern) { currentFillStyle = typeof value === 'string' ? value : String(value) }, get strokeStyle() { return currentStrokeStyle }, set strokeStyle(value: string | CanvasGradient | CanvasPattern) { currentStrokeStyle = typeof value === 'string' ? value : String(value) }, get globalCompositeOperation() { return currentCompositeOperation }, set globalCompositeOperation(value: GlobalCompositeOperation) { currentCompositeOperation = value }, } return context as unknown as CanvasRenderingContext2D } function createFakeCanvas( recorder: FakeCanvasRecorder | null = null, width = 320, height = 180, ): HTMLCanvasElement { const context = createFakeCanvasContext(recorder) return { width, height, getContext: (kind: string) => kind === '2d' ? context : null, } as unknown as HTMLCanvasElement } function installFakeCanvasDom(createCanvas: () => HTMLCanvasElement = () => createFakeCanvas()): { restore: () => void } { const globalWithDom = globalThis as typeof globalThis & { window?: Window document?: Document ImageData?: typeof ImageData } const previousWindow = globalWithDom.window const previousDocument = globalWithDom.document const previousImageData = globalWithDom.ImageData globalWithDom.window = { ...(previousWindow ?? globalThis), devicePixelRatio: 1, } as Window globalWithDom.document = { createElement(tagName: string) { if (tagName !== 'canvas') { throw new Error(`Unsupported element in test DOM: ${tagName}`) } return createCanvas() }, } as Document if (globalWithDom.ImageData === undefined) { class FakeImageData { data: Uint8ClampedArray width: number height: number constructor(width: number, height: number) { this.width = width this.height = height this.data = new Uint8ClampedArray(width * height * 4) } } globalWithDom.ImageData = FakeImageData as unknown as typeof ImageData } return { restore(): void { if (previousWindow === undefined) { delete globalWithDom.window } else { globalWithDom.window = previousWindow } if (previousDocument === undefined) { delete globalWithDom.document } else { globalWithDom.document = previousDocument } if (previousImageData === undefined) { delete globalWithDom.ImageData } else { globalWithDom.ImageData = previousImageData } }, } } function assertArraysAlmostEqual(actual: number[], expected: number[], tolerance: number, message: string): void { assert.equal(actual.length, expected.length, `${message}: length mismatch`) for (let index = 0; index < actual.length; index += 1) { if (Math.abs(actual[index] - expected[index]) > tolerance) { assert.fail(`${message}: arrays diverged at index ${index}; expected ${expected[index]}, got ${actual[index]}`) } } } function assertArraysDiffer(actual: number[], expected: number[], tolerance: number, message: string): void { for (let index = 0; index < actual.length; index += 1) { if (Math.abs(actual[index] - expected[index]) > tolerance) { return } } assert.fail(message) } function renderSpectrumSnapshot(options: Partial): { primaryPointY: number[] heatmapPointY: number[] renderedHeatmapY: number[] heatmapIntensity: number[] } { const dom = installFakeCanvasDom() const { left, right } = createProgramSamples(2048) const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => [{ left, right }], getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showSideLine: true, heatmapFill: true, fillGradient: false, showGrid: false, dataSource, ...options, }) try { const state = analyzer as unknown as { drawFrame: () => void renderHeatmap: ( xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number, ) => void primaryPointY: Float32Array heatmapPointY: Float32Array primaryPointHeatmap: Float32Array } const originalRenderHeatmap = state.renderHeatmap.bind(state) let renderedHeatmapY: number[] = [] state.renderHeatmap = (xPoints, yPoints, heatmapIntensity, pointCount, width, height) => { renderedHeatmapY = Array.from(yPoints.subarray(0, pointCount)) originalRenderHeatmap(xPoints, yPoints, heatmapIntensity, pointCount, width, height) } state.drawFrame() const snapshotState = analyzer as unknown as { primaryPointY: Float32Array heatmapPointY: Float32Array primaryPointHeatmap: Float32Array } return { primaryPointY: Array.from(snapshotState.primaryPointY), heatmapPointY: Array.from(snapshotState.heatmapPointY), renderedHeatmapY, heatmapIntensity: Array.from(snapshotState.primaryPointHeatmap), } } finally { analyzer.dispose() dom.restore() } } function renderSpectrumHeatmap( options: Partial, heatmapIntensity: number[], yPoints: number[] = heatmapIntensity.map(() => 0), ): FakeCanvasRecorder { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom() const canvas = createFakeCanvas(recorder) canvas.width = heatmapIntensity.length canvas.height = 24 const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(canvas, { showSideLine: true, heatmapFill: true, fillGradient: false, showGrid: false, dataSource, ...options, }) try { const state = analyzer as unknown as { renderHeatmap: ( xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number, ) => void } state.renderHeatmap( Float32Array.from(heatmapIntensity.map((_value, index) => index)), Float32Array.from(yPoints), Float32Array.from(heatmapIntensity), heatmapIntensity.length, canvas.width, canvas.height, ) return recorder } finally { analyzer.dispose() dom.restore() } } function projectSpectrumDb(options: Partial, db: number): { heatmapIntensity: number[] yPoints: number[] } { const dom = installFakeCanvasDom() const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showSideLine: true, showGrid: false, dataSource, ...options, }) try { const state = analyzer as unknown as { fillSpectrumPoints: ( frequencyData: Float32Array, dataLength: number, width: number, height: number, minFrequency: number, maxFrequency: number, nyquist: number, tiltDbPerOctave: number, xOut: Float32Array, yOut: Float32Array, heatmapIntensityOut: Float32Array | null, ) => { pointCount: number } } const pointCount = 2 const xOut = new Float32Array(pointCount) const yOut = new Float32Array(pointCount) const heatmapIntensity = new Float32Array(pointCount) const result = state.fillSpectrumPoints( Float32Array.from([db, db, db, db]), 4, pointCount, 100, 20, 40, 2000, 0, xOut, yOut, heatmapIntensity, ) return { heatmapIntensity: Array.from(heatmapIntensity.subarray(0, result.pointCount)), yPoints: Array.from(yOut.subarray(0, result.pointCount)), } } finally { analyzer.dispose() dom.restore() } } function renderSpectrogramColumnImage(options: Partial, values: number[]): number[] { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const canvas = createFakeCanvas() canvas.width = 1 canvas.height = values.length const dataSource = { getPendingSpectrogramSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const spectrogram = new Spectrogram(canvas, { dataSource, ...options, }) try { const state = spectrogram as unknown as { ensureColumnBuffers: (height: number) => void shiftAndPaintColumn: (values: Float32Array) => void } state.ensureColumnBuffers(values.length) state.shiftAndPaintColumn(Float32Array.from(values)) return recorder.imageDataWrites.at(-1)?.data ?? [] } finally { spectrogram.dispose() dom.restore() } } function renderSpectrogramShift( options: Partial, values: number[], canvasSize: { width: number; height: number } = { width: 4, height: values.length }, ): FakeCanvasRecorder { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const canvas = createFakeCanvas() canvas.width = canvasSize.width canvas.height = canvasSize.height const dataSource = { getPendingSpectrogramSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const spectrogram = new Spectrogram(canvas, { dataSource, ...options, }) try { const state = spectrogram as unknown as { ensureColumnBuffers: (height: number) => void shiftAndPaintColumn: (values: Float32Array) => void } state.ensureColumnBuffers(values.length) state.shiftAndPaintColumn(Float32Array.from(values)) return recorder } finally { spectrogram.dispose() dom.restore() } } test('parseColorToRgb handles hex, rgb, rgba, and percentage formats', () => { assert.deepEqual(parseColorToRgb('#38bdf8'), { r: 56, g: 189, b: 248 }) assert.deepEqual(parseColorToRgb('#3bf'), { r: 51, g: 187, b: 255 }) assert.deepEqual(parseColorToRgb('rgb(10, 20, 30)'), { r: 10, g: 20, b: 30 }) assert.deepEqual(parseColorToRgb('rgba(10 20 30 / 0.5)'), { r: 10, g: 20, b: 30 }) assert.deepEqual(parseColorToRgb('rgb(10%, 20%, 30%)'), { r: 26, g: 51, b: 77 }) }) test('color helpers fall back predictably for invalid values', () => { assert.equal(colorToRgbChannels('rgba(1, 2, 3, 0.5)'), '1, 2, 3') assert.equal(colorToRgbChannels('nope'), null) assert.deepEqual(resolveColorToRgb('still-nope'), DEFAULT_VISUALIZER_TINT) assert.deepEqual(resolveColorToRgb('rgb(4, 5, 6)'), { r: 4, g: 5, b: 6 }) }) test('calculateResizedWindowBounds supports every resize direction', () => { const expectedByDirection: Record<(typeof RESIZE_DIRECTIONS)[number], WindowBounds> = { n: { x: 100, y: 220, width: 300, height: 160 }, s: { x: 100, y: 200, width: 300, height: 200 }, e: { x: 100, y: 200, width: 340, height: 180 }, w: { x: 140, y: 200, width: 260, height: 180 }, ne: { x: 100, y: 220, width: 340, height: 160 }, nw: { x: 140, y: 220, width: 260, height: 160 }, se: { x: 100, y: 200, width: 340, height: 200 }, sw: { x: 140, y: 200, width: 260, height: 200 }, } for (const edge of RESIZE_DIRECTIONS) { assert.deepEqual( resizeBounds(edge, { x: 40, y: 20 }), expectedByDirection[edge], `expected ${edge} resize bounds to match`, ) } }) test('calculateResizedWindowBounds clamps east and south resizes to minimum size', () => { assert.deepEqual( resizeBounds('e', { x: -220, y: 0 }, 140, 90), { x: 100, y: 200, width: 140, height: 180 }, ) assert.deepEqual( resizeBounds('s', { x: 0, y: -140 }, 120, 100), { x: 100, y: 200, width: 300, height: 100 }, ) }) test('calculateResizedWindowBounds keeps north and west edges anchored when clamped', () => { assert.deepEqual( resizeBounds('w', { x: 220, y: 0 }, 140, 90), { x: 260, y: 200, width: 140, height: 180 }, ) assert.deepEqual( resizeBounds('nw', { x: 220, y: 140 }, 140, 100), { x: 260, y: 280, width: 140, height: 100 }, ) }) test('inputGainDbToLinear converts dB offsets to expected linear gain values', () => { assert.equal(inputGainDbToLinear(0), 1) assertAlmostEqual(inputGainDbToLinear(6), 1.9952623149688795, 1e-12, '+6 dB gain') assertAlmostEqual(inputGainDbToLinear(-6), 0.5011872336272722, 1e-12, '-6 dB gain') }) test('applyInputGainToStereoSamples scales both channels in place', () => { const { left, right } = createFilledStereoChunk(0.25, -0.5, 3) const linearGain = inputGainDbToLinear(6) applyInputGainToStereoSamples(left, right, linearGain) for (const sample of left) { assertAlmostEqual(sample, 0.25 * linearGain, 1e-6, 'left channel scaled') } for (const sample of right) { assertAlmostEqual(sample, -0.5 * linearGain, 1e-6, 'right channel scaled') } }) test('applyInputGainToStereoSamples is a no-op for unity gain', () => { const left = new Float32Array([0.1, -0.2, 0.3]) const right = new Float32Array([-0.4, 0.5, -0.6]) const initialLeft = Array.from(left) const initialRight = Array.from(right) applyInputGainToStereoSamples(left, right, 1) assert.deepEqual(Array.from(left), initialLeft) assert.deepEqual(Array.from(right), initialRight) }) test('FrameScheduler dispatches every animation frame in display-sync mode', () => { const raf = installFakeAnimationFrame() try { const scheduler = new FrameScheduler({ frameTarget: 'display-sync' }) let frameCount = 0 const unsubscribe = scheduler.subscribe(() => { frameCount += 1 }) assert.equal(raf.pendingCount(), 1) raf.runFrame(0) raf.runFrame(16.7) raf.runFrame(33.4) assert.equal(frameCount, 3) assert.equal(raf.pendingCount(), 1) unsubscribe() assert.equal(raf.pendingCount(), 0) } finally { raf.restore() } }) test('FrameScheduler caps numeric frame targets by skipping intermediate animation frames', () => { const raf = installFakeAnimationFrame() try { const scheduler = new FrameScheduler({ frameTarget: 30 }) let frameCount = 0 const unsubscribe = scheduler.subscribe(() => { frameCount += 1 }) raf.runFrame(0) raf.runFrame(16.7) raf.runFrame(33.4) raf.runFrame(50.1) raf.runFrame(66.8) assert.equal(frameCount, 3) assert.equal(raf.pendingCount(), 1) unsubscribe() assert.equal(raf.pendingCount(), 0) } finally { raf.restore() } }) test('FrameScheduler updates cadence when the frame target changes without duplicating subscriptions', () => { const raf = installFakeAnimationFrame() try { const scheduler = new FrameScheduler({ frameTarget: 30 }) let frameCount = 0 const unsubscribe = scheduler.subscribe(() => { frameCount += 1 }) raf.runFrame(0) raf.runFrame(16.7) assert.equal(frameCount, 1) assert.equal(raf.pendingCount(), 1) scheduler.setFrameTarget(60) raf.runFrame(33.4) raf.runFrame(41.7) raf.runFrame(50.1) assert.equal(frameCount, 3) assert.equal(raf.pendingCount(), 1) unsubscribe() assert.equal(raf.pendingCount(), 0) } finally { raf.restore() } }) test('VisualizerFrameLoop renders one invalidated frame while idle', () => { const raf = installFakeAnimationFrame() try { let frameCount = 0 const loop = new VisualizerFrameLoop({ shouldRun: () => false, onFrame: () => { frameCount += 1 }, }) loop.start() assert.equal(raf.pendingCount(), 1) raf.runFrame() assert.equal(frameCount, 1) assert.equal(raf.pendingCount(), 0) loop.dispose() } finally { raf.restore() } }) test('VisualizerFrameLoop stays subscribed while running and detaches when playback stops', () => { const raf = installFakeAnimationFrame() try { let frameCount = 0 let running = true const loop = new VisualizerFrameLoop({ shouldRun: () => running, onFrame: () => { frameCount += 1 }, }) loop.start() raf.runFrame() assert.equal(frameCount, 1) assert.equal(raf.pendingCount(), 1) running = false raf.runFrame() assert.equal(frameCount, 2) assert.equal(raf.pendingCount(), 0) loop.dispose() } finally { raf.restore() } }) test('VisualizerFrameLoop invalidate and stop manage subscriptions correctly', () => { const raf = installFakeAnimationFrame() try { let frameCount = 0 const loop = new VisualizerFrameLoop({ shouldRun: () => false, onFrame: () => { frameCount += 1 }, }) loop.start() raf.runFrame() assert.equal(frameCount, 1) assert.equal(raf.pendingCount(), 0) loop.invalidate() assert.equal(raf.pendingCount(), 1) loop.stop() assert.equal(raf.pendingCount(), 0) raf.runFrame() assert.equal(frameCount, 1) } finally { raf.restore() } }) test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked neighbor', () => { const nextOrder = moveDockedScopeOrder( [...SCOPE_KINDS], new Set(), createScopePopouts(), 'vectorscope', 'left', ) assert.deepEqual(nextOrder, [ 'spectrum', 'vectorscope', 'oscilloscope', 'spectrogram', 'vumeter', 'lufsmeter', 'waveform', 'nowPlaying', ]) }) test('analyzer layout locks the loudness meter width', () => { const columns = buildAnalyzerGridTemplateColumns( ['spectrum', 'lufsmeter', 'waveform'], { spectrum: 1, lufsmeter: 0.15, waveform: 1 }, ) assert.equal(LOCKED_LOUDNESS_METER_WIDTH_PX, 150) assert.equal( columns, `minmax(0, 1fr) minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px) minmax(0, 1fr)`, ) }) test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrum.heatmapSmoothing = 0.64 const theme = resolveTheme(createDefaultTheme()) const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum) assert.equal(options.showSideLine, true) assert.equal(options.heatmapSmoothing, 0.64) assert.equal(options.secondaryLineColor, theme.spectrum.sideLine) assert.equal(options.lineColor, theme.spectrum.line) assert.equal(options.backgroundColor, theme.spectrum.background) assert.equal(options.gridColor, theme.spectrum.guides) }) test('default profile starts with spectrum peak info disabled', () => { const profile = createDefaultProfile('Default') assert.equal(profile.scopeSettings.spectrum.peakInfoMode, DEFAULT_SPECTRUM_PEAK_INFO_MODE) assert.equal(normalizeSpectrumPeakInfoMode('nope'), DEFAULT_SPECTRUM_PEAK_INFO_MODE) }) test('default profile starts with high-detail spectrogram FFT size', () => { const profile = createDefaultProfile('Default') assert.equal(profile.scopeSettings.spectrogram.fftSize, 4096) assert.equal(profile.scopeSettings.spectrogram.tiltDbPerOctave, 4) }) test('spectrum pitch helpers format nearest note with octave and cents', () => { assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(440)), 'A4 0c') assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(261.6255653005986)), 'C4 0c') assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(Number.NaN)), '--') }) test('normalizeHeatDb uses the shared hybrid heat window', () => { assert.equal(normalizeHeatDb(HEAT_MIN_DB), 0) assert.equal(normalizeHeatDb(-60), 0.5) assert.equal(normalizeHeatDb(HEAT_MAX_DB), 1) assert.equal(normalizeHeatDb(-140), 0) assert.equal(normalizeHeatDb(12), 1) assert.equal(normalizeHeatDb(Number.NaN), 0) }) test('SpectrumAnalyzer heat intensity uses shared heat scale without changing line geometry', () => { const referenceDb = -60 const defaultLine = projectSpectrumDb({ minDecibels: -90, maxDecibels: -10, }, referenceDb) const expandedLine = projectSpectrumDb({ minDecibels: -120, maxDecibels: 0, }, referenceDb) const expectedHeat = Math.pow(normalizeHeatDb(referenceDb), 1.4) assertAlmostEqual(defaultLine.heatmapIntensity[0], expectedHeat, 1e-6, 'heat intensity should use shared heat dB normalization') assertArraysAlmostEqual( defaultLine.heatmapIntensity, expandedLine.heatmapIntensity, 1e-6, 'heat intensity should ignore spectrum line min/max dB options', ) assertArraysDiffer( defaultLine.yPoints, expandedLine.yPoints, 1e-6, 'line geometry should still respond to spectrum line min/max dB options', ) }) test('SpectrumAnalyzer heatmap output does not change when only line smoothing changes', () => { const looseLine = renderSpectrumSnapshot({ smoothing: 0, heatmapSmoothing: 0.5, }) const tightLine = renderSpectrumSnapshot({ smoothing: 0.99, heatmapSmoothing: 0.5, }) assertArraysDiffer( looseLine.primaryPointY, tightLine.primaryPointY, 1e-6, 'line path should respond to the line smoothing control', ) assertArraysAlmostEqual( looseLine.heatmapIntensity, tightLine.heatmapIntensity, 1e-6, 'heatmap intensity should ignore the line smoothing control', ) assertArraysAlmostEqual( looseLine.renderedHeatmapY, looseLine.primaryPointY, 1e-6, 'rendered heatmap fill should follow the line geometry', ) assertArraysAlmostEqual( tightLine.renderedHeatmapY, tightLine.primaryPointY, 1e-6, 'rendered heatmap fill should follow the line geometry', ) }) test('SpectrumAnalyzer line output does not change when only heatmap smoothing changes', () => { const looseHeat = renderSpectrumSnapshot({ smoothing: 0.9, heatmapSmoothing: 0, }) const tightHeat = renderSpectrumSnapshot({ smoothing: 0.9, heatmapSmoothing: 0.99, }) assertArraysAlmostEqual( looseHeat.primaryPointY, tightHeat.primaryPointY, 1e-6, 'line path should ignore the heatmap smoothing control', ) assertArraysDiffer( looseHeat.heatmapIntensity, tightHeat.heatmapIntensity, 1e-6, 'heatmap intensity should respond to the heatmap smoothing control', ) assertArraysAlmostEqual( looseHeat.renderedHeatmapY, looseHeat.primaryPointY, 1e-6, 'rendered heatmap fill should follow the line geometry', ) assertArraysAlmostEqual( tightHeat.renderedHeatmapY, tightHeat.primaryPointY, 1e-6, 'rendered heatmap fill should follow the line geometry', ) }) test('SpectrumAnalyzer renders heatmap fill on the spectrum line', () => { const snapshot = renderSpectrumSnapshot({ smoothing: 0.99, heatmapSmoothing: 0, }) assertArraysAlmostEqual(snapshot.renderedHeatmapY, snapshot.primaryPointY, 1e-6, 'heatmap fill should use the line geometry') }) test('SpectrumAnalyzer heatmap does not repaint the full viewport when heat base is omitted', () => { const recorder = renderSpectrumHeatmap({}, [0.24, 0.62, 1]) assert.equal( recorder.fillRects.some((rect) => rect.x === 0 && rect.y === 0 && rect.width === 3 && rect.height === 24), false, ) }) test('SpectrumAnalyzer heatmap honors authored token alpha for low-end heat colors', () => { const recorder = renderSpectrumHeatmap({ heatColors: [ 'rgba(15, 7, 33, 0)', 'rgba(163, 26, 121, 0.6)', 'rgb(255, 241, 209)', ], }, [0.48, 0.62, 1]) const renderedAlpha = recorder.fillRects .map((rect) => parseColorToRgba(rect.fillStyle)?.a ?? null) .filter((value): value is number => value !== null) assert.equal(renderedAlpha.some((value) => value > 0 && value < 1), true) assert.equal(renderedAlpha.some((value) => Math.abs(value - 1) < 1e-3), true) }) test('SpectrumAnalyzer heat base only fills the visible heatmap area under the curve', () => { const recorder = renderSpectrumHeatmap( { heatBaseColor: 'rgb(12, 34, 56)', heatColors: [ 'rgba(15, 7, 33, 0)', 'rgba(163, 26, 121, 0)', 'rgba(255, 241, 209, 0)', ], }, [0, 0, 0], [8, 12, 16], ) const baseRects = recorder.fillRects.filter((rect) => rect.fillStyle === 'rgb(12, 34, 56)') assert.equal(baseRects.length, 3) assert.deepEqual( baseRects.map((rect) => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })), [ { x: 0, y: 8, width: 1, height: 16 }, { x: 1, y: 12, width: 1, height: 12 }, { x: 2, y: 16, width: 1, height: 8 }, ], ) }) test('Spectrogram heatmap preserves authored alpha in generated image data', () => { const imageData = renderSpectrogramColumnImage({ heatColors: [ 'rgba(15, 7, 33, 0)', 'rgba(163, 26, 121, 0.5)', 'rgb(255, 241, 209)', ], }, [0, 0.62, 1]) assert.equal(imageData[3], 0) assert.equal(imageData[7] > 0 && imageData[7] < 255, true) assert.equal(imageData[11], 255) }) test('Spectrogram solid color uses intensity as alpha while preserving tint RGB', () => { const imageData = renderSpectrogramColumnImage({ colorScheme: 'mono', lineColor: 'rgb(10, 20, 30)', }, [0, 0.5, 1]) assert.deepEqual(imageData.slice(0, 3), [10, 20, 30]) assert.equal(imageData[3], 0) assert.deepEqual(imageData.slice(4, 7), [10, 20, 30]) assert.equal(imageData[7], 128) assert.deepEqual(imageData.slice(8, 11), [10, 20, 30]) assert.equal(imageData[11], 255) }) test('Spectrogram solid color honors authored tint alpha', () => { const imageData = renderSpectrogramColumnImage({ colorScheme: 'mono', lineColor: 'rgba(100, 150, 200, 0.5)', }, [0.5, 1]) assert.deepEqual(imageData.slice(0, 3), [100, 150, 200]) assert.equal(imageData[3], 64) assert.deepEqual(imageData.slice(4, 7), [100, 150, 200]) assert.equal(imageData[7], 128) }) test('Spectrogram keeps the historical display dB range for line thickness', () => { const dom = installFakeCanvasDom() const dataSource = { getPendingSpectrogramSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const spectrogram = new Spectrogram(createFakeCanvas(), { dataSource }) try { const state = spectrogram as unknown as { options: { minDecibels: number maxDecibels: number } } assert.equal(state.options.minDecibels, -90) assert.equal(state.options.maxDecibels, -12) } finally { spectrogram.dispose() dom.restore() } }) test('Spectrogram custom heat colors land on shared low mid and high thresholds', () => { const imageData = renderSpectrogramColumnImage({ heatColors: [ 'rgb(10, 20, 30)', 'rgb(40, 50, 60)', 'rgb(70, 80, 90)', ], }, [ normalizeHeatDb(HEAT_LOW_DB), normalizeHeatDb(HEAT_MID_DB), normalizeHeatDb(HEAT_MAX_DB), ]) assert.deepEqual(imageData.slice(0, 3), [10, 20, 30]) assert.deepEqual(imageData.slice(4, 7), [40, 50, 60]) assert.deepEqual(imageData.slice(8, 11), [70, 80, 90]) assert.equal(imageData[3] > 0 && imageData[3] < imageData[7], true) assert.equal(imageData[7] > imageData[3] && imageData[7] < imageData[11], true) assert.equal(imageData[11], 255) }) test('Spectrogram low-intensity heat stays mostly transparent with default RGB heat colors', () => { const imageData = renderSpectrogramColumnImage({ heatColors: [ 'rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)', ], }, [0, 0.1, 1]) assert.equal(imageData[3], 0) assert.equal(imageData[7] > 0 && imageData[7] < 40, true) assert.equal(imageData[11], 255) }) test('Spectrogram shifts existing columns with copy compositing to avoid transparent streaking', () => { const recorder = renderSpectrogramShift({ heatColors: [ 'rgba(15, 7, 33, 0)', 'rgba(163, 26, 121, 0.5)', 'rgb(255, 241, 209)', ], }, [0.2, 0.6, 1]) assert.equal(recorder.drawImageCalls.at(-1)?.compositeOperation, 'copy') }) test('Spectrogram vertical orientation paints newest frequency row at the bottom', () => { const recorder = renderSpectrogramShift({ orientation: 'vertical', colorScheme: 'mono', lineColor: 'rgb(10, 20, 30)', }, [0, 0.5, 1], { width: 3, height: 5 }) const write = recorder.imageDataWrites.at(-1) assert.ok(write) assert.equal(write.x, 0) assert.equal(write.y, 4) assert.equal(write.width, 3) assert.equal(write.height, 1) assert.deepEqual(write.data.slice(0, 3), [10, 20, 30]) assert.equal(write.data[3], 0) assert.deepEqual(write.data.slice(4, 7), [10, 20, 30]) assert.equal(write.data[7], 128) assert.deepEqual(write.data.slice(8, 11), [10, 20, 30]) assert.equal(write.data[11], 255) }) test('Spectrogram vertical orientation shifts existing rows upward with copy compositing', () => { const recorder = renderSpectrogramShift({ orientation: 'vertical', }, [0.2, 0.6, 1], { width: 3, height: 5 }) const drawCall = recorder.drawImageCalls.at(-1) assert.equal(drawCall?.compositeOperation, 'copy') assert.equal(drawCall?.args[1], 0) assert.equal(drawCall?.args[2], -1) }) test('Spectrogram paints multiple native analyzer columns in order', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const canvas = createFakeCanvas(recorder, 4, 2) let pending = [Float32Array.from([0, 1, 0, -1])] const dataSource = { getPendingSpectrogramSamples: () => { const chunks = pending pending = [] return chunks }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer: SpectrogramNativeAnalyzer = { isAvailable: () => true, configure: () => {}, process: () => ({ display: Float32Array.from([0.25, 0.5, 0.75, 1]), heat: Float32Array.from([0.25, 0.5, 0.75, 1]), columnCount: 2, rowCount: 2, }), reset: () => {}, } const spectrogram = new Spectrogram(canvas, { dataSource, nativeAnalyzer, colorScheme: 'mono', lineColor: 'rgb(10, 20, 30)', }) try { const state = spectrogram as unknown as { drawFrame: () => void } state.drawFrame() const writes = recorder.imageDataWrites assert.equal(writes.length, 2) assert.deepEqual(writes[0].data.slice(0, 8), [10, 20, 30, 64, 10, 20, 30, 128]) assert.deepEqual(writes[1].data.slice(0, 8), [10, 20, 30, 191, 10, 20, 30, 255]) } finally { spectrogram.dispose() dom.restore() } }) test('Spectrogram forwards orientation and row count to the native analyzer', () => { const dom = installFakeCanvasDom() const canvas = createFakeCanvas(null, 3, 5) let capturedConfig: SpectrogramNativeOptions | null = null let pending = [Float32Array.from([0, 0])] const dataSource = { getPendingSpectrogramSamples: () => { const chunks = pending pending = [] return chunks }, getSampleRate: () => 44100, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer: SpectrogramNativeAnalyzer = { isAvailable: () => true, configure: (options) => { capturedConfig = options }, process: (): SpectrogramNativeResult => ({ display: new Float32Array(0), heat: new Float32Array(0), columnCount: 0, rowCount: 3, }), reset: () => {}, } const spectrogram = new Spectrogram(canvas, { dataSource, nativeAnalyzer, orientation: 'vertical', scaleMode: 'linear', fftSize: 8192, scrollSpeed: 4, tiltDbPerOctave: 5.5, }) try { const state = spectrogram as unknown as { drawFrame: () => void } state.drawFrame() assert.equal(capturedConfig?.orientation, 'vertical') assert.equal(capturedConfig?.rowCount, 3) assert.equal(capturedConfig?.scaleMode, 'linear') assert.equal(capturedConfig?.fftSize, 8192) assert.equal(capturedConfig?.scrollSpeed, 4) assert.equal(capturedConfig?.tiltDbPerOctave, 5.5) assert.equal(capturedConfig?.sampleRate, 44100) } finally { spectrogram.dispose() dom.restore() } }) test('Spectrogram freezes the waterfall when native analyzer is unavailable', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom() const canvas = createFakeCanvas(recorder, 4, 4) let pending = [Float32Array.from([0, 1, 0, -1])] let pendingFlushes = 0 let nativeProcessCalls = 0 const dataSource = { getPendingSpectrogramSamples: () => { pendingFlushes += 1 const chunks = pending pending = [] return chunks }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer: SpectrogramNativeAnalyzer = { isAvailable: () => false, configure: () => {}, process: () => { nativeProcessCalls += 1 return null }, reset: () => {}, } const spectrogram = new Spectrogram(canvas, { dataSource, nativeAnalyzer, }) try { const state = spectrogram as unknown as { drawFrame: () => void } state.drawFrame() assert.equal(pendingFlushes, 1) assert.equal(nativeProcessCalls, 0) assert.equal(recorder.imageDataWrites.length, 0) assert.equal(recorder.drawImageCalls.length, 1) } finally { spectrogram.dispose() dom.restore() } }) test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => { const dom = installFakeCanvasDom() const sampleRate = 48000 const { left, right } = createSineStereoChunk(440, sampleRate, 4096) let peakInfo: SpectrumPeakInfo | null = null const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => [{ left, right }], getSampleRate: () => sampleRate, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showSideLine: true, showGrid: false, fillGradient: false, smoothing: 0, tiltDbPerOctave: 0, fftSize: 4096, dataSource, capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { peakInfo = nextPeakInfo }, }) try { const state = analyzer as unknown as { drawFrame: () => void primaryPointFrequency: Float32Array isLocalPeak: (index: number, pointCount: number) => boolean } state.drawFrame() assert.ok(peakInfo, 'expected peak info to be reported') assert.ok(peakInfo.frequencyHz > 437 && peakInfo.frequencyHz < 443, `expected peak frequency near 440 Hz, got ${peakInfo.frequencyHz}`) assert.match(peakInfo.key, /^A4 [+-]?\d+c$/) assert.ok(peakInfo.db > -20, `expected an audible peak dB, got ${peakInfo.db}`) assert.ok(peakInfo.normalizedX >= 0 && peakInfo.normalizedX <= 1, 'peak x should be normalized') assert.ok(peakInfo.normalizedY >= 0 && peakInfo.normalizedY <= 1, 'peak y should be normalized') let selectedIndex = 0 let smallestDistance = Number.POSITIVE_INFINITY for (let index = 0; index < state.primaryPointFrequency.length; index += 1) { if (!state.isLocalPeak(index, state.primaryPointFrequency.length)) { continue } const distance = Math.abs(state.primaryPointFrequency[index] - peakInfo.frequencyHz) if (distance < smallestDistance) { smallestDistance = distance selectedIndex = index } } assert.equal( state.isLocalPeak(selectedIndex, state.primaryPointFrequency.length), true, 'reported peak should stay anchored to a local maximum on the rendered curve', ) assert.ok( smallestDistance < 3, `expected reported peak frequency to stay near a local-peak candidate, got distance ${smallestDistance}`, ) } finally { analyzer.dispose() dom.restore() } }) test('SpectrumAnalyzer smooths peak selection without smoothing the reported position', () => { const dom = installFakeCanvasDom() const sampleRate = 48000 const frameA = createCompositeStereoChunk([ { frequencyHz: 440, amplitude: 0.95 }, { frequencyHz: 660, amplitude: 0.6 }, ], sampleRate, 4096) const frameB = createCompositeStereoChunk([ { frequencyHz: 440, amplitude: 0.72 }, { frequencyHz: 660, amplitude: 0.84 }, ], sampleRate, 4096) const frameC = createCompositeStereoChunk([ { frequencyHz: 440, amplitude: 0.28 }, { frequencyHz: 660, amplitude: 0.95 }, ], sampleRate, 4096) const pendingFrames = [frameA, frameB, frameC] const peakKeys: string[] = [] const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => { const nextFrame = pendingFrames.shift() return nextFrame ? [nextFrame] : [] }, getSampleRate: () => sampleRate, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showSideLine: true, showGrid: false, fillGradient: false, smoothing: 0, tiltDbPerOctave: 0, fftSize: 4096, dataSource, capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { if (nextPeakInfo) { peakKeys.push(nextPeakInfo.key) } }, }) try { const state = analyzer as unknown as { drawFrame: () => void } state.drawFrame() state.drawFrame() state.drawFrame() assert.match(peakKeys[0] ?? '', /^A4 [+-]?\d+c$/) assert.match(peakKeys[1] ?? '', /^A4 [+-]?\d+c$/) assert.match(peakKeys[2] ?? '', /^E5 [+-]?\d+c$/) } finally { analyzer.dispose() dom.restore() } }) test('SpectrumAnalyzer does not over-bias toward an octave-lower peak', () => { const dom = installFakeCanvasDom() const sampleRate = 48000 const frame = createCompositeStereoChunk([ { frequencyHz: 174.614, amplitude: 0.8 }, { frequencyHz: 349.228, amplitude: 1.0 }, ], sampleRate, 4096) let peakInfo: SpectrumPeakInfo | null = null const dataSource = { getPendingSpectrumSamples: () => [], getPendingSpectrumStereoSamples: () => [frame], getSampleRate: () => sampleRate, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showSideLine: true, showGrid: false, fillGradient: false, smoothing: 0, tiltDbPerOctave: 0, fftSize: 4096, dataSource, capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { peakInfo = nextPeakInfo }, }) try { const state = analyzer as unknown as { drawFrame: () => void } state.drawFrame() assert.ok(peakInfo, 'expected peak info to be reported') assert.match(peakInfo.key, /^F4 [+-]?\d+c$/) assert.ok( peakInfo.frequencyHz > 347 && peakInfo.frequencyHz < 351, `expected peak frequency near 349 Hz, got ${peakInfo.frequencyHz}`, ) } finally { analyzer.dispose() dom.restore() } }) test('astra playback progress advances from updatedAt while playing', () => { const progress = getAstraPlaybackProgress({ playbackState: 'playing', currentTime: 12, duration: 120, queueLength: 4, outputDeviceLabel: 'Built-in Output', visualizerLineColor: '#38bdf8', currentTrack: { id: 'track-1', title: 'Track', artist: 'Artist', album: 'Album', isFavorite: false, artworkDataUrl: null, }, updatedAt: 1000, }, 3500) assertAlmostEqual(progress.currentTime, 14.5, 1e-6, 'current time advances') assertAlmostEqual(progress.progress, 14.5 / 120, 1e-6, 'progress ratio advances') assert.equal(formatAstraTime(progress.currentTime), '0:14') }) test('normalizeWheelDelta keeps pixel deltas unchanged', () => { assert.equal(normalizeWheelDelta(24, 0, 320), 24) }) test('normalizeWheelDelta converts line deltas to pixels', () => { assert.equal(normalizeWheelDelta(3, 1, 320), 48) }) test('normalizeWheelDelta scales page deltas to viewport width', () => { assert.equal(normalizeWheelDelta(2, 2, 500), 900) }) test('getHorizontalWheelScrollResult converts vertical wheel input into horizontal movement', () => { assert.deepEqual( getHorizontalWheelScrollResult({ clientWidth: 320, deltaMode: 0, deltaX: 0, deltaY: 60, scrollLeft: 40, scrollWidth: 960, }), { appliedDelta: 60, nextScrollLeft: 100, }, ) }) test('getHorizontalWheelScrollResult leaves native horizontal wheel input alone', () => { assert.equal( getHorizontalWheelScrollResult({ clientWidth: 320, deltaMode: 0, deltaX: 8, deltaY: 40, scrollLeft: 40, scrollWidth: 960, }), null, ) }) test('getHorizontalWheelScrollResult is a no-op when the rail does not overflow', () => { assert.equal( getHorizontalWheelScrollResult({ clientWidth: 320, deltaMode: 0, deltaX: 0, deltaY: 40, scrollLeft: 0, scrollWidth: 320, }), null, ) }) test('getHorizontalWheelScrollResult is a no-op for excluded interactive controls', () => { assert.equal( getHorizontalWheelScrollResult({ clientWidth: 320, deltaMode: 0, deltaX: 0, deltaY: 40, isTargetExcluded: true, scrollLeft: 0, scrollWidth: 960, }), null, ) }) test('getHorizontalWheelScrollResult moves scrollLeft left for negative deltas', () => { assert.deepEqual( getHorizontalWheelScrollResult({ clientWidth: 320, deltaMode: 0, deltaX: 0, deltaY: -50, scrollLeft: 120, scrollWidth: 960, }), { appliedDelta: -50, nextScrollLeft: 70, }, ) }) test('getHorizontalWheelScrollResult normalizes line and page delta modes', () => { assert.deepEqual( getHorizontalWheelScrollResult({ clientWidth: 400, deltaMode: 1, deltaX: 0, deltaY: 2, scrollLeft: 10, scrollWidth: 1200, }), { appliedDelta: 32, nextScrollLeft: 42, }, ) assert.deepEqual( getHorizontalWheelScrollResult({ clientWidth: 400, deltaMode: 2, deltaX: 0, deltaY: 1, scrollLeft: 10, scrollWidth: 1200, }), { appliedDelta: 360, nextScrollLeft: 370, }, ) }) test('scopeSettingsToOptions wires waveform stereo mode into analyzer options', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.waveform.mode = 'stereo' profile.scopeSettings.waveform.multiband = true const theme = resolveTheme(createDefaultTheme()) const options = scopeSettingsToOptions('waveform', profile.scopeSettings.waveform, theme.waveform) assert.equal(options.mode, 'stereo') assert.equal(options.multiband, true) assert.equal(options.lineColor, theme.waveform.line) assert.equal(options.backgroundColor, theme.waveform.background) assert.equal(options.gridMajorColor, theme.waveform.guides) assert.equal(options.gridMinorColor, theme.waveform.guidesSecondary) assert.equal(Object.hasOwn(options, 'gainDb'), false) }) test('scopeSettingsToOptions forwards shared scope background and guides to oscilloscope and vectorscope', () => { const profile = createDefaultProfile('Default') const authoredTheme = createDefaultTheme() authoredTheme.scopes.background = 'rgb(3, 4, 5)' authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)' const theme = resolveTheme(authoredTheme) const oscilloscope = scopeSettingsToOptions('oscilloscope', profile.scopeSettings.oscilloscope, theme.oscilloscope) assert.equal(oscilloscope.backgroundColor, 'rgb(3, 4, 5)') assert.equal(oscilloscope.gridMajorColor, theme.oscilloscope.guides) assert.equal(oscilloscope.gridMinorColor, theme.oscilloscope.guidesSecondary) const vectorscope = scopeSettingsToOptions('vectorscope', profile.scopeSettings.vectorscope, theme.vectorscope) assert.equal(vectorscope.backgroundColor, 'rgb(3, 4, 5)') assert.equal(vectorscope.gridMajorColor, theme.vectorscope.guides) assert.equal(vectorscope.gridMinorColor, theme.vectorscope.guidesSecondary) assert.equal(vectorscope.labelColor, theme.vectorscope.labels) }) test('Oscilloscope projects raw sample amplitude without renderer gain', () => { const dom = installFakeCanvasDom() const dataSource = { getPendingOscilloscopeSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const oscilloscope = new Oscilloscope(createFakeCanvas(), { dataSource }) try { const state = oscilloscope as unknown as { projectSampleY: (sample: number, height: number) => number } assert.equal(state.projectSampleY(0.5, 180), 45) assert.equal(state.projectSampleY(-0.5, 180), 135) } finally { oscilloscope.dispose() dom.restore() } }) test('Vectorscope uses the base layout radius for projection scale', () => { const dom = installFakeCanvasDom() const dataSource = { getPendingVectorscopeSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource }) try { const state = vectorscope as unknown as { getProjectionScale: (radius: number) => number } const layout = getVectorscopeLayout(320, 180, 'lissajous') assert.equal(state.getProjectionScale(layout.radius), layout.radius) assert.equal(layout.radius, 81) } finally { vectorscope.dispose() dom.restore() } }) test('vectorscope adds a subtle dashed outer boundary without changing the base graph', () => { const lissajousRecorder = createFakeCanvasRecorder() drawVectorscopeGridForMode( createFakeCanvasContext(lissajousRecorder), 320, 180, 'rgba(255, 255, 255, 0.12)', 'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.2)', 'lissajous', ) assert.equal( lissajousRecorder.lineDashes.some((segments) => segments.length > 0), false, 'lissajous should not render an outer headroom boundary', ) const linearRecorder = createFakeCanvasRecorder() drawVectorscopeGridForMode( createFakeCanvasContext(linearRecorder), 320, 180, 'rgba(255, 255, 255, 0.12)', 'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.2)', 'linear-bipolar', ) assert.equal( linearRecorder.lineDashes.some((segments) => segments.length > 0), true, 'linear mode should render a dashed outer max boundary', ) const polarRecorder = createFakeCanvasRecorder() drawVectorscopeGridForMode( createFakeCanvasContext(polarRecorder), 320, 180, 'rgba(255, 255, 255, 0.12)', 'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.2)', 'polar-bipolar', ) const polarLayout = getVectorscopeLayout(320, 180, 'polar-bipolar') const dashedPolarArc = polarRecorder.arcs.find((arc) => arc.lineDash.length > 0) const expectedPolarOverflowRadius = Math.min( Math.min(polarLayout.centerX, 320 - polarLayout.centerX, polarLayout.centerY, 180 - polarLayout.centerY) * 0.98, polarLayout.radius * 1.25, ) assert.equal( polarRecorder.lineDashes.some((segments) => segments.length > 0), true, 'polar mode should render a dashed outer boundary', ) assert.ok( dashedPolarArc && dashedPolarArc.radius > polarLayout.radius, 'polar dashed boundary should sit outside the existing graph', ) assertAlmostEqual( dashedPolarArc?.radius ?? 0, expectedPolarOverflowRadius, 1e-6, 'polar dashed boundary should follow the next relative grid step, clamped to the canvas', ) }) test('Vectorscope keeps the original linear projection behavior', () => { const dom = installFakeCanvasDom() const dataSource = { getPendingVectorscopeSamples: () => [], getSampleRate: () => 48000, isPlaying: () => false, subscribeToSessionChanges: () => () => {}, } const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource }) try { const state = vectorscope as unknown as { drawProjectedDot: ( ctx: CanvasRenderingContext2D, left: number, right: number, mode: 'linear-bipolar', centerX: number, centerY: number, scale: number, dotSize: number, ) => void getProjectionScale: (radius: number) => number } const layout = getVectorscopeLayout(320, 180, 'linear-bipolar') const recorder = createFakeCanvasRecorder() const ctx = createFakeCanvasContext(recorder) const scale = state.getProjectionScale(layout.radius) state.drawProjectedDot(ctx, -1, 1, 'linear-bipolar', layout.centerX, layout.centerY, scale, 2) assert.equal(recorder.fillRects.length, 1) const rect = recorder.fillRects[0] const projectedCenterX = rect.x + rect.width / 2 const projectedCenterY = rect.y + rect.height / 2 assertAlmostEqual( projectedCenterX, layout.centerX + layout.radius * Math.SQRT2, 1e-6, 'linear projection should preserve the original unscaled mapping', ) assertAlmostEqual( projectedCenterY, layout.centerY, 1e-6, 'linear overs peak should stay on the side axis', ) } finally { vectorscope.dispose() dom.restore() } }) test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.spectrogram.orientation = 'vertical' profile.scopeSettings.spectrogram.tiltDbPerOctave = 5.2 profile.scopeSettings.lufsmeter.readout = 'shortTerm' profile.scopeSettings.vumeter.needleChannels = 'combined' const authoredTheme = createDefaultTheme() authoredTheme.scopes.background = 'rgb(6, 7, 8)' authoredTheme.vumeter.track = 'rgb(9, 10, 11)' authoredTheme.vumeter.needleLeft = 'rgb(20, 21, 22)' authoredTheme.vumeter.needleRight = 'rgb(23, 24, 25)' authoredTheme.vumeter.needleCombined = 'rgb(26, 27, 28)' authoredTheme.lufsmeter.track = 'rgb(12, 13, 14)' authoredTheme.lufsmeter.target = 'rgb(15, 16, 17)' const theme = resolveTheme(authoredTheme) const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram) assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)') assert.equal(spectrogram.orientation, 'vertical') assert.equal(spectrogram.tiltDbPerOctave, 5.2) const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter) assert.equal(vumeter.backgroundColor, 'rgb(6, 7, 8)') assert.equal(vumeter.trackColor, 'rgb(9, 10, 11)') assert.equal(vumeter.scaleColor, theme.vumeter.scale) assert.equal(vumeter.labelColor, theme.vumeter.labels) assert.equal(vumeter.needleLeftColor, 'rgb(20, 21, 22)') assert.equal(vumeter.needleRightColor, 'rgb(23, 24, 25)') assert.equal(vumeter.needleCombinedColor, 'rgb(26, 27, 28)') assert.equal(vumeter.needleChannels, 'combined') const lufsmeter = scopeSettingsToOptions('lufsmeter', profile.scopeSettings.lufsmeter, theme.lufsmeter) assert.equal(lufsmeter.backgroundColor, 'rgb(6, 7, 8)') assert.equal(lufsmeter.trackColor, 'rgb(12, 13, 14)') assert.equal(lufsmeter.targetColor, 'rgb(15, 16, 17)') assert.equal(lufsmeter.scaleColor, theme.lufsmeter.scale) assert.equal(lufsmeter.labelColor, theme.lufsmeter.labels) assert.equal(lufsmeter.readout, 'shortTerm') }) test('VUMeter shared needle helpers map dBFS to a classic VU face', () => { // K-14: 0 VU = -14 dBFS assert.equal(dbfsToClassicVu(-14), 0) assert.equal(dbfsToClassicVu(-11), 3) assert.equal(dbfsToClassicVu(-15), -1) assert.equal(dbfsToClassicVu(-40), -20) assertAlmostEqual(classicVuToNormalized(-20), 0, 1e-12, '-20 VU should start the scale') assertAlmostEqual(classicVuToNormalized(0), 0.81, 1e-12, '0 VU should sit near the hot zone') assertAlmostEqual(classicVuToNormalized(3), 1, 1e-12, '+3 VU should end the scale') }) test('VUMeter needle face layout stays fixed instead of scaling up', () => { const wide = resolveVUNeedleFaceLayout(1200, 500) assert.equal(wide.width, VU_NEEDLE_FACE_WIDTH_CSS_PX) assert.equal(wide.height, VU_NEEDLE_FACE_HEIGHT_CSS_PX) assertAlmostEqual(wide.x, (1200 - VU_NEEDLE_FACE_WIDTH_CSS_PX) / 2, 1e-12, 'fixed face should center in extra width') assertAlmostEqual(wide.y, 500 - VU_NEEDLE_FACE_HEIGHT_CSS_PX, 1e-12, 'fixed face should bottom-align in extra height') assert.equal(wide.scale, 1) const retinaWide = resolveVUNeedleFaceLayout(2400, 1000, 2) assert.equal(retinaWide.width, VU_NEEDLE_FACE_WIDTH_CSS_PX * 2) assert.equal(retinaWide.height, VU_NEEDLE_FACE_HEIGHT_CSS_PX * 2) assert.equal(retinaWide.scale, 1) const small = resolveVUNeedleFaceLayout(280, 180) assert.equal(small.width, 280) assert.equal(small.height, 180) assertAlmostEqual(small.scale, 0.5, 1e-12, 'fixed face should only shrink when the canvas is smaller') }) test('VUMeter shared needle helpers switch between stereo needles and combined RMS', () => { const stereo = resolveVUNeedleReadings({ leftDb: -6, rightDb: -12, leftPeakDb: -3, rightPeakDb: -9, needleChannels: 'stereo', }) const combined = resolveVUNeedleReadings({ leftDb: -6, rightDb: -12, leftPeakDb: -3, rightPeakDb: -9, needleChannels: 'combined', }) const expectedCombinedDb = stereoRmsDbAverage(-6, -12) assert.equal(stereo.length, 2) assert.equal(stereo[0]?.id, 'left') assert.equal(stereo[1]?.id, 'right') assert.equal(combined.length, 1) assert.equal(combined[0]?.id, 'combined') assertAlmostEqual(combined[0]?.db ?? 0, expectedCombinedDb, 1e-12, 'combined needle should average stereo RMS power') assert.equal(combined[0]?.peakDb, -3) assertAlmostEqual(combined[0]?.vu ?? 0, dbfsToClassicVu(expectedCombinedDb), 1e-12, 'combined needle should use classic VU mapping') }) test('scopeSummary reports VU needle channel mode', () => { const profile = createDefaultProfile('Default') assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'BAR · HORIZONTAL · K-14') profile.scopeSettings.vumeter.mode = 'needle' assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · STEREO · K-14') profile.scopeSettings.vumeter.needleChannels = 'combined' assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED · K-14') profile.scopeSettings.vumeter.referenceDb = -13.5 assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED · -13.5 dBFS') }) test('VUMeter dBFS-to-VU mapping honors custom references', () => { // Default (K-14) assert.equal(dbfsToClassicVu(-14), 0) // K-18 (broadcast) assert.equal(dbfsToClassicVu(-18, -18), 0) assert.equal(dbfsToClassicVu(-15, -18), 3) // K-10 (loud masters) assert.equal(dbfsToClassicVu(-10, -10), 0) assert.equal(dbfsToClassicVu(-30, -10), -20) }) test('scopeSummary includes only waveform display modes', () => { const profile = createDefaultProfile('Default') assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Mono') profile.scopeSettings.waveform.mode = 'stereo' assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Stereo') profile.scopeSettings.waveform.multiband = true assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Stereo · RGB') }) test('scopeSummary includes loudness readout source', () => { const profile = createDefaultProfile('Default') assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Short-term LUFS') profile.scopeSettings.lufsmeter.readout = 'integrated' assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Integrated LUFS') profile.scopeSettings.lufsmeter.readout = 'momentary' assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Momentary LUFS') }) test('scopeSummary includes spectrum peak mode when enabled', () => { const profile = createDefaultProfile('Default') assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048') profile.scopeSettings.spectrum.peakInfoMode = 'on' assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak') profile.scopeSettings.spectrum.peakInfoMode = 'following' assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow') }) test('scopeSummary includes spectrogram orientation', () => { const profile = createDefaultProfile('Default') assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'HORIZONTAL · LOG · sharper') profile.scopeSettings.spectrogram.orientation = 'vertical' assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'VERTICAL · LOG · sharper') }) test('scopeSummary summarizes now playing field visibility', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.nowPlaying.showArtist = false profile.scopeSettings.nowPlaying.showControls = false assert.equal(scopeSummary('nowPlaying', profile.scopeSettings.nowPlaying), 'Cover · Title · Bar · Time') }) test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => { const dataSource = new ScopePopoutDataSource('waveform') const monoChunk = new Float32Array([0.1, 0.2, 0.3]) const stereoLeft = new Float32Array([0.4, 0.5]) const stereoRight = new Float32Array([0.6, 0.7]) const nextMonoChunk = new Float32Array([0.8]) dataSource.pushAudioBatch([monoChunk]) assert.equal(dataSource.getPendingWaveformSamples()[0], monoChunk) assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) dataSource.pushAudioBatch([{ left: stereoLeft, right: stereoRight }]) assert.equal(dataSource.getPendingWaveformSamples().length, 0) const stereoBatch = dataSource.getPendingWaveformStereoSamples() assert.equal(stereoBatch.length, 1) assert.equal(stereoBatch[0]?.left, stereoLeft) assert.equal(stereoBatch[0]?.right, stereoRight) dataSource.pushAudioBatch([nextMonoChunk]) assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk) }) test('applying a profile snapshot does not change the machine-local frame target', () => { const previousPerformanceState = usePerformanceStore.getState() const previousSettingsState = useSettingsStore.getState() try { usePerformanceStore.getState().setFrameTarget(120) const defaultProfile = createDefaultProfile('Default') const alternateProfile = createDefaultProfile('Live Mix') alternateProfile.hiddenScopes = [] useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: 'profile_live_mix', profiles: { profile_default: defaultProfile, profile_live_mix: alternateProfile, }, }) assert.equal(usePerformanceStore.getState().frameTarget, 120) } finally { usePerformanceStore.setState({ frameTarget: previousPerformanceState.frameTarget, dockedRenderFps: previousPerformanceState.dockedRenderFps, }) useSettingsStore.setState(previousSettingsState) } }) test('applying a profile snapshot does not change the machine-local trim', () => { const previousAudioState = useAudioStore.getState() const previousSettingsState = useSettingsStore.getState() try { useAudioStore.setState({ inputGainDb: 6.5 }) const defaultProfile = createDefaultProfile('Default') const alternateProfile = createDefaultProfile('Live Mix') alternateProfile.hiddenScopes = [] useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: 'profile_live_mix', profiles: { profile_default: defaultProfile, profile_live_mix: alternateProfile, }, }) assert.equal(useAudioStore.getState().inputGainDb, 6.5) } finally { useAudioStore.setState(previousAudioState) useSettingsStore.setState(previousSettingsState) } }) test('profile draft comparisons return to clean after reverting a change', () => { const baselineProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) baselineProfile.windowBounds = { x: 24, y: 48, width: 900, height: 180 } baselineProfile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 160, y: 90, width: 420, height: 240 }, } const baselineDraft = buildProfileDraft({ scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, scopeSettings: baselineProfile.scopeSettings, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) const changedDraft = buildProfileDraft({ scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, scopeSettings: { ...baselineProfile.scopeSettings, waveform: { ...baselineProfile.scopeSettings.waveform, scrollSpeed: baselineProfile.scopeSettings.waveform.scrollSpeed + 1, }, }, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) const revertedDraft = buildProfileDraft({ scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, scopeSettings: baselineProfile.scopeSettings, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) assert.equal(profilesMatch(baselineDraft, changedDraft), false) assert.equal(profilesMatch(baselineDraft, revertedDraft), true) }) test('buildProfileDraft omits theme metadata from the runtime draft', () => { const profile = createDefaultProfile('Live Mix') const draft = buildProfileDraft({ scopeOrder: profile.scopeOrder, hiddenScopes: profile.hiddenScopes, widthWeights: profile.widthWeights, scopeSettings: profile.scopeSettings, scopePopouts: profile.scopePopouts, windowBounds: profile.windowBounds, }, profile.name) assert.equal('themeId' in draft, false) }) test('resolveNativeThemeSource follows the active theme brightness for native UI', () => { const darkTheme = createDefaultTheme() const lightTheme = createDefaultTheme() lightTheme.app.background = 'rgb(248, 250, 252)' lightTheme.app.surface = 'rgba(255, 255, 255, 0.96)' lightTheme.app.surfaceAlt = 'rgba(255, 255, 255, 0.92)' lightTheme.app.text = 'rgb(15, 23, 42)' lightTheme.app.textMuted = 'rgba(15, 23, 42, 0.48)' lightTheme.controls.menuSurface = 'rgb(255, 255, 255)' lightTheme.controls.menuBorder = 'rgba(15, 23, 42, 0.12)' assert.equal(resolveNativeThemeSource(darkTheme), 'dark') assert.equal(resolveNativeThemeSource(lightTheme), 'light') }) test('resolveThemeCreditDetails normalizes descriptions and enables links only for valid http and https theme websites', () => { assert.deepEqual( resolveThemeCreditDetails({ credit: 'Night Shift', website: 'https://themes.example/night', description: ' Soft neon palette ', }), { credit: 'Night Shift', url: 'https://themes.example/night', description: 'Soft neon palette', }, ) assert.deepEqual( resolveThemeCreditDetails({ credit: 'Night Shift', website: 'http://themes.example/night', description: 'Soft neon palette', }), { credit: 'Night Shift', url: 'http://themes.example/night', description: 'Soft neon palette', }, ) assert.deepEqual( resolveThemeCreditDetails({ credit: 'Night Shift', website: 'ftp://themes.example/night', description: 'Soft neon palette', }), { credit: 'Night Shift', url: null, description: 'Soft neon palette', }, ) assert.deepEqual( resolveThemeCreditDetails({ credit: 'Night Shift', website: 'not a url', description: 'Soft neon palette', }), { credit: 'Night Shift', url: null, description: 'Soft neon palette', }, ) assert.deepEqual( resolveThemeCreditDetails({ credit: ' ', website: 'https://themes.example/night' }), { credit: null, url: null, description: null, }, ) assert.deepEqual( resolveThemeCreditDetails({ description: 'Soft neon palette' }), { credit: null, url: null, description: null, }, ) }) test('resolveThemeOptionLabel appends creator credit when present', () => { assert.equal( resolveThemeOptionLabel({ name: 'Night Shift', credit: 'Astra' }), 'Night Shift - Astra', ) assert.equal( resolveThemeOptionLabel({ name: 'Night Shift' }), 'Night Shift', ) assert.equal( resolveThemeOptionLabel({ name: 'Night Shift', credit: ' ' }), 'Night Shift', ) }) test('BottomBar theme section renders compact credit metadata and opens valid links through Electron', async () => { const componentSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8') const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') assert.match(componentSource, /const themeCredit = resolveThemeCreditDetails\(activeTheme\)/) assert.match(componentSource, /window\.electronAPI\.openExternalUrl\(url\)/) assert.match(componentSource, /bottom-bar__theme-metadata/) assert.match(componentSource, /By \{themeCredit\.credit\}/) assert.match(componentSource, /themeCredit\.description \?/) assert.match(componentSource, /bottom-bar__theme-description/) assert.match(componentSource, /bottom-bar__theme-separator/) assert.match(componentSource, /bottom-bar__section-header/) assert.match(componentSource, /bottom-bar__theme-credit--link/) assert.match(stylesSource, /\.bottom-bar__section--theme \{[\s\S]*min-width: 420px;/) assert.match(stylesSource, /\.bottom-bar__section-header \{/) assert.match(stylesSource, /\.bottom-bar__theme-metadata \{/) assert.match(stylesSource, /\.bottom-bar__theme-description \{/) assert.match(stylesSource, /\.bottom-bar__theme-credit--link \{/) }) test('BottomBar close button uses flat themed control backgrounds', async () => { const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') const closeBlock = [...stylesSource.matchAll(/^\.settings-panel__close \{([\s\S]*?)\n\}/gm)] .map((match) => match[1]) .find((block) => block.includes('min-height: 34px;')) const closeHoverBlock = stylesSource.match(/\.settings-panel__close:hover \{([\s\S]*?)\n\}/)?.[1] assert.ok(closeBlock) assert.ok(closeHoverBlock) assert.match(closeBlock, /background: var\(--control-bg\);/) assert.match(closeHoverBlock, /background: var\(--control-bg-hover\);/) assert.doesNotMatch(closeBlock, /linear-gradient/) assert.doesNotMatch(closeHoverBlock, /linear-gradient/) }) test('toolbar uses the Prism logo support link and static package icons are configured', async () => { const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8') const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') const packageSource = await readFile(join(process.cwd(), 'package.json'), 'utf8') const packageJson = JSON.parse(packageSource) as { build: { mac: { icon: string } win: { icon: string } linux: { icon: string } extraResources: Array<{ from: string; to: string }> } } assert.match(toolbarSource, /import PrismLogo from '\.\/PrismLogo'/) assert.match(toolbarSource, /const PRISM_SUPPORT_URL = 'https:\/\/ko-fi\.com\/boof2015'/) assert.match(toolbarSource, /window\.electronAPI\.openExternalUrl\(PRISM_SUPPORT_URL\)/) assert.match(toolbarSource, //) assert.match(toolbarSource, /toolbar__brand-heart/) assert.doesNotMatch(toolbarSource, /toolbar__brand-mark/) assert.doesNotMatch(toolbarSource, /toolbar__brand-text/) assert.match(stylesSource, /\.toolbar__brand-heart \{/) assert.match(stylesSource, /\.prism-logo__path--upper \{[\s\S]*fill: var\(--accent\);/) assert.match(stylesSource, /animation: titlebarLogoHeartPop 0\.22s cubic-bezier\(0\.22, 1, 0\.36, 1\);/) assert.match(stylesSource, /@keyframes titlebarLogoHeartPop/) assert.match(stylesSource, /@media \(prefers-reduced-motion: reduce\)/) assert.match(mainSource, /nativeImage/) assert.match(mainSource, /function applyStaticDockIcon\(\)/) assert.match(mainSource, /process\.platform !== 'darwin' \|\| app\.isPackaged/) assert.match(mainSource, /app\.dock\?\.setIcon\(icon\)/) assert.match(mainSource, /applyStaticDockIcon\(\)/) assert.match(mainSource, /function getStaticWindowIconOptions\(\)/) assert.match(mainSource, /\.\.\.getStaticWindowIconOptions\(\)/) assert.equal(packageJson.build.mac.icon, 'resources/icon.icns') assert.equal(packageJson.build.win.icon, 'resources/icon.ico') assert.equal(packageJson.build.linux.icon, 'resources/icon.png') assert.ok(packageJson.build.extraResources.some((entry) => { return entry.from === 'resources/icon.png' && entry.to === 'icon.png' })) }) test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'linux', argv: [], env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', }, }), { displayServer: 'wayland', useNativeDragRegions: true, supportsProgrammaticReposition: false, supportsGeometryPersistence: false, }, ) }) test('resolveWindowCapabilities respects --ozone-platform=x11 in a Wayland session', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'linux', argv: ['--ozone-platform=x11'], env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0', }, }), { displayServer: 'x11', useNativeDragRegions: false, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, }, ) }) test('resolveWindowCapabilities detects X11 sessions on Linux', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'linux', argv: [], env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', }, }), { displayServer: 'x11', useNativeDragRegions: false, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, }, ) }) test('resolveWindowCapabilities uses native drag regions on macOS while preserving geometry controls', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'darwin', argv: [], env: {}, }), { displayServer: 'other', useNativeDragRegions: true, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, }, ) }) test('resolveWindowCapabilities uses native drag regions on Windows while preserving geometry controls', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'win32', argv: [], env: {}, }), { displayServer: 'other', useNativeDragRegions: true, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, }, ) }) test('Wayland window controls use native drag regions and omit unsupported reposition/geometry paths', async () => { const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8') const appSource = await readFile(join(process.cwd(), 'src', 'renderer', 'App.tsx'), 'utf8') const bridgeSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'ScopePopoutBridge.tsx'), 'utf8') const stripSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Strip.tsx'), 'utf8') const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') assert.match(toolbarSource, /WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE/) assert.match(toolbarSource, /disabled=\{!supportsProgrammaticReposition\}/) assert.match(toolbarSource, /useNativeDragRegions \? 'is-native-drag' : ''/) assert.match(appSource, /onMouseDown=\{useNativeDragRegions \? undefined : handleAltDragStart\}/) assert.match(bridgeSource, /bounds: supportsGeometryPersistence\s*\?\s*scopePopouts\[kind\]\?\.windowBounds\s*:\s*undefined/) assert.match(stripSource, /if \(!supportsGeometryPersistence\) \{\s*popOutScope\(kind\)/) assert.match(stylesSource, /\.toolbar\.is-native-drag \{/) assert.match(stylesSource, /\.scope-popout__header\.is-native-drag \{/) }) test('main and detached windows keep frameless Prism chrome while enabling snap-compatible OS window semantics', async () => { const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') const appSource = await readFile(join(process.cwd(), 'src', 'renderer', 'App.tsx'), 'utf8') const popoutSource = await readFile(join(process.cwd(), 'src', 'renderer', 'popouts', 'ScopePopoutWindow.tsx'), 'utf8') const nowPlayingSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'NowPlayingConfigWindow.tsx'), 'utf8') assert.match(mainSource, /function getSnapCapableFramelessWindowOptions\(\): Pick<[\s\S]*?return \{[\s\S]*?frame: false,[\s\S]*?roundedCorners: false,[\s\S]*?hasShadow: false,[\s\S]*?thickFrame: true,[\s\S]*?resizable: true,[\s\S]*?maximizable: true,[\s\S]*?fullscreenable: true,[\s\S]*?skipTaskbar: false,[\s\S]*?\}/) assert.match(mainSource, /function createMainWindow\(\): void \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) assert.match(mainSource, /function createScopePopoutWindow\(kind: ScopeKind, rawBounds\?: WindowBounds\): BrowserWindow \| null \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) assert.match(mainSource, /function createNowPlayingConfigWindow\(\): BrowserWindow \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) assert.match(popoutSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) assert.match(nowPlayingSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) assert.doesNotMatch(appSource, /WindowResizeOverlay/) assert.doesNotMatch(popoutSource, /WindowResizeOverlay/) assert.doesNotMatch(nowPlayingSource, /WindowResizeOverlay/) }) test('programmatic top/bottom reposition flushes fresh bounds through persistence channels', async () => { const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') assert.match(mainSource, /function sendMainWindowBoundsChanged\(window: BrowserWindow\): void \{[\s\S]*?window\.webContents\.send\('window:bounds-changed', toLogicalBounds\(window\)\)[\s\S]*?\}/) assert.match(mainSource, /function flushMainWindowBoundsChanged\(window: BrowserWindow\): void \{[\s\S]*?clearPendingMainWindowBoundsSave\(\)[\s\S]*?sendMainWindowBoundsChanged\(window\)[\s\S]*?\}/) assert.match(mainSource, /function sendScopePopoutBoundsChanged\(kind: ScopeKind, window: BrowserWindow\): void \{[\s\S]*?mainWindow\.webContents\.send\('scope-popout:bounds-changed', kind, toLogicalBounds\(window\)\)[\s\S]*?\}/) assert.match(mainSource, /function flushScopePopoutBoundsChanged\(kind: ScopeKind, window: BrowserWindow\): void \{[\s\S]*?popoutBoundsTimers\.delete\(kind\)[\s\S]*?suppressNextPopoutBoundsEvents\.delete\(kind\)[\s\S]*?sendScopePopoutBoundsChanged\(kind, window\)[\s\S]*?\}/) assert.match(mainSource, /applyLogicalBounds\(targetWindow, \{[\s\S]*?height: logicalBounds\.height,[\s\S]*?\}\)\s*flushRepositionedWindowBounds\(targetWindow\)\s*return/) assert.match(mainSource, /targetWindow\.setSize\(workArea\.width, height\)\s*flushRepositionedWindowBounds\(targetWindow\)/) }) test('toggleScope appends now playing to the scope order when it is enabled from an opt-in profile', () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow() try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) seedProfileDraftState(profile) assert.equal(useSettingsStore.getState().scopeOrder.includes('nowPlaying'), false) useSettingsStore.getState().toggleScope('nowPlaying') assert.equal(useSettingsStore.getState().scopeOrder.at(-1), 'nowPlaying') assert.equal(useSettingsStore.getState().hiddenScopes.has('nowPlaying'), false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('resolveMainWindowSettingsHeight waits for real measurements instead of using a placeholder height', () => { assert.equal(resolveMainWindowSettingsHeight(false, 312, 96), 0) assert.equal(resolveMainWindowSettingsHeight(true, 0, 96), 0) assert.equal(resolveMainWindowSettingsHeight(true, 312, 0), 0) assert.equal(resolveMainWindowSettingsHeight(true, 312, 96), 408) }) test('resolveMainWindowSettingsPanelHeight uses intrinsic scope-track height when the panel is stretched', () => { const globalWithStyle = globalThis as typeof globalThis & { getComputedStyle?: (element: Element) => CSSStyleDeclaration } const previousGetComputedStyle = globalWithStyle.getComputedStyle globalWithStyle.getComputedStyle = () => ({ paddingTop: '10px', paddingBottom: '4px', }) as CSSStyleDeclaration try { const stretchedPanel = { scrollHeight: 640 } as HTMLElement const collapsedScopeTrack = { scrollHeight: 220.25 } as HTMLElement assert.equal(resolveMainWindowSettingsPanelHeight(stretchedPanel, collapsedScopeTrack), 235) assert.equal(resolveMainWindowSettingsPanelHeight(stretchedPanel, null), 640) } finally { if (previousGetComputedStyle) { globalWithStyle.getComputedStyle = previousGetComputedStyle } else { delete globalWithStyle.getComputedStyle } } }) test('expanded main-window bounds can push upward into an overlapping display above', () => { const resolved = resolveExpandedMainWindowBounds( { x: 700, y: 1110, width: 900, height: 180 }, 400, [ { x: 0, y: 0, width: 1920, height: 1080 }, { x: 600, y: 1080, width: 720, height: 260 }, ], ) assert.equal(resolved.x, 700) assert.equal(resolved.y, 760) assert.equal(resolved.height, 580) }) test('expanded main-window bounds can span into a taller side display instead of clamping to one display', () => { const resolved = resolveExpandedMainWindowBounds( { x: 650, y: 650, width: 400, height: 180 }, 300, [ { x: 0, y: 0, width: 800, height: 800 }, { x: 800, y: 0, width: 800, height: 1200 }, ], ) assert.equal(resolved.x, 650) assert.equal(resolved.y, 650) assert.equal(resolved.height, 480) }) test('dragged main-window bounds keep a visible grab margin without sticking at a display seam', () => { const clamped = clampDraggedMainWindowBounds( { x: 760, y: 120, width: 400, height: 220 }, [ { x: 0, y: 0, width: 800, height: 900 }, { x: 800, y: 0, width: 800, height: 900 }, ], 64, ) assert.equal(clamped.x, 760) assert.equal(clamped.y, 120) }) test('restored window bounds clamp back to a visible display margin', () => { const clamped = clampRestoredWindowBounds( { x: 1800, y: 1400, width: 420, height: 240 }, [{ x: 0, y: 0, width: 800, height: 600 }], 64, ) assert.equal(clamped.x, 736) assert.equal(clamped.y, 536) assert.equal(clamped.width, 420) assert.equal(clamped.height, 240) }) test('raiseWindowAboveNormalPopouts raises the main window when an unpinned popout exists', () => { const main = createFakeStackableWindow() const normalPopout = createFakeStackableWindow(false) const pinnedPopout = createFakeStackableWindow(true) const raised = raiseWindowAboveNormalPopouts(main.window, [normalPopout.window, pinnedPopout.window]) assert.equal(raised, true) assert.equal(main.getMoveTopCalls(), 1) }) test('raiseWindowAboveNormalPopouts leaves pinned popouts above the main window', () => { const main = createFakeStackableWindow() const pinnedPopout = createFakeStackableWindow(true) const raised = raiseWindowAboveNormalPopouts(main.window, [pinnedPopout.window]) assert.equal(raised, false) assert.equal(main.getMoveTopCalls(), 0) }) test('main-window bounds updates persist working state in Electron mode', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow() try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } seedProfileDraftState(profile) useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.equal(fakeStorage.getSetCount(), 1) useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) assert.equal(fakeStorage.getSetCount(), 2) useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.equal(fakeStorage.getSetCount(), 3) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('native Wayland main-window bounds updates do not dirty or persist geometry', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow({ platform: 'linux', windowCapabilities: resolveWindowCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', }, }), }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } seedProfileDraftState(profile) useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.deepEqual(useSettingsStore.getState().windowBounds, profile.windowBounds) assert.equal(fakeStorage.getSetCount(), 0) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles restores persisted dirty window bounds while keeping the saved profile as baseline', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const restoredBounds: WindowBounds[] = [] const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 } const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => ({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }), getWindowBounds: async () => dirtyBounds, setWindowBounds: (bounds: WindowBounds) => { restoredBounds.push(bounds) }, }) try { useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) await Promise.resolve() await Promise.resolve() const rawStored = fakeStorage.getItem('prism:settings') assert.ok(rawStored) const stored = JSON.parse(rawStored) as Record fakeStorage.setItem('prism:settings', JSON.stringify({ ...stored, windowBounds: dirtyBounds, })) restoredBounds.length = 0 useSettingsStore.setState(previousSettingsState) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID) assert.deepEqual(state.windowBounds, dirtyBounds) assert.deepEqual(state.savedProfileBaseline?.windowBounds, profile.windowBounds) assert.equal(state.hasUnsavedProfileChanges, true) assert.deepEqual(restoredBounds, [dirtyBounds]) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles ignores persisted geometry on native Wayland and keeps the saved profile clean', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const savedBounds = { x: 10, y: 20, width: 900, height: 180 } const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } const restoredBounds: WindowBounds[] = [] let getWindowBoundsCalls = 0 const fakeWindow = installFakeElectronWindow({ platform: 'linux', windowCapabilities: resolveWindowCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', }, }), getProfileSnapshot: async () => { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = savedBounds profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: savedPopoutBounds, } return { activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, } }, getWindowBounds: async () => { getWindowBoundsCalls += 1 return { x: 44, y: 55, width: 900, height: 180 } }, setWindowBounds: (bounds: WindowBounds) => { restoredBounds.push(bounds) }, }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = savedBounds profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: savedPopoutBounds, } fakeStorage.setItem('prism:settings', JSON.stringify({ scopeOrder: profile.scopeOrder, hiddenScopes: profile.hiddenScopes, widthWeights: profile.widthWeights, scopeSettings: profile.scopeSettings, scopePopouts: { ...profile.scopePopouts, spectrum: { poppedOut: true, windowBounds: { x: 188, y: 92, width: 440, height: 260 }, }, }, windowBounds: { x: 44, y: 55, width: 900, height: 180 }, })) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID) assert.deepEqual(state.windowBounds, savedBounds) assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds) assert.equal(state.hasUnsavedProfileChanges, false) assert.deepEqual(restoredBounds, []) assert.equal(getWindowBoundsCalls, 0) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles ignores stale persisted theme-only state and loads the saved profile normally', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const savedBounds = { x: 10, y: 20, width: 900, height: 180 } const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = savedBounds return { activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, } }, getWindowBounds: async () => savedBounds, setWindowBounds: () => {}, }) try { fakeStorage.setItem('prism:settings', JSON.stringify({ themeId: 'theme_midnight', })) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID) assert.deepEqual(state.windowBounds, savedBounds) assert.deepEqual(state.savedProfileBaseline?.windowBounds, savedBounds) assert.equal(state.hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles ignores legacy persisted inline popouts and restores active profile popouts', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: savedPopoutBounds, } return { activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, } }, getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { const staleProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) staleProfile.scopePopouts.spectrum = { poppedOut: false, windowBounds: savedPopoutBounds, } fakeStorage.setItem('prism:settings', JSON.stringify({ scopeOrder: staleProfile.scopeOrder, hiddenScopes: staleProfile.hiddenScopes, widthWeights: staleProfile.widthWeights, scopeSettings: staleProfile.scopeSettings, scopePopouts: staleProfile.scopePopouts, })) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.scopePopouts.spectrum.poppedOut, true) assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds) assert.equal(state.hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles restores saved popout open state while preserving matching dirty draft values', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } const dirtyPopoutBounds = { x: 180, y: 72, width: 440, height: 260 } const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: savedPopoutBounds, } const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => ({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }), getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) await Promise.resolve() await Promise.resolve() const rawStored = fakeStorage.getItem('prism:settings') assert.ok(rawStored) const stored = JSON.parse(rawStored) as { activeProfileId?: string | null profileBaselineSignature?: string scopePopouts: ScopePopoutStateMap } assert.equal(stored.activeProfileId, DEFAULT_PROFILE_ID) assert.equal(typeof stored.profileBaselineSignature, 'string') fakeStorage.setItem('prism:settings', JSON.stringify({ ...stored, scopeSettings: { ...profile.scopeSettings, spectrum: { ...profile.scopeSettings.spectrum, smoothing: 0.42, }, }, scopePopouts: { ...stored.scopePopouts, spectrum: { poppedOut: false, windowBounds: dirtyPopoutBounds, }, }, })) useSettingsStore.setState(previousSettingsState) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.scopePopouts.spectrum.poppedOut, true) assert.deepEqual(state.scopePopouts.spectrum.windowBounds, dirtyPopoutBounds) assert.equal(state.scopeSettings.spectrum.smoothing, 0.42) assert.equal(state.hasUnsavedProfileChanges, true) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('initializeProfiles discards persisted popout state when the saved profile baseline changed', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } const originalProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) const changedProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) changedProfile.scopePopouts.spectrum = { poppedOut: true, windowBounds: savedPopoutBounds, } let snapshotProfile = originalProfile const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => ({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: snapshotProfile, }, }), getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: originalProfile, }, }) await Promise.resolve() await Promise.resolve() const rawStored = fakeStorage.getItem('prism:settings') assert.ok(rawStored) const stored = JSON.parse(rawStored) as { scopePopouts: ScopePopoutStateMap } fakeStorage.setItem('prism:settings', JSON.stringify({ ...stored, scopePopouts: { ...stored.scopePopouts, spectrum: { poppedOut: false, windowBounds: { x: 180, y: 72, width: 440, height: 260 }, }, }, })) snapshotProfile = changedProfile useSettingsStore.setState(previousSettingsState) await useSettingsStore.getState().initializeProfiles() const state = useSettingsStore.getState() assert.equal(state.scopePopouts.spectrum.poppedOut, true) assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds) assert.equal(state.hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('profiles without saved window bounds mark the first user move dirty after load sync completes', async () => { const previousSettingsState = useSettingsStore.getState() const currentBounds = { x: 10, y: 20, width: 900, height: 180 } const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => currentBounds, setWindowBounds: () => {}, }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) await Promise.resolve() await Promise.resolve() assert.deepEqual(useSettingsStore.getState().savedProfileBaseline?.windowBounds, currentBounds) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('loading a profile syncs the live window bounds without marking the draft dirty', async () => { const previousSettingsState = useSettingsStore.getState() const currentBounds = { x: 52, y: 18, width: 940, height: 192 } const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => currentBounds, setWindowBounds: () => {}, }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) await Promise.resolve() await Promise.resolve() assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.deepEqual(useSettingsStore.getState().windowBounds, currentBounds) assert.deepEqual(useSettingsStore.getState().savedProfileBaseline?.windowBounds, currentBounds) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('switching profiles leaves the global theme alone and does not mark the profile dirty', async () => { const previousSettingsState = useSettingsStore.getState() const previousThemeState = useThemeStore.getState() const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) const liveMixProfile = createDefaultProfile('Live Mix') const midnightTheme = createDefaultTheme() midnightTheme.name = 'Midnight' midnightTheme.credit = 'Night Shift' midnightTheme.app.background = 'rgb(6, 10, 20)' useThemeStore.setState({ themes: { Default: createDefaultTheme(), Midnight: midnightTheme, }, activeThemeId: 'Midnight', activeTheme: resolveTheme(midnightTheme), accent: resolveTheme(midnightTheme).interface.accent, }) useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: 'profile_live_mix', profiles: { [DEFAULT_PROFILE_ID]: defaultProfile, profile_live_mix: liveMixProfile, }, }) await Promise.resolve() await Promise.resolve() assert.equal(useThemeStore.getState().activeThemeId, 'Midnight') assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) useThemeStore.setState(previousThemeState) fakeWindow.restore() } }) test('profile load absorbs immediate macOS-style window bound adjustments without marking dirty', async () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => ({ x: 16, y: 18, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) useSettingsStore.getState().updateMainWindowBounds({ x: 16, y: 18, width: 900, height: 180 }) await Promise.resolve() await Promise.resolve() assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('profile load absorbs immediate popout bound adjustments without marking dirty', async () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 140, y: 60, width: 420, height: 240 }, } useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, profiles: { [DEFAULT_PROFILE_ID]: profile, }, }) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 156, y: 58, width: 420, height: 240 }) await Promise.resolve() await Promise.resolve() assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('geometry sync window extends while load-time macOS bound updates continue', () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow() try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) seedProfileDraftState(profile) useSettingsStore.setState((state) => ({ ...state, geometrySyncUntil: Date.now() + 50, })) const before = useSettingsStore.getState().geometrySyncUntil useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 18, width: 900, height: 180 }) assert.ok(useSettingsStore.getState().geometrySyncUntil > before) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() } }) test('popout bounds updates persist working state in Electron mode', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow() try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 140, y: 60, width: 420, height: 240 }, } seedProfileDraftState(profile) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.equal(fakeStorage.getSetCount(), 1) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) assert.equal(fakeStorage.getSetCount(), 2) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.equal(fakeStorage.getSetCount(), 3) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('native Wayland popout bounds updates do not dirty or persist geometry', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow({ platform: 'linux', windowCapabilities: resolveWindowCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', }, }), }) try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 140, y: 60, width: 420, height: 240 }, } seedProfileDraftState(profile) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 156, y: 58, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.deepEqual(useSettingsStore.getState().scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds) assert.equal(fakeStorage.getSetCount(), 0) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() fakeStorage.restore() } }) test('moveDockedScopeOrder is a no-op at the docked boundaries', () => { const initialOrder = [...SCOPE_KINDS] assert.equal( moveDockedScopeOrder(initialOrder, new Set(), createScopePopouts(), 'spectrum', 'left'), initialOrder, ) assert.equal( moveDockedScopeOrder(initialOrder, new Set(), createScopePopouts(), 'nowPlaying', 'right'), initialOrder, ) }) test('moveDockedScopeOrder preserves hidden scope positions in the full order', () => { const nextOrder = moveDockedScopeOrder( [...SCOPE_KINDS], new Set(['oscilloscope']), createScopePopouts(), 'vectorscope', 'left', ) assert.deepEqual(nextOrder, [ 'vectorscope', 'oscilloscope', 'spectrum', 'spectrogram', 'vumeter', 'lufsmeter', 'waveform', 'nowPlaying', ]) }) test('moveDockedScopeOrder preserves popped-out scope positions in the full order', () => { const nextOrder = moveDockedScopeOrder( [...SCOPE_KINDS], new Set(), createScopePopouts(['oscilloscope']), 'spectrogram', 'left', ) assert.deepEqual(nextOrder, [ 'spectrum', 'oscilloscope', 'spectrogram', 'vectorscope', 'vumeter', 'lufsmeter', 'waveform', 'nowPlaying', ]) }) test('VUMeterBallistics holds VU, bar, and correlation steady when an active frame receives no chunks', () => { const sampleRate = 48000 const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const meter = new VUMeterBallistics(sampleRate) const initial = meter.process([createFilledStereoChunk(0.5, 0.5, windowSamples)], 300) const held = meter.process([], 316) assert.ok(initial.vuLDb > -7 && initial.vuLDb < -5) assertAlmostEqual(held.vuLDb, initial.vuLDb, 1e-12, 'left VU should hold across empty frames') assertAlmostEqual(held.vuRDb, initial.vuRDb, 1e-12, 'right VU should hold across empty frames') assertAlmostEqual(held.barLDb, initial.barLDb, 1e-12, 'left bar should hold across empty frames') assertAlmostEqual(held.barRDb, initial.barRDb, 1e-12, 'right bar should hold across empty frames') assertAlmostEqual(held.correlation, initial.correlation, 1e-12, 'correlation should hold across empty frames') assert.notEqual(held.vuLDb, VU_METER_MIN_DB) }) test('VUMeterBallistics produces the same VU, bar, and correlation for contiguous and irregular chunk delivery', () => { const sampleRate = 48000 const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const program = createProgramSamples(windowSamples) const contiguous = new VUMeterBallistics(sampleRate) const irregular = new VUMeterBallistics(sampleRate) const contiguousSnapshot = contiguous.process([program], (windowSamples / sampleRate) * 1000) const chunkSizes = [127, 509, 33, 2048, 401, 89, 3072, 17, 611, 1536] let offset = 0 let nowMs = 0 let chunkIndex = 0 while (offset < windowSamples) { const chunkSize = chunkSizes[chunkIndex % chunkSizes.length] ?? 1 const nextOffset = Math.min(windowSamples, offset + chunkSize) const left = program.left.slice(offset, nextOffset) const right = program.right.slice(offset, nextOffset) nowMs += ((nextOffset - offset) / sampleRate) * 1000 irregular.process([{ left, right }], nowMs) offset = nextOffset chunkIndex += 1 } const irregularSnapshot = irregular.getSnapshot() assertAlmostEqual(irregularSnapshot.vuLDb, contiguousSnapshot.vuLDb, 1e-6, 'left VU should be chunking-invariant') assertAlmostEqual(irregularSnapshot.vuRDb, contiguousSnapshot.vuRDb, 1e-6, 'right VU should be chunking-invariant') assertAlmostEqual(irregularSnapshot.barLDb, contiguousSnapshot.barLDb, 1e-6, 'left bar should be chunking-invariant') assertAlmostEqual(irregularSnapshot.barRDb, contiguousSnapshot.barRDb, 1e-6, 'right bar should be chunking-invariant') assertAlmostEqual(irregularSnapshot.correlation, contiguousSnapshot.correlation, 1e-6, 'correlation should be chunking-invariant') }) test('VUMeterBallistics keeps RMS finite across loud-then-silent sequences (NaN-drift regression)', () => { const sampleRate = 48000 const meter = new VUMeterBallistics(sampleRate) const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) // Run loud audio long enough to fully populate the sliding-window buffer. meter.process([createFilledStereoChunk(0.7, 0.7, windowSamples * 2)], 100) // Then feed many windows of digital silence. Floating-point cancellation in // the running-sum slide-out used to drift sumSqL/sumSqR slightly below zero, // producing NaN through Math.sqrt. After the fix, the RMS must remain finite // and decay all the way to VU_METER_MIN_DB. let nowMs = 100 for (let i = 0; i < 20; i += 1) { nowMs += 50 meter.process([createFilledStereoChunk(0, 0, windowSamples)], nowMs) } const snapshot = meter.getSnapshot() assert.ok(Number.isFinite(snapshot.vuLDb), 'left VU must stay finite (no NaN)') assert.ok(Number.isFinite(snapshot.vuRDb), 'right VU must stay finite (no NaN)') assert.equal(snapshot.vuLDb, VU_METER_MIN_DB) assert.equal(snapshot.vuRDb, VU_METER_MIN_DB) }) test('VUMeterBallistics lets the bar outrun the VU needle while peak hold remains highest', () => { const sampleRate = 48000 const meter = new VUMeterBallistics(sampleRate) const baselineSamples = Math.floor(sampleRate * 0.2) const burstSamples = Math.floor(sampleRate * 0.016) meter.process([createFilledStereoChunk(0.1, 0.1, baselineSamples)], 200) const burstSnapshot = meter.process([createFilledStereoChunk(1.0, 1.0, burstSamples)], 216) const beforeDecay = meter.process([], 216 + VU_PEAK_HOLD_MS - 10) const afterDecay = meter.process([], 216 + VU_PEAK_HOLD_MS + 100) assert.ok(burstSnapshot.vuLDb < -10) assert.ok(burstSnapshot.barLDb > burstSnapshot.vuLDb + 8) assert.ok(burstSnapshot.peakLDb >= burstSnapshot.barLDb) assert.equal(burstSnapshot.peakLDb, 0) assert.equal(beforeDecay.peakLDb, 0) assert.ok(afterDecay.peakLDb < beforeDecay.peakLDb) assert.ok(afterDecay.peakLDb > -3) }) test('NativeVisualizerTransport feeds native scope state from chunk arrival without a render tick', () => { const { bridge, calls } = createFakeTransportBridge() const transport = new NativeVisualizerTransport(bridge) const left = new Float32Array([0.2, 0.4, 0.6]) const right = new Float32Array([0.8, 0.6, 0.4]) transport.setDemand({ spectrum: true, oscilloscope: true, vectorscope: true, }) transport.reset({ sessionId: 1, sampleRate: 48000, channelCount: 2, capturing: true, }) transport.handleChunk(left, right, { sessionId: 1, channelCount: 2, }) assert.equal(calls.oscilloscopePushes.length, 1) assert.equal(calls.vectorscopePushes.length, 1) assert.equal(calls.spectrumPushes.length, 1) assert.equal(calls.oscilloscopePushes[0], left) assert.equal(calls.vectorscopePushes[0]?.left, left) assert.equal(calls.vectorscopePushes[0]?.right, right) assert.deepEqual(Array.from(calls.spectrumPushes[0]), [0.5, 0.5, 0.5]) assert.deepEqual(readSpectrumMagnitudes(transport), [0.5, 0.5]) }) test('NativeVisualizerTransport resets cached state on session changes and sample-rate updates', () => { const { bridge, calls } = createFakeTransportBridge() const transport = new NativeVisualizerTransport(bridge) transport.setDemand({ spectrum: true, oscilloscope: true, vectorscope: true }) transport.reset({ sessionId: 7, sampleRate: 48000, channelCount: 2, capturing: true, }) transport.handleChunk(new Float32Array([1, 1]), new Float32Array([1, 1]), { sessionId: 7, channelCount: 2, }) assert.deepEqual(readSpectrumMagnitudes(transport), [1, 1]) transport.reset({ sessionId: 8, sampleRate: 96000, channelCount: 2, capturing: true, }) assert.equal(calls.oscilloscopeSampleRates.at(-1), 96000) assert.equal(calls.spectrumSampleRates.at(-1), 96000) assert.equal(calls.vectorscopeSampleRates.at(-1), 96000) assert.equal(calls.oscilloscopeResets >= 2, true) assert.equal(calls.spectrumResets >= 2, true) assert.equal(calls.vectorscopeResets >= 2, true) assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0) }) test('NativeVisualizerTransport stops feeding scopes when demand is removed', () => { const { bridge, calls } = createFakeTransportBridge() const transport = new NativeVisualizerTransport(bridge) transport.setDemand({ spectrum: true }) transport.reset({ sessionId: 2, sampleRate: 48000, channelCount: 2, capturing: true, }) transport.handleChunk(new Float32Array([0.25]), new Float32Array([0.75]), { sessionId: 2, channelCount: 2, }) assert.equal(calls.spectrumPushes.length, 1) transport.setDemand({}) transport.handleChunk(new Float32Array([0.5]), new Float32Array([0.5]), { sessionId: 2, channelCount: 2, }) assert.equal(calls.spectrumPushes.length, 1) assert.equal(calls.spectrumResets >= 1, true) assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0) }) test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => { const splitter = new MultibandSplitter() splitter.configure(48000) const splitTarget = createMultibandChunk(8) const leftRef = splitTarget.low.left const rightRef = splitTarget.high.right const left = new Float32Array([1, 0.75, 0.25, -0.25, -0.75, -1, -0.5, 0.5]) const right = new Float32Array([0.5, 0.25, -0.5, -1, -0.5, 0.25, 0.75, 1]) const splitCount = splitter.splitInto(left, right, splitTarget) assert.equal(splitCount, 8) assert.equal(splitTarget.low.left, leftRef) assert.equal(splitTarget.high.right, rightRef) const buffer = new MultibandBuffer(16) buffer.push(splitTarget, splitCount) const pointTarget = createMultibandChunk(8) const pointRef = pointTarget.mid.left const pointCount = buffer.fillPointsInto(pointTarget, 8) assert.equal(pointCount, 8) assert.equal(pointTarget.mid.left, pointRef) assert.notEqual(pointTarget.low.left[0], 0) }) function createFakeWaveformNativeAnalyzer(options: { available?: boolean monoSummary?: Float32Array stereoSummary?: Float32Array } = {}): WaveformNativeAnalyzer & { configs: Array<{ sampleRate: number; samplesPerColumn: number }> monoPushes: Float32Array[] stereoPushes: Array<{ left: Float32Array; right: Float32Array }> resetCount: number } { return { configs: [], monoPushes: [], stereoPushes: [], resetCount: 0, isAvailable: () => options.available ?? true, configure(sampleRate, samplesPerColumn) { this.configs.push({ sampleRate, samplesPerColumn }) }, processMono(samples) { this.monoPushes.push(samples) return options.monoSummary ?? new Float32Array(0) }, processStereo(left, right) { this.stereoPushes.push({ left, right }) return options.stereoSummary ?? new Float32Array(0) }, reset() { this.resetCount += 1 }, } } function createFakeVectorscopeNativeAnalyzer(options: { multibandAvailable?: boolean multibandData?: Float32Array multibandCount?: number } = {}): VectorscopeNativeAnalyzer & { sampleRates: number[] multibandPushes: Array<{ left: Float32Array; right: Float32Array }> multibandRequests: number[] resetCount: number } { const analyzer: VectorscopeNativeAnalyzer & { sampleRates: number[] multibandPushes: Array<{ left: Float32Array; right: Float32Array }> multibandRequests: number[] resetCount: number } = { sampleRates: [], multibandPushes: [], multibandRequests: [], resetCount: 0, isAvailable: () => true, isMultibandAvailable: () => options.multibandAvailable ?? true, setSampleRate(sampleRate) { this.sampleRates.push(sampleRate) }, pushSamples() {}, fillPoints() { return 0 }, reset() { this.resetCount += 1 }, } if (options.multibandAvailable !== false) { analyzer.pushMultibandSamples = (left, right) => { analyzer.multibandPushes.push({ left, right }) } analyzer.getMultibandPoints = (maxPoints) => { analyzer.multibandRequests.push(maxPoints) const data = options.multibandData ?? new Float32Array(0) return { data, count: options.multibandCount ?? Math.floor(data.length / 6), } } } return analyzer } test('Waveform uses native multiband column summaries when available', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const nativeAnalyzer = createFakeWaveformNativeAnalyzer({ monoSummary: new Float32Array([-0.5, 0.5, 1, 0, 0]), }) const dataSource = { getPendingWaveformSamples: () => [new Float32Array([0.25])], getPendingWaveformStereoSamples: () => [], getSampleRate: () => 64, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const waveform = new Waveform(createFakeCanvas(recorder), { dataSource, multiband: true, nativeAnalyzer, bandColors: { low: '#ff0000', mid: '#00ff00', high: '#0000ff', }, }) try { ;(waveform as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.monoPushes.length, 1) assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 1) assert.equal( recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(235, 20, 0, 0.72)'), true, ) } finally { waveform.dispose() dom.restore() } }) test('Waveform falls back to JavaScript multiband when native is unavailable', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const nativeAnalyzer = createFakeWaveformNativeAnalyzer({ available: false }) const dataSource = { getPendingWaveformSamples: () => [new Float32Array([0.75])], getPendingWaveformStereoSamples: () => [], getSampleRate: () => 64, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const waveform = new Waveform(createFakeCanvas(recorder), { dataSource, multiband: true, nativeAnalyzer, }) try { ;(waveform as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.monoPushes.length, 0) assert.equal(recorder.fillRects.some((rect) => rect.width === 1), true) } finally { waveform.dispose() dom.restore() } }) test('Waveform reconfigures and resets native multiband state on option changes', () => { const dom = installFakeCanvasDom() const nativeAnalyzer = createFakeWaveformNativeAnalyzer() const dataSource = { getPendingWaveformSamples: () => [], getPendingWaveformStereoSamples: () => [], getSampleRate: () => 128, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const waveform = new Waveform(createFakeCanvas(), { dataSource, multiband: true, nativeAnalyzer, }) try { nativeAnalyzer.configs.length = 0 nativeAnalyzer.resetCount = 0 waveform.setOptions({ scrollSpeed: 2 }) assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 1) assert.equal(nativeAnalyzer.resetCount > 0, true) waveform.setOptions({ multiband: false }) assert.equal(nativeAnalyzer.resetCount > 1, true) } finally { waveform.dispose() dom.restore() } }) test('Vectorscope multiband uses native interleaved band points when available', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const nativeAnalyzer = createFakeVectorscopeNativeAnalyzer({ multibandData: new Float32Array([0.1, 0.2, 0.2, 0.1, -0.1, 0.15]), multibandCount: 1, }) const dataSource = { getPendingVectorscopeSamples: () => [{ left: new Float32Array([0.2]), right: new Float32Array([0.1]) }], getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const vectorscope = new Vectorscope(createFakeCanvas(recorder), { dataSource, multiband: true, showGrid: false, displayPoints: 1, nativeAnalyzer, bandColors: { low: '#110000', mid: '#001100', high: '#000011', }, }) try { ;(vectorscope as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.multibandPushes.length, 1) assert.deepEqual(nativeAnalyzer.multibandRequests, [1]) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#110000'), true) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#001100'), true) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#000011'), true) } finally { vectorscope.dispose() dom.restore() } }) test('Vectorscope multiband falls back to JavaScript splitter when native multiband is missing', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const nativeAnalyzer = createFakeVectorscopeNativeAnalyzer({ multibandAvailable: false }) const dataSource = { getPendingVectorscopeSamples: () => [createSineStereoChunk(440, 48000, 8)], getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const vectorscope = new Vectorscope(createFakeCanvas(recorder), { dataSource, multiband: true, showGrid: false, displayPoints: 8, nativeAnalyzer, bandColors: { low: '#210000', mid: '#002100', high: '#000021', }, }) try { ;(vectorscope as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.multibandPushes.length, 0) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#210000'), true) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#002100'), true) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === '#000021'), true) } finally { vectorscope.dispose() dom.restore() } }) function createFakeVUMeterNativeAnalyzer( snapshotOverrides: Partial = {}, available = true, ): VUMeterNativeAnalyzer & { pushed: Array<{ left: Float32Array; right: Float32Array }> resetCount: number sampleRates: number[] } { const analyzer = { pushed: [] as Array<{ left: Float32Array; right: Float32Array }>, resetCount: 0, sampleRates: [] as number[], snapshot: { vuLDb: -12, vuRDb: -13, barLDb: -10, barRDb: -12, peakLDb: -4, peakRDb: -5, correlation: 0.5, ...snapshotOverrides, } satisfies VUMeterNativeSnapshot, isAvailable: () => available, setSampleRate(sampleRate: number): void { this.sampleRates.push(sampleRate) }, pushSamples(left: Float32Array, right: Float32Array): void { this.pushed.push({ left: new Float32Array(left), right: new Float32Array(right) }) }, getSnapshot(): VUMeterNativeSnapshot { return this.snapshot }, reset(): void { this.resetCount += 1 }, } return analyzer } test('VUMeter draws from native DSP snapshots when available', () => { const recorder = createFakeCanvasRecorder() const dataSource = { getPendingVUMeterSamples: () => [], getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeVUMeterNativeAnalyzer({ vuLDb: -12.4, vuRDb: -16.2, peakLDb: -3, peakRDb: -5, correlation: 0.25, }) const meter = new VUMeter(createFakeCanvas(recorder), { dataSource, nativeAnalyzer, lineColor: 'rgb(255, 0, 96)', }) try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.pushed.length, 0) assert.equal(recorder.fillTexts.some((text) => text.text === '-12.4'), true) assert.equal(recorder.fillTexts.some((text) => text.text === '-16.2'), true) assert.equal( recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.82)'), true, ) } finally { meter.dispose() } }) test('VUMeter concatenates queued chunks before pushing them to native DSP', () => { const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([1, 2, 3]), right: new Float32Array([4, 5, 6]), }, { left: new Float32Array([7, 8]), right: new Float32Array([9, 10]), }, ] const dataSource = { getPendingVUMeterSamples: () => { const drained = chunkQueue.slice() chunkQueue.length = 0 return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeVUMeterNativeAnalyzer() const meter = new VUMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) try { ;(meter as unknown as { processAudio: () => void }).processAudio() assert.equal(nativeAnalyzer.pushed.length, 1) assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.left ?? []), [1, 2, 3, 7, 8]) assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.right ?? []), [4, 5, 6, 9, 10]) } finally { meter.dispose() } }) test('VUMeter resets native DSP when the sample rate or session changes', () => { let sampleRate = 48000 let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([0.1]), right: new Float32Array([0.2]) }, ] let sessionListener: (() => void) | null = null const dataSource = { getPendingVUMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => sampleRate, isPlaying: () => true, subscribeToSessionChanges: (listener: () => void) => { sessionListener = listener return () => {} }, } const nativeAnalyzer = createFakeVUMeterNativeAnalyzer() const meter = new VUMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter) try { processAudio() sampleRate = 96000 pendingChunks = [{ left: new Float32Array([0.3]), right: new Float32Array([0.4]) }] processAudio() sessionListener?.() assert.deepEqual(nativeAnalyzer.sampleRates, [48000, 96000, 96000]) assert.equal(nativeAnalyzer.resetCount, 3) } finally { meter.dispose() } }) test('VUMeter drains audio and renders fallback state when native DSP is unavailable', () => { const recorder = createFakeCanvasRecorder() let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([0.5, 0.5, 0.5, 0.5]), right: new Float32Array([0.5, 0.5, 0.5, 0.5]) }, ] const dataSource = { getPendingVUMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeVUMeterNativeAnalyzer({ vuLDb: -1, vuRDb: -1 }, false) const meter = new VUMeter(createFakeCanvas(recorder), { dataSource, nativeAnalyzer, lineColor: 'rgb(255, 0, 96)', }) try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.pushed.length, 0) assert.equal(pendingChunks.length, 0) assert.equal(recorder.fillTexts.some((text) => text.text === '-6.0'), true) } finally { meter.dispose() } }) function createFakeLUFSMeterNativeAnalyzer( snapshotOverrides: Partial = {}, available = true, ): LUFSMeterNativeAnalyzer & { pushed: Array<{ left: Float32Array; right: Float32Array }> resetCount: number sampleRates: number[] } { const analyzer = { pushed: [] as Array<{ left: Float32Array; right: Float32Array }>, resetCount: 0, sampleRates: [] as number[], snapshot: { momentaryLUFS: -18.4, shortTermLUFS: -19.1, integratedLUFS: -20.2, vuLDb: -12, vuRDb: -13, barLDb: -10, barRDb: -12, peakLDb: -4, peakRDb: -5, correlation: 0.5, ...snapshotOverrides, } satisfies LUFSMeterNativeSnapshot, isAvailable: () => available, setSampleRate(sampleRate: number): void { this.sampleRates.push(sampleRate) }, pushSamples(left: Float32Array, right: Float32Array): void { this.pushed.push({ left: new Float32Array(left), right: new Float32Array(right) }) }, getSnapshot(): LUFSMeterNativeSnapshot { return this.snapshot }, reset(): void { this.resetCount += 1 }, } return analyzer } test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => { const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() const leftChunk = new Float32Array(4800) const rightChunk = new Float32Array(4800) for (let index = 0; index < leftChunk.length; index += 1) { const sample = index % 2 === 0 ? 0.55 : -0.55 leftChunk[index] = sample rightChunk[index] = sample * 0.75 } let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: leftChunk, right: rightChunk }, ] const dataSource = { getPendingLUFSMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 }) const meter = new LUFSMeter(createFakeCanvas(recorder), { dataSource, nativeAnalyzer, readout: 'momentary', lineColor: 'rgb(255, 0, 96)', trackColor: 'rgba(255, 0, 96, 0.08)', targetColor: 'rgb(1, 2, 3)', scaleColor: 'rgb(4, 5, 6)', labelColor: 'rgb(7, 8, 9)', }) try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.pushed.length, 1) assert.equal(nativeAnalyzer.pushed[0]?.left.length, leftChunk.length) const trackRects = recorder.fillRects.filter((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.08)') assert.equal(trackRects.length >= 3, true) assert.equal(trackRects.some((rect) => rect.width > 12), true) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.88)'), true) assert.equal( recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 12 && rect.width < 80), true, ) // Readout tag pill: drawn with line color, fits to text width with padding, // and uses the compact tag height. Sized smaller than the LUFS level bar. assert.equal( recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width >= 40 && rect.width <= 100 && rect.height >= 14 && rect.height <= 24, ), true, ) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(1, 2, 3)'), true) for (const scaleLabel of ['0', '6', '12', '24', '36', '50']) { assert.equal( recorder.fillTexts.some((text) => text.text === scaleLabel && text.fillStyle === 'rgb(4, 5, 6)'), true, ) } assert.equal( recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'), true, ) } finally { meter.dispose() dom.restore() } }) test('LUFSMeter fits readout text inside narrow tags', () => { const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() const leftChunk = new Float32Array(4800) const rightChunk = new Float32Array(4800) leftChunk.fill(0.55) rightChunk.fill(0.55) let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: leftChunk, right: rightChunk }, ] const dataSource = { getPendingLUFSMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 }) const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), { dataSource, nativeAnalyzer, readout: 'momentary', lineColor: 'rgb(255, 0, 96)', trackColor: 'rgba(255, 0, 96, 0.08)', targetColor: 'rgb(1, 2, 3)', scaleColor: 'rgb(4, 5, 6)', labelColor: 'rgb(7, 8, 9)', }) try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() const tagRect = recorder.fillRects.find((rect) => ( rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 50 && rect.height <= 34 )) const readout = recorder.fillTexts.find((text) => text.text.endsWith('LUFS')) assert.ok(tagRect) assert.ok(readout) const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0) const estimatedTextWidth = readout.text.length * fontSize * 0.62 assert.equal(estimatedTextWidth <= tagRect.width - 8, true) } finally { meter.dispose() dom.restore() } }) test('LUFSMeter concatenates queued chunks before pushing them to native DSP', () => { const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([1, 2, 3]), right: new Float32Array([4, 5, 6]), }, { left: new Float32Array([7, 8]), right: new Float32Array([9, 10]), }, ] const dataSource = { getPendingLUFSMeterSamples: () => { const drained = chunkQueue.slice() chunkQueue.length = 0 return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer() const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) try { ;(meter as unknown as { processAudio: () => void }).processAudio() assert.equal(nativeAnalyzer.pushed.length, 1) assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.left ?? []), [1, 2, 3, 7, 8]) assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.right ?? []), [4, 5, 6, 9, 10]) } finally { meter.dispose() } }) test('LUFSMeter resets native DSP when the sample rate or session changes', () => { let sampleRate = 48000 let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([0.1]), right: new Float32Array([0.2]) }, ] let sessionListener: (() => void) | null = null const dataSource = { getPendingLUFSMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => sampleRate, isPlaying: () => true, subscribeToSessionChanges: (listener: () => void) => { sessionListener = listener return () => {} }, } const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer() const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter) try { processAudio() sampleRate = 96000 pendingChunks = [{ left: new Float32Array([0.3]), right: new Float32Array([0.4]) }] processAudio() sessionListener?.() assert.deepEqual(nativeAnalyzer.sampleRates, [48000, 96000, 96000]) assert.equal(nativeAnalyzer.resetCount, 3) } finally { meter.dispose() } }) test('LUFSMeter drains audio and renders silence when native DSP is unavailable', () => { const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ { left: new Float32Array([0.9, 0.9]), right: new Float32Array([0.9, 0.9]) }, ] const dataSource = { getPendingLUFSMeterSamples: () => { const drained = pendingChunks pendingChunks = [] return drained }, getSampleRate: () => 48000, isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -5 }, false) const meter = new LUFSMeter(createFakeCanvas(recorder), { dataSource, nativeAnalyzer, readout: 'momentary', lineColor: 'rgb(255, 0, 96)', }) try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() assert.equal(nativeAnalyzer.pushed.length, 0) assert.equal(pendingChunks.length, 0) assert.equal( recorder.fillTexts.some((text) => text.text === '-∞LUFS'), true, ) } finally { meter.dispose() dom.restore() } }) test('NativePolledCaptureBackend forwards all drained chunks, respects hidden-document backoff, and cancels on stop', async () => { const timers = installFakeTimeouts() try { const { NativePolledCaptureBackend } = await import('../src/renderer/audio/AudioCapture') const drainResults = [ { chunks: [ { left: new Float32Array([0.1, 0.2]), right: new Float32Array([0.3, 0.4]), channelCount: 2, capturedAtMilliseconds: 5, sequence: 1, }, { left: new Float32Array([0.5, 0.6]), right: new Float32Array([0.7, 0.8]), channelCount: 2, capturedAtMilliseconds: 15, sequence: 2, }, ], overwriteCount: 0, queueDepth: 0, }, { chunks: [], overwriteCount: 0, queueDepth: 0, }, { chunks: [], overwriteCount: 0, queueDepth: 0, }, ] const nativeModule = { getSupport: () => ({ available: true, reason: null }), listOutputDevices: () => [], start: () => ({ sampleRate: 48000, channelCount: 2, deviceId: 'device', deviceLabel: 'Device', }), stop: () => {}, drain: () => drainResults.shift() ?? { chunks: [], overwriteCount: 0, queueDepth: 0, }, nowMilliseconds: () => 0, } class TestNativeBackend extends NativePolledCaptureBackend { readonly kind = 'native-macos' as const protected getNativeCaptureModule() { return nativeModule } protected getBackendLabel(): string { return 'Test Native' } } const backend = new TestNativeBackend({ kind: 'native-macos', available: true, reason: null, }) const receivedSequences: number[] = [] const receivedChunkTimes: number[] = [] backend.subscribe((chunk) => { receivedSequences.push(chunk.sequence) receivedChunkTimes.push(chunk.capturedAt) }) await backend.start() assert.equal(timers.nextDelay(), 0) timers.runNext() assert.deepEqual(receivedSequences, [1, 2]) assert.equal(receivedChunkTimes.length, 2) assert.equal(timers.nextDelay(), 0) timers.runNext() assert.equal(timers.nextDelay(), 2) timers.setHidden(true) timers.runNext() assert.equal(timers.nextDelay(), 16) await backend.stop() assert.equal(timers.pendingCount(), 0) } finally { timers.restore() } }) test('NativePolledCaptureBackend trims stale backlog to the newest live slice when catch-up mode is enabled', async () => { const timers = installFakeTimeouts() try { const { NativePolledCaptureBackend } = await import('../src/renderer/audio/AudioCapture') const drainResults = [ { chunks: [ { left: new Float32Array([0.1]), right: new Float32Array([0.1]), channelCount: 2, capturedAtMilliseconds: 0, sequence: 1, }, { left: new Float32Array([0.2]), right: new Float32Array([0.2]), channelCount: 2, capturedAtMilliseconds: 10, sequence: 2, }, { left: new Float32Array([0.3]), right: new Float32Array([0.3]), channelCount: 2, capturedAtMilliseconds: 30, sequence: 3, }, { left: new Float32Array([0.4]), right: new Float32Array([0.4]), channelCount: 2, capturedAtMilliseconds: 70, sequence: 4, }, { left: new Float32Array([0.5]), right: new Float32Array([0.5]), channelCount: 2, capturedAtMilliseconds: 90, sequence: 5, }, ], overwriteCount: 0, queueDepth: 3, }, { chunks: [{ left: new Float32Array([0.6]), right: new Float32Array([0.6]), channelCount: 2, capturedAtMilliseconds: 120, sequence: 6, }], overwriteCount: 0, queueDepth: 0, }, { chunks: [], overwriteCount: 0, queueDepth: 0, }, ] const nativeModule = { getSupport: () => ({ available: true, reason: null }), listOutputDevices: () => [], start: () => ({ sampleRate: 48000, channelCount: 2, deviceId: 'device', deviceLabel: 'Device', }), stop: () => {}, drain: () => drainResults.shift() ?? { chunks: [], overwriteCount: 0, queueDepth: 0, }, nowMilliseconds: () => 0, } class TestNativeBackend extends NativePolledCaptureBackend { readonly kind = 'native-linux' as const protected getNativeCaptureModule() { return nativeModule } protected getBackendLabel(): string { return 'Test Native' } protected shouldTrimBacklogForLiveCapture(): boolean { return true } } const backend = new TestNativeBackend({ kind: 'native-linux', available: true, reason: null, }) const receivedSequences: number[] = [] backend.subscribe((chunk) => { receivedSequences.push(chunk.sequence) }) await backend.start() assert.equal(timers.nextDelay(), 0) timers.runNext() assert.deepEqual(receivedSequences, [4, 5]) assert.equal(timers.nextDelay(), 0) timers.runNext() assert.deepEqual(receivedSequences, [4, 5, 6]) assert.equal(timers.nextDelay(), 0) timers.runNext() assert.equal(timers.nextDelay(), 2) await backend.stop() assert.equal(timers.pendingCount(), 0) } finally { timers.restore() } })