Skip to content

Commit 86c13b0

Browse files
feat(spectrogram): improve reassigned spectrogram power accumulation
- render reassigned points into an offscreen power accum target then resolve to color in a second pass. - avoids blending already colored splats and makes overlapping reassigned energy combine correctly before dB shading. - improved fidelity and readability Signed-off-by: Maika Namuo <httpworldview@gmail.com>
1 parent 3893f09 commit 86c13b0

3 files changed

Lines changed: 280 additions & 67 deletions

File tree

‎src/visuals/render/common.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,8 @@ pub(in crate::visuals) struct RenderPipelineSpec<'a> {
495495
pub(in crate::visuals) buffers: &'a [wgpu::VertexBufferLayout<'a>],
496496
pub(in crate::visuals) bind_group_layouts: &'a [&'a wgpu::BindGroupLayout],
497497
pub(in crate::visuals) topology: wgpu::PrimitiveTopology,
498+
pub(in crate::visuals) blend: Option<wgpu::BlendState>,
499+
pub(in crate::visuals) write_mask: wgpu::ColorWrites,
498500
}
499501

500502
pub(in crate::visuals) fn create_render_pipeline(
@@ -522,8 +524,8 @@ pub(in crate::visuals) fn create_render_pipeline(
522524
entry_point: Some(spec.fragment_entry),
523525
targets: &[Some(wgpu::ColorTargetState {
524526
format,
525-
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
526-
write_mask: wgpu::ColorWrites::ALL,
527+
blend: spec.blend,
528+
write_mask: spec.write_mask,
527529
})],
528530
compilation_options: wgpu::PipelineCompilationOptions::default(),
529531
}),
@@ -556,6 +558,8 @@ fn create_sdf_pipeline(
556558
buffers: &[SdfVertex::layout()],
557559
bind_group_layouts: &[],
558560
topology,
561+
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
562+
write_mask: wgpu::ColorWrites::ALL,
559563
},
560564
)
561565
}

‎src/visuals/render/shaders/spectrogram.wgsl‎

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
const LOG10_E: f32 = 0.4342944819;
2+
const LN_TO_DB: f32 = 4.342944819;
3+
const DB_TO_LOG2: f32 = 0.3321928095;
4+
// Rg16Float cannot hold -140 dB power in one channel; G is a scaled fallback.
5+
const LOW_POWER_SCALE: f32 = 16777216.0;
6+
const INV_LOW_POWER_SCALE: f32 = 0.000000059604644775390625;
7+
const F16_MAX: f32 = 65504.0;
28
const LOG_KNEE_HZ: f32 = 20.0;
39

