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.
- schnippo/__init__.py +3 -0
- schnippo/acoustic.py +166 -0
- schnippo/analysis.py +183 -0
- schnippo/audio.py +267 -0
- schnippo/branding.py +26 -0
- schnippo/cache.py +61 -0
- schnippo/cache_admin.py +94 -0
- schnippo/config.py +181 -0
- schnippo/context.py +149 -0
- schnippo/cutlist.py +1358 -0
- schnippo/data/guides/01_rough_cut.md +69 -0
- schnippo/data/guides/02_fine_cut.md +45 -0
- schnippo/data/guides/03_broll.md +39 -0
- schnippo/data/guides/04_polish.md +40 -0
- schnippo/data/guides/05_motion_graphics.md +63 -0
- schnippo/data/guides/06_sound_design.md +31 -0
- schnippo/data/guides/07_reels.md +30 -0
- schnippo/data/guides/design_tokens.json +124 -0
- schnippo/data/guides/technik.md +86 -0
- schnippo/data/sfx/LICENSES.md +54 -0
- schnippo/data/sfx/analysis/groups.json +974 -0
- schnippo/data/sfx/library.json +860 -0
- schnippo/data/sfx/recorded.json +289 -0
- schnippo/data/sfx/wav/alarm_bell.wav +0 -0
- schnippo/data/sfx/wav/alarm_real.wav +0 -0
- schnippo/data/sfx/wav/click_mouse.wav +0 -0
- schnippo/data/sfx/wav/click_mouse_sharp.wav +0 -0
- schnippo/data/sfx/wav/click_mouse_soft.wav +0 -0
- schnippo/data/sfx/wav/clock_timer.wav +0 -0
- schnippo/data/sfx/wav/clock_timer_b.wav +0 -0
- schnippo/data/sfx/wav/ding_bell.wav +0 -0
- schnippo/data/sfx/wav/ding_bright.wav +0 -0
- schnippo/data/sfx/wav/ding_chime.wav +0 -0
- schnippo/data/sfx/wav/ding_glass.wav +0 -0
- schnippo/data/sfx/wav/ding_notify.wav +0 -0
- schnippo/data/sfx/wav/ding_real.wav +0 -0
- schnippo/data/sfx/wav/downer_tape.wav +0 -0
- schnippo/data/sfx/wav/downer_vinyl.wav +0 -0
- schnippo/data/sfx/wav/impact_boom.wav +0 -0
- schnippo/data/sfx/wav/impact_hit.wav +0 -0
- schnippo/data/sfx/wav/impact_soft_low.wav +0 -0
- schnippo/data/sfx/wav/impact_soft_mid.wav +0 -0
- schnippo/data/sfx/wav/pop_bright.wav +0 -0
- schnippo/data/sfx/wav/pop_mid.wav +0 -0
- schnippo/data/sfx/wav/pop_soft.wav +0 -0
- schnippo/data/sfx/wav/riser_impact.wav +0 -0
- schnippo/data/sfx/wav/riser_long.wav +0 -0
- schnippo/data/sfx/wav/riser_short.wav +0 -0
- schnippo/data/sfx/wav/riser_swell.wav +0 -0
- schnippo/data/sfx/wav/swish_bright.wav +0 -0
- schnippo/data/sfx/wav/swish_mid.wav +0 -0
- schnippo/data/sfx/wav/swish_soft.wav +0 -0
- schnippo/data/sfx/wav/tick_bright.wav +0 -0
- schnippo/data/sfx/wav/tick_mid.wav +0 -0
- schnippo/data/sfx/wav/tick_soft.wav +0 -0
- schnippo/data/sfx/wav/typing_mech.wav +0 -0
- schnippo/data/sfx/wav/typing_mech_b.wav +0 -0
- schnippo/data/sfx/wav/whoosh_big.wav +0 -0
- schnippo/data/sfx/wav/whoosh_end.wav +0 -0
- schnippo/data/sfx/wav/whoosh_mid.wav +0 -0
- schnippo/data/sfx/wav/whoosh_soft.wav +0 -0
- schnippo/delivery.py +237 -0
- schnippo/design.py +78 -0
- schnippo/edges.py +121 -0
- schnippo/errors.py +63 -0
- schnippo/export.py +201 -0
- schnippo/fcpxml.py +72 -0
- schnippo/ffmpeg_dist.py +32 -0
- schnippo/formats.py +149 -0
- schnippo/formatting.py +123 -0
- schnippo/frames.py +272 -0
- schnippo/framing.py +25 -0
- schnippo/glossary.py +182 -0
- schnippo/guard.py +125 -0
- schnippo/guides.py +48 -0
- schnippo/jobs.py +68 -0
- schnippo/kenburns.py +59 -0
- schnippo/keychain.py +35 -0
- schnippo/markers.py +80 -0
- schnippo/media.py +247 -0
- schnippo/mg/__init__.py +0 -0
- schnippo/mg/fonts/dm-sans-600.woff2 +0 -0
- schnippo/mg/fonts/dm-sans-800.woff2 +0 -0
- schnippo/mg/fonts/dm-sans-LICENSE.txt +93 -0
- schnippo/mg/fonts/instrument-sans-600.woff2 +0 -0
- schnippo/mg/fonts/instrument-sans-700.woff2 +0 -0
- schnippo/mg/fonts/instrument-sans-LICENSE.txt +93 -0
- schnippo/mg/fonts/inter-600.woff2 +0 -0
- schnippo/mg/fonts/inter-800.woff2 +0 -0
- schnippo/mg/fonts/inter-LICENSE.txt +93 -0
- schnippo/mg/fonts/manrope-600.woff2 +0 -0
- schnippo/mg/fonts/manrope-800.woff2 +0 -0
- schnippo/mg/fonts/manrope-LICENSE.txt +93 -0
- schnippo/mg/fonts/space-grotesk-600.woff2 +0 -0
- schnippo/mg/fonts/space-grotesk-700.woff2 +0 -0
- schnippo/mg/fonts/space-grotesk-LICENSE.txt +93 -0
- schnippo/mg/place.py +127 -0
- schnippo/mg/preview.py +142 -0
- schnippo/mg/render.py +254 -0
- schnippo/mg/templates.py +331 -0
- schnippo/mg/tokens.py +133 -0
- schnippo/migrate.py +38 -0
- schnippo/models.py +130 -0
- schnippo/onboarding.py +120 -0
- schnippo/passes.py +188 -0
- schnippo/prefs.py +103 -0
- schnippo/profiles.py +163 -0
- schnippo/projects.py +129 -0
- schnippo/py.typed +0 -0
- schnippo/qc.py +329 -0
- schnippo/reels.py +238 -0
- schnippo/reframe.py +296 -0
- schnippo/render.py +123 -0
- schnippo/resolve_bridge.py +1081 -0
- schnippo/retakes.py +127 -0
- schnippo/revise.py +479 -0
- schnippo/script.py +79 -0
- schnippo/segments.py +89 -0
- schnippo/sense.py +146 -0
- schnippo/server.py +1388 -0
- schnippo/setup_check.py +184 -0
- schnippo/sfx.py +380 -0
- schnippo/sfx_profile.py +250 -0
- schnippo/sfx_record.py +103 -0
- schnippo/sfx_sources.py +108 -0
- schnippo/snap.py +117 -0
- schnippo/subtitles.py +132 -0
- schnippo/toolinfo.py +18 -0
- schnippo/transcribe/__init__.py +0 -0
- schnippo/transcribe/base.py +41 -0
- schnippo/transcribe/gladia.py +188 -0
- schnippo/ui/card.html +289 -0
- schnippo/ui/design.js +168 -0
- schnippo/updates.py +71 -0
- schnippo/workspace.py +1721 -0
- schnippo-0.11.0.dist-info/METADATA +208 -0
- schnippo-0.11.0.dist-info/RECORD +139 -0
- schnippo-0.11.0.dist-info/WHEEL +4 -0
- schnippo-0.11.0.dist-info/entry_points.txt +3 -0
schnippo/__init__.py
ADDED
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
|