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 +5 -0
- package/lib/client.js +21 -19
- package/lib/index.js +53 -46
- package/package.json +4 -4
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 ||
|
|
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
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
4
|
-
"description": "DeepSeek Harness plugin: gives the agent a voice
|
|
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
|
|
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
|
+
}
|