mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
5328 lines
170 KiB
TypeScript
5328 lines
170 KiB
TypeScript
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,
|
|
SpectrumNativeAnalyzer,
|
|
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<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
|
|
type FakeElectronAPI = {
|
|
platform: string
|
|
windowCapabilities: WindowCapabilities
|
|
[key: string]: unknown
|
|
}
|
|
type WindowWithTimers = typeof globalThis & Pick<Window, 'setTimeout' | 'clearTimeout'> & {
|
|
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<number, FrameRequestCallback>()
|
|
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<number, { callback: () => 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<string, string>()
|
|
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<string, unknown> = {}): {
|
|
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)
|
|
}
|
|
|
|
interface FakeSpectrumNativeAnalyzer extends SpectrumNativeAnalyzer {
|
|
calls: {
|
|
monoPushes: Float32Array[]
|
|
stereoPushes: Array<{ left: Float32Array; right: Float32Array }>
|
|
fillMagnitudes: number
|
|
fillRawMagnitudes: number
|
|
fillSideMagnitudes: number
|
|
resets: number
|
|
}
|
|
}
|
|
|
|
function runFft(re: Float32Array, im: Float32Array): void {
|
|
const size = re.length
|
|
if (size <= 1) return
|
|
|
|
let j = 0
|
|
for (let i = 1; i < size; i += 1) {
|
|
let bit = size >> 1
|
|
while (j & bit) {
|
|
j ^= bit
|
|
bit >>= 1
|
|
}
|
|
j ^= bit
|
|
|
|
if (i < j) {
|
|
let tmp = re[i]
|
|
re[i] = re[j]
|
|
re[j] = tmp
|
|
tmp = im[i]
|
|
im[i] = im[j]
|
|
im[j] = tmp
|
|
}
|
|
}
|
|
|
|
for (let len = 2; len <= size; len <<= 1) {
|
|
const halfLen = len >> 1
|
|
const angle = -2 * Math.PI / len
|
|
const wRe = Math.cos(angle)
|
|
const wIm = Math.sin(angle)
|
|
|
|
for (let i = 0; i < size; i += len) {
|
|
let curRe = 1
|
|
let curIm = 0
|
|
|
|
for (let k = 0; k < halfLen; k += 1) {
|
|
const evenIndex = i + k
|
|
const oddIndex = i + k + halfLen
|
|
const tRe = curRe * re[oddIndex] - curIm * im[oddIndex]
|
|
const tIm = curRe * im[oddIndex] + curIm * re[oddIndex]
|
|
|
|
re[oddIndex] = re[evenIndex] - tRe
|
|
im[oddIndex] = im[evenIndex] - tIm
|
|
re[evenIndex] += tRe
|
|
im[evenIndex] += tIm
|
|
|
|
const nextRe = curRe * wRe - curIm * wIm
|
|
curIm = curRe * wIm + curIm * wRe
|
|
curRe = nextRe
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer {
|
|
let fftSize = 2048
|
|
let sampleRate = 48000
|
|
let smoothing = 0.9
|
|
let bufferedSamples = 0
|
|
let history = new Float32Array(fftSize)
|
|
let sideHistory = new Float32Array(fftSize)
|
|
let rawMagnitudes = new Float32Array(fftSize / 2)
|
|
let magnitudes = new Float32Array(fftSize / 2)
|
|
let sideMagnitudes = new Float32Array(fftSize / 2)
|
|
let re = new Float32Array(fftSize)
|
|
let im = new Float32Array(fftSize)
|
|
rawMagnitudes.fill(-100)
|
|
magnitudes.fill(-100)
|
|
sideMagnitudes.fill(-100)
|
|
|
|
const calls: FakeSpectrumNativeAnalyzer['calls'] = {
|
|
monoPushes: [],
|
|
stereoPushes: [],
|
|
fillMagnitudes: 0,
|
|
fillRawMagnitudes: 0,
|
|
fillSideMagnitudes: 0,
|
|
resets: 0,
|
|
}
|
|
|
|
const resize = (size: number): void => {
|
|
fftSize = size
|
|
bufferedSamples = 0
|
|
history = new Float32Array(fftSize)
|
|
sideHistory = new Float32Array(fftSize)
|
|
rawMagnitudes = new Float32Array(fftSize / 2)
|
|
magnitudes = new Float32Array(fftSize / 2)
|
|
sideMagnitudes = new Float32Array(fftSize / 2)
|
|
re = new Float32Array(fftSize)
|
|
im = new Float32Array(fftSize)
|
|
rawMagnitudes.fill(-100)
|
|
magnitudes.fill(-100)
|
|
sideMagnitudes.fill(-100)
|
|
}
|
|
|
|
const updateMagnitudes = (source: Float32Array, output: Float32Array, rawOutput: Float32Array | null): void => {
|
|
for (let index = 0; index < fftSize; index += 1) {
|
|
const window = fftSize <= 1 ? 1 : 0.5 * (1 - Math.cos((2 * Math.PI * index) / (fftSize - 1)))
|
|
re[index] = source[index] * window
|
|
im[index] = 0
|
|
}
|
|
runFft(re, im)
|
|
|
|
const scale = 2 / fftSize
|
|
for (let index = 0; index < output.length; index += 1) {
|
|
const magnitude = Math.hypot(re[index], im[index]) * scale
|
|
let db = 20 * Math.log10(Math.max(magnitude, 1e-10))
|
|
db += 6
|
|
db = Math.min(12, Math.max(-120, db))
|
|
if (rawOutput) {
|
|
rawOutput[index] = db
|
|
}
|
|
output[index] = bufferedSamples < fftSize
|
|
? db
|
|
: smoothing * output[index] + (1 - smoothing) * db
|
|
}
|
|
}
|
|
|
|
const pushMonoHistory = (samples: Float32Array): void => {
|
|
const length = samples.length
|
|
if (length >= fftSize) {
|
|
history.set(samples.subarray(length - fftSize))
|
|
sideHistory.fill(0)
|
|
bufferedSamples = fftSize
|
|
return
|
|
}
|
|
|
|
history.copyWithin(0, length)
|
|
sideHistory.copyWithin(0, length)
|
|
history.set(samples, fftSize - length)
|
|
sideHistory.fill(0, fftSize - length)
|
|
bufferedSamples = Math.min(fftSize, bufferedSamples + length)
|
|
}
|
|
|
|
const pushStereoHistory = (left: Float32Array, right: Float32Array): void => {
|
|
const length = Math.min(left.length, right.length)
|
|
if (length >= fftSize) {
|
|
const start = length - fftSize
|
|
for (let index = 0; index < fftSize; index += 1) {
|
|
const leftValue = left[start + index]
|
|
const rightValue = right[start + index]
|
|
history[index] = (leftValue + rightValue) * 0.5
|
|
sideHistory[index] = (leftValue - rightValue) * 0.5
|
|
}
|
|
bufferedSamples = fftSize
|
|
return
|
|
}
|
|
|
|
history.copyWithin(0, length)
|
|
sideHistory.copyWithin(0, length)
|
|
const writeStart = fftSize - length
|
|
for (let index = 0; index < length; index += 1) {
|
|
history[writeStart + index] = (left[index] + right[index]) * 0.5
|
|
sideHistory[writeStart + index] = (left[index] - right[index]) * 0.5
|
|
}
|
|
bufferedSamples = Math.min(fftSize, bufferedSamples + length)
|
|
}
|
|
|
|
const copyInto = (source: Float32Array, output: Float32Array): number => {
|
|
const count = Math.min(source.length, output.length)
|
|
output.set(source.subarray(0, count), 0)
|
|
return count
|
|
}
|
|
|
|
const analyzer: FakeSpectrumNativeAnalyzer = {
|
|
calls,
|
|
isAvailable: () => true,
|
|
setFFTSize: (size) => {
|
|
if (size !== fftSize) {
|
|
resize(size)
|
|
}
|
|
},
|
|
getFFTSize: () => fftSize,
|
|
setSampleRate: (nextSampleRate) => {
|
|
sampleRate = nextSampleRate
|
|
},
|
|
setSmoothing: (nextSmoothing) => {
|
|
smoothing = Math.min(0.99, Math.max(0, nextSmoothing))
|
|
},
|
|
pushSamples: (audioData) => {
|
|
calls.monoPushes.push(new Float32Array(audioData))
|
|
pushMonoHistory(audioData)
|
|
updateMagnitudes(history, magnitudes, rawMagnitudes)
|
|
updateMagnitudes(sideHistory, sideMagnitudes, null)
|
|
},
|
|
pushStereoSamples: (leftChannel, rightChannel) => {
|
|
calls.stereoPushes.push({
|
|
left: new Float32Array(leftChannel),
|
|
right: new Float32Array(rightChannel),
|
|
})
|
|
pushStereoHistory(leftChannel, rightChannel)
|
|
updateMagnitudes(history, magnitudes, rawMagnitudes)
|
|
updateMagnitudes(sideHistory, sideMagnitudes, null)
|
|
},
|
|
fillRawMagnitudes: (output) => {
|
|
calls.fillRawMagnitudes += 1
|
|
return copyInto(rawMagnitudes, output)
|
|
},
|
|
fillMagnitudes: (output) => {
|
|
calls.fillMagnitudes += 1
|
|
return copyInto(magnitudes, output)
|
|
},
|
|
fillSideMagnitudes: (output) => {
|
|
calls.fillSideMagnitudes += 1
|
|
return copyInto(sideMagnitudes, output)
|
|
},
|
|
getRawMagnitudes: () => rawMagnitudes,
|
|
getMagnitudes: () => magnitudes,
|
|
getSideMagnitudes: () => sideMagnitudes,
|
|
process: (audioData) => {
|
|
analyzer.pushSamples(audioData)
|
|
return magnitudes
|
|
},
|
|
binToFrequency: (bin) => bin * sampleRate / fftSize,
|
|
reset: () => {
|
|
calls.resets += 1
|
|
history.fill(0)
|
|
sideHistory.fill(0)
|
|
rawMagnitudes.fill(-100)
|
|
magnitudes.fill(-100)
|
|
sideMagnitudes.fill(-100)
|
|
bufferedSamples = 0
|
|
},
|
|
}
|
|
|
|
return analyzer
|
|
}
|
|
|
|
function renderSpectrumSnapshot(options: Partial<SpectrumAnalyzerOptions>): {
|
|
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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
...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<SpectrumAnalyzerOptions>,
|
|
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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
...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<SpectrumAnalyzerOptions>, 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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
...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<SpectrogramOptions>, 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<SpectrogramOptions>,
|
|
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<ScopeKind>(),
|
|
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('SpectrumAnalyzer side line uses native stereo spectrum without draining mono samples', () => {
|
|
const dom = installFakeCanvasDom()
|
|
const nativeAnalyzer = createFakeSpectrumNativeAnalyzer()
|
|
let monoDrains = 0
|
|
let stereoDrains = 0
|
|
const left = new Float32Array([1, 0.5, -0.5, -1])
|
|
const right = new Float32Array([-1, -0.5, 0.5, 1])
|
|
const dataSource = {
|
|
getPendingSpectrumSamples: () => {
|
|
monoDrains += 1
|
|
return [new Float32Array([0.25, 0.25, 0.25, 0.25])]
|
|
},
|
|
getPendingSpectrumStereoSamples: () => {
|
|
stereoDrains += 1
|
|
return [{ left, right }]
|
|
},
|
|
getSampleRate: () => 48000,
|
|
isPlaying: () => true,
|
|
subscribeToSessionChanges: () => () => {},
|
|
}
|
|
|
|
const analyzer = new SpectrumAnalyzer(createFakeCanvas(), {
|
|
showSideLine: true,
|
|
showGrid: false,
|
|
fillGradient: false,
|
|
dataSource,
|
|
nativeAnalyzer,
|
|
})
|
|
|
|
try {
|
|
const state = analyzer as unknown as { drawFrame: () => void }
|
|
state.drawFrame()
|
|
|
|
assert.equal(monoDrains, 0)
|
|
assert.equal(stereoDrains, 1)
|
|
assert.equal(nativeAnalyzer.calls.monoPushes.length, 0)
|
|
assert.equal(nativeAnalyzer.calls.stereoPushes.length, 1)
|
|
assert.deepEqual(Array.from(nativeAnalyzer.calls.stereoPushes[0]?.left ?? []), Array.from(left))
|
|
assert.deepEqual(Array.from(nativeAnalyzer.calls.stereoPushes[0]?.right ?? []), Array.from(right))
|
|
assert.equal(nativeAnalyzer.calls.fillSideMagnitudes, 1)
|
|
} finally {
|
|
analyzer.dispose()
|
|
dom.restore()
|
|
}
|
|
})
|
|
|
|
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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
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,
|
|
nativeAnalyzer: createFakeSpectrumNativeAnalyzer(),
|
|
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, /<PrismLogo \/>/)
|
|
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,
|
|
supportsBlurredBackground: 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,
|
|
supportsBlurredBackground: false,
|
|
},
|
|
)
|
|
})
|
|
|
|
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,
|
|
supportsBlurredBackground: false,
|
|
},
|
|
)
|
|
})
|
|
|
|
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,
|
|
supportsBlurredBackground: 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,
|
|
supportsBlurredBackground: false,
|
|
},
|
|
)
|
|
})
|
|
|
|
test('resolveWindowCapabilities gates blurred backgrounds on the Windows 11 22H2 build', () => {
|
|
const resolveForBuild = (osVersion?: string) => resolveWindowCapabilities({
|
|
platform: 'win32',
|
|
argv: [],
|
|
env: {},
|
|
osVersion,
|
|
}).supportsBlurredBackground
|
|
|
|
assert.equal(resolveForBuild('10.0.22621'), true)
|
|
assert.equal(resolveForBuild('10.0.26200'), true)
|
|
assert.equal(resolveForBuild('10.0.19045'), false)
|
|
assert.equal(resolveForBuild(undefined), false)
|
|
assert.equal(resolveForBuild('not-a-version'), false)
|
|
})
|
|
|
|
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 getFramelessWindowOptions\([\s\S]*?return \{[\s\S]*?frame: false,[\s\S]*?transparent: background\.mode === 'clear' \|\| transparentOnWindows,[\s\S]*?roundedCorners: false,[\s\S]*?hasShadow: false,[\s\S]*?thickFrame: background\.mode === 'solid',[\s\S]*?backgroundMaterial: 'none'[\s\S]*?resizable: true,[\s\S]*?maximizable: true,[\s\S]*?fullscreenable: true,[\s\S]*?skipTaskbar: false,[\s\S]*?\}/)
|
|
assert.match(mainSource, /function createMainWindow\(restoreBounds\?: WindowBounds\): void \{[\s\S]*?\.\.\.getFramelessWindowOptions\(background\),/)
|
|
assert.match(mainSource, /function createScopePopoutWindow\(kind: ScopeKind, rawBounds\?: WindowBounds\): BrowserWindow \| null \{[\s\S]*?\.\.\.getFramelessWindowOptions\(background\),/)
|
|
assert.match(mainSource, /function createNowPlayingConfigWindow\(\): BrowserWindow \{[\s\S]*?\.\.\.getFramelessWindowOptions\(\),/)
|
|
assert.match(popoutSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/)
|
|
assert.match(nowPlayingSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/)
|
|
// Blurred and clear windows drop the native thick frame on Windows, so the
|
|
// JS resize overlay mounts for both; the now-playing config window always
|
|
// keeps native semantics.
|
|
assert.match(appSource, /windowBackgroundMode !== 'solid' && <WindowResizeOverlay \/>/)
|
|
assert.match(popoutSource, /windowBackgroundMode !== 'solid' && <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<string, unknown>
|
|
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<ScopeKind>(), createScopePopouts(), 'spectrum', 'left'),
|
|
initialOrder,
|
|
)
|
|
assert.equal(
|
|
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'nowPlaying', 'right'),
|
|
initialOrder,
|
|
)
|
|
})
|
|
|
|
test('moveDockedScopeOrder preserves hidden scope positions in the full order', () => {
|
|
const nextOrder = moveDockedScopeOrder(
|
|
[...SCOPE_KINDS],
|
|
new Set<ScopeKind>(['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<ScopeKind>(),
|
|
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('Waveform maps x4 to the old max scroll rate and x8 to faster headroom', () => {
|
|
const dom = installFakeCanvasDom()
|
|
const nativeAnalyzer = createFakeWaveformNativeAnalyzer()
|
|
const dataSource = {
|
|
getPendingWaveformSamples: () => [],
|
|
getPendingWaveformStereoSamples: () => [],
|
|
getSampleRate: () => 4096,
|
|
isPlaying: () => true,
|
|
subscribeToSessionChanges: () => () => {},
|
|
}
|
|
const waveform = new Waveform(createFakeCanvas(), {
|
|
dataSource,
|
|
nativeAnalyzer,
|
|
scrollSpeed: 4,
|
|
})
|
|
|
|
try {
|
|
assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 8)
|
|
|
|
nativeAnalyzer.configs.length = 0
|
|
waveform.setOptions({ scrollSpeed: 8 })
|
|
assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 4)
|
|
} 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<VUMeterNativeSnapshot> = {},
|
|
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<LUFSMeterNativeSnapshot> = {},
|
|
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()
|
|
}
|
|
})
|