fillercut 1.2.4__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.
- fillercut/__init__.py +22 -0
- fillercut/assets/__init__.py +196 -0
- fillercut/assets/manifest.json +43 -0
- fillercut/audio/__init__.py +1 -0
- fillercut/audio/extractor.py +119 -0
- fillercut/audio/probe.py +98 -0
- fillercut/audio/silence.py +161 -0
- fillercut/cli.py +870 -0
- fillercut/config.py +492 -0
- fillercut/detect/__init__.py +1 -0
- fillercut/detect/fillers.py +149 -0
- fillercut/detect/silence.py +40 -0
- fillercut/export/__init__.py +15 -0
- fillercut/export/fcp7.py +281 -0
- fillercut/export/medya.py +289 -0
- fillercut/export/srt.py +245 -0
- fillercut/gunluk.py +205 -0
- fillercut/kurulum/__init__.py +16 -0
- fillercut/kurulum/indir.py +314 -0
- fillercut/kurulum/yollar.py +235 -0
- fillercut/models.py +141 -0
- fillercut/pipeline.py +719 -0
- fillercut/plan/__init__.py +1 -0
- fillercut/plan/cutplan.py +458 -0
- fillercut/render/__init__.py +1 -0
- fillercut/render/encoder.py +429 -0
- fillercut/render/render.py +220 -0
- fillercut/report/__init__.py +1 -0
- fillercut/report/html_report.py +409 -0
- fillercut/report/json_report.py +379 -0
- fillercut/report/review_server.py +197 -0
- fillercut/surec.py +91 -0
- fillercut/transcribe/__init__.py +1 -0
- fillercut/transcribe/base.py +48 -0
- fillercut/transcribe/fw_backend.py +176 -0
- fillercut/transcribe/reanchor.py +145 -0
- fillercut/transcribe/wcpp_backend.py +290 -0
- fillercut/web/__init__.py +7 -0
- fillercut/web/app.py +252 -0
- fillercut/web/fs.py +535 -0
- fillercut/web/geri_bildirim.py +140 -0
- fillercut/web/jobs.py +730 -0
- fillercut/web/kurulum.py +276 -0
- fillercut/web/native.py +483 -0
- fillercut/web/review.py +629 -0
- fillercut/web/static/app.js +1582 -0
- fillercut/web/static/index.html +294 -0
- fillercut/web/static/style.css +830 -0
- fillercut/web/waveform.py +86 -0
- fillercut-1.2.4.dist-info/METADATA +417 -0
- fillercut-1.2.4.dist-info/RECORD +54 -0
- fillercut-1.2.4.dist-info/WHEEL +4 -0
- fillercut-1.2.4.dist-info/entry_points.txt +2 -0
- fillercut-1.2.4.dist-info/licenses/LICENSE +21 -0
fillercut/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Filler-Cut — konuşma analiziyle filler ve sessizlik kesen CLI aracı.
|
|
2
|
+
|
|
3
|
+
**Versiyonun tek doğruluk kaynağı `pyproject.toml`'dır.** Burada sabit bir
|
|
4
|
+
sürüm dizesi TUTULMAZ: v0.3.1'de `--version`'ın bayat `0.1.0` basmasının kök
|
|
5
|
+
sebebi versiyonun iki ayrı yerde yazılmasıydı. Runtime'da kurulu dağıtımın
|
|
6
|
+
metadata'sı okunur; böylece bump tek yerde (`pyproject.toml`) yapılır.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
#: pyproject.toml'daki `[project] name` — dağıtım adı (paket adıyla aynı).
|
|
12
|
+
#: `tests/test_cli.py` bu eşitliği pyproject'ten okuyarak kilitler.
|
|
13
|
+
DIST_NAME = "fillercut"
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version(DIST_NAME)
|
|
17
|
+
except PackageNotFoundError: # pragma: no cover - kurulu değilken (çıplak import)
|
|
18
|
+
# Repo kökünden `sys.path` hilesiyle import edilmiş olabilir; sürüm bilgisi
|
|
19
|
+
# yoktur ama import patlamamalıdır.
|
|
20
|
+
__version__ = "0.0.0+notinstalled"
|
|
21
|
+
|
|
22
|
+
__all__ = ["DIST_NAME", "__version__"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""İndirme manifesti — sihirbazın ne indireceğinin tek doğruluk kaynağı.
|
|
2
|
+
|
|
3
|
+
`manifest.json` **veridir**: her varlığın adı, URL'si, boyutu ve SHA-256'sı
|
|
4
|
+
orada durur; bu modül onu okur, doğrular ve tipli hale getirir. İndirme
|
|
5
|
+
motoru (`kurulum/indir.py`) hash ve boyutu buradan alır — yani "ne indirdik"
|
|
6
|
+
sorusunun cevabı koda gömülü değildir.
|
|
7
|
+
|
|
8
|
+
**Manifest'teki her hash gerçek indirmeden hesaplanmıştır** ve HF'in kendi
|
|
9
|
+
API'siyle (`siblings[].lfs.sha256`) çapraz doğrulanmıştır; ölçüm ve kaynak
|
|
10
|
+
seçimi kararı `experiments/download_spike/README.md`'de. Bir değeri
|
|
11
|
+
"güncellerken" indirmeden değiştirmek sessizce bozar — `tests/test_assets.py`
|
|
12
|
+
ölçülen değerleri ayrıca kilitler.
|
|
13
|
+
|
|
14
|
+
**Tuzak:** HF'in `ETag` başlığı 64 hex karakterdir ama SHA-256 DEĞİLDİR (xet
|
|
15
|
+
içerik hash'i). "ETag'i hash diye yaz" kestirmesi ölçüldü ve yanlış çıktı.
|
|
16
|
+
|
|
17
|
+
Kaynak seçimi (spike kararı): modeller resmi upstream'den (Hugging Face
|
|
18
|
+
`ggerganov/whisper.cpp`), wcpp binary'si **kendi** GitHub Release
|
|
19
|
+
asset'imizden — upstream whisper.cpp Windows release'leri Vulkan binary'si
|
|
20
|
+
yayınlamıyor (bkz. AGENTS.md, Vulkan dağıtım hattı).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
#: Paket içi manifest yolu. PyInstaller fazı bu dosyayı bundle'lamalı
|
|
31
|
+
#: (`--add-data`); wheel'e `[tool.hatch.build]` üzerinden zaten giriyor.
|
|
32
|
+
MANIFEST_YOLU = Path(__file__).parent / "manifest.json"
|
|
33
|
+
|
|
34
|
+
#: Şema sürümü. Alan EKLEMEK bump gerektirmez (bilinmeyen alan hata verir,
|
|
35
|
+
#: yani eski kod yeni manifest'i okumaz — bump o zaman gerekir).
|
|
36
|
+
MANIFEST_VERSION = 1
|
|
37
|
+
|
|
38
|
+
#: Zorunlu alanlar — eksikse `ManifestHatasi`.
|
|
39
|
+
_ZORUNLU = ("ad", "url", "sha256", "boyut", "tur", "varsayilan_mi")
|
|
40
|
+
|
|
41
|
+
#: İzinli alanların tamamı (zorunlular + opsiyoneller). Bilinmeyen anahtar
|
|
42
|
+
#: SESSİZCE YUTULMAZ: config.py'nin "bilinmeyen anahtar → uyarı" davranışının
|
|
43
|
+
#: aksine burada hata veririz, çünkü manifest kullanıcı dosyası değil bizim
|
|
44
|
+
#: veri dosyamızdır — yazım hatası bug'dır.
|
|
45
|
+
_IZINLI = frozenset(_ZORUNLU) | {"aciklama", "arsiv", "calistirilabilir"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ManifestHatasi(Exception):
|
|
49
|
+
"""Manifest okunamadı/geçersiz — Türkçe, eyleme dökülebilir mesaj."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Varlik:
|
|
54
|
+
"""Manifest'teki tek indirilebilir varlık.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
ad: Tekil kimlik; CLI (`fillercut setup --model AD`) ve UI bunu kullanır.
|
|
58
|
+
tur: ``"binary"`` ya da ``"model"``.
|
|
59
|
+
url: Doğrudan indirme adresi (yalnız https).
|
|
60
|
+
sha256: İndirilen baytların SHA-256'sı (64 hex).
|
|
61
|
+
boyut: Bayt cinsinden tam boyut — disk alanı kontrolü ve ilerleme
|
|
62
|
+
yüzdesi bundan hesaplanır.
|
|
63
|
+
varsayilan_mi: Sihirbazda önceden seçili gelir.
|
|
64
|
+
aciklama: UI'da kullanıcıya gösterilen tek satır.
|
|
65
|
+
arsiv: ``"zip"`` ise indirme sonrası açılır; ``None`` ise dosya
|
|
66
|
+
doğrudan hedefe konur.
|
|
67
|
+
calistirilabilir: Arşiv içindeki çalıştırılabilir dosyanın adı
|
|
68
|
+
(arşiv DÜZ yapıdadır — DLL'ler exe'nin YANINDA olmak zorunda,
|
|
69
|
+
o yüzden arşivin tamamı tek dizine açılır).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
ad: str
|
|
73
|
+
tur: str
|
|
74
|
+
url: str
|
|
75
|
+
sha256: str
|
|
76
|
+
boyut: int
|
|
77
|
+
varsayilan_mi: bool
|
|
78
|
+
aciklama: str = ""
|
|
79
|
+
arsiv: str | None = None
|
|
80
|
+
calistirilabilir: str | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def dosya_adi(self) -> str:
|
|
84
|
+
"""URL'nin son parçası — diske bu adla yazılır."""
|
|
85
|
+
return self.url.rsplit("/", 1)[-1]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _varlik_kur(ham: dict[str, Any]) -> Varlik:
|
|
89
|
+
eksik = [a for a in _ZORUNLU if a not in ham]
|
|
90
|
+
if eksik:
|
|
91
|
+
raise ManifestHatasi(
|
|
92
|
+
f"manifest girdisinde eksik alan: {', '.join(eksik)} "
|
|
93
|
+
f"(girdi: {ham.get('ad', '?')})"
|
|
94
|
+
)
|
|
95
|
+
fazla = set(ham) - _IZINLI
|
|
96
|
+
if fazla:
|
|
97
|
+
raise ManifestHatasi(
|
|
98
|
+
f"manifest girdisinde bilinmeyen alan: {', '.join(sorted(fazla))} "
|
|
99
|
+
f"(girdi: {ham.get('ad', '?')})"
|
|
100
|
+
)
|
|
101
|
+
if ham["tur"] not in ("binary", "model"):
|
|
102
|
+
raise ManifestHatasi(f"bilinmeyen tur: {ham['tur']!r} (binary|model)")
|
|
103
|
+
return Varlik(
|
|
104
|
+
ad=str(ham["ad"]),
|
|
105
|
+
tur=str(ham["tur"]),
|
|
106
|
+
url=str(ham["url"]),
|
|
107
|
+
sha256=str(ham["sha256"]),
|
|
108
|
+
boyut=int(ham["boyut"]),
|
|
109
|
+
varsayilan_mi=bool(ham["varsayilan_mi"]),
|
|
110
|
+
aciklama=str(ham.get("aciklama", "")),
|
|
111
|
+
arsiv=ham.get("arsiv"),
|
|
112
|
+
calistirilabilir=ham.get("calistirilabilir"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def manifest_yukle(yol: Path | None = None) -> tuple[Varlik, ...]:
|
|
117
|
+
"""Manifest'i okur ve doğrular; her çağrıda diskten (cache YOK).
|
|
118
|
+
|
|
119
|
+
Cache bilinçli olarak yok: dosya paket içindedir ve okuma maliyeti
|
|
120
|
+
ihmal edilebilir; cache'lemek testlerde ve PyInstaller'da bayat durum
|
|
121
|
+
riski yaratırdı.
|
|
122
|
+
"""
|
|
123
|
+
hedef = yol if yol is not None else MANIFEST_YOLU
|
|
124
|
+
try:
|
|
125
|
+
ham = json.loads(hedef.read_text(encoding="utf-8"))
|
|
126
|
+
except FileNotFoundError as exc:
|
|
127
|
+
raise ManifestHatasi(f"manifest bulunamadı: {hedef}") from exc
|
|
128
|
+
except (OSError, ValueError) as exc:
|
|
129
|
+
raise ManifestHatasi(f"manifest okunamadı ({hedef}): {exc}") from exc
|
|
130
|
+
|
|
131
|
+
if not isinstance(ham, dict) or "varliklar" not in ham:
|
|
132
|
+
raise ManifestHatasi(f"manifest biçimi geçersiz: {hedef}")
|
|
133
|
+
surum = ham.get("manifest_version")
|
|
134
|
+
if surum != MANIFEST_VERSION:
|
|
135
|
+
raise ManifestHatasi(
|
|
136
|
+
f"desteklenmeyen manifest_version: {surum} "
|
|
137
|
+
f"(bu sürüm {MANIFEST_VERSION} bekliyor)"
|
|
138
|
+
)
|
|
139
|
+
return tuple(_varlik_kur(g) for g in ham["varliklar"])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def varliklar() -> tuple[Varlik, ...]:
|
|
143
|
+
"""Paket içi manifest'teki tüm varlıklar."""
|
|
144
|
+
return manifest_yukle()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def modeller() -> tuple[Varlik, ...]:
|
|
148
|
+
"""Yalnız modeller — sihirbazın model seçicisi bunu listeler."""
|
|
149
|
+
return tuple(v for v in varliklar() if v.tur == "model")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def binary_varligi() -> Varlik:
|
|
153
|
+
"""Tek wcpp binary'si (Vulkan win-x64).
|
|
154
|
+
|
|
155
|
+
GPU tespiti / CUDA-vs-Vulkan seçimi YOKTUR (kapsam dışı): tek Vulkan
|
|
156
|
+
ikilisi üç donanım ailesini de sürüyor, CUDA yolu ileri kullanıcı için
|
|
157
|
+
manuel kalır.
|
|
158
|
+
"""
|
|
159
|
+
for v in varliklar():
|
|
160
|
+
if v.tur == "binary":
|
|
161
|
+
return v
|
|
162
|
+
raise ManifestHatasi("manifest'te binary varlığı yok")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def varsayilan_model() -> Varlik:
|
|
166
|
+
"""Sihirbazda önceden seçili gelen model."""
|
|
167
|
+
for m in modeller():
|
|
168
|
+
if m.varsayilan_mi:
|
|
169
|
+
return m
|
|
170
|
+
raise ManifestHatasi("manifest'te varsayılan model işaretlenmemiş")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def varlik_bul(ad: str) -> Varlik:
|
|
174
|
+
"""Ada göre varlık; yoksa geçerli adları sayan Türkçe hata."""
|
|
175
|
+
tumu = varliklar()
|
|
176
|
+
for v in tumu:
|
|
177
|
+
if v.ad == ad:
|
|
178
|
+
return v
|
|
179
|
+
raise ManifestHatasi(
|
|
180
|
+
f"bilinmeyen varlık adı: {ad!r} — geçerli adlar: "
|
|
181
|
+
+ ", ".join(v.ad for v in tumu)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = [
|
|
186
|
+
"MANIFEST_VERSION",
|
|
187
|
+
"MANIFEST_YOLU",
|
|
188
|
+
"ManifestHatasi",
|
|
189
|
+
"Varlik",
|
|
190
|
+
"binary_varligi",
|
|
191
|
+
"manifest_yukle",
|
|
192
|
+
"modeller",
|
|
193
|
+
"varlik_bul",
|
|
194
|
+
"varliklar",
|
|
195
|
+
"varsayilan_model",
|
|
196
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 1,
|
|
3
|
+
"varliklar": [
|
|
4
|
+
{
|
|
5
|
+
"ad": "whisper-cli-vulkan-win-x64",
|
|
6
|
+
"tur": "binary",
|
|
7
|
+
"url": "https://github.com/inanx12/Filler-Cut/releases/download/v1.1.0/fillercut-whisper-cli-vulkan-win-x64.zip",
|
|
8
|
+
"sha256": "01707efa01523d042cd1b5980dabc43c70a68509360b16ee77d23317b90ccdf9",
|
|
9
|
+
"boyut": 23672623,
|
|
10
|
+
"varsayilan_mi": true,
|
|
11
|
+
"arsiv": "zip",
|
|
12
|
+
"calistirilabilir": "whisper-cli.exe",
|
|
13
|
+
"aciklama": "whisper.cpp / Vulkan (AMD, Intel, NVIDIA - ayni ikili)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"ad": "ggml-large-v3-turbo-q5_0",
|
|
17
|
+
"tur": "model",
|
|
18
|
+
"url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin",
|
|
19
|
+
"sha256": "394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2",
|
|
20
|
+
"boyut": 574041195,
|
|
21
|
+
"varsayilan_mi": true,
|
|
22
|
+
"aciklama": "Onerilen: hiz ve dogruluk dengesi (Turkce icin repo referansi)"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"ad": "ggml-small-q5_1",
|
|
26
|
+
"tur": "model",
|
|
27
|
+
"url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin",
|
|
28
|
+
"sha256": "ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb",
|
|
29
|
+
"boyut": 190085487,
|
|
30
|
+
"varsayilan_mi": false,
|
|
31
|
+
"aciklama": "Hafif: yavas baglanti veya dar disk icin; dogruluk daha dusuk"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"ad": "ggml-large-v3-q5_0",
|
|
35
|
+
"tur": "model",
|
|
36
|
+
"url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin",
|
|
37
|
+
"sha256": "d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1",
|
|
38
|
+
"boyut": 1081140203,
|
|
39
|
+
"varsayilan_mi": false,
|
|
40
|
+
"aciklama": "Kalite agirlikli: turbo olmayan large-v3, en yavas secenek"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Katman 1 — EXTRACT: ses çıkarımı ve dalga formu analizi."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Katman 1 — EXTRACT: ffmpeg ile videodan 16 kHz mono WAV çıkarımı.
|
|
2
|
+
|
|
3
|
+
ASR ve sessizlik tespiti için tek kanallı, 16 kHz WAV yeterlidir; orijinal
|
|
4
|
+
ses kanalı/kazancı korunmaz çünkü bu dosya sadece analiz içindir (DESIGN.md §2).
|
|
5
|
+
|
|
6
|
+
Bu modül bilerek sadece standart kütüphaneyi kullanır: birim testleri
|
|
7
|
+
`subprocess.run`'ı mock'layarak ffmpeg olmadan da çalışır.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fillercut import surec
|
|
17
|
+
|
|
18
|
+
#: ASR backend'lerinin beklediği örnekleme hızı (Hz).
|
|
19
|
+
SAMPLE_RATE = 16_000
|
|
20
|
+
#: Analiz için mono yeterli.
|
|
21
|
+
CHANNELS = 1
|
|
22
|
+
|
|
23
|
+
#: Hata mesajında gösterilecek maksimum stderr uzunluğu.
|
|
24
|
+
_STDERR_TAIL = 400
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExtractionError(RuntimeError):
|
|
28
|
+
"""ffmpeg çıkarımı başarısız olduğunda fırlatılır."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_command(input_path: Path, output_path: Path) -> list[str]:
|
|
32
|
+
"""ffmpeg komut satırını üretir.
|
|
33
|
+
|
|
34
|
+
Saf fonksiyondur — yan etkisi yoktur, testler doğrudan bunu doğrular.
|
|
35
|
+
"""
|
|
36
|
+
return [
|
|
37
|
+
"ffmpeg",
|
|
38
|
+
"-y", # çıktı varsa soru sormadan üzerine yaz
|
|
39
|
+
"-i",
|
|
40
|
+
str(input_path),
|
|
41
|
+
"-vn", # video akışını at
|
|
42
|
+
"-ac",
|
|
43
|
+
str(CHANNELS),
|
|
44
|
+
"-ar",
|
|
45
|
+
str(SAMPLE_RATE),
|
|
46
|
+
"-f",
|
|
47
|
+
"wav",
|
|
48
|
+
str(output_path),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_output_path(input_path: Path) -> Path:
|
|
53
|
+
"""Girdiyle aynı klasörde, aynı isimli `.wav` yolu."""
|
|
54
|
+
return input_path.with_suffix(".wav")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_audio(
|
|
58
|
+
input_path: str | Path,
|
|
59
|
+
output_path: str | Path | None = None,
|
|
60
|
+
*,
|
|
61
|
+
timeout: float = 600.0,
|
|
62
|
+
) -> Path:
|
|
63
|
+
"""Videodan 16 kHz mono WAV çıkarır.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
input_path: Kaynak video (veya ses) dosyası.
|
|
67
|
+
output_path: Hedef WAV; verilmezse girdinin yanına `<isim>.wav` yazılır.
|
|
68
|
+
timeout: ffmpeg işlemi için saniye cinsinden üst sınır.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Üretilen WAV dosyasının yolu.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
FileNotFoundError: Girdi dosyası yoksa.
|
|
75
|
+
ExtractionError: ffmpeg bulunamazsa, sıfırdan farklı kodla çıkarsa,
|
|
76
|
+
süre aşımına uğrarsa veya çıktıyı üretemezse.
|
|
77
|
+
"""
|
|
78
|
+
src = Path(input_path)
|
|
79
|
+
if not src.is_file():
|
|
80
|
+
raise FileNotFoundError(f"girdi dosyası bulunamadı: {src}")
|
|
81
|
+
|
|
82
|
+
if shutil.which("ffmpeg") is None:
|
|
83
|
+
raise ExtractionError(
|
|
84
|
+
"ffmpeg bulunamadı — PATH'e kurulu olmalı (bkz. README: sistem bağımlılığı)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
dst = Path(output_path) if output_path is not None else default_output_path(src)
|
|
88
|
+
cmd = build_command(src, dst)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
proc = surec.kos(
|
|
92
|
+
cmd,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
# `errors` olmadan decode STRICT'tir ve hata subprocess.run
|
|
96
|
+
# ÇAĞRISININ KENDİSİNDE UnicodeDecodeError olarak patlar; aşağıdaki
|
|
97
|
+
# ExtractionError sarması hiç devreye giremez. ffmpeg banner'ı locale
|
|
98
|
+
# encoding'inde (Windows-TR: cp1254) çözülemeyen byte içerebilir —
|
|
99
|
+
# Türkçe dosya adları, kaynak metadata'sı. Locale encoding'i korunur:
|
|
100
|
+
# okunan şey yalnızca kullanıcıya gösterilen hata kuyruğudur.
|
|
101
|
+
errors="replace",
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
check=False,
|
|
104
|
+
)
|
|
105
|
+
except subprocess.TimeoutExpired as exc:
|
|
106
|
+
raise ExtractionError(
|
|
107
|
+
f"ffmpeg {timeout:.0f} sn içinde bitmedi: {src}"
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
tail = (proc.stderr or "").strip()[-_STDERR_TAIL:]
|
|
112
|
+
raise ExtractionError(
|
|
113
|
+
f"ffmpeg hata kodu {proc.returncode} ile çıktı: {src}\n{tail}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not dst.is_file() or dst.stat().st_size == 0:
|
|
117
|
+
raise ExtractionError(f"ffmpeg çıktı üretmedi: {dst}")
|
|
118
|
+
|
|
119
|
+
return dst
|
fillercut/audio/probe.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""ffprobe ile medya süresi — ms-int (zaman birimi kuralı, models.py docstring'i).
|
|
2
|
+
|
|
3
|
+
`total_ms` iki yerde zorunludur: `audio/silence.py`'nin kapanmamış sessizliği
|
|
4
|
+
uzatması ve `report/json_report.py`'nin plan/gerçeklik uyuşma kontrolü. Tek
|
|
5
|
+
ffprobe çağrısıyla alınır (pipeline [1] EXTRACT öncesi okur).
|
|
6
|
+
|
|
7
|
+
Saf/yan-etki ayrımı (extractor deseni): `parse_duration` saf fonksiyondur
|
|
8
|
+
(str → int ms); subprocess çağrısı `probe_duration_ms` wrapper'ındadır.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from fillercut import surec
|
|
18
|
+
|
|
19
|
+
#: Hata mesajında gösterilecek maksimum stderr uzunluğu.
|
|
20
|
+
_STDERR_TAIL = 400
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProbeError(RuntimeError):
|
|
24
|
+
"""ffprobe çalıştırılamadığında/başarısız olduğunda fırlatılır."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_command(path: Path) -> list[str]:
|
|
28
|
+
"""ffprobe komut satırı — saf fonksiyon (extractor deseni).
|
|
29
|
+
|
|
30
|
+
`format=duration` container süresidir (saniye-float, örn. `14.814331`).
|
|
31
|
+
"""
|
|
32
|
+
return [
|
|
33
|
+
"ffprobe",
|
|
34
|
+
"-v",
|
|
35
|
+
"error",
|
|
36
|
+
"-show_entries",
|
|
37
|
+
"format=duration",
|
|
38
|
+
"-of",
|
|
39
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
40
|
+
str(path),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_duration(stdout: str) -> int:
|
|
45
|
+
"""ffprobe `format=duration` çıktısını (saniye-float) ms-int'e çevirir.
|
|
46
|
+
|
|
47
|
+
Saf fonksiyondur. `int()` yerine `round()`: kırpma değil en yakın ms —
|
|
48
|
+
audio/silence.py ile aynı kural.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ProbeError: Çıktı sayı değilse veya süre pozitif değilse.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
ms = int(round(float(stdout.strip()) * 1000))
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise ProbeError(f"ffprobe süre çıktısı parse edilemedi: {stdout.strip()!r}") from exc
|
|
57
|
+
if ms <= 0:
|
|
58
|
+
raise ProbeError(f"ffprobe pozitif olmayan süre döndü: {ms}ms")
|
|
59
|
+
return ms
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def probe_duration_ms(path: str | Path, *, timeout: float = 60.0) -> int:
|
|
63
|
+
"""Medya dosyasının süresini ffprobe ile ms-int olarak döner.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
FileNotFoundError: Girdi dosyası yoksa.
|
|
67
|
+
ProbeError: ffprobe bulunamazsa, hata koduyla çıkarsa, süre aşımına
|
|
68
|
+
uğrarsa veya çıktısı parse edilemezse.
|
|
69
|
+
"""
|
|
70
|
+
src = Path(path)
|
|
71
|
+
if not src.is_file():
|
|
72
|
+
raise FileNotFoundError(f"girdi dosyası bulunamadı: {src}")
|
|
73
|
+
if shutil.which("ffprobe") is None:
|
|
74
|
+
raise ProbeError("ffprobe bulunamadı — ffmpeg ile birlikte PATH'e kurulu olmalı")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
proc = surec.kos(
|
|
78
|
+
build_command(src),
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
# ffprobe çıktısı spec gereği UTF-8'dir (Türkçe `title` metadata'sı
|
|
82
|
+
# dahil); locale encoding'i (Windows-TR: cp1254) onu mojibake'e
|
|
83
|
+
# çevirir. `errors` olmadan decode strict'tir ve hata subprocess.run
|
|
84
|
+
# ÇAĞRISININ KENDİSİNDE UnicodeDecodeError olarak patlar — aşağıdaki
|
|
85
|
+
# ProbeError sarması hiç devreye giremez.
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
errors="replace",
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
except subprocess.TimeoutExpired as exc:
|
|
92
|
+
raise ProbeError(f"ffprobe {timeout:.0f} sn içinde bitmedi: {src}") from exc
|
|
93
|
+
|
|
94
|
+
if proc.returncode != 0:
|
|
95
|
+
tail = (proc.stderr or "").strip()[-_STDERR_TAIL:]
|
|
96
|
+
raise ProbeError(f"ffprobe hata kodu {proc.returncode} ile çıktı: {src}\n{tail}")
|
|
97
|
+
|
|
98
|
+
return parse_duration(proc.stdout)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Katman 3 — DETECT (dalga formu tarafı): ffmpeg `silencedetect` parse'ı.
|
|
2
|
+
|
|
3
|
+
**KRİTİK:** `silencedetect` sonuçları **stderr**'e yazar, stdout'a DEĞİL.
|
|
4
|
+
Komut::
|
|
5
|
+
|
|
6
|
+
ffmpeg -i in.wav -af silencedetect=noise=-35dB:d=0.4 -f null -
|
|
7
|
+
|
|
8
|
+
stderr'deki satırlar şöyledir::
|
|
9
|
+
|
|
10
|
+
[silencedetect @ 0000021f8a3b4c00] silence_start: 1.0234
|
|
11
|
+
[silencedetect @ 0000021f8a3b4c00] silence_end: 2.4576 | silence_duration: 1.4342
|
|
12
|
+
|
|
13
|
+
`parse_silence` saf fonksiyondur (str → list[Segment]); subprocess çağrısı
|
|
14
|
+
`detect_silence` wrapper'ındadır — extractor.py ile aynı desen.
|
|
15
|
+
|
|
16
|
+
Süre sınırlaması (`silence_min_ms`) burada UYGULANMAZ; o filtre
|
|
17
|
+
`detect/silence.py`'nin işi. Bu modül ffmpeg'in raporladığını aynen döner.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from fillercut import surec
|
|
28
|
+
from fillercut.models import Segment
|
|
29
|
+
|
|
30
|
+
#: Eşik sabitleri — config'e v0.2'de taşınacak (DESIGN.md §6).
|
|
31
|
+
NOISE_DB = -35
|
|
32
|
+
MIN_SILENCE_SEC = 0.4
|
|
33
|
+
|
|
34
|
+
_START_RE = re.compile(r"silence_start:\s*(-?\d+(?:\.\d+)?)")
|
|
35
|
+
_END_RE = re.compile(r"silence_end:\s*(-?\d+(?:\.\d+)?)\s*\|\s*silence_duration:")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SilenceDetectionError(RuntimeError):
|
|
39
|
+
"""ffmpeg silencedetect çalıştırılamadığında/başarısız olduğunda fırlatılır."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sn_to_ms(sn: float) -> int:
|
|
43
|
+
"""Saniye (float) → milisaniye (int).
|
|
44
|
+
|
|
45
|
+
`int()` yerine `round()`: 1.4342 sn → 1434 ms (kırpma değil, en yakın ms).
|
|
46
|
+
Kesim noktalarındaki birikimli kaymayı önler (ms-int disiplini).
|
|
47
|
+
"""
|
|
48
|
+
return int(round(sn * 1000))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_silence(stderr: str, total_duration_ms: int | None = None) -> list[Segment]:
|
|
52
|
+
"""silencedetect stderr çıktısını ``kind="silence"`` segmentlere çevirir.
|
|
53
|
+
|
|
54
|
+
Saf fonksiyondur — yan etki yok, ffmpeg olmadan test edilebilir.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
stderr: ffmpeg'in stderr çıktısı (banner/satır gürültüsü tolere edilir).
|
|
58
|
+
total_duration_ms: Dosya sessizlikle biterse ffmpeg `silence_end` BASMAZ;
|
|
59
|
+
açık kalan sessizlik bu değere uzatılır. Böyle bir durumda zorunludur.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: Kapanmamış `silence_start` varken `total_duration_ms`
|
|
63
|
+
verilmediyse.
|
|
64
|
+
"""
|
|
65
|
+
segments: list[Segment] = []
|
|
66
|
+
pending_start_ms: int | None = None
|
|
67
|
+
|
|
68
|
+
def _kapat(end_ms: int) -> None:
|
|
69
|
+
nonlocal pending_start_ms
|
|
70
|
+
if pending_start_ms is not None and end_ms > pending_start_ms:
|
|
71
|
+
segments.append(
|
|
72
|
+
Segment(
|
|
73
|
+
start_ms=pending_start_ms,
|
|
74
|
+
end_ms=end_ms,
|
|
75
|
+
kind="silence",
|
|
76
|
+
reason=(
|
|
77
|
+
f"sessizlik {end_ms - pending_start_ms}ms "
|
|
78
|
+
f"(noise={NOISE_DB}dB, min={MIN_SILENCE_SEC}s)"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
pending_start_ms = None
|
|
83
|
+
|
|
84
|
+
for line in stderr.splitlines():
|
|
85
|
+
if m := _START_RE.search(line):
|
|
86
|
+
pending_start_ms = _sn_to_ms(float(m.group(1)))
|
|
87
|
+
elif m := _END_RE.search(line):
|
|
88
|
+
_kapat(_sn_to_ms(float(m.group(1))))
|
|
89
|
+
|
|
90
|
+
if pending_start_ms is not None:
|
|
91
|
+
if total_duration_ms is None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"kapanmamış silence_start var (dosya sessizlikle bitiyor); "
|
|
94
|
+
"uzatma için total_duration_ms gerekli"
|
|
95
|
+
)
|
|
96
|
+
_kapat(total_duration_ms)
|
|
97
|
+
|
|
98
|
+
return segments
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_command(wav_path: Path) -> list[str]:
|
|
102
|
+
"""silencedetect komut satırı — saf fonksiyon (extractor deseni)."""
|
|
103
|
+
return [
|
|
104
|
+
"ffmpeg",
|
|
105
|
+
"-i",
|
|
106
|
+
str(wav_path),
|
|
107
|
+
"-af",
|
|
108
|
+
f"silencedetect=noise={NOISE_DB}dB:d={MIN_SILENCE_SEC}",
|
|
109
|
+
"-f",
|
|
110
|
+
"null",
|
|
111
|
+
"-",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def detect_silence(
|
|
116
|
+
wav_path: str | Path,
|
|
117
|
+
*,
|
|
118
|
+
total_duration_ms: int | None = None,
|
|
119
|
+
timeout: float = 600.0,
|
|
120
|
+
) -> list[Segment]:
|
|
121
|
+
"""WAV üzerinde silencedetect çalıştırır ve stderr'i parse eder.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
FileNotFoundError: Girdi dosyası yoksa.
|
|
125
|
+
SilenceDetectionError: ffmpeg yoksa veya hata koduyla çıkarsa.
|
|
126
|
+
"""
|
|
127
|
+
src = Path(wav_path)
|
|
128
|
+
if not src.is_file():
|
|
129
|
+
raise FileNotFoundError(f"girdi dosyası bulunamadı: {src}")
|
|
130
|
+
if shutil.which("ffmpeg") is None:
|
|
131
|
+
raise SilenceDetectionError("ffmpeg bulunamadı — PATH'e kurulu olmalı")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
proc = surec.kos(
|
|
135
|
+
build_command(src),
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
# `errors` olmadan decode STRICT'tir ve hata subprocess.run
|
|
139
|
+
# ÇAĞRISININ KENDİSİNDE UnicodeDecodeError olarak patlar; aşağıdaki
|
|
140
|
+
# SilenceDetectionError sarması hiç devreye giremez. Parse ettiğimiz
|
|
141
|
+
# `silence_start:`/`silence_end:` satırları saf ASCII'dir — Türkçe
|
|
142
|
+
# dosya adı yalnızca ATLANAN header satırlarında geçer, dolayısıyla
|
|
143
|
+
# U+FFFD'ye dönen byte'lar sonuçları etkilemez. Locale encoding'i
|
|
144
|
+
# korunur: ffmpeg log'u UTF-8 değil, locale encoding'indedir.
|
|
145
|
+
errors="replace",
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
check=False,
|
|
148
|
+
)
|
|
149
|
+
except subprocess.TimeoutExpired as exc:
|
|
150
|
+
raise SilenceDetectionError(
|
|
151
|
+
f"ffmpeg {timeout:.0f} sn içinde bitmedi: {src}"
|
|
152
|
+
) from exc
|
|
153
|
+
|
|
154
|
+
if proc.returncode != 0:
|
|
155
|
+
tail = (proc.stderr or "").strip()[-400:]
|
|
156
|
+
raise SilenceDetectionError(
|
|
157
|
+
f"ffmpeg hata kodu {proc.returncode} ile çıktı: {src}\n{tail}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# DİKKAT: sonuçlar stderr'de — stdout bilerek kullanılmıyor.
|
|
161
|
+
return parse_silence(proc.stderr, total_duration_ms=total_duration_ms)
|