diff --git a/src/main/index.ts b/src/main/index.ts index 4a19b06..5f2e865 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 @@ -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, ) diff --git a/src/renderer/analyzerLayout.ts b/src/renderer/analyzerLayout.ts index b769944..ee4c61c 100644 --- a/src/renderer/analyzerLayout.ts +++ b/src/renderer/analyzerLayout.ts @@ -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) { diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 5650435..0d99415 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -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)), } } diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 7627011..08e918a 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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, diff --git a/src/renderer/utils/canvasSizing.ts b/src/renderer/utils/canvasSizing.ts new file mode 100644 index 0000000..f99f4bb --- /dev/null +++ b/src/renderer/utils/canvasSizing.ts @@ -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) +} diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index f70df5b..145ed89 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -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 diff --git a/src/shared/scopeSizing.ts b/src/shared/scopeSizing.ts new file mode 100644 index 0000000..863a439 --- /dev/null +++ b/src/shared/scopeSizing.ts @@ -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 +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 163e3c9..05a5d62 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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 }> = [ {