diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 4063985..69e7bcf 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -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 } } diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index e8c0b4a..24c4133 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -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)) } } diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index c39b159..2f3acc5 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -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', } } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index d7ddcfd..82f95a3 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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): { } } +function renderSpectrumHeatmap(options: Partial, 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, 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, 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 diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index 149868d..b0c490c 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -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:/)