ffmpeg-skill 2.19.0 → 2.20.0
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/docs/contract.md +12 -2
- package/package.json +1 -1
- package/scripts/_common/probe.py +16 -0
- package/scripts/export.py +14 -0
- package/scripts/join.py +19 -3
- package/scripts/loudness.py +39 -8
- package/scripts/silence.py +32 -1
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.20.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.20.0", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
|
@@ -699,6 +699,16 @@ from where the expected value came, and what was not measured.
|
|
|
699
699
|
| `audio_substituted` | `join.py` (video join) | the clips that had no audio stream, whose span of the join is generated silence; `notes` says the same |
|
|
700
700
|
| loudness `tolerance.tp` | `loudness.py`, `export.py`, and `check.py`'s true-peak row | the one dBTP margin every tool applies (`_platforms.TP_TOLERANCE_DB`, 0.1 dB); before 2.18 it was 0.1, 0.05 and 0.0 in the three tools |
|
|
701
701
|
|
|
702
|
+
Keys added in 2.20, all additive, from the same paper's baseline experiment (each was a length or a
|
|
703
|
+
silence a tool reported as fine while another measurement disagreed):
|
|
704
|
+
|
|
705
|
+
| key | tool | what it holds |
|
|
706
|
+
|---|---|---|
|
|
707
|
+
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
|
|
708
|
+
| `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
|
|
709
|
+
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
710
|
+
| `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
|
|
711
|
+
|
|
702
712
|
Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
703
713
|
|
|
704
714
|
| key | tool | what it holds |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/scripts/_common/probe.py
CHANGED
|
@@ -300,6 +300,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
300
300
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
301
301
|
"duration": _to_float(audio.get("duration")),
|
|
302
302
|
"start_time": _to_float(audio.get("start_time")),
|
|
303
|
+
# Matroska/WebM give no stream duration, only a DURATION tag; the container's own length
|
|
304
|
+
# is the longest stream's, which is not the sound's when the picture runs longer (2.20)
|
|
305
|
+
"tagged_duration": _tag_seconds((audio.get("tags") or {}).get("DURATION")),
|
|
303
306
|
}
|
|
304
307
|
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
305
308
|
out["audio_streams"] = [{
|
|
@@ -497,6 +500,19 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
497
500
|
}
|
|
498
501
|
|
|
499
502
|
|
|
503
|
+
def _tag_seconds(value: Any) -> "Optional[float]":
|
|
504
|
+
"""A Matroska DURATION tag ("00:00:04.008000000") in seconds, or None."""
|
|
505
|
+
try:
|
|
506
|
+
if "-" in str(value): # "-00:00:01": int("-00") is 0, so the sign would be lost
|
|
507
|
+
return None
|
|
508
|
+
h, m, sec = str(value).split(":")
|
|
509
|
+
seconds = int(h) * 3600 + int(m) * 60 + float(sec)
|
|
510
|
+
except (TypeError, ValueError):
|
|
511
|
+
return None
|
|
512
|
+
# float() reads "nan" and "inf": a length that is not a finite, non-negative number is none
|
|
513
|
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
514
|
+
|
|
515
|
+
|
|
500
516
|
def _to_float(v: Any) -> Optional[float]:
|
|
501
517
|
try:
|
|
502
518
|
return float(v) if v is not None else None
|
package/scripts/export.py
CHANGED
|
@@ -333,6 +333,20 @@ def main() -> int:
|
|
|
333
333
|
elif platform and has_audio and args.normalize:
|
|
334
334
|
spec = PLATFORMS[platform]
|
|
335
335
|
notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
|
|
336
|
+
if p["max"] and not args.allow_long and not STATE.dry_run:
|
|
337
|
+
# the preset's length promise, measured the way check.py measures it (the container's
|
|
338
|
+
# duration, no tolerance): before 2.20 nothing re-read the length, and a --normalize pass
|
|
339
|
+
# that left the sound 0.1 s long shipped a 90.10 s Reels file as verified
|
|
340
|
+
written = probe(output, role="output").get("duration")
|
|
341
|
+
if written is not None:
|
|
342
|
+
step = {"step": "duration", "property": "duration", "ok": written <= p["max"],
|
|
343
|
+
"expected": {"max": p["max"]}, "measured": round(written, 3),
|
|
344
|
+
"delta": round(written - p["max"], 3), "tolerance": 0.0, "provenance": "specification"}
|
|
345
|
+
else:
|
|
346
|
+
# the preset promised a length; one that could not be read is not one that held
|
|
347
|
+
step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
|
|
348
|
+
"reason": "not_measured: the written file reports no duration"}
|
|
349
|
+
extra.setdefault("verification", []).append(step)
|
|
336
350
|
if notes:
|
|
337
351
|
extra["notes"] = notes
|
|
338
352
|
# the delivery keeps the source's picture and, except as a GIF, its sound
|
package/scripts/join.py
CHANGED
|
@@ -81,6 +81,20 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
|
|
|
81
81
|
return round(sum(durs) - d * (len(durs) - 1), 3)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def overlap_report(durs: List[float], expected: Optional[float]) -> Dict[str, Any]:
|
|
85
|
+
"""The clips' own total next to the joined length (2.20). The joined length is derived from
|
|
86
|
+
the transition the join itself applied, so a check against it alone cannot show that a fade
|
|
87
|
+
took time out of the material; the sum of the inputs is the yardstick the user holds."""
|
|
88
|
+
if expected is None or STATE_PENDING:
|
|
89
|
+
return {}
|
|
90
|
+
total = round(sum(durs), 3)
|
|
91
|
+
out: Dict[str, Any] = {"inputs_duration": total, "transition_shortening": round(total - expected, 3)}
|
|
92
|
+
if out["transition_shortening"] > 0.0005:
|
|
93
|
+
STATE_NOTES.append(f"the transitions overlap the clips: {fmt_secs(total)} of clips play as {fmt_secs(expected)} "
|
|
94
|
+
f"({out['transition_shortening']:.3f} s shorter)")
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
84
98
|
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
85
99
|
"""The one length a clip holds in a join, for its picture and its sound alike: the
|
|
86
100
|
video stream's, or the audio stream's when the sound runs more than a frame past the picture
|
|
@@ -175,7 +189,8 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
175
189
|
die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
|
|
176
190
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
|
|
177
191
|
+ ("crossfade" if d else "butt join") + ")")
|
|
178
|
-
|
|
192
|
+
overlap = overlap_report(durs, expected)
|
|
193
|
+
emit(output, mode="audio", **overlap, **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
|
|
179
194
|
sample_rate=rate, channels=channels, video=False)
|
|
180
195
|
return 0
|
|
181
196
|
|
|
@@ -568,7 +583,7 @@ def main() -> int:
|
|
|
568
583
|
verification.append({"step": "duration", "property": "video_duration", "ok": abs(measured - expected) <= tol,
|
|
569
584
|
"measured": round(measured, 3), "expected": expected,
|
|
570
585
|
"delta": round(measured - expected, 3), "tolerance": round(tol, 3),
|
|
571
|
-
"provenance": "input_premeasurement"})
|
|
586
|
+
"provenance": "input_premeasurement", "inputs_total": round(sum(lens), 3)})
|
|
572
587
|
# a clip with no audio stream is joined over generated silence: the join is right to do that, but
|
|
573
588
|
# the report must say which clips' sound is not theirs (the paper's join finding: absence was
|
|
574
589
|
# filled and nothing in the result showed it)
|
|
@@ -577,7 +592,8 @@ def main() -> int:
|
|
|
577
592
|
STATE_NOTES.append(f"{len(substituted)} clip(s) had no audio stream; their span of the join is generated "
|
|
578
593
|
"silence: " + ", ".join(os.path.basename(p) for p in substituted))
|
|
579
594
|
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
580
|
-
|
|
595
|
+
overlap = overlap_report(lens, expected)
|
|
596
|
+
emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
581
597
|
verification=verification, expect_streams={"video": True, "audio": True},
|
|
582
598
|
audio_substituted=substituted,
|
|
583
599
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
package/scripts/loudness.py
CHANGED
|
@@ -20,6 +20,8 @@ import re
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
from _platforms import TP_TOLERANCE_DB
|
|
23
|
+
|
|
24
|
+
AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
23
25
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
24
26
|
|
|
25
27
|
|
|
@@ -93,6 +95,12 @@ def main() -> int:
|
|
|
93
95
|
bitrate = args.audio_bitrate or "192k"
|
|
94
96
|
|
|
95
97
|
dropped_streams = False
|
|
98
|
+
# loudnorm emits its last block whole, and through the AAC encoder that came out as 0.1 s more
|
|
99
|
+
# sound than the input had (a 90.000 s Reels export left at 90.100 s, which check.py fails):
|
|
100
|
+
# the sound is trimmed back to the input's own length. `duration=` counts from the first sample,
|
|
101
|
+
# so sound that starts late keeps its end (`end=` is a timestamp, and cut it). atrim follows
|
|
102
|
+
# loudnorm directly: an aresample between them fails channel-layout negotiation on FFmpeg 5.1
|
|
103
|
+
in_audio = audio_length(meta)
|
|
96
104
|
|
|
97
105
|
def encode(tp: float, bitrate: str) -> None:
|
|
98
106
|
nonlocal dropped_streams
|
|
@@ -101,6 +109,8 @@ def main() -> int:
|
|
|
101
109
|
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
102
110
|
f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
|
|
103
111
|
)
|
|
112
|
+
if in_audio:
|
|
113
|
+
af += f",atrim=duration={float(in_audio):.6f}"
|
|
104
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
105
115
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
106
116
|
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
@@ -158,19 +168,40 @@ def main() -> int:
|
|
|
158
168
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
159
169
|
kind="verification", output=output, result=result,
|
|
160
170
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
171
|
+
verification = [{"step": "loudness",
|
|
172
|
+
"ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
|
|
173
|
+
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
|
|
174
|
+
"property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
|
|
175
|
+
"measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
|
|
176
|
+
"delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
|
|
177
|
+
"tolerance": {"lufs": 1.0, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
|
|
178
|
+
written = probe(output, role="output")
|
|
179
|
+
out_audio = audio_length(written)
|
|
180
|
+
if in_audio and out_audio:
|
|
181
|
+
# a levels pass changes no timing: the sound is as long as the input's, to one AAC frame
|
|
182
|
+
tol = max(1024.0 / float(sr), AUDIO_LENGTH_TOLERANCE)
|
|
183
|
+
verification.append({"step": "duration", "property": "audio_duration", "ok": abs(out_audio - in_audio) <= tol,
|
|
184
|
+
"expected": round(float(in_audio), 3), "measured": round(float(out_audio), 3),
|
|
185
|
+
"delta": round(float(out_audio) - float(in_audio), 3), "tolerance": round(tol, 3),
|
|
186
|
+
"provenance": "input_premeasurement"})
|
|
187
|
+
else:
|
|
188
|
+
# a length that could not be read is not a length that matched
|
|
189
|
+
verification.append({"step": "duration", "property": "audio_duration", "ok": False,
|
|
190
|
+
"reason": "not_measured: the " + ("input" if not in_audio else "output") + " reports no duration"})
|
|
161
191
|
emit(output, result=result, dropped_non_av_streams=dropped_streams,
|
|
162
192
|
measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
|
|
163
|
-
targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra},
|
|
164
|
-
verification=[{"step": "loudness",
|
|
165
|
-
"ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
|
|
166
|
-
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
|
|
167
|
-
"property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
|
|
168
|
-
"measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
|
|
169
|
-
"delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
|
|
170
|
-
"tolerance": {"lufs": 1.0, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}])
|
|
193
|
+
targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra}, verification=verification)
|
|
171
194
|
return 0
|
|
172
195
|
|
|
173
196
|
|
|
197
|
+
def audio_length(meta: dict) -> "Optional[float]":
|
|
198
|
+
"""The sound's own length: the stream's duration, else Matroska's DURATION tag. Never the
|
|
199
|
+
container's, which is the picture's when the picture runs longer (and would fail a correct
|
|
200
|
+
output against an expected length the sound never had)."""
|
|
201
|
+
audio = meta.get("audio") or {}
|
|
202
|
+
return audio.get("duration") or audio.get("tagged_duration")
|
|
203
|
+
|
|
204
|
+
|
|
174
205
|
def _kbps(value: str) -> int:
|
|
175
206
|
v = value.lower().rstrip("k")
|
|
176
207
|
try:
|
package/scripts/silence.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
17
|
import sys
|
|
18
|
-
from typing import List, Tuple
|
|
18
|
+
from typing import Any, Dict, List, Tuple
|
|
19
19
|
|
|
20
20
|
# `detect` moved into _common/probe.py in 1.16.0 so metadata.py --auto-chapters can measure the
|
|
21
21
|
# same silences without importing this tool; the body is unchanged and the name still lives here.
|
|
@@ -44,6 +44,7 @@ def merge_spans(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
|
|
|
44
44
|
return out
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
DIGITAL_SILENCE_DB = -90.0 # 2.20: at or below this a span is digital silence (16-bit dither sits near -96)
|
|
47
48
|
BREATH_FLOOR = 0.12 # shortest gap silencedetect is asked for under --speech-aware
|
|
48
49
|
|
|
49
50
|
|
|
@@ -255,6 +256,33 @@ def _words_from_transcript(data):
|
|
|
255
256
|
return out, had_segments, untimed
|
|
256
257
|
|
|
257
258
|
|
|
259
|
+
def silence_kinds(path: str, silences: "List[Tuple[float, float]]", duration: float,
|
|
260
|
+
threshold: float) -> "Dict[str, Any]":
|
|
261
|
+
"""Which detected silences are digital silence and which are quiet sound under the threshold
|
|
262
|
+
(2.20). Both are removed alike, but they are not the same finding: a room tone, a breath or a
|
|
263
|
+
whisper under --threshold is sound, and a report that called all of it "silence" hid that the
|
|
264
|
+
cut took audible material. One more silencedetect pass at DIGITAL_SILENCE_DB finds the digital
|
|
265
|
+
spans; a detected silence that is at least 99% digital counts as digital."""
|
|
266
|
+
if not silences or STATE.dry_run:
|
|
267
|
+
return {}
|
|
268
|
+
digital = [(s, duration if e == float("inf") else e) for s, e in detect(path, DIGITAL_SILENCE_DB, 0.02)]
|
|
269
|
+
labels, tally = [], {"digital": [0, 0.0], "below_threshold": [0, 0.0]}
|
|
270
|
+
for s, e in silences:
|
|
271
|
+
e = duration if e == float("inf") else e
|
|
272
|
+
span = max(e - s, 1e-9)
|
|
273
|
+
covered = sum(max(0.0, min(e, de) - max(s, ds)) for ds, de in digital)
|
|
274
|
+
kind = "digital" if covered / span >= 0.99 else "below_threshold"
|
|
275
|
+
labels.append(kind)
|
|
276
|
+
tally[kind][0] += 1
|
|
277
|
+
tally[kind][1] += e - s
|
|
278
|
+
return {"silence_kinds": labels,
|
|
279
|
+
"silence_breakdown": {"digital": {"count": tally["digital"][0], "seconds": round(tally["digital"][1], 3),
|
|
280
|
+
"level_db": DIGITAL_SILENCE_DB},
|
|
281
|
+
"below_threshold": {"count": tally["below_threshold"][0],
|
|
282
|
+
"seconds": round(tally["below_threshold"][1], 3),
|
|
283
|
+
"level_db": threshold}}}
|
|
284
|
+
|
|
285
|
+
|
|
258
286
|
def main() -> int:
|
|
259
287
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
260
288
|
ap.add_argument("input")
|
|
@@ -344,6 +372,7 @@ def main() -> int:
|
|
|
344
372
|
if args.filler:
|
|
345
373
|
silence_keeps = keep_ranges(merge_spans(list(silences)), duration, args.margin, args.min_keep)
|
|
346
374
|
silence_only = max(0.0, duration - sum(e - s for s, e in silence_keeps))
|
|
375
|
+
kinds = silence_kinds(args.input, silences, duration, args.threshold)
|
|
347
376
|
summary = {
|
|
348
377
|
"silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
|
|
349
378
|
"keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
|
|
@@ -351,6 +380,8 @@ def main() -> int:
|
|
|
351
380
|
"kept_duration": round(kept, 3),
|
|
352
381
|
"removed_seconds": round(silence_only, 3),
|
|
353
382
|
}
|
|
383
|
+
if kinds:
|
|
384
|
+
summary.update(kinds)
|
|
354
385
|
if args.speech_aware:
|
|
355
386
|
summary["speech_aware"] = {
|
|
356
387
|
"min_silence": args.min_silence, "floor": min(BREATH_FLOOR, args.min_silence),
|