LUFS panel fix

This commit is contained in:
Boof2015
2026-08-17 00:56:17 -04:00
parent c73dac4fc1
commit 5c6677c2a5
8 changed files with 371 additions and 53 deletions
+6 -6
View File
@@ -23,6 +23,7 @@ import type {
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
import type { DialogOptions, DialogResult } from '../types/dialog'
import { normalizeProfile } from '../shared/profileState'
import { getScopePopoutMinWidth } from '../shared/scopeSizing'
import { resolveNativeThemeSource } from '../shared/themeState'
import {
resolveMacWindowBlurMaterial,
@@ -141,7 +142,6 @@ const WINDOW_DEFAULTS = {
const POPOUT_DEFAULTS = {
width: 360,
height: 240,
minWidth: 220,
minHeight: 160,
}
@@ -1543,7 +1543,7 @@ function buildProfileMenuTemplate(
return template
}
function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
function normalizeBounds(kind: ScopeKind, raw: unknown, fallback: WindowBounds): WindowBounds {
if (typeof raw !== 'object' || raw === null) return fallback
const candidate = raw as Partial<WindowBounds>
@@ -1559,7 +1559,7 @@ function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
width: Math.max(POPOUT_DEFAULTS.minWidth, Math.round(candidate.width)),
width: Math.max(getScopePopoutMinWidth(kind), Math.round(candidate.width)),
height: Math.max(POPOUT_DEFAULTS.minHeight, Math.round(candidate.height)),
}
}
@@ -1878,7 +1878,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
height: POPOUT_DEFAULTS.height,
}
const normalizedBounds = shouldRestoreGeometry
? normalizeBounds(rawBounds, fallbackBounds)
? normalizeBounds(kind, rawBounds, fallbackBounds)
: fallbackBounds
const bounds = shouldRestoreGeometry
? clampRestoredWindowBounds(normalizedBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN)
@@ -1889,7 +1889,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
const options: BrowserWindowConstructorOptions = {
width: bounds.width,
height: bounds.height,
minWidth: POPOUT_DEFAULTS.minWidth,
minWidth: getScopePopoutMinWidth(kind),
minHeight: POPOUT_DEFAULTS.minHeight,
...getFramelessWindowOptions(background),
autoHideMenuBar: true,
@@ -1974,7 +1974,7 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
if (supportsGeometryPersistence() && desired.bounds) {
const currentBounds = popoutWindow.getBounds()
const nextBounds = clampRestoredWindowBounds(
normalizeBounds(desired.bounds, currentBounds),
normalizeBounds(kind, desired.bounds, currentBounds),
getDisplayWorkAreas(),
RESTORED_WINDOW_VISIBLE_MARGIN,
)
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ScopeKind } from '../types/scope'
import { MIN_LOUDNESS_METER_WIDTH_PX } from '../shared/scopeSizing'
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
export const LOCKED_LOUDNESS_METER_WIDTH_PX = 150
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
return scope === 'spectrogram'
@@ -27,7 +27,8 @@ export function buildAnalyzerGridTemplateColumns(
}
if (scope === 'lufsmeter') {
return `minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px)`
const resolvedWeight = weight > 0 ? weight : DEFAULT_COLLAPSED_SCOPE_WEIGHT
return `minmax(${MIN_LOUDNESS_METER_WIDTH_PX}px, ${resolvedWeight}fr)`
}
if (usesCollapsedDefaultWeight(scope) && weight <= 0) {
+2 -1
View File
@@ -8,6 +8,7 @@ import { audioRouter } from '../audio/AudioRouter'
import { usePerformanceStore } from '../stores/performanceStore'
import { FrameScheduler } from '../visualizers/frameScheduler'
import { getRendererWindowCapabilities } from '../windowCapabilities'
import { getScopePopoutMinWidth } from '../../shared/scopeSizing'
interface StripProps {
onMeasurementActiveChange?: (active: boolean) => void
@@ -209,7 +210,7 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El
nextBounds = {
x: Math.round(windowBounds.x + rect.left),
y: Math.round(windowBounds.y + rect.top),
width: Math.max(220, Math.round(rect.width)),
width: Math.max(getScopePopoutMinWidth(kind), Math.round(rect.width)),
height: Math.max(160, Math.round(rect.height)),
}
}
+25
View File
@@ -2458,6 +2458,31 @@ button.toolbar__version:hover {
transform: none;
}
@media (max-width: 180px) {
.scope-popout__header {
gap: 2px;
padding: 4px;
}
.scope-popout__drag {
flex: 0 0 auto;
gap: 0;
}
.scope-popout__title-group {
display: none;
}
.scope-popout__actions {
gap: 2px;
}
.scope-popout__button {
width: 24px;
height: 24px;
}
}
.toolbar__brand:focus-visible,
.toolbar__profile-button:focus-visible,
.toolbar__chip:focus-visible,
+52
View File
@@ -0,0 +1,52 @@
const DEFAULT_PIXEL_RATIO = 1
function normalizePositiveNumber(value: unknown, fallback = DEFAULT_PIXEL_RATIO): number {
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback
}
function getComputedStyleForElement(element: Element): CSSStyleDeclaration | null {
const ownerWindow = element.ownerDocument?.defaultView
if (ownerWindow?.getComputedStyle) {
return ownerWindow.getComputedStyle(element)
}
if (typeof window !== 'undefined' && window.getComputedStyle) {
return window.getComputedStyle(element)
}
return null
}
function parsePositiveCssPixelValue(value: string | null | undefined): number | null {
if (!value) return null
const numeric = Number.parseFloat(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
}
function getCanvasCssSize(canvas: HTMLCanvasElement, axis: 'width' | 'height'): number | null {
const inlineSize = parsePositiveCssPixelValue(axis === 'width' ? canvas.style?.width : canvas.style?.height)
if (inlineSize !== null) return inlineSize
const computedStyle = getComputedStyleForElement(canvas)
const computedSize = parsePositiveCssPixelValue(axis === 'width' ? computedStyle?.width : computedStyle?.height)
if (computedSize !== null) return computedSize
const clientSize = axis === 'width' ? canvas.clientWidth : canvas.clientHeight
return Number.isFinite(clientSize) && clientSize > 0 ? clientSize : null
}
export function getCanvasBackingPixelRatio(canvas: HTMLCanvasElement): number {
const cssWidth = getCanvasCssSize(canvas, 'width')
if (cssWidth !== null && canvas.width > 0) {
const ratio = canvas.width / cssWidth
if (Number.isFinite(ratio) && ratio > 0) return ratio
}
const cssHeight = getCanvasCssSize(canvas, 'height')
if (cssHeight !== null && canvas.height > 0) {
const ratio = canvas.height / cssHeight
if (Number.isFinite(ratio) && ratio > 0) return ratio
}
if (typeof window === 'undefined') return DEFAULT_PIXEL_RATIO
return normalizePositiveNumber(window.devicePixelRatio)
}
+67 -40
View File
@@ -6,6 +6,7 @@ import {
} from '../audio/native'
import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter'
import { resolveColorToRgb } from '../utils/color'
import { getCanvasBackingPixelRatio } from '../utils/canvasSizing'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import { FrameScheduler } from './frameScheduler'
import { VisualizerFrameLoop } from './visualizerFrameLoop'
@@ -70,6 +71,8 @@ const COMPACT_METER_MIN_DB = -50
const COMPACT_METER_MAX_DB = 0
const TARGET_LUFS = -14
const METER_MIN_DB = -60
const MAX_METER_VIEWPORT_CSS_WIDTH = 240
const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS'
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
momentaryLUFS: METER_MIN_LUFS,
@@ -374,24 +377,70 @@ export class LUFSMeter {
private drawBars(width: number, height: number): void {
const ctx = this.ctx
const tint = resolveColorToRgb(this.options.lineColor)
const dpr = window.devicePixelRatio || 1
const dpr = getCanvasBackingPixelRatio(this.canvas)
const cssWidth = width / dpr
const cssHeight = height / dpr
const paddingX = Math.max(Math.round(4 * dpr), Math.floor(width * 0.012))
const paddingY = Math.max(Math.round(4 * dpr), Math.floor(height * 0.025))
const paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr)
const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr)
const meterTop = paddingY
const meterBottom = height - paddingY
const meterHeight = Math.max(1, meterBottom - meterTop)
const scaleWidth = Math.max(Math.round(22 * dpr), Math.min(Math.round(36 * dpr), Math.floor(width * 0.14)))
const barWidth = Math.max(Math.round(6 * dpr), Math.min(Math.round(14 * dpr), Math.floor(width * 0.04)))
const barGap = Math.max(Math.round(3 * dpr), Math.floor(width * 0.012))
const lufsBarGap = Math.max(Math.round(5 * dpr), Math.floor(width * 0.018))
const lufsBarWidth = Math.max(Math.round(12 * dpr), Math.min(Math.round(28 * dpr), Math.floor(width * 0.07)))
const tagGap = Math.max(Math.round(6 * dpr), Math.floor(width * 0.02))
const leftBarX = paddingX + scaleWidth
const availableViewportWidth = Math.max(1, width - paddingX * 2)
const viewportWidth = Math.max(1, Math.min(
availableViewportWidth,
Math.round(MAX_METER_VIEWPORT_CSS_WIDTH * dpr),
))
const viewportX = Math.round((width - viewportWidth) / 2)
const viewportCssWidth = viewportWidth / dpr
const scaleWidth = Math.round(Math.max(22, Math.min(36, Math.floor(viewportCssWidth * 0.14))) * dpr)
const barGap = Math.round(Math.max(3, Math.floor(viewportCssWidth * 0.012)) * dpr)
const lufsBarGap = Math.round(Math.max(5, Math.floor(viewportCssWidth * 0.018)) * dpr)
const tagGap = Math.round(Math.max(6, Math.floor(viewportCssWidth * 0.02)) * dpr)
const minimumBarWidth = Math.round(6 * dpr)
const minimumLufsBarWidth = minimumBarWidth * 2
const selectedLufs = this.selectedLufs()
const displayValue = selectedLufs <= METER_MIN_LUFS + 1
? '-∞'
: selectedLufs.toFixed(1)
const tagHeightCss = Math.max(1, Math.min(
meterHeight / dpr,
Math.max(16, Math.min(22, Math.floor(cssHeight * 0.1))),
))
const tagHeight = Math.max(1, Math.round(tagHeightCss * dpr))
const tagPaddingCss = Math.max(4, Math.min(7, Math.floor(tagHeightCss * 0.4)))
const tagPadding = Math.round(tagPaddingCss * dpr)
const readoutFontSizeCss = Math.max(9, Math.min(13, Math.floor(tagHeightCss * 0.62)))
const readoutFontSize = Math.round(readoutFontSizeCss * dpr)
const minimumReadoutFontSize = Math.round(Math.max(7, Math.floor(readoutFontSizeCss * 0.7)) * dpr)
ctx.font = `700 ${readoutFontSize}px "JetBrains Mono", "SF Mono", monospace`
const fullReadoutWidth = Math.ceil(ctx.measureText(FULL_READOUT_WIDTH_SAMPLE).width) + tagPadding * 2
const fixedLayoutWidth = scaleWidth + barGap + lufsBarGap + tagGap
const minimumBarsWidth = minimumBarWidth * 2 + minimumLufsBarWidth
const maximumReadoutWidth = Math.max(1, viewportWidth - fixedLayoutWidth - minimumBarsWidth)
const useFullReadout = fullReadoutWidth <= maximumReadoutWidth
const reservedReadoutWidth = Math.max(1, Math.min(
maximumReadoutWidth,
fullReadoutWidth,
))
const readoutCandidates = useFullReadout
? [`${displayValue}LUFS`, displayValue]
: [displayValue]
const readoutLayout = this.resolveReadoutTextLayout(
readoutCandidates,
Math.max(1, reservedReadoutWidth - tagPadding * 2),
readoutFontSize,
minimumReadoutFontSize,
)
const barsWidth = Math.max(4, viewportWidth - fixedLayoutWidth - reservedReadoutWidth)
const barWidth = Math.max(1, Math.floor(barsWidth / 4))
const lufsBarWidth = Math.max(2, barsWidth - barWidth * 2)
const leftBarX = viewportX + scaleWidth
const rightBarX = leftBarX + barWidth + barGap
const lufsBarX = rightBarX + barWidth + lufsBarGap
const tagAreaX = lufsBarX + lufsBarWidth + tagGap
const tagAreaWidth = Math.max(1, width - paddingX - tagAreaX)
this.drawFastPeakBar(
leftBarX,
@@ -414,7 +463,6 @@ export class LUFSMeter {
dpr,
)
const selectedLufs = this.selectedLufs()
const loudnessNorm = this.compactDbToNormalized(selectedLufs)
const loudnessY = Math.round(meterBottom - loudnessNorm * meterHeight)
const lufsBarHeight = Math.round(loudnessNorm * meterHeight)
@@ -431,8 +479,9 @@ export class LUFSMeter {
ctx.fillRect(leftBarX, targetY, Math.max(1, lufsBarX + lufsBarWidth - leftBarX), Math.max(1, Math.round(dpr)))
const tickValues = [0, -6, -12, -24, -36, -50]
const tickMarkWidth = Math.max(4, Math.round(6 * dpr))
const tickFontSize = Math.max(Math.round(8 * dpr), Math.min(Math.round(13 * dpr), Math.floor(height * 0.055)))
const tickMarkWidth = Math.round(6 * dpr)
const tickFontSizeCss = Math.max(8, Math.min(13, Math.floor(cssHeight * 0.055)))
const tickFontSize = Math.round(tickFontSizeCss * dpr)
ctx.font = `600 ${tickFontSize}px "JetBrains Mono", "SF Mono", monospace`
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
@@ -443,36 +492,14 @@ export class LUFSMeter {
meterTop + tickFontSize / 2,
Math.min(meterBottom - tickFontSize / 2, y),
)
ctx.fillText(`${Math.abs(tick)}`, paddingX + scaleWidth - Math.round(7 * dpr), labelY)
ctx.fillRect(paddingX + scaleWidth - tickMarkWidth, y, tickMarkWidth, Math.max(1, Math.round(dpr)))
ctx.fillText(`${Math.abs(tick)}`, viewportX + scaleWidth - Math.round(7 * dpr), labelY)
ctx.fillRect(viewportX + scaleWidth - tickMarkWidth, y, tickMarkWidth, Math.max(1, Math.round(dpr)))
}
const displayValue = selectedLufs <= METER_MIN_LUFS + 1
? '-∞'
: selectedLufs.toFixed(1)
const displayCandidates = [
`${displayValue}LUFS`,
displayValue,
]
const tagHeight = Math.min(
meterHeight,
Math.max(Math.round(16 * dpr), Math.min(Math.round(22 * dpr), Math.floor(height * 0.1))),
)
const tagPadding = Math.max(Math.round(4 * dpr), Math.min(Math.round(7 * dpr), Math.floor(tagHeight * 0.4)))
const readoutFontSize = Math.max(
Math.round(9 * dpr),
Math.min(Math.round(13 * dpr), Math.floor(tagHeight * 0.62)),
)
const readoutLayout = this.resolveReadoutTextLayout(
displayCandidates,
Math.max(1, tagAreaWidth - tagPadding * 2),
readoutFontSize,
Math.max(Math.round(7 * dpr), Math.floor(readoutFontSize * 0.7)),
)
ctx.font = `700 ${readoutLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace`
const measuredText = ctx.measureText(readoutLayout.text).width
const tagWidth = Math.max(1, Math.min(tagAreaWidth, Math.ceil(measuredText) + tagPadding * 2))
const tagX = tagAreaX
const tagWidth = Math.max(1, Math.min(reservedReadoutWidth, Math.ceil(measuredText) + tagPadding * 2))
const tagX = Math.round(tagAreaX + (reservedReadoutWidth - tagWidth) / 2)
const tagY = Math.round(Math.max(meterTop, Math.min(meterBottom - tagHeight, loudnessY - tagHeight / 2)))
ctx.fillStyle = this.options.lineColor
+10
View File
@@ -0,0 +1,10 @@
import type { ScopeKind } from '../types/scope'
export const DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX = 220
export const MIN_LOUDNESS_METER_WIDTH_PX = 112
export function getScopePopoutMinWidth(kind: ScopeKind): number {
return kind === 'lufsmeter'
? MIN_LOUDNESS_METER_WIDTH_PX
: DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX
}
+206 -4
View File
@@ -18,9 +18,13 @@ import {
resolveMainWindowSettingsPanelHeight,
} from '../src/renderer/mainWindowSettings'
import {
LOCKED_LOUDNESS_METER_WIDTH_PX,
buildAnalyzerGridTemplateColumns,
} from '../src/renderer/analyzerLayout'
import {
DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX,
MIN_LOUDNESS_METER_WIDTH_PX,
getScopePopoutMinWidth,
} from '../src/shared/scopeSizing'
import {
formatAstraTime,
getAstraPlaybackProgress,
@@ -771,6 +775,27 @@ function createFakeCanvas(
} as unknown as HTMLCanvasElement
}
function createFakeCssSizedCanvas(
recorder: FakeCanvasRecorder,
cssWidth: number,
cssHeight: number,
pixelRatio = 1,
): HTMLCanvasElement {
const canvas = createFakeCanvas(
recorder,
Math.round(cssWidth * pixelRatio),
Math.round(cssHeight * pixelRatio),
)
Object.defineProperty(canvas, 'style', {
configurable: true,
value: {
width: `${cssWidth}px`,
height: `${cssHeight}px`,
},
})
return canvas
}
function installFakeCanvasDom(createCanvas: () => HTMLCanvasElement = () => createFakeCanvas()): {
restore: () => void
} {
@@ -1677,19 +1702,30 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
])
})
test('analyzer layout locks the loudness meter width', () => {
test('analyzer layout lets the loudness meter follow its width weight down to its supported minimum', () => {
const columns = buildAnalyzerGridTemplateColumns(
['spectrum', 'lufsmeter', 'waveform'],
{ spectrum: 1, lufsmeter: 0.15, waveform: 1 },
)
assert.equal(LOCKED_LOUDNESS_METER_WIDTH_PX, 150)
assert.equal(MIN_LOUDNESS_METER_WIDTH_PX, 112)
assert.equal(
columns,
`minmax(0, 1fr) minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px) minmax(0, 1fr)`,
`minmax(0, 1fr) minmax(${MIN_LOUDNESS_METER_WIDTH_PX}px, 0.15fr) minmax(0, 1fr)`,
)
})
test('scope popout sizing permits compact LUFS windows without changing other scope minimums', () => {
assert.equal(getScopePopoutMinWidth('lufsmeter'), MIN_LOUDNESS_METER_WIDTH_PX)
assert.equal(MIN_LOUDNESS_METER_WIDTH_PX, 112)
assert.equal(DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX, 220)
for (const kind of SCOPE_KINDS) {
if (kind === 'lufsmeter') continue
assert.equal(getScopePopoutMinWidth(kind), DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX)
}
})
test('scope canvas layout swaps logical dimensions for quarter-turn rotations', () => {
const horizontal = resolveScopeCanvasLayout(640, 360, 2, 0)
assert.deepEqual(horizontal, {
@@ -6414,6 +6450,172 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
}
})
test('LUFSMeter centers wide layouts in a bounded viewport', () => {
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
const canvas = createFakeCssSizedCanvas(recorder, 522, 196)
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 })
const meter = new LUFSMeter(canvas, {
dataSource: {
getPendingLUFSMeterSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
},
nativeAnalyzer,
lineColor: '#123456',
trackColor: '#111111',
targetColor: '#222222',
scaleColor: '#333333',
})
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111')
const target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222')
const tag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22)
const readout = recorder.fillTexts[recorder.fillTexts.length - 1]
const viewportLeft = (canvas.width - 240) / 2
const viewportRight = viewportLeft + 240
assert.equal(tracks.length, 3)
assert.ok(target)
assert.ok(tag)
assert.equal(tracks[0]?.x, 174)
assert.equal(tracks[0]?.width, tracks[1]?.width)
assert.ok(Math.abs((tracks[2]?.width ?? 0) - (tracks[0]?.width ?? 0) * 2) <= 3)
assert.equal(target.x, tracks[0]?.x)
assert.equal(target.x + target.width, (tracks[2]?.x ?? 0) + (tracks[2]?.width ?? 0))
assert.equal(readout?.text, '-8.7LUFS')
for (const rect of recorder.fillRects) {
assert.ok(rect.x >= viewportLeft, `rectangle starts left of LUFS viewport: ${JSON.stringify(rect)}`)
assert.ok(rect.x + rect.width <= viewportRight, `rectangle exceeds LUFS viewport: ${JSON.stringify(rect)}`)
}
} finally {
meter.dispose()
dom.restore()
}
})
test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bounds', () => {
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
const canvas = createFakeCssSizedCanvas(recorder, 112, 196)
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 })
const meter = new LUFSMeter(canvas, {
dataSource: {
getPendingLUFSMeterSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
},
nativeAnalyzer,
lineColor: '#123456',
trackColor: '#111111',
targetColor: '#222222',
scaleColor: '#333333',
})
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111')
const tag = recorder.fillRects.find((rect) => (
rect.fillStyle === '#123456'
&& rect.height > 2
&& rect.height <= 22
))
const readout = recorder.fillTexts[recorder.fillTexts.length - 1]
assert.deepEqual(tracks.map((rect) => rect.width), [6, 6, 12])
assert.ok(tag)
assert.equal(readout?.text, '-8.7')
for (const rect of recorder.fillRects) {
assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`)
assert.ok(rect.y >= 0, `rectangle starts above canvas: ${JSON.stringify(rect)}`)
assert.ok(rect.width > 0, `rectangle has non-positive width: ${JSON.stringify(rect)}`)
assert.ok(rect.height > 0, `rectangle has non-positive height: ${JSON.stringify(rect)}`)
assert.ok(rect.x + rect.width <= canvas.width, `rectangle exceeds canvas width: ${JSON.stringify(rect)}`)
assert.ok(rect.y + rect.height <= canvas.height, `rectangle exceeds canvas height: ${JSON.stringify(rect)}`)
}
const fontSize = Number(readout?.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const estimatedTextWidth = (readout?.text.length ?? 0) * fontSize * 0.62
assert.ok((readout?.x ?? 0) >= tag.x)
assert.ok((readout?.x ?? 0) + estimatedTextWidth <= tag.x + tag.width)
} finally {
meter.dispose()
dom.restore()
}
})
test('LUFSMeter keeps equivalent CSS geometry across canvas backing ratios', () => {
const dom = installFakeCanvasDom()
const renderAtRatio = (pixelRatio: number): {
tracks: FakeCanvasRecorder['fillRects']
tag: FakeCanvasRecorder['fillRects'][number]
} => {
const recorder = createFakeCanvasRecorder()
const canvas = createFakeCssSizedCanvas(recorder, 522, 196, pixelRatio)
const meter = new LUFSMeter(canvas, {
dataSource: {
getPendingLUFSMeterSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
},
nativeAnalyzer: createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 }),
lineColor: '#123456',
trackColor: '#111111',
targetColor: '#222222',
scaleColor: '#333333',
})
;(meter as unknown as { drawFrame: () => void }).drawFrame()
meter.dispose()
const tracks = recorder.fillRects
.filter((rect) => rect.fillStyle === '#111111')
.map((rect) => ({
...rect,
x: rect.x / pixelRatio,
y: rect.y / pixelRatio,
width: rect.width / pixelRatio,
height: rect.height / pixelRatio,
}))
const rawTag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22 * pixelRatio)
assert.ok(rawTag)
const tag = {
...rawTag,
x: rawTag.x / pixelRatio,
y: rawTag.y / pixelRatio,
width: rawTag.width / pixelRatio,
height: rawTag.height / pixelRatio,
}
return { tracks, tag }
}
try {
const baseline = renderAtRatio(1)
for (const pixelRatio of [1.25, 2]) {
const result = renderAtRatio(pixelRatio)
assert.equal(result.tracks.length, baseline.tracks.length)
for (let index = 0; index < baseline.tracks.length; index += 1) {
assertAlmostEqual(result.tracks[index]?.x ?? 0, baseline.tracks[index]?.x ?? 0, 2, `track ${index} x at ${pixelRatio}x`)
assertAlmostEqual(result.tracks[index]?.width ?? 0, baseline.tracks[index]?.width ?? 0, 2, `track ${index} width at ${pixelRatio}x`)
}
assertAlmostEqual(result.tag.x, baseline.tag.x, 2, `readout tag x at ${pixelRatio}x`)
assertAlmostEqual(result.tag.width, baseline.tag.width, 2, `readout tag width at ${pixelRatio}x`)
}
} finally {
dom.restore()
}
})
test('LUFSMeter concatenates queued chunks before pushing them to native DSP', () => {
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [
{