From 8cd76ffe4687887432f2d31c80154c17c2e6ab82 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 10:05:30 -0700 Subject: [PATCH] Duck Spotify volume while speaking instead of talking over it The extension already paused YouTube while VoxClaw spoke, but Spotify web playback was left at full volume, so speech competed with music. Fade playing open.spotify.com tabs down to 20% for the duration of the speech and restore each tab's original volume afterward. Restore is held 1.5s because /status reports reading:false during the speech queue's 1s inter-item gap; without the hold, volume yo-yos between queued utterances. Volume is also restored if the listener disappears or polling is turned off, so tabs are never left stuck quiet. Verified on Spotify's real DOM: the player is a media element in the main document (readyState 4), so querySelectorAll reaches it without shadow-DOM traversal. Co-Authored-By: Claude Opus 5 --- BrowserExtension/manifest.json | 7 +- BrowserExtension/popup.html | 2 +- BrowserExtension/service_worker.js | 121 ++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/BrowserExtension/manifest.json b/BrowserExtension/manifest.json index 56f3086..26679e4 100644 --- a/BrowserExtension/manifest.json +++ b/BrowserExtension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "VoxClaw", - "description": "Pauses YouTube when VoxClaw speaks, resumes when it finishes.", - "version": "1.0.0", + "description": "Pauses YouTube and ducks Spotify when VoxClaw speaks, restores when it finishes.", + "version": "1.1.0", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", @@ -19,7 +19,8 @@ "https://*.youtube.com/*", "https://youtube.com/*", "https://youtu.be/*", - "https://*.youtube-nocookie.com/*" + "https://*.youtube-nocookie.com/*", + "https://open.spotify.com/*" ], "action": { "default_title": "VoxClaw", diff --git a/BrowserExtension/popup.html b/BrowserExtension/popup.html index 6107fcc..0ac0f2c 100644 --- a/BrowserExtension/popup.html +++ b/BrowserExtension/popup.html @@ -31,7 +31,7 @@

VoxClaw

Checking...
diff --git a/BrowserExtension/service_worker.js b/BrowserExtension/service_worker.js index e0d1997..fc3c1eb 100644 --- a/BrowserExtension/service_worker.js +++ b/BrowserExtension/service_worker.js @@ -1,8 +1,19 @@ const POLL_INTERVAL_MS = 1000; const STATUS_URL = "http://localhost:4140/status"; +// Spotify ducking: lower playing tabs to this volume while speaking, with +// short fades so it reads as ducking rather than a glitch. Restore is held +// briefly because /status flips to not-reading during the queue's 1s +// inter-item gap — without the hold, volume yo-yos between queued utterances. +const DUCK_VOLUME = 0.2; +const FADE_DOWN_MS = 300; +const FADE_UP_MS = 500; +const RESTORE_HOLD_MS = 1500; + let wasReading = false; let pausedTabs = []; +let duckedTabs = []; +let restoreTimer = null; let pollTimer = null; function log(...args) { @@ -27,10 +38,21 @@ async function pollStatus() { if (isReading && !wasReading) { pausedTabs = await pauseYouTube(); log(`Speaking started, paused ${pausedTabs.length} tab(s)`); - } else if (!isReading && wasReading && pausedTabs.length > 0) { - await resumeYouTube(pausedTabs); - log(`Speaking finished, resumed ${pausedTabs.length} tab(s)`); - pausedTabs = []; + if (restoreTimer) { + // Speech resumed within the hold window — stay ducked. + clearTimeout(restoreTimer); + restoreTimer = null; + } else if (duckedTabs.length === 0) { + duckedTabs = await duckSpotify(); + log(`Ducked ${duckedTabs.length} Spotify tab(s)`); + } + } else if (!isReading && wasReading) { + if (pausedTabs.length > 0) { + await resumeYouTube(pausedTabs); + log(`Speaking finished, resumed ${pausedTabs.length} tab(s)`); + pausedTabs = []; + } + scheduleSpotifyRestore(); } wasReading = isReading; @@ -42,6 +64,88 @@ async function pollStatus() { pausedTabs = []; } } + // Listener gone — restore immediately rather than waiting out the hold. + if (restoreTimer) { + clearTimeout(restoreTimer); + restoreTimer = null; + } + if (duckedTabs.length > 0) { + const tabs = duckedTabs; + duckedTabs = []; + await restoreSpotify(tabs); + } + } +} + +function scheduleSpotifyRestore() { + if (restoreTimer || duckedTabs.length === 0) return; + restoreTimer = setTimeout(async () => { + restoreTimer = null; + const tabs = duckedTabs; + duckedTabs = []; + await restoreSpotify(tabs); + log(`Restored volume on ${tabs.length} Spotify tab(s)`); + }, RESTORE_HOLD_MS); +} + +async function duckSpotify() { + const tabs = await chrome.tabs.query({ url: ["https://open.spotify.com/*"] }); + + const ducked = []; + for (const tab of tabs) { + if (!tab.id) continue; + try { + const [{ result } = {}] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (duckVolume, fadeMs) => { + const media = [...document.querySelectorAll("video, audio")].find( + el => !el.paused && !el.ended && el.readyState >= 2 && el.volume > duckVolume + ); + if (!media) return null; + const from = media.volume; + const start = performance.now(); + const step = now => { + const t = Math.min((now - start) / fadeMs, 1); + media.volume = from + (duckVolume - from) * t; + if (t < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + return from; + }, + args: [DUCK_VOLUME, FADE_DOWN_MS] + }); + if (result !== null && result !== undefined) { + ducked.push({ tabId: tab.id, volume: result }); + } + } catch {} + } + return ducked; +} + +async function restoreSpotify(tabs) { + for (const { tabId, volume } of tabs) { + try { + const tab = await chrome.tabs.get(tabId); + if (!tab?.url?.startsWith("https://open.spotify.com/")) continue; + await chrome.scripting.executeScript({ + target: { tabId }, + func: (targetVolume, fadeMs) => { + // Restore even if playback was paused mid-speech, so the next + // play resumes at the original volume. + const media = document.querySelector("video, audio"); + if (!media || media.volume >= targetVolume) return; + const from = media.volume; + const start = performance.now(); + const step = now => { + const t = Math.min((now - start) / fadeMs, 1); + media.volume = from + (targetVolume - from) * t; + if (t < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, + args: [volume, FADE_UP_MS] + }); + } catch {} } } @@ -108,6 +212,15 @@ function stopPolling() { clearInterval(pollTimer); pollTimer = null; } + if (restoreTimer) { + clearTimeout(restoreTimer); + restoreTimer = null; + } + if (duckedTabs.length > 0) { + const tabs = duckedTabs; + duckedTabs = []; + void restoreSpotify(tabs); + } log("Polling stopped"); }