@gobing-ai/knowledge-kit 0.0.34 → 0.0.36
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/dist/board/collect/index.css +42 -0
- package/dist/board/collect/index.js +787 -0
- package/dist/board/daily-voice/index.css +13 -0
- package/dist/board/daily-voice/index.js +902 -0
- package/dist/board/itc/index.css +14 -0
- package/dist/board/itc/index.js +1026 -0
- package/dist/index.js +11769 -9250
- package/package.json +10 -9
- package/plugins/generations/content-gen/dist/index.js +1015 -10814
- package/plugins/generations/content-gen/package.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
- package/plugins/generations/core-facts-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +32 -3
- package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
- package/plugins/generations/dailynews-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
- package/plugins/generations/episode-plan-gen/package.json +1 -1
- package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
- package/plugins/generations/gemini-tts/package.json +1 -1
- package/plugins/generations/image-gen/dist/index.js +1239 -11035
- package/plugins/generations/image-gen/package.json +1 -1
- package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
- package/plugins/generations/news-report-gen/package.json +1 -1
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +1066 -10864
- package/plugins/generations/voice-gen/package.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/generations/voxcpm-tts/README.md +72 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
- package/plugins/generations/voxcpm-tts/package.json +16 -0
- package/plugins/generations/voxcpm-tts/plugin.json +6 -0
- package/plugins/generations/voxcpm-tts/profiles.json +10 -0
- package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
- package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
- package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
- package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
- package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
- package/plugins/ingestions/aihot-ingest/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
- package/plugins/ingestions/horizon-ingest/package.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
- package/plugins/ingestions/karakeep-local/package.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
- package/plugins/ingestions/last30days-ingest/package.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +1250 -11033
- package/plugins/ingestions/web-search/package.json +1 -1
- package/plugins/kk/README.md +29 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +9 -1
- package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
- package/plugins/kk/scripts/daily-stages.ts +702 -63
- package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/skills/publish/SKILL.md +8 -4
- package/plugins/kk/workflows/feature-verification.yaml +93 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +43 -11
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
- package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
- package/plugins/kk/workflows/kk-itc.yaml +8 -1
- package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
- package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
- package/plugins/publishings/emdash-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
- package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +6 -2
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +6 -2
- package/plugins/publishings/medium-pub/dist/index.js +978 -10805
- package/plugins/publishings/medium-pub/package.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
- package/plugins/publishings/podcast-pub/package.json +1 -1
- package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
- package/plugins/publishings/qiita-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
- package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +5 -2
- package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
- package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
- package/plugins/publishings/surfdash-pub/package.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
- package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
- package/plugins/publishings/threads-pub/dist/index.js +12394 -0
- package/plugins/publishings/threads-pub/package.json +17 -0
- package/plugins/publishings/threads-pub/plugin.json +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +340 -0
- package/plugins/publishings/threads-pub/tsconfig.json +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
- package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
- package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
- package/plugins/publishings/weibo-pub/package.json +19 -0
- package/plugins/publishings/weibo-pub/plugin.json +12 -0
- package/plugins/publishings/weibo-pub/src/index.ts +868 -0
- package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
- package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +1948 -11440
- package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
- package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
- package/plugins/publishings/zenn-pub/package.json +1 -1
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/voxcpm-tts",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"typecheck": "tsc --noEmit"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@gobing-ai/ts-utils": "catalog:",
|
|
10
|
+
"@gobing-ai/voice-gen": "workspace:*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@gobing-ai/kk-core": "workspace:*",
|
|
14
|
+
"@types/bun": "1.4.3"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""VoxCPM inference worker for the kk `voxcpm-tts` generator plugin.
|
|
2
|
+
|
|
3
|
+
A private, invocation-scoped NDJSON worker the TypeScript adapter spawns through `uv run
|
|
4
|
+
--locked`. One model load per process; sequential integer-id health/render requests on stdin;
|
|
5
|
+
one JSON response per line on stdout. At startup fd 1 is pointed at stderr and the protocol
|
|
6
|
+
writes to the saved original fd, so library stdout stays clean at the fd level too — not just
|
|
7
|
+
the Python `sys.stdout` object (ADR-036 / docs/design/voxcpm-daily-voice-driver.md).
|
|
8
|
+
|
|
9
|
+
Backend: independently published ``voxcpm==2.0.3`` on Python 3.12. `vendors/` is reference-only —
|
|
10
|
+
every model/cache/profile/import path is canonicalized and refused if it routes into `vendors/`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import struct
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
LINE_MAX_BYTES = 1_048_576
|
|
24
|
+
_VENDOR_SEGMENT = "vendors"
|
|
25
|
+
_ENV_PATH_KEYS = (
|
|
26
|
+
"PYTHONPATH",
|
|
27
|
+
"UV_PROJECT_ENVIRONMENT",
|
|
28
|
+
"UV_CACHE_DIR",
|
|
29
|
+
"HF_HOME",
|
|
30
|
+
"HF_HUB_CACHE",
|
|
31
|
+
"XDG_CACHE_HOME",
|
|
32
|
+
"TRANSFORMERS_CACHE",
|
|
33
|
+
"HF_DATASETS_CACHE",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WorkerError(Exception):
|
|
38
|
+
"""An actionable worker failure surfaced to the adapter as `{ok:false, error}`."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _canonical(path: str) -> str:
|
|
42
|
+
return str(Path(path).expanduser().resolve())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def vendor_blocked(path: str) -> bool:
|
|
46
|
+
"""True when the canonical path (or any symlink target) contains a `vendors` segment."""
|
|
47
|
+
resolved = Path(path).expanduser()
|
|
48
|
+
try:
|
|
49
|
+
resolved = resolved.resolve()
|
|
50
|
+
except OSError:
|
|
51
|
+
resolved = resolved.absolute()
|
|
52
|
+
if _VENDOR_SEGMENT in resolved.parts:
|
|
53
|
+
return True
|
|
54
|
+
if resolved.exists():
|
|
55
|
+
real = Path(os.path.realpath(resolved))
|
|
56
|
+
if _VENDOR_SEGMENT in real.parts:
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def assert_no_vendor(path: str, label: str) -> str:
|
|
62
|
+
if vendor_blocked(path):
|
|
63
|
+
raise WorkerError(f"{label} must not reference vendors/ (reference-only): {path}")
|
|
64
|
+
return _canonical(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def assert_env_paths_safe(environ: dict[str, str]) -> None:
|
|
68
|
+
for key in _ENV_PATH_KEYS:
|
|
69
|
+
raw = environ.get(key)
|
|
70
|
+
if not raw:
|
|
71
|
+
continue
|
|
72
|
+
for part in raw.split(os.pathsep):
|
|
73
|
+
if part:
|
|
74
|
+
assert_no_vendor(part, key)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_registry(raw: object, registry_dir: str) -> list[dict]:
|
|
78
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("profiles"), list):
|
|
79
|
+
raise WorkerError('profile registry must be an object with a "profiles" array')
|
|
80
|
+
seen_ids: set[str] = set()
|
|
81
|
+
seen_names: set[str] = set()
|
|
82
|
+
profiles: list[dict] = []
|
|
83
|
+
for index, entry in enumerate(raw["profiles"]):
|
|
84
|
+
if not isinstance(entry, dict):
|
|
85
|
+
raise WorkerError(f"profile entry {index} is not an object")
|
|
86
|
+
pid = str(entry.get("id", "")).strip()
|
|
87
|
+
name = str(entry.get("name", "")).strip()
|
|
88
|
+
if not pid or not name:
|
|
89
|
+
raise WorkerError(f"profile entry {index} needs nonempty id and name")
|
|
90
|
+
if pid in seen_ids or name in seen_names:
|
|
91
|
+
raise WorkerError(f"duplicate profile id/name {pid!r}")
|
|
92
|
+
seen_ids.add(pid)
|
|
93
|
+
seen_names.add(name)
|
|
94
|
+
mode = entry.get("mode")
|
|
95
|
+
if mode not in ("instruct", "reference", "continuation"):
|
|
96
|
+
raise WorkerError(f"profile {pid!r} has unsupported mode {mode!r}")
|
|
97
|
+
resolved = dict(entry)
|
|
98
|
+
for key in ("reference_wav_path", "prompt_wav_path"):
|
|
99
|
+
value = entry.get(key)
|
|
100
|
+
if value is None:
|
|
101
|
+
continue
|
|
102
|
+
if not isinstance(value, str) or not value.strip():
|
|
103
|
+
raise WorkerError(f"profile {pid!r} field {key} must be a nonempty string")
|
|
104
|
+
path = value if os.path.isabs(value) else os.path.join(registry_dir, value)
|
|
105
|
+
resolved[key] = assert_no_vendor(path, f"profile {pid!r} {key}")
|
|
106
|
+
if mode == "instruct" and not str(entry.get("instruct", "")).strip():
|
|
107
|
+
raise WorkerError(f"instruct profile {pid!r} needs a nonempty instruct")
|
|
108
|
+
if mode == "reference" and not resolved.get("reference_wav_path"):
|
|
109
|
+
raise WorkerError(f"reference profile {pid!r} needs reference_wav_path")
|
|
110
|
+
if mode == "continuation" and not (resolved.get("prompt_wav_path") and entry.get("prompt_text")):
|
|
111
|
+
raise WorkerError(f"continuation profile {pid!r} needs prompt_wav_path and prompt_text")
|
|
112
|
+
profiles.append(resolved)
|
|
113
|
+
return profiles
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_registry(environ: dict[str, str]) -> list[dict]:
|
|
117
|
+
path = environ.get("VOXCPM_PROFILES_PATH")
|
|
118
|
+
if not path:
|
|
119
|
+
raise WorkerError("VOXCPM_PROFILES_PATH is not set")
|
|
120
|
+
assert_no_vendor(path, "VOXCPM_PROFILES_PATH")
|
|
121
|
+
try:
|
|
122
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
123
|
+
except FileNotFoundError as err:
|
|
124
|
+
raise WorkerError(f"profile registry not found: {path}") from err
|
|
125
|
+
except json.JSONDecodeError as err:
|
|
126
|
+
raise WorkerError(f"profile registry is not valid JSON: {err}") from err
|
|
127
|
+
return parse_registry(raw, str(Path(path).parent))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_instruct(text: str, instruct: str | None) -> str:
|
|
131
|
+
"""VoxCPM2 renders an instruction as a parenthesized text prefix; the spoken transcript is
|
|
132
|
+
unchanged for downstream QC."""
|
|
133
|
+
if instruct:
|
|
134
|
+
return f"({instruct}){text}"
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def to_pcm16(waveform: object) -> bytes:
|
|
139
|
+
import numpy as np # imported lazily so helper tests need no torch/numpy
|
|
140
|
+
|
|
141
|
+
array = np.asarray(waveform, dtype="float32").reshape(-1)
|
|
142
|
+
if array.size == 0:
|
|
143
|
+
raise WorkerError("model returned an empty waveform")
|
|
144
|
+
if not np.all(np.isfinite(array)):
|
|
145
|
+
raise WorkerError("model returned a non-finite waveform")
|
|
146
|
+
clipped = np.clip(array, -1.0, 1.0)
|
|
147
|
+
return (clipped * 32767.0).astype("<i2").tobytes()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_pcm16_wav(path: str, pcm: bytes, sample_rate: int, channels: int = 1) -> None:
|
|
151
|
+
if channels != 1:
|
|
152
|
+
raise WorkerError(f"refusing to write {channels}-channel audio; VoxCPM output must be mono")
|
|
153
|
+
if not isinstance(sample_rate, int) or sample_rate <= 0:
|
|
154
|
+
raise WorkerError(f"invalid sample rate {sample_rate}")
|
|
155
|
+
block_align = channels * 2
|
|
156
|
+
byte_rate = sample_rate * block_align
|
|
157
|
+
header = b"RIFF" + struct.pack("<I", 36 + len(pcm)) + b"WAVE"
|
|
158
|
+
header += b"fmt " + struct.pack("<IHHIIHH", 16, 1, channels, sample_rate, byte_rate, block_align, 16)
|
|
159
|
+
header += b"data" + struct.pack("<I", len(pcm))
|
|
160
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
with open(path, "wb") as handle:
|
|
162
|
+
handle.write(header)
|
|
163
|
+
handle.write(pcm)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _snapshot_revision(model_id: str, local_dir: str) -> str:
|
|
167
|
+
try:
|
|
168
|
+
from huggingface_hub import scan_cache_dir # type: ignore
|
|
169
|
+
|
|
170
|
+
for repo in scan_cache_dir().repos:
|
|
171
|
+
if repo.repo_id == model_id:
|
|
172
|
+
return repo.repo_path.name
|
|
173
|
+
except Exception: # noqa: BLE001 - provenance is best-effort, never fatal
|
|
174
|
+
return "unknown"
|
|
175
|
+
return "unknown"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_backend(environ: dict[str, str]) -> dict:
|
|
179
|
+
"""Resolve the local snapshot, load the model once, and report provenance."""
|
|
180
|
+
assert_env_paths_safe(environ)
|
|
181
|
+
model_id = environ.get("VOXCPM_MODEL", "openbmb/VoxCPM2")
|
|
182
|
+
device = environ.get("VOXCPM_DEVICE", "auto")
|
|
183
|
+
if device not in ("auto", "cpu", "cuda", "mps"):
|
|
184
|
+
raise WorkerError(f"unsupported VOXCPM_DEVICE {device!r} (allowed: auto, cpu, cuda, mps)")
|
|
185
|
+
|
|
186
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
187
|
+
import voxcpm # type: ignore
|
|
188
|
+
from huggingface_hub import snapshot_download # type: ignore
|
|
189
|
+
|
|
190
|
+
if os.path.isdir(model_id):
|
|
191
|
+
local = assert_no_vendor(model_id, "VOXCPM_MODEL")
|
|
192
|
+
revision = "unknown"
|
|
193
|
+
else:
|
|
194
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
195
|
+
local = snapshot_download(repo_id=model_id)
|
|
196
|
+
local = assert_no_vendor(local, "model snapshot")
|
|
197
|
+
revision = _snapshot_revision(model_id, local)
|
|
198
|
+
|
|
199
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
200
|
+
model = voxcpm.VoxCPM.from_pretrained(
|
|
201
|
+
local,
|
|
202
|
+
load_denoiser=False,
|
|
203
|
+
optimize=False,
|
|
204
|
+
device=None if device == "auto" else device,
|
|
205
|
+
)
|
|
206
|
+
package_version = getattr(voxcpm, "__version__", "unknown")
|
|
207
|
+
sample_rate = int(model.tts_model.sample_rate)
|
|
208
|
+
if sample_rate <= 0:
|
|
209
|
+
raise WorkerError(f"model reported a non-positive sample rate {sample_rate}")
|
|
210
|
+
return {
|
|
211
|
+
"model": model,
|
|
212
|
+
"path": local,
|
|
213
|
+
"revision": revision,
|
|
214
|
+
"package": package_version,
|
|
215
|
+
"sample_rate": sample_rate,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _render(state: dict, request: dict) -> dict:
|
|
220
|
+
text = request.get("text")
|
|
221
|
+
if not isinstance(text, str) or not text.strip():
|
|
222
|
+
raise WorkerError("render text must be a nonempty string")
|
|
223
|
+
dest = request.get("dest")
|
|
224
|
+
if not isinstance(dest, str) or not os.path.isabs(dest):
|
|
225
|
+
raise WorkerError("render destination must be an absolute path")
|
|
226
|
+
assert_no_vendor(dest, "render destination")
|
|
227
|
+
|
|
228
|
+
profile = request.get("profile") or {}
|
|
229
|
+
mode = profile.get("mode")
|
|
230
|
+
out_text = text
|
|
231
|
+
if mode == "instruct":
|
|
232
|
+
out_text = apply_instruct(text, profile.get("instruct"))
|
|
233
|
+
elif mode == "reference":
|
|
234
|
+
reference = assert_no_vendor(profile["reference_wav_path"], "reference_wav_path")
|
|
235
|
+
profile = {**profile, "reference_wav_path": reference}
|
|
236
|
+
elif mode == "continuation":
|
|
237
|
+
prompt_wav = assert_no_vendor(profile["prompt_wav_path"], "prompt_wav_path")
|
|
238
|
+
profile = {**profile, "prompt_wav_path": prompt_wav}
|
|
239
|
+
else:
|
|
240
|
+
raise WorkerError(f"unsupported profile mode {mode!r}")
|
|
241
|
+
|
|
242
|
+
seed = request.get("seed")
|
|
243
|
+
if seed is not None:
|
|
244
|
+
import torch # type: ignore
|
|
245
|
+
|
|
246
|
+
torch.manual_seed(int(seed))
|
|
247
|
+
|
|
248
|
+
import numpy as np # type: ignore
|
|
249
|
+
|
|
250
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
251
|
+
if mode == "instruct":
|
|
252
|
+
waveform = state["model"].generate(text=out_text)
|
|
253
|
+
elif mode == "reference":
|
|
254
|
+
waveform = state["model"].generate(text=out_text, reference_wav_path=profile["reference_wav_path"])
|
|
255
|
+
else:
|
|
256
|
+
waveform = state["model"].generate(
|
|
257
|
+
text=out_text,
|
|
258
|
+
prompt_wav_path=profile["prompt_wav_path"],
|
|
259
|
+
prompt_text=profile["prompt_text"],
|
|
260
|
+
)
|
|
261
|
+
del np
|
|
262
|
+
pcm = to_pcm16(waveform)
|
|
263
|
+
sample_rate = state["sample_rate"]
|
|
264
|
+
write_pcm16_wav(dest, pcm, sample_rate)
|
|
265
|
+
duration = len(pcm) / 2 / sample_rate
|
|
266
|
+
return {
|
|
267
|
+
"ok": True,
|
|
268
|
+
"duration": duration,
|
|
269
|
+
"wav_path": dest,
|
|
270
|
+
"sample_rate": sample_rate,
|
|
271
|
+
"model": {"path": state["path"], "revision": state["revision"], "package": state["package"]},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _respond(stdout: io.TextIOBase, request_id: object, payload: dict) -> None:
|
|
276
|
+
message = {"id": request_id, **payload}
|
|
277
|
+
stdout.write(json.dumps(message) + "\n")
|
|
278
|
+
stdout.flush()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _handle(state: dict, environ: dict[str, str], request: dict) -> dict:
|
|
282
|
+
op = request.get("op")
|
|
283
|
+
if op == "health":
|
|
284
|
+
backend = state.get("backend")
|
|
285
|
+
if backend is None:
|
|
286
|
+
load_registry(environ)
|
|
287
|
+
backend = load_backend(environ)
|
|
288
|
+
state["backend"] = backend
|
|
289
|
+
return {
|
|
290
|
+
"ok": True,
|
|
291
|
+
"sample_rate": backend["sample_rate"],
|
|
292
|
+
"model": {
|
|
293
|
+
"path": backend["path"],
|
|
294
|
+
"revision": backend["revision"],
|
|
295
|
+
"package": backend["package"],
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
if op == "render":
|
|
299
|
+
backend = state.get("backend")
|
|
300
|
+
if backend is None:
|
|
301
|
+
raise WorkerError("render before health; the model is not loaded")
|
|
302
|
+
return _render(backend, request)
|
|
303
|
+
raise WorkerError(f"unsupported op {op!r}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def isolate_protocol_stdout() -> io.TextIOBase:
|
|
307
|
+
"""Point fd 1 at stderr so a native library writing to fd 1 cannot corrupt the NDJSON
|
|
308
|
+
channel, and return the real protocol stream (the saved original fd 1). R4's "protocol
|
|
309
|
+
stdout stays clean" must hold for fd-level writes, not only Python `sys.stdout`."""
|
|
310
|
+
saved = os.dup(1)
|
|
311
|
+
os.dup2(2, 1)
|
|
312
|
+
return os.fdopen(saved, "w", encoding="utf-8", newline="\n")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main(
|
|
316
|
+
argv: list[str] | None = None,
|
|
317
|
+
environ: dict[str, str] | None = None,
|
|
318
|
+
protocol_stdout: io.TextIOBase | None = None,
|
|
319
|
+
) -> int:
|
|
320
|
+
del argv
|
|
321
|
+
env = environ if environ is not None else dict(os.environ)
|
|
322
|
+
stdout = protocol_stdout if protocol_stdout is not None else sys.stdout
|
|
323
|
+
state: dict = {}
|
|
324
|
+
for raw in sys.stdin:
|
|
325
|
+
line = raw.rstrip("\n")
|
|
326
|
+
if not line.strip():
|
|
327
|
+
continue
|
|
328
|
+
if len(line.encode("utf-8")) > LINE_MAX_BYTES:
|
|
329
|
+
_respond(stdout, None, {"ok": False, "error": "request frame exceeds 1 MiB"})
|
|
330
|
+
return 1
|
|
331
|
+
try:
|
|
332
|
+
request = json.loads(line)
|
|
333
|
+
if not isinstance(request, dict) or not isinstance(request.get("id"), int):
|
|
334
|
+
raise WorkerError("request must carry an integer id")
|
|
335
|
+
except (json.JSONDecodeError, WorkerError) as err:
|
|
336
|
+
_respond(stdout, None, {"ok": False, "error": f"malformed request: {err}"})
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
payload = _handle(state, env, request)
|
|
340
|
+
except WorkerError as err:
|
|
341
|
+
_respond(stdout, request.get("id"), {"ok": False, "error": str(err)})
|
|
342
|
+
continue
|
|
343
|
+
except Exception as err: # noqa: BLE001 - surface any backend failure actionably
|
|
344
|
+
_respond(stdout, request.get("id"), {"ok": False, "error": f"{type(err).__name__}: {err}"})
|
|
345
|
+
continue
|
|
346
|
+
# The success path MUST answer too: the adapter resolves a request by its id, so a
|
|
347
|
+
# silent success left every health/render waiting out its deadline (task 0258).
|
|
348
|
+
_respond(stdout, request.get("id"), payload)
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
raise SystemExit(main(protocol_stdout=isolate_protocol_stdout()))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
3
|
+
import { processGeneratorIO } from '@gobing-ai/voice-gen';
|
|
4
|
+
import { createVoxCpmClient, VOXCPM_TTS_GENERATOR, type VoxCpmClientOptions } from './voxcpm-client';
|
|
5
|
+
|
|
6
|
+
export * from './voxcpm-client';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `kk` plugin entry: render Doc[] into audio Content through the shared voice-gen pipeline,
|
|
10
|
+
* injecting the invocation-scoped VoxCPM adapter and closing it (worker, signals, scratch) in
|
|
11
|
+
* `finally`. The worker stays lazy until `health()` runs after shared Doc validation, so empty
|
|
12
|
+
* input starts nothing (AC3).
|
|
13
|
+
*/
|
|
14
|
+
export async function main(argv: string[] = process.argv.slice(2), options: VoxCpmClientOptions = {}): Promise<number> {
|
|
15
|
+
const { values } = parseArgs({
|
|
16
|
+
args: argv,
|
|
17
|
+
options: {
|
|
18
|
+
in: { type: 'string' },
|
|
19
|
+
out: { type: 'string' },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!values.in || !values.out) {
|
|
24
|
+
echoError('voxcpm-tts failed: Missing required arguments: --in and --out');
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handle = createVoxCpmClient(options);
|
|
29
|
+
try {
|
|
30
|
+
await processGeneratorIO(values.in, values.out, handle.client, undefined, VOXCPM_TTS_GENERATOR);
|
|
31
|
+
return 0;
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
echoError(`voxcpm-tts failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
34
|
+
return 1;
|
|
35
|
+
} finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (import.meta.main) {
|
|
41
|
+
process.exit(await main());
|
|
42
|
+
}
|