@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +120 -26
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +22187 -0
- package/plugins/generations/content-gen/plugin.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +22068 -0
- package/plugins/generations/core-facts-gen/plugin.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +22050 -0
- package/plugins/generations/daily-article-gen/plugin.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +13 -1
- package/plugins/generations/dailynews-gen/dist/index.js +22344 -0
- package/plugins/generations/dailynews-gen/plugin.json +1 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +22503 -0
- package/plugins/generations/episode-plan-gen/plugin.json +1 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
- package/plugins/generations/image-gen/config.example.yaml +75 -0
- package/plugins/generations/image-gen/dist/index.js +24862 -0
- package/plugins/generations/image-gen/package.json +17 -0
- package/plugins/generations/image-gen/plugin.json +7 -0
- package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
- package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
- package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
- package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
- package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
- package/plugins/generations/image-gen/src/bytes.ts +19 -0
- package/plugins/generations/image-gen/src/index.ts +319 -0
- package/plugins/generations/image-gen/src/job.ts +143 -0
- package/plugins/generations/image-gen/src/paths.ts +31 -0
- package/plugins/generations/image-gen/src/presets.ts +344 -0
- package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
- package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
- package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
- package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
- package/plugins/generations/image-gen/src/providers/google.ts +268 -0
- package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
- package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
- package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
- package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
- package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
- package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
- package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
- package/plugins/generations/image-gen/src/providers/types.ts +286 -0
- package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
- package/plugins/generations/image-gen/tsconfig.json +8 -0
- package/plugins/generations/news-report-gen/dist/index.js +22193 -0
- package/plugins/generations/news-report-gen/package.json +17 -0
- package/plugins/generations/news-report-gen/plugin.json +7 -0
- package/plugins/generations/news-report-gen/src/index.ts +308 -0
- package/plugins/generations/news-report-gen/tsconfig.json +4 -0
- package/plugins/generations/omni-voice-gen/Makefile +14 -0
- package/plugins/generations/omni-voice-gen/README.md +112 -0
- package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
- package/plugins/generations/omni-voice-gen/plugin.json +6 -0
- package/plugins/generations/omni-voice-gen/profiles.json +12 -0
- package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
- package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
- package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
- package/plugins/generations/voice-gen/dist/index.js +23055 -0
- package/plugins/generations/voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +16 -1
- package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +22378 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +22125 -0
- package/plugins/ingestions/horizon-ingest/plugin.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
- package/plugins/ingestions/karakeep-local/plugin.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +22070 -0
- package/plugins/ingestions/last30days-ingest/plugin.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +24399 -0
- package/plugins/ingestions/web-search/plugin.json +1 -1
- package/plugins/kk/commands/image-extract.md +40 -0
- package/plugins/kk/commands/image-generate.md +32 -0
- package/plugins/kk/config.example.yaml +80 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/skills/image-authoring/SKILL.md +257 -0
- package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
- package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
- package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
- package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
- package/plugins/kk/skills/image-authoring/references/style-extraction.md +139 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
- package/plugins/publishings/emdash-pub/dist/index.js +22263 -0
- package/plugins/publishings/emdash-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +22650 -0
- package/plugins/publishings/podcast-pub/plugin.json +8 -2
- package/plugins/publishings/podcast-pub/src/index.ts +18 -2
- package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
- package/plugins/publishings/qiita-pub/dist/index.js +22101 -0
- package/plugins/publishings/qiita-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +22323 -0
- package/plugins/publishings/surfdash-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
- package/plugins/publishings/zenn-pub/dist/index.js +22142 -0
- package/plugins/publishings/zenn-pub/plugin.json +1 -1
- package/plugins/sp/scripts/batch-preflight.mjs +346 -0
- package/plugins/sp/scripts/batch-preflight.ts +459 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
- package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
- package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
- package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
- package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
- package/plugins/sp/scripts/idea-handoff.mjs +22 -0
- package/plugins/sp/scripts/idea-handoff.ts +44 -0
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
- package/plugins/sp/scripts/inline-run-setup.ts +198 -0
- package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
- package/plugins/sp/scripts/pr-reviewing.ts +925 -0
- package/plugins/sp/scripts/quality-gate.mjs +179 -0
- package/plugins/sp/scripts/quality-gate.ts +217 -0
- package/plugins/sp/scripts/script-contract-check.ts +319 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
- package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
- package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
- package/plugins/sp/scripts/task-size-precheck.ts +175 -0
- package/plugins/sp/scripts/transition-shim-check.ts +238 -0
- package/plugins/sp/scripts/validate-commands.ts +689 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
- package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
- 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
|
+
)
|