// ==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; }
};
})();