schnippo 0.11.0__py3-none-any.whl

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 (139) hide show
  1. schnippo/__init__.py +3 -0
  2. schnippo/acoustic.py +166 -0
  3. schnippo/analysis.py +183 -0
  4. schnippo/audio.py +267 -0
  5. schnippo/branding.py +26 -0
  6. schnippo/cache.py +61 -0
  7. schnippo/cache_admin.py +94 -0
  8. schnippo/config.py +181 -0
  9. schnippo/context.py +149 -0
  10. schnippo/cutlist.py +1358 -0
  11. schnippo/data/guides/01_rough_cut.md +69 -0
  12. schnippo/data/guides/02_fine_cut.md +45 -0
  13. schnippo/data/guides/03_broll.md +39 -0
  14. schnippo/data/guides/04_polish.md +40 -0
  15. schnippo/data/guides/05_motion_graphics.md +63 -0
  16. schnippo/data/guides/06_sound_design.md +31 -0
  17. schnippo/data/guides/07_reels.md +30 -0
  18. schnippo/data/guides/design_tokens.json +124 -0
  19. schnippo/data/guides/technik.md +86 -0
  20. schnippo/data/sfx/LICENSES.md +54 -0
  21. schnippo/data/sfx/analysis/groups.json +974 -0
  22. schnippo/data/sfx/library.json +860 -0
  23. schnippo/data/sfx/recorded.json +289 -0
  24. schnippo/data/sfx/wav/alarm_bell.wav +0 -0
  25. schnippo/data/sfx/wav/alarm_real.wav +0 -0
  26. schnippo/data/sfx/wav/click_mouse.wav +0 -0
  27. schnippo/data/sfx/wav/click_mouse_sharp.wav +0 -0
  28. schnippo/data/sfx/wav/click_mouse_soft.wav +0 -0
  29. schnippo/data/sfx/wav/clock_timer.wav +0 -0
  30. schnippo/data/sfx/wav/clock_timer_b.wav +0 -0
  31. schnippo/data/sfx/wav/ding_bell.wav +0 -0
  32. schnippo/data/sfx/wav/ding_bright.wav +0 -0
  33. schnippo/data/sfx/wav/ding_chime.wav +0 -0
  34. schnippo/data/sfx/wav/ding_glass.wav +0 -0
  35. schnippo/data/sfx/wav/ding_notify.wav +0 -0
  36. schnippo/data/sfx/wav/ding_real.wav +0 -0
  37. schnippo/data/sfx/wav/downer_tape.wav +0 -0
  38. schnippo/data/sfx/wav/downer_vinyl.wav +0 -0
  39. schnippo/data/sfx/wav/impact_boom.wav +0 -0
  40. schnippo/data/sfx/wav/impact_hit.wav +0 -0
  41. schnippo/data/sfx/wav/impact_soft_low.wav +0 -0
  42. schnippo/data/sfx/wav/impact_soft_mid.wav +0 -0
  43. schnippo/data/sfx/wav/pop_bright.wav +0 -0
  44. schnippo/data/sfx/wav/pop_mid.wav +0 -0
  45. schnippo/data/sfx/wav/pop_soft.wav +0 -0
  46. schnippo/data/sfx/wav/riser_impact.wav +0 -0
  47. schnippo/data/sfx/wav/riser_long.wav +0 -0
  48. schnippo/data/sfx/wav/riser_short.wav +0 -0
  49. schnippo/data/sfx/wav/riser_swell.wav +0 -0
  50. schnippo/data/sfx/wav/swish_bright.wav +0 -0
  51. schnippo/data/sfx/wav/swish_mid.wav +0 -0
  52. schnippo/data/sfx/wav/swish_soft.wav +0 -0
  53. schnippo/data/sfx/wav/tick_bright.wav +0 -0
  54. schnippo/data/sfx/wav/tick_mid.wav +0 -0
  55. schnippo/data/sfx/wav/tick_soft.wav +0 -0
  56. schnippo/data/sfx/wav/typing_mech.wav +0 -0
  57. schnippo/data/sfx/wav/typing_mech_b.wav +0 -0
  58. schnippo/data/sfx/wav/whoosh_big.wav +0 -0
  59. schnippo/data/sfx/wav/whoosh_end.wav +0 -0
  60. schnippo/data/sfx/wav/whoosh_mid.wav +0 -0
  61. schnippo/data/sfx/wav/whoosh_soft.wav +0 -0
  62. schnippo/delivery.py +237 -0
  63. schnippo/design.py +78 -0
  64. schnippo/edges.py +121 -0
  65. schnippo/errors.py +63 -0
  66. schnippo/export.py +201 -0
  67. schnippo/fcpxml.py +72 -0
  68. schnippo/ffmpeg_dist.py +32 -0
  69. schnippo/formats.py +149 -0
  70. schnippo/formatting.py +123 -0
  71. schnippo/frames.py +272 -0
  72. schnippo/framing.py +25 -0
  73. schnippo/glossary.py +182 -0
  74. schnippo/guard.py +125 -0
  75. schnippo/guides.py +48 -0
  76. schnippo/jobs.py +68 -0
  77. schnippo/kenburns.py +59 -0
  78. schnippo/keychain.py +35 -0
  79. schnippo/markers.py +80 -0
  80. schnippo/media.py +247 -0
  81. schnippo/mg/__init__.py +0 -0
  82. schnippo/mg/fonts/dm-sans-600.woff2 +0 -0
  83. schnippo/mg/fonts/dm-sans-800.woff2 +0 -0
  84. schnippo/mg/fonts/dm-sans-LICENSE.txt +93 -0
  85. schnippo/mg/fonts/instrument-sans-600.woff2 +0 -0
  86. schnippo/mg/fonts/instrument-sans-700.woff2 +0 -0
  87. schnippo/mg/fonts/instrument-sans-LICENSE.txt +93 -0
  88. schnippo/mg/fonts/inter-600.woff2 +0 -0
  89. schnippo/mg/fonts/inter-800.woff2 +0 -0
  90. schnippo/mg/fonts/inter-LICENSE.txt +93 -0
  91. schnippo/mg/fonts/manrope-600.woff2 +0 -0
  92. schnippo/mg/fonts/manrope-800.woff2 +0 -0
  93. schnippo/mg/fonts/manrope-LICENSE.txt +93 -0
  94. schnippo/mg/fonts/space-grotesk-600.woff2 +0 -0
  95. schnippo/mg/fonts/space-grotesk-700.woff2 +0 -0
  96. schnippo/mg/fonts/space-grotesk-LICENSE.txt +93 -0
  97. schnippo/mg/place.py +127 -0
  98. schnippo/mg/preview.py +142 -0
  99. schnippo/mg/render.py +254 -0
  100. schnippo/mg/templates.py +331 -0
  101. schnippo/mg/tokens.py +133 -0
  102. schnippo/migrate.py +38 -0
  103. schnippo/models.py +130 -0
  104. schnippo/onboarding.py +120 -0
  105. schnippo/passes.py +188 -0
  106. schnippo/prefs.py +103 -0
  107. schnippo/profiles.py +163 -0
  108. schnippo/projects.py +129 -0
  109. schnippo/py.typed +0 -0
  110. schnippo/qc.py +329 -0
  111. schnippo/reels.py +238 -0
  112. schnippo/reframe.py +296 -0
  113. schnippo/render.py +123 -0
  114. schnippo/resolve_bridge.py +1081 -0
  115. schnippo/retakes.py +127 -0
  116. schnippo/revise.py +479 -0
  117. schnippo/script.py +79 -0
  118. schnippo/segments.py +89 -0
  119. schnippo/sense.py +146 -0
  120. schnippo/server.py +1388 -0
  121. schnippo/setup_check.py +184 -0
  122. schnippo/sfx.py +380 -0
  123. schnippo/sfx_profile.py +250 -0
  124. schnippo/sfx_record.py +103 -0
  125. schnippo/sfx_sources.py +108 -0
  126. schnippo/snap.py +117 -0
  127. schnippo/subtitles.py +132 -0
  128. schnippo/toolinfo.py +18 -0
  129. schnippo/transcribe/__init__.py +0 -0
  130. schnippo/transcribe/base.py +41 -0
  131. schnippo/transcribe/gladia.py +188 -0
  132. schnippo/ui/card.html +289 -0
  133. schnippo/ui/design.js +168 -0
  134. schnippo/updates.py +71 -0
  135. schnippo/workspace.py +1721 -0
  136. schnippo-0.11.0.dist-info/METADATA +208 -0
  137. schnippo-0.11.0.dist-info/RECORD +139 -0
  138. schnippo-0.11.0.dist-info/WHEEL +4 -0
  139. schnippo-0.11.0.dist-info/entry_points.txt +3 -0
