From 59677a6a7d5f56ba53671784e60e1e1c37e28d09 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 09:12:52 -0700 Subject: [PATCH] Add signal attenuation knob to oscilloscope control panel 8-position volume control (5%-40%, default 20%) between Source and Vertical sections. Persists via localStorage, keyboard accessible, speech volume scales proportionally. --- docs/src/components/OscilloscopeDisplay.astro | 55 ++++++++++++++++++- docs/src/styles/oscilloscope.css | 29 ++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs/src/components/OscilloscopeDisplay.astro b/docs/src/components/OscilloscopeDisplay.astro index 0ad105f..f019abe 100644 --- a/docs/src/components/OscilloscopeDisplay.astro +++ b/docs/src/components/OscilloscopeDisplay.astro @@ -58,6 +58,14 @@ Spirals + +
+ +
+ 20% +
+
@@ -126,8 +134,8 @@ // ── Skin configuration ──────────────────────────────────── const SKINS: Record = { - '465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Source', 'Vertical', 'Horizontal', 'Trigger'] }, - '545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Vert Ampl', 'Sweep', 'Trigger'] }, + '465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Source', 'Level', 'Vertical', 'Horizontal', 'Trigger'] }, + '545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Atten', 'Vert Ampl', 'Sweep', 'Trigger'] }, }; const SKIN_ORDER = Object.keys(SKINS); @@ -441,6 +449,45 @@ ctx.fill(); } + // ── Volume / attenuation control ───────────────────────── + function initVolume() { + const knob = document.getElementById('scope-volume-knob'); + const label = document.getElementById('scope-volume-label'); + const audio = document.getElementById('scope-audio') as HTMLAudioElement | null; + if (!knob || !label || !audio) return; + if (knob.dataset.volumeInit) return; + knob.dataset.volumeInit = 'true'; + + const LEVELS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40]; + + let levelIndex = parseInt(localStorage.getItem('scope-volume') || '3', 10); + if (levelIndex < 0 || levelIndex >= LEVELS.length) levelIndex = 3; + + function applyLevel(idx: number) { + levelIndex = idx; + const vol = LEVELS[idx]; + audio!.volume = vol; + label!.textContent = `${Math.round(vol * 100)}%`; + const rotation = (idx / LEVELS.length) * 270 - 135; + knob!.style.setProperty('--knob-rotation', `${rotation}deg`); + localStorage.setItem('scope-volume', String(idx)); + } + + knob.addEventListener('click', () => { + applyLevel((levelIndex + 1) % LEVELS.length); + }); + + knob.addEventListener('keydown', (e: Event) => { + const ke = e as KeyboardEvent; + if (ke.key === 'Enter' || ke.key === ' ') { + ke.preventDefault(); + applyLevel((levelIndex + 1) % LEVELS.length); + } + }); + + applyLevel(levelIndex); + } + // ── Outer Limits easter egg ────────────────────────────── function initOuterLimits() { const knobs = document.querySelectorAll('.scope-easter-knob'); @@ -493,7 +540,8 @@ utterance = new SpeechSynthesisUtterance(text); utterance.rate = 0.85; utterance.pitch = 0.7; - utterance.volume = 0.8; + const audio = document.getElementById('scope-audio') as HTMLAudioElement | null; + utterance.volume = Math.min(0.8, (audio?.volume ?? 0.2) * 2); // Prefer a deep English voice if available const voices = window.speechSynthesis.getVoices(); @@ -546,6 +594,7 @@ function initAll() { initSkin(); initScope(); + initVolume(); initOuterLimits(); } diff --git a/docs/src/styles/oscilloscope.css b/docs/src/styles/oscilloscope.css index 8ed9766..f06caa0 100644 --- a/docs/src/styles/oscilloscope.css +++ b/docs/src/styles/oscilloscope.css @@ -261,6 +261,35 @@ outline-offset: 2px; } +/* ── Volume / attenuation knob (interactive) ──────────── */ +.scope-volume-knob { + cursor: pointer; + transition: transform 0.15s; + transform: rotate(var(--knob-rotation, 0deg)); +} + +.scope-volume-knob:hover { + transform: rotate(var(--knob-rotation, 0deg)) scale(1.08); +} + +.scope-volume-knob:active { + transform: rotate(var(--knob-rotation, 0deg)) scale(0.95); +} + +.scope-volume-knob:focus-visible { + outline: 2px solid var(--scope-teal); + outline-offset: 2px; +} + +/* ── Volume label ─────────────────────────────────────── */ +.scope-volume-label { + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + /* ── Signal name label ──────────────────────────────────── */ .scope-source-name { max-width: 60px;