|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>cold signal</title> |
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300;1,300;1,400&family=JetBrains+Mono:wght@300;400&display=swap" rel="stylesheet"> |
|
<script src="https://unpkg.com/@strudel/repl@1.0.2"></script> |
|
<style> |
|
:root { |
|
--void: #000000; |
|
--dim: #1A1A20; |
|
--muted: #2A2A35; |
|
--amber: #B8A878; |
|
--gold: #7A6018; |
|
--glow: #D4C080; |
|
/* override strudel theme vars */ |
|
--background: #000000 !important; |
|
--lineBackground: #00000000 !important; |
|
--foreground: #B8A878 !important; |
|
--caret: #D4C080 !important; |
|
--selection: rgba(184, 168, 120, 0.15) !important; |
|
--selectionMatch: rgba(184, 168, 120, 0.08) !important; |
|
--lineHighlight: rgba(184, 168, 120, 0.04) !important; |
|
--gutterBackground: transparent !important; |
|
--gutterForeground: #7A6018 !important; |
|
--gutterBorder: transparent !important; |
|
} |
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
|
|
|
html, body { |
|
width: 100%; height: 100%; |
|
background: var(--void); |
|
overflow: hidden; |
|
} |
|
|
|
body { |
|
display: flex; |
|
flex-direction: column; |
|
font-family: 'JetBrains Mono', monospace; |
|
color: var(--amber); |
|
position: relative; |
|
} |
|
|
|
/* pure black — no gradients */ |
|
|
|
/* ─── HEADER (vertical stack) ─── */ |
|
header { |
|
flex-shrink: 0; |
|
display: flex; |
|
flex-direction: column; |
|
gap: 12px; |
|
padding: 24px 0 16px; |
|
width: 75%; |
|
margin: 0 auto; |
|
position: relative; |
|
z-index: 2; |
|
} |
|
|
|
.title-block { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 7px; |
|
} |
|
|
|
h1 { |
|
font-family: 'Cormorant', Georgia, serif; |
|
font-weight: 300; |
|
font-style: italic; |
|
font-size: 30px; |
|
letter-spacing: 0.05em; |
|
color: var(--amber); |
|
line-height: 1; |
|
} |
|
|
|
.sub { |
|
font-size: 8.5px; |
|
letter-spacing: 0.25em; |
|
color: var(--gold); |
|
text-transform: uppercase; |
|
} |
|
|
|
/* ─── CONTROLS (horizontal row below title) ─── */ |
|
.controls { |
|
display: flex; |
|
align-items: center; |
|
gap: 14px; |
|
} |
|
|
|
.transport { |
|
display: flex; |
|
gap: 6px; |
|
align-items: center; |
|
} |
|
|
|
.btn-t { |
|
font-family: 'JetBrains Mono', monospace; |
|
font-size: 9px; |
|
letter-spacing: 0.2em; |
|
background: none; |
|
border: 1px solid rgba(122, 96, 24, 0.5); |
|
color: rgba(184, 168, 120, 0.6); |
|
padding: 5px 12px; |
|
cursor: pointer; |
|
transition: border-color .2s, color .2s; |
|
} |
|
|
|
.btn-t:hover { |
|
border-color: var(--amber); |
|
color: var(--glow); |
|
} |
|
|
|
.btn-t.active { |
|
border-color: var(--amber); |
|
color: var(--amber); |
|
} |
|
|
|
.btn-t:disabled { |
|
opacity: .3; |
|
cursor: default; |
|
} |
|
|
|
.sep { |
|
width: 1px; |
|
height: 14px; |
|
background: rgba(122, 96, 24, 0.3); |
|
} |
|
|
|
.status-row { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
} |
|
|
|
.dot { |
|
width: 5px; height: 5px; |
|
border-radius: 50%; |
|
background: rgba(184, 168, 120, 0.3); |
|
flex-shrink: 0; |
|
transition: background 0.6s ease; |
|
} |
|
|
|
.status-row.playing .dot { |
|
background: var(--amber); |
|
animation: blink 1.4s ease-in-out infinite; |
|
} |
|
|
|
@keyframes blink { |
|
0%, 100% { opacity: 1; } |
|
50% { opacity: .15; } |
|
} |
|
|
|
#statusText { |
|
font-size: 8px; |
|
letter-spacing: 0.18em; |
|
color: rgba(184, 168, 120, 0.55); |
|
transition: color 0.6s ease; |
|
} |
|
|
|
.status-row.playing #statusText { |
|
color: var(--amber); |
|
} |
|
|
|
.freq { |
|
font-size: 8px; |
|
letter-spacing: 0.12em; |
|
color: var(--gold); |
|
margin-left: auto; |
|
transition: color 0.15s, text-shadow 0.15s; |
|
} |
|
.freq.burst { |
|
color: var(--amber); |
|
text-shadow: 0 0 6px rgba(184, 168, 120, 0.4); |
|
} |
|
|
|
/* ─── EDITOR WRAP ─── */ |
|
.editor-wrap { |
|
flex: 1; |
|
min-height: 0; |
|
position: relative; |
|
z-index: 1; |
|
padding: 0 0 20px; |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
/* @strudel/repl renders CM as a SIBLING div next to strudel-editor, not inside it */ |
|
strudel-editor { |
|
display: none; /* hide the inert element */ |
|
} |
|
|
|
/* the sibling div that repl inserts IS the editor — make it fill the wrap */ |
|
.editor-wrap > div { |
|
flex: 1; |
|
min-height: 0; |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
.editor-wrap > div .cm-editor { |
|
flex: 1; |
|
min-height: 0; |
|
max-width: 100vw !important; |
|
background: transparent !important; |
|
} |
|
|
|
.editor-wrap > div .cm-editor .cm-scroller { |
|
font-family: 'JetBrains Mono', monospace !important; |
|
font-size: 13px !important; |
|
padding: 0 12.5% !important; |
|
overflow-x: hidden !important; |
|
overflow-wrap: anywhere !important; |
|
} |
|
.cm-editor .cm-content { |
|
white-space: pre-wrap !important; |
|
word-break: break-all !important; |
|
overflow-wrap: anywhere !important; |
|
max-width: 100% !important; |
|
} |
|
.cm-editor .cm-line { |
|
white-space: pre-wrap !important; |
|
word-break: break-all !important; |
|
overflow-wrap: anywhere !important; |
|
} |
|
/* force line wrapping mode */ |
|
.cm-editor .cm-lineWrapping { white-space: pre-wrap !important; } |
|
|
|
/* CodeMirror overrides for void aesthetic */ |
|
.cm-editor { background: transparent !important; } |
|
.cm-editor .cm-gutters { display: none !important; } |
|
.cm-editor .cm-content { caret-color: var(--glow) !important; } |
|
.cm-editor .cm-cursor { border-left-color: var(--glow) !important; } |
|
.cm-editor .cm-selectionBackground { background: rgba(184, 168, 120, 0.12) !important; } |
|
.cm-editor.cm-focused .cm-selectionBackground { background: rgba(184, 168, 120, 0.18) !important; } |
|
.cm-editor .cm-activeLine { background: rgba(184, 168, 120, 0.03) !important; } |
|
.cm-editor .cm-matchingBracket { color: var(--glow) !important; background: rgba(212, 192, 128, 0.12) !important; } |
|
.cm-editor .cm-scroller { overflow-y: hidden !important; scrollbar-width: none !important; } |
|
.cm-editor .cm-scroller::-webkit-scrollbar { display: none !important; } |
|
|
|
/* monochrome gold — nuke all syntax colors */ |
|
.cm-editor .cm-line, |
|
.cm-editor .cm-line *, |
|
.cm-editor .cm-content, |
|
.cm-editor .cm-gutterElement { |
|
color: var(--amber) !important; |
|
} |
|
/* comments slightly dimmer */ |
|
.cm-editor .cm-line .tok-comment, |
|
.cm-editor .cm-line .tok-lineComment, |
|
.cm-editor .cm-line .tok-blockComment { |
|
color: rgba(184, 168, 120, 0.35) !important; |
|
} |
|
.cm-editor .cm-slider input[type="range"] { accent-color: var(--gold) !important; } |
|
|
|
/* hide any strudel-injected panels/toolbars */ |
|
.cm-panels { display: none !important; } |
|
|
|
/* ─── PHASE DISC (session timer) ─── */ |
|
.phase-disc { |
|
width: 160px; |
|
margin: 8px auto 0; |
|
text-align: center; |
|
opacity: 0.4; |
|
transition: opacity 0.6s ease; |
|
} |
|
.phase-disc.active { opacity: 1; } |
|
.phase-disc svg { width: 160px; height: 160px; } |
|
.pd-ring { fill: none; stroke: var(--gold); stroke-width: 1; opacity: 0.5; } |
|
.pd-arc { |
|
fill: none; |
|
stroke: var(--amber); |
|
stroke-width: 3; |
|
stroke-linecap: round; |
|
transition: opacity 0.3s; |
|
} |
|
.pd-sweep { |
|
stroke: var(--glow); |
|
stroke-width: 1; |
|
filter: drop-shadow(0 0 3px var(--gold)); |
|
opacity: 0; |
|
transition: opacity 0.3s; |
|
} |
|
.phase-disc.active .pd-sweep { opacity: 1; } |
|
.pd-time { |
|
font-family: 'JetBrains Mono', monospace; |
|
font-size: 11px; |
|
fill: var(--amber); |
|
text-anchor: middle; |
|
dominant-baseline: central; |
|
} |
|
.pd-tick { stroke: var(--gold); stroke-width: 1; opacity: 0.4; } |
|
.pd-duration { |
|
font-family: 'JetBrains Mono', monospace; |
|
font-size: 8px; |
|
letter-spacing: 0.15em; |
|
color: var(--gold); |
|
cursor: pointer; |
|
margin-top: 4px; |
|
user-select: none; |
|
transition: color 0.2s; |
|
} |
|
.pd-duration:hover { color: var(--amber); } |
|
.phase-disc.ending .pd-arc, |
|
.phase-disc.ending .pd-time { |
|
animation: pd-pulse 0.6s ease-in-out infinite; |
|
} |
|
@keyframes pd-pulse { |
|
0%, 100% { opacity: 1; } |
|
50% { opacity: 0.3; } |
|
} |
|
|
|
/* bottom vignette so editor fades into the void */ |
|
.editor-wrap::after { |
|
content: ''; |
|
position: absolute; |
|
bottom: 20px; left: 0; right: 0; |
|
height: 28px; |
|
background: linear-gradient(to top, #000, transparent); |
|
pointer-events: none; |
|
z-index: 2; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<header> |
|
<div class="title-block"> |
|
<h1>cold signal</h1> |
|
<span class="sub">cm9 · a♭maj7 · e♭maj7 · g7 · 67 bpm</span> |
|
</div> |
|
<div class="controls"> |
|
<div class="transport"> |
|
<button class="btn-t" id="playBtn">▶ play</button> |
|
<button class="btn-t" id="stopBtn">■ stop</button> |
|
</div> |
|
<span class="sep"></span> |
|
<div class="status-row" id="statusRow"> |
|
<span class="dot"></span> |
|
<span id="statusText">ctrl+enter to play</span> |
|
</div> |
|
<span class="freq" id="freq">— — —.— kHz</span> |
|
</div> |
|
<!-- phase disc — session timer / telemetry gauge --> |
|
<div class="phase-disc" id="phaseDisc"> |
|
<svg viewBox="0 0 90 90"> |
|
<circle cx="45" cy="45" r="42" class="pd-ring"/> |
|
<g id="pdTicks"></g> |
|
<circle cx="45" cy="45" r="36" class="pd-arc" id="pdArc" |
|
transform="rotate(-90 45 45)"/> |
|
<line x1="45" y1="45" x2="45" y2="9" class="pd-sweep" id="pdSweep"/> |
|
<text x="45" y="45" class="pd-time" id="pdTime">05:00</text> |
|
</svg> |
|
<div class="pd-duration" id="pdDuration">05:00</div> |
|
</div> |
|
</header> |
|
|
|
<div class="editor-wrap" id="editorWrap"></div> |
|
|
|
<script> |
|
// ─── composition ───────────────────────────────────────────────────────────── |
|
// "cold signal" — berlin school × deep space radio × numbers station × probe telemetry |
|
// Feel: Klaus Schulze × shortwave radio × analog sequencer × glacial drift |
|
// NO DRUMS — rhythm comes from the machine sequencer pulse in the harmony itself |
|
// |
|
// Layers: SEQUENCE (casio machine pulse) · PAD (juno glacial wash) · |
|
// TEXTURE (trump radio signal) · BLIPS (arpy telemetry) · |
|
// SUB (jvbass drone) · ATMOSPHERE (wind static) |
|
// |
|
// CRITICAL: Semicolons required after top-level statements. The strudel-editor |
|
// web component strips newlines from textContent, collapsing code to one line. |
|
// Without semicolons, `samples(...)setcps(...)` causes "Unexpected token (1:90)". |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
const CODE = `samples('https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/strudel.json'); setcps(.56); let chords = chord("<Cm9 Ab^7 Eb^7 G7>/4").dict('ireal'); stack(chords.n("[0 <2 1>*2](<3 5>,8)").anchor("C4").voicing().s("casio").lpf(sine.range(180,650).slow(16)).lpq(7).delay(.4).delaytime(".75").gain(perlin.range(.35,.65)).mask("<1 1 0 1 1 0 1 0>/8").room(.85).size(5), chords.n("[0,2,4]").anchor("C3").voicing().s("juno").lpf(sine.range(200,800).slow(32)).lpq(3).gain(perlin.range(.25,.55)).mask("<1 1 1 1 1 1 1 0>/16").room(.9).size(8).attack(.5).release(2), chords.n("[0 <3 2 <1 4>>*2](<3 5>,8)").anchor("C5").voicing().s("trump").speed(perlin.range(.93,1.07)).lpf(sine.range(400,1200).slow(24)).lpq(5).mask("<0 0 0 1>/16").delay(.5).delaytime(".75").gain(perlin.range(.25,.6)).room(.95).size(8), n("<0!5 <2 3> 0 1>").s("arpy").mask("<0 0 0 0 0 0 0 1>/16").speed(perlin.range(.95,1.05)).delay(.6).delaytime("1.5").gain(.2).room(.95).size(9).lpf(800), n("<0!7 1>").set(chords).mode("root:c2").voicing().s("jvbass").lpf(sine.range(80,350).slow(24)).lpq(4).gain(.7).shape(.25).mask("<1!14 0 1>/16").room(.3).size(3), s("wind").mask("<0 0 0 1>/16").lpf(300).lpq(2).gain(.06).room(.95).size(10), chords.n("[0 <2 4> <4 6> <6 0>]*4").anchor("C6").voicing().s("glockenspiel").lpf(sine.range(1500,4000).slow(8)).lpq(3).gain(perlin.range(.18,.42)).mask("<1 0 1 1 0 1 0 1>/8").delay(.35).delaytime(".125").room(.9).size(7)).late("[0 .01]*4").late("[0 .01]*2")`; |
|
|
|
// ─── mount the editor ───────────────────────────────────────────────────── |
|
const editor = document.createElement('strudel-editor'); |
|
editor.setAttribute('code', CODE); // repl reads 'code' attr directly — safe for <> brackets |
|
editor.setAttribute('auto-focus', ''); |
|
document.getElementById('editorWrap').appendChild(editor); |
|
|
|
// ─── kill strudel's theme injection ───────────────────────────────────── |
|
// @strudel/web injects <style id="strudel-theme-vars"> with !important |
|
// CSS vars that override ours. Remove it and force our palette inline. |
|
const nukeTheme = () => { |
|
const st = document.getElementById('strudel-theme-vars'); |
|
if (st) st.remove(); |
|
const r = document.documentElement.style; |
|
r.setProperty('--background', '#030304', 'important'); |
|
r.setProperty('--lineBackground', '#03030400', 'important'); |
|
r.setProperty('--foreground', '#B8A878', 'important'); |
|
r.setProperty('--caret', '#D4C080', 'important'); |
|
r.setProperty('--selection', 'rgba(184,168,120,0.15)', 'important'); |
|
r.setProperty('--selectionMatch', 'rgba(184,168,120,0.08)', 'important'); |
|
r.setProperty('--lineHighlight', 'rgba(184,168,120,0.04)', 'important'); |
|
r.setProperty('--gutterBackground', 'transparent', 'important'); |
|
r.setProperty('--gutterForeground', '#7A6018', 'important'); |
|
r.setProperty('--gutterBorder', 'transparent', 'important'); |
|
}; |
|
nukeTheme(); |
|
// also catch any late re-injection |
|
new MutationObserver(nukeTheme).observe(document.head, { childList: true }); |
|
|
|
// ─── state ──────────────────────────────────────────────────────────────── |
|
const playBtn = document.getElementById('playBtn'); |
|
const stopBtn = document.getElementById('stopBtn'); |
|
const statusRow = document.getElementById('statusRow'); |
|
const statusText = document.getElementById('statusText'); |
|
|
|
// ─── burst mode state ────────────────────────────────────────────────── |
|
let burstActive = false; |
|
let burstInterval = null; |
|
|
|
function setPlaying(on) { |
|
if (on) { |
|
statusText.textContent = 'transmitting'; |
|
statusRow.classList.add('playing'); |
|
playBtn.classList.add('active'); |
|
playBtn.textContent = '▶ play'; |
|
pdStart(); |
|
// start burst cycle: fire immediately, then ~3s burst every ~12s |
|
if (burstInterval) clearInterval(burstInterval); |
|
// fire first burst immediately |
|
burstActive = true; |
|
freqEl.classList.add('burst'); |
|
setTimeout(() => { |
|
burstActive = false; |
|
freqEl.classList.remove('burst'); |
|
}, 3000); |
|
// then recur every ~12s |
|
burstInterval = setInterval(() => { |
|
burstActive = true; |
|
freqEl.classList.add('burst'); |
|
setTimeout(() => { |
|
burstActive = false; |
|
freqEl.classList.remove('burst'); |
|
}, 3000); |
|
}, 12000); |
|
} else { |
|
statusText.textContent = 'signal lost'; |
|
statusRow.classList.remove('playing'); |
|
playBtn.classList.remove('active'); |
|
playBtn.textContent = '▶ play'; |
|
pdStop(); |
|
// clear burst cycle |
|
if (burstInterval) { clearInterval(burstInterval); burstInterval = null; } |
|
burstActive = false; |
|
freqEl.classList.remove('burst'); |
|
} |
|
} |
|
|
|
// ─── transport ──────────────────────────────────────────────────────────── |
|
// @strudel/repl defines strudel-editor and stores StrudelMirror as editor.editor. |
|
// Initialization is async — poll until it's ready. |
|
let strudelMirror = null; |
|
const pollEditor = setInterval(() => { |
|
if (editor.editor?.evaluate) { |
|
strudelMirror = editor.editor; |
|
clearInterval(pollEditor); |
|
} |
|
}, 100); |
|
|
|
playBtn.addEventListener('mousedown', e => e.preventDefault()); |
|
stopBtn.addEventListener('mousedown', e => e.preventDefault()); |
|
|
|
playBtn.addEventListener('click', () => { |
|
if (!strudelMirror) { statusText.textContent = 'loading…'; return; } |
|
statusText.textContent = 'loading…'; |
|
strudelMirror.evaluate(); |
|
}); |
|
|
|
stopBtn.addEventListener('click', () => { |
|
strudelMirror?.stop(); |
|
}); |
|
|
|
// @strudel/repl fires 'update' events, not strudel:play/stop |
|
editor.addEventListener('update', (e) => { |
|
if (e.detail?.started) setPlaying(true); |
|
else if (e.detail?.started === false) setPlaying(false); |
|
if (e.detail?.error) { |
|
statusText.textContent = 'error — check console'; |
|
statusRow.classList.remove('playing'); |
|
playBtn.classList.remove('active'); |
|
} |
|
}); |
|
|
|
// ─── frequency scanner ──────────────────────────────────────────────────── |
|
const freqEl = document.getElementById('freq'); |
|
let freqVal = 8502.3; |
|
let freqDir = 1; |
|
|
|
setInterval(() => { |
|
if (burstActive) { |
|
freqVal += freqDir * 0.8 * (0.7 + Math.random() * 1.2); |
|
if (Math.random() < 0.3) freqVal += (Math.random() - 0.5) * 200; |
|
} else { |
|
freqVal += freqDir * 0.1 * (0.7 + Math.random() * 0.6); |
|
if (Math.random() < 0.04) freqVal += (Math.random() - 0.5) * 90; |
|
} |
|
if (freqVal > 9800) freqDir = -1; |
|
if (freqVal < 7200) freqDir = 1; |
|
freqEl.textContent = freqVal.toFixed(1) + ' kHz'; |
|
}, 180); |
|
|
|
// ─── phase disc (session timer) ───────────────────────────────────────── |
|
const phaseDisc = document.getElementById('phaseDisc'); |
|
const pdArc = document.getElementById('pdArc'); |
|
const pdSweep = document.getElementById('pdSweep'); |
|
const pdTime = document.getElementById('pdTime'); |
|
const pdTicks = document.getElementById('pdTicks'); |
|
const pdDurLabel = document.getElementById('pdDuration'); |
|
|
|
const PD_R = 36; |
|
const PD_C = 2 * Math.PI * PD_R; // ~226.19 |
|
const PD_PRE = [180, 300, 600, 900, Infinity]; // 3m 5m 10m 15m ∞ |
|
let pdIdx = 1; // default 5:00 |
|
let pdDur = PD_PRE[pdIdx]; |
|
let pdT0 = null; // performance.now() at play |
|
let pdRaf = null; |
|
|
|
pdArc.setAttribute('stroke-dasharray', PD_C); |
|
pdArc.setAttribute('stroke-dashoffset', 0); |
|
|
|
function pdFmt(sec) { |
|
if (!isFinite(sec)) return '\u221E'; // ∞ |
|
const m = Math.floor(sec / 60); |
|
const s = Math.floor(sec % 60); |
|
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); |
|
} |
|
|
|
function pdDrawTicks() { |
|
pdTicks.innerHTML = ''; |
|
if (!isFinite(pdDur)) return; |
|
const n = pdDur / 60; |
|
for (let i = 0; i < n; i++) { |
|
const a = (i / n) * 360 - 90; |
|
const r = a * Math.PI / 180; |
|
const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line'); |
|
tick.setAttribute('x1', 45 + 40 * Math.cos(r)); |
|
tick.setAttribute('y1', 45 + 40 * Math.sin(r)); |
|
tick.setAttribute('x2', 45 + 44 * Math.cos(r)); |
|
tick.setAttribute('y2', 45 + 44 * Math.sin(r)); |
|
tick.setAttribute('class', 'pd-tick'); |
|
pdTicks.appendChild(tick); |
|
} |
|
} |
|
|
|
function pdFrame() { |
|
if (!pdT0) return; |
|
const elapsed = (performance.now() - pdT0) / 1000; |
|
const remaining = isFinite(pdDur) ? Math.max(0, pdDur - elapsed) : Infinity; |
|
const progress = isFinite(pdDur) ? 1 - remaining / pdDur : 0; |
|
|
|
// arc drains from full → empty |
|
pdArc.setAttribute('stroke-dashoffset', PD_C * progress); |
|
|
|
// sweep line tracks drain edge |
|
pdSweep.setAttribute('transform', |
|
'rotate(' + (-90 + progress * 360) + ' 45 45)'); |
|
|
|
// center readout |
|
pdTime.textContent = isFinite(pdDur) ? pdFmt(remaining) : pdFmt(elapsed); |
|
|
|
// final 30 s warning |
|
if (isFinite(pdDur) && remaining <= 30 && remaining > 0) { |
|
phaseDisc.classList.add('ending'); |
|
} else { |
|
phaseDisc.classList.remove('ending'); |
|
} |
|
|
|
// auto-stop at zero |
|
if (isFinite(pdDur) && remaining <= 0) { |
|
pdTime.textContent = '00:00'; |
|
phaseDisc.classList.remove('ending'); |
|
sendKey('.'); |
|
setPlaying(false); |
|
statusText.textContent = 'transmission complete'; |
|
return; // don't request next frame |
|
} |
|
|
|
pdRaf = requestAnimationFrame(pdFrame); |
|
} |
|
|
|
function pdStart() { |
|
if (pdRaf) cancelAnimationFrame(pdRaf); |
|
pdArc.setAttribute('stroke-dashoffset', 0); |
|
pdSweep.setAttribute('transform', 'rotate(-90 45 45)'); |
|
pdTime.textContent = pdFmt(pdDur); |
|
pdT0 = performance.now(); |
|
phaseDisc.classList.add('active'); |
|
phaseDisc.classList.remove('ending'); |
|
pdRaf = requestAnimationFrame(pdFrame); |
|
} |
|
|
|
function pdStop() { |
|
if (pdRaf) cancelAnimationFrame(pdRaf); |
|
pdRaf = null; |
|
pdT0 = null; |
|
phaseDisc.classList.remove('active', 'ending'); |
|
// freeze at current position — don't reset arc/sweep/time |
|
} |
|
|
|
// duration selector — click to cycle presets |
|
pdDurLabel.addEventListener('click', () => { |
|
if (pdT0) return; // locked while playing |
|
pdIdx = (pdIdx + 1) % PD_PRE.length; |
|
pdDur = PD_PRE[pdIdx]; |
|
pdDurLabel.textContent = pdFmt(pdDur); |
|
pdTime.textContent = pdFmt(pdDur); |
|
// reset arc to full when switching presets |
|
pdArc.setAttribute('stroke-dashoffset', 0); |
|
pdSweep.setAttribute('transform', 'rotate(-90 45 45)'); |
|
pdDrawTicks(); |
|
}); |
|
|
|
// initialize |
|
pdDrawTicks(); |
|
pdTime.textContent = pdFmt(pdDur); |
|
pdDurLabel.textContent = pdFmt(pdDur); |
|
</script> |
|
|
|
</body> |
|
</html> |