Chime on Replit Completion

// ==UserScript==
// @name         Replit Chat Completion Ping (Stop/Working FSM + Airy Harp Chime)
// @namespace    nicholas.tools
// @version      1.4.0
// @description  Chimes when Replit chat finishes. Detects streaming via Stop or "Working..". Uses Sound #4 (Airy harp up-gliss). Rich console logs + control panel (window.ReplitPing).
// @match        https://replit.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /* =========================
   * Config
   * ========================= */
  let DEBUG = true;          // high-level logs (state changes, detections)
  let TRACE_SCAN = false;    // very chatty: log detector scans (toggle at runtime)
  const STABILITY_MS_DEFAULT = 200; // require this much time of "no Stop & no Working" before DONE
  let STABILITY_MS = STABILITY_MS_DEFAULT;
  const POLL_MS = 250;

  // Matchers (case-insensitive)
  const STOP_TEXTS = ["stop"];        // exact match (normalized)
  const WORKING_TOKENS = ["working"]; // starts-with; allows Working., Working.., Working…

  /* =========================
   * Pretty Console Logging
   * ========================= */
  const tag = (lvl) => [
    "%cREPLIT-PING%c " + lvl + "%c",
    "background:#121212;color:#00e5ff;padding:1px 4px;border-radius:3px",
    "color:#999",
    "color:inherit"
  ];
  const cI = (...a) => DEBUG && console.log(...tag("ℹ️"), ...a);
  const cS = (...a) => DEBUG && console.log(...tag("✅"), ...a);
  const cW = (...a) => DEBUG && console.log(...tag("⚠️"), ...a);
  const cE = (...a) => DEBUG && console.log(...tag("⛔"), ...a);
  const cT = (...a) => (DEBUG && TRACE_SCAN) && console.log(...tag("🔎"), ...a);

  const nowStr = () => new Date().toLocaleTimeString();

  /* =========================
   * DOM / Text Helpers
   * ========================= */
  const isEl = (n) => n && n.nodeType === 1;
  const isVisible = (el) => {
    if (!isEl(el)) return false;
    const rect = el.getBoundingClientRect?.();
    if (!rect || rect.width === 0 || rect.height === 0) return false;
    const st = getComputedStyle(el);
    if (st.display === "none" || st.visibility === "hidden" || parseFloat(st.opacity || "1") < 0.05) return false;
    return true;
  };

  const norm = (s) => (s || "")
    .replace(/\u2026/g, "...") // ellipsis → three dots
    .replace(/[.\s]+$/g, "")   // trim trailing dots/spaces
    .trim()
    .toLowerCase();

  function findVisibleEquals(tokens) {
    const hits = [];
    const nodes = document.querySelectorAll("span,button,[role='button'],div");
    let scanned = 0;
    for (const el of nodes) {
      scanned++;
      if (!isVisible(el)) continue;
      const txt = norm(el.textContent || "");
      if (tokens.some(tok => txt === tok)) {
        hits.push(el.closest("button,[role='button']") || el);
      }
    }
    cT(`findVisibleEquals scanned=${scanned} hits=${hits.length} tokens=${tokens.join(",")}`);
    return Array.from(new Set(hits));
  }

  function findVisibleStartsWith(tokens) {
    const hits = [];
    const nodes = document.querySelectorAll("span,div,button,[role='button']");
    let scanned = 0;
    for (const el of nodes) {
      scanned++;
      if (!isVisible(el)) continue;
      const txt = norm(el.textContent || "");
      if (tokens.some(tok => txt.startsWith(tok))) {
        hits.push(el);
      }
    }
    cT(`findVisibleStartsWith scanned=${scanned} hits=${hits.length} tokens=${tokens.join(",")}`);
    return Array.from(new Set(hits));
  }

  const isStopVisible    = () => findVisibleEquals(STOP_TEXTS).length > 0;
  const isWorkingVisible = () => findVisibleStartsWith(WORKING_TOKENS).length > 0;
  const isStreamingNow   = () => isStopVisible() || isWorkingVisible();

  /* =========================
   * SOUND #4 — Airy Harp Up-Gliss (HTMLAudio WAV + WebAudio fallback)
   *  - Four soft “pluck” notes rising: C5 → E5 → G5 → C6
   *  - Triangle oscillators with fast attack & gentle decay
   *  - Subtle global low-pass to keep it airy
   * ========================= */
  function makeReplitChimeWavDataURL() {
    const sr = 44100;
    const N  = Math.floor(sr * 1.05); // ~1.05s buffer
    const data = new Float32Array(N);

    // Pluck events
    const freqs = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
    const starts = [0.00, 0.06, 0.12, 0.18];        // seconds
    const A = 0.008;  // Attack seconds
    const D = 0.22;   // Total pluck duration (~decay to near zero)
    const amp = 0.62; // per-note peak

    const tri = (x) => (2 / Math.PI) * Math.asin(Math.sin(x));

    for (let n = 0; n < freqs.length; n++) {
      const f = freqs[n];
      const startIdx = Math.floor(starts[n] * sr);
      const len = Math.floor(D * sr);
      for (let i = 0; i < len && (startIdx + i) < N; i++) {
        const t = i / sr;
        // Envelope: quick attack → gentle exponential decay
        let env;
        if (t < A) {
          env = t / A;
        } else {
          const tau = 0.10; // decay constant
          env = Math.exp(-(t - A) / tau);
        }
        // Triangle pluck
        const s = tri(2 * Math.PI * f * t);
        data[startIdx + i] += amp * env * s;
      }
    }

    // Simple global low-pass (1st-order IIR) for softness (~4.5 kHz)
    const fc = 4500;
    const alpha = (2 * Math.PI * fc) / (2 * Math.PI * fc + sr);
    let y = 0;
    for (let i = 0; i < N; i++) {
      const x = data[i];
      y = y + alpha * (x - y);
      data[i] = y;
    }

    // Gentle soft clip
    for (let i = 0; i < N; i++) {
      const v = Math.max(-1, Math.min(1, data[i]));
      data[i] = Math.tanh(1.05 * v);
    }

    // Pack to 16-bit PCM WAV (mono)
    const bytes = 44 + N * 2;
    const dv = new DataView(new ArrayBuffer(bytes));
    let off = 0;
    const wStr = (s) => { for (let i = 0; i < s.length; i++) dv.setUint8(off++, s.charCodeAt(i)); };
    const w32 = (u) => { dv.setUint32(off, u, true); off += 4; };
    const w16 = (u) => { dv.setUint16(off, u, true); off += 2; };

    wStr("RIFF"); w32(36 + N * 2); wStr("WAVE");
    wStr("fmt "); w32(16); w16(1); w16(1); w32(sr); w32(sr * 2); w16(2); w16(16);
    wStr("data"); w32(N * 2);
    for (let i = 0; i < N; i++) {
      const v = Math.max(-1, Math.min(1, data[i]));
      dv.setInt16(off, v < 0 ? v * 0x8000 : v * 0x7FFF, true);
      off += 2;
    }

    // Base64 encode
    const u8 = new Uint8Array(dv.buffer);
    let b64 = "";
    for (let i = 0; i < u8.length; i += 0x8000) {
      b64 += btoa(String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)));
    }
    return `data:audio/wav;base64,${b64}`;
  }

  const CHIME_URL = makeReplitChimeWavDataURL();
  const primeAudioEl = new Audio(CHIME_URL);
  primeAudioEl.preload = "auto";

  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  let ctx;
  const ensureCtx = () => (ctx ||= new AudioCtx());

  async function playChime(reason) {
    // Primary: HTMLAudio
    try {
      const a = primeAudioEl.cloneNode();
      a.volume = 1.0;
      await a.play();
      cS(`🔊 Chime (Airy Harp, HTMLAudio) reason=${reason} @ ${nowStr()}`);
      return;
    } catch (e1) {
      cW("HTMLAudio failed; trying WebAudio (Airy Harp)", e1);
    }

    // Fallback: WebAudio version of Sound #4 (Airy Harp)
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      const c = window.__rp_ctx || new AC();
      window.__rp_ctx = c;
      if (c.state !== "running") await c.resume();

      const t0 = c.currentTime + 0.02;

      // Output chain: low-pass for softness
      const out = c.createGain(); out.gain.setValueAtTime(0.85, t0);
      const lp  = c.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 4500; lp.Q.value = 0.6;
      out.connect(lp); lp.connect(c.destination);

      const pluck = (time, f) => {
        const o = c.createOscillator(); o.type = "triangle"; o.frequency.setValueAtTime(f, time);
        const g = c.createGain();
        // Envelope: fast attack, gentle decay
        g.gain.setValueAtTime(0.0001, time);
        g.gain.exponentialRampToValueAtTime(0.6,  time + 0.008);
        g.gain.exponentialRampToValueAtTime(0.001, time + 0.22);
        o.connect(g); g.connect(out);
        o.start(time); o.stop(time + 0.25);
      };

      const freqs = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
      let cur = t0;
      for (const f of freqs) { pluck(cur, f); cur += 0.06; }

      cS(`🔊 Chime (Airy Harp, WebAudio) reason=${reason} @ ${nowStr()}`);
    } catch (e2) {
      cE("WebAudio play failed", e2);
    }
  }

  // Unlock audio on first interaction/visibility
  const unlock = async () => {
    try { await primeAudioEl.play(); primeAudioEl.pause(); primeAudioEl.currentTime = 0; cI("Audio unlocked via HTMLAudio"); } catch {}
    try { if (AudioCtx) { const c = ensureCtx(); if (c.state !== "running") await c.resume(); cI("AudioContext resumed"); } } catch {}
    window.removeEventListener("pointerdown", unlock, true);
    window.removeEventListener("keydown", unlock, true);
  };
  window.addEventListener("pointerdown", unlock, true);
  window.addEventListener("keydown", unlock, true);
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") unlock(); });

  /* =========================
   * FSM (Stop/Working-driven)
   * ========================= */
  let sid = 0;
  let s = null;
  let pollId = 0;
  let lastStop = false;
  let lastWork = false;

  const STATE = { IDLE: "IDLE", STREAMING: "STREAMING", DONE: "DONE" };

  function startPoll() {
    if (pollId) return;
    pollId = window.setInterval(tick, POLL_MS);
  }
  function stopPoll() {
    if (pollId) { clearInterval(pollId); pollId = 0; }
  }

  function armStreaming(origin) {
    if (s && s.state !== STATE.DONE) return;
    s = { id: ++sid, state: STATE.STREAMING, lastStableStart: 0, sawStreaming: true };
    cI(`▶️ STREAMING s#${s.id} (origin=${origin}) @ ${nowStr()}`);
    startPoll();
  }

  function maybeComplete() {
    if (!s || s.state === STATE.DONE) return;
    const streaming = isStreamingNow();
    const now = performance.now();

    // Visibility transition logs
    const curStop = isStopVisible();
    const curWork = isWorkingVisible();
    if (curStop !== lastStop) {
      cI(`Stop visibility: ${curStop ? "ON" : "OFF"}`);
      lastStop = curStop;
    }
    if (curWork !== lastWork) {
      cI(`Working visibility: ${curWork ? "ON" : "OFF"}`);
      lastWork = curWork;
    }

    if (!streaming) {
      if (!s.lastStableStart) {
        s.lastStableStart = now;
        cI(`⏳ Stability window started (${STABILITY_MS}ms)`);
      }
      const elapsed = now - s.lastStableStart;
      if (elapsed >= STABILITY_MS) {
        s.state = STATE.DONE;
        cS(`DONE s#${s.id} (no Stop & no Working for ${Math.round(elapsed)}ms) @ ${nowStr()}`);
        playChime(`s#${s.id}`);
        stopPoll();
        s = null;
      }
    } else {
      if (s.lastStableStart) cW("Stability reset (streaming reappeared)");
      s.lastStableStart = 0;
    }
  }

  function tick() {
    const streaming = isStreamingNow();
    if (streaming && (!s || s.state === STATE.DONE)) {
      armStreaming("tick");
    }
    if (s && s.state === STATE.STREAMING) {
      maybeComplete();
    }
  }

  /* =========================
   * Observers & Event hooks
   * ========================= */
  const obs = new MutationObserver((mutations) => {
    let nudge = false;
    for (const m of mutations) {
      if (m.type === "childList") {
        for (const n of [...m.addedNodes, ...m.removedNodes]) {
          if (isEl(n)) {
            const txt = norm(n.textContent || "");
            if (STOP_TEXTS.some(s => txt.includes(s)) || WORKING_TOKENS.some(w => txt.startsWith(w))) { nudge = true; break; }
          }
        }
      } else if (m.type === "attributes") {
        const el = m.target;
        if (!isEl(el)) continue;
        const txt = norm(el.textContent || "");
        if (STOP_TEXTS.some(s => txt.includes(s)) || WORKING_TOKENS.some(w => txt.startsWith(w))) { nudge = true; }
      }
      if (nudge) break;
    }
    if (nudge) {
      cT("Mutation nudged tick()");
      tick();
    }
  });

  function start() {
    if (!document.body) {
      document.addEventListener("DOMContentLoaded", start, { once: true });
      return;
    }
    obs.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["class", "style", "aria-hidden"]
    });
    startPoll();
    cI("Armed (Stop/Working FSM). Will chime when both disappear (stable).");
    // Initial state snapshot
    lastStop = isStopVisible();
    lastWork = isWorkingVisible();
    cI(`Initial: Stop=${lastStop} Working=${lastWork}`);
    if (lastStop || lastWork) armStreaming("initial");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", start, { once: true });
  } else {
    start();
  }

  /* =========================
   * Runtime Controls (Console)
   * =========================
   * window.ReplitPing.setDebug(true|false)
   * window.ReplitPing.setTrace(true|false)
   * window.ReplitPing.setStability(ms)
   * window.ReplitPing.status()
   * window.ReplitPing.test()
   */
  window.ReplitPing = {
    setDebug(v){ DEBUG = !!v; cI(`DEBUG=${DEBUG}`); return DEBUG; },
    setTrace(v){ TRACE_SCAN = !!v; cI(`TRACE_SCAN=${TRACE_SCAN}`); return TRACE_SCAN; },
    setStability(ms){ STABILITY_MS = Math.max(0, Number(ms)||STABILITY_MS_DEFAULT); cI(`STABILITY_MS=${STABILITY_MS}`); return STABILITY_MS; },
    status(){
      const streaming = isStreamingNow();
      const state = s ? s.state : STATE.IDLE;
      const info = {
        state,
        sessionId: s?.id ?? null,
        stopVisible: isStopVisible(),
        workingVisible: isWorkingVisible(),
        streaming,
        stabilityMs: STABILITY_MS,
        pollActive: !!pollId,
        time: nowStr(),
      };
      cI("Status", info);
      return info;
    },
    async test(){ await playChime("manual-test"); return true; }
  };

})();

Leave a Reply

Your email address will not be published. Required fields are marked *