mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix heatmap volume scale
This commit is contained in:
Generated
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<SpectrumAnalyzerOptions>, 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<SpectrogramOptions>, 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: [
|
||||
|
||||
Reference in New Issue
Block a user