diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index ede7f4a..05149ec 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type JSX } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import type { ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' import type { @@ -12,6 +12,7 @@ import type { ResolvedVUMeterTheme, ResolvedWaveformTheme, } from '../../types/theme' +import type { SpectrumPeakInfo } from '../../types/spectrum' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' import AstraScopeModule from './AstraScopeModule' @@ -65,10 +66,84 @@ interface CanvasResizeState { dpr: number } +const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 +const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248 +const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42 + +interface SizeMeasurement { + width: number + height: number +} + function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleTheme { return theme[kind] as ScopeModuleTheme } +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function formatSpectrumPeakDb(value: number): string { + if (!Number.isFinite(value)) { + return '--' + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB` +} + +function formatSpectrumPeakFrequency(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '--' + } + + if (value >= 1000) { + return `${(value / 1000).toFixed(2)}kHz` + } + + return `${value.toFixed(2)}Hz` +} + +function resolveFollowingPeakOverlayStyle( + peakInfo: SpectrumPeakInfo, + resizeState: CanvasResizeState | null, + overlaySize: SizeMeasurement | null, +): CSSProperties { + if (!resizeState) { + return { + left: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`, + top: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`, + } + } + + const width = resizeState.cssWidth + const height = resizeState.cssHeight + const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX + const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX + const peakX = peakInfo.normalizedX * width + const peakY = peakInfo.normalizedY * height + const maxLeft = Math.max( + SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + ) + const maxTop = Math.max( + SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + height - overlayHeight - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + ) + + const canPlaceAbove = peakY - overlayHeight >= SPECTRUM_PEAK_OVERLAY_MARGIN_PX + const canPlaceBelow = peakY + overlayHeight <= height - SPECTRUM_PEAK_OVERLAY_MARGIN_PX + + const left = peakX + const top = canPlaceAbove || !canPlaceBelow + ? peakY - overlayHeight + : peakY + + return { + left: `${clampNumber(left, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxLeft)}px`, + top: `${clampNumber(top, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxTop)}px`, + } +} + function measureCanvasResizeState(container: HTMLDivElement): CanvasResizeState { const rect = container.getBoundingClientRect() const cssWidth = Math.max(1, Math.floor(rect.width)) @@ -239,12 +314,16 @@ function createVisualizer( theme: ScopeModuleTheme, frameScheduler?: FrameScheduler, dataSource?: ScopeModuleProps['dataSource'], + onSpectrumPeakInfo?: (peakInfo: SpectrumPeakInfo | null) => void, + captureSpectrumPeakInfo = false, ): Visualizer | null { const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler } switch (scopeKind) { case 'spectrum': return new SpectrumAnalyzer(canvas, { ...opts, + capturePeakInfo: captureSpectrumPeakInfo, + onPeakInfo: onSpectrumPeakInfo, ...(dataSource ? { dataSource: dataSource as SpectrumAnalyzerDataSource } : {}), }) case 'oscilloscope': @@ -299,11 +378,65 @@ export default function ScopeModule({ const appliedResizeRef = useRef(null) const resizeFrameRef = useRef(null) const snapshotCanvasRef = useRef(null) + const peakOverlayRef = useRef(null) + const [spectrumPeakInfo, setSpectrumPeakInfo] = useState(null) + const [peakOverlaySize, setPeakOverlaySize] = useState(null) const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) const activeTheme = useThemeStore((s) => s.activeTheme) const mySettings = settings ?? storeSettings const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) + const spectrumPeakMode = scopeKind === 'spectrum' + ? (mySettings as ScopeSettings['spectrum']).peakInfoMode + : 'off' + const captureSpectrumPeakInfo = scopeKind === 'spectrum' && spectrumPeakMode !== 'off' + const handleSpectrumPeakInfo = useCallback((nextPeakInfo: SpectrumPeakInfo | null): void => { + setSpectrumPeakInfo(nextPeakInfo) + }, []) + + useEffect(() => { + if (!captureSpectrumPeakInfo) { + setSpectrumPeakInfo(null) + } + }, [captureSpectrumPeakInfo]) + + useLayoutEffect(() => { + const peakOverlay = peakOverlayRef.current + if ( + scopeKind !== 'spectrum' + || spectrumPeakMode !== 'following' + || !spectrumPeakInfo + || !peakOverlay + ) { + setPeakOverlaySize(null) + return + } + + const updatePeakOverlaySize = (): void => { + const nextWidth = peakOverlay.offsetWidth + const nextHeight = peakOverlay.offsetHeight + setPeakOverlaySize((previousSize) => { + if (previousSize?.width === nextWidth && previousSize?.height === nextHeight) { + return previousSize + } + return { width: nextWidth, height: nextHeight } + }) + } + + updatePeakOverlaySize() + + if (typeof ResizeObserver === 'undefined') { + return + } + + const resizeObserver = new ResizeObserver(() => { + updatePeakOverlaySize() + }) + resizeObserver.observe(peakOverlay) + return () => { + resizeObserver.disconnect() + } + }, [scopeKind, spectrumPeakMode, spectrumPeakInfo]) if (scopeKind === 'astra') { return ( @@ -319,7 +452,16 @@ export default function ScopeModule({ if (!canvas) return initializedRef.current = false - const viz = createVisualizer(scopeKind, canvas, mySettings, myTheme, frameScheduler, dataSource) + const viz = createVisualizer( + scopeKind, + canvas, + mySettings, + myTheme, + frameScheduler, + dataSource, + handleSpectrumPeakInfo, + captureSpectrumPeakInfo, + ) if (!viz) return visualizerRef.current = viz @@ -333,18 +475,25 @@ export default function ScopeModule({ viz.dispose() visualizerRef.current = null initializedRef.current = false + setSpectrumPeakInfo(null) } - }, [dataSource, frameScheduler, myTheme, mySettings, scopeKind]) + }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, myTheme, mySettings, scopeKind]) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, myTheme), frameScheduler, + ...(scopeKind === 'spectrum' + ? { + capturePeakInfo: captureSpectrumPeakInfo, + onPeakInfo: handleSpectrumPeakInfo, + } + : {}), ...(dataSource ? { dataSource } : {}), } visualizerRef.current.setOptions(opts) - }, [dataSource, frameScheduler, mySettings, myTheme, scopeKind]) + }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind]) useEffect(() => { const container = containerRef.current @@ -432,8 +581,15 @@ export default function ScopeModule({ } }, []) + const spectrumPeakOverlayStyle = scopeKind === 'spectrum' + && spectrumPeakMode === 'following' + && spectrumPeakInfo + ? resolveFollowingPeakOverlayStyle(spectrumPeakInfo, appliedResizeRef.current, peakOverlaySize) + : undefined + return (
+ {scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && ( +
+ {formatSpectrumPeakDb(spectrumPeakInfo.db)} + / + {formatSpectrumPeakFrequency(spectrumPeakInfo.frequencyHz)} + / + {spectrumPeakInfo.key} +
+ )}
) } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 223690c..ff1de8b 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -35,7 +35,16 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] case 'spectrum': { const scopeSettings = settings as ScopeSettings['spectrum'] const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` - return scopeSettings.showSideLine ? `${summary} · Side` : summary + const parts = [summary] + if (scopeSettings.showSideLine) { + parts.push('Side') + } + if (scopeSettings.peakInfoMode === 'on') { + parts.push('Peak') + } else if (scopeSettings.peakInfoMode === 'following') { + parts.push('Peak Follow') + } + return parts.join(' · ') } case 'oscilloscope': { const scopeSettings = settings as ScopeSettings['oscilloscope'] @@ -218,6 +227,18 @@ export default function ScopeSettingsSection({ ))} + onUpdate('spectrum', { + peakInfoMode: value as ScopeSettings['spectrum']['peakInfoMode'], + })} + > + + + + + void dataSource?: SpectrumAnalyzerDataSource frameScheduler?: FrameScheduler } type ResolvedSpectrumAnalyzerOptions = Required> +type SpectrumPointFillResult = { + pointCount: number + peakInfo: SpectrumPeakInfo | null +} type HeatStop = { at: number; color: [number, number, number] } @@ -64,6 +73,11 @@ const FFT_SILENCE_DB = -100 const SPECTRUM_DB_FLOOR = -120 const SPECTRUM_DB_CEILING = 12 const SIDE_LINE_WIDTH_RATIO = 0.75 +const PEAK_SELECTION_MAX_DISTANCE_OCTAVES = 0.5 +const PEAK_SELECTION_SWITCH_THRESHOLD_DB = 4 +const PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE = 2.5 +const PEAK_SELECTION_UPWARD_SWITCH_THRESHOLD_DB = 2 +const NOOP_SPECTRUM_PEAK_INFO_CALLBACK = (_peakInfo: SpectrumPeakInfo | null): void => {} function clampSmoothing(value: number): number { return Math.min(0.99, Math.max(0, value)) @@ -220,6 +234,8 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { tiltReferenceHz: 1000, fftSize: 2048, showSideLine: false, + capturePeakInfo: false, + onPeakInfo: NOOP_SPECTRUM_PEAK_INFO_CALLBACK, } const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { @@ -265,6 +281,9 @@ export class SpectrumAnalyzer { private primaryPointHeatmap = new Float32Array(0) private secondaryPointX = new Float32Array(0) private secondaryPointY = new Float32Array(0) + private primaryPointDb = new Float32Array(0) + private primaryPointFrequency = new Float32Array(0) + private lastSelectedPeakInfo: SpectrumPeakInfo | null = null constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas @@ -371,6 +390,7 @@ export class SpectrumAnalyzer { this.jsHasSpectrumData = false this.nativeBufferedSamples = 0 this.nativeHasSpectrumData = false + this.lastSelectedPeakInfo = null } private updateSampleRateIfNeeded(): void { @@ -523,6 +543,8 @@ export class SpectrumAnalyzer { this.primaryPointHeatmap = new Float32Array(pointCount) this.secondaryPointX = new Float32Array(pointCount) this.secondaryPointY = new Float32Array(pointCount) + this.primaryPointDb = new Float32Array(pointCount) + this.primaryPointFrequency = new Float32Array(pointCount) } } @@ -705,10 +727,11 @@ export class SpectrumAnalyzer { xOut: Float32Array, yOut: Float32Array, heatmapIntensityOut: Float32Array | null, - ): number { + capturePeakInfo = false, + ): SpectrumPointFillResult { const bufferLength = Math.min(dataLength, frequencyData.length) if (bufferLength <= 0) { - return 0 + return { pointCount: 0, peakInfo: null } } const binWidth = nyquist / bufferLength const numPoints = Math.max(2, Math.floor(width)) @@ -738,9 +761,182 @@ export class SpectrumAnalyzer { if (heatmapIntensityOut) { heatmapIntensityOut[index] = Math.pow(clampedNormalized, HEATMAP_GAMMA) } + + if (capturePeakInfo) { + this.primaryPointDb[index] = db + this.primaryPointFrequency[index] = centerFrequency + } } - return numPoints + return { + pointCount: numPoints, + peakInfo: capturePeakInfo ? this.selectPeakInfo(numPoints, width, height) : null, + } + } + + private buildPeakInfoAt(index: number, height: number): SpectrumPeakInfo | null { + if ( + index < 0 + || index >= this.primaryPointDb.length + || index >= this.primaryPointFrequency.length + || index >= this.primaryPointX.length + || index >= this.primaryPointY.length + ) { + return null + } + + const frequencyHz = this.primaryPointFrequency[index] + const db = this.primaryPointDb[index] + return { + db, + frequencyHz, + normalizedX: this.primaryPointX[index] / Math.max(1, this.canvas.width), + normalizedY: this.primaryPointY[index] / Math.max(1, height), + key: formatSpectrumPitchInfo(resolveSpectrumPitchInfo(frequencyHz)), + } + } + + private isLocalPeak(index: number, pointCount: number): boolean { + const currentDb = this.primaryPointDb[index] + const previousDb = index > 0 + ? this.primaryPointDb[index - 1] + : Number.NEGATIVE_INFINITY + const nextDb = index + 1 < pointCount + ? this.primaryPointDb[index + 1] + : Number.NEGATIVE_INFINITY + + return currentDb >= previousDb + && currentDb >= nextDb + && (currentDb > previousDb || currentDb > nextDb) + } + + private getPeakSelectionScore(index: number): number { + const frequencyHz = this.primaryPointFrequency[index] + const db = this.primaryPointDb[index] + if (!Number.isFinite(frequencyHz) || frequencyHz <= 0 || !Number.isFinite(db)) { + return Number.NEGATIVE_INFINITY + } + + const referenceFrequencyHz = Math.max(1, this.options.minFrequency) + const octaveOffset = Math.max(0, Math.log2(frequencyHz / referenceFrequencyHz)) + return db - octaveOffset * PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE + } + + private isBetterPeakIndex(candidateIndex: number, bestIndex: number): boolean { + const candidateScore = this.getPeakSelectionScore(candidateIndex) + const bestScore = this.getPeakSelectionScore(bestIndex) + if (candidateScore !== bestScore) { + return candidateScore > bestScore + } + + const candidateDb = this.primaryPointDb[candidateIndex] + const bestDb = this.primaryPointDb[bestIndex] + if (candidateDb !== bestDb) { + return candidateDb > bestDb + } + + return this.primaryPointFrequency[candidateIndex] < this.primaryPointFrequency[bestIndex] + } + + private findBestPeakIndex(candidateIndices: number[]): number { + let bestIndex = candidateIndices[0] + + for (let index = 1; index < candidateIndices.length; index += 1) { + const candidateIndex = candidateIndices[index] + if (this.isBetterPeakIndex(candidateIndex, bestIndex)) { + bestIndex = candidateIndex + } + } + + return bestIndex + } + + private findBestPointIndex(pointCount: number): number { + let bestIndex = 0 + + for (let index = 1; index < pointCount; index += 1) { + if (this.isBetterPeakIndex(index, bestIndex)) { + bestIndex = index + } + } + + return bestIndex + } + + private findBestNearbyPeakIndex(candidateIndices: number[], targetFrequencyHz: number): number { + let bestIndex = -1 + + for (const candidateIndex of candidateIndices) { + const candidateFrequencyHz = this.primaryPointFrequency[candidateIndex] + if (!Number.isFinite(candidateFrequencyHz) || candidateFrequencyHz <= 0) { + continue + } + + const octaveDistance = Math.abs(Math.log2(candidateFrequencyHz / targetFrequencyHz)) + if (octaveDistance > PEAK_SELECTION_MAX_DISTANCE_OCTAVES) { + continue + } + + if (bestIndex === -1 || this.isBetterPeakIndex(candidateIndex, bestIndex)) { + bestIndex = candidateIndex + } + } + + return bestIndex + } + + private selectPeakInfo(pointCount: number, _width: number, height: number): SpectrumPeakInfo | null { + if (pointCount <= 0) { + this.lastSelectedPeakInfo = null + return null + } + + const candidateIndices: number[] = [] + for (let index = 0; index < pointCount; index += 1) { + if (this.isLocalPeak(index, pointCount)) { + candidateIndices.push(index) + } + } + + if (candidateIndices.length === 0) { + candidateIndices.push(this.findBestPointIndex(pointCount)) + } + + const strongestIndex = this.findBestPeakIndex(candidateIndices) + const strongestScore = this.getPeakSelectionScore(strongestIndex) + + const previousPeak = this.lastSelectedPeakInfo + let selectedIndex = strongestIndex + + if (previousPeak && Number.isFinite(previousPeak.frequencyHz) && previousPeak.frequencyHz > 0) { + const stickyIndex = this.findBestNearbyPeakIndex(candidateIndices, previousPeak.frequencyHz) + + if ( + stickyIndex !== -1 + && strongestScore < ( + this.getPeakSelectionScore(stickyIndex) + + PEAK_SELECTION_SWITCH_THRESHOLD_DB + + ( + this.primaryPointFrequency[strongestIndex] > this.primaryPointFrequency[stickyIndex] + ? PEAK_SELECTION_UPWARD_SWITCH_THRESHOLD_DB + : 0 + ) + ) + ) { + selectedIndex = stickyIndex + } + } + + const peakInfo = this.buildPeakInfoAt(selectedIndex, height) + this.lastSelectedPeakInfo = peakInfo + return peakInfo + } + + private emitPeakInfo(peakInfo: SpectrumPeakInfo | null): void { + if (peakInfo === null) { + this.lastSelectedPeakInfo = null + } + this.options.onPeakInfo(peakInfo) } private renderHeatmap(xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number): void { @@ -832,6 +1028,7 @@ export class SpectrumAnalyzer { } this.resetJsState() this.renderStaticLayer(minFrequency, maxFrequency) + this.emitPeakInfo(null) return } @@ -854,6 +1051,7 @@ export class SpectrumAnalyzer { if (!isNativeAvailable()) { console.error('SpectrumAnalyzer: Native DSP required') this.renderStaticLayer(minFrequency, maxFrequency) + this.emitPeakInfo(null) return } @@ -885,12 +1083,13 @@ export class SpectrumAnalyzer { if (!primaryData || primaryDataLength === 0) { this.renderStaticLayer(minFrequency, maxFrequency) + this.emitPeakInfo(null) return } const pointCount = Math.max(2, Math.floor(width)) this.ensurePointBuffers(pointCount) - const primaryPointCount = this.fillSpectrumPoints( + const primaryRender = this.fillSpectrumPoints( primaryData, primaryDataLength, width, @@ -902,8 +1101,9 @@ export class SpectrumAnalyzer { this.primaryPointX, this.primaryPointY, null, + options.capturePeakInfo, ) - const heatmapPointCount = heatmapData && heatmapDataLength > 0 + const heatmapRender = heatmapData && heatmapDataLength > 0 ? this.fillSpectrumPoints( heatmapData, heatmapDataLength, @@ -917,9 +1117,9 @@ export class SpectrumAnalyzer { this.heatmapPointY, this.primaryPointHeatmap, ) - : 0 + : { pointCount: 0, peakInfo: null } - const secondaryPointCount = secondaryData && secondaryDataLength > 0 + const secondaryRender = secondaryData && secondaryDataLength > 0 ? this.fillSpectrumPoints( secondaryData, secondaryDataLength, @@ -933,12 +1133,12 @@ export class SpectrumAnalyzer { this.secondaryPointY, null, ) - : 0 + : { pointCount: 0, peakInfo: null } this.renderStaticLayer(minFrequency, maxFrequency) - if (options.heatmapFill && heatmapPointCount > 0) { - const renderPointCount = Math.min(primaryPointCount, heatmapPointCount) + if (options.heatmapFill && heatmapRender.pointCount > 0) { + const renderPointCount = Math.min(primaryRender.pointCount, heatmapRender.pointCount) this.renderHeatmap( this.primaryPointX, this.primaryPointY, @@ -947,15 +1147,17 @@ export class SpectrumAnalyzer { width, height, ) - } else if (options.fillGradient && primaryPointCount > 0) { - this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryPointCount, width, height) + } else if (options.fillGradient && primaryRender.pointCount > 0) { + this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryRender.pointCount, width, height) } - this.renderStroke(this.primaryPointX, this.primaryPointY, primaryPointCount, options.lineColor, options.lineWidth * dpr) - if (secondaryPointCount > 0) { + this.renderStroke(this.primaryPointX, this.primaryPointY, primaryRender.pointCount, options.lineColor, options.lineWidth * dpr) + if (secondaryRender.pointCount > 0) { const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr) - this.renderStroke(this.secondaryPointX, this.secondaryPointY, secondaryPointCount, options.secondaryLineColor, secondaryLineWidth) + this.renderStroke(this.secondaryPointX, this.secondaryPointY, secondaryRender.pointCount, options.secondaryLineColor, secondaryLineWidth) } + + this.emitPeakInfo(primaryRender.peakInfo) } private renderStaticLayer(minFrequency: number, maxFrequency: number): void { @@ -1064,5 +1266,6 @@ export class SpectrumAnalyzer { } this.resetJsState() this.lastSampleRate = 0 + this.emitPeakInfo(null) } } diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index d9d11ab..27c87a5 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -14,6 +14,7 @@ import { } from '../types/profile' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, type ScopeKind } from '../types/scope' import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings' +import { normalizeSpectrumPeakInfoMode } from '../types/spectrum' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -117,9 +118,16 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} + const rawSpectrum: Partial = typeof parsed.spectrum === 'object' && parsed.spectrum !== null + ? parsed.spectrum + : {} return { - spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...(parsed.spectrum ?? {}) }, + spectrum: { + ...DEFAULT_SCOPE_SETTINGS.spectrum, + ...rawSpectrum, + peakInfoMode: normalizeSpectrumPeakInfoMode(rawSpectrum.peakInfoMode), + }, oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) }, vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) }, spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) }, diff --git a/src/types/settings.ts b/src/types/settings.ts index b9478ca..e234025 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -3,6 +3,7 @@ import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram import type { VUMeterMode, VUMeterOrientation } from './vumeter' import type { LUFSMeterMode } from './lufsmeter' import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform' +import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum' export interface ScopeSettings { spectrum: { @@ -15,6 +16,7 @@ export interface ScopeSettings { smoothing: number fillGradient: boolean showSideLine: boolean + peakInfoMode: SpectrumPeakInfoMode } oscilloscope: { pitchLock: boolean @@ -60,7 +62,7 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false }, + spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, diff --git a/src/types/spectrum.ts b/src/types/spectrum.ts index 0745c71..c1e93b7 100644 --- a/src/types/spectrum.ts +++ b/src/types/spectrum.ts @@ -8,6 +8,34 @@ export const MIN_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE = -2.0 export const MAX_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE = 8.0 export const SPECTRUM_HEATMAP_TILT_STEP = 0.1 +export type SpectrumPeakInfoMode = 'off' | 'on' | 'following' + +export interface SpectrumPitchInfo { + note: string + octave: number + cents: number +} + +export interface SpectrumPeakInfo { + db: number + frequencyHz: number + normalizedX: number + normalizedY: number + key: string +} + +const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const + +export const DEFAULT_SPECTRUM_PEAK_INFO_MODE: SpectrumPeakInfoMode = 'off' + +export function isSpectrumPeakInfoMode(value: unknown): value is SpectrumPeakInfoMode { + return value === 'off' || value === 'on' || value === 'following' +} + +export function normalizeSpectrumPeakInfoMode(value: unknown): SpectrumPeakInfoMode { + return isSpectrumPeakInfoMode(value) ? value : DEFAULT_SPECTRUM_PEAK_INFO_MODE +} + export function clampSpectrumTiltDbPerOctave(value: unknown): number { const numeric = Number(value) if (!Number.isFinite(numeric)) { @@ -35,3 +63,33 @@ export function clampSpectrumHeatmapTiltDbPerOctave(value: unknown): number { Math.max(MIN_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, rounded) ) } + +export function resolveSpectrumPitchInfo(frequencyHz: number): SpectrumPitchInfo | null { + if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) { + return null + } + + const midiNote = 69 + (12 * Math.log2(frequencyHz / 440)) + const nearestMidiNote = Math.round(midiNote) + const cents = Math.round((midiNote - nearestMidiNote) * 100) + const noteIndex = ((nearestMidiNote % 12) + 12) % 12 + const octave = Math.floor(nearestMidiNote / 12) - 1 + + return { + note: NOTE_NAMES[noteIndex], + octave, + cents, + } +} + +export function formatSpectrumPitchInfo(pitchInfo: SpectrumPitchInfo | null): string { + if (!pitchInfo) { + return '--' + } + + const centsLabel = pitchInfo.cents > 0 + ? `+${pitchInfo.cents}c` + : `${pitchInfo.cents}c` + + return `${pitchInfo.note}${pitchInfo.octave} ${centsLabel}` +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index f709e55..9581842 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -44,6 +44,13 @@ import { import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap, WindowBounds } from '../src/types/popout' import { RESIZE_DIRECTIONS } from '../src/types/windowResize' +import { + DEFAULT_SPECTRUM_PEAK_INFO_MODE, + formatSpectrumPitchInfo, + normalizeSpectrumPeakInfoMode, + resolveSpectrumPitchInfo, + type SpectrumPeakInfo, +} from '../src/types/spectrum' import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource' import { VUMeterBallistics, @@ -227,6 +234,45 @@ function createProgramSamples(length: number): { left: Float32Array; right: Floa return { left, right } } +function createSineStereoChunk(frequencyHz: number, sampleRate: number, length: number): { + left: Float32Array + right: Float32Array +} { + const left = new Float32Array(length) + const right = new Float32Array(length) + + for (let index = 0; index < length; index += 1) { + const sample = Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate) * 0.8 + left[index] = sample + right[index] = sample + } + + return { left, right } +} + +function createCompositeStereoChunk( + partials: Array<{ frequencyHz: number; amplitude: number }>, + sampleRate: number, + length: number, +): { + left: Float32Array + right: Float32Array +} { + const left = new Float32Array(length) + const right = new Float32Array(length) + + for (let index = 0; index < length; index += 1) { + let sample = 0 + for (const partial of partials) { + sample += Math.sin((2 * Math.PI * partial.frequencyHz * index) / sampleRate) * partial.amplitude + } + left[index] = sample + right[index] = sample + } + + return { left, right } +} + function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutStateMap { const poppedOutSet = new Set(poppedOutScopes) return SCOPE_KINDS.reduce((acc, kind) => { @@ -922,6 +968,19 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer assert.equal(options.gridColor, theme.spectrum.guides) }) +test('default profile starts with spectrum peak info disabled', () => { + const profile = createDefaultProfile('Default') + + assert.equal(profile.scopeSettings.spectrum.peakInfoMode, DEFAULT_SPECTRUM_PEAK_INFO_MODE) + assert.equal(normalizeSpectrumPeakInfoMode('nope'), DEFAULT_SPECTRUM_PEAK_INFO_MODE) +}) + +test('spectrum pitch helpers format nearest note with octave and cents', () => { + assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(440)), 'A4 0c') + assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(261.6255653005986)), 'C4 0c') + assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(Number.NaN)), '--') +}) + test('SpectrumAnalyzer heatmap output does not change when only line smoothing changes', () => { const looseLine = renderSpectrumSnapshot({ smoothing: 0, @@ -1003,6 +1062,130 @@ test('SpectrumAnalyzer renders heatmap fill on the spectrum line', () => { assertArraysAlmostEqual(snapshot.renderedHeatmapY, snapshot.primaryPointY, 1e-6, 'heatmap fill should use the line geometry') }) +test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const { left, right } = createSineStereoChunk(440, sampleRate, 4096) + let peakInfo: SpectrumPeakInfo | null = null + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [{ left, right }], + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave: 0, + fftSize: 4096, + dataSource, + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + peakInfo = nextPeakInfo + }, + }) + + try { + const state = analyzer as unknown as { + drawFrame: () => void + primaryPointX: Float32Array + isLocalPeak: (index: number, pointCount: number) => boolean + } + state.drawFrame() + + assert.ok(peakInfo, 'expected peak info to be reported') + assert.ok(peakInfo.frequencyHz > 420 && peakInfo.frequencyHz < 460, `expected peak frequency near 440 Hz, got ${peakInfo.frequencyHz}`) + assert.match(peakInfo.key, /^A4 [+-]?\d+c$/) + assert.ok(peakInfo.db > -20, `expected an audible peak dB, got ${peakInfo.db}`) + assert.ok(peakInfo.normalizedX >= 0 && peakInfo.normalizedX <= 1, 'peak x should be normalized') + assert.ok(peakInfo.normalizedY >= 0 && peakInfo.normalizedY <= 1, 'peak y should be normalized') + + const selectedX = peakInfo.normalizedX * 320 + let selectedIndex = 0 + let smallestDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < state.primaryPointX.length; index += 1) { + const distance = Math.abs(state.primaryPointX[index] - selectedX) + if (distance < smallestDistance) { + smallestDistance = distance + selectedIndex = index + } + } + assert.equal( + state.isLocalPeak(selectedIndex, state.primaryPointX.length), + true, + 'reported peak should stay anchored to a local maximum on the rendered curve', + ) + } finally { + analyzer.dispose() + dom.restore() + } +}) + +test('SpectrumAnalyzer smooths peak selection without smoothing the reported position', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const frameA = createCompositeStereoChunk([ + { frequencyHz: 440, amplitude: 0.95 }, + { frequencyHz: 660, amplitude: 0.6 }, + ], sampleRate, 4096) + const frameB = createCompositeStereoChunk([ + { frequencyHz: 440, amplitude: 0.72 }, + { frequencyHz: 660, amplitude: 0.84 }, + ], sampleRate, 4096) + const frameC = createCompositeStereoChunk([ + { frequencyHz: 440, amplitude: 0.28 }, + { frequencyHz: 660, amplitude: 0.95 }, + ], sampleRate, 4096) + const pendingFrames = [frameA, frameB, frameC] + const peakKeys: string[] = [] + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => { + const nextFrame = pendingFrames.shift() + return nextFrame ? [nextFrame] : [] + }, + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave: 0, + fftSize: 4096, + dataSource, + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + if (nextPeakInfo) { + peakKeys.push(nextPeakInfo.key) + } + }, + }) + + try { + const state = analyzer as unknown as { + drawFrame: () => void + } + state.drawFrame() + state.drawFrame() + state.drawFrame() + + assert.match(peakKeys[0] ?? '', /^A4 [+-]?\d+c$/) + assert.match(peakKeys[1] ?? '', /^A4 [+-]?\d+c$/) + assert.match(peakKeys[2] ?? '', /^E5 [+-]?\d+c$/) + } finally { + analyzer.dispose() + dom.restore() + } +}) + test('astra playback progress advances from updatedAt while playing', () => { const progress = getAstraPlaybackProgress({ playbackState: 'playing', @@ -1269,6 +1452,18 @@ test('scopeSummary includes Stereo for waveform only when stereo mode is enabled assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB') }) +test('scopeSummary includes spectrum peak mode when enabled', () => { + const profile = createDefaultProfile('Default') + + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048') + + profile.scopeSettings.spectrum.peakInfoMode = 'on' + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak') + + profile.scopeSettings.spectrum.peakInfoMode = 'following' + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow') +}) + test('scopeSummary summarizes astra field visibility', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.astra.showArtist = false