davinci-resolve-mcp 2.68.2 → 2.69.1
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/CHANGELOG.md +184 -0
- package/README.md +46 -3
- package/docs/SKILL.md +181 -1
- package/docs/install.md +27 -1
- package/install.py +26 -1
- package/package.json +1 -1
- package/resolve-advanced/LICENSE +21 -0
- package/resolve-advanced/package.json +2 -1
- package/resolve-advanced/vendor/conform-qc/package.json +1 -0
- package/resolve-advanced/vendor/drp-format/package.json +1 -0
- package/resolve-advanced/vendor/drt-format/package.json +1 -0
- package/scripts/doctor.py +129 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +522 -3
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +68 -2
- package/src/utils/broll_placement.py +201 -0
- package/src/utils/conform_lint.py +437 -0
- package/src/utils/edit_engine.py +633 -1
- package/src/utils/edit_handles.py +206 -0
- package/src/utils/edit_report.py +360 -0
- package/src/utils/first_impression.py +212 -0
- package/src/utils/prebalance.py +471 -0
- package/src/utils/project_journal.py +360 -0
- package/src/utils/reference_match.py +203 -0
- package/src/utils/rhythm_audit.py +252 -0
- package/src/utils/rule_of_six.py +217 -0
- package/src/utils/setup_sheet.py +121 -0
- package/src/utils/shot_assembly.py +259 -0
- package/src/utils/silence_ripple.py +89 -2
- package/src/utils/sound_density.py +253 -0
- package/src/utils/split_edits.py +180 -0
- package/src/utils/take_ranking.py +206 -0
- package/src/utils/transcript_edit.py +101 -5
- package/src/utils/turnover.py +187 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Split edits — sound leads picture, made measurable.
|
|
2
|
+
|
|
3
|
+
**The ear is faster than the eye.** Sound arrives at the audience ahead of
|
|
4
|
+
picture and is processed ahead of it, which is why the classical advice is to
|
|
5
|
+
consider the cut as a sound event first. Sound can pre-lap into the next scene and pull the
|
|
6
|
+
audience forward, linger from the previous one and create resonance, or carry
|
|
7
|
+
continuity across images that have none.
|
|
8
|
+
|
|
9
|
+
The split edit is that principle made concrete, and it is one of the few classical
|
|
10
|
+
positions that is a straightforward measurement:
|
|
11
|
+
|
|
12
|
+
| Shape | What happens | Effect |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| **L-cut** | Audio from the *outgoing* shot continues under the incoming picture | Lingers; creates resonance and continuity |
|
|
15
|
+
| **J-cut** | Audio from the *incoming* shot starts under the outgoing picture | Pre-laps; pulls the audience forward |
|
|
16
|
+
| **Straight** | Audio and picture cut together | Neutral, and the NLE default |
|
|
17
|
+
|
|
18
|
+
## Why the ratio is the finding
|
|
19
|
+
|
|
20
|
+
A timeline of nothing but straight cuts is worth knowing about. It is what an
|
|
21
|
+
NLE produces when nobody has thought about sound: every audio edit sitting
|
|
22
|
+
exactly on its picture edit because that is where the software put it. It is
|
|
23
|
+
rarely what an editor wants and almost never what a dialogue scene wants.
|
|
24
|
+
|
|
25
|
+
**There is no correct ratio and this module does not suggest one.** It reports
|
|
26
|
+
the distribution. An editor reads that instantly; a prescription would be
|
|
27
|
+
nonsense across a montage, a dialogue two-hander and a documentary interview.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any, Dict, List, Mapping, Sequence
|
|
33
|
+
|
|
34
|
+
#: Audio and picture edits within this many frames of each other are the same
|
|
35
|
+
#: join. Wider than a frame or two because audio edits get nudged by hand and a
|
|
36
|
+
#: two-frame offset is not a deliberate split edit.
|
|
37
|
+
PAIRING_WINDOW_FRAMES = 48
|
|
38
|
+
|
|
39
|
+
#: Below this the offset is a nudge, not an intent. A one-frame audio slip is
|
|
40
|
+
#: not sound leading picture; it is somebody trimming.
|
|
41
|
+
MIN_SPLIT_FRAMES = 3
|
|
42
|
+
|
|
43
|
+
SHAPE_L = "L-cut"
|
|
44
|
+
SHAPE_J = "J-cut"
|
|
45
|
+
SHAPE_STRAIGHT = "straight"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _edit_points(items: Sequence[Mapping[str, Any]]) -> List[int]:
|
|
49
|
+
"""Cut points on a track — every boundary where one item meets the next."""
|
|
50
|
+
frames = set()
|
|
51
|
+
for item in items:
|
|
52
|
+
for key in ("timeline_start_frame", "timeline_end_frame"):
|
|
53
|
+
value = item.get(key)
|
|
54
|
+
if value is not None:
|
|
55
|
+
frames.add(int(value))
|
|
56
|
+
return sorted(frames)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def classify_joins(
|
|
60
|
+
video_items: Sequence[Mapping[str, Any]],
|
|
61
|
+
audio_items: Sequence[Mapping[str, Any]],
|
|
62
|
+
*,
|
|
63
|
+
fps: float = 24.0,
|
|
64
|
+
pairing_window: int = PAIRING_WINDOW_FRAMES,
|
|
65
|
+
min_split: int = MIN_SPLIT_FRAMES,
|
|
66
|
+
) -> Dict[str, Any]:
|
|
67
|
+
"""Pair each picture edit with the nearest audio edit and classify the shape.
|
|
68
|
+
|
|
69
|
+
An unpaired picture edit is reported as `unpaired`, **not** as a straight
|
|
70
|
+
cut: no audio edit anywhere near a picture cut usually means the audio runs
|
|
71
|
+
continuously across it, which is a different and much stronger form of sound
|
|
72
|
+
carrying picture than a straight cut is.
|
|
73
|
+
"""
|
|
74
|
+
picture = _edit_points(video_items)
|
|
75
|
+
sound = _edit_points(audio_items)
|
|
76
|
+
joins: List[Dict[str, Any]] = []
|
|
77
|
+
unpaired: List[int] = []
|
|
78
|
+
|
|
79
|
+
for cut in picture:
|
|
80
|
+
if not sound:
|
|
81
|
+
unpaired.append(cut)
|
|
82
|
+
continue
|
|
83
|
+
nearest = min(sound, key=lambda s: abs(s - cut))
|
|
84
|
+
offset = nearest - cut
|
|
85
|
+
if abs(offset) > pairing_window:
|
|
86
|
+
unpaired.append(cut)
|
|
87
|
+
continue
|
|
88
|
+
if abs(offset) < min_split:
|
|
89
|
+
shape, meaning = SHAPE_STRAIGHT, "audio and picture cut together"
|
|
90
|
+
elif offset > 0:
|
|
91
|
+
# Audio edit is LATER than picture: outgoing sound continues under
|
|
92
|
+
# the incoming image.
|
|
93
|
+
shape, meaning = SHAPE_L, "outgoing audio continues under the new picture — lingers"
|
|
94
|
+
else:
|
|
95
|
+
# Audio edit is EARLIER: incoming sound starts under the outgoing
|
|
96
|
+
# image.
|
|
97
|
+
shape, meaning = SHAPE_J, "incoming audio starts under the old picture — pulls forward"
|
|
98
|
+
joins.append({
|
|
99
|
+
"picture_frame": cut,
|
|
100
|
+
"audio_frame": nearest,
|
|
101
|
+
"offset_frames": offset,
|
|
102
|
+
"offset_seconds": round(offset / fps, 3) if fps else None,
|
|
103
|
+
"shape": shape,
|
|
104
|
+
"meaning": meaning,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
counts = {
|
|
108
|
+
shape: sum(1 for j in joins if j["shape"] == shape)
|
|
109
|
+
for shape in (SHAPE_L, SHAPE_J, SHAPE_STRAIGHT)
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
"joins": joins,
|
|
113
|
+
"counts": counts,
|
|
114
|
+
"unpaired_picture_cuts": unpaired,
|
|
115
|
+
"paired_count": len(joins),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def audit(
|
|
120
|
+
video_items: Sequence[Mapping[str, Any]],
|
|
121
|
+
audio_items: Sequence[Mapping[str, Any]],
|
|
122
|
+
*,
|
|
123
|
+
fps: float = 24.0,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Report the split-edit distribution across a timeline."""
|
|
126
|
+
if not video_items:
|
|
127
|
+
return {"success": False, "error": "No video items supplied"}
|
|
128
|
+
if not audio_items:
|
|
129
|
+
return {
|
|
130
|
+
"success": False,
|
|
131
|
+
"error": "No audio items supplied — split edits cannot be assessed",
|
|
132
|
+
"remediation": (
|
|
133
|
+
"Pass the timeline's audio track items. Without them this cannot "
|
|
134
|
+
"tell a straight cut from an unexamined one, and reporting 'all "
|
|
135
|
+
"straight' would be a fabrication."
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result = classify_joins(video_items, audio_items, fps=fps)
|
|
140
|
+
counts = result["counts"]
|
|
141
|
+
splits = counts[SHAPE_L] + counts[SHAPE_J]
|
|
142
|
+
paired = result["paired_count"]
|
|
143
|
+
findings: List[Dict[str, Any]] = []
|
|
144
|
+
|
|
145
|
+
if paired >= 8 and splits == 0:
|
|
146
|
+
findings.append({
|
|
147
|
+
"criterion": "rhythm",
|
|
148
|
+
"scope": "sequence",
|
|
149
|
+
"at": result["joins"][0]["picture_frame"] if result["joins"] else 0,
|
|
150
|
+
"summary": f"all {paired} joins are straight cuts — sound never leads picture",
|
|
151
|
+
"detail": (
|
|
152
|
+
"Every audio edit sits on its picture edit, which is where the NLE "
|
|
153
|
+
"puts them by default. The ear is faster than the eye: an L-cut lets "
|
|
154
|
+
"the outgoing line breathe under the new image, a J-cut pulls the "
|
|
155
|
+
"audience into the next scene before they see it. A dialogue "
|
|
156
|
+
"sequence with no split edits anywhere is usually one nobody has "
|
|
157
|
+
"listened to yet."
|
|
158
|
+
),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"success": True,
|
|
163
|
+
"kind": "split_edit_audit",
|
|
164
|
+
"paired_joins": paired,
|
|
165
|
+
"counts": counts,
|
|
166
|
+
"split_ratio": round(splits / paired, 3) if paired else None,
|
|
167
|
+
"unpaired_picture_cuts": len(result["unpaired_picture_cuts"]),
|
|
168
|
+
"joins": result["joins"],
|
|
169
|
+
"findings": findings,
|
|
170
|
+
"note": (
|
|
171
|
+
f"{counts[SHAPE_L]} L-cuts, {counts[SHAPE_J]} J-cuts, "
|
|
172
|
+
f"{counts[SHAPE_STRAIGHT]} straight across {paired} paired joins"
|
|
173
|
+
+ (f"; {len(result['unpaired_picture_cuts'])} picture cuts had no audio "
|
|
174
|
+
"edit nearby, which usually means sound runs continuously across them"
|
|
175
|
+
if result["unpaired_picture_cuts"] else "")
|
|
176
|
+
+ ". There is NO correct ratio and none is suggested — a montage, a "
|
|
177
|
+
"two-hander and an interview all want different distributions. This is "
|
|
178
|
+
"a reading, not a score."
|
|
179
|
+
),
|
|
180
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Ranking takes on what can actually be measured.
|
|
2
|
+
|
|
3
|
+
Two people asked for this after watching an automated first pass — "can it
|
|
4
|
+
determine which takes are worse or better when it comes to the script?" — and
|
|
5
|
+
one answered his own question in the next comment: "I'd trust it even less to
|
|
6
|
+
choose the right take."
|
|
7
|
+
|
|
8
|
+
He is right, and the honest version of this feature says so out loud.
|
|
9
|
+
|
|
10
|
+
**What this measures:** fluency. Filler density, stammered restarts, how long
|
|
11
|
+
the speaker goes without stumbling, and — when a script is supplied — how much
|
|
12
|
+
of the intended line actually got said. All countable, all reproducible, none of
|
|
13
|
+
them a matter of opinion.
|
|
14
|
+
|
|
15
|
+
**What this cannot measure:** whether the take is any good. In the editorial
|
|
16
|
+
weighting every cutter works from, emotion is roughly half the decision and
|
|
17
|
+
story most of the rest; fluency is not on the list at all. The take that lands
|
|
18
|
+
is regularly the one that stumbles — the hesitation *is* the performance, the
|
|
19
|
+
breath before the answer *is* the beat. A ranking that put the smoothest read on
|
|
20
|
+
top and called it "best" would be confidently wrong in exactly the cases that
|
|
21
|
+
matter most, and would train its user to stop looking.
|
|
22
|
+
|
|
23
|
+
So this returns a **fluency ranking with its evidence**, never a "best take",
|
|
24
|
+
and every result says which one it is. Use it to find the clean safety take, to
|
|
25
|
+
spot the one where someone dried, or to skip the six warm-ups before the read
|
|
26
|
+
got going — not to choose the performance. That is the editor's call and this
|
|
27
|
+
module has nothing useful to say about it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any, Dict, Mapping, Optional, Sequence
|
|
33
|
+
|
|
34
|
+
from src.utils import transcript_edit as _te
|
|
35
|
+
|
|
36
|
+
#: Below this, a take is too short for rate-based metrics to mean anything —
|
|
37
|
+
#: three fluent words is not evidence of fluency.
|
|
38
|
+
MIN_MEANINGFUL_SECONDS = 3.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _duration(words: Sequence[Mapping[str, Any]]) -> float:
|
|
42
|
+
spans = [s for s in (_te._word_span(w) for w in words) if s is not None]
|
|
43
|
+
if not spans:
|
|
44
|
+
return 0.0
|
|
45
|
+
return max(e for _, e in spans) - min(s for s, _ in spans)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _longest_fluent_run(words: Sequence[Mapping[str, Any]], disfluent_spans: Sequence[Dict[str, Any]]) -> float:
|
|
49
|
+
"""Longest stretch with no filler and no restart in it."""
|
|
50
|
+
spans = [s for s in (_te._word_span(w) for w in words) if s is not None]
|
|
51
|
+
if not spans:
|
|
52
|
+
return 0.0
|
|
53
|
+
start, end = min(s for s, _ in spans), max(e for _, e in spans)
|
|
54
|
+
breaks = sorted(
|
|
55
|
+
(float(d["start_seconds"]), float(d["end_seconds"]))
|
|
56
|
+
for d in disfluent_spans
|
|
57
|
+
if d.get("start_seconds") is not None and d.get("end_seconds") is not None
|
|
58
|
+
)
|
|
59
|
+
longest, cursor = 0.0, start
|
|
60
|
+
for b_start, b_end in breaks:
|
|
61
|
+
longest = max(longest, b_start - cursor)
|
|
62
|
+
cursor = max(cursor, b_end)
|
|
63
|
+
return round(max(longest, end - cursor), 3)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _script_coverage(words: Sequence[Mapping[str, Any]], script: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
67
|
+
"""Fraction of the script's content words that appear in the take.
|
|
68
|
+
|
|
69
|
+
Bag-of-words on purpose: word order in a delivered read legitimately differs
|
|
70
|
+
from the page, and penalising that would rank paraphrase below a stumbling
|
|
71
|
+
literal reading. This answers "did they get through it", not "was it
|
|
72
|
+
word-perfect".
|
|
73
|
+
"""
|
|
74
|
+
if not script or not script.strip():
|
|
75
|
+
return None
|
|
76
|
+
wanted = {_te.normalize_token(t) for t in script.split()}
|
|
77
|
+
wanted = {t for t in wanted if t and t not in _te.FILLER_WORDS}
|
|
78
|
+
if not wanted:
|
|
79
|
+
return None
|
|
80
|
+
said = {_te.normalize_token(w.get("word")) for w in words}
|
|
81
|
+
hit = wanted & said
|
|
82
|
+
return {
|
|
83
|
+
"covered": len(hit),
|
|
84
|
+
"script_words": len(wanted),
|
|
85
|
+
"ratio": round(len(hit) / len(wanted), 3),
|
|
86
|
+
"missing_sample": sorted(wanted - hit)[:12],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def score_take(
|
|
91
|
+
words: Sequence[Mapping[str, Any]],
|
|
92
|
+
*,
|
|
93
|
+
label: str,
|
|
94
|
+
script: Optional[str] = None,
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
"""Countable fluency metrics for one take. No judgement of performance."""
|
|
97
|
+
duration = _duration(words)
|
|
98
|
+
fillers = _te.detect_fillers(words)
|
|
99
|
+
false_starts = _te.detect_false_starts(words)
|
|
100
|
+
per_minute = (len(fillers) / (duration / 60.0)) if duration > 0 else 0.0
|
|
101
|
+
wpm = (len(words) / (duration / 60.0)) if duration > 0 else 0.0
|
|
102
|
+
|
|
103
|
+
result: Dict[str, Any] = {
|
|
104
|
+
"label": label,
|
|
105
|
+
"duration_seconds": round(duration, 2),
|
|
106
|
+
"word_count": len(words),
|
|
107
|
+
"words_per_minute": round(wpm, 1),
|
|
108
|
+
"filler_count": len(fillers),
|
|
109
|
+
"fillers_per_minute": round(per_minute, 2),
|
|
110
|
+
"false_start_count": len(false_starts),
|
|
111
|
+
"longest_fluent_run_seconds": _longest_fluent_run(words, list(fillers) + list(false_starts)),
|
|
112
|
+
"script_coverage": _script_coverage(words, script),
|
|
113
|
+
"reliable": duration >= MIN_MEANINGFUL_SECONDS,
|
|
114
|
+
}
|
|
115
|
+
if not result["reliable"]:
|
|
116
|
+
result["caveat"] = (
|
|
117
|
+
f"only {result['duration_seconds']}s of speech — too short for these "
|
|
118
|
+
"rates to mean anything; ranked last rather than dropped so it stays visible"
|
|
119
|
+
)
|
|
120
|
+
result["fluency_score"] = _fluency_score(result)
|
|
121
|
+
result["evidence"] = _evidence_line(result)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fluency_score(m: Mapping[str, Any]) -> float:
|
|
126
|
+
"""0-100, higher = more fluent. Deliberately simple and explainable.
|
|
127
|
+
|
|
128
|
+
Not tuned or learned: a weighting nobody can read is not reviewable, and the
|
|
129
|
+
components are published alongside so a reader can disagree with the
|
|
130
|
+
arithmetic instead of having to trust it.
|
|
131
|
+
"""
|
|
132
|
+
if not m["reliable"]:
|
|
133
|
+
return 0.0
|
|
134
|
+
score = 100.0
|
|
135
|
+
score -= min(40.0, float(m["fillers_per_minute"]) * 5.0)
|
|
136
|
+
score -= min(30.0, float(m["false_start_count"]) * 6.0)
|
|
137
|
+
coverage = m.get("script_coverage")
|
|
138
|
+
if coverage:
|
|
139
|
+
score -= (1.0 - float(coverage["ratio"])) * 30.0
|
|
140
|
+
return round(max(0.0, score), 1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _evidence_line(m: Mapping[str, Any]) -> str:
|
|
144
|
+
bits = [
|
|
145
|
+
f"{m['filler_count']} fillers ({m['fillers_per_minute']}/min)",
|
|
146
|
+
f"{m['false_start_count']} restarts",
|
|
147
|
+
f"longest clean run {m['longest_fluent_run_seconds']}s",
|
|
148
|
+
]
|
|
149
|
+
coverage = m.get("script_coverage")
|
|
150
|
+
if coverage:
|
|
151
|
+
bits.append(f"{int(coverage['ratio'] * 100)}% of script covered")
|
|
152
|
+
return "; ".join(bits)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: Attached to every ranking. Not a disclaimer to be skimmed — it is the
|
|
156
|
+
#: difference between a useful tool and a misleading one.
|
|
157
|
+
RANKING_CAVEAT = (
|
|
158
|
+
"This ranks FLUENCY, not quality. It counts fillers, restarts and script "
|
|
159
|
+
"coverage — all measurable. It cannot hear performance, and performance is "
|
|
160
|
+
"most of what makes a take the right one. The take that plays best is "
|
|
161
|
+
"regularly the least fluent one: the hesitation is often the acting. Use "
|
|
162
|
+
"this to find the clean safety take or to skip the warm-ups, not to choose "
|
|
163
|
+
"the read."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def rank_takes(
|
|
168
|
+
takes: Sequence[Mapping[str, Any]],
|
|
169
|
+
*,
|
|
170
|
+
script: Optional[str] = None,
|
|
171
|
+
) -> Dict[str, Any]:
|
|
172
|
+
"""Rank takes by measurable fluency, with the evidence and the caveat.
|
|
173
|
+
|
|
174
|
+
Each take: `{"label": str, "words": [transcript_words rows]}`.
|
|
175
|
+
"""
|
|
176
|
+
if not takes:
|
|
177
|
+
return {"success": False, "error": "No takes supplied"}
|
|
178
|
+
|
|
179
|
+
scored = [
|
|
180
|
+
score_take(t.get("words") or [], label=str(t.get("label") or f"take {i + 1}"), script=script)
|
|
181
|
+
for i, t in enumerate(takes)
|
|
182
|
+
]
|
|
183
|
+
scored.sort(key=lambda s: (-s["fluency_score"], -s["longest_fluent_run_seconds"]))
|
|
184
|
+
for position, take in enumerate(scored, start=1):
|
|
185
|
+
take["rank"] = position
|
|
186
|
+
|
|
187
|
+
unreliable = [s["label"] for s in scored if not s["reliable"]]
|
|
188
|
+
return {
|
|
189
|
+
"success": True,
|
|
190
|
+
"kind": "take_ranking",
|
|
191
|
+
"ranked_by": "fluency",
|
|
192
|
+
"take_count": len(scored),
|
|
193
|
+
"takes": scored,
|
|
194
|
+
"most_fluent": scored[0]["label"] if scored else None,
|
|
195
|
+
"script_supplied": bool(script and script.strip()),
|
|
196
|
+
"unreliable_takes": unreliable,
|
|
197
|
+
"caveat": RANKING_CAVEAT,
|
|
198
|
+
"note": (
|
|
199
|
+
f"Ranked {len(scored)} takes by fluency. "
|
|
200
|
+
+ (f"{len(unreliable)} {'take was' if len(unreliable) == 1 else 'takes were'} "
|
|
201
|
+
"too short to score reliably and sit at the bottom — not necessarily "
|
|
202
|
+
"bad, just unmeasurable. "
|
|
203
|
+
if unreliable else "")
|
|
204
|
+
+ "The top entry is the most fluent, which is NOT the same as the best."
|
|
205
|
+
),
|
|
206
|
+
}
|
|
@@ -151,19 +151,112 @@ def detect_fillers(words: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
151
151
|
return out
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
#: Terminal punctuation on the *preceding* word. A pause after one of these is
|
|
155
|
+
#: where a thought ended, which is where an audience expects to breathe.
|
|
156
|
+
_SENTENCE_FINAL_PUNCT = (".", "?", "!", "…")
|
|
157
|
+
#: A pause here separates clauses — a real beat, but a smaller one.
|
|
158
|
+
_CLAUSE_FINAL_PUNCT = (",", ";", ":", "—", "–")
|
|
159
|
+
|
|
160
|
+
#: How much longer a *sentence-final* pause must be before it counts as dead air.
|
|
161
|
+
#:
|
|
162
|
+
#: A silence gate cannot tell these apart, because acoustically they are
|
|
163
|
+
#: identical: 900 ms of room tone after "…and that changed everything." and
|
|
164
|
+
#: 900 ms of room tone in the middle of "the thing is — uh — we had to leave"
|
|
165
|
+
#: measure the same. Editorially they are opposites. The first is the beat that
|
|
166
|
+
#: lets the line land; removing it is the single most common way an automated
|
|
167
|
+
#: tighten makes a cut feel machine-made. The second is a stumble.
|
|
168
|
+
#:
|
|
169
|
+
#: So sentence-final pauses are not exempt — a four-second hole after a full
|
|
170
|
+
#: stop is still dead air — they simply have to be much longer before we touch
|
|
171
|
+
#: them. Clause-final sits between the two.
|
|
172
|
+
BREATHING_ROOM_MULTIPLIER = 2.5
|
|
173
|
+
CLAUSE_PAUSE_MULTIPLIER = 1.5
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def classify_pause(prev_word: Optional[Dict[str, Any]], next_word: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
177
|
+
"""Where a pause sits syntactically, which decides whether it is craft or noise.
|
|
178
|
+
|
|
179
|
+
Honest limit: this reads punctuation, so it is only as good as the
|
|
180
|
+
transcriber's. ASR output with no punctuation at all cannot be classified —
|
|
181
|
+
that returns `unpunctuated` with low confidence rather than silently
|
|
182
|
+
assuming every pause is removable, which would reintroduce exactly the
|
|
183
|
+
behaviour this function exists to prevent.
|
|
184
|
+
"""
|
|
185
|
+
raw = str((prev_word or {}).get("word") or "").strip()
|
|
186
|
+
if not raw:
|
|
187
|
+
return {"position": "unknown", "is_breathing_room": False,
|
|
188
|
+
"multiplier": 1.0, "confidence": "low",
|
|
189
|
+
"reason": "no preceding word text"}
|
|
190
|
+
if raw.endswith(_SENTENCE_FINAL_PUNCT):
|
|
191
|
+
return {"position": "sentence_final", "is_breathing_room": True,
|
|
192
|
+
"multiplier": BREATHING_ROOM_MULTIPLIER, "confidence": "high",
|
|
193
|
+
"reason": f"pause follows end of sentence ({raw[-1]!r}) — breathing room"}
|
|
194
|
+
if raw.endswith(_CLAUSE_FINAL_PUNCT):
|
|
195
|
+
return {"position": "clause_final", "is_breathing_room": True,
|
|
196
|
+
"multiplier": CLAUSE_PAUSE_MULTIPLIER, "confidence": "medium",
|
|
197
|
+
"reason": f"pause follows a clause break ({raw[-1]!r})"}
|
|
198
|
+
# No terminal punctuation. If nothing in the transcript is punctuated at all
|
|
199
|
+
# we must not read that as "every pause is mid-sentence".
|
|
200
|
+
return {"position": "intra_clause", "is_breathing_room": False,
|
|
201
|
+
"multiplier": 1.0, "confidence": "high",
|
|
202
|
+
"reason": "pause falls inside a phrase — not a beat the writing asked for"}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def transcript_is_punctuated(words: Sequence[Dict[str, Any]]) -> bool:
|
|
206
|
+
"""Whether this transcript carries sentence punctuation at all.
|
|
207
|
+
|
|
208
|
+
Used to downgrade pause classification honestly: without punctuation every
|
|
209
|
+
pause looks intra-clause, and acting on that would strip every beat in the
|
|
210
|
+
programme.
|
|
211
|
+
"""
|
|
212
|
+
for w in words:
|
|
213
|
+
raw = str((w or {}).get("word") or "").strip()
|
|
214
|
+
if raw.endswith(_SENTENCE_FINAL_PUNCT) or raw.endswith(_CLAUSE_FINAL_PUNCT):
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
154
219
|
def detect_long_pauses(
|
|
155
|
-
words: Sequence[Dict[str, Any]],
|
|
220
|
+
words: Sequence[Dict[str, Any]],
|
|
221
|
+
*,
|
|
222
|
+
max_pause: float,
|
|
223
|
+
handle: float,
|
|
224
|
+
min_cut: float,
|
|
225
|
+
preserve_breathing_room: bool = True,
|
|
156
226
|
) -> List[Dict[str, Any]]:
|
|
157
227
|
"""Gaps between words longer than `max_pause`, trimmed by a handle each side.
|
|
158
228
|
|
|
159
229
|
Word-boundary based, so unlike a silence gate this never cuts into a word
|
|
160
230
|
that merely dipped below a threshold.
|
|
231
|
+
|
|
232
|
+
With `preserve_breathing_room` (the default), the threshold is scaled by the
|
|
233
|
+
pause's syntactic position — see `classify_pause`. A pause after a full stop
|
|
234
|
+
has to be markedly longer than one mid-phrase before it is proposed for
|
|
235
|
+
removal, because the first is usually deliberate and the second usually is
|
|
236
|
+
not. Set it False to treat every gap as equal, which is the older behaviour.
|
|
161
237
|
"""
|
|
162
238
|
out: List[Dict[str, Any]] = []
|
|
163
|
-
|
|
164
|
-
|
|
239
|
+
indexed = [(i, s) for i, s in enumerate(_word_span(w) for w in words) if s is not None]
|
|
240
|
+
punctuated = transcript_is_punctuated(words) if preserve_breathing_room else False
|
|
241
|
+
for (i_prev, (_, prev_end)), (_, (next_start, _)) in zip(indexed, indexed[1:]):
|
|
165
242
|
gap = next_start - prev_end
|
|
166
|
-
if
|
|
243
|
+
if preserve_breathing_room and punctuated:
|
|
244
|
+
classification = classify_pause(words[i_prev], None)
|
|
245
|
+
else:
|
|
246
|
+
classification = {
|
|
247
|
+
"position": "unpunctuated" if preserve_breathing_room else "unclassified",
|
|
248
|
+
"is_breathing_room": False,
|
|
249
|
+
"multiplier": 1.0,
|
|
250
|
+
"confidence": "low" if preserve_breathing_room else "high",
|
|
251
|
+
"reason": (
|
|
252
|
+
"transcript carries no punctuation, so a pause cannot be placed "
|
|
253
|
+
"syntactically — treated as removable, review before accepting"
|
|
254
|
+
if preserve_breathing_room
|
|
255
|
+
else "breathing-room preservation disabled by caller"
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
effective_max = max_pause * float(classification["multiplier"])
|
|
259
|
+
if gap <= effective_max:
|
|
167
260
|
continue
|
|
168
261
|
start, end = prev_end + handle, next_start - handle
|
|
169
262
|
if end - start >= min_cut:
|
|
@@ -171,7 +264,10 @@ def detect_long_pauses(
|
|
|
171
264
|
"start_seconds": round(start, 3),
|
|
172
265
|
"end_seconds": round(end, 3),
|
|
173
266
|
"pause_seconds": round(gap, 3),
|
|
174
|
-
"confidence": "
|
|
267
|
+
"confidence": classification["confidence"],
|
|
268
|
+
"pause_position": classification["position"],
|
|
269
|
+
"threshold_seconds": round(effective_max, 3),
|
|
270
|
+
"classification_reason": classification["reason"],
|
|
175
271
|
})
|
|
176
272
|
return out
|
|
177
273
|
|