@gobing-ai/knowledge-kit 0.0.12 → 0.0.14

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.
Files changed (152) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +120 -26
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +22187 -0
  5. package/plugins/generations/content-gen/plugin.json +1 -1
  6. package/plugins/generations/core-facts-gen/dist/index.js +22068 -0
  7. package/plugins/generations/core-facts-gen/plugin.json +1 -1
  8. package/plugins/generations/daily-article-gen/dist/index.js +22050 -0
  9. package/plugins/generations/daily-article-gen/plugin.json +1 -1
  10. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  11. package/plugins/generations/dailynews-gen/dist/index.js +22344 -0
  12. package/plugins/generations/dailynews-gen/plugin.json +1 -1
  13. package/plugins/generations/episode-plan-gen/dist/index.js +22503 -0
  14. package/plugins/generations/episode-plan-gen/plugin.json +1 -1
  15. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  16. package/plugins/generations/image-gen/config.example.yaml +75 -0
  17. package/plugins/generations/image-gen/dist/index.js +24862 -0
  18. package/plugins/generations/image-gen/package.json +17 -0
  19. package/plugins/generations/image-gen/plugin.json +7 -0
  20. package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
  21. package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
  22. package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
  23. package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
  24. package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
  25. package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
  26. package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
  27. package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
  28. package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
  29. package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
  30. package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
  31. package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
  32. package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
  33. package/plugins/generations/image-gen/src/bytes.ts +19 -0
  34. package/plugins/generations/image-gen/src/index.ts +319 -0
  35. package/plugins/generations/image-gen/src/job.ts +143 -0
  36. package/plugins/generations/image-gen/src/paths.ts +31 -0
  37. package/plugins/generations/image-gen/src/presets.ts +344 -0
  38. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  39. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  40. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  41. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  42. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  43. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  44. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  45. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  46. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  47. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  48. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  49. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  50. package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
  51. package/plugins/generations/image-gen/src/providers/types.ts +286 -0
  52. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  53. package/plugins/generations/image-gen/tsconfig.json +8 -0
  54. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  55. package/plugins/generations/news-report-gen/package.json +17 -0
  56. package/plugins/generations/news-report-gen/plugin.json +7 -0
  57. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  58. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  59. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  60. package/plugins/generations/omni-voice-gen/README.md +112 -0
  61. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  62. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  63. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  64. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  65. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  66. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  67. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  68. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  69. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  70. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  71. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  72. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  73. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  74. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  75. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  76. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  77. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  78. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  79. package/plugins/generations/voice-gen/dist/index.js +23055 -0
  80. package/plugins/generations/voice-gen/plugin.json +1 -1
  81. package/plugins/generations/voice-gen/src/index.ts +16 -1
  82. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  83. package/plugins/ingestions/aihot-ingest/dist/index.js +22378 -0
  84. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  85. package/plugins/ingestions/horizon-ingest/dist/index.js +22125 -0
  86. package/plugins/ingestions/horizon-ingest/plugin.json +1 -1
  87. package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
  88. package/plugins/ingestions/karakeep-local/plugin.json +1 -1
  89. package/plugins/ingestions/last30days-ingest/dist/index.js +22070 -0
  90. package/plugins/ingestions/last30days-ingest/plugin.json +1 -1
  91. package/plugins/ingestions/web-search/dist/index.js +24399 -0
  92. package/plugins/ingestions/web-search/plugin.json +1 -1
  93. package/plugins/kk/commands/image-extract.md +40 -0
  94. package/plugins/kk/commands/image-generate.md +32 -0
  95. package/plugins/kk/config.example.yaml +80 -0
  96. package/plugins/kk/plugin.json +1 -1
  97. package/plugins/kk/skills/image-authoring/SKILL.md +257 -0
  98. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  99. package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
  100. package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
  101. package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
  102. package/plugins/kk/skills/image-authoring/references/style-extraction.md +139 -0
  103. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  104. package/plugins/publishings/emdash-pub/dist/index.js +22263 -0
  105. package/plugins/publishings/emdash-pub/plugin.json +1 -1
  106. package/plugins/publishings/podcast-pub/dist/index.js +22650 -0
  107. package/plugins/publishings/podcast-pub/plugin.json +8 -2
  108. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  109. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  110. package/plugins/publishings/qiita-pub/dist/index.js +22101 -0
  111. package/plugins/publishings/qiita-pub/plugin.json +1 -1
  112. package/plugins/publishings/surfdash-pub/dist/index.js +22323 -0
  113. package/plugins/publishings/surfdash-pub/plugin.json +1 -1
  114. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  115. package/plugins/publishings/zenn-pub/dist/index.js +22142 -0
  116. package/plugins/publishings/zenn-pub/plugin.json +1 -1
  117. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  118. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  119. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  120. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  121. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  122. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  123. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  124. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  125. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  126. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  127. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  128. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  129. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  130. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  131. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  132. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  133. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  134. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  135. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  136. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  137. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  138. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  139. package/plugins/sp/scripts/quality-gate.ts +217 -0
  140. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  141. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  142. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  143. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  144. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  145. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  146. package/plugins/sp/scripts/validate-commands.ts +689 -0
  147. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  148. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  149. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  150. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  151. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  152. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -0,0 +1,289 @@