410
// Classic storage domain -- keep in sync with processor.rs CLASSIC_DB_STORE_*.
@@ -36,10 +42,9 @@ struct Uniforms {
3642
col_stride_u16: u32,
3743
// FFT bin spacing (sample_rate / fft_size); only used by classic sampling.
3844
bin_hz: f32,
39-
// Three u32 pads (vec3 would align to 16 B and desync the Rust layout).
45+
accum_width: f32,
46+
accum_height: f32,
4047
_pad0: u32,
41-
_pad1: u32,
42-
_pad2: u32,
4348

4449
// (pos1, pos2, pos3, spread0), (spread1, spread2, spread3, spread4).
4550
// Stops 0 and 4 are constant 0.0 / 1.0
@@ -48,13 +53,23 @@ struct Uniforms {
4853
palette: array<vec4<f32>, 5>,
4954
}
5055

51-
struct VertexOutput {
56+
struct AccumOutput {
5257
@builtin(position) position: vec4<f32>,
53-
@location(0) magnitude_db: f32,
54-
@location(1) freq_hz: f32,
58+
@location(0) @interpolate(flat) magnitude_db: f32,
59+
@location(1) @interpolate(flat) freq_hz: f32,
60+
}
61+
62+
struct ClassicOutput {
63+
@builtin(position) position: vec4<f32>,
64+
}
65+
66+
struct ResolveOutput {
67+
@builtin(position) position: vec4<f32>,
68+
@location(0) accum_pos: vec2<f32>,
5569
}
5670

5771
@group(0) @binding(0) var<uniform> u: Uniforms;
72+
@group(0) @binding(1) var accum_tex: texture_2d<f32>;
5873
@group(0) @binding(2) var<storage, read> mags: array<u32>;
5974

6075
// (Glasberg & Moore 1990)
@@ -127,19 +142,6 @@ fn extents() -> vec2<f32> {
127142
);
128143
}
129144

130-
// Pre-rotation (time, freq) pos -> clip-space NDC under u.rotation, u.bounds.
131-
fn place(pos: vec2<f32>, ext: vec2<f32>) -> vec4<f32> {
132-
var rotated: vec2<f32>;
133-
switch u.rotation {
134-
case 1u: { rotated = vec2<f32>(ext.y - pos.y, pos.x); }
135-
case 2u: { rotated = vec2<f32>(ext.x - pos.x, ext.y - pos.y); }
136-
case 3u: { rotated = vec2<f32>(pos.y, ext.x - pos.x); }
137-
default: { rotated = pos; }
138-
}
139-
rotated += u.bounds.xy;
140-
return vec4<f32>(rotated.x * u.clip_scale.x - 1.0, 1.0 - rotated.y * u.clip_scale.y, 0.0, 1.0);
141-
}
142-
143145
// `col_stride_u16` rounds ppc up to even so u16 pairs never straddle u32 words.
144146
fn unpack_mag(slot: u32, bin_in_col: u32) -> f32 {
145147
let idx = slot * u.col_stride_u16 + bin_in_col;
@@ -150,30 +152,43 @@ fn unpack_mag(slot: u32, bin_in_col: u32) -> f32 {
150152
const CULL_POS: vec4<f32> = vec4<f32>(0.0, 0.0, 2.0, 1.0);
151153
const CLASSIC_SENTINEL_DB: f32 = -10000.0;
152154

155+
fn place_accum(pos: vec2<f32>) -> vec4<f32> {
156+
let size = vec2<f32>(max(u.accum_width, 1.0), max(u.accum_height, 1.0));
157+
return vec4<f32>(pos.x / size.x * 2.0 - 1.0, 1.0 - pos.y / size.y * 2.0, 0.0, 1.0);
158+
}
159+
153160
@vertex
154-
fn vs_splat(
161+
fn vs_accum_splat(
155162
@location(0) corner: vec2<f32>,
156163
@location(1) time_offset: f32,
157164
@location(2) freq_hz: f32,
158165
@location(3) magnitude_db: f32,
159166
@builtin(instance_index) inst: u32,
160-
) -> VertexOutput {
167+
) -> AccumOutput {
161168
let zoomed = (freq_to_norm(freq_hz) - u.uv_y_range.x) * u.inv_uv_range;
162169
if magnitude_db < -900.0 || zoomed < -0.01 || zoomed > 1.01 {
163-
return VertexOutput(CULL_POS, magnitude_db, freq_hz);
170+
return AccumOutput(CULL_POS, magnitude_db, freq_hz);
164171
}
165172
let ext = extents();
166173
let age = compute_age(inst / max(u.points_per_col, 1u));
167174
let pos = vec2<f32>(ext.x - (f32(age) - time_offset) * u.scale_factor, (1.0 - zoomed) * ext.y)
168175
+ corner * u.scale_factor;
169-
return VertexOutput(place(pos, ext), magnitude_db, freq_hz);
176+
return AccumOutput(place_accum(pos), magnitude_db, freq_hz);
170177
}
171178

172179
@vertex
173-
fn vs_classic(@location(0) corner: vec2<f32>) -> VertexOutput {
180+
fn vs_classic(@location(0) corner: vec2<f32>) -> ClassicOutput {
174181
let px = u.bounds.xy + (corner + vec2<f32>(0.5)) * u.bounds.zw;
175182
let clip = vec4<f32>(px.x * u.clip_scale.x - 1.0, 1.0 - px.y * u.clip_scale.y, 0.0, 1.0);
176-
return VertexOutput(clip, CLASSIC_SENTINEL_DB, 0.0);
183+
return ClassicOutput(clip);
184+
}
185+
186+
@vertex
187+
fn vs_resolve(@location(0) corner: vec2<f32>) -> ResolveOutput {
188+
let local = (corner + vec2<f32>(0.5)) * u.bounds.zw;
189+
let px = u.bounds.xy + local;
190+
let clip = vec4<f32>(px.x * u.clip_scale.x - 1.0, 1.0 - px.y * u.clip_scale.y, 0.0, 1.0);
191+
return ResolveOutput(clip, unrotate(local, extents()));
177192
}
178193

179194
fn norm_to_freq(norm: f32) -> f32 {
@@ -231,22 +246,27 @@ fn classic_sample(frag_xy: vec2<f32>) -> vec2<f32> {
231246
return vec2<f32>(mag, freq_hz);
232247
}
233248

234-
fn shade(mut_mag: f32, freq_hz: f32) -> vec4<f32> {
249+
fn apply_tilt(mut_mag: f32, freq_hz: f32) -> f32 {
235250
var mag = mut_mag;
236-
237251
// dB/octave tilt relative to 1 kHz. Do not lift sentinels/floor bins.
238252
if u.tilt_db != 0.0 {
239253
if !(mag > DB_ANALYSIS_FLOOR + DB_FLOOR_EPS) {
240-
return vec4<f32>(0.0);
254+
return CLASSIC_SENTINEL_DB;
241255
}
242256
if freq_hz > 0.0 {
243257
mag += u.tilt_db * log2(freq_hz / 1000.0);
244258
}
245259
}
260+
return mag;
261+
}
246262

263+
fn shade_db(mag: f32) -> vec4<f32> {
247264
let range = max(u.ceiling_db - u.floor_db, 0.001);
248265
let normalized = clamp((mag - u.floor_db) / range, 0.0, 1.0);
249-
let adjusted = pow(normalized, max(u.contrast, 0.01));
266+
var adjusted = normalized;
267+
if abs(u.contrast - 1.0) > 1e-4 {
268+
adjusted = pow(normalized, max(u.contrast, 0.01));
269+
}
250270

251271
// Mix palette stops in sRGB space (web-colors pipeline).
252272
let color = palette_color(adjusted);
@@ -255,13 +275,53 @@ fn shade(mut_mag: f32, freq_hz: f32) -> vec4<f32> {
255275
return vec4<f32>(color.rgb * color.a, color.a);
256276
}
257277

278+
fn shade(mut_mag: f32, freq_hz: f32) -> vec4<f32> {
279+
let mag = apply_tilt(mut_mag, freq_hz);
280+
if mag < -9000.0 {
281+
return vec4<f32>(0.0);
282+
}
283+
return shade_db(mag);
284+
}
285+
286+
fn db_to_power(db: f32) -> f32 {
287+
return exp2(db * DB_TO_LOG2);
288+
}
289+
290+
fn power_to_db(power: f32) -> f32 {
291+
return max(log(max(power, 1e-20)) * LN_TO_DB, DB_ANALYSIS_FLOOR);
292+
}
293+
258294
@fragment
259-
fn fs_splat(in: VertexOutput) -> @location(0) vec4<f32> {
260-
return shade(in.magnitude_db, in.freq_hz);
295+
fn fs_accum(in: AccumOutput) -> @location(0) vec2<f32> {
296+
let mag = apply_tilt(in.magnitude_db, in.freq_hz);
297+
if mag < -9000.0 {
298+
return vec2<f32>(0.0);
299+
}
300+
let power = db_to_power(mag);
301+
return vec2<f32>(power, power * LOW_POWER_SCALE);
302+
}
303+
304+
fn accumulated_power(pos: vec2<f32>) -> f32 {
305+
if pos.x < 0.0 || pos.y < 0.0 || pos.x >= u.accum_width || pos.y >= u.accum_height {
306+
return 0.0;
307+
}
308+
309+
let scaled = textureLoad(accum_tex, vec2<i32>(pos), 0).rg;
310+
let scaled_low_power = scaled.g > 0.0 && scaled.g < F16_MAX;
311+
return select(scaled.r, scaled.g * INV_LOW_POWER_SCALE, scaled_low_power);
312+
}
313+
314+
@fragment
315+
fn fs_resolve(in: ResolveOutput) -> @location(0) vec4<f32> {
316+
let power = accumulated_power(in.accum_pos);
317+
if power <= 0.0 {
318+
return vec4<f32>(0.0);
319+
}
320+
return shade_db(power_to_db(power));
261321
}
262322

263323
@fragment
264-
fn fs_classic(in: VertexOutput) -> @location(0) vec4<f32> {
324+
fn fs_classic(in: ClassicOutput) -> @location(0) vec4<f32> {
265325
let sample = classic_sample(in.position.xy);
266326
if sample.x < -9000.0 {
267327
return vec4<f32>(0.0);

0 commit comments

Comments
 (0)