mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 19:44:16 +02:00
LUFS panel fix
This commit is contained in:
+6
-6
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }> = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user