primocode 10.16.0 → 10.17.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.
Files changed (234) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +11 -2
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,971 @@
1
+ """Background-noise removal for the voice: `audio.denoise` in edit_data.json ("off", "leve", "forte").
2
+
3
+ The method was chosen by a measured comparison (afftdn with a fixed floor, anlmdn, noisereduce and a
4
+ spectral gate lost to it on clean and on noisy phone speech). It needs only ffmpeg filters that every
5
+ build has (highpass, asendcmd, afftdn):
6
+
7
+ highpass 80 Hz (2 poles) -> afftdn with a noise profile sampled from the source's own pauses
8
+
9
+ The noise clip is put in front of the audio, afftdn samples it between `sn start` and `sn stop`
10
+ (asendcmd) and the clip is cut off again. afftdn delays the audio by a fixed number of samples (1200
11
+ at 48 kHz) and keeps the sample count, so every range is read with that many extra real samples and
12
+ trimmed: the voice stays on the same sample. A self-test checks the three filters exist and measures the
13
+ delay once per ffmpeg build.
14
+
15
+ leve: nr 10, nf = round(noise RMS of the clip + 3 dB), clamped to [-80, -20]
16
+ forte: nr 20, same nf. (afftdn's default nf -50 would silently cancel the sampled profile.)
17
+
18
+ Noise profile, one per source: the pauses of edit/regions/<stem>.json (only when its source_sig matches;
19
+ measured on the fly otherwise), 60 ms trimmed at both ends, longest first, up to 2 s, joined with 5 ms
20
+ crossfades, read with the same channel choice as render_cut. With less than 0.3 s of pause the quietest
21
+ 50 ms windows are used instead (at most 2 s and 10% of the file), nf goes 3 dB higher and "forte" runs as
22
+ "leve". When even the quietest windows sit close to the speech level the source is left untouched.
23
+
24
+ compose_final rebuilds the voice from the sources, one cut_timeline.json range at a time (denoise before
25
+ gain, limiter and fades, exactly like render_cut places them), mixes the ranges in samples, loudnorms it
26
+ like render_cut and uses it as the voice. cut.mp4 and its approval stay untouched. Without the sources it
27
+ cleans cut.mp4's own audio with a profile from its quietest windows (weaker, with a warning).
28
+
29
+ This module also measures noise for noise_check.py (measure_source).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import heapq
36
+ import json
37
+ import math
38
+ import os
39
+ import re
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ from concurrent.futures import ThreadPoolExecutor
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+
47
+ import numpy as np
48
+
49
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
50
+ from common import file_signature, has_ffmpeg_filter, load_json, probe_video, save_json # noqa: E402
51
+ import render_cut as rc # noqa: E402
52
+
53
+ SR = 48000
54
+ VERSION = "denoise/1"
55
+ MODES = ("off", "leve", "forte")
56
+ LABEL = {"off": "desligado", "leve": "leve", "forte": "forte"}
57
+ NR = {"leve": 10, "forte": 20}
58
+ HIGHPASS = "highpass=f=80:p=2"
59
+ NF_OFFSET_DB = 3.0 # nf sits this far above the measured noise of the clip
60
+ FALLBACK_EXTRA_DB = 3.0 # quietest windows read low (they catch the dips of the noise): nf 3 dB higher
61
+ NF_MIN, NF_MAX = -80, -20
62
+ PRE = 12000 # 0.25 s of real audio read before each range (afftdn settles on it)
63
+ PAUSE_TRIM_S = 0.06
64
+ PAUSE_MIN_S = 0.10 # a trimmed pause shorter than this is not used
65
+ PROFILE_MAX_S = 2.0
66
+ PROFILE_MIN_S = 0.3 # pauses needed for a pause profile
67
+ CLIP_MIN_S = 0.2 # shortest clip afftdn gets to sample (after the crossfades)
68
+ XFADE = 240 # 5 ms crossfade between joined noise pieces
69
+ WIN = 2400 # 50 ms analysis windows
70
+ FALLBACK_SHARE = 0.10
71
+ FALLBACK_MIN_WINDOWS = 6
72
+ SN_STOP_MARGIN_S = 0.06
73
+ SPEECH_GAP_MIN_DB = 12.0 # quiet windows closer than this to the speech level are speech, not noise
74
+ EDGE_GUARD_S = 0.06 # the floor measurement skips this much around every range edge
75
+ FLOOR_PERCENTILE = 5 # noise floor = level only 5% of the 50 ms windows stay under
76
+ SELFTEST_EXPECTED_DELAY = 1200
77
+ BLOCK = SR # streaming decode block (1 s = 20 analysis windows)
78
+ PRE_EMPHASIS = 0.95 # the sync check compares x[n] - 0.95 x[n-1] (speech band, not rumble)
79
+ SYNC_MIN_CORR = 0.4 # a range of the rebuilt voice correlates at least this much with the cut
80
+ SYNC_MIN_DB = -60.0 # spans quieter than this are not compared
81
+ SYNC_MAX_LAG = 240
82
+ SYNC_LAG_TOL = 8 # the 80 Hz highpass alone shifts about 3 samples
83
+ SYNC_LAG_WINDOW = 20 * SR
84
+ FADE_GUARD = 1440 # the 30 ms fades at range edges are left out of the sync check
85
+ REPORT_KEYS = ("noise_floor_before_db", "noise_floor_after_db", "reduction_db", "sync")
86
+
87
+
88
+ class DenoiseError(Exception):
89
+ pass
90
+
91
+
92
+ class SyncMismatch(DenoiseError):
93
+ """The voice rebuilt from the sources is not the voice of cut.mp4 (wrong file, changed source)."""
94
+
95
+
96
+ def normalize_mode(value) -> str:
97
+ """"off" | "leve" | "forte" from edit_data (None and false are off). PT-BR ValueError otherwise."""
98
+ if value is None or value is False:
99
+ return "off"
100
+ if isinstance(value, str):
101
+ v = value.strip().lower()
102
+ if v in ("", "off", "desligado", "nenhum"):
103
+ return "off"
104
+ if v in ("leve", "forte"):
105
+ return v
106
+ shown = json.dumps(value, ensure_ascii=False) if isinstance(value, (str, int, float, bool)) else repr(value)
107
+ raise ValueError(f'audio.denoise precisa ser "off", "leve" ou "forte" (veio {shown})')
108
+
109
+
110
+ def db(power: float) -> float:
111
+ return float(10.0 * math.log10(max(float(power), 1e-12)))
112
+
113
+
114
+ def nf_for(noise_db: float, extra_db: float = 0.0) -> int:
115
+ return int(min(NF_MAX, max(NF_MIN, round(noise_db + NF_OFFSET_DB + extra_db))))
116
+
117
+
118
+ _FF_VERSION: str | None = None
119
+ _FF_BUILD: str | None = None
120
+ FILTERS = ("highpass", "asendcmd", "afftdn")
121
+
122
+
123
+ def ffmpeg_version() -> str:
124
+ global _FF_VERSION, _FF_BUILD
125
+ if _FF_VERSION is None:
126
+ out = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout
127
+ _FF_VERSION = (out.splitlines() or ["ffmpeg"])[0].strip()
128
+ _FF_BUILD = hashlib.sha1(out.encode()).hexdigest()[:16] # version line + configuration: tells builds apart
129
+ return _FF_VERSION
130
+
131
+
132
+ def ffmpeg_build() -> str:
133
+ ffmpeg_version()
134
+ return _FF_BUILD or ""
135
+
136
+
137
+ def _hash(obj) -> str:
138
+ return hashlib.sha1(json.dumps(obj, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()[:16]
139
+
140
+
141
+ def graph_args(script: Path) -> list[str]:
142
+ m = re.search(r"ffmpeg version n?(\d+)", ffmpeg_version())
143
+ major = int(m.group(1)) if m else 7
144
+ return ["-/filter_complex", str(script)] if major >= 7 else ["-filter_complex_script", str(script)]
145
+
146
+
147
+ def run_ffmpeg(args: list, what: str) -> subprocess.CompletedProcess:
148
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", *[str(a) for a in args]], capture_output=True)
149
+ if proc.returncode != 0:
150
+ tail = "\n".join(proc.stderr.decode("utf-8", "replace").strip().splitlines()[-8:])
151
+ raise DenoiseError(f"o ffmpeg falhou ao {what}: {tail}")
152
+ return proc
153
+
154
+
155
+ def f32_stereo(raw: bytes) -> np.ndarray:
156
+ x = np.frombuffer(raw[: len(raw) // 8 * 8], dtype="<f4")
157
+ return x.reshape(-1, 2)
158
+
159
+
160
+ def write_wav(path: Path, x: np.ndarray) -> None:
161
+ """48 kHz float32 WAV (IEEE float) of an (n, 2) array."""
162
+ x = np.ascontiguousarray(np.asarray(x, dtype="<f4").reshape(-1, 2))
163
+ data = x.tobytes()
164
+ header = b"".join([
165
+ b"RIFF", (36 + len(data)).to_bytes(4, "little"), b"WAVE",
166
+ b"fmt ", (16).to_bytes(4, "little"), (3).to_bytes(2, "little"), (2).to_bytes(2, "little"),
167
+ SR.to_bytes(4, "little"), (SR * 8).to_bytes(4, "little"), (8).to_bytes(2, "little"), (32).to_bytes(2, "little"),
168
+ b"data", len(data).to_bytes(4, "little"),
169
+ ])
170
+ tmp = path.with_name(f".tmp-{os.getpid()}-{path.name}")
171
+ tmp.write_bytes(header + data)
172
+ os.replace(tmp, path)
173
+
174
+
175
+ def read_audio(path: Path, af: str | None = None) -> np.ndarray:
176
+ """Whole first audio track as float32 (n, 2) at 48 kHz."""
177
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-map", "0:a:0"]
178
+ if af:
179
+ cmd += ["-af", af]
180
+ cmd += ["-f", "f32le", "-ar", str(SR), "-ac", "2", "-"]
181
+ proc = subprocess.run(cmd, capture_output=True)
182
+ if proc.returncode != 0:
183
+ raise DenoiseError(f"não consegui ler o áudio de {Path(path).name}")
184
+ return f32_stereo(proc.stdout)
185
+
186
+
187
+ # ============================================================== noise clip (profile)
188
+
189
+ def _join(pieces: list[np.ndarray]) -> np.ndarray:
190
+ pieces = [p for p in pieces if len(p)]
191
+ if not pieces:
192
+ return np.zeros((0, 2), dtype=np.float32)
193
+ out = pieces[0].astype(np.float32)
194
+ ramp = np.linspace(0.0, 1.0, XFADE, dtype=np.float32)[:, None]
195
+ for p in pieces[1:]:
196
+ p = p.astype(np.float32)
197
+ if len(out) > XFADE and len(p) > XFADE:
198
+ mixed = out[-XFADE:] * (1.0 - ramp) + p[:XFADE] * ramp
199
+ out = np.concatenate([out[:-XFADE], mixed, p[XFADE:]])
200
+ else:
201
+ out = np.concatenate([out, p])
202
+ return out
203
+
204
+
205
+ def _intervals_to_samples(spans, total: int | None = None) -> list[tuple[int, int]]:
206
+ out = []
207
+ for a, b in spans:
208
+ i0, i1 = int(round(float(a) * SR)), int(round(float(b) * SR))
209
+ if total is not None:
210
+ i1 = min(i1, total)
211
+ if i1 > i0:
212
+ out.append((max(0, i0), i1))
213
+ return sorted(out)
214
+
215
+
216
+ def pause_intervals(regions: dict | None, duration: float) -> list[tuple[float, float]]:
217
+ """Pauses of a regions file trimmed PAUSE_TRIM_S at both ends (time order)."""
218
+ out = []
219
+ for a, b in (regions or {}).get("silences") or []:
220
+ a2, b2 = max(0.0, float(a) + PAUSE_TRIM_S), min(duration, float(b) - PAUSE_TRIM_S)
221
+ if b2 - a2 >= PAUSE_MIN_S:
222
+ out.append((a2, b2))
223
+ return sorted(out)
224
+
225
+
226
+ def pick_pauses(pauses: list[tuple[float, float]], max_s: float = PROFILE_MAX_S) -> list[tuple[float, float]]:
227
+ """Longest pauses first until max_s (the last one shortened), returned in time order."""
228
+ chosen, total = [], 0.0
229
+ for a, b in sorted(pauses, key=lambda ab: ab[0] - ab[1]):
230
+ take = min(b - a, max_s - total)
231
+ if take <= 0:
232
+ break
233
+ chosen.append((a, a + take))
234
+ total += take
235
+ return sorted(chosen)
236
+
237
+
238
+ def stream_analysis(src: Path, channel_mode: str, duration: float, picks: list[tuple[float, float]],
239
+ speech: list | None = None, pauses: list | None = None) -> dict:
240
+ """One decoding pass of a source (render_cut's resampling and channel choice).
241
+
242
+ Returns 50 ms window levels (dB, mono), the samples inside `picks` (stereo pieces), the quietest
243
+ windows as fallback candidates (stereo) and the power inside `speech` and `pauses` spans."""
244
+ chain = f"{rc.AUDIO_RESAMPLE},{rc.channel_filter(channel_mode)},aformat=sample_fmts=flt:channel_layouts=stereo"
245
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-i", str(src), "-map", "0:a:0", "-af", chain,
246
+ "-f", "f32le", "-ar", str(SR), "-ac", "2", "-"]
247
+ n_est = max(1, int(duration * SR) // WIN)
248
+ k_quiet = max(FALLBACK_MIN_WINDOWS, min(int(PROFILE_MAX_S * SR) // WIN, int(FALLBACK_SHARE * n_est)))
249
+ pick_s = _intervals_to_samples(picks)
250
+ pieces: list[list[np.ndarray]] = [[] for _ in pick_s]
251
+ sums = {"speech": [0.0, 0], "pauses": [0.0, 0]}
252
+ spans = {"speech": _intervals_to_samples(speech or []), "pauses": _intervals_to_samples(pauses or [])}
253
+ heap: list[tuple[float, int, np.ndarray]] = [] # (-level, index, samples): the k quietest windows
254
+ levels: list[float] = []
255
+ pos = 0
256
+ carry = b""
257
+ with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as proc:
258
+ assert proc.stdout is not None
259
+ while True:
260
+ raw = proc.stdout.read(BLOCK * 8)
261
+ if not raw:
262
+ break
263
+ raw = carry + raw
264
+ usable = len(raw) // 8 * 8
265
+ carry = raw[usable:]
266
+ x = np.frombuffer(raw[:usable], dtype="<f4").reshape(-1, 2)
267
+ n = len(x)
268
+ mono = x.astype(np.float64).mean(axis=1)
269
+ for j, (a, b) in enumerate(pick_s):
270
+ lo, hi = max(a, pos), min(b, pos + n)
271
+ if hi > lo:
272
+ pieces[j].append(x[lo - pos:hi - pos].copy())
273
+ for key, lst in spans.items():
274
+ for a, b in lst:
275
+ lo, hi = max(a, pos), min(b, pos + n)
276
+ if hi > lo:
277
+ seg = mono[lo - pos:hi - pos]
278
+ sums[key][0] += float(np.sum(seg * seg))
279
+ sums[key][1] += hi - lo
280
+ w = n // WIN
281
+ if w:
282
+ powers = np.mean(mono[: w * WIN].reshape(w, WIN) ** 2, axis=1)
283
+ for i, pw in enumerate(powers):
284
+ lv = db(pw)
285
+ levels.append(lv)
286
+ if lv < -90.0: # digital silence is not noise
287
+ continue
288
+ idx = len(levels) - 1
289
+ item = (-lv, idx, x[i * WIN:(i + 1) * WIN].copy())
290
+ if len(heap) < k_quiet:
291
+ heapq.heappush(heap, item)
292
+ elif -heap[0][0] > lv:
293
+ heapq.heapreplace(heap, item)
294
+ pos += n
295
+ code = proc.wait()
296
+ if code != 0 and pos == 0:
297
+ raise DenoiseError(f"não consegui ler o áudio de {src.name}")
298
+ lv = np.array(levels) if levels else np.zeros(0)
299
+ audible = lv[lv > -90.0]
300
+ quiet = [samples for _, _, samples in sorted(heap, key=lambda t: t[1])]
301
+ return {
302
+ "samples": pos,
303
+ "levels": lv,
304
+ "speech_ref_db": float(np.percentile(audible, 90)) if audible.size else None,
305
+ "pieces": [np.concatenate(p) if p else np.zeros((0, 2), np.float32) for p in pieces],
306
+ "quiet": quiet,
307
+ "speech_db": db(sums["speech"][0] / sums["speech"][1]) if sums["speech"][1] else None,
308
+ "speech_seconds": sums["speech"][1] / SR,
309
+ "pause_db": db(sums["pauses"][0] / sums["pauses"][1]) if sums["pauses"][1] else None,
310
+ "pause_seconds": sums["pauses"][1] / SR,
311
+ }
312
+
313
+
314
+ def _profile_prefix(src: Path) -> str:
315
+ """Cache file prefix of a source's profile. The path hash keeps two sources apart when one stem starts like
316
+ the other ("take" and "take_2") or the same name sits in two folders: the cleanup never deletes the other's."""
317
+ return f"profile_{src.stem}_{_hash([str(Path(src).absolute())])[:8]}_"
318
+
319
+
320
+ def load_regions(src: Path, edit_dir: Path | None) -> tuple[dict | None, str]:
321
+ """(regions, where) for a source: edit/regions/<stem>.json when it belongs to this file, else measured now."""
322
+ if edit_dir is not None:
323
+ path = Path(edit_dir) / "regions" / f"{src.stem}.json"
324
+ try:
325
+ reg = load_json(path)
326
+ except (ValueError, OSError):
327
+ reg = None
328
+ if (isinstance(reg, dict) and isinstance(reg.get("silences"), list) and "start" not in reg
329
+ and reg.get("source_sig") == file_signature(src)):
330
+ return reg, "regions"
331
+ try:
332
+ import speech_regions
333
+ return speech_regions.compute_regions(src, "auto", 0.25), "medido agora"
334
+ except Exception: # noqa: BLE001 (no regions: the quietest windows are used)
335
+ return None, "sem pausas medidas"
336
+
337
+
338
+ @dataclass
339
+ class Profile:
340
+ stem: str
341
+ how: str # "pausas" | "janelas" | "nenhum"
342
+ seconds: float
343
+ noise_db: float | None # RMS of the clip (mono), dBFS
344
+ extra_db: float
345
+ nf: int | None
346
+ usable: bool
347
+ speech_ref_db: float | None
348
+ pause_seconds: float
349
+ wav: Path | None
350
+ reason: str = ""
351
+
352
+ def as_dict(self) -> dict:
353
+ return {"how": self.how, "seconds": round(self.seconds, 3),
354
+ "noise_db": None if self.noise_db is None else round(self.noise_db, 1),
355
+ "nf": self.nf, "extra_db": self.extra_db, "usable": self.usable,
356
+ "speech_ref_db": None if self.speech_ref_db is None else round(self.speech_ref_db, 1),
357
+ "pause_seconds": round(self.pause_seconds, 3), "reason": self.reason}
358
+
359
+
360
+ def _profile_from_clip(stem: str, clip: np.ndarray, how: str, extra: float, speech_ref: float | None,
361
+ pause_s: float, wav: Path) -> Profile:
362
+ noise = db(float(np.mean(clip.astype(np.float64).mean(axis=1) ** 2))) if len(clip) else None
363
+ usable, reason = True, ""
364
+ if noise is None or len(clip) < int(CLIP_MIN_S * SR):
365
+ usable, reason = False, "sem ruído suficiente para medir"
366
+ elif how == "janelas" and speech_ref is not None and speech_ref - noise < SPEECH_GAP_MIN_DB:
367
+ usable, reason = False, "não há trecho só de ruído (as partes mais quietas ainda têm voz)"
368
+ if usable:
369
+ write_wav(wav, clip)
370
+ return Profile(stem=stem, how=how, seconds=len(clip) / SR, noise_db=noise, extra_db=extra,
371
+ nf=nf_for(noise, extra) if noise is not None else None, usable=usable, speech_ref_db=speech_ref,
372
+ pause_seconds=pause_s, wav=wav if usable else None, reason=reason)
373
+
374
+
375
+ def learn_profile(src: Path, info: dict, channel_mode: str, edit_dir: Path | None, work: Path) -> Profile:
376
+ """Noise clip of one source (cached in work/ by source signature, channel choice and regions)."""
377
+ duration = float(info.get("duration") or 0.0)
378
+ regions, _where = load_regions(src, edit_dir)
379
+ pauses = pause_intervals(regions, duration)
380
+ key = _hash([VERSION, file_signature(src), channel_mode, pauses, SR])
381
+ work.mkdir(parents=True, exist_ok=True)
382
+ prefix = _profile_prefix(src)
383
+ meta_path = work / f"{prefix}{key}.json"
384
+ wav = work / f"{prefix}{key}.wav"
385
+ meta = load_json(meta_path) if meta_path.exists() else None
386
+ if isinstance(meta, dict) and meta.get("key") == key and (not meta.get("usable") or wav.exists()):
387
+ return Profile(stem=src.stem, how=meta["how"], seconds=meta["seconds"], noise_db=meta["noise_db"],
388
+ extra_db=meta["extra_db"], nf=meta["nf"], usable=meta["usable"],
389
+ speech_ref_db=meta.get("speech_ref_db"), pause_seconds=meta.get("pause_seconds", 0.0),
390
+ wav=wav if meta["usable"] else None, reason=meta.get("reason", ""))
391
+ pause_total = sum(b - a for a, b in pauses)
392
+ picks = pick_pauses(pauses) if pause_total >= PROFILE_MIN_S else []
393
+ an = stream_analysis(src, channel_mode, duration, picks)
394
+ if picks:
395
+ prof = _profile_from_clip(src.stem, _join(an["pieces"]), "pausas", 0.0, an["speech_ref_db"], pause_total, wav)
396
+ else:
397
+ prof = _profile_from_clip(src.stem, _join(an["quiet"]), "janelas", FALLBACK_EXTRA_DB, an["speech_ref_db"],
398
+ pause_total, wav)
399
+ legacy = re.compile(rf"profile_{re.escape(src.stem)}_[0-9a-f]{{16}}\.(json|wav)") # names before the path hash
400
+ for old in work.glob("profile_*"):
401
+ if (old.name.startswith(prefix) and not old.name.startswith(prefix + key)) or legacy.fullmatch(old.name):
402
+ old.unlink(missing_ok=True)
403
+ save_json(meta_path, {"key": key, "source": str(src), **prof.as_dict()})
404
+ return prof
405
+
406
+
407
+ # ============================================================== graphs
408
+
409
+ def denoise_head(clip_samples: int, nr: int, nf: int) -> str:
410
+ stop = max(0.0, clip_samples / SR - SN_STOP_MARGIN_S)
411
+ return (f"{HIGHPASS},asendcmd=c='0.0 afftdn sn start;{stop:.4f} afftdn sn stop',"
412
+ f"afftdn=nr={nr}:nf={nf}")
413
+
414
+
415
+ def range_graph(clip_samples: int | None, nr: int | None, nf: int | None, pre: int, delay: int, samples: int,
416
+ gain_db: float, channel_mode: str, fade_in: bool = True, fade_out: bool = True) -> str:
417
+ """filter_complex of one range. Input 0: the source read from (audio_src_start - pre/SR); input 1: the
418
+ noise clip. Without a clip (nr None) it is render_cut's own extraction, sample for sample."""
419
+ head = f"{rc.AUDIO_RESAMPLE},{rc.channel_filter(channel_mode)}"
420
+ tail = ",".join(rc.af_tail(gain_db, samples, fade_in, fade_out)) # no fade where the voice goes on
421
+ if nr is None or clip_samples is None or nf is None:
422
+ return (f"[0:a]{head},atrim=start_sample={pre}:end_sample={pre + samples},asetpts=N/SR/TB,{tail}[out]")
423
+ need = pre + delay + samples
424
+ start = clip_samples + pre + delay
425
+ return (f"[1:a]aresample={SR},aformat=sample_fmts=flt:channel_layouts=stereo[nz];"
426
+ f"[0:a]{head},aformat=sample_fmts=flt:channel_layouts=stereo,atrim=end_sample={need},"
427
+ f"apad=whole_len={need}[v];"
428
+ f"[nz][v]concat=n=2:v=0:a=1,{denoise_head(clip_samples, nr, nf)},"
429
+ f"atrim=start_sample={start}:end_sample={start + samples},asetpts=N/SR/TB,{tail}[out]")
430
+
431
+
432
+ def whole_graph(clip_samples: int, nr: int, nf: int, delay: int, total: int) -> str:
433
+ """filter_complex that cleans a whole track (input 0) with the clip of input 1, same length and sync."""
434
+ start = clip_samples + delay
435
+ return (f"[1:a]aresample={SR},aformat=sample_fmts=flt:channel_layouts=stereo[nz];"
436
+ f"[0:a]aresample={SR},aformat=sample_fmts=flt:channel_layouts=stereo,atrim=end_sample={total},"
437
+ f"apad=whole_len={total + delay}[v];"
438
+ f"[nz][v]concat=n=2:v=0:a=1,{denoise_head(clip_samples, nr, nf)},"
439
+ f"atrim=start_sample={start}:end_sample={start + total},asetpts=N/SR/TB[out]")
440
+
441
+
442
+ # ============================================================== self-test
443
+
444
+ def self_test(work: Path) -> dict:
445
+ """Runs the chain once on 1 s of synthetic noise with a chirp (cached per ffmpeg build): the filters
446
+ exist, sample count kept, delay measured (1200 expected at 48 kHz) and noise lowered."""
447
+ work.mkdir(parents=True, exist_ok=True)
448
+ version = ffmpeg_version()
449
+ cache = work / "selftest.json"
450
+ key = _hash([VERSION, ffmpeg_build(), HIGHPASS, NR, SN_STOP_MARGIN_S])
451
+ old = load_json(cache) if cache.exists() else None
452
+ if isinstance(old, dict) and old.get("key") == key:
453
+ return old
454
+ result = {"key": key, "ffmpeg": version, "ok": False, "delay": None, "reason": ""}
455
+ try:
456
+ missing = [name for name in FILTERS if not has_ffmpeg_filter(name)]
457
+ if missing:
458
+ raise DenoiseError(f"o ffmpeg instalado não tem o filtro {', '.join(missing)}; instale uma versão completa do ffmpeg")
459
+ rng = np.random.default_rng(20260915)
460
+ sigma = 10 ** (-40 / 20)
461
+ clip = rng.normal(0, sigma, (int(0.5 * SR), 1)).repeat(2, axis=1).astype(np.float32)
462
+ sig = rng.normal(0, sigma, (SR, 1)).astype(np.float64)
463
+ t = np.arange(int(0.3 * SR)) / SR
464
+ chirp = 0.25 * np.sin(2 * np.pi * (300 * t + (3000 - 300) / (2 * 0.3) * t * t))
465
+ a = int(0.5 * SR)
466
+ sig[a:a + len(chirp), 0] += chirp
467
+ sig = sig.repeat(2, axis=1).astype(np.float32)
468
+ clip_wav, sig_wav = work / "selftest_clip.wav", work / "selftest_sig.wav"
469
+ write_wav(clip_wav, clip)
470
+ write_wav(sig_wav, sig)
471
+ L, n = len(clip), len(sig)
472
+ nf = nf_for(-40.0)
473
+
474
+ def run(core: str) -> np.ndarray:
475
+ graph = (f"[1:a]aformat=sample_fmts=flt:channel_layouts=stereo[nz];"
476
+ f"[0:a]aformat=sample_fmts=flt:channel_layouts=stereo[v];[nz][v]concat=n=2:v=0:a=1,{core}[out]")
477
+ proc = run_ffmpeg(["-v", "error", "-i", sig_wav, "-i", clip_wav, "-filter_complex", graph, "-map", "[out]",
478
+ "-f", "f32le", "-ar", SR, "-ac", 2, "-"], "testar a limpeza de ruído")
479
+ return f32_stereo(proc.stdout)[:, 0].astype(np.float64)
480
+
481
+ ref = run(HIGHPASS)
482
+ out = run(denoise_head(L, NR["leve"], nf))
483
+ if len(out) != L + n or len(ref) != L + n:
484
+ raise DenoiseError(f"o filtro mudou o número de amostras ({len(out)} em vez de {L + n})")
485
+ r = ref[L + a - 2400: L + a + len(chirp) + 2400]
486
+ o = out[L + a - 2400: L + a + len(chirp) + 2400 + 6000]
487
+ dots = np.correlate(o, r, mode="valid")
488
+ energy = np.concatenate([[0.0], np.cumsum(o * o)])
489
+ norms = np.sqrt(energy[len(r):] - energy[:-len(r)]) * float(np.sqrt(np.sum(r * r))) + 1e-12
490
+ corr = dots / norms[: len(dots)]
491
+ lag = int(np.argmax(corr))
492
+ best = float(corr[lag])
493
+ if best < 0.8:
494
+ raise DenoiseError(f"não achei o atraso do filtro (correlação {best:.2f})")
495
+ q0, q1 = L + int(0.05 * SR), L + int(0.45 * SR)
496
+ drop = db(float(np.mean(out[q0 + lag:q1 + lag] ** 2))) - db(float(np.mean(ref[q0:q1] ** 2)))
497
+ if drop > -3.0:
498
+ raise DenoiseError(f"o ruído de teste só baixou {drop:.1f} dB")
499
+ result.update(ok=True, delay=int(lag), drop_db=round(drop, 1), correlation=round(best, 3))
500
+ except DenoiseError as e:
501
+ result["reason"] = str(e)
502
+ except Exception as e: # noqa: BLE001
503
+ result["reason"] = f"{e.__class__.__name__}: {e}"
504
+ for name in ("selftest_clip.wav", "selftest_sig.wav"):
505
+ (work / name).unlink(missing_ok=True)
506
+ save_json(cache, result)
507
+ return result
508
+
509
+
510
+ # ============================================================== voice rebuild (compose_final)
511
+
512
+ @dataclass
513
+ class VoiceResult:
514
+ path: Path | None
515
+ report: dict
516
+ warnings: list[str] = field(default_factory=list)
517
+
518
+
519
+ def _edl_sources(edit_dir: Path, timeline: dict) -> tuple[dict | None, str]:
520
+ """edl.json "sources" when the EDL is the one of the cut: same edl_sig, or the same ranges (a moved folder
521
+ changes only the paths). The sync check in build_voice still confirms every range against cut.mp4."""
522
+ path = edit_dir / "edl.json"
523
+ try:
524
+ raw = json.loads(path.read_text(encoding="utf-8"))
525
+ except (OSError, ValueError):
526
+ return None, "o edl.json não foi encontrado"
527
+ if not isinstance(raw, dict) or not isinstance(raw.get("sources"), dict):
528
+ return None, "o edl.json não tem \"sources\""
529
+ if timeline.get("edl_sig") and rc.edl_signature(raw) != timeline.get("edl_sig"):
530
+ segs = timeline.get("segments") or []
531
+ ranges = raw.get("ranges") if isinstance(raw.get("ranges"), list) else []
532
+ try:
533
+ same = len(segs) == len(ranges) and all(
534
+ str(s.get("id")) == str(r.get("id")) and s.get("source") == r.get("source")
535
+ and abs(float(s["edl_start"]) - float(r["start"])) < 1e-6
536
+ and float(s["edl_end"]) <= float(r["end"]) + 1e-6
537
+ and abs(float(s.get("gain_db") or 0.0) - float(r.get("gain_db") or 0.0)) < 1e-6
538
+ for s, r in zip(segs, ranges))
539
+ except (KeyError, TypeError, ValueError):
540
+ same = False
541
+ if not same:
542
+ return None, "o edl.json mudou depois do corte"
543
+ return raw["sources"], ""
544
+
545
+
546
+ def resolve_segment_sources(edit_dir: Path, timeline: dict) -> tuple[dict[str, Path] | None, str]:
547
+ """Source path per timeline source key: segments[].source_path, else edl.json."""
548
+ out: dict[str, Path] = {}
549
+ edl_sources = None
550
+ edl_reason = ""
551
+ for s in timeline.get("segments") or []:
552
+ key = str(s.get("source"))
553
+ if key in out:
554
+ continue
555
+ path = Path(str(s["source_path"])) if s.get("source_path") else None
556
+ if path is None or not path.exists():
557
+ if edl_sources is None and not edl_reason:
558
+ edl_sources, edl_reason = _edl_sources(edit_dir, timeline)
559
+ if edl_sources is None:
560
+ return None, (f"a fonte {key} não foi encontrada ({path})" if path is not None else edl_reason)
561
+ if key not in edl_sources:
562
+ return None, f"a fonte {key} não está no edl.json"
563
+ path = rc.resolve_source(str(edl_sources[key]), edit_dir)
564
+ if not path.exists():
565
+ return None, f"a fonte {key} não foi encontrada ({path})"
566
+ out[key] = path
567
+ return out, ""
568
+
569
+
570
+ def _segments_ok(timeline: dict | None) -> tuple[list[dict], int, str]:
571
+ if not timeline or not timeline.get("segments"):
572
+ return [], 0, "sem cut_timeline.json"
573
+ try:
574
+ fps = int(round(float(timeline.get("fps"))))
575
+ except (TypeError, ValueError):
576
+ return [], 0, "o cut_timeline.json não tem fps"
577
+ if fps <= 0 or SR % fps:
578
+ return [], 0, f"fps {fps} do corte"
579
+ segs = []
580
+ for s in timeline["segments"]:
581
+ try:
582
+ segs.append({"id": str(s.get("id")), "source": str(s["source"]),
583
+ "audio_src_start": float(s["audio_src_start"]), "lead": int(s["lead_frames"]),
584
+ "frames": int(s["frames"]), "out_start_frame": int(s["out_start_frame"]),
585
+ "gain_db": float(s.get("gain_db") or 0.0), "audio_mode": s.get("audio_mode"),
586
+ "fade_in": bool(s.get("audio_fade_in", True)), "fade_out": bool(s.get("audio_fade_out", True))})
587
+ except (KeyError, TypeError, ValueError):
588
+ return [], fps, "o cut_timeline.json é de uma versão antiga (faltam campos de áudio)"
589
+ return segs, fps, ""
590
+
591
+
592
+ def _mono(x: np.ndarray) -> np.ndarray:
593
+ return x.astype(np.float64).mean(axis=1) if x.ndim == 2 else x.astype(np.float64)
594
+
595
+
596
+ def _levels(x: np.ndarray) -> np.ndarray:
597
+ n = len(x) // WIN
598
+ return 10 * np.log10(np.mean(x[: n * WIN].reshape(n, WIN) ** 2, axis=1) + 1e-12) if n else np.zeros(0)
599
+
600
+
601
+ def noise_floors(before: np.ndarray, after: np.ndarray, edges: list[int], shift_db: float = 0.0) -> tuple[float | None, float | None]:
602
+ """Noise floor (dBFS) before and after: the level only FLOOR_PERCENTILE% of the 50 ms windows stay under,
603
+ each track on its own, leaving out range edges (fades) and digital silence. `after` moves by shift_db so
604
+ both are read at the same loudness."""
605
+ b, a = _mono(before), _mono(after)
606
+ m = min(len(a), len(b))
607
+ lb, la = _levels(b[:m]), _levels(a[:m])
608
+ ok = lb > -90.0
609
+ guard = int(EDGE_GUARD_S * SR)
610
+ for e in edges:
611
+ ok[max(0, (e - guard) // WIN): min(len(ok), (e + guard) // WIN + 1)] = False
612
+ if int(ok.sum()) < FALLBACK_MIN_WINDOWS:
613
+ return None, None
614
+ return float(np.percentile(lb[ok], FLOOR_PERCENTILE)), float(np.percentile(la[ok], FLOOR_PERCENTILE)) + shift_db
615
+
616
+
617
+ def sync_check(before: np.ndarray, after: np.ndarray, spans: list[tuple[str, int, int]]) -> dict:
618
+ """Is the rebuilt voice the cut's voice, on the same samples? Correlation (pre-emphasized, lag 0) per range
619
+ span, and the best lag of the whole track within +-240 samples."""
620
+ b, a = _mono(before), _mono(after)
621
+ m = min(len(a), len(b))
622
+ b = b[1:m] - PRE_EMPHASIS * b[: m - 1]
623
+ a = a[1:m] - PRE_EMPHASIS * a[: m - 1]
624
+ worst = None
625
+ for rid, s0, s1 in spans:
626
+ s0, s1 = max(0, s0), min(len(b), s1)
627
+ if s1 - s0 < WIN:
628
+ continue
629
+ x, y = b[s0:s1], a[s0:s1]
630
+ ex, ey = float(np.dot(x, x)), float(np.dot(y, y))
631
+ if db(ex / (s1 - s0)) < SYNC_MIN_DB: # (almost) silent span: nothing to compare
632
+ continue
633
+ c = float(np.dot(x, y)) / math.sqrt(ex * ey + 1e-24)
634
+ if worst is None or c < worst[1]:
635
+ worst = (rid, c)
636
+ lag = None
637
+ if m > 4 * SYNC_MAX_LAG:
638
+ k = min(len(a), SYNC_LAG_WINDOW) # the lag is the same all along: 20 s are enough
639
+ fa, fb = np.fft.rfft(a[:k]), np.fft.rfft(b[:k])
640
+ cc = np.fft.irfft(fa * np.conj(fb), n=k)
641
+ lags = np.concatenate([cc[-SYNC_MAX_LAG:], cc[:SYNC_MAX_LAG + 1]])
642
+ lag = int(np.argmax(lags)) - SYNC_MAX_LAG
643
+ return {"worst_range": worst[0] if worst else None, "min_correlation": None if worst is None else round(worst[1], 3),
644
+ "lag_samples": lag,
645
+ "ok": (worst is None or worst[1] >= SYNC_MIN_CORR) and (lag is None or abs(lag) <= SYNC_LAG_TOL)}
646
+
647
+
648
+ def _loudness(path: Path) -> float | None:
649
+ m = rc.loudnorm_measure(path)
650
+ try:
651
+ return float(m["input_i"]) if m else None
652
+ except (KeyError, TypeError, ValueError):
653
+ return None
654
+
655
+
656
+ def _cached_voice(res: VoiceResult, voice: Path, meta_path: Path, key: str) -> bool:
657
+ cached = load_json(meta_path) if meta_path.exists() and voice.exists() else None
658
+ if isinstance(cached, dict) and cached.get("key") == key:
659
+ res.report.update({k: cached[k] for k in REPORT_KEYS if cached.get(k) is not None})
660
+ res.path = voice
661
+ return True
662
+ return False
663
+
664
+
665
+ def _finish(res: VoiceResult, base: Path, voice: Path, edges: list[int], spans: list[tuple[str, int, int]] | None,
666
+ key: str, meta_path: Path, work: Path) -> None:
667
+ """Noise floors before/after, sync check (raises when the rebuilt voice is not the cut's voice), cache."""
668
+ report = res.report
669
+ after = read_audio(voice)
670
+ if spans is not None:
671
+ # the cut through the same 80 Hz highpass: rumble under 80 Hz (street, traffic) that the rebuilt voice no
672
+ # longer has would otherwise pull the correlation of a range with little speech under the limit
673
+ check = sync_check(read_audio(base, f"aresample={SR},{HIGHPASS}"), after, spans)
674
+ report["sync"] = check
675
+ if not check["ok"]:
676
+ voice.unlink(missing_ok=True)
677
+ where = f" (trecho {check['worst_range']}, correlação {check['min_correlation']})" if check["worst_range"] else ""
678
+ raise SyncMismatch(f"a voz refeita das fontes não bate com o som do corte{where}")
679
+ i_before, i_after = _loudness(base), _loudness(voice)
680
+ shift = (i_before - i_after) if (i_before is not None and i_after is not None) else 0.0
681
+ fb, fa = noise_floors(read_audio(base, f"aresample={SR}"), after, edges, shift)
682
+ if fb is not None and fa is not None:
683
+ report.update(noise_floor_before_db=round(fb, 1), noise_floor_after_db=round(fa, 1),
684
+ reduction_db=round(fa - fb, 1))
685
+ res.path = voice
686
+ save_json(meta_path, {"key": key, **{k: report.get(k) for k in REPORT_KEYS}})
687
+ for old in work.glob("voice_*"):
688
+ if key not in old.name:
689
+ old.unlink(missing_ok=True)
690
+
691
+
692
+ def build_voice(edit_dir: Path, base: Path, timeline: dict | None, fps: int, total: int, mode: str,
693
+ work: Path, jobs: int = 4, keep_temp: bool = False) -> VoiceResult:
694
+ """The cleaned voice track (48 kHz float stereo WAV, `total` samples) or path None when nothing was cleaned."""
695
+ edit_dir = Path(edit_dir)
696
+ work.mkdir(parents=True, exist_ok=True)
697
+ report = {"mode": mode, "applied": "off", "method": None, "noise_floor_before_db": None,
698
+ "noise_floor_after_db": None, "reduction_db": None, "delay_samples": None, "sources": [], "warnings": []}
699
+ res = VoiceResult(path=None, report=report)
700
+
701
+ def warn(msg: str) -> None:
702
+ res.warnings.append(msg)
703
+ report["warnings"].append(msg)
704
+
705
+ if mode == "off":
706
+ return res
707
+ test = self_test(work)
708
+ if not test.get("ok"):
709
+ warn(f"a limpeza de ruído foi pulada: o teste do ffmpeg falhou ({test.get('reason')}). O vídeo sai com o som original")
710
+ return res
711
+ delay = int(test["delay"])
712
+ report["delay_samples"] = delay
713
+
714
+ segs, tl_fps, reason = _segments_ok(timeline)
715
+ sources = None
716
+ if not reason:
717
+ tl_total = int((timeline or {}).get("frames") or sum(s["frames"] for s in segs)) * SR // tl_fps
718
+ if abs(tl_total - total) > SR // max(1, fps):
719
+ reason = "o cut_timeline.json não bate com o vídeo base"
720
+ if not reason:
721
+ sources, reason = resolve_segment_sources(edit_dir, timeline or {})
722
+ infos: dict[str, dict] = {}
723
+ for key, path in (sources or {}).items():
724
+ try:
725
+ infos[key] = probe_video(path)
726
+ except Exception: # noqa: BLE001
727
+ reason = f"não consegui ler a fonte {key} ({path.name})"
728
+ break
729
+ if reason:
730
+ warn(f"limpeza de ruído feita no som do corte, mais fraca que nas fontes: {reason}")
731
+ return _clean_cut(res, base, mode, delay, total, work, warn, keep_temp)
732
+ try:
733
+ return _from_sources(res, edit_dir, base, timeline or {}, segs, tl_fps, sources or {}, infos, mode, delay,
734
+ total, work, jobs, keep_temp, warn)
735
+ except SyncMismatch as e:
736
+ warn(f"limpeza de ruído feita no som do corte, mais fraca que nas fontes: {e}")
737
+ report["sources"] = []
738
+ report["sync_sources_refused"] = report.pop("sync", None)
739
+ return _clean_cut(res, base, mode, delay, total, work, warn, keep_temp)
740
+
741
+
742
+ def _from_sources(res: VoiceResult, edit_dir: Path, base: Path, timeline: dict, segs: list[dict], tl_fps: int,
743
+ sources: dict[str, Path], infos: dict[str, dict], mode: str, delay: int, total: int, work: Path,
744
+ jobs: int, keep_temp: bool, warn) -> VoiceResult:
745
+ report = res.report
746
+ report["method"] = "fontes"
747
+ spf = SR // tl_fps
748
+ modes: dict[str, str] = {}
749
+ for s in segs:
750
+ key = s["source"]
751
+ if key not in modes:
752
+ if not infos[key].get("has_audio"):
753
+ modes[key] = "silent"
754
+ elif s.get("audio_mode") in ("stereo", "mono", "left", "right", "multi"):
755
+ modes[key] = s["audio_mode"]
756
+ else:
757
+ modes[key] = rc.channel_mode(sources[key], infos[key])
758
+
759
+ plans: dict[str, dict] = {}
760
+ for key, path in sources.items():
761
+ entry = {"source": key, "file": path.name, "applied": "off", "profile": None, "nr": None, "nf": None,
762
+ "ranges": [s["id"] for s in segs if s["source"] == key]}
763
+ report["sources"].append(entry)
764
+ plans[key] = {"prof": None, "nr": None}
765
+ if modes[key] == "silent":
766
+ entry["reason"] = "sem áudio"
767
+ continue
768
+ prof = learn_profile(path, infos[key], modes[key], edit_dir, work)
769
+ entry["profile"] = prof.as_dict()
770
+ if not prof.usable:
771
+ entry["reason"] = prof.reason
772
+ warn(f"{key}: {prof.reason}. Esse vídeo ficou sem limpeza de ruído")
773
+ continue
774
+ applied = mode
775
+ if prof.how == "janelas":
776
+ if mode == "forte":
777
+ applied = "leve"
778
+ warn(f"{key} quase não tem pausa ({prof.pause_seconds:.2f} s): o ruído foi medido nas partes mais quietas"
779
+ + (" e a limpeza ficou em leve, no lugar de forte, para não abafar a voz" if mode == "forte" else ""))
780
+ entry.update(applied=applied, nr=NR[applied], nf=prof.nf)
781
+ plans[key] = {"prof": prof, "nr": NR[applied]}
782
+
783
+ applied_set = {e["applied"] for e in report["sources"] if e["applied"] != "off"}
784
+ if not applied_set:
785
+ if all(m == "silent" for m in modes.values()):
786
+ warn("a limpeza de ruído foi pulada: os vídeos do corte não têm áudio")
787
+ return res
788
+ report["applied"] = next(iter(applied_set)) if len(applied_set) == 1 else "misto"
789
+
790
+ ranges = []
791
+ for s in segs:
792
+ pl = plans[s["source"]]
793
+ prof = pl["prof"]
794
+ samples = (s["lead"] + s["frames"]) * spf
795
+ pre = min(PRE, int(round(s["audio_src_start"] * SR)))
796
+ src = sources[s["source"]]
797
+ rk = [VERSION, str(src), file_signature(src), round(s["audio_src_start"], 6), samples, pre, s["gain_db"],
798
+ modes[s["source"]], pl["nr"], prof.nf if prof else None, prof.wav.name if prof and prof.wav else None, delay]
799
+ if not (s["fade_in"] and s["fade_out"]):
800
+ rk += [s["fade_in"], s["fade_out"]]
801
+ ranges.append({"seg": s, "src": src, "prof": prof, "nr": pl["nr"], "samples": samples, "pre": pre,
802
+ "delay_out": (s["out_start_frame"] - s["lead"]) * spf, "key": _hash(rk)})
803
+
804
+ voice_key = _hash([VERSION, ffmpeg_version(), denoise_head(0, 0, 0), total, [(r["key"], r["delay_out"]) for r in ranges],
805
+ timeline.get("edl_sig"), timeline.get("rendered_at"), file_signature(base), rc.LOUDNORM])
806
+ voice = work / f"voice_{voice_key}.wav"
807
+ meta_path = work / f"voice_{voice_key}.json"
808
+ if _cached_voice(res, voice, meta_path, voice_key):
809
+ return res
810
+
811
+ tmp_dir = work / f"tmp_{voice_key}"
812
+ tmp_dir.mkdir(parents=True, exist_ok=True)
813
+
814
+ def render_range(r: dict) -> Path:
815
+ s = r["seg"]
816
+ out = tmp_dir / f"range_{s['id']}_{r['key']}.wav"
817
+ if modes[s["source"]] == "silent":
818
+ write_wav(out, np.zeros((r["samples"], 2), dtype=np.float32))
819
+ return out
820
+ prof = r["prof"]
821
+ clean = prof is not None and r["nr"] is not None
822
+ graph = range_graph(rc.wav_samples(prof.wav) if clean else None, r["nr"], prof.nf if clean else None,
823
+ r["pre"], delay, r["samples"], s["gain_db"], modes[s["source"]], s["fade_in"], s["fade_out"])
824
+ script = tmp_dir / f"range_{s['id']}_{r['key']}.txt"
825
+ script.write_text(graph + "\n", encoding="utf-8")
826
+ need = r["pre"] + (delay if clean else 0) + r["samples"]
827
+ args = ["-v", "error", "-y", "-ss", f"{s['audio_src_start'] - r['pre'] / SR:.6f}", "-t", f"{need / SR + 0.5:.6f}",
828
+ "-i", r["src"]]
829
+ if clean:
830
+ args += ["-i", prof.wav]
831
+ args += [*graph_args(script), "-map", "[out]", "-c:a", "pcm_f32le", "-ar", SR, "-ac", 2, out]
832
+ run_ffmpeg(args, f"limpar o ruído do trecho {s['id']}")
833
+ got = rc.wav_samples(out)
834
+ if got != r["samples"]:
835
+ raise DenoiseError(f"o trecho {s['id']} saiu com {got} amostras, esperava {r['samples']}")
836
+ return out
837
+
838
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
839
+ wavs = list(pool.map(render_range, ranges))
840
+
841
+ mix = tmp_dir / "mix.wav"
842
+ inputs, chains, labels = [], [], []
843
+ for i, (r, w) in enumerate(zip(ranges, wavs)):
844
+ inputs += ["-i", w]
845
+ chains.append(f"[{i}:a]adelay=delays={r['delay_out']}S:all=1[a{i}]")
846
+ labels.append(f"[a{i}]")
847
+ if len(ranges) == 1:
848
+ graph = f"{chains[0]};[a0]apad=whole_len={total},atrim=end_sample={total}[out]"
849
+ else:
850
+ graph = (";".join(chains) + ";" + "".join(labels) + f"amix=inputs={len(ranges)}:normalize=0:duration=longest:"
851
+ f"dropout_transition=0,apad=whole_len={total},atrim=end_sample={total}[out]")
852
+ script = tmp_dir / "mix.txt"
853
+ script.write_text(graph + "\n", encoding="utf-8")
854
+ run_ffmpeg(["-v", "error", "-y", *inputs, *graph_args(script), "-map", "[out]", "-c:a", "pcm_f32le", "-ar", SR,
855
+ "-ac", 2, mix], "juntar a voz limpa")
856
+ measured = rc.loudnorm_measure(mix)
857
+ res.report["loudnorm_modo"] = rc.loudnorm_modo(measured) # PrimoCode: o portão reprova o dinâmico
858
+ tmp_voice = voice.with_name(".tmp-" + voice.name)
859
+ if measured:
860
+ L = rc.LOUDNORM
861
+ af = (f"loudnorm=I={L['I']}:TP={L['TP']}:LRA={L['LRA']}:measured_I={measured['input_i']}:"
862
+ f"measured_TP={measured['input_tp']}:measured_LRA={measured['input_lra']}:"
863
+ f"measured_thresh={measured['input_thresh']}:offset={measured['target_offset']}:linear=true,"
864
+ f"aresample={SR},apad=whole_len={total},atrim=end_sample={total}")
865
+ run_ffmpeg(["-v", "error", "-y", "-i", mix, "-af", af, "-c:a", "pcm_f32le", "-ar", SR, "-ac", 2, "-f", "wav",
866
+ tmp_voice], "normalizar a voz limpa")
867
+ else:
868
+ os.replace(mix, tmp_voice)
869
+ os.replace(tmp_voice, voice)
870
+ if not keep_temp:
871
+ for d in work.glob("tmp_*"):
872
+ shutil.rmtree(d, ignore_errors=True)
873
+ edges = [e for r in ranges for e in (r["delay_out"], r["delay_out"] + r["samples"])]
874
+ # each range is compared where it plays alone: from the end of the previous range's audio to where the next one starts
875
+ starts = [r["delay_out"] for r in ranges] + [total]
876
+ spans = []
877
+ for i, r in enumerate(ranges):
878
+ lo = max(r["delay_out"], ranges[i - 1]["delay_out"] + ranges[i - 1]["samples"] if i else 0)
879
+ spans.append((r["seg"]["id"], lo + FADE_GUARD, min(r["delay_out"] + r["samples"], starts[i + 1]) - FADE_GUARD))
880
+ _finish(res, base, voice, edges, spans, voice_key, meta_path, work)
881
+ return res
882
+
883
+
884
+ def _clean_cut(res: VoiceResult, base: Path, mode: str, delay: int, total: int, work: Path, warn, keep_temp: bool) -> VoiceResult:
885
+ """No usable sources: clean the cut's own audio with a profile from its quietest windows, always leve."""
886
+ report = res.report
887
+ report["method"] = "corte"
888
+ report["applied"] = "off"
889
+ try:
890
+ info = probe_video(base)
891
+ except Exception: # noqa: BLE001
892
+ warn("a limpeza de ruído foi pulada: não consegui ler o áudio do corte")
893
+ return res
894
+ if not info.get("has_audio"):
895
+ warn("a limpeza de ruído foi pulada: o corte não tem áudio")
896
+ return res
897
+ an = stream_analysis(base, "stereo", float(info.get("duration") or 0.0), [])
898
+ prof = _profile_from_clip("corte", _join(an["quiet"]), "janelas", FALLBACK_EXTRA_DB, an["speech_ref_db"], 0.0,
899
+ work / f"profile_corte_{_hash([file_signature(base)])}.wav")
900
+ entry = {"source": "corte", "file": base.name, "applied": "off", "profile": prof.as_dict(), "nr": None, "nf": prof.nf,
901
+ "ranges": []}
902
+ report["sources"].append(entry)
903
+ if not prof.usable:
904
+ warn(f"a limpeza de ruído foi pulada: {prof.reason}")
905
+ return res
906
+ applied = "leve"
907
+ if mode == "forte":
908
+ warn("no som do corte a limpeza ficou em leve, no lugar de forte, para não abafar a voz")
909
+ entry.update(applied=applied, nr=NR[applied])
910
+ report["applied"] = applied
911
+ key = _hash([VERSION, ffmpeg_version(), denoise_head(0, 0, 0), "corte", file_signature(base), total, delay, prof.nf,
912
+ NR[applied]])
913
+ voice = work / f"voice_{key}.wav"
914
+ meta_path = work / f"voice_{key}.json"
915
+ if _cached_voice(res, voice, meta_path, key):
916
+ return res
917
+ script = work / f"cut_{key}.txt"
918
+ script.write_text(whole_graph(rc.wav_samples(prof.wav), NR[applied], prof.nf, delay, total) + "\n", # type: ignore[arg-type]
919
+ encoding="utf-8")
920
+ tmp_voice = voice.with_name(".tmp-" + voice.name)
921
+ run_ffmpeg(["-v", "error", "-y", "-i", base, "-i", prof.wav, *graph_args(script), "-map", "[out]",
922
+ "-c:a", "pcm_f32le", "-ar", SR, "-ac", 2, "-f", "wav", tmp_voice], "limpar o ruído do corte")
923
+ os.replace(tmp_voice, voice)
924
+ if not keep_temp:
925
+ script.unlink(missing_ok=True)
926
+ for old in work.glob("profile_corte_*"):
927
+ if old != prof.wav and re.fullmatch(r"profile_corte_[0-9a-f]{16}\.wav", old.name):
928
+ old.unlink(missing_ok=True)
929
+ _finish(res, base, voice, [], None, key, meta_path, work)
930
+ return res
931
+
932
+
933
+ # ============================================================== noise_check
934
+
935
+ def measure_source(src: Path, edit_dir: Path | None) -> dict:
936
+ """Noise floor in the pauses, speech level and their difference (dB) for one video."""
937
+ try:
938
+ info = probe_video(src)
939
+ except Exception: # noqa: BLE001
940
+ return {"source": str(src), "stem": src.stem, "error": "não consegui ler o arquivo"}
941
+ out = {"source": str(src), "stem": src.stem, "duration": round(float(info.get("duration") or 0.0), 2)}
942
+ if not info.get("has_audio"):
943
+ return {**out, "error": "sem áudio"}
944
+ mode = rc.channel_mode(src, info)
945
+ regions, where = load_regions(src, edit_dir)
946
+ duration = float(info.get("duration") or 0.0)
947
+ pauses = pause_intervals(regions, duration)
948
+ speech = [(float(a), float(b)) for a, b in (regions or {}).get("speech") or []]
949
+ an = stream_analysis(src, mode, duration, [], speech=speech, pauses=pauses)
950
+ pause_s = an["pause_seconds"]
951
+ how = "pausas"
952
+ noise = an["pause_db"]
953
+ if pause_s < PAUSE_MIN_S or noise is None:
954
+ how = "partes mais quietas"
955
+ clip = _join(an["quiet"])
956
+ noise = (db(float(np.mean(clip.astype(np.float64).mean(axis=1) ** 2))) + FALLBACK_EXTRA_DB) if len(clip) else None
957
+ speech_db = an["speech_db"] if an["speech_db"] is not None else an["speech_ref_db"]
958
+ out.update(regions=where, noise_from=how, pause_seconds=round(pause_s, 2),
959
+ noise_db=None if noise is None else round(noise, 1),
960
+ speech_db=None if speech_db is None else round(speech_db, 1))
961
+ if noise is None or speech_db is None:
962
+ return {**out, "error": "sem fala detectada"}
963
+ snr = speech_db - noise
964
+ out["snr_db"] = round(snr, 1)
965
+ if snr >= 30.0:
966
+ out.update(level="limpo", advice="limpo, não precisa", suggest="off")
967
+ elif snr >= 20.0:
968
+ out.update(level="leve", advice="ruído leve", suggest="leve")
969
+ else:
970
+ out.update(level="forte", advice="ruído forte", suggest="forte")
971
+ return out