ChatGPT Chime on Chat Completion (Tampermonkey Script)

Tampermonkey script

// ==UserScript==
// @name         ChatGPT Completion Ping (Composer FSM, background-safe, no-timeout)
// @namespace    nicholas.tools
// @version      5.4.0
// @description  Chime on completion even when window/tab isn't focused. No timeout; FSM: saw Stop → Stop gone + editor empty. Poll + resilient audio.
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /* =========================
   * Logging
   * ========================= */
  const DEBUG = true;
  const log = (...a) => DEBUG && console.log("[COMP-PING]", ...a);
  const t = () => new Date().toLocaleTimeString();

  /* =========================
   * Selectors (composer only)
   * ========================= */
  const COMPOSER_EDITABLE    = '#prompt-textarea.ProseMirror[contenteditable="true"]';
  const COMPOSER_FALLBACK_TA = 'textarea[name="prompt-textarea"]';
  const SEND_BTN             = '#composer-submit-button[data-testid="send-button"]';
  const STOP_BTN             = '#composer-submit-button[data-testid="stop-button"]';

  /* =========================
   * Audio: HTMLAudio primary (WAV data URL), WebAudio fallback
   * ========================= */
  function makeChimeWavDataURL() {
    const sr = 44100, dur = 0.99;
    const notes = [
      { f: 987.77, d: 0.22 }, { f: 1318.51, d: 0.22 },
      { f: 1174.66, d: 0.20 }, { f: 1318.51, d: 0.30 },
    ];
    const gap = 0.055, amp = 0.28;
    const N = Math.floor(sr * dur);
    const data = new Float32Array(N).fill(0);
    let t0 = 0;
    for (const { f, d } of notes) {
      const nSamp = Math.floor(d * sr);
      const start = Math.floor(t0 * sr);
      for (let i = 0; i < nSamp && start + i < N; i++) {
        const env = i < 0.01*sr ? i/(0.01*sr) : (i > nSamp-0.03*sr ? Math.max(0, (nSamp - i)/(0.03*sr)) : 1);
        const s = Math.sin(2*Math.PI*f*(i/sr));
        const s2 = Math.sin(2*Math.PI*(f*1.005)*(i/sr)) * 0.6;
        data[start+i] += amp * env * (0.7*s + 0.3*s2);
      }
      t0 += d + gap;
    }
    const pcm = new DataView(new ArrayBuffer(44 + N*2));
    let off = 0;
    const wStr = (s) => { for (let i=0;i<s.length;i++) pcm.setUint8(off++, s.charCodeAt(i)); };
    const w32  = (u) => { pcm.setUint32(off, u, true); off+=4; };
    const w16  = (u) => { pcm.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])); pcm.setInt16(off, v<0?v*0x8000:v*0x7FFF, true); off+=2; }
    const u8 = new Uint8Array(pcm.buffer);
    const b64 = btoa(String.fromCharCode(...u8));
    return `data:audio/wav;base64,${b64}`;
  }

  const CHIME_URL = makeChimeWavDataURL();
  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) {
    try {
      const a = primeAudioEl.cloneNode();
      a.volume = 1.0;
      await a.play();
      log(`🔊 DONE (HTMLAudio) ${reason} @ ${t()}`);
      return;
    } catch {}
    try {
      const c = ensureCtx();
      if (c.state !== "running") await c.resume();
      const t0 = c.currentTime + 0.02;
      const master = c.createGain(); master.gain.setValueAtTime(0.9, t0); master.connect(c.destination);
      const lp = c.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=4200; lp.Q.value=0.6; lp.connect(master);
      const delay = c.createDelay(0.5); delay.delayTime.value=0.18;
      const fb = c.createGain(); fb.gain.value=0.22; delay.connect(fb); fb.connect(delay); delay.connect(master);
      const bus = c.createGain(); bus.gain.value=0.85; bus.connect(lp); bus.connect(delay);

      const seq = [
        { f: 987.77, d: 0.22 }, { f: 1318.51, d: 0.22 },
        { f: 1174.66, d: 0.20 }, { f: 1318.51, d: 0.30 },
      ];
      let cur = t0, gap = 0.055;
      for (const {f,d} of seq) {
        const o1=c.createOscillator(), g1=c.createGain(); o1.type="triangle"; o1.frequency.value=f;
        g1.gain.setValueAtTime(0.0001,cur); g1.gain.exponentialRampToValueAtTime(0.6,cur+0.01); g1.gain.exponentialRampToValueAtTime(0.001,cur+d);
        o1.connect(g1); g1.connect(bus); o1.start(cur); o1.stop(cur+d+0.02);

        const o2=c.createOscillator(), g2=c.createGain(); o2.type="sine"; o2.frequency.setValueAtTime(f*1.005,cur);
        g2.gain.setValueAtTime(0.0001,cur); g2.gain.exponentialRampToValueAtTime(0.35,cur+0.012); g2.gain.exponentialRampToValueAtTime(0.001,cur+d);
        o2.connect(g2); g2.connect(bus); o2.start(cur); o2.stop(cur+d+0.02);

        cur += d + gap;
      }
      log(`🔊 DONE (WebAudio) ${reason} @ ${t()}`);
    } catch {}
  }

  // Prime on user interaction
  const unlock = async () => {
    try { await primeAudioEl.play(); primeAudioEl.pause(); primeAudioEl.currentTime = 0; } catch {}
    try { if (AudioCtx) { const c = ensureCtx(); if (c.state !== "running") await c.resume(); } } 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(); });

  /* =========================
   * Composer helpers
   * ========================= */
  const isEl = (n) => n && n.nodeType === 1;
  const visible = (sel) => { const el = document.querySelector(sel); return !!(el && el.offsetParent !== null); };
  const editorEl = () => document.querySelector(COMPOSER_EDITABLE) || document.querySelector(COMPOSER_FALLBACK_TA) || null;
  function editorEmpty() {
    const el = editorEl();
    if (!el) return true;
    if (el.matches('textarea')) return (el.value || '').replace(/\u200b/g,'').trim().length === 0;
    const txt = (el.textContent || '').replace(/\u200b/g,'').trim();
    return txt.length === 0;
  }
  const isStopVisible = () => visible(STOP_BTN);

  /* =========================
   * FSM + background-safe polling (NO TIMEOUT)
   * ========================= */
  let sid = 0;
  let s = null;
  let pollId = 0;
  const STATE = { IDLE:'IDLE', ARMED:'ARMED', CLEARED:'CLEARED', STREAMING:'STREAMING', DONE:'DONE' };

  function stopPoll() { if (pollId) { clearInterval(pollId); pollId = 0; } }

  function startPoll() {
    stopPoll();
    // steady 250ms poll; browsers may throttle in background which is fine
    pollId = window.setInterval(() => tick(true), 250);
  }

  function cancelSession(reason) {
    if (!s) return;
    log(`CANCEL s#${s.id} (${reason})`);
    stopPoll();
    s = null;
  }

  function arm(reason) {
    // Cancel any previous session (no timeout; avoid multiple active)
    if (s) cancelSession("re-ARM");
    s = {
      id: ++sid,
      state: STATE.ARMED,
      sawStop: false,
      sawCleared: editorEmpty(),
      lastStopGoneAt: 0
    };
    log(`ARM s#${s.id} (${reason}) empty=${s.sawCleared} stop=${isStopVisible()} @ ${t()}`);
    startPoll();
    tick();
  }

  function transition(newState, why) {
    if (!s || s.state === STATE.DONE) return;
    if (s.state !== newState) {
      s.state = newState;
      log(`${newState} s#${s.id} (${why}) empty=${editorEmpty()} stop=${isStopVisible()} @ ${t()}`);
    }
  }

  function evaluate() {
    if (!s || s.state === STATE.DONE) return;

    // Editor cleared after send
    if (!s.sawCleared && editorEmpty()) {
      s.sawCleared = true;
      transition(STATE.CLEARED, "editor cleared");
    }

    // Streaming seen
    if (!s.sawStop && isStopVisible()) {
      s.sawStop = true;
      transition(STATE.STREAMING, "stop visible");
    }

    // Stop disappears
    if (s.sawStop && !isStopVisible() && !s.lastStopGoneAt) {
      s.lastStopGoneAt = performance.now();
      log(`STOP-GONE s#${s.id} (detected)`);
    }

    // Completion: saw Stop once AND Stop gone AND editor empty (150ms stability)
    if (s.sawStop && !isStopVisible() && editorEmpty()) {
      const stable = s.lastStopGoneAt ? (performance.now() - s.lastStopGoneAt) : 999;
      if (stable >= 150) {
        transition(STATE.DONE, "stop gone + editor empty");
        playChime(`s#${s.id}`);
        stopPoll();
        s = null;
      }
    }
  }

  const tick = () => { evaluate(); };

  /* =========================
   * Events & Observers
   * ========================= */
  document.addEventListener("click", (e) => {
    const btn = isEl(e.target) ? e.target.closest(SEND_BTN) : null;
    if (!btn) return;
    arm("send-button click");
  }, true);

  document.addEventListener("keydown", (e) => {
    const ed = isEl(e.target) && (e.target.closest(COMPOSER_EDITABLE) || e.target.closest(COMPOSER_FALLBACK_TA));
    if (!ed) return;
    if (e.key !== "Enter" || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
    if (document.querySelector(SEND_BTN)) arm("keyboard Enter");
  }, true);

  const obs = new MutationObserver((mutations) => {
    if (!s) return;
    for (const m of mutations) {
      if (m.type === "attributes") {
        const el = m.target;
        if (isEl(el) && (el.id === "composer-submit-button" || el.matches(COMPOSER_EDITABLE) || el.matches(COMPOSER_FALLBACK_TA))) {
          tick();
        }
      }
      if (m.type === "childList") {
        for (const n of m.addedNodes) {
          if (!isEl(n)) continue;
          if (n.matches(STOP_BTN) || n.matches(SEND_BTN) ||
              n.querySelector?.(STOP_BTN) || n.querySelector?.(SEND_BTN) ||
              n.matches(COMPOSER_EDITABLE) || n.matches(COMPOSER_FALLBACK_TA) ||
              n.querySelector?.(COMPOSER_EDITABLE) || n.querySelector?.(COMPOSER_FALLBACK_TA)) {
            tick();
            break;
          }
        }
        for (const n of m.removedNodes) {
          if (!isEl(n)) continue;
          if (n.matches(STOP_BTN) || n.matches(SEND_BTN) ||
              n.matches(COMPOSER_EDITABLE) || n.matches(COMPOSER_FALLBACK_TA)) {
            tick();
            break;
          }
        }
      }
      if (m.type === "characterData") tick();
    }
  });

  function start() {
    obs.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["data-testid","id","class","style","contenteditable","value"],
      characterData: true
    });
    log("armed (composer FSM, background-safe, no-timeout). Completes on: saw Stop → Stop gone + editor empty.");
  }

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

Leave a Reply

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