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 +55 -9
- package/lib/index.js +23 -5
- package/package.json +1 -1
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.
|
|
214
|
+
if (!p || !p.id) return;
|
|
163
215
|
if (p.id === lastPlayed) return;
|
|
164
216
|
setLastPlayed(p.id);
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|