diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs index 5fb5abd..e47c4fb 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs @@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable 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("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 // 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 diff --git a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs index c20faa1..9695548 100644 --- a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs +++ b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs @@ -160,6 +160,47 @@ public sealed class WaveformVisualizerControlState /// 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; + + /// + /// 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 off while the waveform stays on. With + /// acceleration present this is a no-op — lava keeps its on-state. + /// + /// + /// 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 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. + /// + /// + /// + /// The probe result — true when WebGL hardware acceleration is present (or the renderer is + /// unknown/masked, favoring the common case), false only on a positive software-renderer + /// match or total WebGL failure. + /// + 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(); + } + /// /// 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 diff --git a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts index 7877cdc..6610333 100644 --- a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts @@ -46,6 +46,11 @@ 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. ────────── /** diff --git a/DeepDrftPublic/Interop/visualizer/hwAccel.test.ts b/DeepDrftPublic/Interop/visualizer/hwAccel.test.ts new file mode 100644 index 0000000..ee3d6c5 --- /dev/null +++ b/DeepDrftPublic/Interop/visualizer/hwAccel.test.ts @@ -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 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`); diff --git a/DeepDrftPublic/Interop/visualizer/hwAccel.ts b/DeepDrftPublic/Interop/visualizer/hwAccel.ts new file mode 100644 index 0000000..9f4220e --- /dev/null +++ b/DeepDrftPublic/Interop/visualizer/hwAccel.ts @@ -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; + } +} diff --git a/DeepDrftTests/WaveformVisualizerControlStateTests.cs b/DeepDrftTests/WaveformVisualizerControlStateTests.cs index 2131cc5..abbd4f7 100644 --- a/DeepDrftTests/WaveformVisualizerControlStateTests.cs +++ b/DeepDrftTests/WaveformVisualizerControlStateTests.cs @@ -88,4 +88,93 @@ public class WaveformVisualizerControlStateTests 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); + } }