dsh-agent-voice 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -154,21 +154,67 @@ window.__ModuleLoader__.load({
154
154
  const [lastPlayed, setLastPlayed] = React.useState(null);
155
155
  const [toast, setToast] = React.useState(null);
156
156
  const toastTimer = React.useRef(null);
157
+ const pendingRef = React.useRef(null);
158
+
159
+ // Browsers block audio.play() before any user gesture (autoplay
160
+ // policy). Unlock audio on the first click/keypress, then replay any
161
+ // announcement that was blocked. This makes Agent Voice audible even
162
+ // though the tab view never received the gesture.
163
+ React.useEffect(() => {
164
+ const unlock = () => {
165
+ // A silent short audio primes the audio pipeline; subsequent
166
+ // play() calls are then allowed by the browser.
167
+ try {
168
+ const silent = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=");
169
+ silent.volume = 0.0001;
170
+ silent.play().catch(() => {});
171
+ } catch {}
172
+ if (pendingRef.current) {
173
+ const p = pendingRef.current;
174
+ pendingRef.current = null;
175
+ playAnnouncement(p);
176
+ }
177
+ };
178
+ window.addEventListener("pointerdown", unlock, { once: true });
179
+ window.addEventListener("keydown", unlock, { once: true });
180
+ window.addEventListener("pointerup", unlock, { once: true });
181
+ return () => {
182
+ window.removeEventListener("pointerdown", unlock);
183
+ window.removeEventListener("keydown", unlock);
184
+ window.removeEventListener("pointerup", unlock);
185
+ };
186
+ }, []);
187
+
188
+ function playAnnouncement(p) {
189
+ // The WAV is fetched one-shot via the action round-trip (base64),
190
+ // then played. Settings never carries the audio bytes.
191
+ const doPlay = (dataUrl) => {
192
+ const audio = new Audio(dataUrl);
193
+ audio.volume = Math.max(0, Math.min(1, Number(p.volume ?? 1)));
194
+ const kindLabel = p.kind === "attention" ? "🔔 Pozor: " : (p.kind === "task" ? "✅ " : "🔊 ");
195
+ setToast(kindLabel + p.text);
196
+ if (toastTimer.current) clearTimeout(toastTimer.current);
197
+ toastTimer.current = setTimeout(() => setToast(null), 6000);
198
+ audio.play().catch(() => {
199
+ // Autoplay still blocked: keep the toast and retry on next gesture.
200
+ pendingRef.current = p;
201
+ });
202
+ };
203
+ if (p.dataUrl) { doPlay(p.dataUrl); return; }
204
+ // One-shot poll for the audio bytes by announcement id.
205
+ action({ action: "poll", id: p.id }).then((r) => {
206
+ if (r && r.ok && r.result && r.result.found && r.result.dataUrl) doPlay(r.result.dataUrl);
207
+ }).catch(() => {});
208
+ }
157
209
 
158
210
  React.useEffect(() => {
159
211
  if (!statusJson) return;
160
212
  let p = null;
161
213
  try { p = JSON.parse(statusJson); } catch { return; }
162
- if (!p || !p.dataUrl || !p.id) return;
214
+ if (!p || !p.id) return;
163
215
  if (p.id === lastPlayed) return;
164
216
  setLastPlayed(p.id);
165
- const audio = new Audio(p.dataUrl);
166
- audio.volume = Math.max(0, Math.min(1, Number(p.volume ?? 1)));
167
- const kindLabel = p.kind === "attention" ? "🔔 Pozor: " : (p.kind === "task" ? "✅ " : "🔊 ");
168
- setToast(kindLabel + p.text);
169
- if (toastTimer.current) clearTimeout(toastTimer.current);
170
- toastTimer.current = setTimeout(() => setToast(null), 6000);
171
- audio.play().catch(() => {});
217
+ playAnnouncement(p);
172
218
  }, [statusJson, lastPlayed]);
173
219
 
174
220
  // Disabled = silent (config read from the same source the host uses).
@@ -193,7 +239,7 @@ window.__ModuleLoader__.load({
193
239
  id: "agent-voice-player",
194
240
  order: 100,
195
241
  label: () => "🔊 Agent Voice přehrávač",
196
- inject: () => ({ config, status }),
242
+ inject: () => ({ action, config, status }),
197
243
  children: {},
198
244
  }, Player));
199
245
 
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@ const STATUS_NS = settingsNamespace("agent-voice-status");
24
24
  const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), ".dsh");
