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.
342 lines
11 KiB
Plaintext
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"
|
|
>⏻ 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 = '⏻ 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 = '⏻ 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>
|