1+ cat > index.html < < 'EOF'
2+ <!DOCTYPE html>
3+ < html lang="en">
4+ < head>
5+ < meta charset="UTF-8">
6+ < meta name="viewport" content="width=device-width, initial-scale=1.0">
7+ < title> Aurora Monitor</ title>
8+
9+ < style>
10+ :root{ --green:#00ff88; }
11+
12+ html,body{
13+ margin:0;
14+ height:100%;
15+ background:#000;
16+ overflow:hidden;
17+ font-family:monospace;
18+ color:var(--green);
19+ }
20+
21+ body::after{
22+ content:"";
23+ position:fixed;
24+ inset:0;
25+ pointer-events:none;
26+ background:repeating-linear-gradient(
27+ to bottom,
28+ rgba(255,255,255,0.04) 0,
29+ rgba(255,255,255,0.04) 1px,
30+ transparent 2px,
31+ transparent 4px
32+ );
33+ opacity:.15;
34+ }
35+
36+ canvas{
37+ position:fixed;
38+ inset:0;
39+ }
40+
41+ .sub{
42+ position:fixed;
43+ bottom:70px;
44+ left:50%;
45+ transform:translateX(-50%);
46+ width:min(92vw,600px);
47+ text-align:center;
48+ font-size:14px;
49+ line-height:1.5;
50+ white-space:pre-wrap;
51+ padding:0 10px;
52+ text-shadow:0 0 8px var(--green);
53+ }
54+
55+ .selector{
56+ position:fixed;
57+ bottom:10px;
58+ left:50%;
59+ transform:translateX(-50%);
60+ display:flex;
61+ gap:6px;
62+ flex-wrap:wrap;
63+ justify-content:center;
64+ width:min(95vw,700px);
65+ }
66+
67+ button{
68+ background:#000;
69+ color:var(--green);
70+ border:1px solid var(--green);
71+ padding:6px 10px;
72+ font-family:monospace;
73+ cursor:pointer;
74+ font-size:11px;
75+ }
76+
77+ button.active{
78+ background:#022;
79+ }
80+ </ style>
81+ </ head>
82+
83+ < body>
84+
85+ < canvas id="c"> </ canvas>
86+ < div class="sub" id="sub"> </ div>
87+ < div class="selector" id="selector"> </ div>
88+
89+ < audio id="audio" preload="auto"> </ audio>
90+
91+ < script>
92+ const TRACKS = [
93+ "The_Physics_of_the_Clip_Economy",
94+ "The_Physics_of_Recomposable_Fragmentation",
95+ "Why_Flat_Roofs_Are_Mathematically_Broken",
96+ "The_Universal_Topology_of_Failure",
97+ "The_secret_geometric_dance_of_typing",
98+ "Your_hands_are_not_typing_symbols"
99+ ]
100+
101+ const audio = document.getElementById('audio')
102+ const subEl = document.getElementById('sub')
103+ const selector = document.getElementById('selector')
104+
105+ function getStartIndex(){
106+ const p = new URLSearchParams(location.search).get("track")
107+ if(!p) return 0
108+ if(!isNaN(p)) return (+p) % TRACKS.length
109+ const norm = s => s.toLowerCase().replace(/[_\s]/g,'')
110+ const i = TRACKS.findIndex(t => norm(t).includes(norm(p)))
111+ return i > = 0 ? i : 0
112+ }
113+
114+ let current = getStartIndex()
115+
116+ function loadTrack(i){
117+ current = i
118+ lastText = ""
119+ subEl.textContent = ""
120+ audio.src = TRACKS[i] + ".mp3"
121+ audio.load()
122+ loadVTT(TRACKS[i] + ".vtt")
123+ updateUI()
124+ }
125+
126+ let cues = []
127+ let lastText = ""
128+
129+ async function loadVTT(file){
130+ try{
131+ const r = await fetch(file)
132+ if(!r.ok) throw new Error(r.status + " " + file)
133+ const txt = await r.text()
134+ cues = parseVTT(txt)
135+ console.log("Loaded", cues.length, "cues from", file)
136+ }catch(e){
137+ console.warn("VTT load failed:", e)
138+ cues = []
139+ }
140+ }
141+
142+ function parseVTT(txt){
143+ const lines = txt.split('\n')
144+ const cues = []
145+ let i = 0
146+
147+ while(i < lines.length){
148+ const line = lines[i].trim()
149+ if(line.includes('--> ')){
150+ const [start, end] = line.split('--> ').map(s => s.trim())
151+ i++
152+ const textLines = []
153+ while(i < lines.length && lines[i].trim()){
154+ textLines.push(lines[i].trim())
155+ i++
156+ }
157+ cues.push({
158+ start: toSec(start),
159+ end: toSec(end),
160+ text: textLines.join('\\n')
161+ })
162+ } else {
163+ i++
164+ }
165+ }
166+ return cues
167+ }
168+
169+ function toSec(t){
170+ const parts = t.split(':').map(Number)
171+ if(parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2]
172+ if(parts.length === 2) return parts[0]*60 + parts[1]
173+ return 0
174+ }
175+
176+ audio.ontimeupdate = ()=> {
177+ const t = audio.currentTime
178+ const grace = 0.5
179+ let active = null
180+
181+ for(let i = cues.length - 1; i > = 0; i--){
182+ const c = cues[i]
183+ if(t > = c.start - 0.1){
184+ if(t < = c.end + grace){
185+ active = c
186+ }
187+ break
188+ }
189+ }
190+
191+ if(active){
192+ lastText = active.text
193+ }
194+
195+ subEl.textContent = lastText
196+ }
197+
198+ TRACKS.forEach((t,i)=> {
199+ const b = document.createElement("button")
200+ b.textContent = i+1
201+ b.onclick = e=> {
202+ e.stopPropagation()
203+ loadTrack(i)
204+ audio.play()
205+ }
206+ selector.appendChild(b)
207+ })
208+
209+ function updateUI(){
210+ [...selector.children].forEach((b,i)=> {
211+ b.classList.toggle("active", i===current)
212+ })
213+ }
214+
215+ let ctxA, analyser, buf
216+
217+ function initAudio(){
218+ if(ctxA) return
219+ ctxA = new (window.AudioContext||window.webkitAudioContext)()
220+ analyser = ctxA.createAnalyser()
221+ analyser.fftSize = 1024
222+ buf = new Uint8Array(analyser.fftSize)
223+ const src = ctxA.createMediaElementSource(audio)
224+ src.connect(analyser)
225+ analyser.connect(ctxA.destination)
226+ }
227+
228+ document.body.onclick = async ()=> {
229+ initAudio()
230+ if(ctxA.state !== "running"){
231+ await ctxA.resume()
232+ }
233+ if(audio.paused){
234+ await audio.play()
235+ } else {
236+ audio.pause()
237+ }
238+ }
239+
240+ audio.onended = ()=> {
241+ loadTrack((current+1)%TRACKS.length)
242+ audio.play()
243+ }
244+
245+ const c = document.getElementById('c')
246+ const g = c.getContext('2d')
247+
248+ let w,h,cx,cy
249+
250+ function resize(){
251+ w=c.width=innerWidth
252+ h=c.height=innerHeight
253+ cx=w/2; cy=h/2
254+ }
255+ resize()
256+ addEventListener('resize',resize)
257+
258+ function draw(){
259+ g.fillStyle='rgba(0,0,0,0.12)'
260+ g.fillRect(0,0,w,h)
261+
262+ const R = Math.min(w,h)*0.4
263+
264+ g.save()
265+ g.beginPath()
266+ g.arc(cx,cy,R,0,Math.PI*2)
267+ g.clip()
268+
269+ if(analyser){
270+ analyser.getByteTimeDomainData(buf)
271+ const len=buf.length
272+ const lag=25
273+
274+ g.beginPath()
275+ for(let i=0;i< len;i++){
276+ const a=(buf[i]-128)/128
277+ const b=(buf[(i+lag)%len]-128)/128
278+ const t=i/len*Math.PI*2
279+
280+ const x = cx + Math.sin(t*2)*a*R*0.7
281+ const y = cy + Math.sin(t*3)*b*R*0.7
282+
283+ i?g.lineTo(x,y):g.moveTo(x,y)
284+ }
285+
286+ g.strokeStyle='#00ff88'
287+ g.shadowColor='#00ff88'
288+ g.shadowBlur=12
289+ g.stroke()
290+ g.shadowBlur=0
291+ }
292+
293+ g.restore()
294+
295+ g.beginPath()
296+ g.arc(cx,cy,R,0,Math.PI*2)
297+ g.strokeStyle='rgba(0,255,136,0.7)'
298+ g.lineWidth=2
299+ g.stroke()
300+
301+ requestAnimationFrame(draw)
302+ }
303+
304+ draw()
305+ loadTrack(current)
306+ </ script>
307+
308+ </ body>
309+ </ html>
0 commit comments