const GRADIO_CLIENT_URL = "https://cdn.jsdelivr.net/npm/@gradio/client/+esm"; const FREQ_MIN = 87.0; const BASE_FREQ_MAX = 108.0; let freqMax = BASE_FREQ_MAX; // expands to 112.0 after breaking the cipher const STATION_TOLERANCE = 0.3; const LOCK_TOLERANCE = 0.05; const NEAR_TOLERANCE = 0.45; const LETTER_RE = /\p{L}/u; const BLIP_DURATION = 0.07; const REDUCED_MOTION = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; // --- DOM --- const tvScreen = document.getElementById("tv-screen"); const dialSvg = document.getElementById("dial"); const gaugeWrap = document.getElementById("gauge-wrap"); const needle = document.getElementById("needle"); const freqDisplay = document.getElementById("freq-display"); const vuBar = document.getElementById("vu-bar"); const tuningLamp = document.getElementById("tuning-lamp"); const transmission = document.getElementById("transmission"); const scopeCanvas = document.getElementById("scope"); const noiseCanvas = document.getElementById("noise"); const consoleWrap = document.getElementById("console-wrap"); const consoleLog = document.getElementById("console-log"); const consoleInput = document.getElementById("console-input"); const hint = document.getElementById("hint"); const stationName = document.getElementById("station-name"); const tuneLoader = document.getElementById("tune-loader"); const tuneLoaderText = document.getElementById("tune-loader-text"); const tuneLoaderPctEl = document.getElementById("tune-loader-pct"); const tuneLoaderFill = document.getElementById("tune-loader-fill"); const announcerFrame = document.getElementById("announcer-frame"); const announcerImg = document.getElementById("announcer-img"); const talkBars = document.getElementById("talk-bars"); const tagline = document.getElementById("tagline"); const labelLanguage = document.getElementById("label-language"); const labelStatic = document.getElementById("label-static"); const labelMode = document.getElementById("label-mode"); const langSwitch = document.getElementById("lang-switch"); const staticToggle = document.getElementById("static-toggle"); const modeToggle = document.getElementById("mode-toggle"); const guideBtn = document.getElementById("guide-btn"); const helpModal = document.getElementById("help-modal"); const helpClose = document.getElementById("help-close"); const helpList = document.getElementById("help-list"); const helpTitleEl = document.getElementById("help-title"); const galaxyCanvas = document.getElementById("galaxy"); const bootEl = document.getElementById("boot"); const bootSub = document.getElementById("boot-sub"); const bootHint = document.getElementById("boot-hint"); const bootLangs = document.getElementById("boot-langs"); const notesCalloutEl = document.getElementById("notes-callout"); const notesCalloutTextEl = document.getElementById("notes-callout-text"); const modeCalloutEl = document.getElementById("mode-callout"); const modeCalloutTextEl = document.getElementById("mode-callout-text"); const powerBtn = document.getElementById("power-btn"); const powerLabel = document.getElementById("power-label"); const tuneKnob = document.getElementById("tune-knob"); const knobFace = document.getElementById("knob-face"); const labelTuning = document.getElementById("label-tuning"); const labelHelp = document.getElementById("label-help"); const mission1El = document.getElementById("mission-1"); const mission2El = document.getElementById("mission-2"); const mission3El = document.getElementById("mission-3"); const mission4El = document.getElementById("mission-4"); const interceptEl = document.getElementById("intercept"); const interceptText = document.getElementById("intercept-text"); const notesTab = document.getElementById("notes-tab"); const notesTabLabel = document.getElementById("notes-tab-label"); const notesPanel = document.getElementById("notes-panel"); const notesClose = document.getElementById("notes-close"); const notesTitle = document.getElementById("notes-title"); const notesFoundLabelEl = document.getElementById("notes-found-label"); const notesCipherLabelEl = document.getElementById("notes-cipher-label"); const notesScratchLabelEl = document.getElementById("notes-scratch-label"); const notesFoundEl = document.getElementById("notes-found"); const notesCipherEl = document.getElementById("notes-cipher"); const notesScratch = document.getElementById("notes-scratch"); const missionHud = document.getElementById("mission-hud"); const missionHudText = document.getElementById("mission-hud-text"); const missionHudFill = document.getElementById("mission-hud-fill"); const missionHudPips = document.getElementById("mission-hud-pips"); const endgameBar = document.getElementById("endgame-bar"); const endgameInput = document.getElementById("endgame-input"); const finaleScreen = document.getElementById("finale-screen"); const finaleMessage = document.getElementById("finale-message"); const finaleReplayBtn = document.getElementById("finale-replay"); // ===================================================================== // i18n: the listener picks a language (affects UI + model broadcasts) // ===================================================================== const I18N = { es: { tagline: "universos paralelos · sintonía manual", hintDefault: "gira el dial · ⟵ ⟶ afina · ↑ ↓ salta entre estaciones · doble clic engancha", staticText: "— estática —", weakSignal: "— señal débil —", lostSignal: "— señal perdida —", loading: "capturando señal", disconnected: "— servidor desconectado —", channelInterrupted: "— canal interrumpido —", consolePlaceholder: "transmitir de vuelta...", languageLabel: "IDIOMA", staticLabel: "ESTÁTICA", modeLabel: "MODO", liveHint: "modo EN VIVO · la IA escribe cada transmisión en el momento (más lento)", fastHint: "modo RÁPIDO · transmisiones al instante desde la memoria", tuningLabel: "SINTONÍA", helpLabel: "AYUDA", guideTitle: "Cómo se escucha", helpTitle: "CÓMO SE ESCUCHA", helpItems: [ ["Gira el dial", "para buscar estaciones escondidas en la estática. Arrástralo, usa la perilla, las flechas del teclado o la rueda del ratón. Afina poco a poco hasta que la señal se aclare y la luz se ponga verde."], ["Escucha cada frecuencia", "con atención. Cada estación es una voz de otro universo, y hay nueve. Algunas son fáciles de hallar; una de ellas solo habla con números en Morse."], ["MODE", "elige RÁPIDO (la IA elige una transmisión que ya escribió) o EN VIVO (la ves escribir una nueva). ESTÁTICA enciende o apaga el ruido de fondo. IDIOMA cambia el idioma de las transmisiones."], ["Revisa la BITÁCORA", "(la pestaña de la derecha). Guarda cada estación que encuentres, el cifrado y tus apuntes. La vas a necesitar."], ], hostName: "EL ANFITRIÓN", bootSub: "una radio que recibe universos perdidos", powerLabel: "ENCENDER", bootHint: "cada oyente recibe emisiones distintas", mission1: "Gira el dial despacio, con cuidado: cada estación es un mundo a punto de apagarse, y la luz se pone verde cuando lo encuentras.", mission2: "Escucha cada frecuencia con atención. El operador de los números dice ser tu guía... pero no está claro de qué lado está. Cuídate de él.", mission3: "Tu misión es rescatarlos del silencio antes de que la última frecuencia se apague para siempre.", mission4: "Al final, la decisión es tuya: ¿merecen estos universos ser salvados?", bootTip: "La BITÁCORA guarda lo que descubras. Échale un ojo.", modeTip: "Elige tu experiencia: RÁPIDO (mira a la IA elegir) o EN VIVO (mira a la IA escribir)", notesTab: "BITÁCORA", notesTitle: "BITÁCORA", notesFoundLabel: "ESTACIONES", notesCipherLabel: "CIFRADO", notesScratchLabel: "APUNTES", notesEmpty: "aún nada — gira el dial", seqLabel: "Secuencia", codeLabel: "Código", intercept: [ "Portadora detectada. Esto no es asunto suyo.", "Aléjese de esta frecuencia. Última advertencia.", "No debería estar aquí. Dé la vuelta. Ahora.", ], consoleIntro: "OPERADOR: Canal restringido. Transmita la secuencia de números dictada para autenticarse.", numberHint: "Escucha el Morse · escribe la secuencia de números en la consola para entrar", codeHint: "el operador te reconoce · transmite tu código en la consola para oír la verdad", cipherBroken: "— CIFRADO ROTO — la banda 108-112 MHz existe ahora —", unlockHint: (code) => `código ${code} aceptado · el dial creció a 108–112 MHz · reúne los 5 fragmentos`, downloading: (name, pct) => `↓ ${name} — ${pct}% · sostén el dial`, received: (name, a, b) => `✓ ${name} recuperado (${a}/${b})`, bandLabel: (c, total) => `EL ÚLTIMO MENSAJE — ${c}/${total} fragmentos`, recoverHint: "banda secreta 108–112 · sintoniza un fragmento y sostén el dial para recuperarlo", fragmentsLabel: "FRAGMENTOS", finaleTitle: "— EL ÚLTIMO MENSAJE —", finaleEnd: "— fin de todas las transmisiones · gracias por escuchar —", finaleGoodbye: "Oyente... seguimos transmitiendo nuestras historias un día más... Gracias.", playAgain: "Jugar de nuevo", intro: "H-hola... ¿me escuchan? Aquí su anfitrión, transmitiendo desde el corazón " + "de la estática. Esta radio recibe las emisoras de universos que se perdieron. " + "Gire el dial. Busque las voces. Hay nueve estaciones esperando... una " + "frecuencia maldita que nunca se limpia... y un canal que solo emite números. " + "Si entiende lo que dicen los números... respóndales. Buena sintonía. " + "[FIN DE TRANSMISION]", }, en: { tagline: "parallel universes · manual tuning", hintDefault: "turn the dial · ⟵ ⟶ fine-tune · ↑ ↓ jump between stations · double-click locks on", staticText: "— static —", weakSignal: "— weak signal —", lostSignal: "— signal lost —", loading: "capturing signal", disconnected: "— server disconnected —", channelInterrupted: "— channel interrupted —", consolePlaceholder: "transmit back...", languageLabel: "LANGUAGE", staticLabel: "STATIC", modeLabel: "MODE", liveHint: "LIVE mode · the AI writes each broadcast on the spot (slower)", fastHint: "FAST mode · broadcasts served instantly from cache", tuningLabel: "TUNING", helpLabel: "HELP", guideTitle: "How to listen", helpTitle: "HOW TO LISTEN", helpItems: [ ["Turn the dial", "to hunt for stations hidden in the static. Drag it, use the bezel knob, the arrow keys, or scroll. Fine-tune slowly until the signal clears and the light turns green."], ["Listen to every frequency", "carefully. Each station is a different voice from a parallel universe, and there are nine. Some are easy to find; one of them only speaks in numbers, in Morse."], ["MODE", "picks FAST (the AI plays a broadcast it already wrote) or LIVE (watch it write a new one). STATIC toggles the background hiss. LANGUAGE switches the broadcast language."], ["Check the FIELD LOG", "(the tab on the right). It remembers every station you find, the cipher, and your own notes. You will need it."], ], hostName: "THE HOST", bootSub: "a radio that picks up lost universes", powerLabel: "POWER ON", bootHint: "every listener gets different broadcasts", mission1: "Turn the dial slowly, carefully: every station is a world about to go dark, and the light turns green when you find it.", mission2: "Listen to each frequency closely. The number-station operator claims to be your guide... but it's unclear whose side he's really on. Be wary of him.", mission3: "Your mission is to pull them back from the silence before the last frequency fades out forever.", mission4: "In the end, the choice is yours: do these universes deserve saving?", bootTip: "The FIELD LOG keeps track of what you find. Keep an eye on it.", modeTip: "Pick your experience: FAST (watch the AI pick it) or LIVE (watch the AI write it)", notesTab: "FIELD LOG", notesTitle: "FIELD LOG", notesFoundLabel: "STATIONS", notesCipherLabel: "CIPHER", notesScratchLabel: "NOTES", notesEmpty: "nothing yet — turn the dial", seqLabel: "Sequence", codeLabel: "Code", intercept: [ "Carrier detected. This is none of your concern.", "Step away from this frequency. Final warning.", "You should not be here. Turn back. Now.", ], consoleIntro: "OPERATOR: Restricted channel. Transmit the dictated number sequence to authenticate.", numberHint: "Listen to the Morse · type the number sequence into the console to break in", codeHint: "the operator remembers you · transmit your code in the console to hear the truth", cipherBroken: "— CIPHER BROKEN — the 108-112 MHz band exists now —", unlockHint: (code) => `code ${code} accepted · the dial grew to 108–112 MHz · recover all 5 fragments`, downloading: (name, pct) => `↓ ${name} — ${pct}% · hold the dial steady`, received: (name, a, b) => `✓ ${name} recovered (${a}/${b})`, bandLabel: (c, total) => `THE LAST MESSAGE — ${c}/${total} fragments`, recoverHint: "secret band 108–112 · tune a fragment and hold the dial steady to recover it", fragmentsLabel: "FRAGMENTS", finaleTitle: "— THE LAST MESSAGE —", finaleEnd: "— end of all transmissions · thank you for listening —", finaleGoodbye: "Listener... we live to broadcast our stories for another day... Thank you.", playAgain: "Play again", intro: "H-hello... can you hear me? This is your host, broadcasting from the heart " + "of the static. This radio picks up stations from universes that got lost. " + "Turn the dial. Find the voices. Nine stations are waiting... one cursed " + "frequency that never comes in clean... and a channel that only broadcasts " + "numbers. If you understand what the numbers say... answer them. Good tuning. " + "[FIN DE TRANSMISION]", }, fr: { tagline: "univers parallèles · réglage manuel", hintDefault: "tourne le cadran · ⟵ ⟶ ajuste · ↑ ↓ saute entre stations · double-clic accroche", staticText: "— parasites —", weakSignal: "— signal faible —", lostSignal: "— signal perdu —", loading: "capture du signal", disconnected: "— serveur déconnecté —", channelInterrupted: "— canal interrompu —", consolePlaceholder: "transmettre en retour...", languageLabel: "LANGUE", staticLabel: "PARASITES", modeLabel: "MODE", liveHint: "mode EN DIRECT · l'IA écrit chaque transmission en direct (plus lent)", fastHint: "mode RAPIDE · transmissions instantanées depuis le cache", tuningLabel: "ACCORD", helpLabel: "AIDE", guideTitle: "Comment écouter", helpTitle: "COMMENT ÉCOUTER", helpItems: [ ["Tourne le cadran", "pour chercher des stations cachées dans les parasites. Fais-le glisser, utilise le bouton, les flèches du clavier ou la molette. Affine lentement jusqu'à ce que le signal s'éclaircisse et que la lumière passe au vert."], ["Écoute chaque fréquence", "attentivement. Chaque station est une voix d'un autre univers, et il y en a neuf. Certaines sont faciles à trouver ; l'une d'elles ne parle qu'en chiffres, en morse."], ["MODE", "choisit RAPIDE (l'IA joue une émission qu'elle a déjà écrite) ou EN DIRECT (regarde-la en écrire une nouvelle). PARASITES coupe le souffle de fond. LANGUE change la langue des émissions."], ["Consulte le JOURNAL", "(l'onglet à droite). Il garde chaque station trouvée, le code et tes notes. Tu en auras besoin."], ], hostName: "VOTRE HÔTE", bootSub: "une radio qui capte les univers perdus", powerLabel: "ALLUMER", bootHint: "chaque auditeur reçoit des émissions différentes", mission1: "Tourne le cadran lentement, avec soin : chaque station est un monde sur le point de s'éteindre, et la lumière passe au vert quand tu le trouves.", mission2: "Écoute chaque fréquence avec attention. L'opérateur des nombres dit être ton guide... mais on ne sait pas vraiment de quel côté il est. Méfie-toi de lui.", mission3: "Ta mission est de les sauver du silence avant que la dernière fréquence ne s'éteigne pour toujours.", mission4: "Au final, c'est à toi de décider : ces univers méritent-ils d'être sauvés ?", bootTip: "Le JOURNAL garde une trace de ce que tu découvres. Garde un œil dessus.", modeTip: "Choisis ton expérience : RAPIDE (regarde l'IA choisir) ou EN DIRECT (regarde l'IA écrire)", notesTab: "JOURNAL", notesTitle: "JOURNAL", notesFoundLabel: "STATIONS", notesCipherLabel: "CHIFFRE", notesScratchLabel: "NOTES", notesEmpty: "rien encore — tourne le cadran", seqLabel: "Séquence", codeLabel: "Code", intercept: [ "Porteuse détectée. Cela ne vous regarde pas.", "Éloignez-vous de cette fréquence. Dernier avertissement.", "Vous ne devriez pas être ici. Faites demi-tour. Maintenant.", ], consoleIntro: "OPÉRATEUR : Canal restreint. Transmettez la séquence de nombres dictée pour vous authentifier.", numberHint: "Écoute le morse · tape la séquence de nombres dans la console pour entrer", codeHint: "l'opérateur te reconnaît · transmets ton code dans la console pour entendre la vérité", cipherBroken: "— CHIFFREMENT BRISÉ — la bande 108-112 MHz existe maintenant —", unlockHint: (code) => `code ${code} accepté · le cadran s'étend à 108–112 MHz · récupère les 5 fragments`, downloading: (name, pct) => `↓ ${name} — ${pct}% · tenez le cadran`, received: (name, a, b) => `✓ ${name} récupéré (${a}/${b})`, bandLabel: (c, total) => `LE DERNIER MESSAGE — ${c}/${total} fragments`, recoverHint: "bande secrète 108–112 · accroche un fragment et tiens le cadran pour le récupérer", fragmentsLabel: "FRAGMENTS", finaleTitle: "— LE DERNIER MESSAGE —", finaleEnd: "— fin de toutes les transmissions · merci d'avoir écouté —", finaleGoodbye: "Auditeur... nous continuons à diffuser nos histoires un jour de plus... Merci.", playAgain: "Rejouer", intro: "B-bonsoir... vous m'entendez ? Ici votre hôte, en direct du cœur des " + "parasites. Cette radio capte les stations d'univers disparus. Tournez le " + "cadran. Cherchez les voix. Neuf stations vous attendent... une fréquence " + "maudite qui ne s'éclaircit jamais... et un canal qui n'émet que des nombres. " + "Si vous comprenez ce que disent les nombres... répondez-leur. Bonne écoute. " + "[FIN DE TRANSMISION]", }, }; let lang = localStorage.getItem("lfr_lang") || (navigator.language || "en").slice(0, 2); // French was removed from the UI to keep things simple (the 1B's French was weak) if (lang !== "es" && lang !== "en") lang = "en"; function t() { return I18N[lang]; } // Per-listener nonce, regenerated on EVERY page load: a reload is always a // clean, fully-consistent fresh session (new dial layout, new cipher, empty // field log). Restoring half a session from cache caused subtle breakage. const variant = Math.floor(Math.random() * 2147483646) + 1; try { sessionStorage.setItem("lfr_variant", String(variant)); // informational only } catch (_) { /* storage unavailable */ } // --- Estado --- let frequency = 95.0; let isDragging = false; let client = null; let currentJob = null; let tuneGeneration = 0; let voiceEnabled = false; let staticOn = true; // always start with the static hiss on // sync the physical switch's look to the real state on load (it defaults to ON // in the HTML, which was wrong whenever the saved state was OFF) if (staticToggle) { staticToggle.classList.toggle("on", staticOn); staticToggle.setAttribute("aria-checked", String(staticOn)); } // FAST (default): broadcasts come instantly from the pre-baked pool. // LIVE: the model generates the broadcast fresh, so you watch the AI write it. let liveMode = false; // always start in FAST so the first experience is instant if (modeToggle) { modeToggle.classList.toggle("on", liveMode); modeToggle.setAttribute("aria-checked", String(liveMode)); } let stations = {}; let stationFreqs = []; const DEFAULT_THEME = { glow: "#7cdf8f", flicker: 0.3 }; const HOST_PORTRAIT = "/static/img/host.webp"; const HOST_VOICE = { pitch_base: 150, pitch_step: 5, pitch_jitter: 14, waveform: "sine" }; // ===================================================================== // Web Audio: master bus → analyser → output // ===================================================================== let audio = null; // { ctx, master, analyser, staticGain, staticFilter } function initAudio() { const ctx = new AudioContext(); const master = ctx.createGain(); master.gain.value = 1; const analyser = ctx.createAnalyser(); analyser.fftSize = 1024; analyser.smoothingTimeConstant = 0.6; master.connect(analyser); analyser.connect(ctx.destination); // looping static const bufferSize = 2 * ctx.sampleRate; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; const source = ctx.createBufferSource(); source.buffer = buffer; source.loop = true; const staticGain = ctx.createGain(); staticGain.gain.value = 0; const staticFilter = ctx.createBiquadFilter(); staticFilter.type = "bandpass"; staticFilter.frequency.value = 1200; staticFilter.Q.value = 0.5; source.connect(staticFilter); staticFilter.connect(staticGain); staticGain.connect(master); source.start(); audio = { ctx, master, analyser, staticGain, staticFilter }; } function resumeAudio() { audio?.ctx?.resume(); } function setStaticLevel(level) { if (!audio) return; const now = audio.ctx.currentTime; const gain = staticOn ? level * 0.12 : 0; audio.staticGain.gain.setTargetAtTime(gain, now, 0.05); audio.staticFilter.frequency.setTargetAtTime(800 + level * 2000, now, 0.05); } function playSquelch() { if (!audio || !staticOn) return; const now = audio.ctx.currentTime; audio.staticGain.gain.setValueAtTime(0.25, now); audio.staticGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); } function staticBurst(intensity = 0.3, duration = 0.4) { if (!audio || !staticOn) return; const now = audio.ctx.currentTime; audio.staticGain.gain.cancelScheduledValues(now); audio.staticGain.gain.setValueAtTime(intensity, now); audio.staticGain.gain.exponentialRampToValueAtTime(0.002, now + duration); } const OSC_WAVEFORMS = new Set(["sine", "square", "sawtooth", "triangle"]); function playBlip(char, voice) { if (!voiceEnabled || !audio?.ctx || !LETTER_RE.test(char)) return; const { ctx, master } = audio; const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = OSC_WAVEFORMS.has(voice.waveform) ? voice.waveform : "square"; const code = char.toLowerCase().codePointAt(0) || 97; const pitchOffset = (code % 26) * (voice.pitch_step || 6); const jitter = (Math.random() - 0.5) * (voice.pitch_jitter || 20); osc.frequency.value = (voice.pitch_base || 220) + pitchOffset + jitter; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.12, now + 0.008); gain.gain.exponentialRampToValueAtTime(0.0001, now + BLIP_DURATION); osc.connect(gain); gain.connect(master); osc.start(now); osc.stop(now + BLIP_DURATION + 0.01); } function playJingle(voice) { if (!audio?.ctx) return; const { ctx, master } = audio; const base = (voice?.pitch_base || 220) * 1.5; const notes = [1, 1.25, 1.5]; notes.forEach((mult, i) => { const t0 = ctx.currentTime + i * 0.12; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; osc.frequency.value = base * mult; gain.gain.setValueAtTime(0.0001, t0); gain.gain.exponentialRampToValueAtTime(0.14, t0 + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.22); osc.connect(gain); gain.connect(master); osc.start(t0); osc.stop(t0 + 0.25); }); } function playClick() { if (!audio?.ctx) return; const { ctx, master } = audio; const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "square"; osc.frequency.value = 70; gain.gain.setValueAtTime(0.18, now); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.06); osc.connect(gain); gain.connect(master); osc.start(now); osc.stop(now + 0.08); } // Closing theme: a slow minor melody that lifts into a major resolution at // the end — sad but hopeful, played once over the finale screen. function playOutroTune() { if (!audio?.ctx) return; const { ctx, master } = audio; const now = ctx.currentTime + 0.3; const tuneGain = ctx.createGain(); tuneGain.gain.value = 0.0001; tuneGain.connect(master); tuneGain.gain.exponentialRampToValueAtTime(0.25, now + 1.5); tuneGain.gain.setTargetAtTime(0.0001, now + 13, 2.5); // melody drifts from A minor toward C major — a quiet lift at the close const melody = [ { freq: 220.0, dur: 1.7 }, // A3 { freq: 261.63, dur: 1.7 }, // C4 { freq: 329.63, dur: 1.7 }, // E4 { freq: 293.66, dur: 1.7 }, // D4 { freq: 349.23, dur: 1.7 }, // F4 { freq: 440.0, dur: 1.7 }, // A4 { freq: 523.25, dur: 2.8 }, // C5 — resting on the major note ]; let t = now; for (const { freq, dur } of melody) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; osc.frequency.value = freq; gain.gain.setValueAtTime(0.0001, t); gain.gain.exponentialRampToValueAtTime(0.55, t + 0.18); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); osc.connect(gain); gain.connect(tuneGain); osc.start(t); osc.stop(t + dur + 0.05); t += dur * 0.85; } // soft drone underneath [110.0, 130.81].forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.value = freq; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.07, now + 2); gain.gain.exponentialRampToValueAtTime(0.0001, now + 13); osc.connect(gain); gain.connect(tuneGain); osc.start(now + i * 0.3); osc.stop(now + 13.5); }); } // ===================================================================== // Morse (number station) // ===================================================================== const MORSE_DIGITS = { 0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-", 5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.", }; let morseTimer = null; function startMorse(sequence) { if (morseTimer || !audio?.ctx) return; const DOT = 0.07; const playSeq = () => { if (!audio?.ctx) return; const { ctx, master } = audio; let t0 = ctx.currentTime + 0.1; for (const digit of sequence) { for (const symbol of MORSE_DIGITS[digit] || "") { const dur = symbol === "." ? DOT : DOT * 3; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.value = 600; gain.gain.setValueAtTime(0.0001, t0); gain.gain.exponentialRampToValueAtTime(0.1, t0 + 0.005); gain.gain.setValueAtTime(0.1, t0 + dur - 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); osc.connect(gain); gain.connect(master); osc.start(t0); osc.stop(t0 + dur + 0.02); t0 += dur + DOT; } t0 += DOT * 3; // pause between digits } }; playSeq(); morseTimer = setInterval(playSeq, 6500); } function stopMorse() { if (morseTimer) { clearInterval(morseTimer); morseTimer = null; } } // ===================================================================== // Station catalog + per-listener frequency layout // // The backend keeps canonical frequencies (104.7 = number station, etc.); // the frontend shuffles them onto the dial deterministically from the session // variant, so every listener's dial is a different map. When we call /tune we // translate the displayed frequency back to its canonical value, so the backend // stays untouched. The Morse sequence and unlock code also vary per listener // (decided by the backend from the same variant). // ===================================================================== let displayToCanon = new Map(); // "92.3" (displayed) -> 104.7 (canonical) let canonToDisplay = new Map(); // "104.7" -> 92.3 function mulberry32(seed) { let a = seed >>> 0; return function () { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // Spread canonical freqs across [lo,hi] in a shuffled order, snapped to 0.1. function assignLayout(canonFreqs, lo, hi, rng) { const n = canonFreqs.length; const order = canonFreqs.slice(); for (let i = n - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } const span = hi - lo; const out = []; for (let i = 0; i < n; i++) { const center = lo + (span * (i + 0.5)) / n; const jitter = (rng() - 0.5) * (span / n) * 0.45; let disp = Math.round((center + jitter) * 10) / 10; disp = Math.max(lo, Math.min(hi, disp)); out.push([order[i], disp]); } return out; } function canonical(displayFreq) { if (displayFreq === null || displayFreq === undefined) return displayFreq; const c = displayToCanon.get(displayFreq.toFixed(1)); return c === undefined ? displayFreq : c; } async function loadStations() { let canon = {}; try { const res = await fetch(`/api/stations?variant=${variant}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); canon = await res.json(); } catch (err) { console.warn("No se pudo cargar /api/stations:", err); canon = {}; } const canonFreqs = Object.keys(canon) .map(Number) .sort((a, b) => a - b); const rng = mulberry32((variant || 1) ^ 0x1234abcd); const assign = assignLayout(canonFreqs, 88.0, 107.0, rng); stations = {}; displayToCanon = new Map(); canonToDisplay = new Map(); for (const [canonF, dispF] of assign) { const meta = canon[canonF.toFixed(1)] || canon[String(canonF)]; if (!meta) continue; const key = dispF.toFixed(1); stations[key] = meta; displayToCanon.set(key, canonF); canonToDisplay.set(canonF.toFixed(1), dispF); if (meta.encrypted && meta.sequence) MORSE_SEQUENCE = meta.sequence; } stationFreqs = Object.keys(stations) .map(Number) .sort((a, b) => a - b); encryptedDisplayFreq = null; for (const [key, meta] of Object.entries(stations)) { if (meta && meta.encrypted) { encryptedDisplayFreq = Number(key); break; } } } function stationMeta(freq) { if (freq === null || freq === undefined) return null; return stations[freq.toFixed(1)] || stations[String(freq)] || null; } function stationDisplayName(meta) { if (!meta) return ""; return meta.names?.[lang] || meta.name || ""; } // ===================================================================== // Decryption state, hidden band and minigame // ===================================================================== let MORSE_SEQUENCE = "77419"; // replaced per-listener from /api/stations const HIDDEN_BAND_MAX = 112.0; const FRAGMENT_VOICE = { pitch_base: 130, pitch_step: 4, pitch_jitter: 30, waveform: "sine" }; const FRAGMENT_THEME = { glow: "#d8fffb", flicker: 0.6 }; let unlockCode = null; let loreShowing = false; let fragmentsMeta = {}; let collectedFragments = new Set(); let driftVel = 0; let downloadProgress = 0; let downloadTarget = null; let finaleStarted = false; async function loadHiddenBand(code) { const res = await fetch( `/api/band?code=${encodeURIComponent(code)}&variant=${variant}` ); if (!res.ok) return false; const data = await res.json(); if (!data.ok) return false; fragmentsMeta = data.fragments || {}; const fragCanon = Object.keys(fragmentsMeta) .map(Number) .sort((a, b) => a - b); const rng = mulberry32((variant || 1) ^ 0x9abcdef0); const assign = assignLayout(fragCanon, 108.3, 111.7, rng); for (const [canonF, dispF] of assign) { const frag = fragmentsMeta[canonF.toFixed(1)] || fragmentsMeta[String(canonF)]; if (!frag) continue; const key = dispF.toFixed(1); stations[key] = { name: frag.name, names: frag.names, fragment: true, voice: FRAGMENT_VOICE, theme: FRAGMENT_THEME, portrait: HOST_PORTRAIT, }; displayToCanon.set(key, canonF); canonToDisplay.set(canonF.toFixed(1), dispF); } stationFreqs = Object.keys(stations) .map(Number) .sort((a, b) => a - b); return true; } // Every reload is a brand-new session (new dial layout, new cipher, empty // field log) — discard any progress a previous session left behind so it // never bleeds into this one. function clearStaleProgress() { try { localStorage.removeItem("lfr_state"); localStorage.removeItem("lfr_state_v2"); localStorage.removeItem("lfr_scratch"); // old cross-session notes cache } catch (_) { /* storage unavailable */ } } // ===================================================================== // Signal physics // ===================================================================== function nearestStation(freq) { if (!stationFreqs.length) return { freq: null, dist: Infinity }; let nearest = stationFreqs[0]; let minDist = Math.abs(freq - nearest); for (const f of stationFreqs) { const d = Math.abs(freq - f); if (d < minDist) { minDist = d; nearest = f; } } return { freq: nearest, dist: minDist }; } function stationFrequency(freq) { const { freq: nearest, dist } = nearestStation(freq); if (nearest === null || dist > STATION_TOLERANCE) return null; return nearest; } function signalStrength(freq) { const stationFreq = stationFrequency(freq); if (stationFreq === null) { const { dist } = nearestStation(freq); if (dist >= NEAR_TOLERANCE) return 0; return Math.max(0, 0.15 * (1 - dist / NEAR_TOLERANCE)); } const dist = Math.abs(freq - stationFreq); if (dist <= LOCK_TOLERANCE) return 1; return Math.max(0, 1 - (dist - LOCK_TOLERANCE) / (NEAR_TOLERANCE - LOCK_TOLERANCE)); } // ===================================================================== // Deterministic text corruption // ===================================================================== function glitchHash(i, bucket) { let h = (i * 374761393 + bucket * 668265263) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } const GLITCH_SYMBOLS = "▓░▒█?¿!@#$%&*~"; function corruptText(text, strength, bucket) { if (strength >= 0.95) return text; const noise = 1 - strength; let out = ""; let i = 0; for (const ch of text) { i += 1; if (ch === " " || ch === "\n") { out += ch; continue; } const r = glitchHash(i, bucket); if (r < noise * 0.35) { out += GLITCH_SYMBOLS[Math.floor(glitchHash(i, bucket + 7) * GLITCH_SYMBOLS.length)]; } else if (r < noise * 0.5) { // character eaten by the interference } else { out += ch; } } return out; } function escapeHtml(text) { return text.replace(/&/g, "&").replace(//g, ">"); } // Canonical control tokens the model always emits (Spanish), mapped to a // localized on-air label per UI language. rawText keeps the canonical token so // detection/events stay simple; only the *display* is localized. This is the // fix for "it says INTERFERENCIA / FIN DE TRANSMISION in English mode". const MARKER_LABELS = { "[JINGLE]": { es: "JINGLE", en: "JINGLE", fr: "JINGLE" }, "[INTERFERENCIA]": { es: "INTERFERENCIA", en: "INTERFERENCE", fr: "INTERFÉRENCE" }, "[CORTE COMERCIAL]": { es: "CORTE COMERCIAL", en: "COMMERCIAL BREAK", fr: "PAUSE PUBLICITAIRE" }, "[FIN DE TRANSMISION]": { es: "FIN DE TRANSMISIÓN", en: "END OF TRANSMISSION", fr: "FIN DE TRANSMISSION" }, "[ACCESO CONCEDIDO]": { es: "ACCESO CONCEDIDO", en: "ACCESS GRANTED", fr: "ACCÈS AUTORISÉ" }, "[ACCESO DENEGADO]": { es: "ACCESO DENEGADO", en: "ACCESS DENIED", fr: "ACCÈS REFUSÉ" }, }; const MARKER_RE = /\[(JINGLE|INTERFERENCIA|CORTE COMERCIAL|FIN DE TRANSMISION|ACCESO CONCEDIDO|ACCESO DENEGADO)\]/g; function markerLabel(canonical) { const entry = MARKER_LABELS[canonical]; return entry ? entry[lang] || entry.es : canonical.slice(1, -1); } function highlightMarkers(text) { return text.replace(MARKER_RE, (m) => `[${markerLabel(m)}]`); } // plain-text variant (console / textContent contexts) function localizeMarkers(text) { return text.replace(MARKER_RE, (m) => `[${markerLabel(m)}]`); } function extractStreamChunk(message) { if (typeof message === "string") return message; if (message?.type === "data") { const raw = message.data; return Array.isArray(raw) ? raw[0] : raw; } if (Array.isArray(message)) return message[0]; return ""; } // ===================================================================== // Transmission rendering + structured token events // ===================================================================== let rawText = ""; let shimmerTimer = null; let glitchBoost = 0; // rises with [INTERFERENCIA], decays on its own let lastCharTime = 0; // drives the announcer's "talking" animation let introActive = false; function renderTransmission() { if (!rawText) return; let strength = signalStrength(frequency); if (finaleStarted || introActive) { strength = 1; // the host and the last message arrive clean } else { const meta = stationMeta(stationFrequency(frequency)); if (meta?.cursed) strength = Math.min(strength, 0.55); // never comes in clean } const bucket = Math.floor(performance.now() / 180); const display = corruptText(rawText, strength, bucket); transmission.innerHTML = highlightMarkers(escapeHtml(display)); transmission.scrollTop = transmission.scrollHeight; } // --------------------------------------------------------------------- // Tuning loader: a 0→100% bar shown while the model is generating, so the // wait reads as "working" instead of "broken". We can't know the real // duration, so the bar eases toward 95% (decelerating, always feels like // progress) and only snaps to 100% when the first token actually arrives. // A short reveal delay keeps cached/instant broadcasts from flashing it. // --------------------------------------------------------------------- let tuneLoaderTimer = null; let tuneLoaderShowTimer = null; let tuneLoaderPct = 0; function setTuneLoaderPct(p) { tuneLoaderPct = p; const r = Math.round(p); if (tuneLoaderFill) tuneLoaderFill.style.width = r + "%"; if (tuneLoaderPctEl) tuneLoaderPctEl.textContent = r + "%"; if (tuneLoader) tuneLoader.setAttribute("aria-valuenow", String(r)); } function clearLoaderTimers() { if (tuneLoaderShowTimer) { clearTimeout(tuneLoaderShowTimer); tuneLoaderShowTimer = null; } if (tuneLoaderTimer) { clearInterval(tuneLoaderTimer); tuneLoaderTimer = null; } } function startTuneLoader() { clearLoaderTimers(); tuneLoaderPct = 0; if (!tuneLoader) return; // Delay the reveal: an instant or cached broadcast shouldn't flash a bar. tuneLoaderShowTimer = setTimeout(() => { tuneLoaderShowTimer = null; if (tuneLoaderText) tuneLoaderText.textContent = t().loading; setTuneLoaderPct(0); tuneLoader.classList.remove("hidden", "done"); const startedAt = performance.now(); tuneLoaderTimer = setInterval(() => { const elapsed = (performance.now() - startedAt) / 1000; const target = 95 * (1 - Math.exp(-elapsed / 2.5)); if (target > tuneLoaderPct) setTuneLoaderPct(target); }, 90); }, 220); } function finishTuneLoader() { // Called on every streamed chunk; only the first one does anything. if (!tuneLoader || tuneLoader.classList.contains("done")) return; clearLoaderTimers(); // Never revealed (fast/cached response) → nothing to fade, just stay hidden. if (tuneLoader.classList.contains("hidden")) return; setTuneLoaderPct(100); tuneLoader.classList.add("done"); setTimeout(() => { if (!tuneLoader) return; tuneLoader.classList.add("hidden"); tuneLoader.classList.remove("done"); }, 380); } function stopTuneLoader() { clearLoaderTimers(); if (tuneLoader) { tuneLoader.classList.add("hidden"); tuneLoader.classList.remove("done"); } } function startShimmer() { if (shimmerTimer) return; shimmerTimer = setInterval(() => { if (!rawText) return; if (signalStrength(frequency) < 0.95) renderTransmission(); }, 180); } function stopShimmer() { if (shimmerTimer) { clearInterval(shimmerTimer); shimmerTimer = null; } } const MARKER_EVENTS = { "[JINGLE]": (voice) => { playJingle(voice); if (REDUCED_MOTION) return; tuningLamp.classList.remove("jingle"); void tuningLamp.offsetWidth; // restarts the animation tuningLamp.classList.add("jingle"); }, "[INTERFERENCIA]": () => { staticBurst(0.3, 0.45); glitchBoost = 1; if (REDUCED_MOTION) return; tvScreen.classList.add("glitching"); setTimeout(() => tvScreen.classList.remove("glitching"), 520); }, "[CORTE COMERCIAL]": () => { playClick(); glitchBoost = Math.max(glitchBoost, 0.4); }, "[FIN DE TRANSMISION]": () => { transmission.classList.add("signing-off"); setStaticLevel(0.25); setTimeout(() => setStaticLevel(1 - signalStrength(frequency)), 1800); }, }; const MARKER_LIST = Object.keys(MARKER_EVENTS); function checkMarkerEvents(voice) { for (const marker of MARKER_LIST) { if (rawText.endsWith(marker)) { MARKER_EVENTS[marker](voice); return; } } } function appendTransmissionChar(char, voice) { rawText += char; lastCharTime = performance.now(); renderTransmission(); playBlip(char, voice); if (char === "]") checkMarkerEvents(voice); } // Streaming-safe apply. The backend leak-scrubber sends the full accumulated // CLEAN text each event, and it can RETRACT already-shown text mid-stream // (when a parroted instruction finally completes and gets cut). We diff against // what we've shown (rawText) by longest common prefix: normally we just append // the new suffix char-by-char (keeps the teletype, blips and marker events); on // a retraction we snap rawText back to the common prefix first, then append — // so a leak that slipped out visibly gets "eaten by the static". function applyServerText(fullClean, voice) { let common = 0; const n = Math.min(fullClean.length, rawText.length); while (common < n && fullClean[common] === rawText[common]) common += 1; if (common < rawText.length) { rawText = rawText.slice(0, common); renderTransmission(); } for (let i = common; i < fullClean.length; i += 1) { appendTransmissionChar(fullClean[i], voice); } } // ===================================================================== // The on-screen announcer // ===================================================================== let currentPortrait = null; function setAnnouncer(portraitUrl) { if (!portraitUrl) { announcerFrame.classList.remove("visible", "talking"); currentPortrait = null; return; } if (portraitUrl === currentPortrait) { announcerFrame.classList.add("visible"); return; } currentPortrait = portraitUrl; announcerImg.src = portraitUrl; announcerFrame.classList.add("visible"); if (!REDUCED_MOTION) { announcerFrame.classList.remove("swapping"); void announcerFrame.offsetWidth; announcerFrame.classList.add("swapping"); setTimeout(() => announcerFrame.classList.remove("swapping"), 550); } } // ===================================================================== // Per-station visual identity // ===================================================================== function applyStationTheme(stationFreq) { const meta = stationFreq !== null ? stationMeta(stationFreq) : null; const theme = meta?.theme || DEFAULT_THEME; document.documentElement.style.setProperty("--station-glow", theme.glow); document.documentElement.style.setProperty( "--flicker-amp", String((theme.flicker ?? 0.3) * 0.1) ); if (stationName) { stationName.textContent = meta ? stationDisplayName(meta) : "· · ·"; } if (hint && !introActive) { hint.textContent = meta ? `♪ ${stationDisplayName(meta)} — ${stationFreq.toFixed(1)} MHz` : t().hintDefault; } } // ===================================================================== // Gauge: conversions + spring needle physics // ===================================================================== const GAUGE_CX = 110; const GAUGE_CY = 124; const ANGLE_MIN = -100; const ANGLE_MAX = 100; function freqToAngle(freq) { const tt = (freq - FREQ_MIN) / (freqMax - FREQ_MIN); return ANGLE_MIN + tt * (ANGLE_MAX - ANGLE_MIN); } // The needle chases the frequency with an under-damped spring (overshoot) let needleAngle = freqToAngle(95.0); let needleVel = 0; // Dial inertia on release let freqVel = 0; // MHz per second let inertiaActive = false; let wasLockedUI = false; // The bezel knob spins several full turns across the whole band. const KNOB_TURNS = 2.5; function knobAngleFor(freq) { return ((freq - FREQ_MIN) / (freqMax - FREQ_MIN)) * 360 * KNOB_TURNS; } function updateDialUI() { freqDisplay.textContent = frequency.toFixed(1); if (knobFace) knobFace.style.transform = `rotate(${knobAngleFor(frequency)}deg)`; tuneKnob?.setAttribute("aria-valuenow", frequency.toFixed(1)); const strength = signalStrength(frequency); const locked = strength >= 0.95; freqDisplay.classList.toggle("locked", locked); needle.classList.toggle("locked", locked); tuningLamp.classList.toggle("on", strength > 0.1); tuningLamp.classList.toggle("locked", locked); if (locked && !wasLockedUI && !REDUCED_MOTION) { freqDisplay.classList.remove("just-locked"); void freqDisplay.offsetWidth; freqDisplay.classList.add("just-locked"); } wasLockedUI = locked; setStaticLevel(1 - strength); if (rawText) renderTransmission(); } // ===================================================================== // Animation loop: needle spring, inertia, oscilloscope, snow, VU // ===================================================================== let lastFrame = performance.now(); let frameCost = 16; let noiseFrameSkip = 0; const scopeCtx = scopeCanvas?.getContext("2d"); let analyserData = null; const NOISE_W = 170; const NOISE_H = 110; let noiseCtx = null; let noiseImage = null; function initNoise() { if (!noiseCanvas) return; noiseCanvas.width = NOISE_W; noiseCanvas.height = NOISE_H; noiseCtx = noiseCanvas.getContext("2d"); noiseImage = noiseCtx.createImageData(NOISE_W, NOISE_H); } function drawNoise(strength) { if (!noiseCtx || REDUCED_MOTION) return; const calm = introActive || finaleStarted; const noiseLevel = calm ? glitchBoost * 0.5 : Math.min(1, (1 - strength) * 0.9 + glitchBoost * 0.6); noiseCanvas.style.opacity = String(noiseLevel * 0.4); if (noiseLevel < 0.02) return; // budget: when frames run slow, draw snow every 3 frames noiseFrameSkip = (noiseFrameSkip + 1) % (frameCost > 28 ? 3 : 1); if (noiseFrameSkip !== 0) return; const d = noiseImage.data; const rollY = Math.floor((performance.now() / 18) % NOISE_H); for (let y = 0; y < NOISE_H; y++) { const inRoll = Math.abs(y - rollY) < 5 ? 70 : 0; for (let x = 0; x < NOISE_W; x++) { const i = (y * NOISE_W + x) * 4; const v = (Math.random() * 160 + inRoll) | 0; d[i] = v * 0.55; d[i + 1] = v; d[i + 2] = v * 0.6; d[i + 3] = 255; } } noiseCtx.putImageData(noiseImage, 0, 0); } function resizeScope() { if (!scopeCanvas) return; const w = scopeCanvas.clientWidth; if (w && scopeCanvas.width !== w) scopeCanvas.width = w; } // ===================================================================== // Galaxy: drifting starfield with twinkle and shooting stars // ===================================================================== let galaxyCtx = null; let stars = []; let planets = []; let shootingStar = null; let nextShootAt = 0; let ufo = null; let nextUfoAt = 0; let comet = null; let nextCometAt = 0; let parallaxX = 0; let parallaxY = 0; let parallaxTX = 0; let parallaxTY = 0; function resizeGalaxy() { if (!galaxyCanvas) return; // half resolution: soft look + cheap to draw galaxyCanvas.width = Math.max(320, Math.floor(window.innerWidth / 2)); galaxyCanvas.height = Math.max(240, Math.floor(window.innerHeight / 2)); } // Distant worlds drifting behind the radio. Each is a soft shaded sphere with // a terminator (day/night) and an optional ring — cheap (a few gradients/frame). const PLANET_PRESETS = [ { x: 0.16, y: 0.24, r: 58, c1: "#ffcaa6", c2: "#7a3b2e", ring: false, drift: 0.006 }, { x: 0.83, y: 0.66, r: 84, c1: "#a8e6c2", c2: "#1f5a52", ring: true, drift: 0.004 }, { x: 0.71, y: 0.16, r: 34, c1: "#b9c6ff", c2: "#2a2f63", ring: false, drift: 0.009 }, ]; function initGalaxy() { if (!galaxyCanvas || REDUCED_MOTION) return; galaxyCtx = galaxyCanvas.getContext("2d"); resizeGalaxy(); const count = Math.min( 230, Math.max(90, Math.floor((window.innerWidth * window.innerHeight) / 9000)) ); stars = Array.from({ length: count }, () => ({ x: Math.random(), y: Math.random(), z: 0.25 + Math.random() * 0.75, // depth → speed, size and twinkle rate tw: Math.random() * Math.PI * 2, hue: Math.random() < 0.85 ? null : Math.random() < 0.5 ? "#9df2ae" : "#9ad8ff", })); planets = PLANET_PRESETS.map((p) => ({ ...p, phase: Math.random() * Math.PI * 2 })); nextShootAt = performance.now() + 3500; nextUfoAt = performance.now() + 9000 + Math.random() * 9000; nextCometAt = performance.now() + 11000 + Math.random() * 12000; } // subtle parallax: the cosmos drifts opposite the pointer function onGalaxyPointer(evt) { parallaxTX = (evt.clientX / window.innerWidth - 0.5) * -22; parallaxTY = (evt.clientY / window.innerHeight - 0.5) * -14; } // a rare bright comet with a long glowing tail function drawComet(now, dt) { if (!comet && now > nextCometAt) { const fromLeft = Math.random() < 0.5; comet = { fromLeft, x: fromLeft ? -0.1 : 1.1, y: 0.08 + Math.random() * 0.32, life: 0, dur: 3.2 + Math.random() * 1.6, }; } if (!comet) return; comet.life += dt; const p = comet.life / comet.dur; if (p >= 1) { comet = null; nextCometAt = now + 16000 + Math.random() * 20000; return; } const w = galaxyCanvas.width; const h = galaxyCanvas.height; const dir = comet.fromLeft ? 1 : -1; const hx = (comet.x + dir * p * 1.2) * w; const hy = (comet.y + p * 0.5) * h; const fade = Math.sin(Math.PI * p); const tailLen = Math.min(w, h) * 0.24; const tx = hx - dir * tailLen; const ty = hy - tailLen * 0.42; const grad = galaxyCtx.createLinearGradient(tx, ty, hx, hy); grad.addColorStop(0, "rgba(157,242,174,0)"); grad.addColorStop(1, `rgba(205,255,222,${0.55 * fade})`); galaxyCtx.strokeStyle = grad; galaxyCtx.lineWidth = 2.6; galaxyCtx.beginPath(); galaxyCtx.moveTo(tx, ty); galaxyCtx.lineTo(hx, hy); galaxyCtx.stroke(); const head = galaxyCtx.createRadialGradient(hx, hy, 0, hx, hy, 8); head.addColorStop(0, `rgba(235,255,240,${0.95 * fade})`); head.addColorStop(1, "rgba(157,242,174,0)"); galaxyCtx.fillStyle = head; galaxyCtx.beginPath(); galaxyCtx.arc(hx, hy, 8, 0, Math.PI * 2); galaxyCtx.fill(); } function drawPlanet(p, now) { const w = galaxyCanvas.width; const h = galaxyCanvas.height; const scale = w / Math.max(640, w); // keep planets sane on big screens const r = p.r * (0.7 + scale * 0.3); const cx = p.x * w + Math.sin(now / 9000 + p.phase) * 8; const cy = p.y * h + Math.cos(now / 11000 + p.phase) * 6; // soft outer glow const halo = galaxyCtx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.8); halo.addColorStop(0, "rgba(180,220,210,0.10)"); halo.addColorStop(1, "rgba(180,220,210,0)"); galaxyCtx.fillStyle = halo; galaxyCtx.beginPath(); galaxyCtx.arc(cx, cy, r * 1.8, 0, Math.PI * 2); galaxyCtx.fill(); if (p.ring) { galaxyCtx.save(); galaxyCtx.translate(cx, cy); galaxyCtx.rotate(-0.5); galaxyCtx.scale(1, 0.32); galaxyCtx.strokeStyle = "rgba(210,235,225,0.28)"; galaxyCtx.lineWidth = r * 0.13; galaxyCtx.beginPath(); galaxyCtx.arc(0, 0, r * 1.5, 0, Math.PI * 2); galaxyCtx.stroke(); galaxyCtx.restore(); } // body with a lit side (terminator) const body = galaxyCtx.createRadialGradient( cx - r * 0.4, cy - r * 0.4, r * 0.1, cx, cy, r ); body.addColorStop(0, p.c1); body.addColorStop(1, p.c2); galaxyCtx.fillStyle = body; galaxyCtx.beginPath(); galaxyCtx.arc(cx, cy, r, 0, Math.PI * 2); galaxyCtx.fill(); // night-side shadow const shade = galaxyCtx.createRadialGradient( cx + r * 0.55, cy + r * 0.4, r * 0.2, cx, cy, r ); shade.addColorStop(0, "rgba(2,4,8,0.55)"); shade.addColorStop(1, "rgba(2,4,8,0)"); galaxyCtx.fillStyle = shade; galaxyCtx.beginPath(); galaxyCtx.arc(cx, cy, r, 0, Math.PI * 2); galaxyCtx.fill(); } function drawUfo(now, dt) { const w = galaxyCanvas.width; const h = galaxyCanvas.height; if (!ufo && now > nextUfoAt) { const dir = Math.random() < 0.5 ? 1 : -1; ufo = { dir, x: dir > 0 ? -0.08 : 1.08, y: 0.1 + Math.random() * 0.5, life: 0, dur: 5 + Math.random() * 3, }; } if (!ufo) return; ufo.life += dt; const p = ufo.life / ufo.dur; if (p >= 1) { ufo = null; nextUfoAt = now + 14000 + Math.random() * 16000; return; } const travel = ufo.dir > 0 ? -0.08 + p * 1.16 : 1.08 - p * 1.16; const cx = travel * w; const cy = (ufo.y + Math.sin(p * Math.PI * 4) * 0.02) * h; const s = Math.min(w, h) * 0.022; // saucer scale const fade = Math.sin(Math.PI * p); // fade in/out at the edges galaxyCtx.save(); galaxyCtx.globalAlpha = fade; // under-glow beam const beam = galaxyCtx.createRadialGradient(cx, cy + s * 1.4, 0, cx, cy + s * 1.4, s * 2.4); beam.addColorStop(0, "rgba(124,223,143,0.30)"); beam.addColorStop(1, "rgba(124,223,143,0)"); galaxyCtx.fillStyle = beam; galaxyCtx.beginPath(); galaxyCtx.ellipse(cx, cy + s * 1.2, s * 2.2, s * 1.3, 0, 0, Math.PI * 2); galaxyCtx.fill(); // dome galaxyCtx.fillStyle = "rgba(190,240,205,0.9)"; galaxyCtx.beginPath(); galaxyCtx.ellipse(cx, cy - s * 0.35, s * 0.7, s * 0.7, 0, Math.PI, 0); galaxyCtx.fill(); // body const bodyG = galaxyCtx.createLinearGradient(cx, cy - s * 0.4, cx, cy + s * 0.5); bodyG.addColorStop(0, "#cfe9d6"); bodyG.addColorStop(1, "#3a6a4a"); galaxyCtx.fillStyle = bodyG; galaxyCtx.beginPath(); galaxyCtx.ellipse(cx, cy, s * 1.7, s * 0.55, 0, 0, Math.PI * 2); galaxyCtx.fill(); // blinking lights const blink = (Math.sin(now / 90) + 1) / 2; for (let i = -1; i <= 1; i++) { galaxyCtx.fillStyle = `rgba(255,210,90,${0.4 + 0.6 * ((blink + (i + 1) * 0.33) % 1)})`; galaxyCtx.beginPath(); galaxyCtx.arc(cx + i * s * 0.9, cy + s * 0.15, s * 0.16, 0, Math.PI * 2); galaxyCtx.fill(); } galaxyCtx.restore(); } function drawGalaxy(now, dt) { if (!galaxyCtx) return; const w = galaxyCanvas.width; const h = galaxyCanvas.height; galaxyCtx.clearRect(0, 0, w, h); // ease toward the pointer-parallax target, then shift the whole cosmos parallaxX += (parallaxTX - parallaxX) * 0.05; parallaxY += (parallaxTY - parallaxY) * 0.05; galaxyCtx.save(); galaxyCtx.translate(parallaxX, parallaxY); // planets sit furthest back for (const p of planets) drawPlanet(p, now); for (const s of stars) { s.x += dt * 0.0045 * s.z; // slow parallax drift if (s.x > 1) s.x -= 1; const alpha = 0.3 + 0.6 * (0.5 + 0.5 * Math.sin((now / 700) * s.z + s.tw)); galaxyCtx.globalAlpha = alpha; galaxyCtx.fillStyle = s.hue || "#cfeede"; const size = s.z * 1.7; galaxyCtx.fillRect(s.x * w, s.y * h, size, size); } galaxyCtx.globalAlpha = 1; if (!shootingStar && now > nextShootAt) { shootingStar = { x: 0.05 + Math.random() * 0.6, y: Math.random() * 0.45, life: 0 }; } if (shootingStar) { shootingStar.life += dt; const p = shootingStar.life / 0.9; if (p >= 1) { shootingStar = null; nextShootAt = now + 6000 + Math.random() * 10000; } else { const sx = (shootingStar.x + p * 0.28) * w; const sy = (shootingStar.y + p * 0.13) * h; const grad = galaxyCtx.createLinearGradient(sx - 46, sy - 21, sx, sy); grad.addColorStop(0, "rgba(157, 242, 174, 0)"); grad.addColorStop(1, `rgba(214, 255, 228, ${0.85 * (1 - p)})`); galaxyCtx.strokeStyle = grad; galaxyCtx.lineWidth = 1.3; galaxyCtx.beginPath(); galaxyCtx.moveTo(sx - 46, sy - 21); galaxyCtx.lineTo(sx, sy); galaxyCtx.stroke(); } } drawUfo(now, dt); drawComet(now, dt); galaxyCtx.restore(); } function drawScope(strength) { if (!scopeCtx) return; const w = scopeCanvas.width; const h = scopeCanvas.height; scopeCtx.clearRect(0, 0, w, h); const glow = getComputedStyle(document.documentElement).getPropertyValue("--station-glow") || "#7cdf8f"; scopeCtx.strokeStyle = glow.trim(); scopeCtx.lineWidth = 1.6; scopeCtx.shadowBlur = 6; scopeCtx.shadowColor = glow.trim(); scopeCtx.beginPath(); if (audio?.analyser) { if (!analyserData) analyserData = new Uint8Array(audio.analyser.fftSize); audio.analyser.getByteTimeDomainData(analyserData); const step = analyserData.length / w; for (let x = 0; x < w; x++) { const v = analyserData[Math.floor(x * step)] / 255; const y = h / 2 + (v - 0.5) * h * 1.6; if (x === 0) scopeCtx.moveTo(x, y); else scopeCtx.lineTo(x, y); } } else { scopeCtx.moveTo(0, h / 2); scopeCtx.lineTo(w, h / 2); } scopeCtx.stroke(); scopeCtx.shadowBlur = 0; // VU: blend of signal strength and real audio energy let rms = 0; if (analyserData) { let sum = 0; for (let i = 0; i < analyserData.length; i += 8) { const v = (analyserData[i] - 128) / 128; sum += v * v; } rms = Math.sqrt(sum / (analyserData.length / 8)); } const vu = Math.min(1, strength * 0.45 + rms * 2.2); vuBar.style.width = `${Math.round(vu * 100)}%`; } function animate(now) { const dt = Math.min(0.05, (now - lastFrame) / 1000); frameCost = frameCost * 0.9 + (now - lastFrame) * 0.1; lastFrame = now; // failure: the operator sweeps the dial helplessly from one end to the other if (failureSwing) { failureSwingT += dt; const span = freqMax - FREQ_MIN; const decay = Math.max(0, 1 - failureSwingT / 9); frequency = FREQ_MIN + span * (0.5 + 0.47 * decay * Math.sin(failureSwingT * 2.1)); updateDialUI(); if (failureSwingT > 9) { failureSwing = false; freqVel = 0; inertiaActive = false; setStaticLevel(1); } } // dial inertia on release (frozen during the endgame) if (inertiaActive && !isDragging && !endgameLock && !failureSwing) { frequency += freqVel * dt; freqVel *= Math.exp(-2.6 * dt); if (frequency < FREQ_MIN) { frequency = FREQ_MIN; freqVel = -freqVel * 0.3; // soft bounce at the end stop } else if (frequency > freqMax) { frequency = freqMax; freqVel = -freqVel * 0.3; } if (Math.abs(freqVel) < 0.04) { inertiaActive = false; freqVel = 0; frequency = Math.round(frequency * 10) / 10; updateDialUI(); scheduleTune(); } else { updateDialUI(); } } // hidden band: the world is powering down, so the dial wobbles faintly when // you let go — but only enough for atmosphere; it won't carry you across // fragments on its own (that used to auto-collect them by accident). if (unlockCode && !finaleStarted && frequency >= BASE_FREQ_MAX) { if (!isDragging && !knobDragging && !inertiaActive) { driftVel += (Math.random() - 0.5) * 0.0025; driftVel = Math.max(-0.009, Math.min(0.009, driftVel)); // faint wobble frequency = Math.min(freqMax, Math.max(FREQ_MIN, frequency + driftVel * dt)); updateDialUI(); } updateDownload(dt); } else { updateMissionHud(false); } // under-damped needle spring (overshoot while settling) const target = freqToAngle(frequency); if (REDUCED_MOTION) { needleAngle = target; } else { const k = 160; // stiffness const c = 14; // damping (< critical → slight overshoot) const accel = (target - needleAngle) * k - needleVel * c; needleVel += accel * dt; needleAngle += needleVel * dt; } needle.setAttribute( "transform", `rotate(${needleAngle.toFixed(2)} ${GAUGE_CX} ${GAUGE_CY})` ); glitchBoost = Math.max(0, glitchBoost - dt * 1.8); // announcer talking: synced with arriving characters const talking = voiceEnabled && now - lastCharTime < 320 && rawText.length > 0; announcerFrame?.classList.toggle("talking", talking); talkBars?.classList.toggle("talking", talking); const strength = signalStrength(frequency); drawScope(strength); drawNoise(strength); drawGalaxy(now, dt); updateIntercept(); requestAnimationFrame(animate); } // ===================================================================== // Dial interaction: horizontal drag + wheel + keyboard // ===================================================================== let dragStartX = 0; let dragStartFreq = 95.0; let lastDragX = 0; let lastDragTime = 0; let dragVel = 0; // MHz por segundo let pendingTune = null; function scheduleTune(delay = 220) { clearTimeout(pendingTune); pendingTune = setTimeout(() => { pendingTune = null; tuneNow(); }, delay); } function setFrequency(freq, { snap = true } = {}) { frequency = Math.max(FREQ_MIN, Math.min(freqMax, freq)); if (snap) frequency = Math.round(frequency * 10) / 10; updateDialUI(); } function onPointerDown(evt) { if (endgameLock) return; evt.preventDefault(); resumeAudio(); cancelIntro(); isDragging = true; inertiaActive = false; freqVel = 0; dragVel = 0; dragStartX = evt.clientX; dragStartFreq = frequency; lastDragX = evt.clientX; lastDragTime = performance.now(); gaugeWrap.setPointerCapture?.(evt.pointerId); } function onPointerMove(evt) { if (!isDragging) return; const rect = gaugeWrap.getBoundingClientRect(); // full gauge width ≈ half the band → fine control without losing reach const mhzPerPx = (freqMax - FREQ_MIN) / (rect.width * 2.2); const dx = evt.clientX - dragStartX; setFrequency(dragStartFreq + dx * mhzPerPx, { snap: false }); const nowT = performance.now(); const dtp = Math.max(1, nowT - lastDragTime) / 1000; const instVel = ((evt.clientX - lastDragX) * mhzPerPx) / dtp; dragVel = dragVel * 0.7 + instVel * 0.3; lastDragX = evt.clientX; lastDragTime = nowT; } function onPointerUp(evt) { if (!isDragging) return; isDragging = false; gaugeWrap.releasePointerCapture?.(evt.pointerId); // The dial stays exactly where you let go. Release momentum used to fling it // freely across the band, which felt random and uncontrollable; a tuning // dial should settle on the frequency you left it on. inertiaActive = false; freqVel = 0; dragVel = 0; setFrequency(frequency); // rounds to the nearest 0.1 scheduleTune(); } function onWheel(evt) { if (endgameLock) return; // don't intercept scrolling inside the transmission or the console if (evt.target.closest(".transmission") || evt.target.closest(".console-wrap")) return; evt.preventDefault(); resumeAudio(); cancelIntro(); inertiaActive = false; const step = evt.deltaY > 0 ? -0.1 : 0.1; setFrequency(frequency + step); scheduleTune(); } function snapToStation(direction) { const candidates = stationFreqs.filter((f) => direction > 0 ? f > frequency + 0.01 : f < frequency - 0.01 ); if (!candidates.length) return; const target = direction > 0 ? Math.min(...candidates) : Math.max(...candidates); setFrequency(target); scheduleTune(); } function onKeyDown(evt) { if (evt.target === consoleInput) return; if (endgameLock) return; const actions = { ArrowLeft: () => { setFrequency(frequency - 0.1); scheduleTune(); }, ArrowRight: () => { setFrequency(frequency + 0.1); scheduleTune(); }, ArrowUp: () => snapToStation(1), ArrowDown: () => snapToStation(-1), }; const action = actions[evt.key]; if (!action) return; evt.preventDefault(); resumeAudio(); cancelIntro(); inertiaActive = false; action(); } // --- Bezel knob: drag like the main dial (horizontal pixel delta) --- // An earlier version mapped drag position to the angle around the knob's // center, which falls apart for an ordinary left-to-right mouse drag: small // movements near the center produce huge angle swings, and the frequency // snaps back toward its starting point instead of following the cursor. let knobDragging = false; let knobDragStartX = 0; let knobDragStartFreq = 95.0; let knobLastX = 0; let knobVel = 0; // MHz per second function onKnobDown(evt) { if (endgameLock) return; evt.preventDefault(); resumeAudio(); cancelIntro(); knobDragging = true; inertiaActive = false; freqVel = 0; knobVel = 0; knobDragStartX = evt.clientX; knobDragStartFreq = frequency; knobLastX = evt.clientX; lastDragTime = performance.now(); tuneKnob.setPointerCapture?.(evt.pointerId); } function onKnobMove(evt) { if (!knobDragging) return; const rect = tuneKnob.getBoundingClientRect(); // full knob width ≈ half the band → fine control without losing reach const mhzPerPx = (freqMax - FREQ_MIN) / (rect.width * 2.2); const dx = evt.clientX - knobDragStartX; setFrequency(knobDragStartFreq + dx * mhzPerPx, { snap: false }); const nowT = performance.now(); const dtp = Math.max(1, nowT - lastDragTime) / 1000; const instVel = ((evt.clientX - knobLastX) * mhzPerPx) / dtp; knobVel = knobVel * 0.7 + instVel * 0.3; knobLastX = evt.clientX; lastDragTime = nowT; } function onKnobUp(evt) { if (!knobDragging) return; knobDragging = false; tuneKnob.releasePointerCapture?.(evt.pointerId); // Same as the main dial: no free-spin. The knob stops where you release it. inertiaActive = false; freqVel = 0; knobVel = 0; setFrequency(frequency); scheduleTune(); } function onKnobWheel(evt) { if (endgameLock) return; evt.preventDefault(); resumeAudio(); cancelIntro(); inertiaActive = false; const step = evt.deltaY > 0 ? -0.1 : 0.1; setFrequency(frequency + step); scheduleTune(); } function onDoubleClick() { if (endgameLock) return; // double click: snaps to the nearest station const { freq } = nearestStation(frequency); if (freq === null) return; cancelIntro(); setFrequency(freq); scheduleTune(); } // ===================================================================== // Streaming /tune // ===================================================================== let lastTunedFreq = null; let wasLocked = false; async function ensureClient() { if (client) return client; const { Client } = await import(GRADIO_CLIENT_URL); client = await Client.connect(window.location.origin); return client; } function cancelTune() { // Bumping the generation makes every in-flight for-await loop break, which // closes its stream client-side; the server self-terminates the superseded // generation via its own epoch. We deliberately do NOT call job.cancel(): // gr.Server has no cancel route, so it just spams 404s and can wedge the // client after enough rapid retunes (the other half of "text stops coming"). tuneGeneration += 1; currentJob = null; } async function tuneNow() { if (introActive || finaleStarted || loreShowing) return; // never interrupt these const strength = signalStrength(frequency); const locked = strength >= 0.95; const stationFreq = stationFrequency(frequency); if (locked && !wasLocked) playSquelch(); wasLocked = locked; applyStationTheme(stationFreq); // number station: Morse + console to "transmit back" const meta = stationMeta(stationFreq); if (meta?.encrypted && strength > 0.3) { showConsole(); if (!unlockCode) { startMorse(MORSE_SEQUENCE); if (hint) hint.textContent = t().numberHint; } else { // already cracked: the operator now invites the truth (the lore reveal) stopMorse(); if (hint) hint.textContent = t().codeHint; } } else { stopMorse(); if (!meta?.encrypted) hideConsole(); } // log discoveries to the field notebook when a station is locked in if (stationFreq !== null && locked) { const kind = meta?.encrypted ? "encrypted" : meta?.fragment ? "fragment" : meta?.cursed ? "cursed" : "station"; logStation(stationFreq, stationDisplayName(meta), kind); } if (meta?.encrypted && strength > 0.3) logCipherSeq(MORSE_SEQUENCE); if (stationFreq === null || strength <= 0) { voiceEnabled = false; cancelTune(); rawText = ""; stopShimmer(); stopTuneLoader(); setAnnouncer(null); transmission.classList.remove("signing-off"); transmission.textContent = t().staticText; transmission.classList.remove("active"); lastTunedFreq = null; return; } if (stationFreq === lastTunedFreq && currentJob) return; lastTunedFreq = stationFreq; cancelTune(); const myGen = tuneGeneration; voiceEnabled = true; rawText = ""; transmission.classList.remove("signing-off"); transmission.textContent = ""; transmission.classList.add("active"); setAnnouncer(meta?.portrait || null); startShimmer(); startTuneLoader(); const voice = meta?.voice || { pitch_base: 220, pitch_step: 6, pitch_jitter: 20, waveform: "square", }; try { const api = await ensureClient(); if (myGen !== tuneGeneration) return; const job = api.submit("/tune", { frequency: canonical(stationFreq), language: lang, variant, live: liveMode ? 1 : 0, }); currentJob = job; for await (const message of job) { if (myGen !== tuneGeneration) break; const chunk = extractStreamChunk(message); if (typeof chunk !== "string" || !chunk) continue; finishTuneLoader(); // first real text: the bar completes and fades applyServerText(chunk, voice); // backend sends full accumulated clean text } if (myGen !== tuneGeneration) return; if (!rawText.trim()) { stopTuneLoader(); transmission.textContent = t().weakSignal; transmission.classList.remove("active"); voiceEnabled = false; } } catch (err) { if (myGen !== tuneGeneration) return; stopTuneLoader(); voiceEnabled = false; const msg = err?.message || String(err); if (msg.includes("Job cancelled") || msg.includes("aborted")) return; console.warn("tune error:", msg); if (msg.includes("fetch") || msg.includes("CONNECTION")) { client = null; transmission.textContent = t().disconnected; } else { transmission.textContent = t().lostSignal; } transmission.classList.remove("active"); } finally { if (myGen === tuneGeneration) currentJob = null; } } // ===================================================================== // Host intro: explains what Lost Frequency Radio is // ===================================================================== let introTimer = null; function playIntro() { cancelTune(); stopMorse(); hideConsole(); clearInterval(introTimer); introActive = true; voiceEnabled = true; rawText = ""; transmission.classList.remove("signing-off"); transmission.textContent = ""; transmission.classList.add("active"); setAnnouncer(HOST_PORTRAIT); if (stationName) stationName.textContent = t().hostName; if (hint) hint.textContent = t().hintDefault; document.documentElement.style.setProperty("--station-glow", "#7cdf8f"); const text = t().intro; let i = 0; introTimer = setInterval(() => { if (!introActive) { clearInterval(introTimer); introTimer = null; return; } if (i >= text.length) { clearInterval(introTimer); introTimer = null; introActive = false; voiceEnabled = false; return; } appendTransmissionChar(text[i], HOST_VOICE); i += 1; }, 30); } function cancelIntro() { if (!introActive) return; introActive = false; voiceEnabled = false; clearInterval(introTimer); introTimer = null; rawText = ""; setAnnouncer(null); transmission.classList.remove("active", "signing-off"); transmission.textContent = t().staticText; } // ===================================================================== // Language and static: bezel switches // ===================================================================== function applyLangUI() { document.documentElement.lang = lang; if (tagline) tagline.textContent = t().tagline; if (labelLanguage) labelLanguage.textContent = t().languageLabel; if (labelStatic) labelStatic.textContent = t().staticLabel; if (labelMode) labelMode.textContent = t().modeLabel; if (labelTuning) labelTuning.textContent = t().tuningLabel; if (labelHelp) labelHelp.textContent = t().helpLabel; if (mission1El) mission1El.textContent = t().mission1; if (mission2El) mission2El.textContent = t().mission2; if (mission3El) mission3El.textContent = t().mission3; if (mission4El) mission4El.textContent = t().mission4; if (notesTabLabel) notesTabLabel.textContent = t().notesTab; if (notesTitle) notesTitle.textContent = t().notesTitle; if (notesFoundLabelEl) notesFoundLabelEl.textContent = t().notesFoundLabel; if (notesCipherLabelEl) notesCipherLabelEl.textContent = t().notesCipherLabel; if (notesScratchLabelEl) notesScratchLabelEl.textContent = t().notesScratchLabel; renderNotes(); if (consoleInput) consoleInput.placeholder = t().consolePlaceholder; if (guideBtn) guideBtn.title = t().guideTitle; if (helpModal && !helpModal.classList.contains("hidden")) buildHelpList(); if (bootSub) bootSub.textContent = t().bootSub; if (powerLabel) powerLabel.textContent = t().powerLabel; if (bootHint) bootHint.textContent = t().bootHint; if (notesCalloutTextEl) notesCalloutTextEl.textContent = t().bootTip; if (modeCalloutTextEl) modeCalloutTextEl.textContent = t().modeTip; for (const btn of langSwitch?.querySelectorAll("button") || []) { btn.classList.toggle("active", btn.dataset.lang === lang); } for (const btn of bootLangs?.querySelectorAll("button") || []) { btn.classList.toggle("active", btn.dataset.lang === lang); } // localized placeholders while nothing is tuned if (!rawText && !transmission.classList.contains("active")) { transmission.textContent = t().staticText; } // refresh the visible station name in the new language if (!introActive) applyStationTheme(stationFrequency(frequency)); } function setLanguage(newLang) { if (!I18N[newLang] || newLang === lang) return; lang = newLang; try { localStorage.setItem("lfr_lang", lang); } catch (_) { /* storage unavailable */ } applyLangUI(); if (introActive) { // restart the welcome in the new language introActive = false; clearInterval(introTimer); playIntro(); return; } // retune the current station in the new language lastTunedFreq = null; cancelTune(); applyStationTheme(stationFrequency(frequency)); tuneNow(); } langSwitch?.addEventListener("click", (evt) => { const btn = evt.target.closest("button[data-lang]"); if (!btn) return; resumeAudio(); playClick(); setLanguage(btn.dataset.lang); }); staticToggle?.addEventListener("click", () => { resumeAudio(); staticOn = !staticOn; staticToggle.classList.toggle("on", staticOn); staticToggle.setAttribute("aria-checked", String(staticOn)); playClick(); setStaticLevel(1 - signalStrength(frequency)); }); modeToggle?.addEventListener("click", () => { resumeAudio(); modeCalloutEl?.classList.add("hidden"); // they found it; stop pointing liveMode = !liveMode; modeToggle.classList.toggle("on", liveMode); modeToggle.setAttribute("aria-checked", String(liveMode)); playClick(); if (hint) hint.textContent = liveMode ? t().liveHint : t().fastHint; // Clear the screen now for instant feedback, then coalesce rapid toggles into // ONE re-tune that uses the FINAL mode. Calling tuneNow() directly on every // click spawned overlapping async tunes that raced and got stuck on the wrong // mode; a short debounce (plus the tuneGeneration epoch) guarantees only the // latest toggle's mode actually streams. cancelTune(); rawText = ""; transmission.textContent = ""; lastTunedFreq = null; scheduleTune(160); }); // The ? button opens a help popup explaining the controls and how to play, // instead of replaying the host intro. function buildHelpList() { if (!helpList) return; if (helpTitleEl) helpTitleEl.textContent = t().helpTitle; helpList.innerHTML = ""; for (const [label, body] of t().helpItems || []) { const li = document.createElement("li"); const strong = document.createElement("strong"); strong.textContent = label + " "; li.appendChild(strong); li.appendChild(document.createTextNode(body)); helpList.appendChild(li); } } function openHelp() { if (!helpModal) return; resumeAudio(); playClick(); buildHelpList(); helpModal.classList.remove("hidden"); helpModal.setAttribute("aria-hidden", "false"); } function closeHelp() { if (!helpModal) return; helpModal.classList.add("hidden"); helpModal.setAttribute("aria-hidden", "true"); } guideBtn?.addEventListener("click", openHelp); helpClose?.addEventListener("click", closeHelp); helpModal?.addEventListener("click", (evt) => { if (evt.target === helpModal) closeHelp(); // click the backdrop to dismiss }); document.addEventListener("keydown", (evt) => { if (evt.key === "Escape" && helpModal && !helpModal.classList.contains("hidden")) { closeHelp(); } }); // ===================================================================== // Boot screen: language pick + power-on ritual // ===================================================================== let powered = false; function powerOn() { if (powered) return; powered = true; resumeAudio(); playClick(); bootEl?.classList.add("hidden"); if (!REDUCED_MOTION) { tvScreen.classList.add("powering-on"); setTimeout(() => tvScreen.classList.remove("powering-on"), 1150); } staticBurst(0.45, 0.7); // Establish the steady static hiss so it is actually audible by default. It // used to decay to silence after the burst and only come back once the dial // moved or the switch was toggled off and on (the "toggle twice to hear it" // bug). The host literally broadcasts "from the heart of the static", so the // hiss under the intro is on-theme. setTimeout(() => setStaticLevel(1 - signalStrength(frequency)), 900); setTimeout(() => playIntro(), REDUCED_MOTION ? 100 : 750); showNotesCallout(); showModeCallout(); } // points at the field log for a bit after power-on, then fades away function showNotesCallout() { if (!notesCalloutEl) return; notesCalloutEl.classList.remove("hidden", "fade-out"); setTimeout(() => { notesCalloutEl.classList.add("fade-out"); setTimeout(() => notesCalloutEl.classList.add("hidden"), 650); }, 15000); } // points at the MODE switch on power-on so a first-timer knows they can choose // FAST (instant) or LIVE (watch the AI generate), then fades away function showModeCallout() { if (!modeCalloutEl) return; modeCalloutEl.classList.remove("hidden", "fade-out"); setTimeout(() => { modeCalloutEl.classList.add("fade-out"); setTimeout(() => modeCalloutEl.classList.add("hidden"), 650); }, 15000); } powerBtn?.addEventListener("click", powerOn); bootLangs?.addEventListener("click", (evt) => { const btn = evt.target.closest("button[data-lang]"); if (!btn) return; setLanguage(btn.dataset.lang); applyLangUI(); }); // ===================================================================== // Encrypted console: social engineering against the operator // ===================================================================== function showConsole() { consoleWrap?.classList.remove("hidden"); // greet with the operator's instruction so the player knows what to do if (consoleLog && consoleLog.children.length === 0) { consolePrint(t().consoleIntro, "op"); } } function hideConsole() { consoleWrap?.classList.add("hidden"); } function consolePrint(text, cls) { if (!consoleLog) return null; const div = document.createElement("div"); div.className = cls; div.textContent = text; consoleLog.appendChild(div); consoleLog.scrollTop = consoleLog.scrollHeight; return div; } let consoleBusy = false; async function sendConsoleMessage(message) { if (consoleBusy) return; consoleBusy = true; consolePrint("> " + message, "you"); const line = consolePrint("...", "op"); try { const api = await ensureClient(); const job = api.submit("/transmit", { message, language: lang, variant }); let acc = ""; for await (const m of job) { const chunk = extractStreamChunk(m); if (typeof chunk !== "string" || !chunk) continue; acc = chunk; if (line) { line.textContent = localizeMarkers(acc); consoleLog.scrollTop = consoleLog.scrollHeight; } } if (acc.includes("[ACCESO CONCEDIDO]")) handleUnlock(acc); // canonical token } catch (err) { if (line) line.textContent = t().channelInterrupted; console.warn("transmit error:", err?.message || err); } finally { consoleBusy = false; } } // The operator's confession: streamed into the screen (where voices speak), // not the little console, because it is long and it matters. async function revealLore() { if (loreShowing) return; loreShowing = true; stopMorse(); hideConsole(); cancelTune(); voiceEnabled = true; setAnnouncer(OPERATOR_PORTRAIT); document.documentElement.style.setProperty("--station-glow", "#58ff8f"); if (stationName) stationName.textContent = cf().operatorName; if (hint) hint.textContent = cf().operatorName; rawText = ""; transmission.classList.remove("signing-off"); transmission.textContent = ""; transmission.classList.add("active"); setStaticLevel(0); try { const api = await ensureClient(); const job = api.submit("/transmit", { message: unlockCode, language: lang, variant }); for await (const m of job) { const chunk = extractStreamChunk(m); if (typeof chunk !== "string" || !chunk) continue; applyServerText(chunk, OP_VOICE); } } catch (err) { console.warn("lore error:", err?.message || err); } finally { voiceEnabled = false; transmission.classList.add("signing-off"); loreShowing = false; } } consoleInput?.addEventListener("keydown", (evt) => { if (evt.key !== "Enter") return; const msg = consoleInput.value.trim(); if (!msg) return; consoleInput.value = ""; resumeAudio(); // after cracking the cipher, transmitting your own code back to the operator // unlocks his confession — the deep lore of what this place really is if (unlockCode && normAnswer(msg).includes(normAnswer(unlockCode))) { revealLore(); return; } sendConsoleMessage(msg); }); // The endgame answer bar (final confrontation + name entry) endgameInput?.addEventListener("keydown", (evt) => { if (evt.key !== "Enter") return; const msg = endgameInput.value.trim(); if (!msg) return; endgameInput.value = ""; resumeAudio(); if (helpPromptActive) handleHelpAnswer(msg); else if (confrontActive) handleConfrontInput(msg); else if (awaitingName) handleName(msg); }); // ===================================================================== // Decryption: climax + band expansion // ===================================================================== function expandBand(animated) { freqMax = HIDDEN_BAND_MAX; buildDial(); if (animated && !REDUCED_MOTION) { dialSvg.classList.remove("expanding"); void dialSvg.getBoundingClientRect(); dialSvg.classList.add("expanding"); setTimeout(() => dialSvg.classList.remove("expanding"), 1700); } updateDialUI(); } async function handleUnlock(revealText) { const codeMatch = revealText.match(/[A-Z]{2,4}-\d{3,5}/); if (!codeMatch) return; unlockCode = codeMatch[0]; logCipherCode(unlockCode); // climax: the Morse stops, the static clears completely stopMorse(); staticBurst(0.4, 0.6); glitchBoost = 1; if (!REDUCED_MOTION) { tvScreen.classList.add("glitching"); setTimeout(() => tvScreen.classList.remove("glitching"), 700); } setTimeout(() => setStaticLevel(0), 700); playJingle({ pitch_base: 300 }); const ok = await loadHiddenBand(unlockCode); if (!ok) return; consolePrint(t().cipherBroken, "op"); if (hint) hint.textContent = t().unlockHint(unlockCode); setTimeout(() => expandBand(true), 900); } // ===================================================================== // Minigame: fragment downloads with a drifting dial // ===================================================================== function totalFragments() { return Object.keys(fragmentsMeta).length; } function updateDownload(dt) { if (finaleStarted) return; const sf = stationFrequency(frequency); const meta = stationMeta(sf); const strength = signalStrength(frequency); const total = totalFragments(); const got = collectedFragments.size; if ( meta?.fragment && sf !== null && !collectedFragments.has(sf.toFixed(1)) && strength >= 0.45 ) { if (downloadTarget !== sf) { downloadTarget = sf; downloadProgress = 0; } downloadProgress = Math.min(1, downloadProgress + dt / 5); // ~5s to recover updateMissionHud(true, got, total, downloadProgress, stationDisplayName(meta)); if (hint) { hint.textContent = t().downloading( stationDisplayName(meta), Math.round(downloadProgress * 100) ); } if (downloadProgress >= 1) { collectedFragments.add(sf.toFixed(1)); playJingle(meta.voice); logStation(sf, stationDisplayName(meta), "fragment"); // recorded in the field log downloadTarget = null; downloadProgress = 0; const newGot = collectedFragments.size; if (hint) hint.textContent = t().received(stationDisplayName(meta), newGot, total); renderNotes(true); updateMissionHud(true, newGot, total, 0, null); if (total > 0 && newGot >= total) setTimeout(startHelpPrompt, 1600); } } else { if (downloadTarget !== null) { // forgiving decay: a brief slip off the fragment doesn't wipe progress downloadProgress = Math.max(0, downloadProgress - dt * 0.12); if (downloadProgress === 0) downloadTarget = null; } updateMissionHud(true, got, total, downloadProgress, null); } } // On-screen objective + progress for the fragment hunt (so the endgame is clear) function updateMissionHud(show, got = 0, total = 0, progress = 0, name = null) { if (!missionHud) return; if (!show || total <= 0) { missionHud.classList.add("hidden"); return; } missionHud.classList.remove("hidden"); if (missionHudText) { missionHudText.textContent = name ? `${t().bandLabel(got, total)} · ↓ ${name} ${Math.round(progress * 100)}%` : `${t().bandLabel(got, total)} · ${t().recoverHint}`; } if (missionHudFill) missionHudFill.style.width = `${Math.round(progress * 100)}%`; if (missionHudPips) { if (missionHudPips.children.length !== total) { missionHudPips.textContent = ""; for (let i = 0; i < total; i++) missionHudPips.appendChild(document.createElement("span")); } [...missionHudPips.children].forEach((p, i) => p.classList.toggle("on", i < got)); } } // ===================================================================== // The Last Message: the complete final transmission // ===================================================================== async function startFinale() { if (finaleStarted) return; finaleStarted = true; cancelTune(); const myGen = tuneGeneration; voiceEnabled = true; rawText = ""; transmission.classList.remove("signing-off"); transmission.textContent = ""; transmission.classList.add("active"); setAnnouncer(HOST_PORTRAIT); document.documentElement.style.setProperty("--station-glow", "#fff6d8"); if (stationName) stationName.textContent = t().finaleTitle; if (hint) hint.textContent = t().finaleTitle; setStaticLevel(0); const voice = { pitch_base: 150, pitch_step: 4, pitch_jitter: 8, waveform: "sine" }; try { const api = await ensureClient(); if (myGen !== tuneGeneration) return; const job = api.submit("/finale", { code: unlockCode, language: lang, variant }); currentJob = job; for await (const message of job) { if (myGen !== tuneGeneration) break; const chunk = extractStreamChunk(message); if (typeof chunk !== "string" || !chunk) continue; applyServerText(chunk, voice); } if (hint) hint.textContent = t().finaleEnd; if (myGen === tuneGeneration) setTimeout(() => showFinaleScreen(), 3200); } catch (err) { console.warn("finale error:", err?.message || err); finaleStarted = false; } finally { if (myGen === tuneGeneration) currentJob = null; } } // The radio goes dark: a last word, a closing tune, and the credits roll. function showFinaleScreen(message) { if (!finaleScreen) return; setStaticLevel(0); if (finaleMessage) finaleMessage.textContent = message || t().finaleGoodbye; if (finaleReplayBtn) { finaleReplayBtn.textContent = t().playAgain; finaleReplayBtn.onclick = () => window.location.reload(); } finaleScreen.classList.remove("hidden"); finaleScreen.setAttribute("aria-hidden", "false"); requestAnimationFrame(() => finaleScreen.classList.add("visible")); setTimeout(() => finaleScreen.classList.add("show-text"), 1300); setTimeout(() => finaleScreen.classList.add("show-credits"), 2800); playOutroTune(); } // ===================================================================== // Gauge construction (galvanometer-style arc) // ===================================================================== function polar(r, angleDeg) { const a = (angleDeg * Math.PI) / 180; return [GAUGE_CX + r * Math.sin(a), GAUGE_CY - r * Math.cos(a)]; } function buildDial() { // background arc const arcBg = document.getElementById("gauge-arc-bg"); if (arcBg) { const r = 100; const [x1, y1] = polar(r, ANGLE_MIN); const [x2, y2] = polar(r, ANGLE_MAX); arcBg.setAttribute("d", `M ${x1} ${y1} A ${r} ${r} 0 1 1 ${x2} ${y2}`); } const ticks = document.getElementById("dial-ticks"); ticks.textContent = ""; for (let f = FREQ_MIN; f <= freqMax; f += 1) { const tt = (f - FREQ_MIN) / (freqMax - FREQ_MIN); const angle = ANGLE_MIN + tt * (ANGLE_MAX - ANGLE_MIN); const major = f % 5 === 0; const r1 = major ? 84 : 90; const r2 = 98; const [x1, y1] = polar(r1, angle); const [x2, y2] = polar(r2, angle); const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", x1); line.setAttribute("y1", y1); line.setAttribute("x2", x2); line.setAttribute("y2", y2); let tickClass = major ? "dial-tick dial-tick-major" : "dial-tick"; if (f > BASE_FREQ_MAX) tickClass += " dial-tick-hidden"; line.setAttribute("class", tickClass); ticks.appendChild(line); if (major) { const [lx, ly] = polar(70, angle); const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("x", lx); text.setAttribute("y", ly + 3); text.setAttribute( "class", f > BASE_FREQ_MAX ? "dial-label dial-label-hidden" : "dial-label" ); text.textContent = f; ticks.appendChild(text); } } } // ===================================================================== // Field notebook: auto-logs stations, the cipher and fragments + free notes // ===================================================================== const NOTES_KEY = `lfr_notes_${variant}`; // session-scoped discoveries let foundLog = []; let cipherSeq = null; let cipherCode = null; function loadNotes() { try { const saved = JSON.parse(sessionStorage.getItem(NOTES_KEY) || "null"); if (saved) { foundLog = saved.found || []; cipherSeq = saved.seq || null; cipherCode = saved.code || null; if (notesScratch) notesScratch.value = saved.scratch || ""; } } catch (_) { /* ignore */ } } function saveNotes() { try { sessionStorage.setItem( NOTES_KEY, JSON.stringify({ found: foundLog, seq: cipherSeq, code: cipherCode, scratch: notesScratch?.value || "", }) ); } catch (_) { /* storage unavailable */ } } function flashNotesTab() { if (!notesTab || notesPanel?.classList.contains("open")) return; notesTab.animate?.( [ { boxShadow: "-4px 0 26px rgba(124,223,143,0.6)" }, { boxShadow: "-4px 0 16px rgba(0,0,0,0.45)" }, ], { duration: 1000 } ); } function logStation(freq, name, kind) { const key = freq.toFixed(1); if (foundLog.some((e) => e.freq === key)) return; foundLog.push({ freq: key, name: name || "", kind: kind || "station" }); foundLog.sort((a, b) => Number(a.freq) - Number(b.freq)); saveNotes(); renderNotes(true); flashNotesTab(); } function logCipherSeq(seq) { if (!seq || cipherSeq === seq) return; cipherSeq = seq; saveNotes(); renderNotes(true); flashNotesTab(); } function logCipherCode(code) { if (!code || cipherCode === code) return; cipherCode = code; saveNotes(); renderNotes(true); flashNotesTab(); } function renderNotes(flashLast) { if (!notesFoundEl) return; notesFoundEl.textContent = ""; if (!foundLog.length) { const li = document.createElement("li"); li.className = "nf-empty"; li.textContent = t().notesEmpty; notesFoundEl.appendChild(li); } else { foundLog.forEach((e, i) => { const li = document.createElement("li"); li.className = "nf-" + (e.kind === "fragment" ? "fragment" : e.kind === "encrypted" ? "cipher" : "station"); const f = document.createElement("span"); f.className = "nf-freq"; f.textContent = e.freq; const n = document.createElement("span"); n.textContent = e.name; li.appendChild(f); li.appendChild(n); if (flashLast && i === foundLog.length - 1) li.classList.add("just-added"); notesFoundEl.appendChild(li); }); } if (notesCipherEl) { if (!cipherSeq && !cipherCode) { notesCipherEl.textContent = "—"; } else { notesCipherEl.innerHTML = ""; if (cipherSeq) { const d = document.createElement("div"); d.textContent = `${t().seqLabel}: ${cipherSeq}`; notesCipherEl.appendChild(d); } if (cipherCode) { const d = document.createElement("div"); d.innerHTML = `${escapeHtml(t().codeLabel)}: ` + `${escapeHtml(cipherCode)}`; notesCipherEl.appendChild(d); } const fragTotal = totalFragments(); if (fragTotal > 0) { const d = document.createElement("div"); d.textContent = `${t().fragmentsLabel}: ${collectedFragments.size}/${fragTotal}`; notesCipherEl.appendChild(d); } } } } function toggleNotes(open) { if (!notesPanel) return; const isOpen = open === undefined ? !notesPanel.classList.contains("open") : open; notesPanel.classList.toggle("open", isOpen); notesPanel.setAttribute("aria-hidden", String(!isOpen)); notesTab?.classList.toggle("hidden", isOpen); } notesTab?.addEventListener("click", () => { resumeAudio(); playClick(); toggleNotes(true); }); notesClose?.addEventListener("click", () => { playClick(); toggleNotes(false); }); notesScratch?.addEventListener("input", saveNotes); // ===================================================================== // Operator intercept: the warden pops up as you near the number station // ===================================================================== let encryptedDisplayFreq = null; const INTERCEPT_RANGE = 2.2; function updateIntercept() { if (!interceptEl) return; // only before the cipher is broken, and never during the host intro if (encryptedDisplayFreq === null || unlockCode || introActive) { interceptEl.classList.remove("active", "urgent"); interceptEl.setAttribute("aria-hidden", "true"); return; } const dist = Math.abs(frequency - encryptedDisplayFreq); // out of range, or already locked (then the station itself takes over) if (dist > INTERCEPT_RANGE || dist <= STATION_TOLERANCE) { interceptEl.classList.remove("active", "urgent"); interceptEl.setAttribute("aria-hidden", "true"); return; } // locked onto a different station that just happens to sit near the // encrypted one on the dial: that station takes over, the warden's // warning shouldn't linger on top of it const lockedFreq = stationFrequency(frequency); if (lockedFreq !== null && lockedFreq !== encryptedDisplayFreq) { interceptEl.classList.remove("active", "urgent"); interceptEl.setAttribute("aria-hidden", "true"); return; } const prox = 1 - (dist - STATION_TOLERANCE) / (INTERCEPT_RANGE - STATION_TOLERANCE); const lines = t().intercept; const band = prox > 0.72 ? 2 : prox > 0.38 ? 1 : 0; interceptEl.style.setProperty("--prox", prox.toFixed(2)); if (interceptText && interceptText.textContent !== lines[band]) { interceptText.textContent = lines[band]; } interceptEl.classList.add("active"); interceptEl.classList.toggle("urgent", band === 2); interceptEl.setAttribute("aria-hidden", "false"); } // ===================================================================== // Final confrontation: the operator's 3-question test, then the payoff // ===================================================================== const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const OPERATOR_PORTRAIT = "/static/img/st_1047.webp"; const MAX_STRIKES = 3; // Some lines just hand off to the next screen with a short sleep — too // short to actually read. waitForContinue() pauses on a "press ENTER to // continue" hint until the listener is ready. let continueResolve = null; function waitForContinue() { return new Promise((resolve) => { const prevHint = hint ? hint.textContent : ""; continueResolve = () => { if (hint) hint.textContent = prevHint; resolve(); }; if (hint) hint.textContent = cf().continueHint; }); } window.addEventListener("keydown", (evt) => { if (evt.key !== "Enter" || !continueResolve) return; evt.preventDefault(); const resolve = continueResolve; continueResolve = null; resolve(); }); let confrontActive = false; let awaitingName = false; let helpPromptActive = false; // the host's "will you help us?" plea let endgameLock = false; // dial is frozen during the confrontation/finale let failureSwing = false; let failureSwingT = 0; let quizSet = []; let quizIndex = 0; let quizStrikes = 0; // Questions answerable only if you actually listened. Three are drawn per // session (seeded by the variant) so the test differs per listener. const QUIZ_POOL = [ { a: ["night drive"], q: { es: "¿En qué emisora cada oyente que llama de madrugada es, en secreto, la misma persona llamando desde distintos años de su vida?", en: "On which station is every late-night caller secretly the same person, calling from different years of their life?", fr: "Sur quelle station chaque auditeur de la nuit est-il, en secret, la même personne appelant depuis différentes années de sa vie ?" } }, { a: ["jupiter", "jupiter fm"], q: { es: "¿Qué emisora da el parte del clima —tormentas de amoniaco, vientos de 900— desde un planeta gigante?", en: "Which station reads the weather — ammonia storms, 900 km/h winds — from a giant planet?", fr: "Quelle station donne la météo — tempêtes d'ammoniac, vents de 900 — depuis une planète géante ?" } }, { a: ["impossible kitchen", "cocina", "cuisine", "kitchen"], q: { es: "¿Qué emisora cocina con ingredientes que no existen, como la sopa de ecos?", en: "Which station cooks with ingredients that don't exist, like soup of echoes?", fr: "Quelle station cuisine avec des ingrédients qui n'existent pas, comme la soupe d'échos ?" } }, { a: ["selenita", "selenite", "selenite"], q: { es: "¿Qué emisora tropical transmite desde un Medellín que queda en la Luna?", en: "Which tropical station broadcasts from a Medellín that sits on the Moon?", fr: "Quelle station tropicale émet depuis un Medellín posé sur la Lune ?" } }, { a: ["midnight archive", "archive", "archivo", "archives"], q: { es: "¿Qué emisora lee fichas de catálogo de objetos perdidos entre universos?", en: "Which station reads catalogue entries for objects lost between universes?", fr: "Quelle station lit des fiches de catalogue d'objets perdus entre les univers ?" } }, { a: ["deep sea", "lounge"], q: { es: "¿Qué emisora pone jazz suave desde el fondo de un océano?", en: "Which station plays smooth jazz from the bottom of an ocean?", fr: "Quelle station passe du jazz feutré depuis le fond d'un océan ?" } }, { a: ["nube", "cloud", "nuage", "103"], q: { es: "¿Qué emisora vende nubes en alquiler, con sombra garantizada?", en: "Which station rents out clouds, with shade guaranteed?", fr: "Quelle station loue des nuages, avec ombre garantie ?" } }, { a: ["wfifty", "fifty", "cincuenta", "aurelio"], q: { es: "Un viejo locutor narra EN VIVO, melodramático, la final de ajedrez entre dos gatos. ¿Cómo se llama su emisora?", en: "An old announcer narrates — live and melodramatic — a chess final between two cats. Name his station.", fr: "Un vieux speaker raconte EN DIRECT, mélodramatique, la finale d'échecs entre deux chats. Quelle est sa station ?" } }, { a: ["hora exacta", "exact hour", "heure exacte"], q: { es: "En un universo donde el tiempo se rompió, una estación aún da la hora: las veinticinco del martes pasado. ¿Cuál es?", en: "In a universe where time broke, one station still gives the hour — twenty-five o'clock last Tuesday. Which is it?", fr: "Dans un univers où le temps s'est brisé, une station donne encore l'heure : vingt-cinq heures mardi dernier. Laquelle ?" } }, { a: ["rooms", "room", "habitacion", "habitaciones", "house", "casa", "pieces", "piece", "maison"], q: { es: "Cuando hablaron los fragmentos, ¿qué dijeron que eran en realidad TODAS las estaciones?", en: "When the fragments spoke, what did they say all the stations really were?", fr: "Quand les fragments ont parlé, qu'ont-ils dit qu'étaient VRAIMENT toutes les stations ?" } }, { a: ["archive", "midnight archive", "archivo", "archives"], q: { es: "Una voz tipo BBC cataloga objetos perdidos entre universos: paraguas que recuerdan la lluvia, llaves de puertas sin construir. ¿Qué emisora?", en: "A BBC-style voice catalogues objects lost between universes — umbrellas that remember rain, keys to doors never built. Which station?", fr: "Une voix à la BBC catalogue des objets perdus entre les univers — des parapluies qui se souviennent de la pluie. Quelle station ?" } }, ]; // Each station thanks the winner by name, in its own voice (fixed + reliable). const STATION_THANKS = { "87.6": { en: (n) => `[JINGLE] This last call goes out to ${n}, dialing in from the only year that ever mattered — tonight. You kept the road open. Drive safe.`, es: (n) => `[JINGLE] La última llamada es para ${n}, marcando desde el único año que importó: esta noche. Mantuviste la carretera abierta. Buen viaje.`, fr: (n) => `[JINGLE] Le dernier appel est pour ${n}, qui appelle depuis la seule année qui ait compté : ce soir. Tu as gardé la route ouverte. Bonne route.`, }, "88.9": { en: (n) => `[JINGLE] ${n}! From the crater of the 70 the whole Moon is dancing for you — you brought the music back. A thousand thanks, friend!`, es: (n) => `[JINGLE] ¡${n}! Desde el cráter de la 70 la Luna entera baila por ti: nos devolviste la música. ¡Mil gracias, parce!`, fr: (n) => `[JINGLE] ${n} ! Depuis le cratère de la 70, toute la Lune danse pour toi : tu nous as rendu la musique. Mille mercis, l'ami !`, }, "91.7": { en: (n) => `[JINGLE] Ladies and gentlemen, a toast from Don Aurelio: ${n} saved the broadcast, and the cats agree — checkmate to silence!`, es: (n) => `[JINGLE] Damas y caballeros, un brindis de Don Aurelio: ${n} salvó la transmisión, y los gatos están de acuerdo. ¡Jaque mate al silencio!`, fr: (n) => `[JINGLE] Mesdames et messieurs, un toast de Don Aurelio : ${n} a sauvé l'émission, et les chats sont d'accord. Échec et mat au silence !`, }, "94.1": { en: (n) => `[JINGLE] The Archive adds one final entry: ${n}, the listener who refused to let us be lost. We will remember. We always remember.`, es: (n) => `[JINGLE] El Archivo añade una última ficha: ${n}, el oyente que no nos dejó perdernos. Lo recordaremos. Siempre recordamos.`, fr: (n) => `[JINGLE] Les Archives ajoutent une dernière fiche : ${n}, l'auditeur qui a refusé de nous laisser disparaître. Nous nous souviendrons. Toujours.`, }, "97.3": { en: (n) => `[JINGLE] Jupiter weather, final bulletin: the storms have calmed because of ${n}. Thank you, small warm voice in the cold.`, es: (n) => `[JINGLE] Júpiter, parte final: las tormentas se calmaron gracias a ${n}. Gracias, pequeña voz cálida en el frío.`, fr: (n) => `[JINGLE] Jupiter, bulletin final : les tempêtes se sont calmées grâce à ${n}. Merci, petite voix chaude dans le froid.`, }, "99.9": { en: (n) => `[JINGLE] At the third tone it will be the exact moment ${n} saved us. ...Beep. For once, the clock is grateful.`, es: (n) => `[JINGLE] Al tercer tono será el instante exacto en que ${n} nos salvó. ...Piiip. Por una vez, el reloj está agradecido.`, fr: (n) => `[JINGLE] Au troisième bip, il sera l'instant exact où ${n} nous a sauvés. ...Biiip. Pour une fois, l'horloge est reconnaissante.`, }, "101.3": { en: (n) => `[JINGLE] Mmm. This last slow number is for ${n} — you kept the lights on down here in the deep. Stay smooth, always.`, es: (n) => `[JINGLE] Mmm. Este último tema lento es para ${n}: mantuviste las luces encendidas aquí abajo. Quédate suave, siempre.`, fr: (n) => `[JINGLE] Mmm. Ce dernier morceau lent est pour ${n} : tu as gardé les lumières allumées ici, dans les profondeurs. Reste doux, toujours.`, }, "103.5": { en: (n) => `[JINGLE] Friends! Today only: infinite gratitude, free of charge, to ${n} — the one who bought us all a little more sky!`, es: (n) => `[JINGLE] ¡Amigos! Solo por hoy: gratitud infinita, sin costo, para ${n}, ¡el que nos compró a todos un poco más de cielo!`, fr: (n) => `[JINGLE] Mes amis ! Aujourd'hui seulement : gratitude infinie, gratuite, pour ${n}, celui qui nous a offert un peu plus de ciel !`, }, "106.1": { en: (n) => `[JINGLE] My darlings, the last dish is named after ${n}: one brave listener and a whole world, saved. Enjoy your meal, and thank you!`, es: (n) => `[JINGLE] Mis amores, el último plato lleva el nombre de ${n}: un oyente valiente y un mundo entero, salvados. ¡Buen provecho, gracias!`, fr: (n) => `[JINGLE] Mes chéris, le dernier plat porte le nom de ${n} : un auditeur courageux et un monde entier, sauvés. Bon appétit, merci !`, }, }; const CONFRONT = { en: { operatorName: "THE OPERATOR", failTitle: "— SIGNAL LOST —", savedTitle: "— THEY ARE SAVED —", defaultName: "the listener", intro: "So. You gathered the fragments. But the last message is earned, not given. Three questions about the voices you found. Prove you were listening. Fail three times and they are gone — forever.", correct: ["...Correct. A lucky guess. Don't smile.", "...Correct again. So you WERE listening. I don't like this."], wrong: (left) => `Wrong. That is not them. ${left} ${left === 1 ? "mistake" : "mistakes"} left.`, concede: "...All three. I concede. I hate that you win. Take them — the frequencies are yours.", askName: "Tell me your name — so they know who pulled them out of the silence.", thanksIntro: (n) => `${n}. They heard you. Listen...`, fail: "You have failed....Probably... you may get a chance in the future...pay attention next time, for now....they are gone, this universe may be gone.....", answerPlaceholder: "type your answer and press Enter...", namePlaceholder: "type your name and press Enter...", hostName: "THE HOST", helpPlea: "You found every fragment — every last one of us. Listener... I have to ask. We are almost gone. Will you help us?", helpPlaceholder: "answer YES or NO and press Enter...", noHelp: "...No. I understand. No one ever stays. Then there is nothing more I can do for them.", declineLine: "OPERATOR: ...You made the right choice. Some frequencies are better left to fade. Goodbye, listener.", continueHint: "— press ENTER to continue —", notFullName: "That is not the full name of the station... I'll be nice... check the FIELD LOG.", }, es: { operatorName: "EL OPERADOR", failTitle: "— SEÑAL PERDIDA —", savedTitle: "— ESTÁN A SALVO —", defaultName: "el oyente", intro: "Así que reuniste los fragmentos. Pero el último mensaje se gana, no se regala. Tres preguntas sobre las voces que encontraste. Demuestra que escuchaste. Falla tres veces y se irán — para siempre.", correct: ["...Correcto. Suerte de principiante. No sonrías.", "...Correcto otra vez. Así que SÍ escuchabas. Esto no me gusta."], wrong: (left) => `Incorrecto. No son ellos. Te ${left === 1 ? "queda 1 error" : "quedan " + left + " errores"}.`, concede: "...Las tres. Me rindo. Odio que ganes. Tómalas — las frecuencias son tuyas.", askName: "Dime tu nombre — para que sepan quién los sacó del silencio.", thanksIntro: (n) => `${n}. Te escucharon. Escucha...`, fail: "Has fallado....Probablemente... quizá tengas otra oportunidad en el futuro...presta atención la próxima vez, por ahora....se han ido, este universo puede que se haya ido.....", answerPlaceholder: "escribe tu respuesta y pulsa Enter...", namePlaceholder: "escribe tu nombre y pulsa Enter...", hostName: "EL ANFITRIÓN", helpPlea: "Encontraste cada fragmento, a cada uno de nosotros. Oyente... tengo que preguntártelo. Casi no quedamos. ¿Nos vas a ayudar?", helpPlaceholder: "responde SÍ o NO y pulsa Enter...", noHelp: "...No. Lo entiendo. Nadie se queda nunca. Entonces ya no hay nada más que pueda hacer por ellos.", declineLine: "OPERADOR: ...Hiciste la elección correcta. Algunas frecuencias es mejor dejarlas apagarse. Adiós, oyente.", continueHint: "— pulsa ENTER para continuar —", notFullName: "Eso no es el nombre completo de la emisora... seré bueno... revisa la BITÁCORA.", }, fr: { operatorName: "L'OPÉRATEUR", failTitle: "— SIGNAL PERDU —", savedTitle: "— ILS SONT SAUVÉS —", defaultName: "l'auditeur", intro: "Alors. Tu as rassemblé les fragments. Mais le dernier message se mérite, il ne se donne pas. Trois questions sur les voix que tu as trouvées. Prouve que tu écoutais. Échoue trois fois et ils disparaîtront — pour toujours.", correct: ["...Correct. Un coup de chance. Ne souris pas.", "...Encore correct. Donc tu écoutais VRAIMENT. Ça ne me plaît pas."], wrong: (left) => `Faux. Ce n'est pas eux. Il te reste ${left} ${left === 1 ? "erreur" : "erreurs"}.`, concede: "...Les trois. Je m'incline. Je déteste que tu gagnes. Prends-les — les fréquences sont à toi.", askName: "Dis-moi ton nom — pour qu'ils sachent qui les a tirés du silence.", thanksIntro: (n) => `${n}. Ils t'ont entendu. Écoute...`, fail: "Tu as échoué....Probablement... tu auras peut-être une chance à l'avenir...fais attention la prochaine fois, pour l'instant....ils sont partis, cet univers est peut-être parti.....", answerPlaceholder: "écris ta réponse et appuie sur Entrée...", namePlaceholder: "écris ton nom et appuie sur Entrée...", hostName: "VOTRE HÔTE", helpPlea: "Tu as trouvé chaque fragment, chacun d'entre nous. Auditeur... je dois te le demander. Il ne reste presque rien de nous. Vas-tu nous aider ?", helpPlaceholder: "réponds OUI ou NON et appuie sur Entrée...", noHelp: "...Non. Je comprends. Personne ne reste jamais. Alors je ne peux plus rien faire pour eux.", declineLine: "OPÉRATEUR : ...Tu as fait le bon choix. Certaines fréquences, mieux vaut les laisser s'éteindre. Au revoir, auditeur.", continueHint: "— appuie sur ENTRÉE pour continuer —", notFullName: "Ce n'est pas le nom complet de la station... je serai sympa... vérifie le JOURNAL.", }, }; function cf() { return CONFRONT[lang] || CONFRONT.es; } function consoleClear() { if (consoleLog) consoleLog.textContent = ""; } function normAnswer(s) { return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); } // True if the player typed a station's frequency or part of its name instead // of actually answering the question — they're flipping through the field // log instead of the full station name. function looksLikeStationReference(norm) { if (!norm) return false; if (/^\d+(\.\d+)?$/.test(norm)) return true; for (const meta of Object.values(stations)) { const name = normAnswer(stationDisplayName(meta) || ""); if (name && name.length >= 3 && (name.includes(norm) || norm.includes(name))) return true; } return false; } function pickQuizSet() { const rng = mulberry32((variant || 1) ^ 0x515a7); const pool = QUIZ_POOL.slice(); for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, 3); } // A character speaks inside the screen (where the stations speak); the player // answers in the bottom bar. The warden has a cold square voice; the host a // warm sine one. const OP_VOICE = { pitch_base: 92, pitch_step: 2, pitch_jitter: 7, waveform: "square" }; const HOST_SAY_VOICE = { pitch_base: 150, pitch_step: 5, pitch_jitter: 14, waveform: "sine" }; async function screenSay(text, { portrait, glow, name, voice }) { setAnnouncer(portrait); document.documentElement.style.setProperty("--station-glow", glow); if (stationName) stationName.textContent = name; rawText = ""; transmission.classList.remove("signing-off"); transmission.textContent = ""; transmission.classList.add("active"); voiceEnabled = true; for (const ch of text) { appendTransmissionChar(ch, voice); await sleep(20); } voiceEnabled = false; } function operatorSay(text) { return screenSay(text, { portrait: OPERATOR_PORTRAIT, glow: "#58ff8f", name: cf().operatorName, voice: OP_VOICE, }); } function hostSay(text) { return screenSay(text, { portrait: HOST_PORTRAIT, glow: "#7cdf8f", name: cf().hostName, voice: HOST_SAY_VOICE, }); } function showEndgameBar(show, placeholder) { if (!endgameBar) return; endgameBar.classList.toggle("hidden", !show); if (show && endgameInput) { endgameInput.value = ""; endgameInput.placeholder = placeholder || ""; setTimeout(() => endgameInput.focus(), 60); } } // After all five fragments: the host (the dog announcer) pleads first. Saying // yes hands you to the operator's test; saying no ends the world quietly. const YES_WORDS = ["yes", "y", "yeah", "yep", "sure", "si", "sí", "claro", "vale", "oui", "ouais", "help", "ayuda", "aider", "ok", "okay"]; const NO_WORDS = ["no", "n", "nope", "nah", "non", "never", "nunca", "jamais", "leave", "go"]; async function startHelpPrompt() { if (helpPromptActive || confrontActive || awaitingName || endgameLock) return; finaleStarted = true; // stops the fragment minigame + drift helpPromptActive = true; endgameLock = true; cancelTune(); stopMorse(); hideConsole(); updateMissionHud(false); setStaticLevel(0); await sleep(800); await hostSay(cf().helpPlea); showEndgameBar(true, cf().helpPlaceholder); } async function handleHelpAnswer(msg) { if (!helpPromptActive) return; const norm = normAnswer(msg); const said = (words) => words.some((w) => norm === w || norm.startsWith(w + " ") || norm === normAnswer(w)); if (said(NO_WORDS)) { helpPromptActive = false; showEndgameBar(false); await hostSay(cf().noHelp); await waitForContinue(); await runDeclineEnding(); return; } if (said(YES_WORDS)) { helpPromptActive = false; showEndgameBar(false); await startConfrontation(); return; } // unclear → ask again await hostSay(cf().helpPlea); showEndgameBar(true, cf().helpPlaceholder); } async function startConfrontation() { if (confrontActive || awaitingName) return; finaleStarted = true; // stops the fragment minigame + drift confrontActive = true; endgameLock = true; quizSet = pickQuizSet(); quizIndex = 0; quizStrikes = 0; cancelTune(); stopMorse(); hideConsole(); updateMissionHud(false); setStaticLevel(0); if (hint) hint.textContent = cf().operatorName; await operatorSay(cf().intro); await waitForContinue(); askQuizQuestion(); } async function askQuizQuestion() { const q = quizSet[quizIndex]; if (!q) return; await operatorSay(`(${quizIndex + 1}/3) ${q.q[lang] || q.q.es}`); showEndgameBar(true, cf().answerPlaceholder); } async function handleConfrontInput(msg) { const q = quizSet[quizIndex]; if (!q || !confrontActive) return; showEndgameBar(false); const norm = normAnswer(msg); const correct = q.a.some((ans) => norm.includes(normAnswer(ans))); if (correct) { playClick(); quizIndex += 1; if (quizIndex >= quizSet.length) { confrontActive = false; await winConfrontation(); } else { await operatorSay(cf().correct[Math.min(quizIndex - 1, cf().correct.length - 1)]); await waitForContinue(); askQuizQuestion(); } return; } if (looksLikeStationReference(norm)) { await operatorSay(cf().notFullName); await waitForContinue(); askQuizQuestion(); return; } quizStrikes += 1; staticBurst(0.3, 0.3); if (quizStrikes >= MAX_STRIKES) { confrontActive = false; await loseConfrontation(); } else { await operatorSay(cf().wrong(MAX_STRIKES - quizStrikes)); await waitForContinue(); askQuizQuestion(); } } async function winConfrontation() { confrontActive = false; await operatorSay(cf().concede); await waitForContinue(); awaitingName = true; await operatorSay(cf().askName); showEndgameBar(true, cf().namePlaceholder); } async function handleName(msg) { if (!awaitingName) return; awaitingName = false; showEndgameBar(false); const name = msg.replace(/[\[\]<>]/g, "").trim().slice(0, 24) || cf().defaultName; await runThankYous(name); } async function runThankYous(name) { hideConsole(); voiceEnabled = true; document.documentElement.style.setProperty("--station-glow", "#fff6d8"); if (stationName) stationName.textContent = cf().savedTitle; if (hint) hint.textContent = cf().thanksIntro(name); setStaticLevel(0); await sleep(1400); for (const canonKey of Object.keys(STATION_THANKS)) { const tmpl = STATION_THANKS[canonKey][lang] || STATION_THANKS[canonKey].es; const dispF = canonToDisplay.get(canonKey); const meta = dispF != null ? stationMeta(dispF) : null; const voice = meta?.voice || HOST_VOICE; setAnnouncer(meta?.portrait || HOST_PORTRAIT); document.documentElement.style.setProperty("--station-glow", meta?.theme?.glow || "#fff6d8"); if (stationName) stationName.textContent = meta ? stationDisplayName(meta) : cf().savedTitle; rawText = ""; transmission.textContent = ""; transmission.classList.add("active"); const text = tmpl(name) + " [FIN DE TRANSMISION]"; for (const ch of text) { appendTransmissionChar(ch, voice); await sleep(26); } await waitForContinue(); } // closing: the host returns to sign off setAnnouncer(HOST_PORTRAIT); document.documentElement.style.setProperty("--station-glow", "#7cdf8f"); if (stationName) stationName.textContent = cf().savedTitle; if (hint) hint.textContent = t().finaleEnd; transmission.classList.add("signing-off"); voiceEnabled = false; setTimeout(() => showFinaleScreen(), 3200); } async function loseConfrontation() { confrontActive = false; awaitingName = false; showEndgameBar(false); await operatorSay(cf().fail); await waitForContinue(); await runFailureEnding(); } // The shared "it is over" end-state: the speaker vanishes and the dial sweeps // helplessly from one end to the other. Used by a failed quiz AND by walking // away from the host's plea. async function runFailureEnding() { confrontActive = false; awaitingName = false; helpPromptActive = false; showEndgameBar(false); await sleep(900); setAnnouncer(null); // the speaker disappears voiceEnabled = false; if (stationName) stationName.textContent = cf().failTitle; if (hint) hint.textContent = cf().failTitle; failureSwing = true; // the dial sweeps from one end to the other failureSwingT = 0; staticBurst(0.5, 1.4); } // Walking away from the host's plea: the dial goes wild, then everything // fades to black for the operator's last word. async function runDeclineEnding() { await runFailureEnding(); await sleep(3000); showFinaleScreen(cf().declineLine); } // ===================================================================== // Init // ===================================================================== async function initRadio() { if (!dialSvg || !freqDisplay || !needle || !gaugeWrap) { console.error("radio.js: faltan elementos del dial en el DOM"); return; } loadNotes(); applyLangUI(); buildDial(); initAudio(); initNoise(); initGalaxy(); resizeScope(); await loadStations(); clearStaleProgress(); updateDialUI(); gaugeWrap.addEventListener("pointerdown", onPointerDown); gaugeWrap.addEventListener("pointermove", onPointerMove); gaugeWrap.addEventListener("pointerup", onPointerUp); gaugeWrap.addEventListener("pointercancel", onPointerUp); gaugeWrap.addEventListener("dblclick", onDoubleClick); tvScreen.addEventListener("wheel", onWheel, { passive: false }); if (tuneKnob) { tuneKnob.addEventListener("pointerdown", onKnobDown); tuneKnob.addEventListener("pointermove", onKnobMove); tuneKnob.addEventListener("pointerup", onKnobUp); tuneKnob.addEventListener("pointercancel", onKnobUp); tuneKnob.addEventListener("wheel", onKnobWheel, { passive: false }); } window.addEventListener("keydown", onKeyDown); window.addEventListener("resize", () => { resizeScope(); resizeGalaxy(); }); if (!REDUCED_MOTION) window.addEventListener("pointermove", onGalaxyPointer); // preload the announcer portraits so station swaps are instant for (const meta of Object.values(stations)) { if (meta.portrait) new Image().src = meta.portrait; } requestAnimationFrame(animate); // the host intro waits behind the boot screen: powerOn() starts it } initRadio(); document.addEventListener("click", resumeAudio, { once: true });