mcltspice/docs/src/components/OscilloscopeDisplay.astro
Ryan Malloy 29fc250c73 Rework Outer Limits plug as narrative continuation
Replace disconnected bottom link with the show's closing narration
subverted: "We now return control of your television set to
oscilloscopemusic.com" — fades in after typewriter finishes with
a dramatic 1.2s delay. Also add oscilloscopemusic.com to the
always-visible attribution bar for N-Spheres tracks.
2026-02-13 06:55:08 -07:00

562 lines
20 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>
<!-- Outer Limits easter egg overlay -->
<div class="scope-outer-limits" id="scope-outer-limits" aria-hidden="true">
<p class="scope-ol-text" id="scope-ol-text"></p>
<p class="scope-ol-closing" id="scope-ol-closing" data-visible="false">
We now return control of your television set to
<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener"
class="scope-ol-link" id="scope-ol-link">oscilloscopemusic.com</a>
</p>
</div>
</div>
</div>
<!-- Control panel -->
<div class="scope-panel">
<div class="scope-controls-row">
<!-- SOURCE section -->
<div class="scope-section">
<span class="scope-section-label">Source</span>
<div class="scope-knob scope-source-knob" id="scope-source-knob"
role="button" tabindex="0" aria-label="Select signal source"></div>
<span class="scope-knob-label scope-source-name" id="scope-source-name">Spirals</span>
</div>
<!-- VERTICAL section -->
<div class="scope-section">
<span class="scope-section-label">Vertical</span>
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Vertical control"></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 scope-easter-knob" role="button" tabindex="0"
aria-label="Horizontal control"></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" id="scope-attribution"></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>
// ── Signal configuration ──────────────────────────────────
const SIGNALS = [
{ id: 'spirals', name: 'Spirals', src: '/spirals_shrt.mp3', artist: 'Jerobeam Fenderson', license: 'CC BY-NC-SA 4.0', link: 'http://oscilloscopemusic.com/' },
{ id: 'function', name: 'Function', src: '/n-spheres/function.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'intersect', name: 'Intersect', src: '/n-spheres/intersect.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'attractor', name: 'Attractor', src: '/n-spheres/attractor.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'flux', name: 'Flux', src: '/n-spheres/flux.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'core', name: 'Core', src: '/n-spheres/core.flac', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
] as const;
// ── 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'] },
};
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 (Source/Input, 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);
});
}
// ── Signal source selector ────────────────────────────────
function initSignal(audio: HTMLAudioElement, screen: HTMLElement) {
const sourceKnob = document.getElementById('scope-source-knob');
const sourceName = document.getElementById('scope-source-name');
if (!sourceKnob || !sourceName) return;
// Guard against double-init
if (sourceKnob.dataset.signalInit) return;
sourceKnob.dataset.signalInit = 'true';
let signalIndex = parseInt(localStorage.getItem('scope-signal') || '0', 10);
if (signalIndex < 0 || signalIndex >= SIGNALS.length) signalIndex = 0;
function applySignal(idx: number, shouldPlay: boolean) {
const signal = SIGNALS[idx];
signalIndex = idx;
// Update source name label
sourceName!.textContent = signal.name;
// Rotate knob indicator to reflect position (custom prop composes with CSS hover scale)
const rotation = (idx / SIGNALS.length) * 270 - 135;
sourceKnob!.style.setProperty('--knob-rotation', `${rotation}deg`);
// Update attribution
renderAttribution(signal);
// Persist selection
localStorage.setItem('scope-signal', String(idx));
// Update audio source
const wasPlaying = !audio.paused;
if (wasPlaying || shouldPlay) {
screen.dataset.loading = 'true';
audio.pause();
}
audio.src = signal.src;
audio.load();
if (wasPlaying || shouldPlay) {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay);
screen.dataset.loading = 'false';
audio.play().catch(e => console.error('Signal playback failed:', e));
};
audio.addEventListener('canplay', onCanPlay);
// Timeout fallback — don't leave loading state indefinitely
setTimeout(() => {
screen.dataset.loading = 'false';
}, 15000);
}
}
// Click cycles to next signal
sourceKnob.addEventListener('click', () => {
const next = (signalIndex + 1) % SIGNALS.length;
applySignal(next, false);
});
// Keyboard: Enter/Space to cycle
sourceKnob.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const next = (signalIndex + 1) % SIGNALS.length;
applySignal(next, false);
}
});
// Apply saved signal on init (don't auto-play)
applySignal(signalIndex, false);
return { applySignal, getIndex: () => signalIndex };
}
function renderAttribution(signal: typeof SIGNALS[number]) {
const el = document.getElementById('scope-attribution');
if (!el) return;
const audioSite = '<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener">oscilloscopemusic.com</a>';
const visualCredit = '· 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>';
if ('license' in signal && signal.license) {
el.innerHTML = `&ldquo;<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>&rdquo; by ${signal.artist} (<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>) ${visualCredit}`;
} else {
el.innerHTML = `&ldquo;${signal.name}&rdquo; from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} · ${audioSite} ${visualCredit}`;
}
}
// ── 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);
// Init signal source selector
initSignal(audio, screen);
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();
}
// ── Outer Limits easter egg ──────────────────────────────
function initOuterLimits() {
const knobs = document.querySelectorAll('.scope-easter-knob');
const overlay = document.getElementById('scope-outer-limits');
const textEl = document.getElementById('scope-ol-text');
const closingEl = document.getElementById('scope-ol-closing');
const linkEl = document.getElementById('scope-ol-link');
if (!knobs.length || !overlay || !textEl) return;
// Guard against double-init
if (overlay.dataset.olInit) return;
overlay.dataset.olInit = 'true';
const quote = 'There is nothing wrong with your television set. Do not attempt to adjust the picture. We are controlling transmission. We will control the horizontal. We will control the vertical. For the next hour, sit quietly and we will control all that you see and hear.';
let typeTimer: number | null = null;
let utterance: SpeechSynthesisUtterance | null = null;
function show() {
overlay!.dataset.active = 'true';
overlay!.setAttribute('aria-hidden', 'false');
textEl!.textContent = '';
if (closingEl) closingEl.dataset.visible = 'false';
typeText(quote, 0);
speak(quote);
}
function dismiss() {
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
overlay!.dataset.active = 'false';
overlay!.setAttribute('aria-hidden', 'true');
if (utterance && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
}
function typeText(text: string, i: number) {
if (i >= text.length) {
// Quote done — fade in the closing narration after a dramatic beat
if (closingEl) setTimeout(() => { closingEl.dataset.visible = 'true'; }, 1200);
return;
}
textEl!.textContent += text[i];
typeTimer = window.setTimeout(() => typeText(text, i + 1), 30);
}
function speak(text: string) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.85;
utterance.pitch = 0.7;
utterance.volume = 0.8;
// Prefer a deep English voice if available
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v =>
v.lang.startsWith('en') && /male|daniel|david|james|google uk/i.test(v.name)
) || voices.find(v => v.lang.startsWith('en'));
if (preferred) utterance.voice = preferred;
window.speechSynthesis.speak(utterance);
}
// Ensure voices are loaded (some browsers load async)
if ('speechSynthesis' in window) {
window.speechSynthesis.getVoices();
window.speechSynthesis.addEventListener('voiceschanged', () => {});
}
// Knob clicks toggle the overlay
knobs.forEach(knob => {
knob.addEventListener('click', () => {
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
});
knob.addEventListener('keydown', (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' || ke.key === ' ') {
ke.preventDefault();
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
}
});
});
// Let the closing link open without dismissing the overlay
if (linkEl) linkEl.addEventListener('click', (e: Event) => e.stopPropagation());
// Dismiss on overlay click or Escape
overlay.addEventListener('click', dismiss);
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && overlay!.dataset.active === 'true') dismiss();
});
}
function initAll() {
initSkin();
initScope();
initOuterLimits();
}
// 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>