dsh-agent-voice 0.1.3 → 0.1.5

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,65 @@ 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);
157
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.
158
163
  React.useEffect(() => {
159
- if (!statusJson) return;
160
- let p = null;
161
- try { p = JSON.parse(statusJson); } catch { return; }
162
- if (!p || !p.dataUrl || !p.id) return;
163
- if (p.id === lastPlayed) return;
164
- setLastPlayed(p.id);
165
- const audio = new Audio(p.dataUrl);
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
+ // Audio is served via HTTP (settings stays small). audioPath is the
190
+ // absolute file path; the route is the public /dsh-agent-voice/audio/<name>.
191
+ let url = p.audioUrl;
192
+ if (!url && p.audioPath) {
193
+ const name = String(p.audioPath).split("/").pop();
194
+ url = "/dsh-agent-voice/audio/" + name;
195
+ }
196
+ const audio = new Audio(url);
166
197
  audio.volume = Math.max(0, Math.min(1, Number(p.volume ?? 1)));
167
198
  const kindLabel = p.kind === "attention" ? "🔔 Pozor: " : (p.kind === "task" ? "✅ " : "🔊 ");
168
199
  setToast(kindLabel + p.text);
169
200
  if (toastTimer.current) clearTimeout(toastTimer.current);
170
201
  toastTimer.current = setTimeout(() => setToast(null), 6000);
171
- audio.play().catch(() => {});
202
+ audio.play().catch((err) => {
203
+ // Autoplay still blocked: keep the toast and retry on next gesture.
204
+ pendingRef.current = p;
205
+ });
206
+ }
207
+
208
+ React.useEffect(() => {
209
+ if (!statusJson) return;
210
+ let p = null;
211
+ try { p = JSON.parse(statusJson); } catch { return; }
212
+ if (!p || (!p.dataUrl && !p.audioPath && !p.audioUrl) || !p.id) return;
213
+ if (p.id === lastPlayed) return;
214
+ setLastPlayed(p.id);
215
+ playAnnouncement(p);
172
216
  }, [statusJson, lastPlayed]);
173
217
 
174
218
  // Disabled = silent (config read from the same source the host uses).
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
 
@@ -96,6 +97,30 @@ async function postWorker(path, body) {
96
97
  }
97
98
 
98
99
  function apply(ctx) {
100
+ // Serve generated audio to the client over HTTP (keeps WAVs out of the
101
+ // settings document entirely).
102
+ const ws = ctx.get("webServer");
103
+ if (ws) {
104
+ ws.register({
105
+ kind: "prefix",
106
+ path: "/dsh-agent-voice/audio/",
107
+ handler: async (req, res) => {
108
+ const name = decodeURIComponent(req.url.split("/").pop() || "");
109
+ if (!/^av-\d+-[a-z0-9]+\.wav$/.test(name)) {
110
+ res.writeHead(404).end("not found");
111
+ return;
112
+ }
113
+ try {
114
+ const data = await readFile(join(AUDIO_DIR, name));
115
+ res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": data.length });
116
+ res.end(data);
117
+ } catch {
118
+ res.writeHead(404).end("not found");
119
+ }
120
+ },
121
+ });
122
+ }
123
+
99
124
  let source = () => ({ ...DEFAULTS });
100
125
  installSettingsSection(ctx, NS, SCHEMA, DEFAULTS, {
101
126
  setSource: (getCurrent) => { source = () => ({ ...DEFAULTS, ...(getCurrent() ?? {}) }); },
@@ -110,29 +135,49 @@ function apply(ctx) {
110
135
  response: z.string(),
111
136
  }), { base: {} });
112
137
 
138
+ // Audio files are served to the client over a webServer route instead of
139
+ // being base64-inlined into the settings document. A full WAV in settings
140
+ // would bloat the shared file by hundreds of KB and stall every settings
141
+ // round-trip (which is exactly what broke the UI before this fix).
113
142
  async function synthesize(text, voice, language) {
143
+ await ensureWorker();
114
144
  const res = await postWorker("/tts", { text, voice, language });
115
- const bytes = await readFile(res.audio);
116
- return "data:audio/wav;base64," + Buffer.from(bytes).toString("base64");
145
+ await mkdir(AUDIO_DIR, { recursive: true });
146
+ const name = `av-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.wav`;
147
+ const file = join(AUDIO_DIR, name);
148
+ await writeFile(file, await readFile(res.audio));
149
+ return file;
117
150
  }
118
151
 
119
152
  async function announce(kind, text) {
120
153
  const cfg = source();
121
154
  if (!cfg.enabled) return { ok: false, skipped: "disabled" };
122
- const dataUrl = await synthesize(text, cfg.voice, cfg.language);
155
+ const file = await synthesize(text, cfg.voice, cfg.language);
156
+ const id = String(Date.now()) + "-" + Math.random().toString(36).slice(2);
123
157
  const payload = {
124
- id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
158
+ id,
125
159
  kind,
126
160
  text,
127
161
  voice: cfg.voice,
128
162
  volume: cfg.volume,
129
- dataUrl,
163
+ audioPath: file, // the client fetches /dsh-agent-voice/audio/<name>
130
164
  at: Date.now(),
131
165
  };
132
166
  await ctx.settings.replace(STATUS_NS, { json: JSON.stringify(payload) });
167
+ cleanupOldAudio().catch(() => {});
133
168
  return { ok: true, kind, text };
134
169
  }
135
170
 
171
+ async function cleanupOldAudio() {
172
+ const { readdir, rm } = await import("node:fs/promises");
173
+ let files = [];
174
+ try { files = (await readdir(AUDIO_DIR)).filter((f) => f.endsWith(".wav")).sort(); } catch { return; }
175
+ while (files.length > 10) {
176
+ const old = files.shift();
177
+ try { await rm(join(AUDIO_DIR, old), { force: true }); } catch {}
178
+ }
179
+ }
180
+
136
181
  async function runAction(req) {
137
182
  const cfg = source();
138
183
  const a = req.action;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-voice",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -1,3 +1,4 @@
1
1
  [2026-08-31 22:08:19] agent-voice worker listening on http://127.0.0.1:7863
2
2
  [2026-08-31 22:08:23] error: 'PiperVoice' object has no attribute 'synthesize_stream_raw'
3
3
  [2026-08-31 22:08:46] agent-voice worker listening on http://127.0.0.1:7863
4
+ [2026-09-01 13:40:42] agent-voice worker listening on http://127.0.0.1:7863