Source knob cycles through 6 tracks: the original CC-licensed Spirals intro plus 5 N-Spheres tracks (Function, Intersect, Attractor, Flux, Core) by Fenderson & Hansi3D. - 48kHz FLAC conversions served via git LFS (~189MB total) - Rotary knob with CSS custom property composable transforms - Dynamic attribution (CC link for Spirals, album credit for N-Spheres) - Signal selection persisted in localStorage - Loading state overlay while buffering larger tracks - Skin-aware labels (465: "Source", 545A: "Input") - Keyboard accessible (Enter/Space to cycle)
447 lines
16 KiB
Plaintext
447 lines
16 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">
|
|
<!-- 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" 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" 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 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 = `“<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>” 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 = `“${signal.name}” from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} ${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 = '⏻ 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>
|