diff --git a/package-lock.json b/package-lock.json index 970eb1f..acbde21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.1.0", + "version": "0.1.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.1.0", + "version": "0.1.0-beta", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index b62ad23..a010853 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.1.0", + "version": "0.1.0-beta", "description": "Open-source audio metering and visualization tool", "main": "./out/main/index.js", "scripts": { diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 69e7bcf..dac0f28 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -13,6 +13,11 @@ import { type SpectrogramClarityMode, type SpectrogramScaleMode, } from '../../types/spectrogram' +import { + HEAT_LOW_DB, + HEAT_MID_DB, + normalizeHeatDb, +} from './heatScale' export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] @@ -43,6 +48,8 @@ interface SpectrogramClarityProfile { tiltDb: number // dB/octave frequency compensation } +const SPECTROGRAM_HEAT_GAIN_COMPENSATION_DB = 6 + const defaultOptions: ResolvedSpectrogramOptions = { fftSize: 4096, minFrequency: 20, @@ -258,11 +265,11 @@ function buildHeatStops(colors: [string, string, string]): ColorStop[] { if (isLegacyDefaultHeatColors(colors)) { return [ { at: 0, color: [0, 0, 0, 0] }, - { at: 0.14, color: [15, 7, 33, 255] }, - { at: 0.32, color: [61, 11, 94, 255] }, - { at: 0.54, color: [163, 26, 121, 255] }, - { at: 0.74, color: [255, 82, 87, 255] }, - { at: 0.9, color: [255, 166, 63, 255] }, + { at: normalizeHeatDb(-80), color: [15, 7, 33, 255] }, + { at: normalizeHeatDb(-70), color: [61, 11, 94, 255] }, + { at: normalizeHeatDb(-60), color: [163, 26, 121, 255] }, + { at: normalizeHeatDb(-45), color: [255, 82, 87, 255] }, + { at: normalizeHeatDb(-35), color: [255, 166, 63, 255] }, { at: 1, color: [255, 241, 209, 255] }, ] } @@ -273,9 +280,9 @@ function buildHeatStops(colors: [string, string, string]): ColorStop[] { return [ { at: 0, color: [0, 0, 0, 0] }, - { at: 0.2, color: scaleHeatColor(low, 0.5) }, - { at: 0.48, color: low }, - { at: 0.76, color: mid }, + { at: normalizeHeatDb(-90), color: scaleHeatColor(low, 0.5) }, + { at: normalizeHeatDb(HEAT_LOW_DB), color: low }, + { at: normalizeHeatDb(HEAT_MID_DB), color: mid }, { at: 1, color: high }, ] } @@ -337,6 +344,7 @@ export class Spectrogram { private rowBandEndBins = new Float32Array(0) private columnValues = new Float32Array(0) private rawColumnValues = new Float32Array(0) + private heatColumnValues = new Float32Array(0) private columnImageData: ImageData | null = null private heatLut: Uint8ClampedArray @@ -455,15 +463,16 @@ export class Spectrogram { this.columnValues = new Float32Array(height) this.rawColumnValues = new Float32Array(height) + this.heatColumnValues = new Float32Array(height) this.columnImageData = new ImageData(1, height) } - private shiftAndPaintColumn(values: Float32Array): void { + private shiftAndPaintColumn(values: Float32Array, heatValues: Float32Array = values): void { const width = this.waterfallCanvas.width const height = this.waterfallCanvas.height if (width <= 0 || height <= 0 || !this.columnImageData) return - this.paintColumnImage(values) + this.paintColumnImage(values, heatValues) // Shift existing content left by 1 pixel const previousCompositeOperation = this.waterfallCtx.globalCompositeOperation @@ -578,7 +587,7 @@ export class Spectrogram { return magnitudes } - private paintColumnImage(values: Float32Array): void { + private paintColumnImage(values: Float32Array, heatValues: Float32Array = values): void { if (!this.columnImageData) return const imageData = this.columnImageData.data @@ -588,7 +597,8 @@ export class Spectrogram { for (let row = 0; row < values.length; row += 1) { const intensity = Math.max(0, Math.min(1, values[row])) - const lutIndex = Math.round(intensity * 255) + const heatIntensity = Math.max(0, Math.min(1, heatValues[row] ?? intensity)) + const lutIndex = Math.round(heatIntensity * 255) const dataIndex = row * 4 if (this.options.colorScheme === 'heat') { @@ -612,6 +622,7 @@ export class Spectrogram { this.ensureColumnBuffers(height) const values = this.columnValues const raw = this.rawColumnValues + const heat = this.heatColumnValues const numBins = magnitudes.length const clarity = getClarityProfile(this.options.clarityMode) @@ -636,7 +647,9 @@ export class Spectrogram { // Frequency-based tilt — dB per octave from reference, scale-mode independent const centerFreq = Math.max(1, centerBin * binWidth) const tiltAmount = clarity.tiltDb * Math.log2(centerFreq / TILT_REFERENCE_HZ) - raw[row] = clamp01(((db + tiltAmount) - minDecibels) / dbRange) + const tiltedDb = db + tiltAmount + raw[row] = clamp01((tiltedDb - minDecibels) / dbRange) + heat[row] = normalizeHeatDb(tiltedDb + SPECTROGRAM_HEAT_GAIN_COMPENSATION_DB) } // Pass 2: local peak suppression — thin spectral lines for sharp/sharper modes @@ -669,7 +682,9 @@ export class Spectrogram { // Suppress off-peak values: peak stays bright, slopes get crushed if (localMax > 1e-6) { const ratio = raw[row] / localMax - raw[row] *= Math.pow(ratio, effectiveSharpness) + const suppression = Math.pow(ratio, effectiveSharpness) + raw[row] *= suppression + heat[row] *= suppression } } } @@ -752,7 +767,7 @@ export class Spectrogram { const magnitudes = this.processFFT(this.sampleBuffer) const values = this.drawColumn(magnitudes) // Each FFT hop = exactly 1 pixel column. No accumulation, no duplication. - this.shiftAndPaintColumn(values) + this.shiftAndPaintColumn(values, this.heatColumnValues) this.sampleBuffer.copyWithin(0, hopSize) this.sampleBufferPos = overlapSamples diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index c8dc23b..2e7d320 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -13,6 +13,11 @@ import { resolveSpectrumPitchInfo, type SpectrumPeakInfo, } from '../../types/spectrum' +import { + HEAT_LOW_DB, + HEAT_MID_DB, + normalizeHeatDb, +} from './heatScale' type SpectrumStereoChunk = { left: Float32Array @@ -193,15 +198,13 @@ function lerpChannel(start: number, end: number, amount: number): number { function buildHeatStops(colors: [string, string, string]): HeatStop[] { if (isLegacyDefaultHeatColors(colors)) { - // Preserve Prism's original default spectrum heatmap instead of flattening it - // into the generic themed stop builder. return [ { at: 0, color: [0, 0, 0, 0] }, - { at: 0.14, color: [15, 7, 33, 255] }, - { at: 0.32, color: [61, 11, 94, 255] }, - { at: 0.54, color: [163, 26, 121, 255] }, - { at: 0.74, color: [255, 82, 87, 255] }, - { at: 0.9, color: [255, 166, 63, 255] }, + { at: normalizeHeatDb(-80), color: [15, 7, 33, 255] }, + { at: normalizeHeatDb(-70), color: [61, 11, 94, 255] }, + { at: normalizeHeatDb(-60), color: [163, 26, 121, 255] }, + { at: normalizeHeatDb(-45), color: [255, 82, 87, 255] }, + { at: normalizeHeatDb(-35), color: [255, 166, 63, 255] }, { at: 1, color: [255, 241, 209, 255] }, ] } @@ -212,9 +215,9 @@ function buildHeatStops(colors: [string, string, string]): HeatStop[] { return [ { at: 0, color: [0, 0, 0, 0] }, - { at: 0.2, color: scaleHeatColor(low, 0.5) }, - { at: 0.48, color: low }, - { at: 0.76, color: mid }, + { at: normalizeHeatDb(-90), color: scaleHeatColor(low, 0.5) }, + { at: normalizeHeatDb(HEAT_LOW_DB), color: low }, + { at: normalizeHeatDb(HEAT_MID_DB), color: mid }, { at: 1, color: high }, ] } @@ -822,7 +825,7 @@ export class SpectrumAnalyzer { xOut[index] = x yOut[index] = height - clampedNormalized * height if (heatmapIntensityOut) { - heatmapIntensityOut[index] = Math.pow(clampedNormalized, HEATMAP_GAMMA) + heatmapIntensityOut[index] = Math.pow(normalizeHeatDb(db), HEATMAP_GAMMA) } if (capturePeakInfo) { diff --git a/src/renderer/visualizers/heatScale.ts b/src/renderer/visualizers/heatScale.ts new file mode 100644 index 0000000..db73c02 --- /dev/null +++ b/src/renderer/visualizers/heatScale.ts @@ -0,0 +1,13 @@ +export const HEAT_MIN_DB = -100 +export const HEAT_LOW_DB = -80 +export const HEAT_MID_DB = -60 +export const HEAT_MAX_DB = -20 + +const HEAT_DB_RANGE = HEAT_MAX_DB - HEAT_MIN_DB + +export function normalizeHeatDb(db: number): number { + if (!Number.isFinite(db)) { + return 0 + } + return Math.max(0, Math.min(1, (db - HEAT_MIN_DB) / HEAT_DB_RANGE)) +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 7121d49..85abf04 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -67,6 +67,13 @@ import { NativeVisualizerTransport, type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' +import { + HEAT_LOW_DB, + HEAT_MAX_DB, + HEAT_MID_DB, + HEAT_MIN_DB, + normalizeHeatDb, +} from '../src/renderer/visualizers/heatScale' import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' @@ -838,6 +845,69 @@ function renderSpectrumHeatmap( } } +function projectSpectrumDb(options: Partial, db: number): { + heatmapIntensity: number[] + yPoints: number[] +} { + const dom = installFakeCanvasDom() + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + dataSource, + ...options, + }) + + try { + const state = analyzer as unknown as { + fillSpectrumPoints: ( + frequencyData: Float32Array, + dataLength: number, + width: number, + height: number, + minFrequency: number, + maxFrequency: number, + nyquist: number, + tiltDbPerOctave: number, + xOut: Float32Array, + yOut: Float32Array, + heatmapIntensityOut: Float32Array | null, + ) => { pointCount: number } + } + const pointCount = 2 + const xOut = new Float32Array(pointCount) + const yOut = new Float32Array(pointCount) + const heatmapIntensity = new Float32Array(pointCount) + const result = state.fillSpectrumPoints( + Float32Array.from([db, db, db, db]), + 4, + pointCount, + 100, + 20, + 40, + 2000, + 0, + xOut, + yOut, + heatmapIntensity, + ) + + return { + heatmapIntensity: Array.from(heatmapIntensity.subarray(0, result.pointCount)), + yPoints: Array.from(yOut.subarray(0, result.pointCount)), + } + } finally { + analyzer.dispose() + dom.restore() + } +} + function renderSpectrogramColumnImage(options: Partial, values: number[]): number[] { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) @@ -1213,6 +1283,42 @@ test('spectrum pitch helpers format nearest note with octave and cents', () => { assert.equal(formatSpectrumPitchInfo(resolveSpectrumPitchInfo(Number.NaN)), '--') }) +test('normalizeHeatDb uses the shared hybrid heat window', () => { + assert.equal(normalizeHeatDb(HEAT_MIN_DB), 0) + assert.equal(normalizeHeatDb(-60), 0.5) + assert.equal(normalizeHeatDb(HEAT_MAX_DB), 1) + assert.equal(normalizeHeatDb(-140), 0) + assert.equal(normalizeHeatDb(12), 1) + assert.equal(normalizeHeatDb(Number.NaN), 0) +}) + +test('SpectrumAnalyzer heat intensity uses shared heat scale without changing line geometry', () => { + const referenceDb = -60 + const defaultLine = projectSpectrumDb({ + minDecibels: -90, + maxDecibels: -10, + }, referenceDb) + const expandedLine = projectSpectrumDb({ + minDecibels: -120, + maxDecibels: 0, + }, referenceDb) + const expectedHeat = Math.pow(normalizeHeatDb(referenceDb), 1.4) + + assertAlmostEqual(defaultLine.heatmapIntensity[0], expectedHeat, 1e-6, 'heat intensity should use shared heat dB normalization') + assertArraysAlmostEqual( + defaultLine.heatmapIntensity, + expandedLine.heatmapIntensity, + 1e-6, + 'heat intensity should ignore spectrum line min/max dB options', + ) + assertArraysDiffer( + defaultLine.yPoints, + expandedLine.yPoints, + 1e-6, + 'line geometry should still respond to spectrum line min/max dB options', + ) +}) + test('SpectrumAnalyzer heatmap output does not change when only line smoothing changes', () => { const looseLine = renderSpectrumSnapshot({ smoothing: 0, @@ -1360,6 +1466,97 @@ test('Spectrogram heatmap preserves authored alpha in generated image data', () assert.equal(imageData[11], 255) }) +test('Spectrogram keeps the historical display dB range for line thickness', () => { + const dom = installFakeCanvasDom() + const dataSource = { + getPendingSpectrogramSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const spectrogram = new Spectrogram(createFakeCanvas(), { dataSource }) + + try { + const state = spectrogram as unknown as { + options: { + minDecibels: number + maxDecibels: number + } + } + assert.equal(state.options.minDecibels, -90) + assert.equal(state.options.maxDecibels, -12) + } finally { + spectrogram.dispose() + dom.restore() + } +}) + +test('Spectrogram heat compensation does not change display intensity range', () => { + const dom = installFakeCanvasDom() + const dataSource = { + getPendingSpectrogramSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const canvas = createFakeCanvas() + canvas.width = 1 + canvas.height = 1 + const spectrogram = new Spectrogram(canvas, { + dataSource, + clarityMode: 'classic', + minDecibels: -90, + maxDecibels: -12, + }) + + try { + const state = spectrogram as unknown as { + drawColumn: (magnitudes: Float32Array) => Float32Array + heatColumnValues: Float32Array + rowCenterBins: Float32Array + rowBandStartBins: Float32Array + rowBandEndBins: Float32Array + } + state.rowCenterBins = Float32Array.from([1]) + state.rowBandStartBins = Float32Array.from([0.5]) + state.rowBandEndBins = Float32Array.from([1.5]) + + const magnitudes = new Float32Array(24) + magnitudes.fill(-120) + magnitudes[1] = -66 + const values = state.drawColumn(magnitudes) + const expectedDisplay = Math.pow((-66 - (-90)) / (-12 - (-90)), 1.4) + const expectedHeat = normalizeHeatDb(-60) + + assertAlmostEqual(values[0], expectedDisplay, 1e-6, 'display intensity should keep the historical spectrogram range') + assertAlmostEqual(state.heatColumnValues[0], expectedHeat, 1e-6, 'heat color should use compensated dB against the shared heat range') + } finally { + spectrogram.dispose() + dom.restore() + } +}) + +test('Spectrogram custom heat colors land on shared low mid and high thresholds', () => { + const imageData = renderSpectrogramColumnImage({ + heatColors: [ + 'rgb(10, 20, 30)', + 'rgb(40, 50, 60)', + 'rgb(70, 80, 90)', + ], + }, [ + normalizeHeatDb(HEAT_LOW_DB), + normalizeHeatDb(HEAT_MID_DB), + normalizeHeatDb(HEAT_MAX_DB), + ]) + + assert.deepEqual(imageData.slice(0, 3), [10, 20, 30]) + assert.deepEqual(imageData.slice(4, 7), [40, 50, 60]) + assert.deepEqual(imageData.slice(8, 11), [70, 80, 90]) + assert.equal(imageData[3] > 0 && imageData[3] < imageData[7], true) + assert.equal(imageData[7] > imageData[3] && imageData[7] < imageData[11], true) + assert.equal(imageData[11], 255) +}) + test('Spectrogram low-intensity heat stays mostly transparent with default RGB heat colors', () => { const imageData = renderSpectrogramColumnImage({ heatColors: [