dsh-agent-voice 0.1.0
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 +8 -0
- package/LICENSE +21 -0
- package/README.cs.md +43 -0
- package/README.md +43 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +156 -0
- package/lib/index.js +251 -0
- package/package.json +60 -0
- package/worker/__pycache__/agent_voice_worker.cpython-314.pyc +0 -0
- package/worker/agent_voice_worker.log +3 -0
- package/worker/agent_voice_worker.py +173 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
- Initial release: `dsh_agent_voice` tool (speak / attention / test / status / voices).
|
|
5
|
+
- Local TTS worker (Piper + XTTS v2) on 127.0.0.1:7863.
|
|
6
|
+
- Automatic announcements on subagent/workflow completion and approval requests.
|
|
7
|
+
- Main-interface settings panel with voice picker, language, volume, and toggles.
|
|
8
|
+
- Curated Piper voices: jirka, kasandra, lessac, ryan (+ XTTS v2).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michal Ožuch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.cs.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# dsh-agent-voice
|
|
2
|
+
|
|
3
|
+
Dejte svému agentovi v DeepSeek Harness hlas. Agent čte nahlas, když **dokončí úkol** a když **potřebuje vaši pozornost** — nikdy nečte své úvahy, průběh ani mezivýsledky.
|
|
4
|
+
|
|
5
|
+
- Lokální, offline převod řeči (Piper hlasy + XTTS v2), žádné cloud API.
|
|
6
|
+
- Kurátorovaná sada hlasů (česky + anglicky) z [rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices).
|
|
7
|
+
- Pěkné nastavení přímo v hlavním rozhraní s živým náhledem.
|
|
8
|
+
- Automatické hlášení, když skončí subagent / workflow, a když agent žádá o rozhodnutí.
|
|
9
|
+
|
|
10
|
+
**🇬🇧 [English guide →](README.md)**
|
|
11
|
+
|
|
12
|
+
## Instalace
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
dsh plugin add dsh-agent-voice
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Restartujte DSH a otevřete záložku **🔊 Agent Voice** v hlavním rozhraní.
|
|
19
|
+
|
|
20
|
+
## Jak to funguje
|
|
21
|
+
|
|
22
|
+
| Akce | Kdy | Příklad |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `dsh_agent_voice action='speak' text='…'` | Úkol dokončen | *"Úkol dokončen."* |
|
|
25
|
+
| `dsh_agent_voice action='attention'` | Agent vás potřebuje | *"Potřebuji tě u počítače."* |
|
|
26
|
+
|
|
27
|
+
Agent má pokyn **nikdy** nečíst nahlas své úvahy ani průběžné kroky a držet hlasové zprávy na jednu větu.
|
|
28
|
+
|
|
29
|
+
## Hlasy
|
|
30
|
+
|
|
31
|
+
| id | Hlas | Jazyk |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `jirka` | Jirka (medium) | cs |
|
|
34
|
+
| `kasandra` | Kasandra (medium) | cs |
|
|
35
|
+
| `lessac` | Lessac (medium) | en |
|
|
36
|
+
| `ryan` | Ryan (high) | en |
|
|
37
|
+
| `xtts` | XTTS v2 (multilingual) | cs, en |
|
|
38
|
+
|
|
39
|
+
Hlasy se stahují z HuggingFace při prvním použití (Piper `.onnx` modely) do `~/.local/share/piper/`.
|
|
40
|
+
|
|
41
|
+
## Licence
|
|
42
|
+
|
|
43
|
+
MIT. Piper hlasy mají licenci MIT (rhasspy/piper-voices). XTTS v2 podléhá nekomerční licenci CPML od Coqui; pro souhlas nastavte `COQUI_TOS_AGREED=1` (plugin to dělá automaticky).
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# dsh-agent-voice
|
|
2
|
+
|
|
3
|
+
Give your DeepSeek Harness agent a voice. The agent speaks aloud when it **finishes a task** and when it **needs your attention** — never its reasoning, progress, or interim reports.
|
|
4
|
+
|
|
5
|
+
- Local, offline TTS (Piper voices + XTTS v2), no cloud API required.
|
|
6
|
+
- A curated set of voices (Czech + English) from [rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices).
|
|
7
|
+
- Nice settings panel in the main interface, with live preview.
|
|
8
|
+
- Automatic announcements when subagents / workflows settle, and when the agent asks for a decision.
|
|
9
|
+
|
|
10
|
+
**🇨🇿 [Český návod →](README.cs.md)**
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
dsh plugin add dsh-agent-voice
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Restart DSH, then open the **🔊 Agent Voice** tab in the main interface.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
| Action | When | Example |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `dsh_agent_voice action='speak' text='…'` | A task completed | *"Úkol dokončen."* |
|
|
25
|
+
| `dsh_agent_voice action='attention'` | The agent needs you | *"Potřebuji tě u počítače."* |
|
|
26
|
+
|
|
27
|
+
The agent is instructed to **never** read its reasoning or intermediate steps aloud, and to keep spoken messages to one sentence.
|
|
28
|
+
|
|
29
|
+
## Voices
|
|
30
|
+
|
|
31
|
+
| id | Voice | Language |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `jirka` | Jirka (medium) | cs |
|
|
34
|
+
| `kasandra` | Kasandra (medium) | cs |
|
|
35
|
+
| `lessac` | Lessac (medium) | en |
|
|
36
|
+
| `ryan` | Ryan (high) | en |
|
|
37
|
+
| `xtts` | XTTS v2 (multilingual) | cs, en |
|
|
38
|
+
|
|
39
|
+
Voices are downloaded from HuggingFace on first use (Piper `.onnx` models) into `~/.local/share/piper/`.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT. Piper voice models are MIT-licensed (rhasspy/piper-voices). XTTS v2 is subject to Coqui's non-commercial CPML license; set `COQUI_TOS_AGREED=1` to agree (the plugin does this automatically).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# dsh-agent-voice bundle patch: register the host row that gives the agent a
|
|
2
|
+
# voice — a `dsh_agent_voice` tool, a lightweight local TTS worker (Piper +
|
|
3
|
+
# XTTS v2), and the Agent Voice settings + live audio playback.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: agent-voice
|
|
6
|
+
name: 'dsh-agent-voice'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-agent-voice",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
const React = require("react");
|
|
7
|
+
|
|
8
|
+
const inject = ["slots", "settingsScope"];
|
|
9
|
+
|
|
10
|
+
const CSS = ".av-root{display:flex;flex-direction:column;gap:14px;padding:16px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}.av-title{font-size:22px;font-weight:700;margin:0;background:linear-gradient(90deg,#ff7ab6,#7c5cff);-webkit-background-clip:text;background-clip:text;color:transparent}.av-sub{color:#9aa0b4;font-size:12px;margin:0}.av-set{border:1px solid #242838;border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:12px}.av-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.av-tag{font-size:11px;color:#ff8fc0;font-weight:700}.av-input{border:1px solid #2a2e3f;background:#151827;color:inherit;border-radius:9px;padding:7px 11px;font:inherit}.av-btn{font:inherit;font-size:12px;font-weight:600;border:1px solid #2a2e3f;background:#1b1f30;color:inherit;border-radius:9px;padding:7px 12px;cursor:pointer}.av-btn:hover{border-color:#ff7ab6}.av-primary{background:linear-gradient(135deg,#ff6aa8,#7c5cff);border:none;color:#fff}.av-switch{position:relative;width:40px;height:22px;background:#2a2e3f;border-radius:22px;cursor:pointer;border:1px solid #3a3f55;transition:.2s}.av-switch.on{background:linear-gradient(135deg,#ff6aa8,#7c5cff)}.av-knob{position:absolute;top:1px;left:1px;width:18px;height:18px;background:#fff;border-radius:50%;transition:.2s}.av-switch.on .av-knob{left:19px}.av-badge{font-size:11px;padding:2px 8px;border-radius:20px;background:#1b1f30;border:1px solid #2a2e3f;color:#9aa0b4}";
|
|
11
|
+
|
|
12
|
+
function useAction(scope) {
|
|
13
|
+
return async function call(args) {
|
|
14
|
+
const id = String(Date.now()) + "-" + Math.random().toString(36).slice(2);
|
|
15
|
+
await scope.set("request", JSON.stringify(args));
|
|
16
|
+
await scope.set("requestId", id);
|
|
17
|
+
for (let i = 0; i < 200; i++) {
|
|
18
|
+
const v = scope.getSnapshot().value;
|
|
19
|
+
if (v && v.responseId === id) {
|
|
20
|
+
try { return JSON.parse(v.response); } catch { return { ok: false, error: "bad response" }; }
|
|
21
|
+
}
|
|
22
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
23
|
+
}
|
|
24
|
+
return { ok: false, error: "timeout" };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function useSnapshot(scope) {
|
|
28
|
+
return React.useSyncExternalStore((cb) => scope.subscribe(cb), () => scope.getSnapshot());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FALLBACK_VOICES = [
|
|
32
|
+
{ id: "jirka", name: "Jirka (česky, muž)", language: "cs", available: true },
|
|
33
|
+
{ id: "kasandra", name: "Kasandra (česky, žena)", language: "cs", available: true },
|
|
34
|
+
{ id: "lessac", name: "Lessac (anglicky, žena)", language: "en", available: true },
|
|
35
|
+
{ id: "ryan", name: "Ryan (anglicky, muž)", language: "en", available: true },
|
|
36
|
+
{ id: "xtts", name: "XTTS v2 (multilingual)", language: "cs,en", available: true },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function AgentVoice(props) {
|
|
40
|
+
const [voices, setVoices] = React.useState(FALLBACK_VOICES);
|
|
41
|
+
const [status, setStatus] = React.useState("");
|
|
42
|
+
const [lastPlayed, setLastPlayed] = React.useState(null);
|
|
43
|
+
|
|
44
|
+
const action = React.useMemo(() => useAction(props.action), [props.action]);
|
|
45
|
+
const cfgSnap = useSnapshot(props.config);
|
|
46
|
+
const cfg = (cfgSnap && cfgSnap.value) || {};
|
|
47
|
+
const enabled = cfg.enabled !== false;
|
|
48
|
+
const voice = cfg.voice || "jirka";
|
|
49
|
+
const language = cfg.language || "cs";
|
|
50
|
+
const announceTasks = cfg.announceTasks !== false;
|
|
51
|
+
const announceAttention = cfg.announceAttention !== false;
|
|
52
|
+
const volume = cfg.volume ?? 1.0;
|
|
53
|
+
|
|
54
|
+
// Live audio playback: watch the status namespace for a new announcement.
|
|
55
|
+
const statusSnap = useSnapshot(props.status);
|
|
56
|
+
const statusJson = (statusSnap && statusSnap.value && statusSnap.value.json) || null;
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!statusJson) return;
|
|
59
|
+
let p = null;
|
|
60
|
+
try { p = JSON.parse(statusJson); } catch { return; }
|
|
61
|
+
if (!p || !p.dataUrl || !p.id) return;
|
|
62
|
+
if (p.id === lastPlayed) return;
|
|
63
|
+
setLastPlayed(p.id);
|
|
64
|
+
const audio = new Audio(p.dataUrl);
|
|
65
|
+
audio.volume = Math.max(0, Math.min(1, Number(p.volume ?? 1)));
|
|
66
|
+
audio.play().catch(() => {});
|
|
67
|
+
}, [statusJson, lastPlayed]);
|
|
68
|
+
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
action({ action: "voices" }).then((r) => {
|
|
71
|
+
if (r && r.ok && r.result && r.result.voices && r.result.voices.length) setVoices(r.result.voices);
|
|
72
|
+
}).catch(() => {});
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
function set(field, value) { props.config.set(field, value); }
|
|
76
|
+
async function test() {
|
|
77
|
+
setStatus("Generuji hlas…");
|
|
78
|
+
const r = await action({ action: "test", text: "Ahoj, tady agent. Tohle je můj hlas." });
|
|
79
|
+
setStatus(r && r.ok ? "Přehrávám ukázku 🔊" : ((r && r.error) || "Chyba"));
|
|
80
|
+
}
|
|
81
|
+
async function attention() {
|
|
82
|
+
const r = await action({ action: "attention" });
|
|
83
|
+
setStatus(r && r.ok ? "Přehrávám výzvu k pozornosti 🔔" : ((r && r.error) || "Chyba"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return React.createElement("div", { className: "av-root" },
|
|
87
|
+
React.createElement("div", null,
|
|
88
|
+
React.createElement("h1", { className: "av-title" }, "Agent Voice"),
|
|
89
|
+
React.createElement("p", { className: "av-sub" }, "Agent čte nahlas dokončené úkoly a žádosti o pozornost · nikdy nečte úvahy ani průběžné zprávy")),
|
|
90
|
+
|
|
91
|
+
React.createElement("div", { className: "av-set" },
|
|
92
|
+
React.createElement("div", { className: "av-row" },
|
|
93
|
+
React.createElement("span", { className: "av-tag" }, "Hlas agenta:"),
|
|
94
|
+
React.createElement("div", { className: "av-switch" + (enabled ? " on" : ""), onClick: () => set("enabled", !enabled) },
|
|
95
|
+
React.createElement("div", { className: "av-knob" })),
|
|
96
|
+
React.createElement("span", { className: "av-sub" }, enabled ? "zapnuto" : "vypnuto")),
|
|
97
|
+
|
|
98
|
+
React.createElement("div", { className: "av-row" },
|
|
99
|
+
React.createElement("span", { className: "av-tag" }, "Hlas:"),
|
|
100
|
+
React.createElement("select", { className: "av-input", value: voice, onChange: (e) => set("voice", e.target.value) },
|
|
101
|
+
voices.map((v) => React.createElement("option", { key: v.id, value: v.id, disabled: v.available === false },
|
|
102
|
+
v.name + (v.available === false ? " (nedostupný)" : ""))))),
|
|
103
|
+
|
|
104
|
+
React.createElement("div", { className: "av-row" },
|
|
105
|
+
React.createElement("span", { className: "av-tag" }, "Jazyk:"),
|
|
106
|
+
React.createElement("select", { className: "av-input", value: language, onChange: (e) => set("language", e.target.value) },
|
|
107
|
+
React.createElement("option", { value: "cs" }, "čeština"),
|
|
108
|
+
React.createElement("option", { value: "en" }, "english"))),
|
|
109
|
+
|
|
110
|
+
React.createElement("div", { className: "av-row" },
|
|
111
|
+
React.createElement("span", { className: "av-tag" }, "Hlasitost:"),
|
|
112
|
+
React.createElement("input", { type: "range", min: 0, max: 1, step: 0.1, value: volume, onChange: (e) => set("volume", Number(e.target.value)) }),
|
|
113
|
+
React.createElement("span", { className: "av-badge" }, Math.round(volume * 100) + " %")),
|
|
114
|
+
|
|
115
|
+
React.createElement("div", { className: "av-row" },
|
|
116
|
+
React.createElement("label", { className: "av-sub" },
|
|
117
|
+
React.createElement("input", { type: "checkbox", checked: announceTasks, onChange: (e) => set("announceTasks", e.target.checked) }),
|
|
118
|
+
" oznamovat dokončené úkoly"),
|
|
119
|
+
React.createElement("label", { className: "av-sub" },
|
|
120
|
+
React.createElement("input", { type: "checkbox", checked: announceAttention, onChange: (e) => set("announceAttention", e.target.checked) }),
|
|
121
|
+
" oznamovat žádost o pozornost")),
|
|
122
|
+
|
|
123
|
+
React.createElement("div", { className: "av-row" },
|
|
124
|
+
React.createElement("button", { className: "av-btn av-primary", onClick: test }, "▶ Vyzkoušet hlas"),
|
|
125
|
+
React.createElement("button", { className: "av-btn", onClick: attention }, "🔔 Výzva k pozornosti"),
|
|
126
|
+
status ? React.createElement("span", { className: "av-badge" }, status) : null)),
|
|
127
|
+
|
|
128
|
+
React.createElement("p", { className: "av-sub" },
|
|
129
|
+
"Doporučené použití: agent zavolá dsh_agent_voice action='speak' po dokončení úkolu a action='attention', " +
|
|
130
|
+
"když tě potřebuje u počítače. Úvahy ani průběžné zprávy se nikdy nečtou nahlas."));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function apply(ctx) {
|
|
134
|
+
const style = document.createElement("style");
|
|
135
|
+
style.textContent = CSS;
|
|
136
|
+
document.head.appendChild(style);
|
|
137
|
+
ctx.effect(() => () => style.remove());
|
|
138
|
+
|
|
139
|
+
const action = ctx.settingsScope.bind({ namespace: "agent-voice-action" });
|
|
140
|
+
const config = ctx.settingsScope.bind({ namespace: "agent-voice" });
|
|
141
|
+
const status = ctx.settingsScope.bind({ namespace: "agent-voice-status" });
|
|
142
|
+
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
143
|
+
name: "conversation.view",
|
|
144
|
+
id: "studio-agent-voice",
|
|
145
|
+
order: 10,
|
|
146
|
+
label: () => "🔊 Agent Voice",
|
|
147
|
+
inject: () => ({ action, config, status }),
|
|
148
|
+
children: {},
|
|
149
|
+
}, AgentVoice));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
exports.apply = apply;
|
|
153
|
+
exports.inject = inject;
|
|
154
|
+
return module.exports;
|
|
155
|
+
},
|
|
156
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
|
+
import { settingsNamespace, installSettingsSection } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const WORKER_PY = join(__dirname, "..", "worker", "agent_voice_worker.py");
|
|
16
|
+
|
|
17
|
+
const name = "agent-voice";
|
|
18
|
+
const inject = ["tools", "settings"];
|
|
19
|
+
|
|
20
|
+
const NS = settingsNamespace("agent-voice");
|
|
21
|
+
const ACTION_NS = settingsNamespace("agent-voice-action");
|
|
22
|
+
const STATUS_NS = settingsNamespace("agent-voice-status");
|
|
23
|
+
|
|
24
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
25
|
+
const SHARED_VENV = join(DSH_HOME, "voice-studio-venv"); // reuse the installed XTTS/Piper venv
|
|
26
|
+
const VENV_DIR = join(DSH_HOME, "agent-voice-venv");
|
|
27
|
+
const WORKER_URL = "http://127.0.0.1:7863";
|
|
28
|
+
const PYTHON = process.env.PYTHON ?? "/opt/homebrew/opt/python@3.11/bin/python3.11";
|
|
29
|
+
|
|
30
|
+
const SCHEMA = z.object({
|
|
31
|
+
enabled: z.boolean(),
|
|
32
|
+
voice: z.string(),
|
|
33
|
+
language: z.string(),
|
|
34
|
+
announceTasks: z.boolean(),
|
|
35
|
+
announceAttention: z.boolean(),
|
|
36
|
+
volume: z.number(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
enabled: true,
|
|
41
|
+
voice: "jirka",
|
|
42
|
+
language: "cs",
|
|
43
|
+
announceTasks: true,
|
|
44
|
+
announceAttention: true,
|
|
45
|
+
volume: 1.0,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
|
|
50
|
+
let workerProcess = null;
|
|
51
|
+
|
|
52
|
+
async function ensureWorker() {
|
|
53
|
+
let venvPython = join(SHARED_VENV, "bin", "python3");
|
|
54
|
+
if (!existsSync(venvPython)) {
|
|
55
|
+
// Fallback: build a lightweight venv with Piper only (XTTS stays optional).
|
|
56
|
+
if (!existsSync(join(VENV_DIR, "bin", "python3"))) {
|
|
57
|
+
await execFileAsync(PYTHON, ["-m", "venv", VENV_DIR], { timeout: 300000 });
|
|
58
|
+
}
|
|
59
|
+
venvPython = join(VENV_DIR, "bin", "python3");
|
|
60
|
+
const marker = join(VENV_DIR, ".deps-installed");
|
|
61
|
+
if (!existsSync(marker)) {
|
|
62
|
+
await execFileAsync(join(VENV_DIR, "bin", "pip"), ["install", "piper-tts"], {
|
|
63
|
+
timeout: 900000,
|
|
64
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
await writeFile(marker, String(Date.now()));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!workerProcess || workerProcess.exitCode !== null) {
|
|
70
|
+
workerProcess = spawn(venvPython, [WORKER_PY], { detached: false, stdio: "ignore" });
|
|
71
|
+
workerProcess.unref?.();
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < 120; i++) {
|
|
74
|
+
try {
|
|
75
|
+
const r = await fetch(`${WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
|
|
76
|
+
if (r.ok) return;
|
|
77
|
+
} catch {
|
|
78
|
+
/* not up yet */
|
|
79
|
+
}
|
|
80
|
+
await sleep(1000);
|
|
81
|
+
}
|
|
82
|
+
throw new Error("agent-voice worker did not become healthy on " + WORKER_URL);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function postWorker(path, body) {
|
|
86
|
+
const r = await fetch(`${WORKER_URL}${path}`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
const data = await r.json().catch(() => ({}));
|
|
92
|
+
if (!r.ok || data.ok !== true) {
|
|
93
|
+
throw new Error(`agent-voice worker ${path}: ${data.error ?? ("HTTP " + r.status)}`);
|
|
94
|
+
}
|
|
95
|
+
return data;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function apply(ctx) {
|
|
99
|
+
let source = () => ({ ...DEFAULTS });
|
|
100
|
+
installSettingsSection(ctx, NS, SCHEMA, DEFAULTS, {
|
|
101
|
+
setSource: (current) => { source = () => ({ ...DEFAULTS, ...(current ?? {}) }); },
|
|
102
|
+
onChange: () => {},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ctx.settings.register(STATUS_NS, z.object({ json: z.string() }), { base: {} });
|
|
106
|
+
const actionScope = ctx.settings.register(ACTION_NS, z.object({
|
|
107
|
+
requestId: z.string(),
|
|
108
|
+
request: z.string(),
|
|
109
|
+
responseId: z.string(),
|
|
110
|
+
response: z.string(),
|
|
111
|
+
}), { base: {} });
|
|
112
|
+
|
|
113
|
+
async function synthesize(text, voice, language) {
|
|
114
|
+
const res = await postWorker("/tts", { text, voice, language });
|
|
115
|
+
const bytes = await readFile(res.audio);
|
|
116
|
+
return "data:audio/wav;base64," + Buffer.from(bytes).toString("base64");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function announce(kind, text) {
|
|
120
|
+
const cfg = source();
|
|
121
|
+
if (!cfg.enabled) return { ok: false, skipped: "disabled" };
|
|
122
|
+
const dataUrl = await synthesize(text, cfg.voice, cfg.language);
|
|
123
|
+
const payload = {
|
|
124
|
+
id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
|
|
125
|
+
kind,
|
|
126
|
+
text,
|
|
127
|
+
voice: cfg.voice,
|
|
128
|
+
volume: cfg.volume,
|
|
129
|
+
dataUrl,
|
|
130
|
+
at: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
await ctx.settings.replace(STATUS_NS, { json: JSON.stringify(payload) });
|
|
133
|
+
return { ok: true, kind, text };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runAction(req) {
|
|
137
|
+
const cfg = source();
|
|
138
|
+
const a = req.action;
|
|
139
|
+
|
|
140
|
+
if (a === "status") {
|
|
141
|
+
return {
|
|
142
|
+
enabled: cfg.enabled,
|
|
143
|
+
voice: cfg.voice,
|
|
144
|
+
language: cfg.language,
|
|
145
|
+
announceTasks: cfg.announceTasks,
|
|
146
|
+
announceAttention: cfg.announceAttention,
|
|
147
|
+
volume: cfg.volume,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (a === "voices") {
|
|
152
|
+
await ensureWorker();
|
|
153
|
+
return postWorker("/voices", {});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!cfg.enabled && a !== "status") {
|
|
157
|
+
return { ok: false, skipped: "agent voice je vypnutý" };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (a === "speak") {
|
|
161
|
+
if (!req.text) throw new Error("speak requires text");
|
|
162
|
+
return announce("speak", req.text);
|
|
163
|
+
}
|
|
164
|
+
if (a === "attention") {
|
|
165
|
+
const text = req.text || "Potřebuji tě u počítače.";
|
|
166
|
+
return announce("attention", text);
|
|
167
|
+
}
|
|
168
|
+
if (a === "test") {
|
|
169
|
+
const text = req.text || "Ahoj, tady agent. Test hlasu proběhl úspěšně.";
|
|
170
|
+
return announce("test", text);
|
|
171
|
+
}
|
|
172
|
+
throw new Error("unknown action: " + a);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let lastRequestId = null;
|
|
176
|
+
actionScope.watch(() => {
|
|
177
|
+
const v = actionScope.get();
|
|
178
|
+
if (!v || !v.requestId || v.requestId === lastRequestId) return;
|
|
179
|
+
lastRequestId = v.requestId;
|
|
180
|
+
let req;
|
|
181
|
+
try { req = JSON.parse(v.request); } catch { return; }
|
|
182
|
+
runAction(req).then(
|
|
183
|
+
(result) => ctx.settings.replace(ACTION_NS, { ...v, responseId: v.requestId, response: JSON.stringify({ ok: true, result }) }),
|
|
184
|
+
(error) => ctx.settings.replace(ACTION_NS, { ...v, responseId: v.requestId, response: JSON.stringify({ ok: false, error: String(error?.message ?? error) }) }),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Automatic announcements (fire-and-forget, guarded by settings).
|
|
189
|
+
ctx.on("subagent/end", () => {
|
|
190
|
+
const cfg = source();
|
|
191
|
+
if (!cfg.enabled || !cfg.announceTasks) return;
|
|
192
|
+
announce("task", "Dílčí úkol dokončen.").catch(() => {});
|
|
193
|
+
});
|
|
194
|
+
ctx.on("workflow/end", () => {
|
|
195
|
+
const cfg = source();
|
|
196
|
+
if (!cfg.enabled || !cfg.announceTasks) return;
|
|
197
|
+
announce("task", "Práce dokončena.").catch(() => {});
|
|
198
|
+
});
|
|
199
|
+
// Attention: when the agent must ask the user for a decision. We observe the
|
|
200
|
+
// approval waterfall without blocking it — we always pass control through.
|
|
201
|
+
ctx.on("approval/request", (req, next) => {
|
|
202
|
+
try {
|
|
203
|
+
const cfg = source();
|
|
204
|
+
if (cfg.enabled && cfg.announceAttention) {
|
|
205
|
+
announce("attention", "Potřebuji tě u počítače.").catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
} catch {}
|
|
208
|
+
return next();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Keep spoken output short and silent about reasoning.
|
|
212
|
+
const sp = ctx.get("systemPrompt");
|
|
213
|
+
if (sp) {
|
|
214
|
+
sp.section({
|
|
215
|
+
name: "agent-voice",
|
|
216
|
+
order: 400,
|
|
217
|
+
text:
|
|
218
|
+
"Mluvíš nahlas přes nástroj dsh_agent_voice. Použij ho POUZE k oznámení " +
|
|
219
|
+
"dokončeného úkolu (action='speak') nebo k žádosti o pozornost uživatele " +
|
|
220
|
+
"(action='attention'). NIKDY nečti nahlas své úvahy, průběžné kroky ani " +
|
|
221
|
+
"dílčí mezivýsledky. Hlasové zprávy udržuj krátké (jedna věta).",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
ctx.tools.register(defineTool({
|
|
226
|
+
name: "dsh_agent_voice",
|
|
227
|
+
description:
|
|
228
|
+
"Speak aloud to the user (local TTS, Czech/English). Use it ONLY to announce a " +
|
|
229
|
+
"completed task ('speak') or to request the user's attention ('attention'). " +
|
|
230
|
+
"NEVER narrate reasoning, progress, or interim results. Keep spoken messages short. " +
|
|
231
|
+
"Also 'status' (config) and 'voices' (available voices) and 'test'.",
|
|
232
|
+
parameters: {
|
|
233
|
+
action: { type: "string", required: true, description: "speak | attention | test | status | voices." },
|
|
234
|
+
text: { type: "string", description: "Text to speak (speak/attention/test)." },
|
|
235
|
+
},
|
|
236
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
237
|
+
async execute(args) {
|
|
238
|
+
try {
|
|
239
|
+
return JSON.stringify(await runAction(args));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return `dsh_agent_voice failed: ${error.message}`;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
ctx.effect(() => () => {
|
|
247
|
+
if (workerProcess && workerProcess.exitCode === null) workerProcess.kill();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-agent-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"worker",
|
|
10
|
+
"cordis.patch.yml",
|
|
11
|
+
"README.md",
|
|
12
|
+
"README.cs.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Michal Ožuch",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"dsh",
|
|
24
|
+
"plugin",
|
|
25
|
+
"agent",
|
|
26
|
+
"voice",
|
|
27
|
+
"tts",
|
|
28
|
+
"speech",
|
|
29
|
+
"piper",
|
|
30
|
+
"xtts"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/meliodascz89/dsh-agent-voice.git"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-connection",
|
|
44
|
+
"@deepseek-ai/dsh-client-locale",
|
|
45
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"exports": {
|
|
51
|
+
".": "./lib/index.js",
|
|
52
|
+
"./client": "./lib/client.js",
|
|
53
|
+
"./package.json": "./package.json"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {}
|
|
60
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local TTS worker for dsh-agent-voice.
|
|
3
|
+
|
|
4
|
+
A small stdlib-only HTTP server for fast, low-memory agent announcements.
|
|
5
|
+
Piper is the default engine (distinct, offline, internet-curated voices);
|
|
6
|
+
XTTS v2 is available as a multilingual fallback.
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
GET /health -> {"ok": true}
|
|
10
|
+
POST /tts {text, voice, language} -> {"ok": true, "audio": path}
|
|
11
|
+
POST /voices {} -> {"ok": true, "voices": [...]}
|
|
12
|
+
|
|
13
|
+
Piper voice models live in ~/.local/share/piper/<model>.onnx(.json).
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import wave
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
|
|
21
|
+
# XTTS v2 model is licensed under Coqui's non-commercial CPML. Using this
|
|
22
|
+
# plugin constitutes agreement; set COQUI_TOS_AGREED=1 explicitly if preferred.
|
|
23
|
+
os.environ.setdefault("COQUI_TOS_AGREED", "1")
|
|
24
|
+
|
|
25
|
+
HOST = "127.0.0.1"
|
|
26
|
+
PORT = 7863
|
|
27
|
+
BASE = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
LOG_PATH = os.path.join(BASE, "agent_voice_worker.log")
|
|
29
|
+
OUT_DIR = os.environ.get("VOICE_OUTPUT_DIR") or "/Volumes/KvaDisk/Projekty/HarnessPlugs/dabing"
|
|
30
|
+
PIPER_DIR = os.path.join(os.path.expanduser("~"), ".local", "share", "piper")
|
|
31
|
+
|
|
32
|
+
# Curated voices (downloaded from https://huggingface.co/rhasspy/piper-voices).
|
|
33
|
+
PIPER_VOICES = {
|
|
34
|
+
"jirka": ("cs_CZ-jirka-medium", "cs"),
|
|
35
|
+
"kasandra": ("cs_CZ-kasandra-medium", "cs"),
|
|
36
|
+
"lessac": ("en_US-lessac-medium", "en"),
|
|
37
|
+
"ryan": ("en_US-ryan-high", "en"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log(msg):
|
|
42
|
+
line = "[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)
|
|
43
|
+
try:
|
|
44
|
+
with open(LOG_PATH, "a") as fh:
|
|
45
|
+
fh.write(line)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
print(line, end="")
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_xtts = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_xtts():
|
|
58
|
+
global _xtts
|
|
59
|
+
if _xtts is None:
|
|
60
|
+
import torch
|
|
61
|
+
_orig_load = torch.load
|
|
62
|
+
|
|
63
|
+
def _load(*a, **kw):
|
|
64
|
+
kw.setdefault("weights_only", False)
|
|
65
|
+
return _orig_load(*a, **kw)
|
|
66
|
+
|
|
67
|
+
torch.load = _load
|
|
68
|
+
from TTS.api import TTS
|
|
69
|
+
log("loading XTTS v2 ...")
|
|
70
|
+
_xtts = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
|
|
71
|
+
log("XTTS v2 ready")
|
|
72
|
+
return _xtts
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _piper_paths(model_name):
|
|
76
|
+
onnx = os.path.join(PIPER_DIR, model_name + ".onnx")
|
|
77
|
+
config = os.path.join(PIPER_DIR, model_name + ".onnx.json")
|
|
78
|
+
if not (os.path.isfile(onnx) and os.path.isfile(config)):
|
|
79
|
+
return None, None
|
|
80
|
+
return onnx, config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def tts_piper(text, model_name, output):
|
|
84
|
+
from piper import PiperVoice
|
|
85
|
+
onnx, config = _piper_paths(model_name)
|
|
86
|
+
if not (onnx and config):
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"piper voice %s nenalezen v %s — stáhni jej z rhasspy/piper-voices" % (model_name, PIPER_DIR)
|
|
89
|
+
)
|
|
90
|
+
voice = PiperVoice.load(onnx, config)
|
|
91
|
+
with wave.open(output, "wb") as w:
|
|
92
|
+
voice.synthesize_wav(text, w)
|
|
93
|
+
return output
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tts_xtts(text, language, output):
|
|
97
|
+
tts = get_xtts()
|
|
98
|
+
tts.tts_to_file(text=text, file_path=output, language=language)
|
|
99
|
+
return output
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def synthesize(text, voice, language, output):
|
|
103
|
+
if voice == "xtts":
|
|
104
|
+
return tts_xtts(text, language or "cs", output)
|
|
105
|
+
model_name, default_lang = PIPER_VOICES.get(voice, ("cs_CZ-jirka-medium", "cs"))
|
|
106
|
+
return tts_piper(text, model_name, output)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_voices():
|
|
110
|
+
voices = []
|
|
111
|
+
for name, (model_name, lang) in PIPER_VOICES.items():
|
|
112
|
+
onnx, _ = _piper_paths(model_name)
|
|
113
|
+
voices.append({"id": name, "name": model_name, "language": lang, "available": bool(onnx)})
|
|
114
|
+
try:
|
|
115
|
+
get_xtts # noqa: B018
|
|
116
|
+
voices.append({"id": "xtts", "name": "XTTS v2 (multilingual)", "language": "cs,en", "available": True})
|
|
117
|
+
except Exception:
|
|
118
|
+
voices.append({"id": "xtts", "name": "XTTS v2 (multilingual)", "language": "cs,en", "available": False})
|
|
119
|
+
return voices
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Handler(BaseHTTPRequestHandler):
|
|
123
|
+
def _send_json(self, obj, status=200):
|
|
124
|
+
data = json.dumps(obj).encode("utf-8")
|
|
125
|
+
self.send_response(status)
|
|
126
|
+
self.send_header("Content-Type", "application/json")
|
|
127
|
+
self.send_header("Content-Length", str(len(data)))
|
|
128
|
+
self.end_headers()
|
|
129
|
+
self.wfile.write(data)
|
|
130
|
+
|
|
131
|
+
def _read_json(self):
|
|
132
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
133
|
+
if length <= 0:
|
|
134
|
+
return {}
|
|
135
|
+
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
136
|
+
|
|
137
|
+
def log_message(self, *args):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def do_GET(self):
|
|
141
|
+
if self.path == "/health":
|
|
142
|
+
return self._send_json({"ok": True})
|
|
143
|
+
return self._send_json({"ok": False, "error": "not found"}, 404)
|
|
144
|
+
|
|
145
|
+
def do_POST(self):
|
|
146
|
+
try:
|
|
147
|
+
body = self._read_json()
|
|
148
|
+
if self.path == "/tts":
|
|
149
|
+
return self._tts(body)
|
|
150
|
+
if self.path == "/voices":
|
|
151
|
+
return self._send_json({"ok": True, "voices": list_voices()})
|
|
152
|
+
return self._send_json({"ok": False, "error": "not found"}, 404)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
log("error: %s" % exc)
|
|
155
|
+
return self._send_json({"ok": False, "error": str(exc)}, 500)
|
|
156
|
+
|
|
157
|
+
def _tts(self, body):
|
|
158
|
+
text = (body.get("text") or "").strip()
|
|
159
|
+
if not text:
|
|
160
|
+
return self._send_json({"ok": False, "error": "missing text"}, 400)
|
|
161
|
+
voice = body.get("voice") or "jirka"
|
|
162
|
+
language = body.get("language") or "cs"
|
|
163
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
164
|
+
output = body.get("output") or os.path.join(OUT_DIR, "agent-voice-%d.wav" % int(time.time() * 1000))
|
|
165
|
+
synthesize(text, voice, language, output)
|
|
166
|
+
return self._send_json({"ok": True, "audio": output})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
171
|
+
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
172
|
+
log("agent-voice worker listening on http://%s:%d" % (HOST, PORT))
|
|
173
|
+
server.serve_forever()
|