diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 67cf657..5728a85 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -251,6 +251,7 @@ export function scopeSettingsToOptions( contrast: s.contrast, clarityMode: s.clarityMode, scaleMode: s.scaleMode, + orientation: s.orientation, colorScheme: s.colorScheme, } } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index b4372e8..8e1ae7d 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -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({ + onUpdate('spectrogram', { orientation: value as ScopeSettings['spectrogram']['orientation'] })} + > + + + + ): ResolvedSpectrogramOptions { return { fftSize: typeof overrides.fftSize === 'number' ? overrides.fftSize : base.fftSize, @@ -111,6 +120,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial 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) diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 2a8612c..75c7eb8 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -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 = typeof parsed.spectrum === 'object' && parsed.spectrum !== null ? parsed.spectrum : {} + const rawSpectrogram: Partial = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null + ? parsed.spectrogram + : {} const rawWaveform: Partial = 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, diff --git a/src/types/settings.ts b/src/types/settings.ts index 643af6b..a8db46b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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 }, diff --git a/src/types/spectrogram.ts b/src/types/spectrogram.ts index f665c14..688b818 100644 --- a/src/types/spectrogram.ts +++ b/src/types/spectrogram.ts @@ -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)) { diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 463ff05..5cf535c 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -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: { diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 2414878..b0cf968 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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, valu } } -function renderSpectrogramShift(options: Partial, values: number[]): FakeCanvasRecorder { +function renderSpectrogramShift( + options: Partial, + 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