1
+ """processGeneratorIO port (voice-gen index.ts): Doc[] -> rendered WAV (+MP3) -> audio Content.
2
+
3
+ Deltas from voice-gen (task 0125 design):
4
+ - One in-process Backend (D6) replaces HTTP generate/poll, so voice-gen's nested network + verify
5
+ loops collapse to one verify loop with a single inline retry on backend exceptions.
6
+ - Retries resample (seed=None) instead of a random seed + halved max_chunk_chars: an explicit seed
7
+ pins attempt 0 only, and OmniVoice chunks by duration, not characters.
8
+ - generationId is uuid4 hex[:16] per accepted render (there is no server history id).
9
+ - metadata.duration and qc.totalDuration share one basis: segment durations plus the gaps
10
+ concat_wavs actually inserts (D8.2).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import uuid
18
+ from collections.abc import Callable
19
+ from dataclasses import fields, is_dataclass
20
+ from pathlib import Path
21
+ from time import monotonic
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+ from .audio import concat_wavs, detect_loudness_dip
27
+ from .backend import Backend, OmnivoiceBackend, SegmentSpec
28
+ from .contract import NOTICE_CONTENT, Content, Doc, content_to_json, parse_doc_list
29
+ from .mp3 import is_mp3_requested, transcode_wav_to_mp3
30
+ from .profiles import load_registry, resolve_profile
31
+ from .qc import (
32
+ TRANSCRIPTION_FIDELITY_FLOOR,
33
+ audit_voice_segments,
34
+ transcription_fidelity,
35
+ )
36
+ from .voicescript import (
37
+ VoiceScript,
38
+ VoiceSegment,
39
+ WarnSink,
40
+ merge_voice_scripts,
41
+ parse_doc_to_voicescript,
42
+ resolve_instruct,
43
+ validate_voice_script,
44
+ )
45
+
46
+ GENERATOR = "kk:omni-voice-gen"
47
+ MAX_VERIFY_RETRIES = 2 # verify attempts 0..2 (voice-gen parity)
48
+
49
+
50
+ def _log(msg: str) -> None:
51
+ print(f"omni-voice-gen: {msg}", file=sys.stderr)
52
+
53
+
54
+ def _delete(*paths: str) -> None:
55
+ for path in paths:
56
+ Path(path).unlink(missing_ok=True)
57
+
58
+
59
+ def _first(*values: Any) -> Any:
60
+ """JS `??` chain: the first value that is not None."""
61
+ return next((value for value in values if value is not None), None)
62
+
63
+
64
+ def _camel(name: str) -> str:
65
+ head, *rest = name.split("_")
66
+ return head + "".join(part.capitalize() for part in rest)
67
+
68
+
69
+ def _plain(value: Any, camel: bool = False) -> Any:
70
+ """Dataclass tree -> YAML/JSON-safe tree with None dropped (JS undefined parity).
71
+
72
+ `camel` renames dataclass field names only (QC report keys), never user dict keys.
73
+ """
74
+ if is_dataclass(value):
75
+ return {
76
+ (_camel(f.name) if camel else f.name): _plain(v, camel)
77
+ for f in fields(value)
78
+ if (v := getattr(value, f.name)) is not None
79
+ }
80
+ if isinstance(value, dict):
81
+ return {key: _plain(v, camel) for key, v in value.items() if v is not None}
82
+ if isinstance(value, (list, tuple)):
83
+ return [_plain(v, camel) for v in value]
84
+ return value
85
+
86
+
87
+ def resolve_profile_target(
88
+ segment: VoiceSegment, speaker: Any, script: VoiceScript, env_default: str | None, docs: list[Doc]
89
+ ) -> str:
90
+ """D4 resolution order (frozen); none set -> error listing every knob."""
91
+ doc_profile = (docs[0].metadata or {}).get("voiceProfile")
92
+ target = _first(
93
+ segment.profile_id,
94
+ segment.profile,
95
+ getattr(speaker, "profile_id", None),
96
+ getattr(speaker, "profile", None),
97
+ script.default_profile_id,
98
+ script.default_profile,
99
+ env_default,
100
+ doc_profile if isinstance(doc_profile, str) else None,
101
+ )
102
+ if not target:
103
+ raise ValueError(
104
+ "No voice profile specified for segment (set profile, profile_id, speaker profile, default_profile, "
105
+ "default_profile_id, VOICEBOX_DEFAULT_PROFILE, or doc metadata.voiceProfile)"
106
+ )
107
+ return str(target)
108
+
109
+
110
+ def _max_run_ms() -> int:
111
+ raw = os.environ.get("VOICE_GEN_MAX_RUN_MS")
112
+ try:
113
+ return int(raw) if raw else 0
114
+ except ValueError as exc:
115
+ raise ValueError(f"VOICE_GEN_MAX_RUN_MS must be an integer millisecond budget, got {raw!r}") from exc
116
+
117
+
118
+ def _generate(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float]:
119
+ try:
120
+ return backend.generate(spec)
121
+ except Exception as exc: # noqa: BLE001 — one inline retry for transient failures (voice-gen network-attempt parity); a second failure propagates
122
+ _log(f"segment render failed ({exc}); retrying once")
123
+ return backend.generate(spec)
124
+
125
+
126
+ def _transcribe(backend: Backend, wav: bytes, language: str) -> str | None:
127
+ try:
128
+ return backend.transcribe(wav, language)
129
+ except Exception: # noqa: BLE001 — ASR unavailable: keep audio, the QC audit is the backstop (voice-gen parity)
130
+ return None
131
+
132
+
133
+ def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float, int, str | None, bool]:
134
+ """Spec §5 verify-retry -> (wav, duration, verify_retries, transcription, loudness_dip)."""
135
+ for attempt in range(MAX_VERIFY_RETRIES + 1):
136
+ final = attempt == MAX_VERIFY_RETRIES # the final attempt is always accepted
137
+ if attempt:
138
+ _log(f"regenerating segment (verify retry {attempt}/{MAX_VERIFY_RETRIES}, resampled)")
139
+ # Explicit seed pins attempt 0 only; retries resample (diffusion decoding is stochastic).
140
+ wav, duration = _generate(backend, spec if attempt == 0 else {**spec, "seed": None})
141
+ transcription = _transcribe(backend, wav, spec["language"])
142
+ if transcription is not None and not final:
143
+ fidelity = transcription_fidelity(spec["text"], transcription)
144
+ if fidelity < TRANSCRIPTION_FIDELITY_FLOOR:
145
+ _log(
146
+ f"transcription fidelity {fidelity:.2f} below {TRANSCRIPTION_FIDELITY_FLOOR} "
147
+ f'(looped/garbage audio): "{transcription[:80]}..."'
148
+ )
149
+ continue
150
+ dip, dip_at = detect_loudness_dip(wav)
151
+ if dip and not final:
152
+ _log(f"loudness dip detected at {dip_at}s — regenerating segment")
153
+ continue
154
+ return wav, duration, attempt, transcription, dip
155
+ raise AssertionError("unreachable: the final verify attempt always returns") # pragma: no cover
156
+
157
+
158
+ def _render(
159
+ docs: list[Doc], audio_path: str, mp3_path: str, backend: Backend, transcoder: Callable[[str, str], None]
160
+ ) -> Content:
161
+ env_default = os.environ.get("VOICEBOX_DEFAULT_PROFILE")
162
+ script = merge_voice_scripts([parse_doc_to_voicescript(doc, env_default) for doc in docs], docs, env_default)
163
+ sink = WarnSink()
164
+ validate_voice_script(script, sink)
165
+ if warning := sink.emit_once():
166
+ print(warning, file=sys.stderr)
167
+ # Env + registry failures surface before the (slow) model load.
168
+ max_run_ms = _max_run_ms()
169
+ registry = load_registry()
170
+
171
+ backend.load()
172
+ started = monotonic()
173
+ total = len(script.segments)
174
+ _log(f"{total} segments queued for render")
175
+
176
+ wavs: list[bytes] = []
177
+ gaps: list[int] = []
178
+ segments_meta: list[dict[str, Any]] = []
179
+ audit_meta: list[dict[str, Any]] = []
180
+ total_duration = 0.0
181
+ for index, segment in enumerate(script.segments):
182
+ if max_run_ms > 0 and (monotonic() - started) * 1000 > max_run_ms:
183
+ raise RuntimeError(
184
+ f"render budget {max_run_ms}ms exceeded after {index}/{total} segments — "
185
+ "raise VOICE_GEN_MAX_RUN_MS if the render is legitimately long"
186
+ )
187
+ segment_started = monotonic()
188
+ speaker = (script.speakers or {}).get(segment.speaker) if segment.speaker else None
189
+ profile = resolve_profile_target(segment, speaker, script, env_default, docs)
190
+ emotion = _first(segment.emotion, getattr(speaker, "emotion", None), script.default_emotion)
191
+ spec: SegmentSpec = {
192
+ "text": segment.text,
193
+ "language": _first(segment.language, getattr(speaker, "language", None), script.language, "en"),
194
+ "profile": resolve_profile(profile, registry),
195
+ "instruct": resolve_instruct(_first(segment.instruct, getattr(speaker, "instruct", None)), emotion),
196
+ "seed": None if segment.seed is None else int(segment.seed),
197
+ "speed": None,
198
+ }
199
+ wav, duration, retries, transcription, dip = _render_verified(backend, spec)
200
+
201
+ gap_ms = segment.gap_ms or 0
202
+ wavs.append(wav)
203
+ gaps.append(gap_ms)
204
+ # D8.2: concat_wavs inserts gaps_ms[i] before segment i only for i > 0 — count exactly that silence.
205
+ total_duration += duration + (gap_ms / 1000 if index else 0)
206
+ segments_meta.append(
207
+ _plain(
208
+ {
209
+ "generationId": uuid.uuid4().hex[:16],
210
+ "profile": profile,
211
+ "speaker": segment.speaker,
212
+ "emotion": emotion,
213
+ "duration": duration,
214
+ "verifyRetries": retries,
215
+ "transcription": transcription,
216
+ "loudnessDip": dip,
217
+ }
218
+ )
219
+ )
220
+ audit_meta.append({"duration": duration, "transcription": transcription, "loudness_dip": dip})
221
+ retry_note = f" ({retries} verify {'retry' if retries == 1 else 'retries'})" if retries else ""
222
+ _log(f"segment {index + 1}/{total} done in {monotonic() - segment_started:.1f}s{retry_note}")
223
+
224
+ Path(audio_path).write_bytes(concat_wavs(wavs, gaps))
225
+ report = audit_voice_segments(script, wavs, audit_meta, backend)
226
+ report.total_duration = round(total_duration, 2) # D8.2: same basis as metadata.duration
227
+ metadata: dict[str, Any] = {
228
+ "generator": GENERATOR,
229
+ "audioPath": audio_path,
230
+ "duration": total_duration,
231
+ "segments": segments_meta,
232
+ "qc": _plain(report, camel=True),
233
+ }
234
+ if os.environ.get("VOICE_GEN_FAIL_ON_QC") == "true" and not report.passed:
235
+ raise RuntimeError(
236
+ f"Voice Quality Control failed ({report.overall_score}/100):\n" + "\n".join(report.critical_issues)
237
+ )
238
+ if is_mp3_requested():
239
+ transcoder(audio_path, mp3_path)
240
+ metadata["mp3Path"] = mp3_path
241
+
242
+ return Content(
243
+ title=script.title or docs[0].title or "Generated voice",
244
+ body=yaml.safe_dump(_plain(script), sort_keys=False, allow_unicode=True),
245
+ format="audio",
246
+ references=[],
247
+ metadata=metadata,
248
+ )
249
+
250
+
251
+ def run(
252
+ in_path: str,
253
+ out_path: str,
254
+ backend: Backend | None = None,
255
+ transcoder: Callable[[str, str], None] | None = None,
256
+ ) -> None:
257
+ """Doc[] JSON at in_path -> Content JSON at out_path + sibling <stem>.wav (and .mp3).
258
+
259
+ Delete-then-render: stale --out/WAV/MP3 are removed at start AND on any failure, and --out is
260
+ written once, after Content validation (voice-gen processGeneratorIO parity).
261
+ """
262
+ out_dir = os.path.dirname(os.path.abspath(out_path))
263
+ stem = Path(out_path).stem
264
+ audio_path = os.path.join(out_dir, f"{stem}.wav")
265
+ mp3_path = os.path.join(out_dir, f"{stem}.mp3")
266
+ _delete(out_path, audio_path, mp3_path)
267
+
268
+ try:
269
+ raw = Path(in_path).read_text(encoding="utf-8")
270
+ except OSError as exc:
271
+ raise ValueError(f"cannot read input {in_path}: {exc.strerror or exc}") from exc
272
+ try:
273
+ docs = parse_doc_list(raw)
274
+ except ValueError as exc:
275
+ raise ValueError(f"Invalid DocList input: {exc}") from exc
276
+
277
+ os.makedirs(out_dir, exist_ok=True)
278
+ if not docs:
279
+ Path(out_path).write_text(content_to_json(NOTICE_CONTENT), encoding="utf-8")
280
+ return
281
+
282
+ try:
283
+ content = _render(
284
+ docs, audio_path, mp3_path, backend or OmnivoiceBackend(), transcoder or transcode_wav_to_mp3
285
+ )
286
+ Path(out_path).write_text(content_to_json(content), encoding="utf-8")
287
+ except BaseException:
288
+ _delete(out_path, audio_path, mp3_path)
289
+ raise
@@ -0,0 +1,100 @@
1
+ """Profile registry (D4): profile name -> clone / saved-prompt / instruct entry.
2
+
3
+ Registry source: OMNIVOICE_PROFILE_REGISTRY env, else <plugin>/profiles.json. Resolution is
4
+ case-insensitive by name; unknown names fail loud naming the profile and the registry path.
5
+ The D4 fallback chain (segment -> speaker -> script default -> VOICEBOX_DEFAULT_PROFILE -> doc
6
+ metadata voiceProfile) and plain-text clone-path handling belong to the pipeline (0125); this
7
+ module owns only load + resolve.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ REGISTRY_ENV_VAR = "OMNIVOICE_PROFILE_REGISTRY"
19
+ DEFAULT_REGISTRY_PATH = Path(__file__).resolve().parents[2] / "profiles.json"
20
+
21
+ ProfileKind = Literal["clone", "prompt", "instruct"]
22
+
23
+
24
+ @dataclass
25
+ class ProfileEntry:
26
+ """One registry entry; exactly one field family is set (enforced at load time)."""
27
+
28
+ kind: ProfileKind
29
+ ref_audio: str | None = None
30
+ ref_text: str | None = None
31
+ prompt: str | None = None
32
+ instruct: str | None = None
33
+
34
+
35
+ class ProfileRegistry(dict[str, ProfileEntry]):
36
+ """Registry map carrying its source path so resolution errors can name it (task R1)."""
37
+
38
+ def __init__(self, entries: dict[str, ProfileEntry], path: Path) -> None:
39
+ super().__init__(entries)
40
+ self.path = path
41
+
42
+
43
+ def _parse_entry(name: str, spec: object) -> ProfileEntry:
44
+ def invalid(reason: str) -> ValueError:
45
+ return ValueError(f'Profile "{name}" is invalid: {reason}')
46
+
47
+ if not isinstance(spec, dict):
48
+ raise invalid("entry must be a JSON object")
49
+ ref_audio, ref_text = spec.get("ref_audio"), spec.get("ref_text")
50
+ prompt, instruct = spec.get("prompt"), spec.get("instruct")
51
+ families = sum([ref_audio is not None or ref_text is not None, prompt is not None, instruct is not None])
52
+ if families != 1:
53
+ raise invalid("exactly one of {ref_audio + ref_text, prompt, instruct} must be set")
54
+ for value in (ref_audio, ref_text, prompt, instruct):
55
+ if value is not None and not isinstance(value, str):
56
+ raise invalid("entry values must be strings")
57
+ if ref_audio is not None or ref_text is not None:
58
+ if ref_audio is None or ref_text is None:
59
+ raise invalid("clone profiles require both ref_audio and ref_text")
60
+ return ProfileEntry(kind="clone", ref_audio=ref_audio, ref_text=ref_text)
61
+ if prompt is not None:
62
+ return ProfileEntry(kind="prompt", prompt=prompt)
63
+ return ProfileEntry(kind="instruct", instruct=instruct)
64
+
65
+
66
+ def load_registry(path: Path | None = None) -> ProfileRegistry:
67
+ """Load the registry JSON; fail loud on unreadable, malformed, or ambiguous content."""
68
+ if path is None:
69
+ env = os.environ.get(REGISTRY_ENV_VAR)
70
+ path = Path(env) if env else DEFAULT_REGISTRY_PATH
71
+ try:
72
+ raw = path.read_text(encoding="utf-8")
73
+ except OSError as exc:
74
+ raise ValueError(f"cannot read profile registry at {path}: {exc.strerror or exc}") from exc
75
+ try:
76
+ data = json.loads(raw)
77
+ except json.JSONDecodeError as exc:
78
+ raise ValueError(f"Malformed profile registry JSON at {path}: {exc}") from exc
79
+ if not isinstance(data, dict):
80
+ # ValueError, not TypeError: every registry failure must funnel into the single
81
+ # fail-loud exit path (same contract as contract.py parse errors).
82
+ raise ValueError( # noqa: TRY004
83
+ f"profile registry at {path} must be a JSON object mapping profile names to entries"
84
+ )
85
+ entries = {name: _parse_entry(name, spec) for name, spec in data.items()}
86
+ lowered = [key.lower() for key in entries]
87
+ if len(set(lowered)) != len(lowered):
88
+ dupes = sorted({key for key in entries if lowered.count(key.lower()) > 1})
89
+ raise ValueError(f"profile registry at {path} has case-insensitive duplicate names: {', '.join(dupes)}")
90
+ return ProfileRegistry(entries, path)
91
+
92
+
93
+ def resolve_profile(name: str, registry: dict[str, ProfileEntry]) -> ProfileEntry:
94
+ """Resolve by case-insensitive name; unknown -> ValueError naming profile + registry path."""
95
+ lowered = name.lower()
96
+ for key, entry in registry.items():
97
+ if key.lower() == lowered:
98
+ return entry
99
+ path = getattr(registry, "path", "<in-memory registry>")
100
+ raise ValueError(f'Unknown voice profile "{name}" in registry at {path}')
@@ -0,0 +1,234 @@
1
+ """QC audit — Python port of voice-gen qc.ts (repetition / fidelity / duration-range /
2
+ aggregate). Loudness-dip detection lives in audio.py (spec §2 layout); thresholds identical (D7).
3
+
4
+ Delta from the TS source: transcription is supplied by a Backend (sync
5
+ `transcribe(wav, language) -> str`, may raise) instead of the async VoiceboxClient.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Protocol
14
+
15
+ from .audio import detect_loudness_dip
16
+ from .voicescript import VoiceScript
17
+
18
+ # Thresholds — identical to voice-gen qc.ts (D7).
19
+ TRANSCRIPTION_FIDELITY_FLOOR = 0.15
20
+
21
+ MAX_DURATION_RATIO = 3.0
22
+ MAX_ABS_DIFF_SEC = 15.0
23
+ MAX_RATE_ZH = 15.0
24
+ MAX_RATE_EN = 30.0
25
+ MAX_RATE_OTHER = 25.0
26
+
27
+ MAX_DURATION_ZH = 15.0
28
+ MAX_DURATION_EN = 30.0
29
+ MAX_DURATION_OTHER = 20.0
30
+
31
+ MAX_DURATION_ZH_DIVISOR = 15.0
32
+ MAX_DURATION_EN_DIVISOR = 20.0
33
+ MAX_DURATION_OTHER_DIVISOR = 15.0
34
+
35
+ RE_CHAR_RUN = re.compile(r"(.)\1{2,}", re.DOTALL)
36
+ RE_WORD_RUN = re.compile(r"(.{2,3}?)\1{2,}", re.DOTALL)
37
+ RE_PHRASE_RUN = re.compile(r"(.{4,30}?)\1{1,}", re.DOTALL)
38
+
39
+
40
+ class Transcriber(Protocol):
41
+ # Concrete `raise` body, not `...`: coverage.py excludes it from measurement, keeping
42
+ # the protocol out of the D2 per-file function gate (same pattern as backend.py).
43
+ def transcribe(self, wav: bytes, language: str) -> str:
44
+ raise NotImplementedError
45
+
46
+
47
+ @dataclass
48
+ class SegmentQualityAudit:
49
+ segment_index: int
50
+ text: str
51
+ duration: float
52
+ expected_duration_range: tuple[float, float]
53
+ speech_rate_chars_per_sec: float
54
+ transcription: str | None
55
+ repetition_detected: bool
56
+ duration_anomaly: bool
57
+ fidelity_score: float | None
58
+ issues: list[str] = field(default_factory=list)
59
+
60
+
61
+ @dataclass
62
+ class VoiceQualityReport:
63
+ passed: bool
64
+ total_duration: float
65
+ overall_score: int
66
+ segment_audits: list[SegmentQualityAudit]
67
+ critical_issues: list[str]
68
+
69
+
70
+ def detect_repetitions(text: str) -> bool:
71
+ """Repeated chars/short words/long phrases — port of voice-gen qc.ts regex battery."""
72
+ return bool(RE_CHAR_RUN.search(text) or RE_WORD_RUN.search(text) or RE_PHRASE_RUN.search(text))
73
+
74
+
75
+ def _ngrams(text: str, n: int) -> set[str]:
76
+ grams: set[str] = set()
77
+ for i in range(len(text) - n + 1):
78
+ grams.add(text[i : i + n])
79
+ return grams
80
+
81
+
82
+ def transcription_fidelity(source: str, transcription: str) -> float:
83
+ """Character 4-gram Jaccard (short texts fall back to 2-grams)."""
84
+ if not source or not transcription:
85
+ return 0.0
86
+ if source == transcription:
87
+ return 1.0
88
+ n = 4 if len(source) >= 20 else 2
89
+ source_grams = _ngrams(source, n)
90
+ trans_grams = _ngrams(transcription, n)
91
+ intersection = 0
92
+ for gram in source_grams:
93
+ if gram in trans_grams:
94
+ intersection += 1
95
+ union = len(source_grams) + len(trans_grams) - intersection
96
+ return intersection / union if union else 0.0
97
+
98
+
99
+ def _split_words(clean: str) -> list[str]:
100
+ return [w for w in re.split(r"[\s\[\],.!?;:,。!?;:]+", clean) if len(w) > 0]
101
+
102
+
103
+ def compute_expected_duration_range(text: str, language: str = "zh") -> tuple[float, float]:
104
+ """Heuristic duration window (s) for a segment — port of voice-gen qc.ts."""
105
+ clean = text.strip()
106
+ if not clean:
107
+ return 0.0, 0.0
108
+ is_zh = language.startswith("zh")
109
+ is_en = language.startswith("en")
110
+ if is_zh:
111
+ cjk_count = sum(1 for ch in clean if ord(ch) > 255)
112
+ word_count = len(_split_words(clean))
113
+ effective_chars = cjk_count + word_count
114
+ min_sec = effective_chars * 0.15
115
+ max_sec = min_sec * 3
116
+ else:
117
+ word_count = len(_split_words(clean))
118
+ char_count = len(re.sub(r"\s+", "", clean))
119
+ if is_en:
120
+ effective_chars = word_count * 4 + math.floor(char_count / 4)
121
+ min_sec = effective_chars * 0.06
122
+ max_sec = min_sec * 3
123
+ else:
124
+ effective_chars = max(char_count, word_count * 2)
125
+ min_sec = effective_chars * 0.08
126
+ max_sec = min_sec * 3
127
+ return round(min_sec, 2), round(max_sec, 2)
128
+
129
+
130
+ def audit_voice_segments(
131
+ script: VoiceScript,
132
+ segment_wavs: list[bytes],
133
+ segment_metadata: list[dict[str, Any]],
134
+ backend: Transcriber | None = None,
135
+ ) -> VoiceQualityReport:
136
+ """Aggregate QC over rendered segments. `backend.transcribe` may raise; failures only
137
+ add an issue entry, never crash the audit (voice-gen parity)."""
138
+ audits: list[SegmentQualityAudit] = []
139
+ total_duration = 0.0
140
+ critical_issues: list[str] = []
141
+
142
+ for index, segment in enumerate(script.segments):
143
+ wav = segment_wavs[index]
144
+ meta = segment_metadata[index]
145
+ text = segment.text if isinstance(segment.text, str) else str(segment.text or "")
146
+ language = segment.language or script.language or "zh"
147
+ duration = float(meta.get("duration") or 0.0)
148
+ total_duration += duration
149
+
150
+ min_sec, max_sec = compute_expected_duration_range(text, language)
151
+ speech_rate = round((len(text) / duration) if duration > 0 else 0.0, 2)
152
+
153
+ issues: list[str] = []
154
+
155
+ # Transcription + fidelity (best-effort)
156
+ transcription = meta.get("transcription")
157
+ fidelity: float | None = None
158
+ if transcription is None and backend is not None and language and wav and len(wav) > 0:
159
+ try:
160
+ transcription = backend.transcribe(wav, language)
161
+ except Exception as err: # noqa: BLE001 — transcription is best-effort; any backend failure only adds an issue (voice-gen parity)
162
+ issues.append(f"transcription_failed: {err}")
163
+ if transcription is not None and transcription != "":
164
+ fidelity = transcription_fidelity(text, transcription)
165
+ if fidelity < TRANSCRIPTION_FIDELITY_FLOOR:
166
+ issues.append(f"transcription_fidelity_low:{fidelity:.2f}")
167
+
168
+ # Repetition
169
+ repetition = detect_repetitions(text) or (
170
+ detect_repetitions(transcription) if transcription is not None else False
171
+ )
172
+ if repetition:
173
+ issues.append("repetition_detected")
174
+
175
+ # Loudness dip: reuse the verify-phase result when present; only run the windowed
176
+ # scan when the verify phase did not (meta.loudness_dip absent).
177
+ dip, dip_at = (
178
+ (bool(meta["loudness_dip"]), None) if "loudness_dip" in meta else detect_loudness_dip(wav)
179
+ )
180
+ if dip:
181
+ issues.append(f"loudness_dip:{f'{dip_at}s' if dip_at is not None else 'unknown'}")
182
+
183
+ # Duration anomaly
184
+ ratio_anomaly = duration > MAX_DURATION_RATIO * max_sec if max_sec > 0 else False
185
+ abs_anomaly = duration > max_sec + MAX_ABS_DIFF_SEC if max_sec > 0 else False
186
+ max_rate = MAX_RATE_ZH if language.startswith("zh") else MAX_RATE_EN if language.startswith("en") else MAX_RATE_OTHER
187
+ rate_anomaly = duration > 0 and (len(text) / duration) > max_rate
188
+ max_possible = (
189
+ MAX_DURATION_ZH
190
+ if language.startswith("zh")
191
+ else MAX_DURATION_EN
192
+ if language.startswith("en")
193
+ else MAX_DURATION_OTHER
194
+ ) + len(text) / (
195
+ MAX_DURATION_ZH_DIVISOR
196
+ if language.startswith("zh")
197
+ else MAX_DURATION_EN_DIVISOR
198
+ if language.startswith("en")
199
+ else MAX_DURATION_OTHER_DIVISOR
200
+ )
201
+ abs_ceiling_anomaly = duration > max_possible
202
+ duration_anomaly = ratio_anomaly or abs_anomaly or rate_anomaly or abs_ceiling_anomaly
203
+ if duration_anomaly:
204
+ issues.append(
205
+ f"duration_anomaly: expected {min_sec:.2f}s-{max_sec:.2f}s, got {duration:.2f}s"
206
+ )
207
+
208
+ audits.append(
209
+ SegmentQualityAudit(
210
+ segment_index=index,
211
+ text=text,
212
+ duration=duration,
213
+ expected_duration_range=(min_sec, max_sec),
214
+ speech_rate_chars_per_sec=speech_rate,
215
+ transcription=transcription,
216
+ repetition_detected=repetition,
217
+ duration_anomaly=duration_anomaly,
218
+ fidelity_score=fidelity,
219
+ issues=issues,
220
+ )
221
+ )
222
+ critical_issues.extend(issues)
223
+
224
+ passed_count = sum(1 for audit in audits if not audit.issues)
225
+ overall_score = int((passed_count / len(audits)) * 100 + 0.5) if audits else 100
226
+ all_passed = len(critical_issues) == 0
227
+
228
+ return VoiceQualityReport(
229
+ passed=all_passed,
230
+ total_duration=round(total_duration, 2),
231
+ overall_score=overall_score,
232
+ segment_audits=audits,
233
+ critical_issues=critical_issues,
234
+ )