dsh-agent-voice 0.1.5 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.1.7
2
+
3
+ - System-level playback: announcement WAV is played through macOS afplay (independent of browser autoplay policy), so the voice is heard on every tab and in the background.
4
+ - macOS notification (osascript) on attention requests: visible alert + sound when the agent needs the user at the keyboard.
5
+
1
6
  # Changelog
2
7
 
3
8
  ## 0.1.0
package/lib/client.js CHANGED
@@ -186,30 +186,32 @@ window.__ModuleLoader__.load({
186
186
  }, []);
187
187
 
188
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);
197
- audio.volume = Math.max(0, Math.min(1, Number(p.volume ?? 1)));
198
- const kindLabel = p.kind === "attention" ? "🔔 Pozor: " : (p.kind === "task" ? "✅ " : "🔊 ");
199
- setToast(kindLabel + p.text);
200
- if (toastTimer.current) clearTimeout(toastTimer.current);
201
- toastTimer.current = setTimeout(() => setToast(null), 6000);
202
- audio.play().catch((err) => {
203
- // Autoplay still blocked: keep the toast and retry on next gesture.
204
- pendingRef.current = p;
205
- });
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(() => {});
206
208
  }
207
209
 
208
210
  React.useEffect(() => {
209
211
  if (!statusJson) return;
210
212
  let p = null;
211
213
  try { p = JSON.parse(statusJson); } catch { return; }
212
- if (!p || (!p.dataUrl && !p.audioPath && !p.audioUrl) || !p.id) return;
214
+ if (!p || !p.id) return;
213
215
  if (p.id === lastPlayed) return;
214
216
  setLastPlayed(p.id);
215
217
  playAnnouncement(p);
@@ -237,7 +239,7 @@ window.__ModuleLoader__.load({
237
239
  id: "agent-voice-player",
238
240
  order: 100,
239
241
  label: () => "🔊 Agent Voice přehrávač",
240
- inject: () => ({ config, status }),
242
+ inject: () => ({ action, config, status }),
241
243
  children: {},
242
244
  }, Player));
243
245
 
package/lib/index.js CHANGED
@@ -48,6 +48,10 @@ const DEFAULTS = {
48
48
 
49
49
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
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
+
51
55
  let workerProcess = null;
52
56
 
53
57
  async function ensureWorker() {
@@ -97,30 +101,6 @@ async function postWorker(path, body) {
97
101
  }
98
102
 
99
103
  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
-
124
104
  let source = () => ({ ...DEFAULTS });
125
105
  installSettingsSection(ctx, NS, SCHEMA, DEFAULTS, {
126
106
  setSource: (getCurrent) => { source = () => ({ ...DEFAULTS, ...(getCurrent() ?? {}) }); },
@@ -135,24 +115,48 @@ function apply(ctx) {
135
115
  response: z.string(),
136
116
  }), { base: {} });
137
117
 
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).
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.
142
122
  async function synthesize(text, voice, language) {
143
123
  await ensureWorker();
144
124
  const res = await postWorker("/tts", { text, voice, language });
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;
125
+ return await readFile(res.audio);
126
+ }
127
+
128
+ // System-level playback: browsers block audio.play() until a user gesture,
129
+ // so the in-page overlay player alone is unreliable. Playing the WAV through
130
+ // macOS afplay guarantees the announcement is heard on every tab (and even
131
+ // when the browser is in the background). Fire-and-forget.
132
+ async function systemPlay(wav) {
133
+ try {
134
+ const tmp = join(AUDIO_DIR, "tmp-" + String(Date.now()) + "-" + Math.random().toString(36).slice(2) + ".wav");
135
+ await writeFile(tmp, wav);
136
+ const af = spawn("/usr/bin/afplay", [tmp], { detached: true, stdio: "ignore" });
137
+ af.unref?.();
138
+ setTimeout(() => {
139
+ try { spawn("/bin/rm", ["-f", tmp]); } catch {}
140
+ }, 30000).unref?.();
141
+ } catch {}
142
+ }
143
+
144
+ // macOS notification (attention only): visible alert + sound even when the
145
+ // browser window is not focused — the "I need you at the keyboard" signal.
146
+ function systemNotify(title, text) {
147
+ try {
148
+ const scr = [
149
+ 'display notification ' + JSON.stringify(String(text).slice(0, 120)) + ' with title ' + JSON.stringify(title),
150
+ ].join("\n");
151
+ const osa = spawn("/usr/bin/osascript", ["-e", scr], { detached: true, stdio: "ignore" });
152
+ osa.unref?.();
153
+ } catch {}
150
154
  }
151
155
 
152
156
  async function announce(kind, text) {
153
157
  const cfg = source();
154
158
  if (!cfg.enabled) return { ok: false, skipped: "disabled" };
155
- const file = await synthesize(text, cfg.voice, cfg.language);
159
+ const wav = await synthesize(text, cfg.voice, cfg.language);
156
160
  const id = String(Date.now()) + "-" + Math.random().toString(36).slice(2);
157
161
  const payload = {
158
162
  id,
@@ -160,22 +164,17 @@ function apply(ctx) {
160
164
  text,
161
165
  voice: cfg.voice,
162
166
  volume: cfg.volume,
163
- audioPath: file, // the client fetches /dsh-agent-voice/audio/<name>
164
167
  at: Date.now(),
165
168
  };
166
169
  await ctx.settings.replace(STATUS_NS, { json: JSON.stringify(payload) });
167
- cleanupOldAudio().catch(() => {});
168
- return { ok: true, kind, text };
169
- }
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 {}
170
+ audioCache.set(id, wav);
171
+ setTimeout(() => audioCache.delete(id), 60000).unref?.();
172
+ // Always play system-wide (independent of browser autoplay policy).
173
+ systemPlay(wav);
174
+ if (kind === "attention") {
175
+ systemNotify("🔔 DSH potřebuji tě", text);
178
176
  }
177
+ return { ok: true, kind, text };
179
178
  }
180
179
 
181
180
  async function runAction(req) {
@@ -214,6 +213,14 @@ function apply(ctx) {
214
213
  const text = req.text || "Ahoj, tady agent. Test hlasu proběhl úspěšně.";
215
214
  return announce("test", text);
216
215
  }
216
+ if (a === "poll") {
217
+ // One-shot audio fetch: the client asks for the WAV of an announcement
218
+ // id and receives it as base64 (never persisted to settings).
219
+ if (!req.id) throw new Error("poll requires id");
220
+ const wav = audioCache.get(req.id);
221
+ if (!wav) return { id: req.id, found: false };
222
+ return { id: req.id, found: true, dataUrl: "data:audio/wav;base64," + Buffer.from(wav).toString("base64") };
223
+ }
217
224
  throw new Error("unknown action: " + a);
218
225
  }
219
226
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-voice",
3
- "version": "0.1.5",
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.",
3
+ "version": "0.1.7",
4
+ "description": "DeepSeek Harness plugin: gives the agent a voice \u2014 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",
7
7
  "files": [
@@ -17,7 +17,7 @@
17
17
  "node": ">=22"
18
18
  },
19
19
  "license": "MIT",
20
- "author": "Michal Ožuch",
20
+ "author": "Michal O\u017euch",
21
21
  "keywords": [
22
22
  "deepseek-harness",
23
23
  "dsh",
@@ -57,4 +57,4 @@
57
57
  "@deepseek-ai/dsh-settings": "^0.1.1-rc.2"
58
58
  },
59
59
  "dependencies": {}
60
- }
60
+ }