dsh-agent-voice 0.1.4 → 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 +49 -5
- package/package.json +1 -1
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,30 +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) {
|
|
114
143
|
await ensureWorker();
|
|
115
144
|
const res = await postWorker("/tts", { text, voice, language });
|
|
116
|
-
|
|
117
|
-
|
|
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;
|
|
118
150
|
}
|
|
119
151
|
|
|
120
152
|
async function announce(kind, text) {
|
|
121
153
|
const cfg = source();
|
|
122
154
|
if (!cfg.enabled) return { ok: false, skipped: "disabled" };
|
|
123
|
-
const
|
|
155
|
+
const file = await synthesize(text, cfg.voice, cfg.language);
|
|
156
|
+
const id = String(Date.now()) + "-" + Math.random().toString(36).slice(2);
|
|
124
157
|
const payload = {
|
|
125
|
-
id
|
|
158
|
+
id,
|
|
126
159
|
kind,
|
|
127
160
|
text,
|
|
128
161
|
voice: cfg.voice,
|
|
129
162
|
volume: cfg.volume,
|
|
130
|
-
|
|
163
|
+
audioPath: file, // the client fetches /dsh-agent-voice/audio/<name>
|
|
131
164
|
at: Date.now(),
|
|
132
165
|
};
|
|
133
166
|
await ctx.settings.replace(STATUS_NS, { json: JSON.stringify(payload) });
|
|
167
|
+
cleanupOldAudio().catch(() => {});
|
|
134
168
|
return { ok: true, kind, text };
|
|
135
169
|
}
|
|
136
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
|
+
|
|
137
181
|
async function runAction(req) {
|
|
138
182
|
const cfg = source();
|
|
139
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",
|