mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-20 04:19:56 +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 { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
|
||||||
import type { DialogOptions, DialogResult } from '../types/dialog'
|
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||||
import { normalizeProfile } from '../shared/profileState'
|
import { normalizeProfile } from '../shared/profileState'
|
||||||
|
import { getScopePopoutMinWidth } from '../shared/scopeSizing'
|
||||||
import { resolveNativeThemeSource } from '../shared/themeState'
|
import { resolveNativeThemeSource } from '../shared/themeState'
|
||||||
import {
|
import {
|
||||||
resolveMacWindowBlurMaterial,
|
resolveMacWindowBlurMaterial,
|
||||||
@@ -141,7 +142,6 @@ const WINDOW_DEFAULTS = {
|
|||||||
const POPOUT_DEFAULTS = {
|
const POPOUT_DEFAULTS = {
|
||||||
width: 360,
|
width: 360,
|
||||||
height: 240,
|
height: 240,
|
||||||
minWidth: 220,
|
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1543,7 @@ function buildProfileMenuTemplate(
|
|||||||
return template
|
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
|
if (typeof raw !== 'object' || raw === null) return fallback
|
||||||
|
|
||||||
const candidate = raw as Partial<WindowBounds>
|
const candidate = raw as Partial<WindowBounds>
|
||||||
@@ -1559,7 +1559,7 @@ function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
|
|||||||
return {
|
return {
|
||||||
x: Math.round(candidate.x),
|
x: Math.round(candidate.x),
|
||||||
y: Math.round(candidate.y),
|
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)),
|
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,
|
height: POPOUT_DEFAULTS.height,
|
||||||
}
|
}
|
||||||
const normalizedBounds = shouldRestoreGeometry
|
const normalizedBounds = shouldRestoreGeometry
|
||||||
? normalizeBounds(rawBounds, fallbackBounds)
|
? normalizeBounds(kind, rawBounds, fallbackBounds)
|
||||||
: fallbackBounds
|
: fallbackBounds
|
||||||
const bounds = shouldRestoreGeometry
|
const bounds = shouldRestoreGeometry
|
||||||
? clampRestoredWindowBounds(normalizedBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN)
|
? clampRestoredWindowBounds(normalizedBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN)
|
||||||
@@ -1889,7 +1889,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
|||||||
const options: BrowserWindowConstructorOptions = {
|
const options: BrowserWindowConstructorOptions = {
|
||||||
width: bounds.width,
|
width: bounds.width,
|
||||||
height: bounds.height,
|
height: bounds.height,
|
||||||
minWidth: POPOUT_DEFAULTS.minWidth,
|
minWidth: getScopePopoutMinWidth(kind),
|
||||||
minHeight: POPOUT_DEFAULTS.minHeight,
|
minHeight: POPOUT_DEFAULTS.minHeight,
|
||||||
...getFramelessWindowOptions(background),
|
...getFramelessWindowOptions(background),
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
@@ -1974,7 +1974,7 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
|
|||||||
if (supportsGeometryPersistence() && desired.bounds) {
|
if (supportsGeometryPersistence() && desired.bounds) {
|
||||||
const currentBounds = popoutWindow.getBounds()
|
const currentBounds = popoutWindow.getBounds()
|
||||||
const nextBounds = clampRestoredWindowBounds(
|
const nextBounds = clampRestoredWindowBounds(
|
||||||
normalizeBounds(desired.bounds, currentBounds),
|
normalizeBounds(kind, desired.bounds, currentBounds),
|
||||||
getDisplayWorkAreas(),
|
getDisplayWorkAreas(),
|
||||||
RESTORED_WINDOW_VISIBLE_MARGIN,
|
RESTORED_WINDOW_VISIBLE_MARGIN,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ScopeKind } from '../types/scope'
|
import type { ScopeKind } from '../types/scope'
|
||||||
|
import { MIN_LOUDNESS_METER_WIDTH_PX } from '../shared/scopeSizing'
|
||||||
|
|
||||||
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
|
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
|
||||||
export const LOCKED_LOUDNESS_METER_WIDTH_PX = 150
|
|
||||||
|
|
||||||
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
|
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
|
||||||
return scope === 'spectrogram'
|
return scope === 'spectrogram'
|
||||||
@@ -27,7 +27,8 @@ export function buildAnalyzerGridTemplateColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'lufsmeter') {
|
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) {
|
if (usesCollapsedDefaultWeight(scope) && weight <= 0) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { audioRouter } from '../audio/AudioRouter'
|
|||||||
import { usePerformanceStore } from '../stores/performanceStore'
|
import { usePerformanceStore } from '../stores/performanceStore'
|
||||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||||
|
import { getScopePopoutMinWidth } from '../../shared/scopeSizing'
|
||||||
|
|
||||||
interface StripProps {
|
interface StripProps {
|
||||||
onMeasurementActiveChange?: (active: boolean) => void
|
onMeasurementActiveChange?: (active: boolean) => void
|
||||||
@@ -209,7 +210,7 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El
|
|||||||
nextBounds = {
|
nextBounds = {
|
||||||
x: Math.round(windowBounds.x + rect.left),
|
x: Math.round(windowBounds.x + rect.left),
|
||||||
y: Math.round(windowBounds.y + rect.top),
|
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)),
|
height: Math.max(160, Math.round(rect.height)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2458,6 +2458,31 @@ button.toolbar__version:hover {
|
|||||||
transform: none;
|
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__brand:focus-visible,
|
||||||
.toolbar__profile-button:focus-visible,
|
.toolbar__profile-button:focus-visible,
|
||||||
.toolbar__chip: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'
|
} from '../audio/native'
|
||||||
import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter'
|
import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter'
|
||||||
import { resolveColorToRgb } from '../utils/color'
|
import { resolveColorToRgb } from '../utils/color'
|
||||||
|
import { getCanvasBackingPixelRatio } from '../utils/canvasSizing'
|
||||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||||
import { FrameScheduler } from './frameScheduler'
|
import { FrameScheduler } from './frameScheduler'
|
||||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||||
@@ -70,6 +71,8 @@ const COMPACT_METER_MIN_DB = -50
|
|||||||
const COMPACT_METER_MAX_DB = 0
|
const COMPACT_METER_MAX_DB = 0
|
||||||
const TARGET_LUFS = -14
|
const TARGET_LUFS = -14
|
||||||
const METER_MIN_DB = -60
|
const METER_MIN_DB = -60
|
||||||
|
const MAX_METER_VIEWPORT_CSS_WIDTH = 240
|
||||||
|
const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS'
|
||||||
|
|
||||||
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
|
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
|
||||||
momentaryLUFS: METER_MIN_LUFS,
|
momentaryLUFS: METER_MIN_LUFS,
|
||||||
@@ -374,24 +377,70 @@ export class LUFSMeter {
|
|||||||
private drawBars(width: number, height: number): void {
|
private drawBars(width: number, height: number): void {
|
||||||
const ctx = this.ctx
|
const ctx = this.ctx
|
||||||
const tint = resolveColorToRgb(this.options.lineColor)
|
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 paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr)
|
||||||
const paddingY = Math.max(Math.round(4 * dpr), Math.floor(height * 0.025))
|
const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr)
|
||||||
const meterTop = paddingY
|
const meterTop = paddingY
|
||||||
const meterBottom = height - paddingY
|
const meterBottom = height - paddingY
|
||||||
const meterHeight = Math.max(1, meterBottom - meterTop)
|
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 availableViewportWidth = Math.max(1, width - paddingX * 2)
|
||||||
const barWidth = Math.max(Math.round(6 * dpr), Math.min(Math.round(14 * dpr), Math.floor(width * 0.04)))
|
const viewportWidth = Math.max(1, Math.min(
|
||||||
const barGap = Math.max(Math.round(3 * dpr), Math.floor(width * 0.012))
|
availableViewportWidth,
|
||||||
const lufsBarGap = Math.max(Math.round(5 * dpr), Math.floor(width * 0.018))
|
Math.round(MAX_METER_VIEWPORT_CSS_WIDTH * dpr),
|
||||||
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 viewportX = Math.round((width - viewportWidth) / 2)
|
||||||
const leftBarX = paddingX + scaleWidth
|
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 rightBarX = leftBarX + barWidth + barGap
|
||||||
const lufsBarX = rightBarX + barWidth + lufsBarGap
|
const lufsBarX = rightBarX + barWidth + lufsBarGap
|
||||||
const tagAreaX = lufsBarX + lufsBarWidth + tagGap
|
const tagAreaX = lufsBarX + lufsBarWidth + tagGap
|
||||||
const tagAreaWidth = Math.max(1, width - paddingX - tagAreaX)
|
|
||||||
|
|
||||||
this.drawFastPeakBar(
|
this.drawFastPeakBar(
|
||||||
leftBarX,
|
leftBarX,
|
||||||
@@ -414,7 +463,6 @@ export class LUFSMeter {
|
|||||||
dpr,
|
dpr,
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedLufs = this.selectedLufs()
|
|
||||||
const loudnessNorm = this.compactDbToNormalized(selectedLufs)
|
const loudnessNorm = this.compactDbToNormalized(selectedLufs)
|
||||||
const loudnessY = Math.round(meterBottom - loudnessNorm * meterHeight)
|
const loudnessY = Math.round(meterBottom - loudnessNorm * meterHeight)
|
||||||
const lufsBarHeight = Math.round(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)))
|
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 tickValues = [0, -6, -12, -24, -36, -50]
|
||||||
const tickMarkWidth = Math.max(4, Math.round(6 * dpr))
|
const tickMarkWidth = Math.round(6 * dpr)
|
||||||
const tickFontSize = Math.max(Math.round(8 * dpr), Math.min(Math.round(13 * dpr), Math.floor(height * 0.055)))
|
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.font = `600 ${tickFontSize}px "JetBrains Mono", "SF Mono", monospace`
|
||||||
ctx.textAlign = 'right'
|
ctx.textAlign = 'right'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
@@ -443,36 +492,14 @@ export class LUFSMeter {
|
|||||||
meterTop + tickFontSize / 2,
|
meterTop + tickFontSize / 2,
|
||||||
Math.min(meterBottom - tickFontSize / 2, y),
|
Math.min(meterBottom - tickFontSize / 2, y),
|
||||||
)
|
)
|
||||||
ctx.fillText(`${Math.abs(tick)}`, paddingX + scaleWidth - Math.round(7 * dpr), labelY)
|
ctx.fillText(`${Math.abs(tick)}`, viewportX + scaleWidth - Math.round(7 * dpr), labelY)
|
||||||
ctx.fillRect(paddingX + scaleWidth - tickMarkWidth, y, tickMarkWidth, Math.max(1, Math.round(dpr)))
|
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`
|
ctx.font = `700 ${readoutLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace`
|
||||||
const measuredText = ctx.measureText(readoutLayout.text).width
|
const measuredText = ctx.measureText(readoutLayout.text).width
|
||||||
const tagWidth = Math.max(1, Math.min(tagAreaWidth, Math.ceil(measuredText) + tagPadding * 2))
|
const tagWidth = Math.max(1, Math.min(reservedReadoutWidth, Math.ceil(measuredText) + tagPadding * 2))
|
||||||
const tagX = tagAreaX
|
const tagX = Math.round(tagAreaX + (reservedReadoutWidth - tagWidth) / 2)
|
||||||
const tagY = Math.round(Math.max(meterTop, Math.min(meterBottom - tagHeight, loudnessY - tagHeight / 2)))
|
const tagY = Math.round(Math.max(meterTop, Math.min(meterBottom - tagHeight, loudnessY - tagHeight / 2)))
|
||||||
|
|
||||||
ctx.fillStyle = this.options.lineColor
|
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,
|
resolveMainWindowSettingsPanelHeight,
|
||||||
} from '../src/renderer/mainWindowSettings'
|
} from '../src/renderer/mainWindowSettings'
|
||||||
import {
|
import {
|
||||||
LOCKED_LOUDNESS_METER_WIDTH_PX,
|
|
||||||
buildAnalyzerGridTemplateColumns,
|
buildAnalyzerGridTemplateColumns,
|
||||||
} from '../src/renderer/analyzerLayout'
|
} from '../src/renderer/analyzerLayout'
|
||||||
|
import {
|
||||||
|
DEFAULT_SCOPE_POPOUT_MIN_WIDTH_PX,
|
||||||
|
MIN_LOUDNESS_METER_WIDTH_PX,
|
||||||
|
getScopePopoutMinWidth,
|
||||||
|
} from '../src/shared/scopeSizing'
|
||||||
import {
|
import {
|
||||||
formatAstraTime,
|
formatAstraTime,
|
||||||
getAstraPlaybackProgress,
|
getAstraPlaybackProgress,
|
||||||
@@ -771,6 +775,27 @@ function createFakeCanvas(
|
|||||||
} as unknown as HTMLCanvasElement
|
} 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()): {
|
function installFakeCanvasDom(createCanvas: () => HTMLCanvasElement = () => createFakeCanvas()): {
|
||||||
restore: () => void
|
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(
|
const columns = buildAnalyzerGridTemplateColumns(
|
||||||
['spectrum', 'lufsmeter', 'waveform'],
|
['spectrum', 'lufsmeter', 'waveform'],
|
||||||
{ spectrum: 1, lufsmeter: 0.15, waveform: 1 },
|
{ 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(
|
assert.equal(
|
||||||
columns,
|
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', () => {
|
test('scope canvas layout swaps logical dimensions for quarter-turn rotations', () => {
|
||||||
const horizontal = resolveScopeCanvasLayout(640, 360, 2, 0)
|
const horizontal = resolveScopeCanvasLayout(640, 360, 2, 0)
|
||||||
assert.deepEqual(horizontal, {
|
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', () => {
|
test('LUFSMeter concatenates queued chunks before pushing them to native DSP', () => {
|
||||||
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [
|
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user