mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix alpha issues in spectrogram and spectrum
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import { resolveColorToRgb } from '../utils/color'
|
||||
import { parseColorToRgba, resolveColorToRgb } from '../utils/color'
|
||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||
import { FrameScheduler } from './frameScheduler'
|
||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
@@ -215,7 +215,7 @@ function getHannWindow(size: number): Float32Array {
|
||||
|
||||
type ColorStop = {
|
||||
at: number
|
||||
color: [number, number, number]
|
||||
color: [number, number, number, number]
|
||||
}
|
||||
|
||||
const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [
|
||||
@@ -226,35 +226,57 @@ const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [
|
||||
|
||||
function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean {
|
||||
return colors.every((color, index) => {
|
||||
const left = resolveColorToRgb(color)
|
||||
const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index])
|
||||
return left.r === right.r && left.g === right.g && left.b === right.b
|
||||
const left = parseColorToRgba(color)
|
||||
const right = parseColorToRgba(LEGACY_DEFAULT_HEAT_COLORS[index])
|
||||
return !!left
|
||||
&& !!right
|
||||
&& left.r === right.r
|
||||
&& left.g === right.g
|
||||
&& left.b === right.b
|
||||
&& Math.round(left.a * 255) === Math.round(right.a * 255)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveHeatColor(color: string, fallback: string): [number, number, number, number] {
|
||||
const parsed = parseColorToRgba(color) ?? parseColorToRgba(fallback)
|
||||
if (!parsed) {
|
||||
return [0, 0, 0, 255]
|
||||
}
|
||||
return [parsed.r, parsed.g, parsed.b, Math.round(parsed.a * 255)]
|
||||
}
|
||||
|
||||
function scaleHeatColor(color: [number, number, number, number], factor: number): [number, number, number, number] {
|
||||
return [
|
||||
Math.round(color[0] * factor),
|
||||
Math.round(color[1] * factor),
|
||||
Math.round(color[2] * factor),
|
||||
Math.round(color[3] * factor),
|
||||
]
|
||||
}
|
||||
|
||||
function buildHeatStops(colors: [string, string, string]): ColorStop[] {
|
||||
if (isLegacyDefaultHeatColors(colors)) {
|
||||
return [
|
||||
{ at: 0, color: [0, 0, 0] },
|
||||
{ at: 0.14, color: [15, 7, 33] },
|
||||
{ at: 0.32, color: [61, 11, 94] },
|
||||
{ at: 0.54, color: [163, 26, 121] },
|
||||
{ at: 0.74, color: [255, 82, 87] },
|
||||
{ at: 0.9, color: [255, 166, 63] },
|
||||
{ at: 1, color: [255, 241, 209] },
|
||||
{ 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: 1, color: [255, 241, 209, 255] },
|
||||
]
|
||||
}
|
||||
|
||||
const low = resolveColorToRgb(colors[0])
|
||||
const mid = resolveColorToRgb(colors[1])
|
||||
const high = resolveColorToRgb(colors[2])
|
||||
const low = resolveHeatColor(colors[0], LEGACY_DEFAULT_HEAT_COLORS[0])
|
||||
const mid = resolveHeatColor(colors[1], LEGACY_DEFAULT_HEAT_COLORS[1])
|
||||
const high = resolveHeatColor(colors[2], LEGACY_DEFAULT_HEAT_COLORS[2])
|
||||
|
||||
return [
|
||||
{ at: 0, color: [0, 0, 0] },
|
||||
{ at: 0.2, color: [Math.round(low.r * 0.5), Math.round(low.g * 0.5), Math.round(low.b * 0.5)] },
|
||||
{ at: 0.48, color: [low.r, low.g, low.b] },
|
||||
{ at: 0.76, color: [mid.r, mid.g, mid.b] },
|
||||
{ at: 1, color: [high.r, high.g, high.b] },
|
||||
{ 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: 1, color: high },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -262,9 +284,9 @@ function lerpChannel(start: number, end: number, amount: number): number {
|
||||
return Math.round(start + ((end - start) * amount))
|
||||
}
|
||||
|
||||
function buildHeatLUT(colors: [string, string, string]): Uint8Array {
|
||||
function buildHeatLUT(colors: [string, string, string]): Uint8ClampedArray {
|
||||
const heatStops = buildHeatStops(colors)
|
||||
const lut = new Uint8Array(256 * 3)
|
||||
const lut = new Uint8ClampedArray(256 * 4)
|
||||
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
const t = index / 255
|
||||
@@ -282,9 +304,10 @@ function buildHeatLUT(colors: [string, string, string]): Uint8Array {
|
||||
|
||||
const span = Math.max(1e-6, end.at - start.at)
|
||||
const amount = Math.max(0, Math.min(1, (t - start.at) / span))
|
||||
lut[index * 3] = lerpChannel(start.color[0], end.color[0], amount)
|
||||
lut[index * 3 + 1] = lerpChannel(start.color[1], end.color[1], amount)
|
||||
lut[index * 3 + 2] = lerpChannel(start.color[2], end.color[2], amount)
|
||||
lut[index * 4] = lerpChannel(start.color[0], end.color[0], amount)
|
||||
lut[index * 4 + 1] = lerpChannel(start.color[1], end.color[1], amount)
|
||||
lut[index * 4 + 2] = lerpChannel(start.color[2], end.color[2], amount)
|
||||
lut[index * 4 + 3] = lerpChannel(start.color[3], end.color[3], amount)
|
||||
}
|
||||
|
||||
return lut
|
||||
@@ -315,7 +338,7 @@ export class Spectrogram {
|
||||
private columnValues = new Float32Array(0)
|
||||
private rawColumnValues = new Float32Array(0)
|
||||
private columnImageData: ImageData | null = null
|
||||
private heatLut: Uint8Array
|
||||
private heatLut: Uint8ClampedArray
|
||||
|
||||
private lastWidth = 0
|
||||
private lastHeight = 0
|
||||
@@ -443,7 +466,10 @@ export class Spectrogram {
|
||||
this.paintColumnImage(values)
|
||||
|
||||
// Shift existing content left by 1 pixel
|
||||
const previousCompositeOperation = this.waterfallCtx.globalCompositeOperation
|
||||
this.waterfallCtx.globalCompositeOperation = 'copy'
|
||||
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)
|
||||
@@ -566,16 +592,16 @@ export class Spectrogram {
|
||||
const dataIndex = row * 4
|
||||
|
||||
if (this.options.colorScheme === 'heat') {
|
||||
imageData[dataIndex] = this.heatLut[lutIndex * 3]
|
||||
imageData[dataIndex + 1] = this.heatLut[(lutIndex * 3) + 1]
|
||||
imageData[dataIndex + 2] = this.heatLut[(lutIndex * 3) + 2]
|
||||
imageData[dataIndex] = this.heatLut[lutIndex * 4]
|
||||
imageData[dataIndex + 1] = this.heatLut[(lutIndex * 4) + 1]
|
||||
imageData[dataIndex + 2] = this.heatLut[(lutIndex * 4) + 2]
|
||||
imageData[dataIndex + 3] = Math.round(this.heatLut[(lutIndex * 4) + 3] * intensity)
|
||||
} else {
|
||||
imageData[dataIndex] = Math.round(tintR * intensity)
|
||||
imageData[dataIndex + 1] = Math.round(tintG * intensity)
|
||||
imageData[dataIndex + 2] = Math.round(tintB * intensity)
|
||||
imageData[dataIndex + 3] = 255
|
||||
}
|
||||
|
||||
imageData[dataIndex + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native'
|
||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||
import { FrameScheduler } from './frameScheduler'
|
||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
import { resolveColorToRgb } from '../utils/color'
|
||||
import { parseColorToRgba } from '../utils/color'
|
||||
import {
|
||||
DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE,
|
||||
DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
|
||||
@@ -65,7 +65,8 @@ type SpectrumRangePeak = {
|
||||
frequencyHz: number
|
||||
}
|
||||
|
||||
type HeatStop = { at: number; color: [number, number, number] }
|
||||
type HeatColor = [number, number, number, number]
|
||||
type HeatStop = { at: number; color: HeatColor }
|
||||
|
||||
const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [
|
||||
'rgb(15, 7, 33)',
|
||||
@@ -158,43 +159,69 @@ function fft(re: Float32Array, im: Float32Array): void {
|
||||
|
||||
function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean {
|
||||
return colors.every((color, index) => {
|
||||
const left = resolveColorToRgb(color)
|
||||
const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index])
|
||||
return left.r === right.r && left.g === right.g && left.b === right.b
|
||||
const left = parseColorToRgba(color)
|
||||
const right = parseColorToRgba(LEGACY_DEFAULT_HEAT_COLORS[index])
|
||||
return !!left
|
||||
&& !!right
|
||||
&& left.r === right.r
|
||||
&& left.g === right.g
|
||||
&& left.b === right.b
|
||||
&& Math.round(left.a * 255) === Math.round(right.a * 255)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveHeatColor(color: string, fallback: string): HeatColor {
|
||||
const parsed = parseColorToRgba(color) ?? parseColorToRgba(fallback)
|
||||
if (!parsed) {
|
||||
return [0, 0, 0, 255]
|
||||
}
|
||||
return [parsed.r, parsed.g, parsed.b, Math.round(parsed.a * 255)]
|
||||
}
|
||||
|
||||
function scaleHeatColor(color: HeatColor, factor: number): HeatColor {
|
||||
return [
|
||||
Math.round(color[0] * factor),
|
||||
Math.round(color[1] * factor),
|
||||
Math.round(color[2] * factor),
|
||||
Math.round(color[3] * factor),
|
||||
]
|
||||
}
|
||||
|
||||
function lerpChannel(start: number, end: number, amount: number): number {
|
||||
return Math.round(start + ((end - start) * amount))
|
||||
}
|
||||
|
||||
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] },
|
||||
{ at: 0.14, color: [15, 7, 33] },
|
||||
{ at: 0.32, color: [61, 11, 94] },
|
||||
{ at: 0.54, color: [163, 26, 121] },
|
||||
{ at: 0.74, color: [255, 82, 87] },
|
||||
{ at: 0.9, color: [255, 166, 63] },
|
||||
{ at: 1, color: [255, 241, 209] },
|
||||
{ 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: 1, color: [255, 241, 209, 255] },
|
||||
]
|
||||
}
|
||||
|
||||
const low = resolveColorToRgb(colors[0])
|
||||
const mid = resolveColorToRgb(colors[1])
|
||||
const high = resolveColorToRgb(colors[2])
|
||||
const low = resolveHeatColor(colors[0], LEGACY_DEFAULT_HEAT_COLORS[0])
|
||||
const mid = resolveHeatColor(colors[1], LEGACY_DEFAULT_HEAT_COLORS[1])
|
||||
const high = resolveHeatColor(colors[2], LEGACY_DEFAULT_HEAT_COLORS[2])
|
||||
|
||||
return [
|
||||
{ at: 0, color: [0, 0, 0] },
|
||||
{ at: 0.2, color: [Math.round(low.r * 0.5), Math.round(low.g * 0.5), Math.round(low.b * 0.5)] },
|
||||
{ at: 0.48, color: [low.r, low.g, low.b] },
|
||||
{ at: 0.76, color: [mid.r, mid.g, mid.b] },
|
||||
{ at: 1, color: [high.r, high.g, high.b] },
|
||||
{ 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: 1, color: high },
|
||||
]
|
||||
}
|
||||
|
||||
function buildHeatLUT(colors: [string, string, string]): Uint8Array {
|
||||
function buildHeatLUT(colors: [string, string, string]): Uint8ClampedArray {
|
||||
const heatStops = buildHeatStops(colors)
|
||||
const lut = new Uint8Array(256 * 3)
|
||||
const lut = new Uint8ClampedArray(256 * 4)
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
const t = i / 255
|
||||
let start = heatStops[0]
|
||||
@@ -208,9 +235,10 @@ function buildHeatLUT(colors: [string, string, string]): Uint8Array {
|
||||
}
|
||||
|
||||
const amount = Math.max(0, Math.min(1, (t - start.at) / Math.max(1e-6, end.at - start.at)))
|
||||
lut[i * 3] = Math.round(start.color[0] + (end.color[0] - start.color[0]) * amount)
|
||||
lut[i * 3 + 1] = Math.round(start.color[1] + (end.color[1] - start.color[1]) * amount)
|
||||
lut[i * 3 + 2] = Math.round(start.color[2] + (end.color[2] - start.color[2]) * amount)
|
||||
lut[i * 4] = lerpChannel(start.color[0], end.color[0], amount)
|
||||
lut[i * 4 + 1] = lerpChannel(start.color[1], end.color[1], amount)
|
||||
lut[i * 4 + 2] = lerpChannel(start.color[2], end.color[2], amount)
|
||||
lut[i * 4 + 3] = lerpChannel(start.color[3], end.color[3], amount)
|
||||
}
|
||||
return lut
|
||||
}
|
||||
@@ -258,7 +286,7 @@ export class SpectrumAnalyzer {
|
||||
private nativeInitialized = false
|
||||
private sampleRate = 48000
|
||||
private lastSampleRate = 0
|
||||
private heatLut: Uint8Array
|
||||
private heatLut: Uint8ClampedArray
|
||||
private staticLayerCanvas: HTMLCanvasElement
|
||||
private staticLayerCtx: CanvasRenderingContext2D
|
||||
private staticLayerKey = ''
|
||||
@@ -976,7 +1004,8 @@ export class SpectrumAnalyzer {
|
||||
|
||||
private renderHeatmap(xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number): void {
|
||||
const baseColor = this.options.heatBaseColor
|
||||
if (baseColor && baseColor !== 'transparent') {
|
||||
const parsedBaseColor = baseColor ? parseColorToRgba(baseColor) : null
|
||||
if (baseColor && baseColor !== 'transparent' && (!parsedBaseColor || parsedBaseColor.a > 0)) {
|
||||
this.ctx.fillStyle = baseColor
|
||||
this.ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
@@ -992,11 +1021,17 @@ export class SpectrumAnalyzer {
|
||||
}
|
||||
|
||||
const lutIndex = Math.round(heatmapIntensity[index] * 255)
|
||||
const r = this.heatLut[lutIndex * 3]
|
||||
const g = this.heatLut[lutIndex * 3 + 1]
|
||||
const b = this.heatLut[lutIndex * 3 + 2]
|
||||
const r = this.heatLut[lutIndex * 4]
|
||||
const g = this.heatLut[lutIndex * 4 + 1]
|
||||
const b = this.heatLut[lutIndex * 4 + 2]
|
||||
const a = Math.round((this.heatLut[lutIndex * 4 + 3] * heatmapIntensity[index]))
|
||||
if (a <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
|
||||
this.ctx.fillStyle = a >= 255
|
||||
? `rgb(${r}, ${g}, ${b})`
|
||||
: `rgba(${r}, ${g}, ${b}, ${Number((a / 255).toFixed(3))})`
|
||||
this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +971,8 @@ export function createTemplateThemeFile(): string {
|
||||
#
|
||||
# [Controls] and [Scopes] are shared override groups.
|
||||
# Module sections show the full set of supported tokens for each module.
|
||||
# Spectrum and Spectrogram heat token alpha is honored directly.
|
||||
# Leave Spectrum heat_base commented unless you want an explicit underlay beneath the heatmap.
|
||||
#
|
||||
# Comment out any optional token to let Prism inherit or derive it.
|
||||
# Leave an entire optional section commented if that area should use Prism's defaults.
|
||||
@@ -1196,7 +1198,7 @@ function resolveSpectrumTheme(
|
||||
section.heatMid ?? DEFAULT_HEAT_MID,
|
||||
section.heatHigh ?? DEFAULT_HEAT_HIGH,
|
||||
],
|
||||
heatBase: section.heatBase ?? background,
|
||||
heatBase: section.heatBase ?? 'transparent',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+249
-11
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_VISUALIZER_TINT,
|
||||
colorToRgbChannels,
|
||||
parseColorToRgb,
|
||||
parseColorToRgba,
|
||||
resolveColorToRgb,
|
||||
} from '../src/renderer/utils/color'
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter'
|
||||
import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope'
|
||||
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
|
||||
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
|
||||
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
|
||||
import {
|
||||
drawVectorscopeGridForMode,
|
||||
@@ -510,7 +512,7 @@ function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8):
|
||||
}
|
||||
|
||||
interface FakeCanvasRecorder {
|
||||
fillRects: Array<{ x: number; y: number; width: number; height: number }>
|
||||
fillRects: Array<{ x: number; y: number; width: number; height: number; fillStyle: string }>
|
||||
strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }>
|
||||
arcs: Array<{
|
||||
x: number
|
||||
@@ -522,6 +524,8 @@ interface FakeCanvasRecorder {
|
||||
lineDash: number[]
|
||||
}>
|
||||
lineDashes: number[][]
|
||||
imageDataWrites: Array<{ x: number; y: number; data: number[] }>
|
||||
drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation }>
|
||||
}
|
||||
|
||||
function createFakeCanvasRecorder(): FakeCanvasRecorder {
|
||||
@@ -530,16 +534,21 @@ function createFakeCanvasRecorder(): FakeCanvasRecorder {
|
||||
strokeRects: [],
|
||||
arcs: [],
|
||||
lineDashes: [],
|
||||
imageDataWrites: [],
|
||||
drawImageCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): CanvasRenderingContext2D {
|
||||
let currentLineDash: number[] = []
|
||||
let currentFillStyle = ''
|
||||
let currentStrokeStyle = ''
|
||||
let currentCompositeOperation: GlobalCompositeOperation = 'source-over'
|
||||
|
||||
return {
|
||||
const context = {
|
||||
clearRect() {},
|
||||
fillRect(x: number, y: number, width: number, height: number) {
|
||||
recorder?.fillRects.push({ x, y, width, height })
|
||||
recorder?.fillRects.push({ x, y, width, height, fillStyle: currentFillStyle })
|
||||
},
|
||||
fillText() {},
|
||||
beginPath() {},
|
||||
@@ -554,7 +563,12 @@ 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() {},
|
||||
drawImage() {
|
||||
recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation })
|
||||
},
|
||||
putImageData(imageData: ImageData, x: number, y: number) {
|
||||
recorder?.imageDataWrites.push({ x, y, data: Array.from(imageData.data) })
|
||||
},
|
||||
save() {},
|
||||
restore() {},
|
||||
translate() {},
|
||||
@@ -574,8 +588,6 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
measureText() {
|
||||
return { width: 0 } as TextMetrics
|
||||
},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
textAlign: 'left',
|
||||
@@ -583,8 +595,28 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter',
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
imageSmoothingEnabled: false,
|
||||
get fillStyle() {
|
||||
return currentFillStyle
|
||||
},
|
||||
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
||||
currentFillStyle = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
get strokeStyle() {
|
||||
return currentStrokeStyle
|
||||
},
|
||||
set strokeStyle(value: string | CanvasGradient | CanvasPattern) {
|
||||
currentStrokeStyle = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
get globalCompositeOperation() {
|
||||
return currentCompositeOperation
|
||||
},
|
||||
set globalCompositeOperation(value: GlobalCompositeOperation) {
|
||||
currentCompositeOperation = value
|
||||
},
|
||||
}
|
||||
|
||||
return context as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createFakeCanvas(recorder: FakeCanvasRecorder | null = null): HTMLCanvasElement {
|
||||
@@ -596,12 +628,17 @@ function createFakeCanvas(recorder: FakeCanvasRecorder | null = null): HTMLCanva
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function installFakeCanvasDom(): {
|
||||
function installFakeCanvasDom(createCanvas: () => HTMLCanvasElement = () => createFakeCanvas()): {
|
||||
restore: () => void
|
||||
} {
|
||||
const globalWithDom = globalThis as typeof globalThis & { window?: Window; document?: Document }
|
||||
const globalWithDom = globalThis as typeof globalThis & {
|
||||
window?: Window
|
||||
document?: Document
|
||||
ImageData?: typeof ImageData
|
||||
}
|
||||
const previousWindow = globalWithDom.window
|
||||
const previousDocument = globalWithDom.document
|
||||
const previousImageData = globalWithDom.ImageData
|
||||
|
||||
globalWithDom.window = {
|
||||
...(previousWindow ?? globalThis),
|
||||
@@ -613,10 +650,26 @@ function installFakeCanvasDom(): {
|
||||
if (tagName !== 'canvas') {
|
||||
throw new Error(`Unsupported element in test DOM: ${tagName}`)
|
||||
}
|
||||
return createFakeCanvas()
|
||||
return createCanvas()
|
||||
},
|
||||
} as Document
|
||||
|
||||
if (globalWithDom.ImageData === undefined) {
|
||||
class FakeImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.data = new Uint8ClampedArray(width * height * 4)
|
||||
}
|
||||
}
|
||||
|
||||
globalWithDom.ImageData = FakeImageData as unknown as typeof ImageData
|
||||
}
|
||||
|
||||
return {
|
||||
restore(): void {
|
||||
if (previousWindow === undefined) {
|
||||
@@ -630,6 +683,12 @@ function installFakeCanvasDom(): {
|
||||
} else {
|
||||
globalWithDom.document = previousDocument
|
||||
}
|
||||
|
||||
if (previousImageData === undefined) {
|
||||
delete globalWithDom.ImageData
|
||||
} else {
|
||||
globalWithDom.ImageData = previousImageData
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -717,6 +776,119 @@ function renderSpectrumSnapshot(options: Partial<SpectrumAnalyzerOptions>): {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpectrumHeatmap(options: Partial<SpectrumAnalyzerOptions>, heatmapIntensity: number[]): FakeCanvasRecorder {
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const dom = installFakeCanvasDom()
|
||||
const canvas = createFakeCanvas(recorder)
|
||||
canvas.width = heatmapIntensity.length
|
||||
canvas.height = 24
|
||||
const dataSource = {
|
||||
getPendingSpectrumSamples: () => [],
|
||||
getPendingSpectrumStereoSamples: () => [],
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => false,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
|
||||
const analyzer = new SpectrumAnalyzer(canvas, {
|
||||
showSideLine: true,
|
||||
heatmapFill: true,
|
||||
fillGradient: false,
|
||||
showGrid: false,
|
||||
dataSource,
|
||||
...options,
|
||||
})
|
||||
|
||||
try {
|
||||
const state = analyzer as unknown as {
|
||||
renderHeatmap: (
|
||||
xPoints: Float32Array,
|
||||
yPoints: Float32Array,
|
||||
heatmapIntensity: Float32Array,
|
||||
pointCount: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => void
|
||||
}
|
||||
state.renderHeatmap(
|
||||
Float32Array.from(heatmapIntensity.map((_value, index) => index)),
|
||||
Float32Array.from(heatmapIntensity.map(() => 0)),
|
||||
Float32Array.from(heatmapIntensity),
|
||||
heatmapIntensity.length,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
)
|
||||
return recorder
|
||||
} finally {
|
||||
analyzer.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpectrogramColumnImage(options: Partial<SpectrogramOptions>, values: number[]): number[] {
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||
const canvas = createFakeCanvas()
|
||||
canvas.width = 1
|
||||
canvas.height = values.length
|
||||
const dataSource = {
|
||||
getPendingSpectrogramSamples: () => [],
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => false,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
|
||||
const spectrogram = new Spectrogram(canvas, {
|
||||
dataSource,
|
||||
...options,
|
||||
})
|
||||
|
||||
try {
|
||||
const state = spectrogram as unknown as {
|
||||
ensureColumnBuffers: (height: number) => void
|
||||
shiftAndPaintColumn: (values: Float32Array) => void
|
||||
}
|
||||
state.ensureColumnBuffers(values.length)
|
||||
state.shiftAndPaintColumn(Float32Array.from(values))
|
||||
return recorder.imageDataWrites.at(-1)?.data ?? []
|
||||
} finally {
|
||||
spectrogram.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpectrogramShift(options: Partial<SpectrogramOptions>, values: number[]): FakeCanvasRecorder {
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
|
||||
const canvas = createFakeCanvas()
|
||||
canvas.width = 4
|
||||
canvas.height = values.length
|
||||
const dataSource = {
|
||||
getPendingSpectrogramSamples: () => [],
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => false,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
|
||||
const spectrogram = new Spectrogram(canvas, {
|
||||
dataSource,
|
||||
...options,
|
||||
})
|
||||
|
||||
try {
|
||||
const state = spectrogram as unknown as {
|
||||
ensureColumnBuffers: (height: number) => void
|
||||
shiftAndPaintColumn: (values: Float32Array) => void
|
||||
}
|
||||
state.ensureColumnBuffers(values.length)
|
||||
state.shiftAndPaintColumn(Float32Array.from(values))
|
||||
return recorder
|
||||
} finally {
|
||||
spectrogram.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
}
|
||||
|
||||
test('parseColorToRgb handles hex, rgb, rgba, and percentage formats', () => {
|
||||
assert.deepEqual(parseColorToRgb('#38bdf8'), { r: 56, g: 189, b: 248 })
|
||||
assert.deepEqual(parseColorToRgb('#3bf'), { r: 51, g: 187, b: 255 })
|
||||
@@ -1109,6 +1281,72 @@ 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 heatmap does not repaint the full viewport when heat base is omitted', () => {
|
||||
const recorder = renderSpectrumHeatmap({}, [0.24, 0.62, 1])
|
||||
|
||||
assert.equal(
|
||||
recorder.fillRects.some((rect) => rect.x === 0 && rect.y === 0 && rect.width === 3 && rect.height === 24),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('SpectrumAnalyzer heatmap honors authored token alpha for low-end heat colors', () => {
|
||||
const recorder = renderSpectrumHeatmap({
|
||||
heatColors: [
|
||||
'rgba(15, 7, 33, 0)',
|
||||
'rgba(163, 26, 121, 0.6)',
|
||||
'rgb(255, 241, 209)',
|
||||
],
|
||||
}, [0.48, 0.62, 1])
|
||||
|
||||
const renderedAlpha = recorder.fillRects
|
||||
.map((rect) => parseColorToRgba(rect.fillStyle)?.a ?? null)
|
||||
.filter((value): value is number => value !== null)
|
||||
|
||||
assert.equal(renderedAlpha.some((value) => value > 0 && value < 1), true)
|
||||
assert.equal(renderedAlpha.some((value) => Math.abs(value - 1) < 1e-3), true)
|
||||
})
|
||||
|
||||
test('Spectrogram heatmap preserves authored alpha in generated image data', () => {
|
||||
const imageData = renderSpectrogramColumnImage({
|
||||
heatColors: [
|
||||
'rgba(15, 7, 33, 0)',
|
||||
'rgba(163, 26, 121, 0.5)',
|
||||
'rgb(255, 241, 209)',
|
||||
],
|
||||
}, [0, 0.62, 1])
|
||||
|
||||
assert.equal(imageData[3], 0)
|
||||
assert.equal(imageData[7] > 0 && imageData[7] < 255, true)
|
||||
assert.equal(imageData[11], 255)
|
||||
})
|
||||
|
||||
test('Spectrogram low-intensity heat stays mostly transparent with default RGB heat colors', () => {
|
||||
const imageData = renderSpectrogramColumnImage({
|
||||
heatColors: [
|
||||
'rgb(15, 7, 33)',
|
||||
'rgb(163, 26, 121)',
|
||||
'rgb(255, 241, 209)',
|
||||
],
|
||||
}, [0, 0.1, 1])
|
||||
|
||||
assert.equal(imageData[3], 0)
|
||||
assert.equal(imageData[7] > 0 && imageData[7] < 40, true)
|
||||
assert.equal(imageData[11], 255)
|
||||
})
|
||||
|
||||
test('Spectrogram shifts existing columns with copy compositing to avoid transparent streaking', () => {
|
||||
const recorder = renderSpectrogramShift({
|
||||
heatColors: [
|
||||
'rgba(15, 7, 33, 0)',
|
||||
'rgba(163, 26, 121, 0.5)',
|
||||
'rgb(255, 241, 209)',
|
||||
],
|
||||
}, [0.2, 0.6, 1])
|
||||
|
||||
assert.equal(recorder.drawImageCalls.at(-1)?.compositeOperation, 'copy')
|
||||
})
|
||||
|
||||
test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => {
|
||||
const dom = installFakeCanvasDom()
|
||||
const sampleRate = 48000
|
||||
|
||||
@@ -116,6 +116,41 @@ flat_controls = true
|
||||
assert.equal(resolved.interface.glassHighlightStrong, 'transparent')
|
||||
})
|
||||
|
||||
test('resolveTheme preserves alpha-bearing heat tokens and defaults spectrum heat base to transparent', () => {
|
||||
const parsed = parseThemeFileContent(`
|
||||
[Theme]
|
||||
format = prism-theme
|
||||
version = 2
|
||||
|
||||
[Spectrum]
|
||||
heat_low = 10, 20, 30, 0
|
||||
heat_mid = 40, 50, 60, 64
|
||||
heat_high = 70, 80, 90, 204
|
||||
heat_base = 1, 2, 3, 32
|
||||
|
||||
[Spectrogram]
|
||||
heat_low = 11, 21, 31, 16
|
||||
heat_mid = 41, 51, 61, 96
|
||||
heat_high = 71, 81, 91, 255
|
||||
`, 'Alpha Heat')
|
||||
|
||||
const resolved = resolveTheme(parsed)
|
||||
const defaultResolved = resolveTheme(createDefaultTheme())
|
||||
|
||||
assert.deepEqual(resolved.spectrum.heatColors, [
|
||||
'rgba(10, 20, 30, 0)',
|
||||
'rgba(40, 50, 60, 0.251)',
|
||||
'rgba(70, 80, 90, 0.8)',
|
||||
])
|
||||
assert.equal(resolved.spectrum.heatBase, 'rgba(1, 2, 3, 0.125)')
|
||||
assert.deepEqual(resolved.spectrogram.heatColors, [
|
||||
'rgba(11, 21, 31, 0.063)',
|
||||
'rgba(41, 51, 61, 0.376)',
|
||||
'rgb(71, 81, 91)',
|
||||
])
|
||||
assert.equal(defaultResolved.spectrum.heatBase, 'transparent')
|
||||
})
|
||||
|
||||
test('parseThemeFileContent derives the theme name from the filename stem and ignores legacy header names', () => {
|
||||
const parsed = parseThemeFileContent(`
|
||||
[Theme]
|
||||
@@ -210,6 +245,8 @@ test('createTemplateThemeFile presents a simplified recommended theme layout', (
|
||||
assert.match(template, /# Uncomment the tokens you want to customize and leave the rest commented to inherit defaults\./)
|
||||
assert.match(template, /# \[Controls\] and \[Scopes\] are shared override groups\./)
|
||||
assert.match(template, /# Module sections show the full set of supported tokens for each module\./)
|
||||
assert.match(template, /# Spectrum and Spectrogram heat token alpha is honored directly\./)
|
||||
assert.match(template, /# Leave Spectrum heat_base commented unless you want an explicit underlay beneath the heatmap\./)
|
||||
assert.match(template, /# Comment out any optional token to let Prism inherit or derive it\./)
|
||||
assert.match(template, /# Leave an entire optional section commented if that area should use Prism's defaults\./)
|
||||
assert.match(template, /# Optional palette extras:/)
|
||||
|
||||
Reference in New Issue
Block a user