fps controls

This commit is contained in:
Boof2015
2026-03-28 13:25:44 -04:00
parent 60e8b921c0
commit 0d5e487385
10 changed files with 475 additions and 18 deletions
+42
View File
@@ -1,8 +1,10 @@
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
import { SCOPE_KINDS } from '../../types/scope'
const SCOPE_LABELS: Record<ScopeKind, string> = {
@@ -20,11 +22,23 @@ interface BottomBarProps {
onHeightChange?: (height: number) => void
}
const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
10: '10',
30: '30',
60: '60',
120: '120',
144: '144',
'display-sync': 'Sync',
}
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps)
const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget)
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const {
@@ -113,6 +127,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
: 'Idle'
const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100))
const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps))
return (
<div className="bottom-bar" ref={rootRef}>
@@ -226,6 +241,33 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--performance">
<div className="bottom-bar__section-title">Performance</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--performance">
<div className="bottom-bar__inline bottom-bar__inline--chips">
{VISUALIZER_FRAME_TARGETS.map((target) => (
<button
key={String(target)}
type="button"
className={`settings-chip ${frameTarget === target ? 'is-active' : ''}`.trim()}
onClick={() => setFrameTarget(target)}
title={target === 'display-sync' ? 'Display Sync' : `Cap visualizers at ${target} FPS`}
>
{FRAME_TARGET_LABELS[target]}
</button>
))}
</div>
<div className="settings-status-pill bottom-bar__fps-pill" title="Docked visualizer render FPS">
<span>{roundedDockedRenderFps} FPS</span>
</div>
</div>
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--trim">
<div className="bottom-bar__section-title">Trim</div>
<div className="bottom-bar__section-body">
+17 -12
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef } from 'react'
import { audioRouter } from '../audio/AudioRouter'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { FrameScheduler } from '../visualizers/frameScheduler'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
@@ -59,14 +61,20 @@ export default function ScopePopoutBridge(): null {
const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds)
const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings)
const accent = useThemeStore((s) => s.accent)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const activePopoutKinds = useMemo(
() => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)),
[hiddenScopes, scopePopouts],
)
const flushScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
const activePopoutKindsRef = useRef<ScopeKind[]>(activePopoutKinds)
const sessionStateRef = useRef(audioRouter.getSessionState())
useEffect(() => {
flushScheduler.setFrameTarget(frameTarget)
}, [flushScheduler, frameTarget])
useEffect(() => {
activePopoutKindsRef.current = activePopoutKinds
}, [activePopoutKinds])
@@ -153,10 +161,9 @@ export default function ScopePopoutBridge(): null {
}, [activePopoutKinds])
useEffect(() => {
let frameId = 0
let unsubscribeFlush: (() => void) | null = null
const flushFrame = (): void => {
frameId = 0
if (!sessionStateRef.current.capturing || activePopoutKindsRef.current.length === 0) {
return
}
@@ -167,22 +174,20 @@ export default function ScopePopoutBridge(): null {
window.electronAPI.sendScopePopoutAudio(kind, batch)
}
}
frameId = window.requestAnimationFrame(flushFrame)
}
const syncFlushLoop = (): void => {
const shouldRun = sessionStateRef.current.capturing && activePopoutKindsRef.current.length > 0
if (!shouldRun) {
if (frameId) {
window.cancelAnimationFrame(frameId)
frameId = 0
if (unsubscribeFlush) {
unsubscribeFlush()
unsubscribeFlush = null
}
return
}
if (!frameId) {
frameId = window.requestAnimationFrame(flushFrame)
if (!unsubscribeFlush) {
unsubscribeFlush = flushScheduler.subscribe(flushFrame)
}
}
@@ -194,12 +199,12 @@ export default function ScopePopoutBridge(): null {
syncFlushLoop()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
if (unsubscribeFlush) {
unsubscribeFlush()
}
unsubscribeSession()
}
}, [activePopoutKinds])
}, [activePopoutKinds, flushScheduler])
useEffect(() => {
return audioRouter.subscribeToSessionChanges((state) => {
+19 -1
View File
@@ -6,6 +6,7 @@ import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
import { audioRouter } from '../audio/AudioRouter'
import { usePerformanceStore } from '../stores/performanceStore'
import { FrameScheduler } from '../visualizers/frameScheduler'
export default function Strip(): JSX.Element {
@@ -17,7 +18,9 @@ export default function Strip(): JSX.Element {
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
const popOutScope = useSettingsStore((s) => s.popOutScope)
const accent = useThemeStore((s) => s.accent)
const frameScheduler = useMemo(() => new FrameScheduler(), [])
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps)
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
const stripRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
@@ -35,6 +38,21 @@ export default function Strip(): JSX.Element {
return { gridTemplateColumns } as CSSProperties
}, [gridTemplateColumns])
useEffect(() => {
frameScheduler.setFrameTarget(frameTarget)
}, [frameScheduler, frameTarget])
useEffect(() => {
const unsubscribe = frameScheduler.subscribeToActualFps((fps) => {
setDockedRenderFps(fps)
})
return () => {
unsubscribe()
setDockedRenderFps(0)
}
}, [frameScheduler, setDockedRenderFps])
const updateHandleOffsets = useCallback((): void => {
if (dockedScopes.length < 2) {
setHandleOffsets([])
+7 -1
View File
@@ -4,6 +4,7 @@ import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings'
import ScopeModule from '../components/ScopeModule'
import ScopeSettingsSection from '../components/ScopeSettingsSection'
import { usePerformanceStore } from '../stores/performanceStore'
import { applyAccentToDOM } from '../stores/themeStore'
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
import { FrameScheduler } from '../visualizers/frameScheduler'
@@ -48,9 +49,14 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
const prevMiniSettingsOpenRef = useRef(false)
const frameScheduler = useMemo(() => new FrameScheduler(), [])
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
useEffect(() => {
frameScheduler.setFrameTarget(frameTarget)
}, [frameScheduler, frameTarget])
useEffect(() => {
const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => {
if (nextSnapshot.kind !== scopeKind) return
+139
View File
@@ -0,0 +1,139 @@
import { create } from 'zustand'
import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance'
const STORAGE_KEY = 'prism:performance'
const SYNC_CHANNEL_NAME = 'prism:performance'
interface PersistedPerformanceState {
frameTarget: VisualizerFrameTarget
}
interface PerformanceState {
frameTarget: VisualizerFrameTarget
dockedRenderFps: number
setFrameTarget: (target: VisualizerFrameTarget) => void
setDockedRenderFps: (fps: number) => void
}
interface StorageLike {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
}
function getStorage(): StorageLike | null {
if (typeof localStorage === 'undefined') {
return null
}
return localStorage
}
export function normalizePerformancePreferences(raw: unknown): PersistedPerformanceState {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PersistedPerformanceState>
: {}
return {
frameTarget: isVisualizerFrameTarget(parsed.frameTarget) ? parsed.frameTarget : 'display-sync',
}
}
export function loadPerformancePreferences(storage = getStorage()): PersistedPerformanceState {
if (!storage) {
return normalizePerformancePreferences(null)
}
try {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) {
return normalizePerformancePreferences(null)
}
return normalizePerformancePreferences(JSON.parse(raw))
} catch {
return normalizePerformancePreferences(null)
}
}
function persistPerformancePreferences(target: VisualizerFrameTarget, storage = getStorage()): void {
if (!storage) return
try {
storage.setItem(STORAGE_KEY, JSON.stringify({ frameTarget: target }))
} catch {
// Ignore localStorage write failures.
}
}
const storedPreferences = loadPerformancePreferences()
export const usePerformanceStore = create<PerformanceState>((set) => ({
frameTarget: storedPreferences.frameTarget,
dockedRenderFps: 0,
setFrameTarget: (target: VisualizerFrameTarget) => {
persistPerformancePreferences(target)
broadcastFrameTarget(target)
set((state) => {
if (state.frameTarget === target) return state
return { ...state, frameTarget: target }
})
},
setDockedRenderFps: (fps: number) => {
const nextFps = Number.isFinite(fps) && fps > 0 ? fps : 0
set((state) => {
if (state.dockedRenderFps === nextFps) return state
return { ...state, dockedRenderFps: nextFps }
})
},
}))
let syncChannel: BroadcastChannel | null = null
let syncBound = false
function getSyncChannel(): BroadcastChannel | null {
if (syncChannel !== null || typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') {
return syncChannel
}
syncChannel = new BroadcastChannel(SYNC_CHANNEL_NAME)
return syncChannel
}
function applyExternalFrameTarget(raw: unknown): void {
if (!isVisualizerFrameTarget(raw)) return
if (usePerformanceStore.getState().frameTarget === raw) return
usePerformanceStore.setState({ frameTarget: raw })
}
function broadcastFrameTarget(target: VisualizerFrameTarget): void {
getSyncChannel()?.postMessage({ frameTarget: target })
}
function bindCrossWindowSync(): void {
if (syncBound || typeof window === 'undefined') {
return
}
syncBound = true
if (typeof window.addEventListener === 'function') {
window.addEventListener('storage', (event: StorageEvent) => {
if (event.key !== STORAGE_KEY || typeof event.newValue !== 'string') return
try {
const parsed = JSON.parse(event.newValue) as PersistedPerformanceState
applyExternalFrameTarget(parsed.frameTarget)
} catch {
// Ignore invalid sync payloads.
}
})
}
getSyncChannel()?.addEventListener('message', (event: MessageEvent<{ frameTarget?: unknown }>) => {
applyExternalFrameTarget(event.data?.frameTarget)
})
}
bindCrossWindowSync()
+16
View File
@@ -906,6 +906,10 @@ select {
min-width: 360px;
}
.bottom-bar__section--performance {
min-width: 328px;
}
.bottom-bar__section--trim {
min-width: 196px;
}
@@ -946,6 +950,10 @@ select {
gap: 6px;
}
.bottom-bar__inline--performance {
gap: 10px;
}
.bottom-bar__divider {
width: 1px;
align-self: stretch;
@@ -974,6 +982,14 @@ select {
max-width: 280px;
}
.bottom-bar__fps-pill {
min-width: 84px;
justify-content: center;
color: rgba(255, 255, 255, 0.84);
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.08em;
}
.bottom-bar__error-text {
margin-top: 0;
max-width: 360px;
+105 -3
View File
@@ -1,8 +1,25 @@
export type FrameSchedulerCallback = () => void
import type { VisualizerFrameTarget } from '../../types/performance'
const FPS_WINDOW_MS = 1000
const TARGET_EPSILON_MS = 0.5
interface FrameSchedulerOptions {
frameTarget?: VisualizerFrameTarget
}
export class FrameScheduler {
private callbacks = new Set<FrameSchedulerCallback>()
private fpsListeners = new Set<(fps: number) => void>()
private dispatchTimestamps: number[] = []
private frameId: number | null = null
private actualFps = 0
private frameTarget: VisualizerFrameTarget
constructor(options: FrameSchedulerOptions = {}) {
this.frameTarget = options.frameTarget ?? 'display-sync'
}
subscribe(callback: FrameSchedulerCallback): () => void {
this.callbacks.add(callback)
@@ -16,6 +33,30 @@ export class FrameScheduler {
}
}
setFrameTarget(target: VisualizerFrameTarget): void {
if (this.frameTarget === target) return
this.frameTarget = target
this.dispatchTimestamps = []
this.updateActualFps(0)
}
getFrameTarget(): VisualizerFrameTarget {
return this.frameTarget
}
getActualFps(): number {
return this.actualFps
}
subscribeToActualFps(listener: (fps: number) => void): () => void {
this.fpsListeners.add(listener)
listener(this.actualFps)
return () => {
this.fpsListeners.delete(listener)
}
}
private start(): void {
if (this.frameId !== null || this.callbacks.size === 0) {
return
@@ -29,18 +70,79 @@ export class FrameScheduler {
window.cancelAnimationFrame(this.frameId)
this.frameId = null
}
this.dispatchTimestamps = []
this.updateActualFps(0)
}
private tick = (): void => {
private tick = (timestamp: number): void => {
this.frameId = null
if (this.callbacks.size === 0) {
return
}
for (const callback of [...this.callbacks]) {
callback()
const now = Number.isFinite(timestamp)
? timestamp
: typeof performance !== 'undefined'
? performance.now()
: Date.now()
if (this.shouldDispatchFrame(now)) {
this.recordDispatch(now)
for (const callback of [...this.callbacks]) {
callback()
}
}
this.start()
}
private shouldDispatchFrame(timestamp: number): boolean {
if (this.frameTarget === 'display-sync') {
return true
}
const lastDispatchTimestamp = this.dispatchTimestamps[this.dispatchTimestamps.length - 1]
if (lastDispatchTimestamp === undefined) {
return true
}
return timestamp - lastDispatchTimestamp >= (1000 / this.frameTarget) - TARGET_EPSILON_MS
}
private recordDispatch(timestamp: number): void {
this.dispatchTimestamps.push(timestamp)
const cutoff = timestamp - FPS_WINDOW_MS
while (this.dispatchTimestamps.length > 0 && this.dispatchTimestamps[0] < cutoff) {
this.dispatchTimestamps.shift()
}
const nextFps = this.computeActualFps()
this.updateActualFps(nextFps)
}
private computeActualFps(): number {
if (this.dispatchTimestamps.length < 2) {
return 0
}
const firstTimestamp = this.dispatchTimestamps[0]
const lastTimestamp = this.dispatchTimestamps[this.dispatchTimestamps.length - 1]
const elapsed = lastTimestamp - firstTimestamp
if (elapsed <= 0) {
return 0
}
return ((this.dispatchTimestamps.length - 1) * 1000) / elapsed
}
private updateActualFps(fps: number): void {
if (this.actualFps === fps) return
this.actualFps = fps
for (const listener of this.fpsListeners) {
listener(fps)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] as const
export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number]
export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget {
return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget)
}