mcltspice/docs/src/components/OscilloscopeDisplay.astro
Ryan Malloy 08e0ee3cba Add Tektronix 545A scope skin with switchable skin picker
Second oscilloscope skin: 1959 Type 545A with blue-green hammertone
panel, cream silk-screened labels, Bakelite knobs, deeper CRT recess,
and ventilation holes. Click the model name to cycle between 465 and
545A skins. Selection persists via localStorage.
2026-02-13 03:44:16 -07:00

342 lines
11 KiB
Plaintext

---
/**
* XY-mode audio oscilloscope display.
* Visual style inspired by the Tektronix 465 (1972).
* Renders stereo audio as Lissajous patterns on a canvas.
*
* Audio: "Spirals" by Jerobeam Fenderson (oscilloscopemusic.com)
* License: CC BY-NC-SA 4.0
* Original visual: Nick Watton (codepen.io/2Mogs)
* Gist: rsp2k (gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53)
* Modernized: ScriptProcessor → AnalyserNode + rAF
*/
---
<div class="scope-frame">
<!-- Top brand bar — model name doubles as skin picker -->
<div class="scope-brand">
<span class="scope-brand-name">Tektronix</span>
<div class="scope-brand-right">
<button class="scope-brand-model" id="scope-skin-picker" type="button" aria-label="Switch oscilloscope model">mcltspice</button>
<span class="scope-brand-sub" id="scope-brand-sub"></span>
</div>
</div>
<!-- Recessed CRT bay -->
<div class="scope-crt-bay">
<div class="scope-screen" data-idle="true">
<canvas
class="scope-canvas"
id="scope-canvas"
width="512"
height="512"
role="img"
aria-label="XY oscilloscope displaying Lissajous patterns from stereo audio"
></canvas>
<div class="scope-graticule"></div>
<div class="scope-scanlines"></div>
</div>
</div>
<!-- Control panel -->
<div class="scope-panel">
<div class="scope-controls-row">
<!-- VERTICAL section -->
<div class="scope-section">
<span class="scope-section-label">Vertical</span>
<div class="scope-knob" aria-hidden="true"></div>
<span class="scope-knob-label">Volts/Div</span>
</div>
<!-- HORIZONTAL section -->
<div class="scope-section">
<span class="scope-section-label">Horizontal</span>
<div class="scope-knob" aria-hidden="true"></div>
<span class="scope-knob-label">Time/Div</span>
</div>
<!-- TRIGGER / POWER section -->
<div class="scope-section">
<span class="scope-section-label">Trigger</span>
<button
class="scope-toggle"
id="scope-toggle"
type="button"
data-active="false"
aria-label="Start oscilloscope audio"
>&#x23FB; on</button>
<div class="scope-led" id="scope-led" data-on="false" aria-hidden="true"></div>
</div>
</div>
</div>
<!-- Attribution -->
<div class="scope-attribution-bar">
<div class="scope-attribution">
"<a href="http://oscilloscopemusic.com/" target="_blank" rel="noopener">Spirals</a>" by Jerobeam Fenderson
(<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>)
· Visual: <a href="https://codepen.io/2Mogs" target="_blank" rel="noopener">Nick Watton</a>
· <a href="https://gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53" target="_blank" rel="noopener">rsp2k</a>
</div>
</div>
<!-- Ventilation holes (545A skin only — hidden by default) -->
<div class="scope-vent-holes" aria-hidden="true">
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
</div>
</div>
<audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio>
<script>
// ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Vert Ampl', 'Sweep', 'Trigger'] },
};
const SKIN_ORDER = Object.keys(SKINS);
function initSkin() {
const frame = document.querySelector('.scope-frame') as HTMLElement | null;
const picker = document.getElementById('scope-skin-picker') as HTMLButtonElement | null;
const brandName = document.querySelector('.scope-brand-name') as HTMLElement | null;
const brandSub = document.getElementById('scope-brand-sub') as HTMLElement | null;
const sectionLabels = document.querySelectorAll('.scope-section-label');
if (!frame || !picker) return;
// Guard against double-init (DOMContentLoaded + astro:page-load both fire)
if (picker.dataset.skinInit) return;
picker.dataset.skinInit = 'true';
let currentSkin = localStorage.getItem('scope-skin') || '465';
if (!SKINS[currentSkin]) currentSkin = '465';
function applySkin(skinId: string) {
const skin = SKINS[skinId];
if (!skin) return;
// Remove all skin classes, then apply the active one
SKIN_ORDER.forEach(id => frame!.classList.remove(`scope-skin-${id}`));
if (skinId !== '465') {
frame!.classList.add(`scope-skin-${skinId}`);
}
frame!.dataset.skin = skinId;
// Update brand text
if (brandName) brandName.textContent = skin.brand;
picker!.textContent = skin.model;
if (brandSub) {
brandSub.textContent = skin.sub;
brandSub.style.display = skin.sub ? '' : 'none';
}
// Update section labels (Vertical/Vert Ampl, etc.)
sectionLabels.forEach((label, i) => {
if (skin.sections[i]) label.textContent = skin.sections[i];
});
localStorage.setItem('scope-skin', skinId);
currentSkin = skinId;
}
// Apply saved or default skin
applySkin(currentSkin);
// Cycle on click
picker.addEventListener('click', () => {
const idx = SKIN_ORDER.indexOf(currentSkin);
const next = SKIN_ORDER[(idx + 1) % SKIN_ORDER.length];
applySkin(next);
});
}
// ── Oscilloscope XY renderer ──────────────────────────────
// Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor
function initScope() {
const canvas = document.getElementById('scope-canvas') as HTMLCanvasElement | null;
const toggle = document.getElementById('scope-toggle') as HTMLButtonElement | null;
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
const screen = canvas?.closest('.scope-screen') as HTMLElement | null;
const led = document.getElementById('scope-led') as HTMLElement | null;
if (!canvas || !toggle || !audio || !screen) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Draw idle state: faint center dot
drawIdle(ctx, canvas.width, canvas.height);
let audioCtx: AudioContext | null = null;
let analyserL: AnalyserNode | null = null;
let analyserR: AnalyserNode | null = null;
let animId: number | null = null;
let isRunning = false;
let source: MediaElementAudioSourceNode | null = null;
// Check reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
toggle.addEventListener('click', async () => {
if (isRunning) {
stop();
} else {
await start();
}
});
async function start() {
try {
// Create audio context on user gesture (required by browsers)
if (!audioCtx) {
audioCtx = new AudioContext();
// Connect: audio → source → splitter → 2x analyser → destination
source = audioCtx.createMediaElementSource(audio!);
const splitter = audioCtx.createChannelSplitter(2);
analyserL = audioCtx.createAnalyser();
analyserR = audioCtx.createAnalyser();
// 2048 samples gives smooth Lissajous curves
analyserL.fftSize = 2048;
analyserR.fftSize = 2048;
source.connect(splitter);
splitter.connect(analyserL, 0);
splitter.connect(analyserR, 1);
// Also connect to destination so we hear the audio
source.connect(audioCtx.destination);
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume();
}
await audio!.play();
isRunning = true;
screen!.dataset.idle = 'false';
toggle.dataset.active = 'true';
toggle.innerHTML = '&#x23FB; off';
toggle.setAttribute('aria-label', 'Stop oscilloscope audio');
if (led) led.dataset.on = 'true';
if (!prefersReducedMotion) {
renderLoop();
} else {
// Reduced motion: render a single static frame after a brief delay
setTimeout(() => renderFrame(), 200);
}
} catch (e) {
console.error('Oscilloscope audio failed:', e);
}
}
function stop() {
audio!.pause();
isRunning = false;
screen!.dataset.idle = 'true';
toggle.dataset.active = 'false';
toggle.innerHTML = '&#x23FB; on';
toggle.setAttribute('aria-label', 'Start oscilloscope audio');
if (led) led.dataset.on = 'false';
if (animId !== null) {
cancelAnimationFrame(animId);
animId = null;
}
// Fade to idle
drawIdle(ctx!, canvas!.width, canvas!.height);
}
function renderLoop() {
if (!isRunning) return;
renderFrame();
animId = requestAnimationFrame(renderLoop);
}
function renderFrame() {
if (!analyserL || !analyserR || !ctx) return;
const bufLen = analyserL.frequencyBinCount;
const dataL = new Float32Array(bufLen);
const dataR = new Float32Array(bufLen);
analyserL.getFloatTimeDomainData(dataL);
analyserR.getFloatTimeDomainData(dataR);
const w = canvas!.width;
const h = canvas!.height;
const cx = w / 2;
const cy = h / 2;
const scale = w * 0.42;
// Fade previous frame (persistence of phosphor)
ctx.fillStyle = 'rgba(10, 10, 10, 0.25)';
ctx.fillRect(0, 0, w, h);
// Draw dots -- teal phosphor (#2dd4bf)
const dotSize = 1.5;
for (let i = 0; i < bufLen; i++) {
const x = cx + dataR[i] * scale;
const y = cy - dataL[i] * scale;
// Brighter center, dimmer edges (simulate beam intensity)
const dist = Math.sqrt(dataL[i] * dataL[i] + dataR[i] * dataR[i]);
const alpha = Math.max(0.3, 1.0 - dist * 0.4);
ctx.fillStyle = `rgba(45, 212, 191, ${alpha})`;
ctx.fillRect(x - dotSize / 2, y - dotSize / 2, dotSize, dotSize);
}
}
// Cleanup on navigation (Astro view transitions or SPA)
document.addEventListener('astro:before-swap', () => {
stop();
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
});
}
function drawIdle(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// Faint center dot
ctx.fillStyle = 'rgba(45, 212, 191, 0.15)';
ctx.beginPath();
ctx.arc(w / 2, h / 2, 3, 0, Math.PI * 2);
ctx.fill();
}
function initAll() {
initSkin();
initScope();
}
// Init when DOM ready (works with Astro's page lifecycle)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
// Re-init on Astro page transitions
document.addEventListener('astro:page-load', initAll);
</script>