mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
add vertical mode to spectrogram
This commit is contained in:
@@ -251,6 +251,7 @@ export function scopeSettingsToOptions(
|
||||
contrast: s.contrast,
|
||||
clarityMode: s.clarityMode,
|
||||
scaleMode: s.scaleMode,
|
||||
orientation: s.orientation,
|
||||
colorScheme: s.colorScheme,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
|
||||
}
|
||||
case 'spectrogram': {
|
||||
const scopeSettings = settings as ScopeSettings['spectrogram']
|
||||
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
|
||||
return `${scopeSettings.orientation.toUpperCase()} · ${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
|
||||
}
|
||||
case 'vumeter': {
|
||||
const scopeSettings = settings as ScopeSettings['vumeter']
|
||||
@@ -507,6 +507,15 @@ export default function ScopeSettingsSection({
|
||||
<option value="linear">Linear</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Orientation"
|
||||
value={current.orientation}
|
||||
onChange={(value) => onUpdate('spectrogram', { orientation: value as ScopeSettings['spectrogram']['orientation'] })}
|
||||
>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
<option value="vertical">Vertical</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Clarity"
|
||||
value={current.clarityMode}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
import {
|
||||
DEFAULT_SPECTROGRAM_CLARITY_MODE,
|
||||
DEFAULT_SPECTROGRAM_CONTRAST,
|
||||
DEFAULT_SPECTROGRAM_ORIENTATION,
|
||||
DEFAULT_SPECTROGRAM_SCALE_MODE,
|
||||
DEFAULT_SPECTROGRAM_SCROLL_SPEED,
|
||||
clampSpectrogramContrast,
|
||||
clampSpectrogramScrollSpeed,
|
||||
isSpectrogramClarityMode,
|
||||
isSpectrogramOrientation,
|
||||
isSpectrogramScaleMode,
|
||||
type SpectrogramClarityMode,
|
||||
type SpectrogramOrientation,
|
||||
type SpectrogramScaleMode,
|
||||
} from '../../types/spectrogram'
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ export interface SpectrogramOptions {
|
||||
contrast?: number
|
||||
clarityMode?: SpectrogramClarityMode
|
||||
scaleMode?: SpectrogramScaleMode
|
||||
orientation?: SpectrogramOrientation
|
||||
colorScheme?: 'heat' | 'mono'
|
||||
lineColor?: string
|
||||
heatColors?: [string, string, string]
|
||||
@@ -66,6 +70,7 @@ const defaultOptions: ResolvedSpectrogramOptions = {
|
||||
contrast: DEFAULT_SPECTROGRAM_CONTRAST,
|
||||
clarityMode: DEFAULT_SPECTROGRAM_CLARITY_MODE,
|
||||
scaleMode: DEFAULT_SPECTROGRAM_SCALE_MODE,
|
||||
orientation: DEFAULT_SPECTROGRAM_ORIENTATION,
|
||||
colorScheme: 'heat',
|
||||
lineColor: '#38bdf8',
|
||||
heatColors: ['rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)'],
|
||||
@@ -96,6 +101,10 @@ function resolveScaleMode(value: unknown, fallback: SpectrogramScaleMode): Spect
|
||||
return isSpectrogramScaleMode(value) ? value : fallback
|
||||
}
|
||||
|
||||
function resolveOrientation(value: unknown, fallback: SpectrogramOrientation): SpectrogramOrientation {
|
||||
return isSpectrogramOrientation(value) ? value : fallback
|
||||
}
|
||||
|
||||
function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<SpectrogramOptions>): ResolvedSpectrogramOptions {
|
||||
return {
|
||||
fftSize: typeof overrides.fftSize === 'number' ? overrides.fftSize : base.fftSize,
|
||||
@@ -111,6 +120,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<Spe
|
||||
: clampSpectrogramContrast(overrides.contrast),
|
||||
clarityMode: resolveClarityMode(overrides.clarityMode, base.clarityMode),
|
||||
scaleMode: resolveScaleMode(overrides.scaleMode, base.scaleMode),
|
||||
orientation: resolveOrientation(overrides.orientation, base.orientation),
|
||||
colorScheme: overrides.colorScheme ?? base.colorScheme,
|
||||
lineColor: overrides.lineColor ?? base.lineColor,
|
||||
heatColors: overrides.heatColors ?? base.heatColors,
|
||||
@@ -365,6 +375,7 @@ export class Spectrogram {
|
||||
private lastMinFrequency = 0
|
||||
private lastMaxFrequency = 0
|
||||
private lastScaleMode: SpectrogramScaleMode | null = null
|
||||
private lastOrientation: SpectrogramOrientation | null = null
|
||||
private unsubscribeSessionChange: (() => void) | null = null
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: SpectrogramOptions = {}) {
|
||||
@@ -440,7 +451,10 @@ export class Spectrogram {
|
||||
this.sampleBufferPos = 0
|
||||
this.lastFftSize = 0
|
||||
this.resetDisplay()
|
||||
} else if (this.options.scaleMode !== previousOptions.scaleMode) {
|
||||
} else if (
|
||||
this.options.scaleMode !== previousOptions.scaleMode
|
||||
|| this.options.orientation !== previousOptions.orientation
|
||||
) {
|
||||
this.resetDisplay()
|
||||
}
|
||||
|
||||
@@ -465,16 +479,27 @@ export class Spectrogram {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureColumnBuffers(height: number): void {
|
||||
if (height <= 0) return
|
||||
if (this.columnValues.length === height && this.columnImageData && this.columnImageData.height === height) {
|
||||
private getFrequencyPixelCount(width: number, height: number): number {
|
||||
return this.options.orientation === 'vertical' ? width : height
|
||||
}
|
||||
|
||||
private ensureColumnBuffers(pixelCount: number): void {
|
||||
if (pixelCount <= 0) return
|
||||
const imageWidth = this.options.orientation === 'vertical' ? pixelCount : 1
|
||||
const imageHeight = this.options.orientation === 'vertical' ? 1 : pixelCount
|
||||
if (
|
||||
this.columnValues.length === pixelCount
|
||||
&& this.columnImageData
|
||||
&& this.columnImageData.width === imageWidth
|
||||
&& this.columnImageData.height === imageHeight
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.columnValues = new Float32Array(height)
|
||||
this.rawColumnValues = new Float32Array(height)
|
||||
this.heatColumnValues = new Float32Array(height)
|
||||
this.columnImageData = new ImageData(1, height)
|
||||
this.columnValues = new Float32Array(pixelCount)
|
||||
this.rawColumnValues = new Float32Array(pixelCount)
|
||||
this.heatColumnValues = new Float32Array(pixelCount)
|
||||
this.columnImageData = new ImageData(imageWidth, imageHeight)
|
||||
}
|
||||
|
||||
private shiftAndPaintColumn(values: Float32Array, heatValues: Float32Array = values): void {
|
||||
@@ -484,14 +509,22 @@ export class Spectrogram {
|
||||
|
||||
this.paintColumnImage(values, heatValues)
|
||||
|
||||
// Shift existing content left by 1 pixel
|
||||
const previousCompositeOperation = this.waterfallCtx.globalCompositeOperation
|
||||
this.waterfallCtx.globalCompositeOperation = 'copy'
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
|
||||
if (this.options.orientation === 'vertical') {
|
||||
// Shift existing content up by 1 pixel.
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, 0, -1)
|
||||
} else {
|
||||
// Shift existing content left by 1 pixel.
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
|
||||
}
|
||||
this.waterfallCtx.globalCompositeOperation = previousCompositeOperation
|
||||
|
||||
// Paint new column at right edge
|
||||
this.waterfallCtx.putImageData(this.columnImageData, width - 1, 0)
|
||||
if (this.options.orientation === 'vertical') {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, 0, height - 1)
|
||||
} else {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, width - 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBandMapping(): void {
|
||||
@@ -499,6 +532,7 @@ export class Spectrogram {
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const fftSize = options.fftSize
|
||||
const frequencyPixelCount = this.getFrequencyPixelCount(width, height)
|
||||
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
|
||||
const nyquist = sampleRate / 2
|
||||
const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
|
||||
@@ -512,6 +546,7 @@ export class Spectrogram {
|
||||
&& minFrequency === this.lastMinFrequency
|
||||
&& maxFrequency === this.lastMaxFrequency
|
||||
&& options.scaleMode === this.lastScaleMode
|
||||
&& options.orientation === this.lastOrientation
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -523,28 +558,39 @@ export class Spectrogram {
|
||||
this.lastMinFrequency = minFrequency
|
||||
this.lastMaxFrequency = maxFrequency
|
||||
this.lastScaleMode = options.scaleMode
|
||||
this.lastOrientation = options.orientation
|
||||
|
||||
const numBins = (fftSize * FFT_PAD_FACTOR) / 2
|
||||
const rowSpan = Math.max(1, height - 1)
|
||||
const rowSpan = Math.max(1, frequencyPixelCount - 1)
|
||||
const binWidth = nyquist / numBins
|
||||
|
||||
this.rowCenterBins = new Float32Array(height)
|
||||
this.rowBandStartBins = new Float32Array(height)
|
||||
this.rowBandEndBins = new Float32Array(height)
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const normalizedPosition = 1 - (row / rowSpan)
|
||||
this.rowCenterBins = new Float32Array(frequencyPixelCount)
|
||||
this.rowBandStartBins = new Float32Array(frequencyPixelCount)
|
||||
this.rowBandEndBins = new Float32Array(frequencyPixelCount)
|
||||
for (let row = 0; row < frequencyPixelCount; row += 1) {
|
||||
const normalizedPosition = options.orientation === 'vertical'
|
||||
? row / rowSpan
|
||||
: 1 - (row / rowSpan)
|
||||
const centerFrequency = frequencyFromScale(
|
||||
options.scaleMode,
|
||||
minFrequency,
|
||||
maxFrequency,
|
||||
normalizedPosition
|
||||
)
|
||||
const upperEdgeNormalized = row === 0
|
||||
? 1
|
||||
: 1 - ((row - 0.5) / rowSpan)
|
||||
const lowerEdgeNormalized = row === height - 1
|
||||
? 0
|
||||
: 1 - ((row + 0.5) / rowSpan)
|
||||
const upperEdgeNormalized = options.orientation === 'vertical'
|
||||
? row === frequencyPixelCount - 1
|
||||
? 1
|
||||
: (row + 0.5) / rowSpan
|
||||
: row === 0
|
||||
? 1
|
||||
: 1 - ((row - 0.5) / rowSpan)
|
||||
const lowerEdgeNormalized = options.orientation === 'vertical'
|
||||
? row === 0
|
||||
? 0
|
||||
: (row - 0.5) / rowSpan
|
||||
: row === height - 1
|
||||
? 0
|
||||
: 1 - ((row + 0.5) / rowSpan)
|
||||
const upperEdgeFrequency = frequencyFromScale(
|
||||
options.scaleMode,
|
||||
minFrequency,
|
||||
@@ -563,7 +609,7 @@ export class Spectrogram {
|
||||
this.rowBandEndBins[row] = Math.max(0, Math.min(numBins, upperEdgeFrequency / binWidth))
|
||||
}
|
||||
|
||||
this.ensureColumnBuffers(height)
|
||||
this.ensureColumnBuffers(frequencyPixelCount)
|
||||
}
|
||||
|
||||
private processFFT(samples: Float32Array): Float32Array {
|
||||
@@ -626,10 +672,12 @@ export class Spectrogram {
|
||||
}
|
||||
|
||||
private drawColumn(magnitudes: Float32Array): Float32Array {
|
||||
const width = this.waterfallCanvas.width
|
||||
const height = this.waterfallCanvas.height
|
||||
if (height <= 0) return this.columnValues
|
||||
const frequencyPixelCount = this.getFrequencyPixelCount(width, height)
|
||||
if (frequencyPixelCount <= 0) return this.columnValues
|
||||
|
||||
this.ensureColumnBuffers(height)
|
||||
this.ensureColumnBuffers(frequencyPixelCount)
|
||||
const values = this.columnValues
|
||||
const raw = this.rawColumnValues
|
||||
const heat = this.heatColumnValues
|
||||
@@ -644,7 +692,7 @@ export class Spectrogram {
|
||||
const binWidth = (sampleRate / 2) / numBins
|
||||
|
||||
// Pass 1: sub-bin interpolation + tilt/gain -> raw normalized values (no gamma yet)
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let row = 0; row < frequencyPixelCount; row += 1) {
|
||||
const centerBin = this.rowCenterBins[row]
|
||||
|
||||
// 4-point Catmull–Rom cubic interpolation in dB — captures the Hann
|
||||
@@ -684,7 +732,7 @@ export class Spectrogram {
|
||||
// Target visual line width in pixels — suppression scales to achieve this
|
||||
const TARGET_LINE_WIDTH = clarity.lineWidth
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let row = 0; row < frequencyPixelCount; row += 1) {
|
||||
// Adaptive window: mainlobe width in pixel rows at this frequency
|
||||
const bandWidthPerRow = Math.max(0.1, this.rowBandEndBins[row] - this.rowBandStartBins[row])
|
||||
const mainlobePixels = mainlobePaddedBins / bandWidthPerRow
|
||||
@@ -700,7 +748,7 @@ export class Spectrogram {
|
||||
let localMax = raw[row]
|
||||
for (let d = 1; d <= halfWin; d += 1) {
|
||||
if (row - d >= 0 && raw[row - d] > localMax) localMax = raw[row - d]
|
||||
if (row + d < height && raw[row + d] > localMax) localMax = raw[row + d]
|
||||
if (row + d < frequencyPixelCount && raw[row + d] > localMax) localMax = raw[row + d]
|
||||
}
|
||||
|
||||
// Suppress off-peak values: peak stays bright, slopes get crushed
|
||||
@@ -715,7 +763,7 @@ export class Spectrogram {
|
||||
|
||||
// Pass 3: apply gamma scaled by user contrast (1.0 = profile default)
|
||||
const effectiveGamma = clarity.gamma * this.options.contrast
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let row = 0; row < frequencyPixelCount; row += 1) {
|
||||
values[row] = Math.pow(raw[row], effectiveGamma)
|
||||
}
|
||||
|
||||
@@ -745,16 +793,28 @@ export class Spectrogram {
|
||||
this.waterfallCanvas.height = height
|
||||
this.waterfallCtx.imageSmoothingEnabled = false
|
||||
|
||||
// Anchor right edge — newest columns stay, old data crops naturally
|
||||
if (previousCtx && previousCanvas.width > 0 && previousCanvas.height > 0) {
|
||||
const srcX = Math.max(0, previousCanvas.width - width)
|
||||
const srcW = Math.min(previousCanvas.width, width)
|
||||
const dstX = Math.max(0, width - previousCanvas.width)
|
||||
this.waterfallCtx.drawImage(
|
||||
previousCanvas,
|
||||
srcX, 0, srcW, previousCanvas.height,
|
||||
dstX, 0, srcW, height
|
||||
)
|
||||
if (this.options.orientation === 'vertical') {
|
||||
// Anchor bottom edge so newest rows stay visible.
|
||||
const srcY = Math.max(0, previousCanvas.height - height)
|
||||
const srcH = Math.min(previousCanvas.height, height)
|
||||
const dstY = Math.max(0, height - previousCanvas.height)
|
||||
this.waterfallCtx.drawImage(
|
||||
previousCanvas,
|
||||
0, srcY, previousCanvas.width, srcH,
|
||||
0, dstY, width, srcH
|
||||
)
|
||||
} else {
|
||||
// Anchor right edge so newest columns stay visible.
|
||||
const srcX = Math.max(0, previousCanvas.width - width)
|
||||
const srcW = Math.min(previousCanvas.width, width)
|
||||
const dstX = Math.max(0, width - previousCanvas.width)
|
||||
this.waterfallCtx.drawImage(
|
||||
previousCanvas,
|
||||
srcX, 0, srcW, previousCanvas.height,
|
||||
dstX, 0, srcW, height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.lastWidth = 0
|
||||
@@ -791,7 +851,7 @@ export class Spectrogram {
|
||||
if (this.sampleBufferPos >= fftSize) {
|
||||
const magnitudes = this.processFFT(this.sampleBuffer)
|
||||
const values = this.drawColumn(magnitudes)
|
||||
// Each FFT hop = exactly 1 pixel column. No accumulation, no duplication.
|
||||
// Each FFT hop = exactly 1 pixel slice. No accumulation, no duplication.
|
||||
this.shiftAndPaintColumn(values, this.heatColumnValues)
|
||||
|
||||
this.sampleBuffer.copyWithin(0, hopSize)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } fr
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
import { isLUFSMeterReadout } from '../types/lufsmeter'
|
||||
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
|
||||
import { isSpectrogramOrientation } from '../types/spectrogram'
|
||||
import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter'
|
||||
import { clampWaveformScrollSpeed } from '../types/waveform'
|
||||
|
||||
@@ -137,6 +138,9 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
const rawSpectrum: Partial<ScopeSettings['spectrum']> = typeof parsed.spectrum === 'object' && parsed.spectrum !== null
|
||||
? parsed.spectrum
|
||||
: {}
|
||||
const rawSpectrogram: Partial<ScopeSettings['spectrogram']> = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null
|
||||
? parsed.spectrogram
|
||||
: {}
|
||||
const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null
|
||||
? parsed.waveform
|
||||
: {}
|
||||
@@ -158,7 +162,13 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
},
|
||||
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
|
||||
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
||||
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
|
||||
spectrogram: {
|
||||
...DEFAULT_SCOPE_SETTINGS.spectrogram,
|
||||
...rawSpectrogram,
|
||||
orientation: isSpectrogramOrientation(rawSpectrogram.orientation)
|
||||
? rawSpectrogram.orientation
|
||||
: DEFAULT_SCOPE_SETTINGS.spectrogram.orientation,
|
||||
},
|
||||
vumeter: {
|
||||
...DEFAULT_SCOPE_SETTINGS.vumeter,
|
||||
...rawVUMeter,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
|
||||
import { DEFAULT_SPECTROGRAM_CONTRAST, type SpectrogramClarityMode, type SpectrogramScaleMode } from './spectrogram'
|
||||
import {
|
||||
DEFAULT_SPECTROGRAM_CONTRAST,
|
||||
DEFAULT_SPECTROGRAM_ORIENTATION,
|
||||
type SpectrogramClarityMode,
|
||||
type SpectrogramOrientation,
|
||||
type SpectrogramScaleMode,
|
||||
} from './spectrogram'
|
||||
import { DEFAULT_VU_REFERENCE_DBFS, type VUMeterMode, type VUMeterNeedleChannels, type VUMeterOrientation } from './vumeter'
|
||||
import { DEFAULT_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter'
|
||||
import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform'
|
||||
@@ -37,6 +43,7 @@ export interface ScopeSettings {
|
||||
contrast: number
|
||||
clarityMode: SpectrogramClarityMode
|
||||
scaleMode: SpectrogramScaleMode
|
||||
orientation: SpectrogramOrientation
|
||||
colorScheme: 'heat' | 'mono'
|
||||
}
|
||||
vumeter: {
|
||||
@@ -68,7 +75,7 @@ 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, 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, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' },
|
||||
spectrogram: { fftSize: 2048, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', orientation: DEFAULT_SPECTROGRAM_ORIENTATION, colorScheme: 'heat' },
|
||||
vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS },
|
||||
lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT },
|
||||
waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, multiband: false },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type SpectrogramClarityMode = 'classic' | 'sharp' | 'sharper'
|
||||
export type SpectrogramScaleMode = 'mel' | 'log' | 'linear'
|
||||
export type SpectrogramOrientation = 'horizontal' | 'vertical'
|
||||
|
||||
export const SPECTROGRAM_CLARITY_MODES: readonly SpectrogramClarityMode[] = [
|
||||
'classic',
|
||||
@@ -11,9 +12,14 @@ export const SPECTROGRAM_SCALE_MODES: readonly SpectrogramScaleMode[] = [
|
||||
'log',
|
||||
'linear',
|
||||
]
|
||||
export const SPECTROGRAM_ORIENTATIONS: readonly SpectrogramOrientation[] = [
|
||||
'horizontal',
|
||||
'vertical',
|
||||
]
|
||||
|
||||
export const DEFAULT_SPECTROGRAM_CLARITY_MODE: SpectrogramClarityMode = 'sharper'
|
||||
export const DEFAULT_SPECTROGRAM_SCALE_MODE: SpectrogramScaleMode = 'log'
|
||||
export const DEFAULT_SPECTROGRAM_ORIENTATION: SpectrogramOrientation = 'horizontal'
|
||||
export const MIN_SPECTROGRAM_SCROLL_SPEED = 0.5
|
||||
export const MAX_SPECTROGRAM_SCROLL_SPEED = 4
|
||||
export const SPECTROGRAM_SCROLL_SPEED_STEP = 0.5
|
||||
@@ -32,6 +38,10 @@ export function isSpectrogramScaleMode(value: unknown): value is SpectrogramScal
|
||||
return typeof value === 'string' && SPECTROGRAM_SCALE_MODES.includes(value as SpectrogramScaleMode)
|
||||
}
|
||||
|
||||
export function isSpectrogramOrientation(value: unknown): value is SpectrogramOrientation {
|
||||
return typeof value === 'string' && SPECTROGRAM_ORIENTATIONS.includes(value as SpectrogramOrientation)
|
||||
}
|
||||
|
||||
export function clampSpectrogramScrollSpeed(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
|
||||
@@ -50,6 +50,7 @@ function createProfile(name: string): Profile {
|
||||
profile.scopeSettings.spectrum.showSideLine = true
|
||||
profile.scopeSettings.spectrum.heatmapSmoothing = 0.67
|
||||
profile.scopeSettings.spectrogram.colorScheme = 'mono'
|
||||
profile.scopeSettings.spectrogram.orientation = 'vertical'
|
||||
return profile
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ test('profile file serialization excludes geometry and round-trips with local me
|
||||
assert.equal(JSON.stringify(file).includes('inputGainDb'), false)
|
||||
assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true })
|
||||
assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67)
|
||||
assert.equal(file.scopeSettings.spectrogram.orientation, 'vertical')
|
||||
assert.equal(file.scopeOrder.includes('nowPlaying'), false)
|
||||
assert.equal(file.hiddenScopes.includes('nowPlaying'), true)
|
||||
assert.equal(file.widthWeights.nowPlaying, 1)
|
||||
@@ -75,9 +77,28 @@ test('profile file serialization excludes geometry and round-trips with local me
|
||||
assert.equal(restored.scopeSettings.spectrum.showSideLine, true)
|
||||
assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67)
|
||||
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
|
||||
assert.equal(restored.scopeSettings.spectrogram.orientation, 'vertical')
|
||||
assert.equal(restored.scopeSettings.nowPlaying.showControls, true)
|
||||
})
|
||||
|
||||
test('mergeScopeSettings defaults missing or invalid spectrogram orientation to horizontal', () => {
|
||||
const vertical = mergeScopeSettings({
|
||||
spectrogram: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
})
|
||||
const invalid = mergeScopeSettings({
|
||||
spectrogram: {
|
||||
orientation: 'diagonal',
|
||||
},
|
||||
})
|
||||
const missing = mergeScopeSettings({})
|
||||
|
||||
assert.equal(vertical.spectrogram.orientation, 'vertical')
|
||||
assert.equal(invalid.spectrogram.orientation, 'horizontal')
|
||||
assert.equal(missing.spectrogram.orientation, 'horizontal')
|
||||
})
|
||||
|
||||
test('mergeScopeSettings defaults missing or invalid VU needle channel settings to stereo', () => {
|
||||
const combined = mergeScopeSettings({
|
||||
vumeter: {
|
||||
|
||||
@@ -558,8 +558,8 @@ interface FakeCanvasRecorder {
|
||||
lineDash: number[]
|
||||
}>
|
||||
lineDashes: number[][]
|
||||
imageDataWrites: Array<{ x: number; y: number; data: number[] }>
|
||||
drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation }>
|
||||
imageDataWrites: Array<{ x: number; y: number; width: number; height: number; data: number[] }>
|
||||
drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation; args: unknown[] }>
|
||||
}
|
||||
|
||||
function createFakeCanvasRecorder(): FakeCanvasRecorder {
|
||||
@@ -601,11 +601,11 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise = false) {
|
||||
recorder?.arcs.push({ x, y, radius, startAngle, endAngle, anticlockwise, lineDash: [...currentLineDash] })
|
||||
},
|
||||
drawImage() {
|
||||
recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation })
|
||||
drawImage(...args: unknown[]) {
|
||||
recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation, args })
|
||||
},
|
||||
putImageData(imageData: ImageData, x: number, y: number) {
|
||||
recorder?.imageDataWrites.push({ x, y, data: Array.from(imageData.data) })
|
||||
recorder?.imageDataWrites.push({ x, y, width: imageData.width, height: imageData.height, data: Array.from(imageData.data) })
|
||||
},
|
||||
save() {},
|
||||
restore() {},
|
||||
@@ -973,12 +973,16 @@ function renderSpectrogramColumnImage(options: Partial<SpectrogramOptions>, valu
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpectrogramShift(options: Partial<SpectrogramOptions>, values: number[]): FakeCanvasRecorder {
|
||||
function renderSpectrogramShift(
|
||||
options: Partial<SpectrogramOptions>,
|
||||
values: number[],
|
||||
canvasSize: { width: number; height: number } = { width: 4, height: values.length },
|
||||
): FakeCanvasRecorder {
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||
const canvas = createFakeCanvas()
|
||||
canvas.width = 4
|
||||
canvas.height = values.length
|
||||
canvas.width = canvasSize.width
|
||||
canvas.height = canvasSize.height
|
||||
const dataSource = {
|
||||
getPendingSpectrogramSamples: () => [],
|
||||
getSampleRate: () => 48000,
|
||||
@@ -1700,6 +1704,73 @@ test('Spectrogram shifts existing columns with copy compositing to avoid transpa
|
||||
assert.equal(recorder.drawImageCalls.at(-1)?.compositeOperation, 'copy')
|
||||
})
|
||||
|
||||
test('Spectrogram vertical orientation paints newest frequency row at the bottom', () => {
|
||||
const recorder = renderSpectrogramShift({
|
||||
orientation: 'vertical',
|
||||
colorScheme: 'mono',
|
||||
lineColor: 'rgb(10, 20, 30)',
|
||||
}, [0, 0.5, 1], { width: 3, height: 5 })
|
||||
const write = recorder.imageDataWrites.at(-1)
|
||||
|
||||
assert.ok(write)
|
||||
assert.equal(write.x, 0)
|
||||
assert.equal(write.y, 4)
|
||||
assert.equal(write.width, 3)
|
||||
assert.equal(write.height, 1)
|
||||
assert.deepEqual(write.data.slice(0, 3), [10, 20, 30])
|
||||
assert.equal(write.data[3], 0)
|
||||
assert.deepEqual(write.data.slice(4, 7), [10, 20, 30])
|
||||
assert.equal(write.data[7], 128)
|
||||
assert.deepEqual(write.data.slice(8, 11), [10, 20, 30])
|
||||
assert.equal(write.data[11], 255)
|
||||
})
|
||||
|
||||
test('Spectrogram vertical orientation shifts existing rows upward with copy compositing', () => {
|
||||
const recorder = renderSpectrogramShift({
|
||||
orientation: 'vertical',
|
||||
}, [0.2, 0.6, 1], { width: 3, height: 5 })
|
||||
const drawCall = recorder.drawImageCalls.at(-1)
|
||||
|
||||
assert.equal(drawCall?.compositeOperation, 'copy')
|
||||
assert.equal(drawCall?.args[1], 0)
|
||||
assert.equal(drawCall?.args[2], -1)
|
||||
})
|
||||
|
||||
test('Spectrogram vertical orientation maps low frequencies to the left', () => {
|
||||
const dom = installFakeCanvasDom()
|
||||
const dataSource = {
|
||||
getPendingSpectrogramSamples: () => [],
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => false,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
const canvas = createFakeCanvas()
|
||||
canvas.width = 3
|
||||
canvas.height = 5
|
||||
const spectrogram = new Spectrogram(canvas, {
|
||||
dataSource,
|
||||
orientation: 'vertical',
|
||||
scaleMode: 'linear',
|
||||
minFrequency: 100,
|
||||
maxFrequency: 300,
|
||||
})
|
||||
|
||||
try {
|
||||
const state = spectrogram as unknown as {
|
||||
ensureBandMapping: () => void
|
||||
rowCenterBins: Float32Array
|
||||
}
|
||||
state.ensureBandMapping()
|
||||
|
||||
assert.equal(state.rowCenterBins.length, 3)
|
||||
assert.equal(state.rowCenterBins[0] < state.rowCenterBins[1], true)
|
||||
assert.equal(state.rowCenterBins[1] < state.rowCenterBins[2], true)
|
||||
} finally {
|
||||
spectrogram.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => {
|
||||
const dom = installFakeCanvasDom()
|
||||
const sampleRate = 48000
|
||||
@@ -2225,6 +2296,7 @@ test('Vectorscope keeps the original linear projection behavior', () => {
|
||||
|
||||
test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
profile.scopeSettings.spectrogram.orientation = 'vertical'
|
||||
profile.scopeSettings.lufsmeter.readout = 'shortTerm'
|
||||
profile.scopeSettings.vumeter.needleChannels = 'combined'
|
||||
const authoredTheme = createDefaultTheme()
|
||||
@@ -2239,6 +2311,7 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe
|
||||
|
||||
const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram)
|
||||
assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)')
|
||||
assert.equal(spectrogram.orientation, 'vertical')
|
||||
|
||||
const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter)
|
||||
assert.equal(vumeter.backgroundColor, 'rgb(6, 7, 8)')
|
||||
@@ -2378,6 +2451,15 @@ test('scopeSummary includes spectrum peak mode when enabled', () => {
|
||||
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow')
|
||||
})
|
||||
|
||||
test('scopeSummary includes spectrogram orientation', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
|
||||
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'HORIZONTAL · LOG · sharper')
|
||||
|
||||
profile.scopeSettings.spectrogram.orientation = 'vertical'
|
||||
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'VERTICAL · LOG · sharper')
|
||||
})
|
||||
|
||||
test('scopeSummary summarizes now playing field visibility', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
profile.scopeSettings.nowPlaying.showArtist = false
|
||||
|
||||
Reference in New Issue
Block a user