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 +52 -8
- package/lib/index.js +50 -5
- package/package.json +1 -1
- package/worker/agent_voice_worker.log +1 -0
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
158
|
+
id,
|
|
125
159
|
kind,
|
|
126
160
|
text,
|
|
127
161
|
voice: cfg.voice,
|
|
128
162
|
volume: cfg.volume,
|
|
129
|
-
|
|
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
|
+
"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
|