25
25
  const SHARED_VENV = join(DSH_HOME, "voice-studio-venv"); // reuse the installed XTTS/Piper venv
26
26
  const VENV_DIR = join(DSH_HOME, "agent-voice-venv");
27
+ const AUDIO_DIR = join(DSH_HOME, "agent-voice-outputs");
27
28
  const WORKER_URL = "http://127.0.0.1:7863";
28
29
  const PYTHON = process.env.PYTHON ?? "/opt/homebrew/opt/python@3.11/bin/python3.11";
29
30
 
@@ -47,6 +48,10 @@ const DEFAULTS = {
47
48
 
48
49
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
49
50
 
51
+ // One-shot audio cache keyed by announcement id: the client polls action=poll
52
+ // to fetch the WAV bytes (base64) for a given id, then the entry expires.
53
+ const audioCache = new Map();
54
+
50
55
  let workerProcess = null;
51
56
 
52
57
  async function ensureWorker() {
@@ -110,27 +115,32 @@ function apply(ctx) {
110
115
  response: z.string(),
111
116
  }), { base: {} });
112
117
 
118
+ // Audio bytes travel to the client via the ACTION round-trip (one-shot,
119
+ // never persisted): the client calls action=poll with an announcement id and
120
+ // the host returns the base64 WAV. The STATUS namespace carries only small
121
+ // metadata (id + text), so the shared settings document stays tiny.
113
122
  async function synthesize(text, voice, language) {
114
123
  await ensureWorker();
115
124
  const res = await postWorker("/tts", { text, voice, language });
116
- const bytes = await readFile(res.audio);
117
- return "data:audio/wav;base64," + Buffer.from(bytes).toString("base64");
125
+ return await readFile(res.audio);
118
126
  }
119
127
 
120
128
  async function announce(kind, text) {
121
129
  const cfg = source();
122
130
  if (!cfg.enabled) return { ok: false, skipped: "disabled" };
123
- const dataUrl = await synthesize(text, cfg.voice, cfg.language);
131
+ const wav = await synthesize(text, cfg.voice, cfg.language);
132
+ const id = String(Date.now()) + "-" + Math.random().toString(36).slice(2);
124
133
  const payload = {
125
- id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
134
+ id,
126
135
  kind,
127
136
  text,
128
137
  voice: cfg.voice,
129
138
  volume: cfg.volume,
130
- dataUrl,
131
139
  at: Date.now(),
132
140
  };
133
141
  await ctx.settings.replace(STATUS_NS, { json: JSON.stringify(payload) });
142
+ audioCache.set(id, wav);
143
+ setTimeout(() => audioCache.delete(id), 60000).unref?.();
134
144
  return { ok: true, kind, text };
135
145
  }
136
146
 
@@ -170,6 +180,14 @@ function apply(ctx) {
170
180
  const text = req.text || "Ahoj, tady agent. Test hlasu proběhl úspěšně.";
171
181
  return announce("test", text);
172
182
  }
183
+ if (a === "poll") {
184
+ // One-shot audio fetch: the client asks for the WAV of an announcement
185
+ // id and receives it as base64 (never persisted to settings).
186
+ if (!req.id) throw new Error("poll requires id");
187
+ const wav = audioCache.get(req.id);
188
+ if (!wav) return { id: req.id, found: false };
189
+ return { id: req.id, found: true, dataUrl: "data:audio/wav;base64," + Buffer.from(wav).toString("base64") };
190
+ }
173
191
  throw new Error("unknown action: " + a);
174
192
  }
175
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-voice",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "DeepSeek Harness plugin: gives the agent a voice — it reads completed tasks and attention requests aloud (never reasoning or interim reports) via local Piper / XTTS v2, with a settings panel and a curated set of voices.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",