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:
parent
08e0ee3cba
commit
be88ea53b7
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal 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
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
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
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
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
BIN
docs/public/n-spheres/intersect.flac
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -41,6 +41,14 @@
|
||||
<!-- 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>
|
||||
@ -72,12 +80,7 @@
|
||||
|
||||
<!-- 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 class="scope-attribution" id="scope-attribution"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ventilation holes (545A skin only — hidden by default) -->
|
||||
@ -100,10 +103,20 @@
|
||||
<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: ['Vertical', 'Horizontal', 'Trigger'] },
|
||||
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Vert Ampl', 'Sweep', 'Trigger'] },
|
||||
'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);
|
||||
|
||||
@ -141,7 +154,7 @@
|
||||
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) => {
|
||||
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 = `“<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() {
|
||||
@ -178,6 +280,9 @@
|
||||
// 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;
|
||||
|
||||
@ -240,6 +240,51 @@
|
||||
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 ────────────────────────────────────────── */
|
||||
.scope-toggle {
|
||||
appearance: none;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user