Add signal source selector to oscilloscope hero

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)
This commit is contained in:
Ryan Malloy 2026-02-13 06:16:47 -07:00
parent 08e0ee3cba
commit be88ea53b7
8 changed files with 175 additions and 9 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
docs/public/n-spheres/*.flac filter=lfs diff=lfs merge=lfs -text

BIN
docs/public/n-spheres/attractor.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/public/n-spheres/core.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/public/n-spheres/flux.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/public/n-spheres/function.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/public/n-spheres/intersect.flac (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -41,6 +41,14 @@
<!-- Control panel --> <!-- Control panel -->
<div class="scope-panel"> <div class="scope-panel">
<div class="scope-controls-row"> <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 --> <!-- VERTICAL section -->
<div class="scope-section"> <div class="scope-section">
<span class="scope-section-label">Vertical</span> <span class="scope-section-label">Vertical</span>
@ -72,12 +80,7 @@
<!-- Attribution --> <!-- Attribution -->
<div class="scope-attribution-bar"> <div class="scope-attribution-bar">
<div class="scope-attribution"> <div class="scope-attribution" id="scope-attribution"></div>
"<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> </div>
<!-- Ventilation holes (545A skin only — hidden by default) --> <!-- Ventilation holes (545A skin only — hidden by default) -->
@ -100,10 +103,20 @@
<audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio> <audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio>
<script> <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 ──────────────────────────────────── // ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = { const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Vertical', 'Horizontal', 'Trigger'] }, '465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Source', 'Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Vert Ampl', 'Sweep', 'Trigger'] }, '545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Vert Ampl', 'Sweep', 'Trigger'] },
}; };
const SKIN_ORDER = Object.keys(SKINS); const SKIN_ORDER = Object.keys(SKINS);
@ -141,7 +154,7 @@
brandSub.style.display = skin.sub ? '' : 'none'; brandSub.style.display = skin.sub ? '' : 'none';
} }
// Update section labels (Vertical/Vert Ampl, etc.) // Update section labels (Source/Input, Vertical/Vert Ampl, etc.)
sectionLabels.forEach((label, i) => { sectionLabels.forEach((label, i) => {
if (skin.sections[i]) label.textContent = skin.sections[i]; if (skin.sections[i]) label.textContent = skin.sections[i];
}); });
@ -161,6 +174,95 @@
}); });
} }
// ── 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 = `&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} ${visualCredit}`;
}
}
// ── Oscilloscope XY renderer ────────────────────────────── // ── Oscilloscope XY renderer ──────────────────────────────
// Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor // Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor
function initScope() { function initScope() {
@ -178,6 +280,9 @@
// Draw idle state: faint center dot // Draw idle state: faint center dot
drawIdle(ctx, canvas.width, canvas.height); drawIdle(ctx, canvas.width, canvas.height);
// Init signal source selector
initSignal(audio, screen);
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
let analyserL: AnalyserNode | null = null; let analyserL: AnalyserNode | null = null;
let analyserR: AnalyserNode | null = null; let analyserR: AnalyserNode | null = null;

View File

@ -240,6 +240,51 @@
line-height: 1; line-height: 1;
} }
/* ── Source knob (interactive) ───────────────────────────── */
/* Uses --knob-rotation custom prop so JS rotation composes with CSS scale */
.scope-source-knob {
cursor: pointer;
transition: transform 0.15s;
transform: rotate(var(--knob-rotation, 0deg));
}
.scope-source-knob:hover {
transform: rotate(var(--knob-rotation, 0deg)) scale(1.08);
}
.scope-source-knob:active {
transform: rotate(var(--knob-rotation, 0deg)) scale(0.95);
}
.scope-source-knob:focus-visible {
outline: 2px solid var(--scope-teal);
outline-offset: 2px;
}
/* ── Signal name label ──────────────────────────────────── */
.scope-source-name {
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
/* ── Loading state overlay ──────────────────────────────── */
.scope-screen[data-loading="true"]::after {
content: '';
position: absolute;
inset: 0;
background: rgba(10, 10, 10, 0.7);
z-index: 5;
animation: scope-loading-pulse 1.2s ease-in-out infinite;
}
@keyframes scope-loading-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.9; }
}
/* ── Power toggle ────────────────────────────────────────── */ /* ── Power toggle ────────────────────────────────────────── */
.scope-toggle { .scope-toggle {
appearance: none; appearance: none;