@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,190 @@
|
|
|
1
|
+
"""WAV parse/concat with gaps + loudness-dip detector — Python port of voice-gen concat.ts
|
|
2
|
+
and the detectLoudnessDip half of qc.ts (spec layout: dip detection lives here, not qc.py).
|
|
3
|
+
|
|
4
|
+
D8.1 delta (bug fix, not a port): gaps_ms[i] is silence inserted BEFORE segment i. The
|
|
5
|
+
voice-gen `gapsMs[0] === 0 ? gapsMs[i] : gapsMs[i - 1]` special case is not replicated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
# Dip thresholds — identical to voice-gen qc.ts (D7).
|
|
14
|
+
DIP_RATIO = 0.4
|
|
15
|
+
DIP_SILENCE_FLOOR = 0.008
|
|
16
|
+
DIP_ABS_CEILING = 0.06
|
|
17
|
+
DIP_NEIGHBOR_FLOOR = 0.02
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ParsedWav:
|
|
22
|
+
audio_format: int
|
|
23
|
+
num_channels: int
|
|
24
|
+
sample_rate: int
|
|
25
|
+
byte_rate: int
|
|
26
|
+
block_align: int
|
|
27
|
+
bits_per_sample: int
|
|
28
|
+
data_bytes: bytes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_wav(buffer: bytes) -> ParsedWav:
|
|
32
|
+
"""Parse a RIFF WAVE buffer into its format fields and raw audio data."""
|
|
33
|
+
if len(buffer) < 44:
|
|
34
|
+
raise ValueError("Invalid WAV buffer: buffer too short")
|
|
35
|
+
|
|
36
|
+
riff_tag = buffer[0:4].decode("latin-1")
|
|
37
|
+
wave_tag = buffer[8:12].decode("latin-1")
|
|
38
|
+
if riff_tag != "RIFF" or wave_tag != "WAVE":
|
|
39
|
+
raise ValueError(f"Invalid WAV buffer: expected RIFF/WAVE header, got {riff_tag}/{wave_tag}")
|
|
40
|
+
|
|
41
|
+
offset = 12
|
|
42
|
+
format_info: dict[str, int] | None = None
|
|
43
|
+
data_bytes: bytes | None = None
|
|
44
|
+
|
|
45
|
+
while offset + 8 <= len(buffer):
|
|
46
|
+
chunk_id = buffer[offset : offset + 4].decode("latin-1")
|
|
47
|
+
(chunk_size,) = struct.unpack_from("<I", buffer, offset + 4)
|
|
48
|
+
chunk_data_offset = offset + 8
|
|
49
|
+
|
|
50
|
+
if chunk_id == "fmt ":
|
|
51
|
+
if chunk_size < 16:
|
|
52
|
+
raise ValueError("Invalid WAV fmt chunk size")
|
|
53
|
+
audio_format, num_channels, sample_rate, byte_rate, block_align, bits_per_sample = struct.unpack_from(
|
|
54
|
+
"<HHIIHH", buffer, chunk_data_offset
|
|
55
|
+
)
|
|
56
|
+
format_info = {
|
|
57
|
+
"audio_format": audio_format,
|
|
58
|
+
"num_channels": num_channels,
|
|
59
|
+
"sample_rate": sample_rate,
|
|
60
|
+
"byte_rate": byte_rate,
|
|
61
|
+
"block_align": block_align,
|
|
62
|
+
"bits_per_sample": bits_per_sample,
|
|
63
|
+
}
|
|
64
|
+
elif chunk_id == "data":
|
|
65
|
+
end = min(chunk_data_offset + chunk_size, len(buffer))
|
|
66
|
+
data_bytes = buffer[chunk_data_offset:end]
|
|
67
|
+
|
|
68
|
+
# Advance to next chunk (aligned to 2 bytes)
|
|
69
|
+
offset = chunk_data_offset + chunk_size + (1 if chunk_size % 2 == 1 else 0)
|
|
70
|
+
|
|
71
|
+
if format_info is None:
|
|
72
|
+
raise ValueError('Invalid WAV: missing "fmt " chunk')
|
|
73
|
+
if data_bytes is None:
|
|
74
|
+
raise ValueError('Invalid WAV: missing "data" chunk')
|
|
75
|
+
|
|
76
|
+
return ParsedWav(**format_info, data_bytes=data_bytes)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def concat_wavs(wav_buffers: list[bytes], gaps_ms: list[int] | None = None) -> bytes:
|
|
80
|
+
"""Concatenate WAV buffers in order; gaps_ms[i] is silence inserted before segment i (D8.1)."""
|
|
81
|
+
if not wav_buffers:
|
|
82
|
+
return b""
|
|
83
|
+
|
|
84
|
+
gaps = gaps_ms or []
|
|
85
|
+
parsed_wavs = [parse_wav(buf) for buf in wav_buffers]
|
|
86
|
+
first = parsed_wavs[0]
|
|
87
|
+
|
|
88
|
+
# Verify format compatibility
|
|
89
|
+
for i in range(1, len(parsed_wavs)):
|
|
90
|
+
current = parsed_wavs[i]
|
|
91
|
+
if current.sample_rate != first.sample_rate:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"WAV sample rate mismatch: expected {first.sample_rate} Hz, got {current.sample_rate} Hz"
|
|
94
|
+
)
|
|
95
|
+
if (
|
|
96
|
+
current.audio_format != first.audio_format
|
|
97
|
+
or current.num_channels != first.num_channels
|
|
98
|
+
or current.bits_per_sample != first.bits_per_sample
|
|
99
|
+
):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"WAV format mismatch between segment 0 and segment {i}: format {first.audio_format} vs "
|
|
102
|
+
f"{current.audio_format}, channels {first.num_channels} vs {current.num_channels}, bits "
|
|
103
|
+
f"{first.bits_per_sample} vs {current.bits_per_sample}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Compute total combined data length
|
|
107
|
+
total_data_length = 0
|
|
108
|
+
pieces: list[bytes] = []
|
|
109
|
+
|
|
110
|
+
for i, wav in enumerate(parsed_wavs):
|
|
111
|
+
if i > 0:
|
|
112
|
+
gap = gaps[i] if i < len(gaps) else 0
|
|
113
|
+
if gap > 0:
|
|
114
|
+
num_gap_bytes = int(first.sample_rate * (gap / 1000)) * first.block_align
|
|
115
|
+
if num_gap_bytes > 0:
|
|
116
|
+
pieces.append(bytes(num_gap_bytes))
|
|
117
|
+
total_data_length += num_gap_bytes
|
|
118
|
+
pieces.append(wav.data_bytes)
|
|
119
|
+
total_data_length += len(wav.data_bytes)
|
|
120
|
+
|
|
121
|
+
# Build the consolidated WAV buffer
|
|
122
|
+
header = struct.pack(
|
|
123
|
+
"<4sI4s4sIHHIIHH4sI",
|
|
124
|
+
b"RIFF",
|
|
125
|
+
36 + total_data_length,
|
|
126
|
+
b"WAVE",
|
|
127
|
+
b"fmt ",
|
|
128
|
+
16,
|
|
129
|
+
first.audio_format,
|
|
130
|
+
first.num_channels,
|
|
131
|
+
first.sample_rate,
|
|
132
|
+
first.byte_rate,
|
|
133
|
+
first.block_align,
|
|
134
|
+
first.bits_per_sample,
|
|
135
|
+
b"data",
|
|
136
|
+
total_data_length,
|
|
137
|
+
)
|
|
138
|
+
return header + b"".join(pieces)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def detect_loudness_dip(wav: bytes) -> tuple[bool, float | None]:
|
|
142
|
+
"""Detect a 'collapsed-volume' window: center RMS dips vs both neighbors.
|
|
143
|
+
|
|
144
|
+
Returns (dip, dip_at_sec). Any parse failure -> (False, None): a dip check must
|
|
145
|
+
never crash the run (voice-gen parity).
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
parsed = parse_wav(wav)
|
|
149
|
+
if parsed.bits_per_sample != 16 or parsed.num_channels < 1 or len(parsed.data_bytes) < 2:
|
|
150
|
+
return False, None
|
|
151
|
+
|
|
152
|
+
data = parsed.data_bytes
|
|
153
|
+
total_frames = len(data) // parsed.block_align
|
|
154
|
+
window_frames = parsed.sample_rate # 1s windows
|
|
155
|
+
if total_frames < window_frames * 5:
|
|
156
|
+
return False, None
|
|
157
|
+
n_wins = total_frames // window_frames
|
|
158
|
+
if n_wins < 7:
|
|
159
|
+
return False, None
|
|
160
|
+
|
|
161
|
+
bytes_per_frame = parsed.block_align
|
|
162
|
+
bytes_per_window = window_frames * bytes_per_frame
|
|
163
|
+
|
|
164
|
+
def window_rms(win_idx: int) -> float:
|
|
165
|
+
start = win_idx * bytes_per_window
|
|
166
|
+
sum_sq = 0.0
|
|
167
|
+
count = 0
|
|
168
|
+
for off in range(start, start + bytes_per_window, bytes_per_frame):
|
|
169
|
+
(sample,) = struct.unpack_from("<h", data, off)
|
|
170
|
+
value = sample / 32768.0
|
|
171
|
+
sum_sq += value * value
|
|
172
|
+
count += 1
|
|
173
|
+
return (sum_sq / count) ** 0.5 if count else 0.0
|
|
174
|
+
|
|
175
|
+
rms = [window_rms(i) for i in range(n_wins)]
|
|
176
|
+
for j in range(1, n_wins - 1):
|
|
177
|
+
prev_rms = rms[j - 1]
|
|
178
|
+
center = rms[j]
|
|
179
|
+
next_rms = rms[j + 1]
|
|
180
|
+
nbr = max(prev_rms, next_rms)
|
|
181
|
+
if (
|
|
182
|
+
center > DIP_SILENCE_FLOOR
|
|
183
|
+
and center < DIP_ABS_CEILING
|
|
184
|
+
and nbr > DIP_NEIGHBOR_FLOOR
|
|
185
|
+
and center < nbr * DIP_RATIO
|
|
186
|
+
):
|
|
187
|
+
return True, round(j + 0.5, 2)
|
|
188
|
+
return False, None
|
|
189
|
+
except Exception: # noqa: BLE001 — a dip check must never crash the run (voice-gen parity)
|
|
190
|
+
return False, None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Backend seam (D6): one in-process OmniVoice load renders every segment.
|
|
2
|
+
|
|
3
|
+
This is the only torch/omnivoice-importing module, behind the Backend Protocol so every
|
|
4
|
+
other module and all tests run without a GPU/model. torch/omnivoice imports stay lazy inside
|
|
5
|
+
methods; FakeBackend replays canned data and never touches the model stack. WAV codec is
|
|
6
|
+
stdlib `wave` only (float32 [-1,1] -> int16 via np.clip; 24kHz mono both directions).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import wave
|
|
14
|
+
from typing import Any, Protocol, TypedDict
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .profiles import ProfileEntry
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL = "k2-fsa/OmniVoice"
|
|
21
|
+
SAMPLE_RATE = 24000 # OmniVoice output rate; ASR transcribe is fixed at 24000 (task R4)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SegmentSpec(TypedDict):
|
|
25
|
+
text: str
|
|
26
|
+
language: str
|
|
27
|
+
profile: ProfileEntry
|
|
28
|
+
instruct: str | None
|
|
29
|
+
seed: int | None
|
|
30
|
+
speed: float | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Backend(Protocol):
|
|
34
|
+
# Concrete `raise` bodies (not `...`): coverage.py excludes ellipsis lines from
|
|
35
|
+
# measurement, which would leave these permanently "uncovered" for the D2 gate.
|
|
36
|
+
def load(self) -> None:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def generate(self, spec: SegmentSpec) -> tuple[bytes, float]: # wav bytes, duration sec
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def transcribe(self, wav: bytes, language: str) -> str: # lazy whisper; may raise
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _float_to_wav(audio: np.ndarray, sample_rate: int) -> bytes:
|
|
47
|
+
pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
|
|
48
|
+
buf = io.BytesIO()
|
|
49
|
+
with wave.open(buf, "wb") as wf:
|
|
50
|
+
wf.setnchannels(1)
|
|
51
|
+
wf.setsampwidth(2)
|
|
52
|
+
wf.setframerate(sample_rate)
|
|
53
|
+
wf.writeframes(pcm.tobytes())
|
|
54
|
+
return buf.getvalue()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _wav_to_float(wav: bytes) -> np.ndarray:
|
|
58
|
+
with wave.open(io.BytesIO(wav), "rb") as wf:
|
|
59
|
+
frames = wf.readframes(wf.getnframes())
|
|
60
|
+
return np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FakeBackend:
|
|
64
|
+
"""Canned test backend: replays wavs/durations/transcriptions by call order."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, wavs: list[bytes], durations: list[float], transcriptions: dict[int, str]) -> None:
|
|
67
|
+
self._wavs = wavs
|
|
68
|
+
self._durations = durations
|
|
69
|
+
self._transcriptions = transcriptions
|
|
70
|
+
self.calls: list[SegmentSpec] = []
|
|
71
|
+
self.loaded = False
|
|
72
|
+
self._transcribe_count = 0
|
|
73
|
+
|
|
74
|
+
def load(self) -> None:
|
|
75
|
+
self.loaded = True
|
|
76
|
+
|
|
77
|
+
def generate(self, spec: SegmentSpec) -> tuple[bytes, float]:
|
|
78
|
+
self.calls.append(spec)
|
|
79
|
+
idx = len(self.calls) - 1
|
|
80
|
+
return self._wavs[idx], self._durations[idx]
|
|
81
|
+
|
|
82
|
+
def transcribe(self, wav: bytes, language: str) -> str:
|
|
83
|
+
text = self._transcriptions.get(self._transcribe_count, "")
|
|
84
|
+
self._transcribe_count += 1
|
|
85
|
+
return text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _default_device() -> str:
|
|
89
|
+
import torch
|
|
90
|
+
|
|
91
|
+
if torch.cuda.is_available():
|
|
92
|
+
return "cuda"
|
|
93
|
+
if torch.backends.mps.is_available():
|
|
94
|
+
return "mps"
|
|
95
|
+
return "cpu"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OmnivoiceBackend:
|
|
99
|
+
"""Real backend: single `OmniVoice.from_pretrained` per instance; ASR lazy (D6/D7)."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, model: str | None = None, device: str | None = None, speed: float | None = None) -> None:
|
|
102
|
+
self._model_id = model or os.environ.get("OMNIVOICE_MODEL", DEFAULT_MODEL)
|
|
103
|
+
self._device = device or os.environ.get("OMNIVOICE_DEVICE")
|
|
104
|
+
self._speed = speed if speed is not None else float(os.environ.get("OMNIVOICE_SPEED", "1.0"))
|
|
105
|
+
self._asr_model = os.environ.get("OMNIVOICE_ASR_MODEL") # None -> omnivoice default whisper
|
|
106
|
+
self._model: Any = None
|
|
107
|
+
self._asr_loaded = False
|
|
108
|
+
|
|
109
|
+
def load(self) -> None:
|
|
110
|
+
if self._model is not None:
|
|
111
|
+
return
|
|
112
|
+
from omnivoice import OmniVoice
|
|
113
|
+
|
|
114
|
+
self._model = OmniVoice.from_pretrained(self._model_id, device_map=self._device or _default_device())
|
|
115
|
+
|
|
116
|
+
def generate(self, spec: SegmentSpec) -> tuple[bytes, float]:
|
|
117
|
+
if self._model is None:
|
|
118
|
+
raise RuntimeError("OmnivoiceBackend.load() must be called before generate()")
|
|
119
|
+
profile = spec["profile"]
|
|
120
|
+
kwargs: dict[str, Any] = {
|
|
121
|
+
"text": spec["text"],
|
|
122
|
+
"language": spec["language"], # straight through — OmniVoice validates (task R5)
|
|
123
|
+
"speed": spec["speed"] if spec["speed"] is not None else self._speed,
|
|
124
|
+
"normalize_text": False,
|
|
125
|
+
}
|
|
126
|
+
if profile.kind == "clone":
|
|
127
|
+
kwargs["ref_audio"] = profile.ref_audio
|
|
128
|
+
kwargs["ref_text"] = profile.ref_text
|
|
129
|
+
elif profile.kind == "prompt":
|
|
130
|
+
from omnivoice import VoiceClonePrompt
|
|
131
|
+
|
|
132
|
+
assert profile.prompt is not None # guaranteed by registry validation
|
|
133
|
+
kwargs["voice_clone_prompt"] = VoiceClonePrompt.load(profile.prompt)
|
|
134
|
+
else: # instruct — spec.instruct wins over the profile instruct
|
|
135
|
+
kwargs["instruct"] = spec["instruct"] if spec["instruct"] is not None else profile.instruct
|
|
136
|
+
if spec["seed"] is not None:
|
|
137
|
+
import torch
|
|
138
|
+
|
|
139
|
+
torch.manual_seed(spec["seed"])
|
|
140
|
+
audios = self._model.generate(**kwargs)
|
|
141
|
+
audio = audios[0]
|
|
142
|
+
return _float_to_wav(audio, SAMPLE_RATE), len(audio) / SAMPLE_RATE
|
|
143
|
+
|
|
144
|
+
def transcribe(self, wav: bytes, language: str) -> str:
|
|
145
|
+
if self._model is None:
|
|
146
|
+
raise RuntimeError("OmnivoiceBackend.load() must be called before transcribe()")
|
|
147
|
+
if not self._asr_loaded:
|
|
148
|
+
self._model.load_asr_model(model_name=self._asr_model)
|
|
149
|
+
self._asr_loaded = True
|
|
150
|
+
return str(self._model.transcribe((_wav_to_float(wav), SAMPLE_RATE)))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Pydantic contract mirroring @gobing-ai/kk-core Doc/Content (packages/kk-core/src/schemas.ts).
|
|
2
|
+
|
|
3
|
+
Doc requires only `id` + `body`; Content requires `body` with `format` defaulting to
|
|
4
|
+
"markdown". Unknown fields are ignored (zod-strip parity). Every violation surfaces as
|
|
5
|
+
ValueError so __main__ maps it onto the single fail-loud exit path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Reference(BaseModel):
|
|
17
|
+
"""Optional citation entry inside Content.references."""
|
|
18
|
+
|
|
19
|
+
url: str | None = None
|
|
20
|
+
title: str | None = None
|
|
21
|
+
cite: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Doc(BaseModel):
|
|
25
|
+
"""One ingested document; `body` is the primary text/markdown payload."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
body: str
|
|
29
|
+
title: str | None = None
|
|
30
|
+
sourceUri: str | None = None
|
|
31
|
+
mediaType: str | None = None
|
|
32
|
+
metadata: dict[str, Any] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Content(BaseModel):
|
|
36
|
+
"""Generator output validated by the kk executor's ContentSchema."""
|
|
37
|
+
|
|
38
|
+
body: str
|
|
39
|
+
title: str | None = None
|
|
40
|
+
format: str = "markdown"
|
|
41
|
+
outline: str | None = None
|
|
42
|
+
references: list[Reference] | None = None
|
|
43
|
+
metadata: dict[str, Any] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
NOTICE_CONTENT = Content(
|
|
47
|
+
title="Notice",
|
|
48
|
+
body="# Notice\nNo documents provided for generation.",
|
|
49
|
+
format="markdown",
|
|
50
|
+
references=[],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_doc_list(raw: str) -> list[Doc]:
|
|
55
|
+
"""Parse a Doc[] JSON string; raise ValueError carrying the reason on any violation."""
|
|
56
|
+
try:
|
|
57
|
+
value = json.loads(raw)
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
raise ValueError(f"input is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}") from exc
|
|
60
|
+
if not isinstance(value, list):
|
|
61
|
+
# ValueError, not TypeError: every parse failure must funnel into the single
|
|
62
|
+
# `Invalid DocList input:` exit path (task R4 contract).
|
|
63
|
+
raise ValueError( # noqa: TRY004
|
|
64
|
+
f"input must be a JSON array of Doc objects, got {type(value).__name__}"
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
return [Doc.model_validate(item) for item in value]
|
|
68
|
+
except ValidationError as exc:
|
|
69
|
+
first = exc.errors()[0]
|
|
70
|
+
loc = ".".join(str(part) for part in first.get("loc", ())) or "root"
|
|
71
|
+
raise ValueError(f"Doc validation failed at [{loc}]: {first['msg']}") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def content_to_json(content: Content) -> str:
|
|
75
|
+
"""2-space-indent JSON with nulls dropped — voice-gen JSON.stringify output parity."""
|
|
76
|
+
return json.dumps(content.model_dump(exclude_none=True), ensure_ascii=False, indent=2)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""VOICE_GEN_MP3 truthiness + ffmpeg transcode — Python port of voice-gen mp3.ts.
|
|
2
|
+
|
|
3
|
+
Same flags (`-y -hide_banner -loglevel error -i <wav> -codec:a libmp3lame -qscale:a 2`),
|
|
4
|
+
same fail-loud contract: missing input, missing ffmpeg, non-zero exit, or no output file
|
|
5
|
+
all raise with the voice-gen message text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
MP3_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_mp3_requested(env: Mapping[str, str] | None = None) -> bool:
|
|
18
|
+
"""True when the operator asked for a sibling MP3 (VOICE_GEN_MP3=true|1|yes|on)."""
|
|
19
|
+
raw = (os.environ if env is None else env).get("VOICE_GEN_MP3")
|
|
20
|
+
if raw is None or raw == "":
|
|
21
|
+
return False
|
|
22
|
+
return raw.strip().lower() in MP3_TRUTHY
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def transcode_wav_to_mp3(wav_path: str, mp3_path: str, ffmpeg_bin: str = "ffmpeg") -> None:
|
|
26
|
+
"""WAV → MP3 via ffmpeg + libmp3lame (VBR -qscale:a 2). Fail-loud on any error."""
|
|
27
|
+
if not os.path.exists(wav_path):
|
|
28
|
+
raise RuntimeError(f"ffmpeg mp3 transcode failed: WAV input not found: {wav_path}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
proc = subprocess.run(
|
|
32
|
+
[
|
|
33
|
+
ffmpeg_bin,
|
|
34
|
+
"-y",
|
|
35
|
+
"-hide_banner",
|
|
36
|
+
"-loglevel",
|
|
37
|
+
"error",
|
|
38
|
+
"-i",
|
|
39
|
+
wav_path,
|
|
40
|
+
"-codec:a",
|
|
41
|
+
"libmp3lame",
|
|
42
|
+
"-qscale:a",
|
|
43
|
+
"2",
|
|
44
|
+
mp3_path,
|
|
45
|
+
],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
50
|
+
except FileNotFoundError as err:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"ffmpeg is required for MP3 output (VOICE_GEN_MP3=true) but was not found on PATH ({err})"
|
|
53
|
+
) from err
|
|
54
|
+
|
|
55
|
+
if proc.returncode != 0:
|
|
56
|
+
detail = proc.stderr.strip() or proc.stdout.strip()
|
|
57
|
+
raise RuntimeError(f"ffmpeg mp3 transcode failed (exit {proc.returncode}): {detail or 'no stderr'}")
|
|
58
|
+
|
|
59
|
+
if not os.path.exists(mp3_path):
|
|
60
|
+
raise RuntimeError(f"ffmpeg mp3 transcode produced no file: {mp3_path}")
|