[object Object]

← back to Voice Chat

auto-shutoff: 30s idle countdown with visible timer + resets on speech

326417dde28251c4e8f89bae097078d145f49f80 · 2026-05-11 08:31:00 -0700 · Steve Abrams

Files touched

Diff

commit 326417dde28251c4e8f89bae097078d145f49f80
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 08:31:00 2026 -0700

    auto-shutoff: 30s idle countdown with visible timer + resets on speech
---
 public/index.html    |  9 +++++++++
 public/voice-chat.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+)

diff --git a/public/index.html b/public/index.html
index 13af434..c319814 100644
--- a/public/index.html
+++ b/public/index.html
@@ -162,6 +162,14 @@
       font-variant-numeric: tabular-nums;
       letter-spacing: 0.4px;
     }
+    .countdown {
+      color: var(--muted);
+      font-size: 12px;
+      font-variant-numeric: tabular-nums;
+      letter-spacing: 0.4px;
+    }
+    .countdown.warn { color: #facc15; }
+    .countdown.crit { color: var(--accent); font-weight: 600; }
     .options {
       display: flex;
       gap: 8px;
@@ -205,6 +213,7 @@
     <button id="mic">START</button>
     <div class="meta">
       <div id="status" class="status">idle</div>
+      <div id="countdown" class="countdown"></div>
       <div id="latency" class="latency"></div>
       <div class="options">
         <select id="voicePicker" title="system voice"></select>
diff --git a/public/voice-chat.js b/public/voice-chat.js
index 2e68635..be982a0 100644
--- a/public/voice-chat.js
+++ b/public/voice-chat.js
@@ -21,8 +21,52 @@ let history = [];           // [{role, content}]
 let stopFlag = false;
 let pendingSpeechAt = null; // ms — set when user-final committed, used to compute mouth-to-mouth
 
+// ── auto-shutoff ────────────────────────────────────────────
+// Mic stops itself after IDLE_TIMEOUT_MS of inactivity.
+// Activity (user speech detected) resets the timer.
+const IDLE_TIMEOUT_MS = 30_000;
+let idleStartedAt = 0;
+let countdownTimer = null;
+
 function setStatus(t) { $status.textContent = t; }
 
+// ── idle countdown ───────────────────────────────────────────
+function startIdleCountdown() {
+  idleStartedAt = Date.now();
+  stopIdleCountdown();
+  countdownTimer = setInterval(() => {
+    const remaining = Math.max(0, IDLE_TIMEOUT_MS - (Date.now() - idleStartedAt));
+    const secs = Math.ceil(remaining / 1000);
+    renderCountdown(secs);
+    if (remaining <= 0) {
+      stopIdleCountdown();
+      stopFlag = true;
+      try { rec && rec.stop(); } catch {}
+      try { speechSynthesis.cancel(); } catch {}
+      setStatus("auto-stopped after 30s idle — tap START to resume");
+    }
+  }, 250);
+}
+function resetIdleCountdown() {
+  if (countdownTimer) idleStartedAt = Date.now();
+}
+function stopIdleCountdown() {
+  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
+  renderCountdown(null);
+}
+function renderCountdown(secs) {
+  const el = document.getElementById("countdown");
+  if (!el) return;
+  if (secs == null) {
+    el.textContent = "";
+    el.classList.remove("warn", "crit");
+  } else {
+    el.textContent = `auto-stop in ${secs}s`;
+    el.classList.toggle("warn", secs <= 10 && secs > 5);
+    el.classList.toggle("crit", secs <= 5);
+  }
+}
+
 function bubble(role, text, opts = {}) {
   // Clear empty-placeholder once we have content
   const empty = $transcript.querySelector(".empty");
@@ -112,11 +156,13 @@ function newRec() {
     $mic.classList.add("listening");
     $mic.textContent = "STOP";
     listening = true;
+    startIdleCountdown();
   };
   r.onend = () => {
     $mic.classList.remove("listening");
     $mic.textContent = "START";
     listening = false;
+    stopIdleCountdown();
     if (!stopFlag) {
       // Auto-restart on accidental end (Chrome cuts off ~60s)
       setTimeout(() => { if (!stopFlag) try { r.start(); } catch {} }, 100);
@@ -136,6 +182,7 @@ function newRec() {
       if (e.results[i].isFinal) final += piece;
       else interim += piece;
     }
+    if (interim || final) resetIdleCountdown();   // any speech activity defers shutoff
     if (interim) {
       if (!interimEl) interimEl = bubble("me", "", { interim: true });
       interimEl.textContent = interim;

← 6475eea pivot to free stack: browser STT+TTS + Mac1 ollama qwen3:8b  ·  back to Voice Chat  ·  broaden .gitignore with snapshot/build excludes aad10fa →