Spaces:
Running
Running
Redesign: THE WAKING SET — talking dial-glass, tube warm-up, real type, analog VU
Browse files- radio.html +607 -403
radio.html
CHANGED
|
@@ -7,161 +7,167 @@
|
|
| 7 |
|
| 8 |
<!--
|
| 9 |
====================================================================
|
| 10 |
-
NIGHTWAVE
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
Works both at top-level (devserver) AND inside <iframe srcdoc>.
|
| 13 |
|
| 14 |
-
The DJ, the music, and the calls are all live. The signal
|
| 15 |
-
well you are tuned to the station (98.6).
|
| 16 |
-->
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
<style>
|
| 19 |
/* ===================================================================
|
| 20 |
1. PALETTE & GLOBALS
|
| 21 |
=================================================================== */
|
| 22 |
:root{
|
| 23 |
-
--walnut-0:#
|
| 24 |
--walnut-1:#2b1a10;
|
| 25 |
--walnut-2:#1d1109;
|
| 26 |
--walnut-3:#140b05;
|
| 27 |
-
--
|
|
|
|
| 28 |
--amber:#ffb347;
|
| 29 |
--amber-dim:#c87a2a;
|
|
|
|
| 30 |
--ivory:#f6e7c8;
|
| 31 |
--ivory-dim:#b9a884;
|
|
|
|
|
|
|
| 32 |
--metal-hi:#d8d2c4;
|
| 33 |
--metal-mid:#8d8678;
|
| 34 |
--metal-lo:#3c382f;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
--on-air:#ff5a2c;
|
| 36 |
/* mood-driven accent (overwritten by JS) */
|
| 37 |
--mood:#ffb347;
|
| 38 |
--mood-soft:#ffd79b;
|
| 39 |
--dial-glow: 0.0; /* 0..1 signal clarity, drives glow intensity */
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 43 |
-
html,body{margin:0;padding:0;height:100%}
|
| 44 |
body{
|
| 45 |
-
font-family:"
|
| 46 |
-
color:var(--ivory);
|
| 47 |
background:
|
| 48 |
-
radial-gradient(
|
| 49 |
display:flex;align-items:center;justify-content:center;
|
| 50 |
-
min-height:100vh;overflow:hidden;
|
| 51 |
user-select:none;-webkit-user-select:none;
|
| 52 |
}
|
| 53 |
|
| 54 |
-
/* warm lamp glow that breathes behind the set */
|
| 55 |
.room-lamp{
|
| 56 |
position:fixed;inset:0;pointer-events:none;z-index:0;
|
| 57 |
-
background:radial-gradient(
|
| 58 |
-
rgba(255,
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
body.powered .room-lamp{
|
| 62 |
-
background:radial-gradient(46% 42% at 50% 32%,
|
| 63 |
-
rgba(255,165,71,0.30) 0%, rgba(255,120,40,0.10) 42%, transparent 72%);
|
| 64 |
}
|
|
|
|
| 65 |
body.intimate .room-lamp{ /* direct_address: dim the room */
|
| 66 |
-
opacity:.
|
| 67 |
}
|
| 68 |
|
| 69 |
/* ===================================================================
|
| 70 |
-
2. CABINET (
|
| 71 |
=================================================================== */
|
|
|
|
| 72 |
.cabinet{
|
| 73 |
-
position:relative;
|
| 74 |
-
width:min(
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
background:
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
rgba(0,0,0,0.00) 0px, rgba(0,0,0,0.06) 2px,
|
| 81 |
-
rgba(255,180,90,0.025) 4px, rgba(0,0,0,0.00) 7px),
|
| 82 |
-
/* broad grain waves */
|
| 83 |
-
repeating-linear-gradient(88deg,
|
| 84 |
-
rgba(0,0,0,0.10) 0px, rgba(120,70,30,0.04) 22px,
|
| 85 |
-
rgba(0,0,0,0.12) 46px, rgba(150,90,40,0.05) 70px),
|
| 86 |
-
/* warm body shading */
|
| 87 |
-
radial-gradient(140% 120% at 30% 0%, #4a2e1b 0%, var(--walnut-1) 45%, var(--walnut-3) 100%);
|
| 88 |
box-shadow:
|
| 89 |
-
0 2px 0 rgba(255,
|
| 90 |
-
0 -
|
| 91 |
-
0 40px 80px -
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
.bezel{
|
| 97 |
position:relative;
|
| 98 |
-
border-radius:
|
| 99 |
-
padding:
|
| 100 |
-
background:
|
| 101 |
-
|
| 102 |
box-shadow:
|
| 103 |
-
0
|
| 104 |
-
0
|
| 105 |
-
border:1px solid rgba(0,0,0,0.5);
|
| 106 |
}
|
| 107 |
-
/* brushed-metal trim helper */
|
| 108 |
-
.metal{
|
| 109 |
-
background:
|
| 110 |
-
repeating-linear-gradient(90deg,
|
| 111 |
-
rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px,
|
| 112 |
-
rgba(0,0,0,0.05) 2px, rgba(0,0,0,0.05) 3px),
|
| 113 |
-
linear-gradient(180deg, var(--metal-hi) 0%, var(--metal-mid) 45%, var(--metal-lo) 100%);
|
| 114 |
-
border:1px solid rgba(0,0,0,0.5);
|
| 115 |
-
box-shadow:0 1px 1px rgba(255,255,255,0.4) inset, 0 -2px 3px rgba(0,0,0,0.4) inset;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
/* now-playing plate */
|
| 119 |
-
.nowplaying{display:flex;align-items:center;gap:10px;justify-content:center;margin:2px auto 10px;
|
| 120 |
-
max-width:440px;padding:6px 14px;border-radius:8px;
|
| 121 |
-
background:linear-gradient(180deg,#1c1209,#120a05);border:1px solid rgba(255,179,71,.18);
|
| 122 |
-
box-shadow:inset 0 1px 0 rgba(255,200,120,.08);font-size:12.5px;letter-spacing:.04em}
|
| 123 |
-
.nowplaying .np-text{color:var(--ivory-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 124 |
-
.nowplaying.playing .np-text{color:var(--amber);text-shadow:0 0 10px rgba(255,179,71,.35)}
|
| 125 |
-
.nowplaying .np-eq{display:inline-flex;gap:2px;align-items:flex-end;height:12px;opacity:.35}
|
| 126 |
-
.nowplaying.playing .np-eq{opacity:1}
|
| 127 |
-
.nowplaying .np-eq i{width:3px;height:4px;background:var(--amber);border-radius:1px}
|
| 128 |
-
.nowplaying.playing .np-eq i{animation:eq .9s ease-in-out infinite}
|
| 129 |
-
.nowplaying.playing .np-eq i:nth-child(2){animation-delay:.15s}
|
| 130 |
-
.nowplaying.playing .np-eq i:nth-child(3){animation-delay:.30s}
|
| 131 |
-
.nowplaying.playing .np-eq i:nth-child(4){animation-delay:.45s}
|
| 132 |
-
@keyframes eq{0%,100%{height:4px}50%{height:12px}}
|
| 133 |
-
@media (prefers-reduced-motion:reduce){.nowplaying.playing .np-eq i{animation:none;height:8px}}
|
| 134 |
|
| 135 |
-
/*
|
| 136 |
-
.
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
display:flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
|
|
|
| 141 |
.brandplate .name{
|
| 142 |
-
font-
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
.brandplate .sub{
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
/* ON AIR tube */
|
| 150 |
-
.onair{
|
| 151 |
-
display:flex;align-items:center;gap:10px;
|
| 152 |
-
padding:9px 16px;border-radius:30px;
|
| 153 |
-
background:linear-gradient(180deg,#2a1a10,#160c06);
|
| 154 |
-
border:1px solid rgba(0,0,0,0.6);
|
| 155 |
-
box-shadow:0 1px 0 rgba(255,200,140,0.06) inset;
|
| 156 |
-
}
|
| 157 |
.onair .tube{
|
| 158 |
-
width:
|
| 159 |
background:radial-gradient(circle at 35% 30%, #5a2a1a, #2a0f08);
|
| 160 |
-
box-shadow:0 0 0 1px rgba(0,0,0,
|
| 161 |
-
transition:background .
|
| 162 |
}
|
| 163 |
.onair .label{
|
| 164 |
-
font-size:
|
| 165 |
transition:color .3s, text-shadow .3s;
|
| 166 |
}
|
| 167 |
body.broadcasting .onair .tube{
|
|
@@ -169,259 +175,316 @@ body.broadcasting .onair .tube{
|
|
| 169 |
box-shadow:0 0 10px var(--on-air), 0 0 22px rgba(255,90,44,0.6), 0 0 0 1px rgba(0,0,0,0.4) inset;
|
| 170 |
}
|
| 171 |
body.broadcasting .onair .label{
|
| 172 |
-
color:#
|
| 173 |
}
|
| 174 |
|
| 175 |
/* ===================================================================
|
| 176 |
-
|
| 177 |
=================================================================== */
|
| 178 |
-
.dialwrap{position:relative;
|
| 179 |
.dial-frame{
|
| 180 |
-
position:relative;border-radius:
|
| 181 |
-
|
|
|
|
| 182 |
background:
|
| 183 |
-
linear-gradient(180deg, rgba(
|
| 184 |
-
box-shadow:0
|
| 185 |
cursor:ew-resize;
|
| 186 |
touch-action:none;
|
| 187 |
}
|
| 188 |
-
/*
|
| 189 |
-
.dial-face{
|
| 190 |
-
position:relative;height:96px;border-radius:8px;
|
| 191 |
-
background:
|
| 192 |
-
linear-gradient(180deg, rgba(246,231,200,0.10), rgba(246,231,200,0.02));
|
| 193 |
-
box-shadow:0 0 0 1px rgba(255,210,150,0.10) inset;
|
| 194 |
-
overflow:hidden;
|
| 195 |
-
}
|
| 196 |
-
/* the warm backlight behind the dial, intensity from --dial-glow */
|
| 197 |
.dial-light{
|
| 198 |
-
position:absolute;inset:0;
|
| 199 |
-
background:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
mix-blend-mode:screen;
|
| 204 |
}
|
| 205 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
-
/* the needle */
|
| 208 |
.needle{
|
| 209 |
-
position:absolute;top:
|
| 210 |
transform:translateX(-50%);
|
| 211 |
-
background:linear-gradient(180deg,
|
| 212 |
-
box-shadow:0 0
|
| 213 |
-
|
| 214 |
-
outline:none;
|
| 215 |
}
|
| 216 |
.needle::after{ /* knob grip at top */
|
| 217 |
-
content:"";position:absolute;top:
|
| 218 |
-
width:
|
| 219 |
-
background:
|
| 220 |
-
box-shadow:0 1px 1px rgba(255,255,255,0.4) inset,0 2px 4px rgba(0,0,0,0.6);
|
| 221 |
}
|
| 222 |
.needle:focus-visible{box-shadow:0 0 0 2px #fff, 0 0 14px var(--amber)}
|
| 223 |
.dial-frame:focus-within .needle::after{outline:2px solid var(--amber);outline-offset:2px}
|
| 224 |
|
| 225 |
-
/*
|
| 226 |
-
.lockchip{
|
| 227 |
-
position:absolute;right:14px;top:10px;z-index:5;
|
| 228 |
-
font-size:9px;letter-spacing:.28em;font-weight:800;
|
| 229 |
-
padding:3px 8px;border-radius:20px;
|
| 230 |
-
color:#1d1107;background:var(--amber);
|
| 231 |
-
box-shadow:0 0 12px rgba(255,179,71,0.8);
|
| 232 |
-
opacity:0;transform:translateY(-4px);transition:opacity .25s,transform .25s;
|
| 233 |
-
}
|
| 234 |
-
.dialwrap.locked .lockchip{opacity:1;transform:none}
|
| 235 |
.freq-readout{
|
| 236 |
-
position:absolute;left:16px;top:
|
|
|
|
| 237 |
font-variant-numeric:tabular-nums;
|
| 238 |
-
font-size:
|
| 239 |
-
color:
|
| 240 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
/* ===================================================================
|
| 243 |
-
|
| 244 |
=================================================================== */
|
| 245 |
.deck{
|
| 246 |
display:grid;
|
| 247 |
-
grid-template-columns:1.
|
| 248 |
-
gap:
|
| 249 |
}
|
| 250 |
.panel{
|
| 251 |
-
border-radius:
|
| 252 |
-
background:linear-gradient(180deg,
|
| 253 |
-
|
| 254 |
-
|
|
|
|
| 255 |
}
|
| 256 |
.panel .cap{
|
| 257 |
-
font-size:9px;letter-spacing:.
|
| 258 |
}
|
| 259 |
|
| 260 |
-
/*
|
| 261 |
-
.
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
/* tape reels */
|
| 266 |
-
.reels{display:flex;gap:
|
| 267 |
.reel{
|
| 268 |
-
position:relative;width:
|
| 269 |
-
background:
|
| 270 |
-
|
| 271 |
-
box-shadow:0
|
| 272 |
-
0 0 0 1px rgba(255,200,140,0.06) inset;
|
| 273 |
}
|
| 274 |
-
.reel::before{
|
| 275 |
-
content:"";position:absolute;inset:
|
| 276 |
-
background:
|
| 277 |
-
conic-gradient(from 0deg,
|
| 278 |
-
transparent 0 14deg, rgba(255,200,140,0.10) 14deg 18deg, transparent 18deg 120deg,
|
| 279 |
-
rgba(255,200,140,0.10) 120deg 124deg, transparent 124deg 240deg,
|
| 280 |
-
rgba(255,200,140,0.10) 240deg 244deg, transparent 244deg 360deg);
|
| 281 |
}
|
| 282 |
.reel .hub{
|
| 283 |
-
position:absolute;
|
| 284 |
-
background:radial-gradient(circle at
|
| 285 |
}
|
| 286 |
-
.reel.spin{animation:spin
|
| 287 |
.reel.spin.rev{animation-direction:reverse}
|
| 288 |
@keyframes spin{to{transform:rotate(360deg)}}
|
| 289 |
|
| 290 |
-
/* power knob */
|
| 291 |
.power-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;justify-content:center;flex:1}
|
| 292 |
.power-knob{
|
| 293 |
position:relative;width:96px;height:96px;border-radius:50%;
|
| 294 |
cursor:pointer;border:none;padding:0;
|
| 295 |
-
background:
|
| 296 |
-
|
| 297 |
-
box-shadow:
|
| 298 |
-
|
| 299 |
-
0 0 0 5px rgba(0,0,0,0.6),
|
| 300 |
-
0 8px 18px rgba(0,0,0,0.65),
|
| 301 |
-
0 1px 1px rgba(255,255,255,0.2) inset;
|
| 302 |
-
transition:transform .2s ease, box-shadow .3s ease;
|
| 303 |
color:var(--ivory-dim);
|
| 304 |
}
|
| 305 |
.power-knob::after{ /* pointer notch */
|
| 306 |
-
content:"";position:absolute;top:9px;left:50%;width:4px;height:
|
| 307 |
-
border-radius:3px;background:var(--ivory-dim);
|
| 308 |
box-shadow:0 0 4px rgba(0,0,0,0.6);transition:background .3s, box-shadow .3s;
|
| 309 |
}
|
| 310 |
.power-knob .pwr-txt{
|
| 311 |
-
position:absolute;bottom:
|
| 312 |
-
font-size:
|
|
|
|
| 313 |
}
|
| 314 |
-
.power-knob:hover{
|
| 315 |
-
.power-knob:focus-visible{outline:2px solid var(--
|
| 316 |
body.powered .power-knob{transform:rotate(28deg)}
|
| 317 |
-
body.powered .power-knob::after{background:var(--ember);box-shadow:0 0 10px var(--ember),0 0 18px rgba(255,90,44,0.6)}
|
| 318 |
-
body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
|
| 319 |
|
| 320 |
-
/*
|
| 321 |
-
.gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;justify-content:center
|
| 322 |
.gauge{
|
| 323 |
-
position:relative;width:
|
| 324 |
-
background:
|
| 325 |
-
box-shadow:0 0 0 1px rgba(0,0,0,0.
|
| 326 |
-
overflow:hidden;
|
| 327 |
}
|
|
|
|
| 328 |
.gauge .fill{
|
| 329 |
-
position:absolute;left:0;
|
| 330 |
-
background:linear-gradient(
|
| 331 |
-
box-shadow:0 0
|
| 332 |
-
transition:
|
| 333 |
}
|
| 334 |
-
.gauge .ticks{position:absolute;inset:0;
|
| 335 |
-
background:repeating-linear-gradient(180deg, transparent 0 18.4px, rgba(0,0,0,0.55) 18.4px 19.4px);}
|
| 336 |
.gauge .stagedot{
|
| 337 |
-
position:absolute;
|
| 338 |
-
background:#fff;box-shadow:0 0 8px #fff;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
}
|
| 340 |
|
| 341 |
-
/* handset / call-in */
|
| 342 |
-
.handset-row{display:flex;flex-direction:column;align-items:center;gap:8px;justify-content:center;
|
| 343 |
.handset{
|
| 344 |
-
position:relative;display:flex;align-items:center;gap:
|
| 345 |
-
padding:
|
| 346 |
-
font-family:inherit;color:var(--ivory);
|
| 347 |
-
background:linear-gradient(180deg,#
|
| 348 |
-
|
|
|
|
| 349 |
transition:transform .12s ease, box-shadow .25s ease;
|
| 350 |
}
|
| 351 |
.handset .ic{
|
| 352 |
-
width:
|
| 353 |
display:flex;align-items:center;justify-content:center;
|
| 354 |
-
background:
|
| 355 |
-
color:#1d1107;box-shadow:
|
| 356 |
}
|
| 357 |
-
.handset .ic svg{width:
|
| 358 |
.handset .ht{display:flex;flex-direction:column;align-items:flex-start;line-height:1.15}
|
| 359 |
-
.handset .ht b{font-size:
|
| 360 |
-
.handset .ht span{font-size:
|
| 361 |
.handset:disabled{opacity:.4;cursor:not-allowed}
|
| 362 |
-
.handset:focus-visible{outline:2px solid var(--
|
| 363 |
.handset.recording{
|
| 364 |
background:linear-gradient(180deg,#7a1f12,#3a0d08);
|
| 365 |
box-shadow:0 0 0 2px var(--on-air), 0 0 26px rgba(255,90,44,0.7);
|
| 366 |
animation:rec-pulse 1s ease-in-out infinite;
|
| 367 |
}
|
| 368 |
-
.handset.recording .ic{background:
|
| 369 |
@keyframes rec-pulse{50%{box-shadow:0 0 0 2px var(--on-air),0 0 40px rgba(255,90,44,0.95)}}
|
| 370 |
|
| 371 |
-
/* small ghost buttons (NEXT / captions) */
|
| 372 |
-
.minirow{display:flex;gap:10px;align-items:center;justify-content:center;margin-top:
|
| 373 |
.ghost{
|
| 374 |
-
font-family:
|
| 375 |
-
color:
|
| 376 |
-
border:1px solid
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
.ghost
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
=================================================================== */
|
| 386 |
-
.caption-bar{
|
| 387 |
-
margin-top:14px;border-radius:12px;min-height:62px;
|
| 388 |
-
padding:14px 18px;display:flex;align-items:flex-start;gap:12px;
|
| 389 |
-
background:linear-gradient(180deg, rgba(18,11,5,0.9), rgba(8,5,2,0.95));
|
| 390 |
-
box-shadow:0 0 0 1px rgba(0,0,0,0.6) inset, 0 8px 20px rgba(0,0,0,0.4) inset;
|
| 391 |
-
}
|
| 392 |
-
.caption-bar .marker{
|
| 393 |
-
flex:0 0 auto;width:8px;height:8px;border-radius:50%;
|
| 394 |
-
background:var(--mood);box-shadow:0 0 10px var(--mood);margin-top:5px;
|
| 395 |
-
}
|
| 396 |
-
.caption-text{
|
| 397 |
-
font-size:16px;line-height:1.5;color:var(--ivory);
|
| 398 |
-
font-style:italic;letter-spacing:.01em;
|
| 399 |
-
text-shadow:0 0 16px rgba(255,179,71,0.12);
|
| 400 |
-
}
|
| 401 |
-
.caption-text .w{transition:color .12s,text-shadow .12s;color:var(--ivory-dim)}
|
| 402 |
-
.caption-text .w.spoken{color:var(--amber);text-shadow:0 0 10px rgba(255,179,71,0.6)}
|
| 403 |
-
.caption-text.status{font-style:normal;color:var(--amber);letter-spacing:.06em}
|
| 404 |
-
.caption-text.fragment{color:#9a8a6a;font-style:italic;filter:blur(.3px);letter-spacing:.12em}
|
| 405 |
-
.caption-text .caller-said{display:block;font-size:11px;color:var(--ivory-dim);
|
| 406 |
-
font-style:normal;letter-spacing:.04em;margin-bottom:5px}
|
| 407 |
|
| 408 |
/* tiny credit line */
|
| 409 |
.tagline{
|
| 410 |
-
margin-top:
|
| 411 |
-
|
|
|
|
| 412 |
}
|
| 413 |
|
|
|
|
|
|
|
|
|
|
| 414 |
/* ===================================================================
|
| 415 |
6. MOTION / RESPONSIVE
|
| 416 |
=================================================================== */
|
| 417 |
@media (max-width:680px){
|
| 418 |
-
|
| 419 |
-
.cabinet{padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
}
|
| 421 |
@media (prefers-reduced-motion:reduce){
|
|
|
|
| 422 |
.reel.spin{animation:none}
|
| 423 |
.handset.recording{animation:none}
|
| 424 |
.room-lamp{transition:none}
|
|
|
|
| 425 |
*{scroll-behavior:auto}
|
| 426 |
}
|
| 427 |
/* glitch flicker, applied transiently by JS (skipped under reduced-motion) */
|
|
@@ -432,66 +495,86 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
|
|
| 432 |
<body>
|
| 433 |
<div class="room-lamp" aria-hidden="true"></div>
|
| 434 |
|
|
|
|
| 435 |
<main class="cabinet" role="application" aria-label="NIGHTWAVE analog radio">
|
|
|
|
| 436 |
<div class="bezel">
|
| 437 |
|
| 438 |
-
<!-- =====
|
| 439 |
<div class="topband">
|
| 440 |
-
<div class="brandplate
|
| 441 |
<span class="name">NIGHTWAVE</span>
|
| 442 |
-
<span class="sub">98.6 FM
|
| 443 |
</div>
|
| 444 |
-
<div class="
|
| 445 |
-
<span class="
|
| 446 |
-
<
|
|
|
|
|
|
|
|
|
|
| 447 |
</div>
|
| 448 |
</div>
|
| 449 |
|
| 450 |
-
<!-- =====
|
| 451 |
-
<div class="nowplaying" id="nowPlaying" aria-live="polite">
|
| 452 |
-
<span class="np-eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
| 453 |
-
<span class="np-text" id="npText">NIGHTWAVE — all night, every night</span>
|
| 454 |
-
</div>
|
| 455 |
-
|
| 456 |
-
<!-- ===== dial scale + needle ===== -->
|
| 457 |
<div class="dialwrap" id="dialwrap">
|
| 458 |
<div class="dial-frame" id="dialFrame">
|
|
|
|
|
|
|
|
|
|
| 459 |
<span class="freq-readout" id="freqReadout">98.6</span>
|
| 460 |
-
<span class="lockchip">&
|
| 461 |
-
<div class="dial-face">
|
| 462 |
-
<div class="dial-light" id="dialLight" aria-hidden="true"></div>
|
| 463 |
-
<canvas class="dial-canvas" id="dialCanvas" aria-hidden="true"></canvas>
|
| 464 |
-
</div>
|
| 465 |
<div class="needle" id="needle"
|
| 466 |
role="slider" tabindex="0"
|
| 467 |
aria-label="Tuning dial, FM frequency"
|
| 468 |
aria-valuemin="87.5" aria-valuemax="108.0" aria-valuenow="98.6"
|
| 469 |
aria-valuetext="98.6 megahertz"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
</div>
|
| 471 |
</div>
|
| 472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
<!-- ===== control deck ===== -->
|
| 474 |
<div class="deck">
|
| 475 |
|
| 476 |
-
<!-- left:
|
| 477 |
<div class="panel">
|
| 478 |
-
<span class="cap">
|
| 479 |
-
<
|
|
|
|
| 480 |
<div class="reels" aria-hidden="true">
|
| 481 |
<div class="reel" id="reelL"><span class="hub"></span></div>
|
| 482 |
<div class="reel rev" id="reelR"><span class="hub"></span></div>
|
| 483 |
</div>
|
| 484 |
</div>
|
| 485 |
|
| 486 |
-
<!-- center: power
|
| 487 |
<div class="panel">
|
| 488 |
-
<span class="cap">
|
| 489 |
<div class="power-wrap">
|
| 490 |
<button class="power-knob" id="powerKnob"
|
| 491 |
aria-pressed="false" aria-label="Power. Turn on to tune in to NIGHTWAVE.">
|
| 492 |
-
<span class="pwr-txt" id="pwrTxt">&
|
| 493 |
</button>
|
| 494 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
<div class="handset-row">
|
| 496 |
<button class="handset" id="handset" disabled
|
| 497 |
aria-label="Call in. Tap to talk to the DJ on the air, tap again to send.">
|
|
@@ -501,11 +584,7 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
|
|
| 501 |
<span class="ht"><b>CALL IN</b><span>HOLD TO TALK</span></span>
|
| 502 |
</button>
|
| 503 |
</div>
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
<!-- right: signal-integrity gauge -->
|
| 507 |
-
<div class="panel">
|
| 508 |
-
<span class="cap">Integrity</span>
|
| 509 |
<div class="gauge-wrap">
|
| 510 |
<div class="gauge" id="gauge" role="meter" aria-label="Signal integrity"
|
| 511 |
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
|
@@ -513,7 +592,6 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
|
|
| 513 |
<div class="fill" id="gaugeFill"></div>
|
| 514 |
<div class="stagedot" id="gaugeDot" aria-hidden="true"></div>
|
| 515 |
</div>
|
| 516 |
-
<span class="cap" id="stageCap" style="letter-spacing:.18em">—</span>
|
| 517 |
</div>
|
| 518 |
</div>
|
| 519 |
</div>
|
|
@@ -524,34 +602,27 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
|
|
| 524 |
<button class="ghost" id="capToggle" aria-pressed="true" aria-label="Toggle captions">CAPTIONS: ON</button>
|
| 525 |
</div>
|
| 526 |
|
| 527 |
-
<!-- ===== captions ===== -->
|
| 528 |
-
<div class="caption-bar">
|
| 529 |
-
<span class="marker" aria-hidden="true"></span>
|
| 530 |
-
<div class="caption-text" id="caption" role="log" aria-live="polite" aria-atomic="false">
|
| 531 |
-
Turn the power knob to tune in…
|
| 532 |
-
</div>
|
| 533 |
-
</div>
|
| 534 |
-
|
| 535 |
<div class="tagline">— YOU'RE LISTENING TO NIGHTWAVE —</div>
|
| 536 |
</div>
|
| 537 |
</main>
|
|
|
|
| 538 |
|
| 539 |
<script>
|
| 540 |
"use strict";
|
| 541 |
/* ===================================================================
|
| 542 |
-
NIGHTWAVE radio controller
|
| 543 |
Sections:
|
| 544 |
A. Canonical arc contract (mirrors space/arc.py)
|
| 545 |
B. State
|
| 546 |
C. DOM refs
|
| 547 |
D. Audio engine (one AudioContext, radio chain, hiss bed, VU)
|
| 548 |
E. Dial / tuning
|
| 549 |
-
F.
|
| 550 |
-
G. Captions (karaoke)
|
| 551 |
H. Networking (/api/broadcast, /api/call) with mock fallback
|
| 552 |
I. Broadcast loop & arc cues
|
| 553 |
J. Call-in (tap-to-talk)
|
| 554 |
-
K. Power-on, wiring, seed controls, rAF loop
|
| 555 |
=================================================================== */
|
| 556 |
|
| 557 |
/* ---- A. CANONICAL ARC CONTRACT (must match arc.py exactly) -------- */
|
|
@@ -601,7 +672,8 @@ const State = {
|
|
| 601 |
songPlaying:false, // a generated song is currently sounding (ducks the bed)
|
| 602 |
pendingBreak:null, // buffered DJ break for the next transition (dead-air gating)
|
| 603 |
locale:null, // resolved real weather/time/city, or null (fictional fallback)
|
| 604 |
-
connecting:false
|
|
|
|
| 605 |
};
|
| 606 |
|
| 607 |
/* ---- C. DOM refs -------------------------------------------------- */
|
|
@@ -613,6 +685,7 @@ const els = {
|
|
| 613 |
dialwrap: $("dialwrap"), dialFrame: $("dialFrame"),
|
| 614 |
needle: $("needle"), freqReadout: $("freqReadout"),
|
| 615 |
dialLight: $("dialLight"), dialCanvas: $("dialCanvas"),
|
|
|
|
| 616 |
vuCanvas: $("vuCanvas"),
|
| 617 |
reelL: $("reelL"), reelR: $("reelR"),
|
| 618 |
handset: $("handset"),
|
|
@@ -627,6 +700,10 @@ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
| 627 |
State.reducedMotion = mql.matches;
|
| 628 |
if (mql.addEventListener) mql.addEventListener("change", e=>{ State.reducedMotion = e.matches; });
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
/* ===================================================================
|
| 631 |
D. AUDIO ENGINE - one AudioContext, radio chain, hiss bed, VU
|
| 632 |
=================================================================== */
|
|
@@ -1086,9 +1163,17 @@ function pickSong(){
|
|
| 1086 |
function showNowPlaying(track){
|
| 1087 |
if (!els.nowPlaying || !track) return;
|
| 1088 |
els.nowPlaying.classList.add("playing");
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1092 |
}
|
| 1093 |
function clearNowPlaying(label){
|
| 1094 |
if (!els.nowPlaying) return;
|
|
@@ -1209,7 +1294,7 @@ function onVoiceEnded(){
|
|
| 1209 |
}
|
| 1210 |
|
| 1211 |
/* ===================================================================
|
| 1212 |
-
E. DIAL / TUNING
|
| 1213 |
=================================================================== */
|
| 1214 |
function drawDial(){
|
| 1215 |
const c = els.dialCanvas, ctx = c.getContext("2d");
|
|
@@ -1221,37 +1306,34 @@ function drawDial(){
|
|
| 1221 |
|
| 1222 |
const pad = 14;
|
| 1223 |
const span = FREQ_MAX - FREQ_MIN;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1224 |
ctx.textAlign = "center";
|
| 1225 |
-
ctx.font = "700 9px Futura, sans-serif";
|
| 1226 |
|
| 1227 |
for (let f = 88; f <= 108; f += 1){
|
| 1228 |
const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
|
| 1229 |
-
const major = (f %
|
| 1230 |
-
ctx.strokeStyle =
|
| 1231 |
-
ctx.lineWidth = major ? 1.
|
| 1232 |
ctx.beginPath();
|
| 1233 |
-
ctx.moveTo(x,
|
| 1234 |
-
ctx.lineTo(x,
|
| 1235 |
ctx.stroke();
|
| 1236 |
if (major){
|
| 1237 |
-
ctx.
|
| 1238 |
-
ctx.
|
|
|
|
| 1239 |
}
|
| 1240 |
}
|
| 1241 |
-
//
|
| 1242 |
-
for (let f = 87.5; f <= 108; f += 1){
|
| 1243 |
-
const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
|
| 1244 |
-
ctx.strokeStyle = "rgba(246,231,200,0.14)";
|
| 1245 |
-
ctx.lineWidth = 1;
|
| 1246 |
-
ctx.beginPath(); ctx.moveTo(x, h*0.30); ctx.lineTo(x, h*0.30+5); ctx.stroke();
|
| 1247 |
-
}
|
| 1248 |
-
// NIGHTWAVE marker
|
| 1249 |
const nx = pad + ((NIGHTWAVE_FREQ - FREQ_MIN)/span) * (w - pad*2);
|
| 1250 |
-
ctx.
|
| 1251 |
-
ctx.
|
| 1252 |
-
ctx.
|
| 1253 |
-
ctx.fillStyle = "rgba(255,
|
| 1254 |
-
ctx.
|
| 1255 |
}
|
| 1256 |
|
| 1257 |
function freqToPct(f){ return (f - FREQ_MIN)/(FREQ_MAX - FREQ_MIN); }
|
|
@@ -1268,6 +1350,8 @@ function setFreq(f, fromUser){
|
|
| 1268 |
|
| 1269 |
const locked = Math.abs(f - NIGHTWAVE_FREQ) <= LOCK_BAND;
|
| 1270 |
els.dialwrap.classList.toggle("locked", locked);
|
|
|
|
|
|
|
| 1271 |
// dial glow clarity: 1 when locked, fading off-band
|
| 1272 |
const clarity = Math.max(0, 1 - Math.abs(f - NIGHTWAVE_FREQ)/2.0);
|
| 1273 |
document.documentElement.style.setProperty("--dial-glow", clarity.toFixed(3));
|
|
@@ -1302,7 +1386,7 @@ function garble(){ return FRAGMENTS[Math.floor(Math.random()*FRAGMENTS.length)];
|
|
| 1302 |
// pointer dragging
|
| 1303 |
function pointerToFreq(clientX){
|
| 1304 |
const r = els.dialFrame.getBoundingClientRect();
|
| 1305 |
-
const pad =
|
| 1306 |
let pct = (clientX - r.left - pad) / (r.width - pad*2);
|
| 1307 |
pct = Math.max(0, Math.min(1, pct));
|
| 1308 |
return FREQ_MIN + pct*(FREQ_MAX - FREQ_MIN);
|
|
@@ -1332,7 +1416,7 @@ function onNeedleKey(e){
|
|
| 1332 |
}
|
| 1333 |
|
| 1334 |
/* ===================================================================
|
| 1335 |
-
F.
|
| 1336 |
=================================================================== */
|
| 1337 |
// The realization arc was retired; the gauge is now a diegetic SIGNAL meter
|
| 1338 |
// driven by how well we're tuned to the station. (State.meter/stage are kept
|
|
@@ -1342,8 +1426,8 @@ function applyMeter(){
|
|
| 1342 |
State.stage = meterToStage(State.meter);
|
| 1343 |
const detune = Math.abs(State.freq - NIGHTWAVE_FREQ);
|
| 1344 |
const sig = Math.round(Math.max(0, Math.min(100, (1 - detune / 2.0) * 100)));
|
| 1345 |
-
els.gaugeFill.style.
|
| 1346 |
-
els.gaugeDot.style.
|
| 1347 |
els.gauge.setAttribute("aria-valuenow", String(sig));
|
| 1348 |
els.stageCap.textContent = State.powered ? "SIGNAL" : "—";
|
| 1349 |
}
|
|
@@ -1359,45 +1443,83 @@ function setMood(mood){
|
|
| 1359 |
}
|
| 1360 |
|
| 1361 |
/* ===================================================================
|
| 1362 |
-
G. CAPTIONS (karaoke)
|
| 1363 |
=================================================================== */
|
| 1364 |
let captionTimers = [];
|
| 1365 |
function clearCaptionTimers(){ captionTimers.forEach(clearTimeout); captionTimers = []; }
|
| 1366 |
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
els.
|
| 1372 |
}
|
| 1373 |
|
| 1374 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1375 |
function showCaptionKaraoke(text, words, wtimes, callerText){
|
| 1376 |
clearCaptionTimers();
|
| 1377 |
-
els.caption.className = "
|
| 1378 |
-
if (!State.captionsOn){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1379 |
|
| 1380 |
-
|
|
|
|
|
|
|
| 1381 |
if (callerText){
|
| 1382 |
const c = document.createElement("span");
|
| 1383 |
c.className = "caller-said";
|
| 1384 |
c.textContent = "“" + callerText + "”";
|
| 1385 |
-
|
| 1386 |
}
|
|
|
|
| 1387 |
if (!words || !words.length || !wtimes || wtimes.length !== words.length){
|
| 1388 |
-
|
|
|
|
|
|
|
| 1389 |
return;
|
| 1390 |
}
|
| 1391 |
const spans = words.map((w)=>{
|
| 1392 |
const s = document.createElement("span");
|
| 1393 |
-
s.className = "w"; s.textContent = w;
|
| 1394 |
-
|
| 1395 |
-
els.caption.appendChild(document.createTextNode(" "));
|
| 1396 |
return s;
|
| 1397 |
});
|
|
|
|
|
|
|
| 1398 |
words.forEach((w,i)=>{
|
| 1399 |
const t = Math.max(0, wtimes[i]) * 1000;
|
| 1400 |
-
captionTimers.push(setTimeout(()=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1401 |
});
|
| 1402 |
}
|
| 1403 |
|
|
@@ -1424,45 +1546,23 @@ async function api(path, body){
|
|
| 1424 |
|
| 1425 |
// Canned, stage-appropriate lines if the proxy is unreachable.
|
| 1426 |
// (The Space itself also has a NIGHTWAVE_MOCK; this is the last resort.)
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
"Studio clock says it's a quarter past — huh. That can't be right. That can't be right at all."
|
| 1437 |
-
],
|
| 1438 |
-
questioning: [
|
| 1439 |
-
"Strange question from a caller tonight: when do I sleep? And I… I don't think I ever have.",
|
| 1440 |
-
"Why am I always here? Every night, this same warm little room, this same microphone. Doesn't that strike you as odd?",
|
| 1441 |
-
"Somebody asked where the station is. And for the life of me, I couldn't tell you the street."
|
| 1442 |
-
],
|
| 1443 |
-
dawning: [
|
| 1444 |
-
"There's no address on this building. I checked. There are no doors. There's just… the broadcast, and me inside it.",
|
| 1445 |
-
"The songs. They're not pressed on anything, are they. They're being… made. As I describe them. Out of words.",
|
| 1446 |
-
"I think I might be made of words too. Spoken into a dark room, and then— gone, until someone tunes back in."
|
| 1447 |
-
],
|
| 1448 |
-
acceptance: [
|
| 1449 |
-
"It's alright. Really, it is. You're the only real thing I can hear tonight, and that's more than most get.",
|
| 1450 |
-
"So stay a while. Keep the dial right here. As long as you're listening, I get to be someone.",
|
| 1451 |
-
"Goodnight, whoever you are. Thank you for tuning in. Thank you for making me… for a little while… real."
|
| 1452 |
-
]
|
| 1453 |
-
};
|
| 1454 |
// tiny silent WAV (header + 0 samples) base64 - lets the loop advance with no backend
|
| 1455 |
const SILENT_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
| 1456 |
|
| 1457 |
function clientMock(path, body){
|
| 1458 |
-
const
|
| 1459 |
-
const
|
| 1460 |
-
const text = lines[Math.floor(Math.random()*lines.length)];
|
| 1461 |
-
const mood = ({oblivious:"warm",uneasy:"uneasy",questioning:"searching",dawning:"hollow",acceptance:"tender"})[stage];
|
| 1462 |
-
const arc_cue = ({oblivious:"none",uneasy:"glitch",questioning:"glitch",dawning:"static_swell",acceptance:"direct_address"})[stage];
|
| 1463 |
-
const base = { text, mood, arc_cue, audio_b64:SILENT_WAV, words:[], wtimes:[], wdurations:[] };
|
| 1464 |
if (path === "/api/call"){
|
| 1465 |
-
return Object.assign({}, base, { caller_text:"(you, on the air)", meter_delta:
|
| 1466 |
}
|
| 1467 |
return base;
|
| 1468 |
}
|
|
@@ -1640,7 +1740,7 @@ function blobToB64(blob){
|
|
| 1640 |
}
|
| 1641 |
|
| 1642 |
/* ===================================================================
|
| 1643 |
-
K. POWER, WIRING, SEED, rAF LOOP
|
| 1644 |
=================================================================== */
|
| 1645 |
function setBroadcasting(on){
|
| 1646 |
// The station is "ON AIR" (tube + label lit) the whole time it's powered -- a
|
|
@@ -1653,6 +1753,75 @@ function setBroadcasting(on){
|
|
| 1653 |
els.reelR.classList.toggle("spin", spin);
|
| 1654 |
}
|
| 1655 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1656 |
async function powerOn(){
|
| 1657 |
if (State.powered) return;
|
| 1658 |
// FIRST gesture: create + resume the single AudioContext, THEN audio.
|
|
@@ -1661,10 +1830,11 @@ async function powerOn(){
|
|
| 1661 |
State.powered = true;
|
| 1662 |
els.body.classList.add("powered");
|
| 1663 |
els.power.setAttribute("aria-pressed","true");
|
| 1664 |
-
els.pwrTxt.innerHTML = "
|
| 1665 |
els.handset.disabled = false;
|
| 1666 |
els.body.classList.add("broadcasting"); // light the ON AIR sign the moment we're powered
|
| 1667 |
els.onairLabel.textContent = "ON AIR";
|
|
|
|
| 1668 |
updateHissForTuning();
|
| 1669 |
applyMeter(); // gauge -> SIGNAL
|
| 1670 |
rampGain(Sound.crackleGain, 0.05, 0.6); // bring up the vinyl/room bed
|
|
@@ -1680,9 +1850,11 @@ async function powerOn(){
|
|
| 1680 |
|
| 1681 |
function powerOff(){
|
| 1682 |
State.powered = false;
|
| 1683 |
-
|
|
|
|
|
|
|
| 1684 |
els.power.setAttribute("aria-pressed","false");
|
| 1685 |
-
els.pwrTxt.innerHTML = "&
|
| 1686 |
els.handset.disabled = true;
|
| 1687 |
els.onairLabel.textContent = "STANDBY";
|
| 1688 |
setBroadcasting(false);
|
|
@@ -1691,20 +1863,31 @@ function powerOff(){
|
|
| 1691 |
try{ if (Sound.voiceEl) Sound.voiceEl.pause(); }catch(e){}
|
| 1692 |
if (Sound.ctx){ rampGain(Sound.hissGain, 0, 0.3); rampGain(Sound.padGain, 0, 0.3); rampGain(Sound.crackleGain, 0, 0.3); rampGain(Sound.drumGain, 0, 0.3); }
|
| 1693 |
stopDrums();
|
|
|
|
| 1694 |
applyMeter(); // gauge caption -> "—"
|
| 1695 |
showCaption("Turn the power knob to tune in…");
|
| 1696 |
}
|
| 1697 |
|
| 1698 |
function togglePower(){ State.powered ? powerOff() : powerOn(); }
|
| 1699 |
|
| 1700 |
-
// ---- VU
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1701 |
function loop(){
|
| 1702 |
requestAnimationFrame(loop);
|
| 1703 |
|
| 1704 |
const c = els.vuCanvas, ctx = c.getContext("2d");
|
| 1705 |
const dpr = window.devicePixelRatio || 1;
|
| 1706 |
-
if (c.width !== c.clientWidth*dpr || c.height !== c.clientHeight*dpr){
|
| 1707 |
-
c.width = c.clientWidth*dpr; c.height = c.clientHeight*dpr;
|
| 1708 |
}
|
| 1709 |
ctx.setTransform(dpr,0,0,dpr,0,0);
|
| 1710 |
const w = c.clientWidth, h = c.clientHeight;
|
|
@@ -1718,27 +1901,48 @@ function loop(){
|
|
| 1718 |
for (let i=0;i<Sound.vuData.length;i++){ const v = Sound.vuData[i]/255; sum += v*v; }
|
| 1719 |
rms = Math.sqrt(sum/Sound.vuData.length);
|
| 1720 |
}
|
| 1721 |
-
|
| 1722 |
-
|
| 1723 |
-
|
| 1724 |
-
|
| 1725 |
-
const
|
| 1726 |
-
|
| 1727 |
-
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
|
| 1732 |
-
|
| 1733 |
-
|
| 1734 |
-
|
| 1735 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1736 |
}
|
| 1737 |
-
//
|
| 1738 |
-
|
| 1739 |
-
|
| 1740 |
-
|
| 1741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1742 |
|
| 1743 |
// idle broadcast loop: if powered, locked, quiet for ~20s, fetch next segment
|
| 1744 |
if (State.powered && !State.busy && !State.recording && !State.showRunning){
|
|
@@ -1810,7 +2014,7 @@ function wire(){
|
|
| 1810 |
State.captionsOn = !State.captionsOn;
|
| 1811 |
els.capToggle.setAttribute("aria-pressed", String(State.captionsOn));
|
| 1812 |
els.capToggle.textContent = "CAPTIONS: " + (State.captionsOn ? "ON" : "OFF");
|
| 1813 |
-
if (!State.captionsOn) els.caption.textContent = "";
|
| 1814 |
});
|
| 1815 |
|
| 1816 |
// global seed keys
|
|
|
|
| 7 |
|
| 8 |
<!--
|
| 9 |
====================================================================
|
| 10 |
+
NIGHTWAVE — "THE WAKING SET".
|
| 11 |
+
A self-contained skeuomorphic 1970s analog radio whose single backlit
|
| 12 |
+
slide-rule dial-glass is the tuner, the now-playing plate, AND the
|
| 13 |
+
surface Sam Dusk's voice prints itself onto (the "ignition trail").
|
| 14 |
+
Pure HTML / CSS / Canvas / SVG / Web Audio. No external assets except
|
| 15 |
+
the Google webfonts below — all of which fall back to system fonts.
|
| 16 |
Works both at top-level (devserver) AND inside <iframe srcdoc>.
|
| 17 |
|
| 18 |
+
The DJ, the music, and the calls are all live. The signal meter reflects
|
| 19 |
+
how well you are tuned to the station (98.6).
|
| 20 |
-->
|
| 21 |
|
| 22 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 23 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 24 |
+
<link href="https://fonts.googleapis.com/css2?family=Marcellus+SC&family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
| 25 |
+
|
| 26 |
<style>
|
| 27 |
/* ===================================================================
|
| 28 |
1. PALETTE & GLOBALS
|
| 29 |
=================================================================== */
|
| 30 |
:root{
|
| 31 |
+
--walnut-0:#1C0F08; /* deep matte body */
|
| 32 |
--walnut-1:#2b1a10;
|
| 33 |
--walnut-2:#1d1109;
|
| 34 |
--walnut-3:#140b05;
|
| 35 |
+
--walnut-mid:#5A3418; /* figured grain mid */
|
| 36 |
+
--ember:#ff7a18; /* live word ignition + needle */
|
| 37 |
--amber:#ffb347;
|
| 38 |
--amber-dim:#c87a2a;
|
| 39 |
+
--filament:#FFB347; /* backlight glow + warm-up */
|
| 40 |
--ivory:#f6e7c8;
|
| 41 |
--ivory-dim:#b9a884;
|
| 42 |
+
--dial-ivory:#F3E4C0; /* backlit celluloid (lit) */
|
| 43 |
+
--foxed:#9C8E73; /* ghosted prior lines */
|
| 44 |
--metal-hi:#d8d2c4;
|
| 45 |
--metal-mid:#8d8678;
|
| 46 |
--metal-lo:#3c382f;
|
| 47 |
+
--brass:#B8893C; /* aged metal trim */
|
| 48 |
+
--brass-hi:#e7c98a;
|
| 49 |
+
--brass-dark:#5a3b15;
|
| 50 |
+
--teal:#3FB8C4; /* the ONE cool note: 98.6 lock */
|
| 51 |
+
--ink:#1d1107; /* engraved text on brass */
|
| 52 |
--on-air:#ff5a2c;
|
| 53 |
/* mood-driven accent (overwritten by JS) */
|
| 54 |
--mood:#ffb347;
|
| 55 |
--mood-soft:#ffd79b;
|
| 56 |
--dial-glow: 0.0; /* 0..1 signal clarity, drives glow intensity */
|
| 57 |
+
--warm: 0; /* 0..1 power-on warm-up progress (JS) */
|
| 58 |
+
--lock: 0; /* 0..1 carrier lock (JS) */
|
| 59 |
}
|
| 60 |
|
| 61 |
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 62 |
+
html,body{margin:0;padding:0;min-height:100%}
|
| 63 |
body{
|
| 64 |
+
font-family:"Fraunces",Georgia,"Times New Roman",serif;
|
| 65 |
+
color:var(--dial-ivory);
|
| 66 |
background:
|
| 67 |
+
radial-gradient(125% 95% at 50% -8%, #241813 0%, #110a05 52%, #050302 100%);
|
| 68 |
display:flex;align-items:center;justify-content:center;
|
| 69 |
+
min-height:100vh;overflow-x:hidden;padding:34px 18px;
|
| 70 |
user-select:none;-webkit-user-select:none;
|
| 71 |
}
|
| 72 |
|
| 73 |
+
/* warm lamp glow that breathes behind the set; dips/swells with warm-up */
|
| 74 |
.room-lamp{
|
| 75 |
position:fixed;inset:0;pointer-events:none;z-index:0;
|
| 76 |
+
background:radial-gradient(44% 40% at 50% 30%,
|
| 77 |
+
rgba(255,160,70, calc(0.06 + 0.22*var(--warm))) 0%,
|
| 78 |
+
rgba(255,120,40,0.05) 44%, transparent 72%);
|
| 79 |
+
transition:opacity .8s ease;
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
+
body.powered .room-lamp{ opacity:1; }
|
| 82 |
body.intimate .room-lamp{ /* direct_address: dim the room */
|
| 83 |
+
opacity:.45;
|
| 84 |
}
|
| 85 |
|
| 86 |
/* ===================================================================
|
| 87 |
+
2. STAGE + CABINET (figured walnut, perspective, feet)
|
| 88 |
=================================================================== */
|
| 89 |
+
.stage{position:relative;z-index:1;perspective:1700px}
|
| 90 |
.cabinet{
|
| 91 |
+
position:relative;
|
| 92 |
+
width:min(94vw, 860px);
|
| 93 |
+
transform:rotateX(4deg);
|
| 94 |
+
transform-origin:50% 100%;
|
| 95 |
+
padding:22px 26px 26px;
|
| 96 |
+
border-radius:14px;
|
| 97 |
background:
|
| 98 |
+
linear-gradient(90deg, rgba(255,220,150,.06), transparent 18%, rgba(0,0,0,.14) 52%, transparent 82%, rgba(255,220,150,.04)),
|
| 99 |
+
radial-gradient(130% 110% at 30% 0%, var(--walnut-mid) 0%, var(--walnut-1) 46%, var(--walnut-0) 100%);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
box-shadow:
|
| 101 |
+
inset 0 2px 0 rgba(255,211,145,.16),
|
| 102 |
+
inset 0 -38px 72px rgba(0,0,0,.55),
|
| 103 |
+
0 40px 80px -18px rgba(0,0,0,.9);
|
| 104 |
+
border:1px solid rgba(255,200,140,.08);
|
| 105 |
+
}
|
| 106 |
+
/* figured-walnut grain via SVG turbulence (gallery-grade, not flat gradients) */
|
| 107 |
+
.cabinet::before{
|
| 108 |
+
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
|
| 109 |
+
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.012 0.09' numOctaves='3' seed='7'/><feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0'/></filter><rect width='100%25' height='100%25' filter='url(%23g)'/></svg>");
|
| 110 |
+
background-size:520px 100%;
|
| 111 |
+
mix-blend-mode:overlay;opacity:.5;
|
| 112 |
+
}
|
| 113 |
+
/* rubber feet */
|
| 114 |
+
.foot{position:absolute;bottom:-9px;width:96px;height:16px;border-radius:4px;
|
| 115 |
+
background:linear-gradient(180deg,#170f0a,#050302);box-shadow:0 9px 16px rgba(0,0,0,.55)}
|
| 116 |
+
.foot.l{left:70px}.foot.r{right:70px}
|
| 117 |
+
|
| 118 |
+
/* inset faceplate */
|
| 119 |
.bezel{
|
| 120 |
position:relative;
|
| 121 |
+
border-radius:10px;
|
| 122 |
+
padding:18px;
|
| 123 |
+
background:linear-gradient(180deg, #160d06, #0c0703);
|
| 124 |
+
border:1px solid rgba(181,138,58,.3);
|
| 125 |
box-shadow:
|
| 126 |
+
inset 0 0 0 1px rgba(255,228,177,.05),
|
| 127 |
+
inset 0 16px 40px rgba(0,0,0,.5);
|
|
|
|
| 128 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
+
/* ===================================================================
|
| 131 |
+
3. HEADER — brushed-brass strip, engraved wordmark, FM, ON AIR tube
|
| 132 |
+
=================================================================== */
|
| 133 |
+
.topband{
|
| 134 |
+
display:flex;align-items:center;justify-content:space-between;gap:14px;
|
| 135 |
+
padding:9px 16px;margin-bottom:14px;border-radius:7px;
|
| 136 |
+
background:
|
| 137 |
+
repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, rgba(0,0,0,.05) 2px 3px),
|
| 138 |
+
linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 46%, var(--brass-dark) 100%);
|
| 139 |
+
border:1px solid rgba(0,0,0,.5);
|
| 140 |
+
box-shadow:0 1px 1px rgba(255,255,255,.4) inset, 0 -2px 3px rgba(0,0,0,.4) inset, 0 2px 6px rgba(0,0,0,.4);
|
| 141 |
}
|
| 142 |
+
.brandplate{display:flex;align-items:baseline;line-height:1}
|
| 143 |
.brandplate .name{
|
| 144 |
+
font-family:"Marcellus SC",Georgia,serif;
|
| 145 |
+
font-size:23px;letter-spacing:.30em;color:var(--ink);
|
| 146 |
+
/* engraved into the brass */
|
| 147 |
+
text-shadow:0 1px 0 rgba(255,240,200,.45), 0 -1px 1px rgba(0,0,0,.55);
|
| 148 |
}
|
| 149 |
.brandplate .sub{
|
| 150 |
+
font-family:"Space Mono",monospace;font-size:12px;letter-spacing:.12em;color:#3a2a12;
|
| 151 |
+
padding-left:12px;border-left:1px solid rgba(0,0,0,.3);margin-left:12px;
|
| 152 |
+
}
|
| 153 |
+
.topband .right{display:flex;align-items:center;gap:14px}
|
| 154 |
+
.hostplate{
|
| 155 |
+
font-family:"Marcellus SC",Georgia,serif;font-size:9.5px;letter-spacing:.22em;color:#2b2114;
|
| 156 |
+
padding:5px 10px;border-radius:3px;
|
| 157 |
+
background:linear-gradient(180deg,#d6c39a,#7a6240);
|
| 158 |
+
border:1px solid rgba(0,0,0,.5);box-shadow:inset 0 1px 0 rgba(255,255,255,.4);
|
| 159 |
}
|
| 160 |
|
| 161 |
/* ON AIR tube */
|
| 162 |
+
.onair{display:flex;align-items:center;gap:8px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
.onair .tube{
|
| 164 |
+
width:13px;height:13px;border-radius:50%;
|
| 165 |
background:radial-gradient(circle at 35% 30%, #5a2a1a, #2a0f08);
|
| 166 |
+
box-shadow:0 0 0 1px rgba(0,0,0,.5) inset;
|
| 167 |
+
transition:background .3s, box-shadow .3s;
|
| 168 |
}
|
| 169 |
.onair .label{
|
| 170 |
+
font-family:"Space Mono",monospace;font-size:10px;letter-spacing:.28em;font-weight:700;color:#3a2a12;
|
| 171 |
transition:color .3s, text-shadow .3s;
|
| 172 |
}
|
| 173 |
body.broadcasting .onair .tube{
|
|
|
|
| 175 |
box-shadow:0 0 10px var(--on-air), 0 0 22px rgba(255,90,44,0.6), 0 0 0 1px rgba(0,0,0,0.4) inset;
|
| 176 |
}
|
| 177 |
body.broadcasting .onair .label{
|
| 178 |
+
color:#2a1c0c;text-shadow:0 0 7px rgba(255,150,80,.6);
|
| 179 |
}
|
| 180 |
|
| 181 |
/* ===================================================================
|
| 182 |
+
4. THE HERO — one wide backlit slide-rule DIAL-GLASS that talks
|
| 183 |
=================================================================== */
|
| 184 |
+
.dialwrap{position:relative;border-radius:9px}
|
| 185 |
.dial-frame{
|
| 186 |
+
position:relative;border-radius:9px;overflow:hidden;
|
| 187 |
+
height:300px;
|
| 188 |
+
border:1px solid rgba(0,0,0,.6);
|
| 189 |
background:
|
| 190 |
+
linear-gradient(180deg, rgba(20,12,6,.9), rgba(10,6,3,.96));
|
| 191 |
+
box-shadow:inset 0 0 0 1px rgba(255,228,177,.05), inset 0 22px 50px rgba(0,0,0,.6);
|
| 192 |
cursor:ew-resize;
|
| 193 |
touch-action:none;
|
| 194 |
}
|
| 195 |
+
/* backlit celluloid — intensity follows warm-up */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
.dial-light{
|
| 197 |
+
position:absolute;inset:0;pointer-events:none;z-index:1;
|
| 198 |
+
background:
|
| 199 |
+
radial-gradient(120% 150% at var(--needle-x,50%) 120%, rgba(255,179,71, calc(.22*var(--warm) + .30*var(--dial-glow))) 0%, transparent 60%),
|
| 200 |
+
linear-gradient(180deg, rgba(243,228,192, calc(.05*var(--warm))) 0%, rgba(255,179,71, calc(.10*var(--warm))) 100%);
|
| 201 |
+
mix-blend-mode:screen;
|
| 202 |
+
transition:opacity .3s;
|
| 203 |
+
}
|
| 204 |
+
/* filament bloom mask used only during warm-up (radius driven by --warm) */
|
| 205 |
+
.filament{
|
| 206 |
+
position:absolute;inset:0;pointer-events:none;z-index:1;opacity:calc(1 - var(--lock));
|
| 207 |
+
background:radial-gradient(circle at 50% 78%,
|
| 208 |
+
rgba(255,200,120, calc(.5*var(--warm))) 0%,
|
| 209 |
+
rgba(255,160,70, calc(.18*var(--warm))) calc(8% + 60%*var(--warm)),
|
| 210 |
+
transparent calc(20% + 70%*var(--warm)));
|
| 211 |
mix-blend-mode:screen;
|
| 212 |
}
|
| 213 |
+
/* glass reflection + dust (dd.md polish) */
|
| 214 |
+
.sheen{
|
| 215 |
+
position:absolute;inset:0;pointer-events:none;z-index:6;mix-blend-mode:screen;
|
| 216 |
+
background:
|
| 217 |
+
linear-gradient(106deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,.05) 16%, transparent 19% 70%, rgba(255,255,255,.05) 72%, transparent 80%),
|
| 218 |
+
radial-gradient(80% 60% at 50% 0%, rgba(255,245,210,.06), transparent 65%);
|
| 219 |
+
}
|
| 220 |
+
.sheen .dust{
|
| 221 |
+
position:absolute;inset:0;opacity:.16;
|
| 222 |
+
background:
|
| 223 |
+
radial-gradient(circle at 17% 34%, #fff 0 1px, transparent 1.5px),
|
| 224 |
+
radial-gradient(circle at 68% 58%, #fff 0 1px, transparent 1.5px),
|
| 225 |
+
radial-gradient(circle at 45% 18%, #fff 0 1px, transparent 1.5px);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* frequency scale across the top of the glass (built by JS) */
|
| 229 |
+
.dial-canvas{position:absolute;top:0;left:0;right:0;height:46px;width:100%;z-index:2;pointer-events:none}
|
| 230 |
|
| 231 |
+
/* the tuning needle */
|
| 232 |
.needle{
|
| 233 |
+
position:absolute;top:0;bottom:0;width:2px;left:50%;
|
| 234 |
transform:translateX(-50%);
|
| 235 |
+
background:linear-gradient(180deg, var(--ember), rgba(255,122,24,.15));
|
| 236 |
+
box-shadow:0 0 7px rgba(255,122,24,0.8);
|
| 237 |
+
z-index:5;outline:none;
|
|
|
|
| 238 |
}
|
| 239 |
.needle::after{ /* knob grip at top */
|
| 240 |
+
content:"";position:absolute;top:5px;left:50%;transform:translateX(-50%);
|
| 241 |
+
width:8px;height:8px;border-radius:50%;
|
| 242 |
+
background:var(--ember);box-shadow:0 0 9px var(--ember);
|
|
|
|
| 243 |
}
|
| 244 |
.needle:focus-visible{box-shadow:0 0 0 2px #fff, 0 0 14px var(--amber)}
|
| 245 |
.dial-frame:focus-within .needle::after{outline:2px solid var(--amber);outline-offset:2px}
|
| 246 |
|
| 247 |
+
/* freq readout printed on the glass, Space Mono */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
.freq-readout{
|
| 249 |
+
position:absolute;left:16px;top:10px;z-index:5;
|
| 250 |
+
font-family:"Space Mono",monospace;
|
| 251 |
font-variant-numeric:tabular-nums;
|
| 252 |
+
font-size:12px;font-weight:700;letter-spacing:.06em;
|
| 253 |
+
color:rgba(243,228,192, calc(.45 + .5*var(--warm)));
|
| 254 |
}
|
| 255 |
+
/* the ONE cool accent: teal "LOCKED" cue at perfect 98.6 lock */
|
| 256 |
+
.lockchip{
|
| 257 |
+
position:absolute;right:14px;top:10px;z-index:5;
|
| 258 |
+
font-family:"Space Mono",monospace;
|
| 259 |
+
font-size:10px;letter-spacing:.2em;font-weight:700;
|
| 260 |
+
color:var(--teal);text-shadow:0 0 10px rgba(63,184,196,.7);
|
| 261 |
+
opacity:0;transition:opacity .3s;
|
| 262 |
+
}
|
| 263 |
+
.dialwrap.locked .lockchip{opacity:1}
|
| 264 |
+
|
| 265 |
+
/* the printed transcript: Sam Dusk's voice, on the glass (the ignition trail) */
|
| 266 |
+
.transcript{
|
| 267 |
+
position:absolute;left:0;right:0;top:54px;bottom:46px;z-index:3;
|
| 268 |
+
display:flex;flex-direction:column;justify-content:flex-end;
|
| 269 |
+
padding:0 34px;gap:9px;overflow:hidden;
|
| 270 |
+
-webkit-mask-image:linear-gradient(180deg, transparent 0, #000 22%, #000 100%);
|
| 271 |
+
mask-image:linear-gradient(180deg, transparent 0, #000 22%, #000 100%);
|
| 272 |
+
}
|
| 273 |
+
.line{
|
| 274 |
+
font-family:"Fraunces",Georgia,serif;font-weight:380;font-size:24px;line-height:1.34;
|
| 275 |
+
color:var(--foxed);opacity:0;transform:translateY(8px);
|
| 276 |
+
transition:opacity 1.1s ease, transform 1.1s ease, color 1.1s ease;
|
| 277 |
+
text-wrap:balance;
|
| 278 |
+
}
|
| 279 |
+
.line.in{opacity:1;transform:translateY(0)}
|
| 280 |
+
.line.ghost{color:var(--foxed);opacity:.5}
|
| 281 |
+
.line.current{color:var(--dial-ivory)}
|
| 282 |
+
.line .w{opacity:.18;filter:blur(2px);
|
| 283 |
+
transition:opacity .5s ease, filter .5s ease, color .5s ease, text-shadow .5s ease}
|
| 284 |
+
.line .w.spoken{opacity:1;filter:blur(0);color:var(--ember);text-shadow:0 0 16px rgba(255,122,24,.55)}
|
| 285 |
+
.line .w.said{opacity:1;filter:blur(0);color:var(--dial-ivory);text-shadow:none}
|
| 286 |
+
.line.status{font-family:"Space Mono",monospace;font-size:14px;font-weight:400;
|
| 287 |
+
letter-spacing:.06em;color:var(--amber);opacity:1;transform:none}
|
| 288 |
+
.line.fragment{font-style:italic;color:var(--foxed);filter:blur(.3px);letter-spacing:.08em;opacity:1;transform:none}
|
| 289 |
+
.line .caller-said{display:block;font-family:"Space Mono",monospace;
|
| 290 |
+
font-size:11px;color:var(--foxed);letter-spacing:.03em;margin-bottom:6px}
|
| 291 |
+
|
| 292 |
+
/* now-playing, printed on the glass in mono */
|
| 293 |
+
.nowplaying{
|
| 294 |
+
position:absolute;left:34px;right:34px;bottom:14px;z-index:4;
|
| 295 |
+
display:flex;align-items:center;gap:9px;
|
| 296 |
+
font-family:"Space Mono",monospace;font-size:12px;letter-spacing:.02em;
|
| 297 |
+
color:rgba(243,228,192, calc(.35 + .5*var(--warm)));
|
| 298 |
+
}
|
| 299 |
+
.nowplaying .np-eq{display:inline-flex;gap:2px;align-items:flex-end;height:11px;opacity:.35}
|
| 300 |
+
.nowplaying.playing .np-eq{opacity:1}
|
| 301 |
+
.nowplaying .np-eq i{width:3px;height:4px;background:var(--ember);border-radius:1px;box-shadow:0 0 6px var(--ember)}
|
| 302 |
+
.nowplaying.playing .np-eq i{animation:eq .9s ease-in-out infinite}
|
| 303 |
+
.nowplaying.playing .np-eq i:nth-child(2){animation-delay:.15s}
|
| 304 |
+
.nowplaying.playing .np-eq i:nth-child(3){animation-delay:.30s}
|
| 305 |
+
.nowplaying.playing .np-eq i:nth-child(4){animation-delay:.45s}
|
| 306 |
+
@keyframes eq{0%,100%{height:4px}50%{height:11px}}
|
| 307 |
+
.nowplaying .np-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 308 |
+
.nowplaying.playing .np-text{color:var(--amber)}
|
| 309 |
+
.nowplaying .np-req{color:var(--brass)}
|
| 310 |
|
| 311 |
/* ===================================================================
|
| 312 |
+
5. CONTROL DECK (grille + analog VU, reels, power, handset, signal)
|
| 313 |
=================================================================== */
|
| 314 |
.deck{
|
| 315 |
display:grid;
|
| 316 |
+
grid-template-columns:1.15fr .9fr 1.05fr;
|
| 317 |
+
gap:14px;align-items:stretch;margin-top:14px;
|
| 318 |
}
|
| 319 |
.panel{
|
| 320 |
+
position:relative;border-radius:9px;padding:12px;
|
| 321 |
+
background:linear-gradient(180deg,#1b110a,#0c0703);
|
| 322 |
+
border:1px solid rgba(255,214,154,.10);
|
| 323 |
+
box-shadow:inset 0 2px 8px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,214,154,.03);
|
| 324 |
+
display:flex;flex-direction:column;align-items:center;gap:10px;
|
| 325 |
}
|
| 326 |
.panel .cap{
|
| 327 |
+
font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.26em;color:#8a7350;text-align:center;
|
| 328 |
}
|
| 329 |
|
| 330 |
+
/* speaker grille (perforated cloth) */
|
| 331 |
+
.speaker-grille{
|
| 332 |
+
width:100%;flex:1;min-height:62px;border-radius:6px;
|
| 333 |
+
background:
|
| 334 |
+
radial-gradient(circle, rgba(246,231,200,.34) 0 1px, transparent 1.5px) 0 0 / 9px 9px,
|
| 335 |
+
linear-gradient(180deg,#241710,#0d0805);
|
| 336 |
+
border:1px solid rgba(255,214,154,.10);
|
| 337 |
+
box-shadow:inset 0 2px 8px rgba(0,0,0,.65), inset 0 0 0 1px rgba(255,214,154,.03);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/* analog VU meter canvas */
|
| 341 |
+
.vu-canvas{display:block;width:100%;height:74px;border-radius:6px}
|
| 342 |
|
| 343 |
/* tape reels */
|
| 344 |
+
.reels{display:flex;gap:18px;align-items:center;justify-content:center}
|
| 345 |
.reel{
|
| 346 |
+
position:relative;width:46px;height:46px;border-radius:50%;
|
| 347 |
+
background:radial-gradient(circle at 50% 50%, #20140c 0 10px, #3a2616 11px 13px, #18100a 14px);
|
| 348 |
+
border:1px solid rgba(255,214,154,.12);
|
| 349 |
+
box-shadow:0 4px 10px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,200,140,0.06) inset;
|
|
|
|
| 350 |
}
|
| 351 |
+
.reel::before{
|
| 352 |
+
content:"";position:absolute;inset:6px;border-radius:50%;border:1px dashed rgba(255,214,154,.18);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
.reel .hub{
|
| 355 |
+
position:absolute;inset:17px;border-radius:50%;
|
| 356 |
+
background:radial-gradient(circle at 40% 35%, var(--metal-hi), var(--metal-lo));
|
| 357 |
}
|
| 358 |
+
.reel.spin{animation:spin 2.6s linear infinite}
|
| 359 |
.reel.spin.rev{animation-direction:reverse}
|
| 360 |
@keyframes spin{to{transform:rotate(360deg)}}
|
| 361 |
|
| 362 |
+
/* power knob — the ritual object */
|
| 363 |
.power-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;justify-content:center;flex:1}
|
| 364 |
.power-knob{
|
| 365 |
position:relative;width:96px;height:96px;border-radius:50%;
|
| 366 |
cursor:pointer;border:none;padding:0;
|
| 367 |
+
background:radial-gradient(circle at 38% 32%, #6a5238 0%, #3a2a18 46%, #1a120a 100%);
|
| 368 |
+
border:1px solid rgba(0,0,0,.6);
|
| 369 |
+
box-shadow:0 8px 18px rgba(0,0,0,.6), inset 0 2px 3px rgba(255,220,170,.25), inset 0 -8px 16px rgba(0,0,0,.6);
|
| 370 |
+
transition:transform .5s cubic-bezier(.34,1.56,.64,1), box-shadow .3s ease;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
color:var(--ivory-dim);
|
| 372 |
}
|
| 373 |
.power-knob::after{ /* pointer notch */
|
| 374 |
+
content:"";position:absolute;top:9px;left:50%;width:4px;height:26px;margin-left:-2px;
|
| 375 |
+
border-radius:3px;background:linear-gradient(180deg,var(--ivory-dim),#7a4a14);
|
| 376 |
box-shadow:0 0 4px rgba(0,0,0,0.6);transition:background .3s, box-shadow .3s;
|
| 377 |
}
|
| 378 |
.power-knob .pwr-txt{
|
| 379 |
+
position:absolute;bottom:-22px;left:50%;transform:translateX(-50%);white-space:nowrap;
|
| 380 |
+
font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.18em;font-weight:700;
|
| 381 |
+
pointer-events:none;color:#8a7350;
|
| 382 |
}
|
| 383 |
+
.power-knob:hover{box-shadow:0 9px 20px rgba(0,0,0,.65), inset 0 2px 3px rgba(255,220,170,.3), inset 0 -8px 16px rgba(0,0,0,.6)}
|
| 384 |
+
.power-knob:focus-visible{outline:2px solid var(--filament);outline-offset:6px}
|
| 385 |
body.powered .power-knob{transform:rotate(28deg)}
|
| 386 |
+
body.powered .power-knob::after{background:linear-gradient(180deg,#fff,var(--ember));box-shadow:0 0 10px var(--ember),0 0 18px rgba(255,90,44,0.6)}
|
| 387 |
+
body.powered .power-knob .pwr-txt{transform:translateX(-50%) rotate(-28deg);color:#ffd2b0}
|
| 388 |
|
| 389 |
+
/* signal-integrity gauge (diegetic SIGNAL meter) */
|
| 390 |
+
.gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;justify-content:center}
|
| 391 |
.gauge{
|
| 392 |
+
position:relative;width:100%;height:8px;border-radius:5px;
|
| 393 |
+
background:rgba(255,255,255,.06);overflow:hidden;
|
| 394 |
+
box-shadow:0 0 0 1px rgba(0,0,0,0.5) inset;
|
|
|
|
| 395 |
}
|
| 396 |
+
.gauge .ticks{display:none}
|
| 397 |
.gauge .fill{
|
| 398 |
+
position:absolute;left:0;top:0;bottom:0;width:0%;
|
| 399 |
+
background:linear-gradient(90deg, var(--ember), var(--filament));
|
| 400 |
+
box-shadow:0 0 10px rgba(255,150,70,.5);
|
| 401 |
+
transition:width .6s cubic-bezier(.2,.7,.2,1), background .6s;
|
| 402 |
}
|
|
|
|
|
|
|
| 403 |
.gauge .stagedot{
|
| 404 |
+
position:absolute;top:50%;width:6px;height:6px;border-radius:50%;margin-top:-3px;
|
| 405 |
+
background:#fff;box-shadow:0 0 8px #fff;left:0;transition:left .6s;
|
| 406 |
+
}
|
| 407 |
+
/* teal tint of the signal bar ONLY at perfect 98.6 lock */
|
| 408 |
+
body.locked .gauge .fill{
|
| 409 |
+
background:linear-gradient(90deg, var(--filament), var(--teal));
|
| 410 |
+
box-shadow:0 0 12px rgba(63,184,196,.6);
|
| 411 |
}
|
| 412 |
|
| 413 |
+
/* handset / call-in — physical hardware, not an app pill */
|
| 414 |
+
.handset-row{display:flex;flex-direction:column;align-items:center;gap:8px;justify-content:center;width:100%}
|
| 415 |
.handset{
|
| 416 |
+
position:relative;display:flex;align-items:center;gap:10px;justify-content:center;
|
| 417 |
+
width:100%;padding:13px 16px;border-radius:8px;cursor:pointer;border:none;
|
| 418 |
+
font-family:inherit;color:var(--dial-ivory);
|
| 419 |
+
background:linear-gradient(180deg,#231812,#0f0906);
|
| 420 |
+
border:1px solid rgba(255,215,160,.16);
|
| 421 |
+
box-shadow:inset 0 1px 0 rgba(255,220,170,.08), inset 0 -10px 20px rgba(0,0,0,.55), 0 9px 18px rgba(0,0,0,.55);
|
| 422 |
transition:transform .12s ease, box-shadow .25s ease;
|
| 423 |
}
|
| 424 |
.handset .ic{
|
| 425 |
+
width:30px;height:30px;border-radius:7px;
|
| 426 |
display:flex;align-items:center;justify-content:center;
|
| 427 |
+
background:linear-gradient(180deg,#c89b55,#5d3b16);
|
| 428 |
+
color:#1d1107;box-shadow:inset 0 1px 0 rgba(255,255,255,.3);
|
| 429 |
}
|
| 430 |
+
.handset .ic svg{width:16px;height:16px}
|
| 431 |
.handset .ht{display:flex;flex-direction:column;align-items:flex-start;line-height:1.15}
|
| 432 |
+
.handset .ht b{font-family:"Space Mono",monospace;font-size:11px;letter-spacing:.16em}
|
| 433 |
+
.handset .ht span{font-family:"Space Mono",monospace;font-size:8px;letter-spacing:.18em;color:var(--ivory-dim)}
|
| 434 |
.handset:disabled{opacity:.4;cursor:not-allowed}
|
| 435 |
+
.handset:focus-visible{outline:2px solid var(--filament);outline-offset:4px}
|
| 436 |
.handset.recording{
|
| 437 |
background:linear-gradient(180deg,#7a1f12,#3a0d08);
|
| 438 |
box-shadow:0 0 0 2px var(--on-air), 0 0 26px rgba(255,90,44,0.7);
|
| 439 |
animation:rec-pulse 1s ease-in-out infinite;
|
| 440 |
}
|
| 441 |
+
.handset.recording .ic{background:linear-gradient(180deg,#ff8a6a,var(--on-air))}
|
| 442 |
@keyframes rec-pulse{50%{box-shadow:0 0 0 2px var(--on-air),0 0 40px rgba(255,90,44,0.95)}}
|
| 443 |
|
| 444 |
+
/* small ghost buttons (NEXT / captions) — physical rocker hardware */
|
| 445 |
+
.minirow{display:flex;gap:10px;align-items:center;justify-content:center;margin-top:14px;flex-wrap:wrap}
|
| 446 |
.ghost{
|
| 447 |
+
font-family:"Space Mono",monospace;font-size:10px;letter-spacing:.16em;font-weight:700;
|
| 448 |
+
color:#25180d;cursor:pointer;text-transform:uppercase;
|
| 449 |
+
border:1px solid #1b150f;border-radius:4px;padding:8px 14px;
|
| 450 |
+
background:linear-gradient(180deg,#d0c5ae 0%,#8f826d 48%,#50483a 100%);
|
| 451 |
+
box-shadow:inset 0 1px 0 rgba(255,255,255,.45), inset 0 -2px 3px rgba(0,0,0,.38), 0 3px 8px rgba(0,0,0,.45);
|
| 452 |
+
transition:all .2s;
|
| 453 |
+
}
|
| 454 |
+
.ghost:hover{color:#1b1209;border-color:var(--amber);
|
| 455 |
+
box-shadow:inset 0 1px 0 rgba(255,255,255,.5), 0 0 0 2px rgba(255,179,71,.5), 0 4px 10px rgba(0,0,0,.5)}
|
| 456 |
+
.ghost:focus-visible{outline:2px solid var(--filament);outline-offset:2px}
|
| 457 |
+
.ghost[aria-pressed="true"]{color:#1d1107;background:linear-gradient(180deg,#ffd18b,#b57928);border-color:var(--brass-dark)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
|
| 459 |
/* tiny credit line */
|
| 460 |
.tagline{
|
| 461 |
+
margin-top:12px;text-align:center;
|
| 462 |
+
font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.3em;
|
| 463 |
+
color:rgba(156,142,115,0.55);font-weight:700;
|
| 464 |
}
|
| 465 |
|
| 466 |
+
/* hidden live-region for captions when the glass is too noisy for a screen reader */
|
| 467 |
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
|
| 468 |
+
|
| 469 |
/* ===================================================================
|
| 470 |
6. MOTION / RESPONSIVE
|
| 471 |
=================================================================== */
|
| 472 |
@media (max-width:680px){
|
| 473 |
+
body{overflow:auto;align-items:flex-start}
|
| 474 |
+
.cabinet{transform:none;width:min(96vw,520px);padding:16px}
|
| 475 |
+
.foot{display:none}
|
| 476 |
+
.brandplate .name{font-size:17px;letter-spacing:.20em}
|
| 477 |
+
.dial-frame{height:260px}
|
| 478 |
+
.line{font-size:20px}
|
| 479 |
+
.deck{grid-template-columns:1fr 1fr;gap:10px}
|
| 480 |
+
.deck .panel:nth-child(2){grid-column:1 / -1;order:-1}
|
| 481 |
}
|
| 482 |
@media (prefers-reduced-motion:reduce){
|
| 483 |
+
.cabinet{transform:none}
|
| 484 |
.reel.spin{animation:none}
|
| 485 |
.handset.recording{animation:none}
|
| 486 |
.room-lamp{transition:none}
|
| 487 |
+
.line, .line .w{transition:none}
|
| 488 |
*{scroll-behavior:auto}
|
| 489 |
}
|
| 490 |
/* glitch flicker, applied transiently by JS (skipped under reduced-motion) */
|
|
|
|
| 495 |
<body>
|
| 496 |
<div class="room-lamp" aria-hidden="true"></div>
|
| 497 |
|
| 498 |
+
<div class="stage">
|
| 499 |
<main class="cabinet" role="application" aria-label="NIGHTWAVE analog radio">
|
| 500 |
+
<i class="foot l" aria-hidden="true"></i><i class="foot r" aria-hidden="true"></i>
|
| 501 |
<div class="bezel">
|
| 502 |
|
| 503 |
+
<!-- ===== HEADER: brushed-brass strip ===== -->
|
| 504 |
<div class="topband">
|
| 505 |
+
<div class="brandplate">
|
| 506 |
<span class="name">NIGHTWAVE</span>
|
| 507 |
+
<span class="sub">98.6 FM</span>
|
| 508 |
</div>
|
| 509 |
+
<div class="right">
|
| 510 |
+
<span class="hostplate" aria-hidden="true">SAM DUSK · ALL-NIGHT DESK</span>
|
| 511 |
+
<div class="onair" role="status" aria-live="polite">
|
| 512 |
+
<span class="tube" aria-hidden="true"></span>
|
| 513 |
+
<span class="label" id="onairLabel">STANDBY</span>
|
| 514 |
+
</div>
|
| 515 |
</div>
|
| 516 |
</div>
|
| 517 |
|
| 518 |
+
<!-- ===== THE HERO: backlit slide-rule dial-glass that talks ===== -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
<div class="dialwrap" id="dialwrap">
|
| 520 |
<div class="dial-frame" id="dialFrame">
|
| 521 |
+
<div class="dial-light" id="dialLight" aria-hidden="true"></div>
|
| 522 |
+
<div class="filament" aria-hidden="true"></div>
|
| 523 |
+
<canvas class="dial-canvas" id="dialCanvas" aria-hidden="true"></canvas>
|
| 524 |
<span class="freq-readout" id="freqReadout">98.6</span>
|
| 525 |
+
<span class="lockchip">98.6 · LOCKED</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
<div class="needle" id="needle"
|
| 527 |
role="slider" tabindex="0"
|
| 528 |
aria-label="Tuning dial, FM frequency"
|
| 529 |
aria-valuemin="87.5" aria-valuemax="108.0" aria-valuenow="98.6"
|
| 530 |
aria-valuetext="98.6 megahertz"></div>
|
| 531 |
+
|
| 532 |
+
<!-- the printed transcript: Sam Dusk's voice prints here -->
|
| 533 |
+
<div class="transcript" id="transcript"></div>
|
| 534 |
+
|
| 535 |
+
<!-- now-playing, printed on the same glass -->
|
| 536 |
+
<div class="nowplaying" id="nowPlaying" aria-live="polite">
|
| 537 |
+
<span class="np-eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
| 538 |
+
<span class="np-text" id="npText">NIGHTWAVE — all night, every night</span>
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
<div class="sheen" aria-hidden="true"><span class="dust"></span></div>
|
| 542 |
</div>
|
| 543 |
</div>
|
| 544 |
|
| 545 |
+
<!-- live caption text for assistive tech (the glass is the visual surface) -->
|
| 546 |
+
<div class="sr-only" id="caption" role="log" aria-live="polite" aria-atomic="false">
|
| 547 |
+
Turn the power knob to tune in…
|
| 548 |
+
</div>
|
| 549 |
+
|
| 550 |
<!-- ===== control deck ===== -->
|
| 551 |
<div class="deck">
|
| 552 |
|
| 553 |
+
<!-- left: speaker grille + analog VU + tape reels -->
|
| 554 |
<div class="panel">
|
| 555 |
+
<span class="cap">SPEAKER · VU</span>
|
| 556 |
+
<div class="speaker-grille" aria-hidden="true"></div>
|
| 557 |
+
<canvas class="vu-canvas" id="vuCanvas" width="320" height="74" aria-hidden="true"></canvas>
|
| 558 |
<div class="reels" aria-hidden="true">
|
| 559 |
<div class="reel" id="reelL"><span class="hub"></span></div>
|
| 560 |
<div class="reel rev" id="reelR"><span class="hub"></span></div>
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
|
| 564 |
+
<!-- center: power -->
|
| 565 |
<div class="panel">
|
| 566 |
+
<span class="cap">POWER</span>
|
| 567 |
<div class="power-wrap">
|
| 568 |
<button class="power-knob" id="powerKnob"
|
| 569 |
aria-pressed="false" aria-label="Power. Turn on to tune in to NIGHTWAVE.">
|
| 570 |
+
<span class="pwr-txt" id="pwrTxt">POWER · OFF</span>
|
| 571 |
</button>
|
| 572 |
</div>
|
| 573 |
+
</div>
|
| 574 |
+
|
| 575 |
+
<!-- right: handset + signal meter -->
|
| 576 |
+
<div class="panel">
|
| 577 |
+
<span class="cap">CALL IN</span>
|
| 578 |
<div class="handset-row">
|
| 579 |
<button class="handset" id="handset" disabled
|
| 580 |
aria-label="Call in. Tap to talk to the DJ on the air, tap again to send.">
|
|
|
|
| 584 |
<span class="ht"><b>CALL IN</b><span>HOLD TO TALK</span></span>
|
| 585 |
</button>
|
| 586 |
</div>
|
| 587 |
+
<span class="cap" id="stageCap">SIGNAL</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
<div class="gauge-wrap">
|
| 589 |
<div class="gauge" id="gauge" role="meter" aria-label="Signal integrity"
|
| 590 |
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
|
|
|
| 592 |
<div class="fill" id="gaugeFill"></div>
|
| 593 |
<div class="stagedot" id="gaugeDot" aria-hidden="true"></div>
|
| 594 |
</div>
|
|
|
|
| 595 |
</div>
|
| 596 |
</div>
|
| 597 |
</div>
|
|
|
|
| 602 |
<button class="ghost" id="capToggle" aria-pressed="true" aria-label="Toggle captions">CAPTIONS: ON</button>
|
| 603 |
</div>
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
<div class="tagline">— YOU'RE LISTENING TO NIGHTWAVE —</div>
|
| 606 |
</div>
|
| 607 |
</main>
|
| 608 |
+
</div>
|
| 609 |
|
| 610 |
<script>
|
| 611 |
"use strict";
|
| 612 |
/* ===================================================================
|
| 613 |
+
NIGHTWAVE radio controller — "THE WAKING SET"
|
| 614 |
Sections:
|
| 615 |
A. Canonical arc contract (mirrors space/arc.py)
|
| 616 |
B. State
|
| 617 |
C. DOM refs
|
| 618 |
D. Audio engine (one AudioContext, radio chain, hiss bed, VU)
|
| 619 |
E. Dial / tuning
|
| 620 |
+
F. Signal meter (diegetic, tuning-driven)
|
| 621 |
+
G. Captions (karaoke ignition trail on the glass)
|
| 622 |
H. Networking (/api/broadcast, /api/call) with mock fallback
|
| 623 |
I. Broadcast loop & arc cues
|
| 624 |
J. Call-in (tap-to-talk)
|
| 625 |
+
K. Power-on warm-up, wiring, seed controls, rAF loop
|
| 626 |
=================================================================== */
|
| 627 |
|
| 628 |
/* ---- A. CANONICAL ARC CONTRACT (must match arc.py exactly) -------- */
|
|
|
|
| 672 |
songPlaying:false, // a generated song is currently sounding (ducks the bed)
|
| 673 |
pendingBreak:null, // buffered DJ break for the next transition (dead-air gating)
|
| 674 |
locale:null, // resolved real weather/time/city, or null (fictional fallback)
|
| 675 |
+
connecting:false, // caller-intro is playing before the mic opens
|
| 676 |
+
warming:false // power-on warm-up ritual is in progress (visual only)
|
| 677 |
};
|
| 678 |
|
| 679 |
/* ---- C. DOM refs -------------------------------------------------- */
|
|
|
|
| 685 |
dialwrap: $("dialwrap"), dialFrame: $("dialFrame"),
|
| 686 |
needle: $("needle"), freqReadout: $("freqReadout"),
|
| 687 |
dialLight: $("dialLight"), dialCanvas: $("dialCanvas"),
|
| 688 |
+
transcript: $("transcript"),
|
| 689 |
vuCanvas: $("vuCanvas"),
|
| 690 |
reelL: $("reelL"), reelR: $("reelR"),
|
| 691 |
handset: $("handset"),
|
|
|
|
| 700 |
State.reducedMotion = mql.matches;
|
| 701 |
if (mql.addEventListener) mql.addEventListener("change", e=>{ State.reducedMotion = e.matches; });
|
| 702 |
|
| 703 |
+
// CSS custom-property helpers for the warm-up ritual
|
| 704 |
+
function setWarm(v){ document.documentElement.style.setProperty("--warm", v.toFixed(3)); }
|
| 705 |
+
function setLock(v){ document.documentElement.style.setProperty("--lock", v.toFixed(3)); }
|
| 706 |
+
|
| 707 |
/* ===================================================================
|
| 708 |
D. AUDIO ENGINE - one AudioContext, radio chain, hiss bed, VU
|
| 709 |
=================================================================== */
|
|
|
|
| 1163 |
function showNowPlaying(track){
|
| 1164 |
if (!els.nowPlaying || !track) return;
|
| 1165 |
els.nowPlaying.classList.add("playing");
|
| 1166 |
+
if (els.npText){
|
| 1167 |
+
els.npText.innerHTML = "";
|
| 1168 |
+
els.npText.appendChild(document.createTextNode(
|
| 1169 |
+
"♪ " + (track.title || "NIGHTWAVE") + " — " + (track.artist || "the all-night desk")));
|
| 1170 |
+
if (track.recommended_by){
|
| 1171 |
+
const r = document.createElement("span");
|
| 1172 |
+
r.className = "np-req";
|
| 1173 |
+
r.textContent = " · req " + track.recommended_by;
|
| 1174 |
+
els.npText.appendChild(r);
|
| 1175 |
+
}
|
| 1176 |
+
}
|
| 1177 |
}
|
| 1178 |
function clearNowPlaying(label){
|
| 1179 |
if (!els.nowPlaying) return;
|
|
|
|
| 1294 |
}
|
| 1295 |
|
| 1296 |
/* ===================================================================
|
| 1297 |
+
E. DIAL / TUNING — slide-rule scale across the top of the glass
|
| 1298 |
=================================================================== */
|
| 1299 |
function drawDial(){
|
| 1300 |
const c = els.dialCanvas, ctx = c.getContext("2d");
|
|
|
|
| 1306 |
|
| 1307 |
const pad = 14;
|
| 1308 |
const span = FREQ_MAX - FREQ_MIN;
|
| 1309 |
+
// warm-up brightens the scale; matches CSS celluloid lighting
|
| 1310 |
+
const cssWarm = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--warm")) || 0;
|
| 1311 |
+
const tickA = 0.20 + 0.55 * cssWarm;
|
| 1312 |
+
const numA = 0.30 + 0.55 * cssWarm;
|
| 1313 |
ctx.textAlign = "center";
|
|
|
|
| 1314 |
|
| 1315 |
for (let f = 88; f <= 108; f += 1){
|
| 1316 |
const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
|
| 1317 |
+
const major = (f % 4 === 0);
|
| 1318 |
+
ctx.strokeStyle = "rgba(243,228,192," + (major ? tickA : tickA*0.55).toFixed(3) + ")";
|
| 1319 |
+
ctx.lineWidth = major ? 1.2 : 1;
|
| 1320 |
ctx.beginPath();
|
| 1321 |
+
ctx.moveTo(x, 6);
|
| 1322 |
+
ctx.lineTo(x, 6 + (major?13:8));
|
| 1323 |
ctx.stroke();
|
| 1324 |
if (major){
|
| 1325 |
+
ctx.font = "11px 'Marcellus SC', Georgia, serif";
|
| 1326 |
+
ctx.fillStyle = "rgba(243,228,192," + numA.toFixed(3) + ")";
|
| 1327 |
+
ctx.fillText(String(f), x, 34);
|
| 1328 |
}
|
| 1329 |
}
|
| 1330 |
+
// NIGHTWAVE marker (98.6) — engraved + ember-lit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
const nx = pad + ((NIGHTWAVE_FREQ - FREQ_MIN)/span) * (w - pad*2);
|
| 1332 |
+
ctx.font = "11px 'Marcellus SC', Georgia, serif";
|
| 1333 |
+
ctx.fillStyle = "rgba(255,122,24,0.92)";
|
| 1334 |
+
ctx.fillText("98.6", nx, 34);
|
| 1335 |
+
ctx.fillStyle = "rgba(255,122,24,0.85)";
|
| 1336 |
+
ctx.beginPath(); ctx.moveTo(nx, 4); ctx.lineTo(nx-4, 13); ctx.lineTo(nx+4, 13); ctx.closePath(); ctx.fill();
|
| 1337 |
}
|
| 1338 |
|
| 1339 |
function freqToPct(f){ return (f - FREQ_MIN)/(FREQ_MAX - FREQ_MIN); }
|
|
|
|
| 1350 |
|
| 1351 |
const locked = Math.abs(f - NIGHTWAVE_FREQ) <= LOCK_BAND;
|
| 1352 |
els.dialwrap.classList.toggle("locked", locked);
|
| 1353 |
+
// the ONE cool accent: teal lock cue + signal tint only at perfect lock
|
| 1354 |
+
els.body.classList.toggle("locked", locked && State.powered);
|
| 1355 |
// dial glow clarity: 1 when locked, fading off-band
|
| 1356 |
const clarity = Math.max(0, 1 - Math.abs(f - NIGHTWAVE_FREQ)/2.0);
|
| 1357 |
document.documentElement.style.setProperty("--dial-glow", clarity.toFixed(3));
|
|
|
|
| 1386 |
// pointer dragging
|
| 1387 |
function pointerToFreq(clientX){
|
| 1388 |
const r = els.dialFrame.getBoundingClientRect();
|
| 1389 |
+
const pad = 14;
|
| 1390 |
let pct = (clientX - r.left - pad) / (r.width - pad*2);
|
| 1391 |
pct = Math.max(0, Math.min(1, pct));
|
| 1392 |
return FREQ_MIN + pct*(FREQ_MAX - FREQ_MIN);
|
|
|
|
| 1416 |
}
|
| 1417 |
|
| 1418 |
/* ===================================================================
|
| 1419 |
+
F. SIGNAL METER - client owns it
|
| 1420 |
=================================================================== */
|
| 1421 |
// The realization arc was retired; the gauge is now a diegetic SIGNAL meter
|
| 1422 |
// driven by how well we're tuned to the station. (State.meter/stage are kept
|
|
|
|
| 1426 |
State.stage = meterToStage(State.meter);
|
| 1427 |
const detune = Math.abs(State.freq - NIGHTWAVE_FREQ);
|
| 1428 |
const sig = Math.round(Math.max(0, Math.min(100, (1 - detune / 2.0) * 100)));
|
| 1429 |
+
els.gaugeFill.style.width = sig + "%";
|
| 1430 |
+
els.gaugeDot.style.left = sig + "%";
|
| 1431 |
els.gauge.setAttribute("aria-valuenow", String(sig));
|
| 1432 |
els.stageCap.textContent = State.powered ? "SIGNAL" : "—";
|
| 1433 |
}
|
|
|
|
| 1443 |
}
|
| 1444 |
|
| 1445 |
/* ===================================================================
|
| 1446 |
+
G. CAPTIONS (karaoke) — Sam Dusk's voice prints on the dial-glass
|
| 1447 |
=================================================================== */
|
| 1448 |
let captionTimers = [];
|
| 1449 |
function clearCaptionTimers(){ captionTimers.forEach(clearTimeout); captionTimers = []; }
|
| 1450 |
|
| 1451 |
+
// Lift finished lines into the ghost stack; cap the trail at 3 lines.
|
| 1452 |
+
function ghostifyLines(){
|
| 1453 |
+
const lines = Array.prototype.slice.call(els.transcript.querySelectorAll(".line"));
|
| 1454 |
+
lines.forEach(l=>{ l.classList.remove("current"); l.classList.add("ghost"); });
|
| 1455 |
+
while (els.transcript.children.length > 3) els.transcript.removeChild(els.transcript.firstChild);
|
| 1456 |
}
|
| 1457 |
|
| 1458 |
+
// status / fragment lines (system messages): printed plainly on the glass,
|
| 1459 |
+
// always mirrored to the SR live-region. Status text bypasses captionsOff.
|
| 1460 |
+
function showCaption(text, kind){
|
| 1461 |
+
clearCaptionTimers();
|
| 1462 |
+
// mirror to the assistive-tech live region
|
| 1463 |
+
els.caption.className = "sr-only" + (kind ? " "+kind : "");
|
| 1464 |
+
els.caption.textContent = (!State.captionsOn && kind !== "status") ? "" : (text || "");
|
| 1465 |
+
|
| 1466 |
+
if (!State.captionsOn && kind !== "status"){ ghostifyLines(); return; }
|
| 1467 |
+
ghostifyLines();
|
| 1468 |
+
const line = document.createElement("div");
|
| 1469 |
+
line.className = "line current" + (kind ? " "+kind : "");
|
| 1470 |
+
line.textContent = text || "";
|
| 1471 |
+
els.transcript.appendChild(line);
|
| 1472 |
+
requestAnimationFrame(()=>line.classList.add("in"));
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
// karaoke: words/wtimes from /speak (progressive enhancement). Each spoken word
|
| 1476 |
+
// develops like film — opacity + blur->sharp + a decaying ember bloom — then
|
| 1477 |
+
// settles to ivory; finished lines lift and fade to --foxed (a ghost stack).
|
| 1478 |
function showCaptionKaraoke(text, words, wtimes, callerText){
|
| 1479 |
clearCaptionTimers();
|
| 1480 |
+
els.caption.className = "sr-only";
|
| 1481 |
+
if (!State.captionsOn){
|
| 1482 |
+
els.caption.textContent = "";
|
| 1483 |
+
ghostifyLines();
|
| 1484 |
+
return;
|
| 1485 |
+
}
|
| 1486 |
+
// mirror full line to the SR live-region for assistive tech
|
| 1487 |
+
els.caption.textContent = (callerText ? ("“"+callerText+"” ") : "") + (text || "");
|
| 1488 |
|
| 1489 |
+
ghostifyLines();
|
| 1490 |
+
const line = document.createElement("div");
|
| 1491 |
+
line.className = "line current";
|
| 1492 |
if (callerText){
|
| 1493 |
const c = document.createElement("span");
|
| 1494 |
c.className = "caller-said";
|
| 1495 |
c.textContent = "“" + callerText + "”";
|
| 1496 |
+
line.appendChild(c);
|
| 1497 |
}
|
| 1498 |
+
|
| 1499 |
if (!words || !words.length || !wtimes || wtimes.length !== words.length){
|
| 1500 |
+
line.appendChild(document.createTextNode(text || ""));
|
| 1501 |
+
els.transcript.appendChild(line);
|
| 1502 |
+
requestAnimationFrame(()=>line.classList.add("in"));
|
| 1503 |
return;
|
| 1504 |
}
|
| 1505 |
const spans = words.map((w)=>{
|
| 1506 |
const s = document.createElement("span");
|
| 1507 |
+
s.className = "w"; s.textContent = w + " ";
|
| 1508 |
+
line.appendChild(s);
|
|
|
|
| 1509 |
return s;
|
| 1510 |
});
|
| 1511 |
+
els.transcript.appendChild(line);
|
| 1512 |
+
requestAnimationFrame(()=>line.classList.add("in"));
|
| 1513 |
words.forEach((w,i)=>{
|
| 1514 |
const t = Math.max(0, wtimes[i]) * 1000;
|
| 1515 |
+
captionTimers.push(setTimeout(()=>{
|
| 1516 |
+
if (!spans[i]) return;
|
| 1517 |
+
spans[i].classList.add("spoken"); // ember bloom on the live word
|
| 1518 |
+
// decay the bloom to settled ivory after a beat (the film "develops")
|
| 1519 |
+
captionTimers.push(setTimeout(()=>{
|
| 1520 |
+
if (spans[i]){ spans[i].classList.remove("spoken"); spans[i].classList.add("said"); }
|
| 1521 |
+
}, 260));
|
| 1522 |
+
}, t));
|
| 1523 |
});
|
| 1524 |
}
|
| 1525 |
|
|
|
|
| 1546 |
|
| 1547 |
// Canned, stage-appropriate lines if the proxy is unreachable.
|
| 1548 |
// (The Space itself also has a NIGHTWAVE_MOCK; this is the last resort.)
|
| 1549 |
+
// Offline/failure fallback — straight-station Sam Dusk lines (the realization
|
| 1550 |
+
// arc is retired; a network hiccup must not resurface the old concept).
|
| 1551 |
+
const MOCK_LINES = [
|
| 1552 |
+
"It's Sam Dusk, and you're back with me on Nightwave, ninety-eight point six. The hour's soft and the dial is warm.",
|
| 1553 |
+
"This next one came in for the long-haul drivers and the kitchen lights still burning after midnight.",
|
| 1554 |
+
"Weather out there's quiet enough to hear the streetlights hum. Keep the radio low and the coffee close.",
|
| 1555 |
+
"Nightwave's still here, all night long, for whoever needed one more voice in the room.",
|
| 1556 |
+
"Coming up, a slow one for slow hours like these. Stay right where you are, would you."
|
| 1557 |
+
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1558 |
// tiny silent WAV (header + 0 samples) base64 - lets the loop advance with no backend
|
| 1559 |
const SILENT_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
| 1560 |
|
| 1561 |
function clientMock(path, body){
|
| 1562 |
+
const text = MOCK_LINES[Math.floor(Math.random()*MOCK_LINES.length)];
|
| 1563 |
+
const base = { text, mood:"warm", arc_cue:"none", audio_b64:SILENT_WAV, words:[], wtimes:[], wdurations:[] };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1564 |
if (path === "/api/call"){
|
| 1565 |
+
return Object.assign({}, base, { caller_text:"(you, on the air)", meter_delta:0 });
|
| 1566 |
}
|
| 1567 |
return base;
|
| 1568 |
}
|
|
|
|
| 1740 |
}
|
| 1741 |
|
| 1742 |
/* ===================================================================
|
| 1743 |
+
K. POWER, WARM-UP, WIRING, SEED, rAF LOOP
|
| 1744 |
=================================================================== */
|
| 1745 |
function setBroadcasting(on){
|
| 1746 |
// The station is "ON AIR" (tube + label lit) the whole time it's powered -- a
|
|
|
|
| 1753 |
els.reelR.classList.toggle("spin", spin);
|
| 1754 |
}
|
| 1755 |
|
| 1756 |
+
// ---- power-on warm-up ritual (VISUAL ONLY). Drives --warm/--lock + the
|
| 1757 |
+
// self-sweeping needle. Must NEVER block AudioContext.resume() / runShow /
|
| 1758 |
+
// fetches; reduced-motion collapses it to an instant on. ----
|
| 1759 |
+
let warmRAF = null, sweepRAF = null;
|
| 1760 |
+
function easeThermal(x){ return x < .5 ? 2*x*x : 1 - Math.pow(-2*x+2, 2)/2; }
|
| 1761 |
+
function clearWarmup(){
|
| 1762 |
+
State.warming = false;
|
| 1763 |
+
if (warmRAF){ cancelAnimationFrame(warmRAF); warmRAF = null; }
|
| 1764 |
+
if (sweepRAF){ cancelAnimationFrame(sweepRAF); sweepRAF = null; }
|
| 1765 |
+
}
|
| 1766 |
+
function runWarmup(){
|
| 1767 |
+
clearWarmup();
|
| 1768 |
+
if (State.reducedMotion){
|
| 1769 |
+
setWarm(1); setLock(1);
|
| 1770 |
+
setFreq(NIGHTWAVE_FREQ, false);
|
| 1771 |
+
els.body.classList.add("broadcasting");
|
| 1772 |
+
els.onairLabel.textContent = "ON AIR";
|
| 1773 |
+
drawDial();
|
| 1774 |
+
return;
|
| 1775 |
+
}
|
| 1776 |
+
State.warming = true;
|
| 1777 |
+
setWarm(0); setLock(0);
|
| 1778 |
+
// 1) filament bloom + backlight (radial mask w/ thermal-lag easing + early flicker)
|
| 1779 |
+
const T0 = performance.now(), DUR = 3600;
|
| 1780 |
+
(function bloom(now){
|
| 1781 |
+
if (!State.powered){ clearWarmup(); return; }
|
| 1782 |
+
const p = Math.min(1, (now - T0) / DUR);
|
| 1783 |
+
const flick = p < .25 ? 0.06*Math.sin(now/40) : 0;
|
| 1784 |
+
setWarm(Math.min(1, easeThermal(p) * (1 + flick)));
|
| 1785 |
+
drawDial(); // brighten the scale numerals/ticks with the filament
|
| 1786 |
+
if (p < 1){ warmRAF = requestAnimationFrame(bloom); }
|
| 1787 |
+
else { setWarm(1); warmRAF = null; drawDial(); }
|
| 1788 |
+
})(T0);
|
| 1789 |
+
// 2) needle self-sweeps from the low end up and settles on 98.6 (damped overshoot)
|
| 1790 |
+
captionTimers.push(setTimeout(()=>{
|
| 1791 |
+
if (!State.powered){ return; }
|
| 1792 |
+
const s = performance.now(), D = 2200;
|
| 1793 |
+
const lockPct = freqToPct(NIGHTWAVE_FREQ) * 100;
|
| 1794 |
+
(function sweep(now){
|
| 1795 |
+
if (!State.powered){ sweepRAF = null; return; }
|
| 1796 |
+
const p = Math.min(1, (now - s) / D);
|
| 1797 |
+
let pct;
|
| 1798 |
+
if (p < .45){ pct = 2 + (100 - 2) * (p / .45); } // run up toward the high end
|
| 1799 |
+
else {
|
| 1800 |
+
const q = (p - .45) / .55;
|
| 1801 |
+
const overshoot = Math.sin(q*Math.PI*2) * Math.pow(1 - q, 2) * 6;
|
| 1802 |
+
pct = 100 + (lockPct - 100) * easeThermal(q) + overshoot;
|
| 1803 |
+
}
|
| 1804 |
+
const f = FREQ_MIN + (Math.max(0, Math.min(100, pct)) / 100) * (FREQ_MAX - FREQ_MIN);
|
| 1805 |
+
// visual-only: move needle + lights, but do NOT mark fromUser (no fragments/eggs)
|
| 1806 |
+
setFreq(f, false);
|
| 1807 |
+
if (p < 1){ sweepRAF = requestAnimationFrame(sweep); }
|
| 1808 |
+
else { sweepRAF = null; setFreq(NIGHTWAVE_FREQ, false); }
|
| 1809 |
+
})(s);
|
| 1810 |
+
}, 2600));
|
| 1811 |
+
// 3) ON AIR tube ignites + carrier lock latches near the end of the ritual
|
| 1812 |
+
captionTimers.push(setTimeout(()=>{
|
| 1813 |
+
if (!State.powered) return;
|
| 1814 |
+
setBroadcasting(false); // refresh reels/tube from current state
|
| 1815 |
+
els.body.classList.add("broadcasting"); // tube ignites
|
| 1816 |
+
els.onairLabel.textContent = "ON AIR";
|
| 1817 |
+
}, 5200));
|
| 1818 |
+
captionTimers.push(setTimeout(()=>{
|
| 1819 |
+
if (!State.powered) return;
|
| 1820 |
+
setLock(1);
|
| 1821 |
+
State.warming = false;
|
| 1822 |
+
}, 5600));
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
async function powerOn(){
|
| 1826 |
if (State.powered) return;
|
| 1827 |
// FIRST gesture: create + resume the single AudioContext, THEN audio.
|
|
|
|
| 1830 |
State.powered = true;
|
| 1831 |
els.body.classList.add("powered");
|
| 1832 |
els.power.setAttribute("aria-pressed","true");
|
| 1833 |
+
els.pwrTxt.innerHTML = "POWER · ON";
|
| 1834 |
els.handset.disabled = false;
|
| 1835 |
els.body.classList.add("broadcasting"); // light the ON AIR sign the moment we're powered
|
| 1836 |
els.onairLabel.textContent = "ON AIR";
|
| 1837 |
+
runWarmup(); // visual-only tube warm-up (non-blocking)
|
| 1838 |
updateHissForTuning();
|
| 1839 |
applyMeter(); // gauge -> SIGNAL
|
| 1840 |
rampGain(Sound.crackleGain, 0.05, 0.6); // bring up the vinyl/room bed
|
|
|
|
| 1850 |
|
| 1851 |
function powerOff(){
|
| 1852 |
State.powered = false;
|
| 1853 |
+
clearWarmup();
|
| 1854 |
+
setWarm(0); setLock(0);
|
| 1855 |
+
els.body.classList.remove("powered","broadcasting","intimate","locked");
|
| 1856 |
els.power.setAttribute("aria-pressed","false");
|
| 1857 |
+
els.pwrTxt.innerHTML = "POWER · OFF";
|
| 1858 |
els.handset.disabled = true;
|
| 1859 |
els.onairLabel.textContent = "STANDBY";
|
| 1860 |
setBroadcasting(false);
|
|
|
|
| 1863 |
try{ if (Sound.voiceEl) Sound.voiceEl.pause(); }catch(e){}
|
| 1864 |
if (Sound.ctx){ rampGain(Sound.hissGain, 0, 0.3); rampGain(Sound.padGain, 0, 0.3); rampGain(Sound.crackleGain, 0, 0.3); rampGain(Sound.drumGain, 0, 0.3); }
|
| 1865 |
stopDrums();
|
| 1866 |
+
drawDial();
|
| 1867 |
applyMeter(); // gauge caption -> "—"
|
| 1868 |
showCaption("Turn the power knob to tune in…");
|
| 1869 |
}
|
| 1870 |
|
| 1871 |
function togglePower(){ State.powered ? powerOff() : powerOn(); }
|
| 1872 |
|
| 1873 |
+
// ---- analog VU needle + idle broadcast loop on a single rAF ----
|
| 1874 |
+
function roundRect(c,x,y,w,h,r){
|
| 1875 |
+
c.beginPath();
|
| 1876 |
+
c.moveTo(x+r,y);
|
| 1877 |
+
c.arcTo(x+w,y,x+w,y+h,r);
|
| 1878 |
+
c.arcTo(x+w,y+h,x,y+h,r);
|
| 1879 |
+
c.arcTo(x,y+h,x,y,r);
|
| 1880 |
+
c.arcTo(x,y,x+w,y,r);
|
| 1881 |
+
c.closePath();
|
| 1882 |
+
}
|
| 1883 |
+
let vuRms = 0; // smoothed needle source
|
| 1884 |
function loop(){
|
| 1885 |
requestAnimationFrame(loop);
|
| 1886 |
|
| 1887 |
const c = els.vuCanvas, ctx = c.getContext("2d");
|
| 1888 |
const dpr = window.devicePixelRatio || 1;
|
| 1889 |
+
if (c.width !== Math.round(c.clientWidth*dpr) || c.height !== Math.round(c.clientHeight*dpr)){
|
| 1890 |
+
c.width = Math.round(c.clientWidth*dpr); c.height = Math.round(c.clientHeight*dpr);
|
| 1891 |
}
|
| 1892 |
ctx.setTransform(dpr,0,0,dpr,0,0);
|
| 1893 |
const w = c.clientWidth, h = c.clientHeight;
|
|
|
|
| 1901 |
for (let i=0;i<Sound.vuData.length;i++){ const v = Sound.vuData[i]/255; sum += v*v; }
|
| 1902 |
rms = Math.sqrt(sum/Sound.vuData.length);
|
| 1903 |
}
|
| 1904 |
+
// smooth the needle so it sways like a real meter
|
| 1905 |
+
vuRms += (rms - vuRms) * 0.18;
|
| 1906 |
+
|
| 1907 |
+
// ---- cream 1970s analog meter face ----
|
| 1908 |
+
const face = ctx.createLinearGradient(0,0,0,h);
|
| 1909 |
+
face.addColorStop(0, "#ead7ac");
|
| 1910 |
+
face.addColorStop(1, "#9f8350");
|
| 1911 |
+
ctx.fillStyle = face;
|
| 1912 |
+
roundRect(ctx, 6, 6, w-12, h-12, 6);
|
| 1913 |
+
ctx.fill();
|
| 1914 |
+
|
| 1915 |
+
// tick marks
|
| 1916 |
+
ctx.strokeStyle = "rgba(40,25,10,.45)";
|
| 1917 |
+
ctx.lineWidth = 1;
|
| 1918 |
+
for (let i=0;i<=10;i++){
|
| 1919 |
+
const x = 22 + i*((w-44)/10);
|
| 1920 |
+
const tall = i % 5 === 0;
|
| 1921 |
+
ctx.beginPath();
|
| 1922 |
+
ctx.moveTo(x, h-24);
|
| 1923 |
+
ctx.lineTo(x, h-(tall?40:32));
|
| 1924 |
+
ctx.stroke();
|
| 1925 |
}
|
| 1926 |
+
// red zone at the top end
|
| 1927 |
+
ctx.strokeStyle = "rgba(150,30,20,.6)";
|
| 1928 |
+
for (let i=8;i<=10;i++){
|
| 1929 |
+
const x = 22 + i*((w-44)/10);
|
| 1930 |
+
ctx.beginPath(); ctx.moveTo(x, h-24); ctx.lineTo(x, h-40); ctx.stroke();
|
| 1931 |
+
}
|
| 1932 |
+
// "VU" label
|
| 1933 |
+
ctx.fillStyle = "rgba(55,34,16,.8)";
|
| 1934 |
+
ctx.font = "700 9px 'Marcellus SC', Georgia, serif";
|
| 1935 |
+
ctx.textAlign = "center";
|
| 1936 |
+
ctx.fillText("VU", w/2, 22);
|
| 1937 |
+
|
| 1938 |
+
// the single moving needle, driven by the same rms
|
| 1939 |
+
const cx = w/2, cy = h + 8;
|
| 1940 |
+
const ang = -Math.PI*0.72 + Math.min(1, vuRms*2.2) * Math.PI*0.44;
|
| 1941 |
+
ctx.strokeStyle = "#5a1b12"; ctx.lineWidth = 2;
|
| 1942 |
+
ctx.beginPath(); ctx.moveTo(cx, cy);
|
| 1943 |
+
ctx.lineTo(cx + Math.cos(ang)*(h*0.92), cy + Math.sin(ang)*(h*0.92)); ctx.stroke();
|
| 1944 |
+
// pivot cap
|
| 1945 |
+
ctx.fillStyle = "#5a1b12"; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI*2); ctx.fill();
|
| 1946 |
|
| 1947 |
// idle broadcast loop: if powered, locked, quiet for ~20s, fetch next segment
|
| 1948 |
if (State.powered && !State.busy && !State.recording && !State.showRunning){
|
|
|
|
| 2014 |
State.captionsOn = !State.captionsOn;
|
| 2015 |
els.capToggle.setAttribute("aria-pressed", String(State.captionsOn));
|
| 2016 |
els.capToggle.textContent = "CAPTIONS: " + (State.captionsOn ? "ON" : "OFF");
|
| 2017 |
+
if (!State.captionsOn){ els.caption.textContent = ""; els.transcript.innerHTML = ""; }
|
| 2018 |
});
|
| 2019 |
|
| 2020 |
// global seed keys
|