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.
Files changed (54) hide show
  1. fillercut/__init__.py +22 -0
  2. fillercut/assets/__init__.py +196 -0
  3. fillercut/assets/manifest.json +43 -0
  4. fillercut/audio/__init__.py +1 -0
  5. fillercut/audio/extractor.py +119 -0
  6. fillercut/audio/probe.py +98 -0
  7. fillercut/audio/silence.py +161 -0
  8. fillercut/cli.py +870 -0
  9. fillercut/config.py +492 -0
  10. fillercut/detect/__init__.py +1 -0
  11. fillercut/detect/fillers.py +149 -0
  12. fillercut/detect/silence.py +40 -0
  13. fillercut/export/__init__.py +15 -0
  14. fillercut/export/fcp7.py +281 -0
  15. fillercut/export/medya.py +289 -0
  16. fillercut/export/srt.py +245 -0
  17. fillercut/gunluk.py +205 -0
  18. fillercut/kurulum/__init__.py +16 -0
  19. fillercut/kurulum/indir.py +314 -0
  20. fillercut/kurulum/yollar.py +235 -0
  21. fillercut/models.py +141 -0
  22. fillercut/pipeline.py +719 -0
  23. fillercut/plan/__init__.py +1 -0
  24. fillercut/plan/cutplan.py +458 -0
  25. fillercut/render/__init__.py +1 -0
  26. fillercut/render/encoder.py +429 -0
  27. fillercut/render/render.py +220 -0
  28. fillercut/report/__init__.py +1 -0
  29. fillercut/report/html_report.py +409 -0
  30. fillercut/report/json_report.py +379 -0
  31. fillercut/report/review_server.py +197 -0
  32. fillercut/surec.py +91 -0
  33. fillercut/transcribe/__init__.py +1 -0
  34. fillercut/transcribe/base.py +48 -0
  35. fillercut/transcribe/fw_backend.py +176 -0
  36. fillercut/transcribe/reanchor.py +145 -0
  37. fillercut/transcribe/wcpp_backend.py +290 -0
  38. fillercut/web/__init__.py +7 -0
  39. fillercut/web/app.py +252 -0
  40. fillercut/web/fs.py +535 -0
  41. fillercut/web/geri_bildirim.py +140 -0
  42. fillercut/web/jobs.py +730 -0
  43. fillercut/web/kurulum.py +276 -0
  44. fillercut/web/native.py +483 -0
  45. fillercut/web/review.py +629 -0
  46. fillercut/web/static/app.js +1582 -0
  47. fillercut/web/static/index.html +294 -0
  48. fillercut/web/static/style.css +830 -0
  49. fillercut/web/waveform.py +86 -0
  50. fillercut-1.2.4.dist-info/METADATA +417 -0
  51. fillercut-1.2.4.dist-info/RECORD +54 -0
  52. fillercut-1.2.4.dist-info/WHEEL +4 -0
  53. fillercut-1.2.4.dist-info/entry_points.txt +2 -0
  54. 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
@@ -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)