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.
This commit is contained in:
Ryan Malloy 2026-02-13 09:12:52 -07:00
parent 80a01a7326
commit 59677a6a7d
2 changed files with 81 additions and 3 deletions

View File

@ -58,6 +58,14 @@
<span class="scope-knob-label scope-source-name" id="scope-source-name">Spirals</span>
</div>
<!-- LEVEL / ATTENUATION section -->
<div class="scope-section">
<span class="scope-section-label">Level</span>
<div class="scope-knob scope-volume-knob" id="scope-volume-knob"
role="button" tabindex="0" aria-label="Signal attenuation level"></div>
<span class="scope-knob-label scope-volume-label" id="scope-volume-label">20%</span>
</div>
<!-- VERTICAL section -->
<div class="scope-section">
<span class="scope-section-label">Vertical</span>
@ -126,8 +134,8 @@
// ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'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();
}

View File

@ -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;