Merge HW-acceleration detection (default lava off on software renderer) into streaming-overhaul

This commit is contained in:
daniel-c-harvey
2026-06-26 10:42:25 -04:00
6 changed files with 398 additions and 0 deletions
@@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return; return;
} }
// Apply the hardware-capability default ONCE per session before seeding the controls: if the
// browser has no WebGL hardware acceleration, the lava subsystem (which software-renders on
// the main thread and starves audio decode) defaults off while the waveform stays on. The
// probe lives in JS (it needs a real WebGL context); the scoped state guards the one-time
// application, so a remounted visualizer never re-applies and a later explicit toggle is
// never clobbered. Sequenced before PushControlsAsync so the seed already carries the
// corrected enables; ApplyCapabilityDefault also raises Changed for the controls UI.
try
{
var hardwareAccelerated = await _module.InvokeAsync<bool>("detectHardwareAcceleration");
ControlState.ApplyCapabilityDefault(hardwareAccelerated);
}
catch (JSException ex)
{
// A probe failure must not regress the HW-accel majority: leave the defaults (lava on).
Logger.LogWarning(ex, "WaveformVisualizer: hardware-acceleration probe failed; leaving lava at its default.");
}
// Seed the module with the current state now that it exists. All control values (the eight // Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state, // dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener // so a mix opened mid-session seeds the module with the knob/toggle positions the listener
@@ -160,6 +160,47 @@ public sealed class WaveformVisualizerControlState
/// </summary> /// </summary>
public event Action? Changed; public event Action? Changed;
// Whether the one-time, capability-driven default has been applied this session. The default-set
// (lava off when the browser has no WebGL hardware acceleration) must run exactly once — on the
// first interactive render, before the listener has touched a toggle — so it sets the *initial
// default* and never clobbers a later explicit in-session toggle. Scoped with the rest of this
// state, so it survives SPA navigation (a remounted visualizer does not re-apply) and resets on a
// fresh page load (F5 re-probes).
private bool _capabilityDefaultApplied;
/// <summary>
/// Applies the hardware-capability default exactly once per session: when the browser reports no
/// WebGL hardware acceleration, the lava subsystem (the expensive, main-thread software-rendered
/// part that starves audio decode) defaults <c>off</c> while the waveform stays <c>on</c>. With
/// acceleration present this is a no-op — lava keeps its <see cref="DefaultLavaEnabled"/> on-state.
///
/// <para>
/// Idempotent and guarded: only the FIRST call this session has any effect, so it sets the initial
/// default and never overrides a listener's explicit toggle (the control remains fully functional —
/// a user on a software renderer may re-enable lava at their own risk). Mutates, coerces
/// Theater Mode, then raises <see cref="Changed"/> once so the controls UI, the visualizer bridge,
/// and the Theater observers all reflect the default in a single cycle. Called by the visualizer
/// bridge on first interactive render, once JS interop (the probe) is available.
/// </para>
/// </summary>
/// <param name="hardwareAccelerated">
/// The probe result — <c>true</c> when WebGL hardware acceleration is present (or the renderer is
/// unknown/masked, favoring the common case), <c>false</c> only on a positive software-renderer
/// match or total WebGL failure.
/// </param>
public void ApplyCapabilityDefault(bool hardwareAccelerated)
{
if (_capabilityDefaultApplied) return;
_capabilityDefaultApplied = true;
// Accelerated (or unknown): keep the as-shipped defaults — no observer churn.
if (hardwareAccelerated) return;
LavaEnabled = false; // the expensive subsystem off; WaveformEnabled stays at its default (true).
CoerceTheaterMode();
NotifyChanged();
}
/// <summary> /// <summary>
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer /// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating /// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
@@ -46,6 +46,11 @@
import { decodePressure } from '../audio/decodePressure.js'; import { decodePressure } from '../audio/decodePressure.js';
// Re-exported so the Blazor bridge (WaveformVisualizer.razor.cs) reaches the HW-accel probe through
// the same module reference it already imports for create() — one JS import surface, no second handle.
// The probe itself (and its unit-tested pure classifier) lives in hwAccel.ts.
export { detectHardwareAcceleration } from './hwAccel.js';
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ────────── // ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
/** /**
@@ -0,0 +1,137 @@
/**
* hwAccel classifier tests — the pure software-renderer signature matching and the
* uncertainty/failure policy that drives the lava default-off decision.
*
* These cover the code-PROVABLE half of the feature: given a renderer string (or its absence, or a
* total WebGL failure), is the browser classified "accelerated" (lava on) or not (lava off)? The
* impure probe (detectHardwareAcceleration → real getContext) is browser-confirmed, not unit-tested.
*
* Same harness convention as decodePressure.test.ts — no test runner in this repo; Node 22+ strips TS
* types natively. Run a copy from the COMPILED output so the `./hwAccel.js` import specifier resolves:
*
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
* cp DeepDrftPublic/Interop/visualizer/hwAccel.test.ts DeepDrftPublic/wwwroot/js/visualizer/
* node DeepDrftPublic/wwwroot/js/visualizer/hwAccel.test.ts
*
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
*/
import {
classifyHardwareAcceleration,
isSoftwareRenderer,
SOFTWARE_RENDERER_SIGNATURES,
} from './hwAccel.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
let passed = 0;
const failures: string[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
passed++;
} catch (e) {
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
}
}
function assertTrue(actual: boolean, msg?: string): void {
if (actual !== true) throw new Error(`${msg ?? 'assertTrue'}: expected true, got ${String(actual)}`);
}
function assertFalse(actual: boolean, msg?: string): void {
if (actual !== false) throw new Error(`${msg ?? 'assertFalse'}: expected false, got ${String(actual)}`);
}
// --- isSoftwareRenderer: positive matches -----------------------------------------------------
// Real-world software renderer strings, as reported by UNMASKED_RENDERER_WEBGL on accel-off configs.
const SOFTWARE_STRINGS = [
'Google SwiftShader',
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)',
'llvmpipe (LLVM 12.0.0, 256 bits)',
'Gallium 0.4 on llvmpipe (LLVM 17.0.6, 256 bits)',
'softpipe',
'Microsoft Basic Render Driver',
'Mesa OffScreen',
'Software Rasterizer',
];
for (const s of SOFTWARE_STRINGS) {
test(`isSoftwareRenderer matches software string: "${s}"`, () => {
assertTrue(isSoftwareRenderer(s), `"${s}" should match a software signature`);
});
}
// --- isSoftwareRenderer: hardware (GPU) strings must NOT match ---------------------------------
const HARDWARE_STRINGS = [
'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)',
'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)',
'ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0, D3D11)',
'Apple GPU',
'Mali-G78',
'Adreno (TM) 650',
];
for (const s of HARDWARE_STRINGS) {
test(`isSoftwareRenderer rejects hardware string: "${s}"`, () => {
assertFalse(isSoftwareRenderer(s), `"${s}" should NOT match any software signature`);
});
}
// --- case-insensitivity -----------------------------------------------------------------------
test('isSoftwareRenderer is case-insensitive', () => {
assertTrue(isSoftwareRenderer('SWIFTSHADER'), 'upper-case must still match');
assertTrue(isSoftwareRenderer('LlVmPiPe'), 'mixed-case must still match');
});
test('every declared signature self-matches (sanity on the list)', () => {
for (const sig of SOFTWARE_RENDERER_SIGNATURES) {
assertTrue(isSoftwareRenderer(sig), `signature "${sig}" must match itself`);
}
});
// --- classifyHardwareAcceleration: the full policy --------------------------------------------
test('positive software match → NOT accelerated (lava off)', () => {
assertFalse(
classifyHardwareAcceleration(true, 'Google SwiftShader'),
'a working context with a software renderer must classify as not accelerated',
);
});
test('real GPU renderer → accelerated (lava on)', () => {
assertTrue(
classifyHardwareAcceleration(true, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
'a working context with a GPU renderer must classify as accelerated',
);
});
// Uncertainty / default-on case: context works but the renderer string is masked or absent.
test('masked renderer (null) with a working context → accelerated (default on)', () => {
assertTrue(
classifyHardwareAcceleration(true, null),
'an unknown renderer must favor the HW-accel majority',
);
});
test('empty/whitespace renderer with a working context → accelerated (default on)', () => {
assertTrue(classifyHardwareAcceleration(true, ''), 'empty string is unknown, not software');
assertTrue(classifyHardwareAcceleration(true, ' '), 'whitespace is unknown, not software');
});
// Total-WebGL-failure case: no context at all → lava can't run → not accelerated.
test('no WebGL context at all → NOT accelerated (lava off), regardless of renderer arg', () => {
assertFalse(classifyHardwareAcceleration(false, null), 'no context → lava off');
assertFalse(
classifyHardwareAcceleration(false, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
'no context dominates even a GPU-looking string',
);
});
// --- report ----------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
}
console.log(`ALL ${passed} TESTS PASSED`);
@@ -0,0 +1,108 @@
/**
* Hardware-acceleration probe for the lava-lamp visualizer.
*
* WHY: with hardware acceleration OFF the WebGL2 lava field software-renders on the main thread and
* starves WebCodecs Opus decode → playback struggles. The decodePressure auto-throttle alone is not
* enough — even throttled, software-rendered lava is too expensive. So when there is no HW-accel
* support we default the LAVA subsystem OFF (the expensive part) while keeping the WAVEFORM ON. With
* HW accel present (the common case) nothing changes — lava defaults on, full quality.
*
* The probe creates a throwaway WebGL context, reads the unmasked renderer string via
* WEBGL_debug_renderer_info, and matches it against known software-renderer signatures.
*
* UNCERTAINTY POLICY (favor the HW-accel majority): lava is disabled ONLY on a positive
* software-renderer match or a total failure to obtain any WebGL context (lava can't run at all). If
* the renderer string is unavailable/masked (some privacy configs strip
* WEBGL_debug_renderer_info) but a context otherwise succeeds, we default to "accelerated" — we do not
* disable lava on absence of evidence, only on positive evidence of software rendering.
*
* LIMIT (browser-confirmed, not code-provable): UNMASKED_RENDERER_WEBGL can be masked, and a given
* browser running with HW accel OFF may report a string none of these signatures match — in which
* case this probe reports "accelerated" and lava stays on. The signature list below is the only
* tunable; if a real software-renderer string slips through, add it here.
*/
/**
* Case-insensitive substrings that positively identify a software (non-GPU) WebGL renderer. Matching
* any one of these means the browser is software-rendering WebGL → lava off. Order is irrelevant.
*/
export const SOFTWARE_RENDERER_SIGNATURES: readonly string[] = [
'swiftshader', // Chrome's software GL fallback (also "Google SwiftShader")
'llvmpipe', // Mesa software rasterizer (Linux)
'softpipe', // Mesa software rasterizer (older/gallium)
'microsoft basic render', // Windows "Microsoft Basic Render Driver"
'mesa offscreen', // Mesa headless/offscreen software path
'software', // generic catch-all ("... Software ...")
];
/**
* Pure predicate: does this renderer string positively identify a software renderer? Case-insensitive
* substring match against {@link SOFTWARE_RENDERER_SIGNATURES}. Empty/whitespace is NOT a match — a
* masked/absent string is "unknown", not "software" (see {@link classifyHardwareAcceleration}).
*/
export function isSoftwareRenderer(renderer: string): boolean {
const r = renderer.toLowerCase();
return SOFTWARE_RENDERER_SIGNATURES.some((sig) => r.includes(sig));
}
/**
* Pure classifier mapping probe observations to "is hardware accelerated?". Split out from the
* DOM-touching {@link detectHardwareAcceleration} so the policy is unit-testable without a browser.
*
* • no WebGL context at all → false (lava can't run — total failure)
* • renderer masked/absent → true (favor the HW-accel majority — absence of evidence)
* • positive software match → false (positive evidence of software rendering)
* • otherwise → true (a real GPU renderer string)
*/
export function classifyHardwareAcceleration(hasWebglContext: boolean, renderer: string | null): boolean {
if (!hasWebglContext) return false;
if (renderer === null || renderer.trim() === '') return true;
return !isSoftwareRenderer(renderer);
}
/** Read the unmasked renderer string, or null when the debug extension is unavailable/masked. */
function readUnmaskedRenderer(gl: WebGLRenderingContext | WebGL2RenderingContext): string | null {
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (!ext) return null;
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
return typeof renderer === 'string' ? renderer : null;
}
// Probe once per page — the renderer is a constant for the lifetime of the document. Cached so the
// scoped C# control-state's one-time default-set never pays for a second throwaway context.
let cached: boolean | undefined;
/**
* Probe the browser for WebGL hardware acceleration. Returns true when the lava subsystem should
* default ON (HW accel present or renderer unknown), false when it should default OFF (positive
* software-renderer match or no WebGL context at all). Cached after the first call; never throws.
*/
export function detectHardwareAcceleration(): boolean {
if (cached !== undefined) return cached;
cached = probe();
return cached;
}
function probe(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as
| WebGLRenderingContext
| WebGL2RenderingContext
| null;
if (!gl) return classifyHardwareAcceleration(false, null);
const result = classifyHardwareAcceleration(true, readUnmaskedRenderer(gl));
// Release the throwaway context — WebGL contexts are a scarce per-page resource (~16 in
// Chrome before force-eviction). The renderer string is already captured in `result` above
// so this is safe to call before returning. Inner try/catch ensures a rogue loseContext
// implementation (or a browser that surfaces it incorrectly) cannot silently swallow the
// result or re-throw out of probe() and trigger the defensive `return true` fallback.
try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch { /* defensive */ }
return result;
} catch {
// getContext/createElement do not throw in practice; this guard is purely defensive. An
// unexpected probe failure should NOT regress the HW-accel majority, so default to
// accelerated (lava on) — only the clean "no context" path above disables lava.
return true;
}
}
@@ -88,4 +88,93 @@ public class WaveformVisualizerControlStateTests
Assert.That(observedTheaterMode, Is.False); Assert.That(observedTheaterMode, Is.False);
} }
// ── ApplyCapabilityDefault: the one-time HW-accel default-set ──
// No HW accel → lava defaults off, waveform stays on (the whole point of the feature).
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_LavaOffWaveformOn()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
Assert.Multiple(() =>
{
Assert.That(_state.LavaEnabled, Is.False);
Assert.That(_state.WaveformEnabled, Is.True);
});
}
// HW accel present → no change; lava keeps its shipped on-default (the common case is untouched).
[Test]
public void ApplyCapabilityDefault_HardwareAccelerated_LavaStaysOn()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
Assert.Multiple(() =>
{
Assert.That(_state.LavaEnabled, Is.True);
Assert.That(_state.WaveformEnabled, Is.True);
});
}
// No HW accel raises Changed once so the controls UI / bridge / theater observers all react.
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_RaisesChanged()
{
var raised = 0;
_state.Changed += () => raised++;
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
Assert.That(raised, Is.EqualTo(1));
}
// HW accel present must not churn observers — no Changed when nothing changed.
[Test]
public void ApplyCapabilityDefault_HardwareAccelerated_DoesNotRaiseChanged()
{
var raised = 0;
_state.Changed += () => raised++;
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
Assert.That(raised, Is.Zero);
}
// Guarded to once per session: a second call cannot re-clobber, even with a different verdict.
[Test]
public void ApplyCapabilityDefault_SecondCall_IsNoOp()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: true); // first call wins (accelerated → on)
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // must be ignored
Assert.That(_state.LavaEnabled, Is.True);
}
// Must not override an explicit in-session toggle: once applied, a user re-enabling lava on a
// software renderer sticks — a later (guarded) call never reverts it.
[Test]
public void ApplyCapabilityDefault_DoesNotOverrideExplicitUserToggle()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // default: lava off
_state.LavaEnabled = true; // user re-enables at their own risk
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // guarded → no-op
Assert.That(_state.LavaEnabled, Is.True);
}
// No HW accel while Theater Mode is on with only lava active → coercion exits theater in the same
// cycle (lava off + waveform off would strand theater; here waveform stays on, but verify the
// coercion path is exercised when it WOULD strand).
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_CoercesStrandedTheater()
{
_state.WaveformEnabled = false; // only lava active
_state.TheaterMode = true;
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // lava off → both off → strand
Assert.That(_state.TheaterMode, Is.False);
}
} }