schnippo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """schnippo: AI rough-cut MCP server for DaVinci Resolve Studio."""
2
+
3
+ __version__ = "0.11.0"
schnippo/acoustic.py ADDED
@@ -0,0 +1,166 @@
1
+ """Acoustic analysis of the 16 kHz mono WAV (numpy, no Torch).
2
+
3
+ * RMS envelope (25 ms window, 10 ms hop) in dBFS.
4
+ * Noise floor = low percentile of the envelope; activity threshold = floor + delta.
5
+ This replaces the Phase-1 rule max(-35 dB, mean-18 dB), which on a quiet recording with a very low
6
+ room noise put the threshold *above* half of the speech frames.
7
+ * Voicing per frame = peak of the normalised autocorrelation in the 75–400 Hz lag range (40 ms window).
8
+ Voiced sounds ("äh", "hm", vowels) have a clear periodicity, breaths/clicks do not.
9
+
10
+ Regions of activity are classified as ``voiced`` (filler candidate), ``breath`` (unvoiced, breath-like
11
+ duration) or ``click`` (very short transient).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import wave
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ SR = 16000
23
+ HOP = 160 # 10 ms
24
+ WIN = 400 # 25 ms
25
+ VWIN = 640 # 40 ms voicing window
26
+ LAG_MIN, LAG_MAX = SR // 400, SR // 75 # 40 .. 213 samples
27
+
28
+
29
+ @dataclass
30
+ class AcousticParams:
31
+ floor_percentile: float = 10.0
32
+ delta_db: float = 25.0 # activity threshold above the noise floor
33
+ min_threshold_db: float = -60.0 # never below this (digital silence / heavy denoise)
34
+ max_threshold_db: float = -30.0
35
+ min_region_s: float = 0.06
36
+ merge_gap_s: float = 0.08
37
+ voiced_corr: float = 0.6 # frame is voiced if autocorrelation peak >= this
38
+ voiced_min_s: float = 0.15 # region needs at least this much voiced material (calibrated on real take)
39
+ voiced_ratio: float = 0.6 # ... and this share of its active frames voiced
40
+ click_max_s: float = 0.08
41
+
42
+ def key(self) -> list:
43
+ return [self.floor_percentile, self.delta_db, self.min_threshold_db, self.max_threshold_db, self.min_region_s,
44
+ self.merge_gap_s, self.voiced_corr, self.voiced_min_s, self.voiced_ratio, self.click_max_s, 2]
45
+
46
+
47
+ @dataclass
48
+ class Acoustic:
49
+ env_db: np.ndarray # per 10 ms frame
50
+ voicing: np.ndarray # per 10 ms frame, 0..1
51
+ floor_db: float
52
+ threshold_db: float
53
+ duration_s: float
54
+ params: AcousticParams = field(default_factory=AcousticParams)
55
+
56
+ # ---- frame helpers ----
57
+ def _sl(self, t0: float, t1: float) -> slice:
58
+ return slice(max(0, int(round(t0 * 100))), max(0, int(round(t1 * 100))))
59
+
60
+ def active(self, t0: float, t1: float) -> np.ndarray:
61
+ return self.env_db[self._sl(t0, t1)] > self.threshold_db
62
+
63
+ def regions(self, t0: float, t1: float) -> list[tuple[float, float]]:
64
+ """Active (non-silent) sub-intervals of [t0, t1]."""
65
+ act = self.active(t0, t1)
66
+ base = int(round(t0 * 100))
67
+ out: list[list[float]] = []
68
+ i = 0
69
+ while i < len(act):
70
+ if act[i]:
71
+ j = i
72
+ while j < len(act) and act[j]:
73
+ j += 1
74
+ a, b = (base + i) / 100, (base + j) / 100
75
+ if out and a - out[-1][1] <= self.params.merge_gap_s:
76
+ out[-1][1] = b
77
+ else:
78
+ out.append([a, b])
79
+ i = j
80
+ else:
81
+ i += 1
82
+ return [(max(a, t0), min(b, t1)) for a, b in out if b - a >= self.params.min_region_s]
83
+
84
+ def classify(self, t0: float, t1: float) -> dict:
85
+ sl = self._sl(t0, t1)
86
+ env, voi = self.env_db[sl], self.voicing[sl]
87
+ act = env > self.threshold_db
88
+ voiced = (voi >= self.params.voiced_corr) & act
89
+ vs = float(voiced.sum()) / 100
90
+ ratio = float(voiced.sum()) / max(1, int(act.sum()))
91
+ dur = t1 - t0
92
+ if vs >= self.params.voiced_min_s and ratio >= self.params.voiced_ratio:
93
+ cls = "voiced"
94
+ elif dur <= self.params.click_max_s:
95
+ cls = "click"
96
+ else:
97
+ cls = "breath"
98
+ return {"class": cls, "peak_db": round(float(env.max()) if len(env) else -120.0, 1),
99
+ "voiced_s": round(vs, 2), "voiced_ratio": round(ratio, 2)}
100
+
101
+ def silent_runs(self, t0: float, t1: float, min_s: float) -> list[tuple[float, float]]:
102
+ act = self.active(t0, t1)
103
+ base = int(round(t0 * 100))
104
+ out, i = [], 0
105
+ while i < len(act):
106
+ if not act[i]:
107
+ j = i
108
+ while j < len(act) and not act[j]:
109
+ j += 1
110
+ if (j - i) / 100 >= min_s:
111
+ out.append(((base + i) / 100, (base + j) / 100))
112
+ i = j
113
+ else:
114
+ i += 1
115
+ return out
116
+
117
+ # ---- persistence ----
118
+ def save(self, path: Path) -> None:
119
+ np.savez_compressed(path, env=self.env_db.astype(np.float32), voi=self.voicing.astype(np.float16),
120
+ meta=np.array([self.floor_db, self.threshold_db, self.duration_s]),
121
+ key=np.array(self.params.key(), dtype=float))
122
+
123
+ @classmethod
124
+ def load(cls, path: Path, params: AcousticParams) -> "Acoustic | None":
125
+ if not path.exists():
126
+ return None
127
+ d = np.load(path)
128
+ if list(d["key"]) != [float(x) for x in params.key()]:
129
+ return None
130
+ floor, thr, dur = (float(x) for x in d["meta"])
131
+ return cls(d["env"].astype(np.float64), d["voi"].astype(np.float64), floor, thr, dur, params)
132
+
133
+
134
+ def read_wav(path: Path) -> np.ndarray:
135
+ with wave.open(str(path)) as w:
136
+ if w.getframerate() != SR or w.getnchannels() != 1 or w.getsampwidth() != 2:
137
+ raise ValueError("expected 16 kHz mono 16-bit WAV")
138
+ return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float64) / 32768.0
139
+
140
+
141
+ def _frames(xp: np.ndarray, win: int, i0: int, i1: int) -> np.ndarray:
142
+ """Frames i0..i1 (hop 10 ms) of the padded signal xp (padded by win//2 in front)."""
143
+ idx = np.arange(win)[None, :] + (np.arange(i0, i1) * HOP)[:, None]
144
+ return xp[idx]
145
+
146
+
147
+ def analyze_wav(path: Path, params: AcousticParams | None = None) -> Acoustic:
148
+ params = params or AcousticParams()
149
+ x = read_wav(path)
150
+ n = 1 + max(0, (len(x) - 1) // HOP)
151
+ xp_e = np.pad(x, (WIN // 2, WIN))
152
+ xp_v = np.pad(x, (VWIN // 2, VWIN))
153
+ env = np.empty(n)
154
+ voi = np.empty(n)
155
+ nfft, hann = 1024, np.hanning(VWIN)[None, :]
156
+ for i0 in range(0, n, 4096): # chunked: bounded memory for hour-long material
157
+ i1 = min(n, i0 + 4096)
158
+ fe = _frames(xp_e, WIN, i0, i1)
159
+ env[i0:i1] = 10 * np.log10(np.mean(fe * fe, axis=1) + 1e-12)
160
+ fv = _frames(xp_v, VWIN, i0, i1)
161
+ fv = (fv - fv.mean(axis=1, keepdims=True)) * hann
162
+ ac = np.fft.irfft(np.abs(np.fft.rfft(fv, nfft)) ** 2, nfft)[:, :LAG_MAX + 1]
163
+ voi[i0:i1] = np.clip(ac[:, LAG_MIN:LAG_MAX + 1].max(axis=1) / (ac[:, 0] + 1e-12), 0, 1)
164
+ floor = float(np.percentile(env, params.floor_percentile))
165
+ thr = float(np.clip(floor + params.delta_db, params.min_threshold_db, params.max_threshold_db))
166
+ return Acoustic(env, voi, floor, thr, len(x) / SR, params)
schnippo/analysis.py ADDED
@@ -0,0 +1,183 @@
1
+ """Deterministic base analysis: fillers, pauses, gap fallback, repetitions, restart candidates.
2
+
3
+ Output is flags + findings only. Nothing here decides what gets cut.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import unicodedata
10
+
11
+ from .acoustic import Acoustic
12
+ from .config import Config
13
+ from .models import AnalyzedTranscript, Finding, RawTranscript, Word
14
+
15
+ _PUNCT = re.compile(r"[^\wäöüß']+", re.UNICODE)
16
+
17
+
18
+ def norm(text: str) -> str:
19
+ t = unicodedata.normalize("NFC", text).lower().strip()
20
+ return _PUNCT.sub("", t)
21
+
22
+
23
+ def _gap_id(prefix: str, prev_num: int, k: int) -> str:
24
+ return f"{prefix}{prev_num:04d}" + (f".{k}" if k > 1 else "")
25
+
26
+
27
+ def analyze(clip_id: str, raw: RawTranscript, cfg: Config, acoustic: Acoustic | None = None,
28
+ clip_duration_s: float | None = None) -> AnalyzedTranscript:
29
+ from .config import EN_HARD_FILLERS
30
+ hard = {norm(f) for f in cfg.hard_fillers} | ({norm(f) for f in EN_HARD_FILLERS}
31
+ if (raw.language or "").lower().startswith("en") else set())
32
+ soft = {norm(f) for f in cfg.soft_fillers}
33
+
34
+ tokens: list[Word] = []
35
+ for t in raw.tokens:
36
+ w = t.model_copy(deep=True)
37
+ n = norm(w.text)
38
+ if n in hard or re.fullmatch(r"(ä+h+m*|ö+h+m*|e+h+m+|h+m+|m+h+m+)", n or "-"):
39
+ w.kind = "filler"
40
+ elif n in soft:
41
+ w.flags.append("soft_filler")
42
+ tokens.append(w)
43
+
44
+ # --- word edges stretched over silence (Gladia sometimes adds a pause to a word, see NOTES) ---
45
+ if acoustic is not None:
46
+ for w in tokens:
47
+ runs = acoustic.silent_runs(w.start_s, w.end_s, cfg.clamp_silence_s)
48
+ if not runs:
49
+ continue
50
+ # silence followed by at most a short onset of the next word -> the word really ended before it
51
+ if w.end_s - runs[-1][1] <= 0.3:
52
+ w.flags.append(f"end_clamped:{w.end_s:.2f}")
53
+ w.end_s = round(runs[-1][0], 3)
54
+ elif runs[0][0] - w.start_s <= 0.3:
55
+ w.flags.append(f"start_clamped:{w.start_s:.2f}")
56
+ w.start_s = round(runs[0][1], 3)
57
+
58
+ # --- gaps: pauses and speech without a recognised word (filler fallback) ---
59
+ entries: list[Word] = []
60
+ findings: list[Finding] = []
61
+ n_inferred = 0
62
+ bounds = [(0.0, None)] + [(t.end_s, t) for t in tokens]
63
+ for i, (gap_start, prev) in enumerate(bounds):
64
+ nxt = tokens[i] if i < len(tokens) else None
65
+ gap_end = nxt.start_s if nxt else (clip_duration_s if clip_duration_s is not None else gap_start)
66
+ prev_num = int(prev.id[1:]) if prev else 0
67
+ if prev:
68
+ entries.append(prev)
69
+ if gap_end - gap_start <= 1e-3:
70
+ continue
71
+ pieces: list[tuple[str, float, float, dict]] = [] # (kind, t0, t1, info)
72
+ cur = gap_start
73
+ for a, b in (acoustic.regions(gap_start, gap_end) if acoustic is not None else []):
74
+ # activity that merely continues a neighbouring word (ASR edges are tight) is not an event
75
+ tp, tn = bool(prev) and a - gap_start <= 0.04, bool(nxt) and gap_end - b <= 0.04
76
+ if tp and tn and b - a < cfg.bridge_min_s: # voice runs through the whole gap: coarticulation
77
+ continue
78
+ if (tp or tn) and b - a < cfg.hug_max_s:
79
+ continue
80
+ info = acoustic.classify(a, b)
81
+ if info["class"] == "click":
82
+ continue
83
+ if info["class"] == "voiced" and b - a < cfg.gap_speech_min_s:
84
+ continue
85
+ if a - cur > 0:
86
+ pieces.append(("pause", cur, a, {}))
87
+ pieces.append((info["class"], a, b, info))
88
+ cur = b
89
+ if gap_end - cur > 0:
90
+ pieces.append(("pause", cur, gap_end, {}))
91
+ kp = ku = kb = 0
92
+ for kind, a, b, info in pieces:
93
+ if kind == "pause":
94
+ if b - a < cfg.pause_threshold_s:
95
+ continue
96
+ kp += 1
97
+ flags = ["long_pause"] if b - a >= cfg.long_pause_s else []
98
+ if prev is None:
99
+ flags.append("leading")
100
+ if nxt is None:
101
+ flags.append("trailing")
102
+ entries.append(Word(id=_gap_id("p", prev_num, kp), text=f"{b - a:.1f}s", start_s=round(a, 3),
103
+ end_s=round(b, 3), kind="pause", flags=flags))
104
+ elif kind == "breath":
105
+ kb += 1
106
+ entries.append(Word(id=_gap_id("b", prev_num, kb), text="breath", start_s=round(a, 3), end_s=round(b, 3),
107
+ kind="breath", flags=["inferred"]))
108
+ else:
109
+ ku += 1
110
+ n_inferred += 1
111
+ uid = _gap_id("u", prev_num, ku)
112
+ entries.append(Word(id=uid, text="?", start_s=round(a, 3), end_s=round(b, 3), kind="filler",
113
+ flags=["inferred", "filler?"]))
114
+ findings.append(Finding(type="unrecognized_speech", first=(uid, uid),
115
+ note=f"stimmhaft ohne Wort {a:.2f}-{b:.2f}s, {info['peak_db']} dB (Füllwort/Dehnung?)"))
116
+
117
+ # --- repetitions / restarts over content tokens (fillers + gap entries ignored) ---
118
+ content = [t for t in tokens if t.kind == "word"]
119
+ keys = [norm(t.text) for t in content]
120
+ flagged_until = -1
121
+ for i in range(len(content)):
122
+ if i <= flagged_until:
123
+ continue
124
+ best = None
125
+ for n in range(6, 0, -1):
126
+ if i + n > len(keys):
127
+ continue
128
+ gram = keys[i:i + n]
129
+ if not all(gram):
130
+ continue
131
+ # the repeat must start soon after the first occurrence ends (restart = "Neuanfang")
132
+ window = 3 if n == 1 else n + 8
133
+ for j in range(i + n, min(i + n + window, len(keys) - n + 1)):
134
+ if keys[j:j + n] == gram:
135
+ if content[j].start_s - content[i + n - 1].end_s > 12.0:
136
+ break
137
+ best = (n, j)
138
+ break
139
+ if best:
140
+ break
141
+ if not best:
142
+ continue
143
+ n, j = best
144
+ first = (content[i].id, content[i + n - 1].id)
145
+ second = (content[j].id, content[j + n - 1].id)
146
+ between = j - (i + n)
147
+ if n == 1 and between == 0:
148
+ ftype, flag = "repeat", "repeat"
149
+ note = f"„{content[i].text} {content[j].text}“"
150
+ elif n == 1:
151
+ continue # single word recurring a few words later: too common in normal speech
152
+ else:
153
+ ftype, flag = "restart_candidate", "restart_candidate"
154
+ note = f"„{' '.join(t.text for t in content[i:i + n])}“ wiederholt" + (
155
+ f" nach {between} Wort/Wörtern" if between else "")
156
+ for t in content[i:j] if ftype == "restart_candidate" else content[i:i + n]:
157
+ if flag not in t.flags:
158
+ t.flags.append(flag)
159
+ findings.append(Finding(type=ftype, first=first, second=second, note=note))
160
+ flagged_until = i + n - 1
161
+
162
+ for e in entries:
163
+ if "long_pause" in e.flags:
164
+ findings.append(Finding(type="long_pause", first=(e.id, e.id), note=e.text))
165
+
166
+ stats = {
167
+ "words": sum(1 for t in tokens if t.kind == "word"),
168
+ "fillers_asr": sum(1 for t in tokens if t.kind == "filler"),
169
+ "fillers_inferred": n_inferred,
170
+ "soft_fillers": sum(1 for t in tokens if "soft_filler" in t.flags),
171
+ "breaths": sum(1 for e in entries if e.kind == "breath"),
172
+ "pauses": sum(1 for e in entries if e.kind == "pause"),
173
+ "repeats": sum(1 for f in findings if f.type == "repeat"),
174
+ "restart_candidates": sum(1 for f in findings if f.type == "restart_candidate"),
175
+ }
176
+ findings.sort(key=lambda f: _sort_key(f.first[0]))
177
+ return AnalyzedTranscript(clip_id=clip_id, provider=raw.provider, language=raw.language,
178
+ entries=entries, findings=findings, stats=stats)
179
+
180
+
181
+ def _sort_key(entry_id: str) -> tuple[int, int]:
182
+ m = re.match(r"[wpub](\d+)(?:\.(\d+))?", entry_id)
183
+ return (int(m.group(1)), 0 if entry_id[0] == "w" else int(m.group(2) or 1)) if m else (0, 0)
schnippo/audio.py ADDED
@@ -0,0 +1,267 @@
1
+ """Deterministic audio measurement: loudness (EBU R128 via ffmpeg ebur128), true peak, noise floor and a speech
2
+ spectral profile compared to a neutral long-term speech spectrum. Recommendations only – Resolve applies them.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ from .media import _bin
14
+
15
+ SR = 48000
16
+ # Long-term average speech spectrum, 1/3-octave levels in dB (approximation of Byrne et al. 1994, male/female average).
17
+ LTASS = {50: 32, 63: 38, 80: 43, 100: 47, 125: 53, 160: 57, 200: 58, 250: 59, 315: 59, 400: 58, 500: 57, 630: 55,
18
+ 800: 53, 1000: 51, 1250: 49, 1600: 48, 2000: 47, 2500: 45, 3150: 44, 4000: 43, 5000: 42, 6300: 41, 8000: 39,
19
+ 10000: 37}
20
+ BANDS = {"rumble": (50, 63), "boxy": (200, 250, 315, 400, 500), "sibilance": (5000, 6300, 8000)}
21
+ REF_NORM = (500, 630, 800, 1000, 1250, 1600, 2000) # both spectra are aligned on this range
22
+
23
+
24
+ def ebur128(input_args: list[str], filter_complex: str | None = None) -> dict:
25
+ """Integrated loudness (LUFS), loudness range (LU) and true peak (dBTP) of an ffmpeg input."""
26
+ cmd = [_bin("ffmpeg"), "-nostdin", "-hide_banner", "-v", "info", *input_args]
27
+ if filter_complex:
28
+ cmd += ["-filter_complex", filter_complex + ",ebur128=peak=true[out]", "-map", "[out]"]
29
+ else:
30
+ cmd += ["-map", "0:a:0", "-af", "ebur128=peak=true"]
31
+ err = subprocess.run(cmd + ["-f", "null", "-"], capture_output=True, text=True, encoding="utf-8", errors="replace").stderr
32
+ summ = err[err.rfind("Summary:"):]
33
+ get = lambda pat: float(m.group(1)) if (m := re.search(pat, summ, re.S)) else None
34
+ return {"lufs": get(r"I:\s+(-?[\d.]+) LUFS"), "lra": get(r"LRA:\s+(-?[\d.]+) LU"), "true_peak": get(r"Peak:\s+(-?[\d.inf]+) dBFS")}
35
+
36
+
37
+ def program_filter(pieces: list[tuple[float, float]]) -> str:
38
+ """ffmpeg filter that concatenates source time ranges of input 0 (the planned cut, without Resolve)."""
39
+ parts = [f"[0:a:0]atrim=start={a:.5f}:end={b:.5f},asetpts=PTS-STARTPTS[p{k}]" for k, (a, b) in enumerate(pieces)]
40
+ return ";".join(parts) + ";" + "".join(f"[p{k}]" for k in range(len(pieces))) + f"concat=n={len(pieces)}:v=0:a=1"
41
+
42
+
43
+ def read_pcm(path: Path) -> np.ndarray:
44
+ raw = subprocess.run([_bin("ffmpeg"), "-nostdin", "-v", "error", "-i", str(path), "-map", "0:a:0", "-ac", "1", "-ar", str(SR),
45
+ "-f", "s16le", "-"], capture_output=True, check=True).stdout
46
+ return np.frombuffer(raw, np.int16).astype(np.float64) / 32768.0
47
+
48
+
49
+ def third_octave_levels(x: np.ndarray, active: list[tuple[float, float]]) -> dict[int, float]:
50
+ """Mean power per 1/3-octave band over the active (speech) intervals, in dB."""
51
+ nfft = 4096
52
+ win = np.hanning(nfft)
53
+ acc, n = np.zeros(nfft // 2 + 1), 0
54
+ for a, b in active:
55
+ seg = x[int(a * SR):int(b * SR)]
56
+ for s in range(0, len(seg) - nfft, nfft // 2):
57
+ acc += np.abs(np.fft.rfft(seg[s:s + nfft] * win)) ** 2
58
+ n += 1
59
+ if n == 0:
60
+ return {}
61
+ psd = acc / n
62
+ freqs = np.fft.rfftfreq(nfft, 1 / SR)
63
+ out = {}
64
+ for fc in LTASS:
65
+ lo, hi = fc / 2 ** (1 / 6), fc * 2 ** (1 / 6)
66
+ sel = (freqs >= lo) & (freqs < hi)
67
+ out[fc] = float(10 * np.log10(psd[sel].sum() + 1e-20)) if sel.any() else -200.0
68
+ return out
69
+
70
+
71
+ def spectral_profile(levels: dict[int, float]) -> dict:
72
+ """Band deviations (dB) of measured vs LTASS after aligning both on 500–2000 Hz."""
73
+ if not levels:
74
+ return {}
75
+ off = np.mean([levels[f] for f in REF_NORM]) - np.mean([LTASS[f] for f in REF_NORM])
76
+ dev = {f: levels[f] - off - LTASS[f] for f in LTASS}
77
+ band = lambda fs: 10 * np.log10(np.mean([10 ** (dev[f] / 10) for f in fs]))
78
+ return {"rumble_db": round(float(band(BANDS["rumble"])), 1), "boxy_db": round(float(band(BANDS["boxy"])), 1),
79
+ "sibilance_db": round(float(band(BANDS["sibilance"])), 1), "per_band": {f: round(float(v), 1) for f, v in dev.items()}}
80
+
81
+
82
+ def recommendations(m: dict, target_lufs: float = -14.0, tp_max: float = -1.0) -> list[str]:
83
+ rec = []
84
+ if m.get("lufs") is not None:
85
+ rec.append(f"Normalisieren: {m['lufs']:.1f} → {target_lufs:.1f} LUFS ({target_lufs - m['lufs']:+.1f} dB)")
86
+ if m.get("true_peak") is not None and m["true_peak"] + (target_lufs - m["lufs"]) > tp_max:
87
+ rec.append(f"Limiter nötig: True Peak nach Verstärkung ≈ {m['true_peak'] + target_lufs - m['lufs']:+.1f} dBTP > {tp_max} dBTP "
88
+ "(Server: Limiter → Multiband Compressor, True Peak ≈ −2,8 dBTP)")
89
+ sp = m.get("spectrum") or {}
90
+ if sp:
91
+ if sp["rumble_db"] > 3: # 04_polish: every EQ measure needs a reason from the measurement
92
+ rec.append(f"Hochpass ~80 Hz: Rumpeln {sp['rumble_db']:+.1f} dB über Referenz")
93
+ else:
94
+ rec.append(f"Kein Hochpass nötig: Rumpeln unauffällig ({sp['rumble_db']:+.1f} dB)")
95
+ if sp["boxy_db"] > 3:
96
+ rec.append(f"Leichte Absenkung 200–500 Hz (−2 bis −3 dB): dumpf/boxy {sp['boxy_db']:+.1f} dB über Referenz")
97
+ if sp["sibilance_db"] > 4:
98
+ rec.append(f"De-Esser: Zischlaute 5–8 kHz {sp['sibilance_db']:+.1f} dB über Referenz")
99
+ return rec
100
+
101
+
102
+ def identical_audio_streams(path: str, seconds: float = 30.0) -> bool | None:
103
+ """True if the file has ≥ 2 audio streams and stream 2 equals stream 1 (difference < −80 dB over the first seconds).
104
+ Phones/apps often record the same mic twice; Resolve then puts both on A1/A2 and the programme is doubled."""
105
+ import json as _json
106
+
107
+ info = _json.loads(subprocess.run([_bin("ffprobe"), "-v", "error", "-select_streams", "a", "-show_entries", "stream=index",
108
+ "-of", "json", path], capture_output=True, text=True).stdout or "{}")
109
+ if len(info.get("streams", [])) < 2:
110
+ return None
111
+ err = subprocess.run([_bin("ffmpeg"), "-nostdin", "-v", "info", "-t", str(seconds), "-i", path, "-filter_complex",
112
+ "[0:a:0][0:a:1]amerge=inputs=2,pan=mono|c0=c0-c1,volumedetect", "-f", "null", "-"],
113
+ capture_output=True, text=True, encoding="utf-8", errors="replace").stderr
114
+ m = re.search(r"max_volume: (-?[\d.inf]+) dB", err)
115
+ return bool(m) and (m.group(1) == "-inf" or float(m.group(1)) < -80.0)
116
+
117
+
118
+ # ---------- phase 6: source channels ----------
119
+ CH_SR = 16000
120
+ SAFETY_SNR_DB = 1.5 # SNR closer than this on two correlated channels → same mic, different gain
121
+ CLIP_SAMPLES = 10 # more full-scale samples than this in the analysed span → channel counts as clipped
122
+
123
+
124
+ def source_channels(path: str) -> list[tuple[int, int]]:
125
+ """(audio stream ordinal, channel in stream) in Resolve's numbering order: channel k+1 = k-th entry
126
+ (embedded_audio_channels counts across streams, measured on 21.1.2 with 2 mono AAC streams)."""
127
+ import json as _json
128
+
129
+ info = _json.loads(subprocess.run([_bin("ffprobe"), "-v", "error", "-select_streams", "a", "-show_entries",
130
+ "stream=index,channels", "-of", "json", path], capture_output=True, text=True).stdout or "{}")
131
+ return [(s, c) for s, st in enumerate(info.get("streams", [])) for c in range(int(st.get("channels") or 1))]
132
+
133
+
134
+ def _channel_pcm(path: str, stream: int, ch: int, seconds: float) -> np.ndarray:
135
+ raw = subprocess.run([_bin("ffmpeg"), "-nostdin", "-v", "error", "-t", str(seconds), "-i", path, "-map", f"0:a:{stream}",
136
+ "-af", f"pan=mono|c0=c{ch}", "-ar", str(CH_SR), "-f", "s16le", "-"], capture_output=True).stdout
137
+ return np.frombuffer(raw, np.int16).astype(np.float64) / 32768.0
138
+
139
+
140
+ def _max_corr(a: np.ndarray, b: np.ndarray, max_lag: int) -> float:
141
+ """Highest Pearson correlation over lags ±max_lag samples (spaced stereo mics are up to ~1–2 ms apart)."""
142
+ m = min(len(a), len(b), 60 * CH_SR)
143
+ a, b = a[:m] - a[:m].mean(), b[:m] - b[:m].mean()
144
+ if m < 2 * max_lag + 2 or a.std() == 0 or b.std() == 0:
145
+ return 0.0
146
+ best = 0.0
147
+ for lag in range(-max_lag, max_lag + 1):
148
+ x, y = (a[lag:], b[:m - lag]) if lag >= 0 else (a[:m + lag], b[-lag:])
149
+ c = float(np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12))
150
+ best = max(best, c)
151
+ return best
152
+
153
+
154
+ def channel_analysis(path: str, seconds: float = 180.0, unbalanced_db: float = 3.0) -> dict:
155
+ """Per source channel: level (RMS, LUFS), speech level (p90 of 50-ms RMS), noise floor (p10), speech/noise ratio;
156
+ correlation between channels (best over ±2 ms lag); class identisch | unausgewogen | verschieden | stereo | mono; best channel.
157
+
158
+ Classes: corr ≥ 0.999 and < 0.1 dB apart → identisch; level or noise floor > unbalanced_db apart (corr ≥ 0.5)
159
+ → unausgewogen; corr < 0.5 → verschieden (two mics, e.g. lavalier + camera); otherwise → stereo (kept).
160
+ Best = highest speech/noise ratio, ties → higher speech level."""
161
+ chans = source_channels(path)
162
+ if not chans:
163
+ return {"channels": [], "class": "none", "best": None, "reason": "keine Tonspur"}
164
+ win = CH_SR // 20
165
+ stats, pcms = [], []
166
+ for k, (s, c) in enumerate(chans, 1):
167
+ x = _channel_pcm(path, s, c, seconds)
168
+ pcms.append(x)
169
+ n = len(x) // win
170
+ rms = np.sqrt(np.mean(x[:n * win].reshape(n, win) ** 2, axis=1) + 1e-12) if n else np.array([1e-6])
171
+ db = 20 * np.log10(rms)
172
+ speech, floor = float(np.percentile(db, 90)), float(np.percentile(db, 10))
173
+ loud = ebur128(["-t", str(seconds), "-i", path], f"[0:a:{s}]pan=mono|c0=c{c}")
174
+ stats.append({"channel": k, "stream": s, "in_stream": c, "rms_db": round(float(20 * np.log10(np.sqrt(np.mean(x ** 2)) + 1e-12)), 1),
175
+ "lufs": loud.get("lufs"), "speech_db": round(speech, 1), "noise_db": round(floor, 1),
176
+ "snr_db": round(speech - floor, 1),
177
+ "clipped": int(np.sum(np.abs(x) >= 0.97)) if len(x) else 0}) # 16-kHz resample rounds the plateaus
178
+ out = {"channels": stats, "correlation": None}
179
+ if len(stats) == 1:
180
+ out.update({"class": "mono", "best": 1, "reason": "nur ein Kanal"})
181
+ return out
182
+ corr = _max_corr(pcms[0], pcms[1], max_lag=int(0.002 * CH_SR))
183
+ out["correlation"] = round(corr, 4)
184
+ s1, s2 = stats[0], stats[1]
185
+ level_gap, noise_gap = abs(s1["speech_db"] - s2["speech_db"]), abs(s1["noise_db"] - s2["noise_db"])
186
+ best = max(stats[:2], key=lambda s: (s["snr_db"], s["speech_db"]))
187
+ safety = None
188
+ loud_ch = s1 if s1["speech_db"] >= s2["speech_db"] else s2
189
+ if corr >= 0.5 and level_gap > unbalanced_db and (abs(s1["snr_db"] - s2["snr_db"]) < SAFETY_SNR_DB
190
+ or loud_ch.get("clipped", 0) > CLIP_SAMPLES):
191
+ # same mic, one channel recorded quieter (safety track, typically −6 dB): the SNR is equal, so take the louder
192
+ # main channel – unless it clips (that is what the safety track is for). Phase 8: the choice flipped per clip.
193
+ main, quiet = (s1, s2) if s1["speech_db"] >= s2["speech_db"] else (s2, s1)
194
+ best = quiet if main.get("clipped", 0) > CLIP_SAMPLES else main
195
+ safety = (f"Sicherheitsspur: Kanal {quiet['channel']} ist {level_gap:.1f} dB leiser bei gleichem Rauschabstand → "
196
+ + (f"Kanal {main['channel']} übersteuert ({main['clipped']} Samples), deshalb die leise Spur"
197
+ if best is quiet else f"lauter Hauptkanal {main['channel']}"))
198
+ if corr >= 0.999 and level_gap < 0.1 and noise_gap < 0.1:
199
+ cls, reason = "identisch", f"Kanäle identisch (Korrelation {corr:.4f}) → Kanal {best['channel']} mittig, Kopie weg"
200
+ elif corr >= 0.5 and (level_gap > unbalanced_db or noise_gap > unbalanced_db):
201
+ cls = "unausgewogen"
202
+ reason = safety or (f"Kanal {best['channel']}: Sprache {best['speech_db']} dB, Rauschen {best['noise_db']} dB "
203
+ f"(Abstand {best['snr_db']} dB) – Pegelunterschied {level_gap:.1f} dB, Rauschunterschied {noise_gap:.1f} dB")
204
+ elif corr < 0.5:
205
+ cls = "verschieden"
206
+ other = s2 if best is s1 else s1
207
+ reason = (f"zwei verschiedene Mikrofone (Korrelation {corr:.2f}); Kanal {best['channel']} hat das bessere "
208
+ f"Sprach/Rausch-Verhältnis ({best['snr_db']} dB gegenüber {other['snr_db']} dB)")
209
+ else:
210
+ cls, best, reason = "stereo", None, f"echtes Stereo (Korrelation {corr:.2f}, Pegel ausgeglichen) → bleibt Stereo"
211
+ out.update({"class": cls, "best": best["channel"] if best else None, "reason": reason})
212
+ return out
213
+
214
+
215
+ def speaker_channels(path: str, spans: dict, min_gap_db: float = 6.0, max_s: float = 90.0) -> dict:
216
+ """Interview with one mic per person: which source channel carries which speaker. spans = {speaker: [(t0, t1), …]}
217
+ (word times from the diarized transcript). Per speaker the speech level (RMS over its words, up to max_s) per channel;
218
+ a speaker gets its own channel if that channel is ≥ min_gap_db louder for it. Returns {"by_speaker": {spk: channel},
219
+ "levels": {spk: [dB per channel]}, "split": bool}."""
220
+ chans = source_channels(path)
221
+ if len(chans) < 2 or len(spans) < 2:
222
+ return {"by_speaker": {}, "levels": {}, "split": False}
223
+ pcm = [_channel_pcm(path, s, c, 1e6) for s, c in chans[:2]]
224
+ levels, best = {}, {}
225
+ for spk, sp in spans.items():
226
+ acc, used = [[], []], 0.0
227
+ for t0, t1 in sp:
228
+ if used >= max_s:
229
+ break
230
+ a, b = int(t0 * CH_SR), int(t1 * CH_SR)
231
+ for k in (0, 1):
232
+ acc[k].append(pcm[k][a:b])
233
+ used += t1 - t0
234
+ if used < 1.0:
235
+ continue
236
+ db = [round(float(20 * np.log10(np.sqrt(np.mean(np.concatenate(x) ** 2)) + 1e-12)), 1) for x in acc]
237
+ levels[spk] = db
238
+ if abs(db[0] - db[1]) >= min_gap_db:
239
+ best[spk] = 1 if db[0] > db[1] else 2
240
+ split = len(set(best.values())) >= 2
241
+ return {"by_speaker": best if split else {}, "levels": levels, "split": split}
242
+
243
+
244
+ def louder_channels(path: str, spans: list[tuple[float, float]], min_gap_db: float = 6.0) -> list[int | None]:
245
+ """Per time span the clearly louder of the first two source channels (1 or 2), None if they are within min_gap_db.
246
+ The signal decides, not the diarization: it took two interviewer questions for the guest (Phase 8)."""
247
+ chans = source_channels(path)
248
+ if len(chans) < 2:
249
+ return [None] * len(spans)
250
+ pcm = [_channel_pcm(path, s, c, 1e6) for s, c in chans[:2]]
251
+ out: list[int | None] = []
252
+ for t0, t1 in spans:
253
+ db = []
254
+ for x in pcm:
255
+ seg = x[int(t0 * CH_SR):int(t1 * CH_SR)]
256
+ db.append(float(20 * np.log10(np.sqrt(np.mean(seg ** 2)) + 1e-12)) if len(seg) else -120.0)
257
+ out.append(None if abs(db[0] - db[1]) < min_gap_db else (1 if db[0] > db[1] else 2))
258
+ return out
259
+
260
+
261
+ def channel_balance(path: Path) -> dict:
262
+ """RMS per output channel of a render (dB) → verification that both speakers play equally loud."""
263
+ err = subprocess.run([_bin("ffmpeg"), "-nostdin", "-nostats", "-v", "info", "-i", str(path), "-map", "0:a:0", "-af",
264
+ "astats=measure_perchannel=RMS_level:measure_overall=none", "-f", "null", "-"],
265
+ capture_output=True, text=True, encoding="utf-8", errors="replace").stderr
266
+ vals = [float(v) for v in re.findall(r"RMS level dB: (-?[\d.inf]+)", err) if v not in ("-inf", "inf")]
267
+ return {"rms_db": [round(v, 2) for v in vals], "diff_db": round(abs(vals[0] - vals[1]), 2) if len(vals) >= 2 else None}
schnippo/branding.py ADDED
@@ -0,0 +1,26 @@
1
+ """Names, links and brand files in ONE place: the bundle build, server, cards, setup texts and the update check read them."""
2
+
3
+ from pathlib import Path
4
+
5
+ DISPLAY_NAME = "Schnippo"
6
+ SLUG = "schnippo" # technical id: bundle name, cache folder, MCP server name; change only with a migration
7
+ TAGLINE = "KI-Rohschnitt für DaVinci Resolve"
8
+ TRADEMARK = "DaVinci Resolve ist eine Marke von Blackmagic Design. Schnippo ist ein unabhängiges Projekt und nicht mit Blackmagic Design verbunden."
9
+ AUTHOR = "Seitz Dominik Visuals"
10
+ AUTHOR_URL = "https://sdv.design"
11
+ HOMEPAGE = "https://schnippo.com"
12
+ REPOSITORY = "https://github.com/Seitzdominik/schnippo"
13
+ REPO_SLUG = "Seitzdominik/schnippo"
14
+ SUPPORT_URL = REPOSITORY + "/issues" # later schnippo.com: change here only
15
+ DOCS_URL = REPOSITORY + "#readme"
16
+ RELEASES_URL = REPOSITORY + "/releases/latest"
17
+ PRIVACY_URL = HOMEPAGE + "/privacy"
18
+
19
+ LEGACY_SLUG = "resolve-cut" # the working title: old folders stay readable
20
+ LEGACY_EXPORT = "resolve-cut-export"
21
+
22
+
23
+ def brand_file(name: str, placeholder: Path | None = None) -> Path | None:
24
+ """assets/brand/<name>, or the placeholder as long as the real file is not there."""
25
+ f = Path(__file__).resolve().parents[2] / "assets" / "brand" / name
26
+ return f if f.is_file() else placeholder