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,233 @@
1
+ """Voice level per phrase (or per EDL range) against the speaker's reference level.
2
+
3
+ The level of a range is the RMS of its SPEECH samples only (pauses inside the range are
4
+ left out, using the speech regions of the source), after an 80 Hz high-pass so room
5
+ rumble does not count as voice. The reference is the speech-weighted median of the levels
6
+ (the level half of the speech time sits at or above), so a cut split between a loud take and
7
+ a quiet take is measured against the take with more speech, not against the middle.
8
+ Ranges at least --drop-db below the reference are listed with the `gain_db` to write in the
9
+ EDL (the whole difference, capped at +-12).
10
+
11
+ Modes:
12
+ - phrases: `voice_levels.py <video>` uses edit/transcripts/<stem>.json (phrases as in
13
+ takes_packed.md). Without a transcript it falls back to the speech regions.
14
+ - EDL: `voice_levels.py [<video>] --edl edit/edl.json` measures the EDL ranges WITH their
15
+ current gain_db applied, so a gain already set is not suggested again. Ranges --drop-db
16
+ above the reference are listed too. The suggestion is the final gain_db (current + change).
17
+ Sources resolve like render_cut (absolute, or relative to the edit dir or its parent). With
18
+ a video only that source is listed.
19
+
20
+ Writes edit/verify/voice_levels.json with every range measured.
21
+
22
+ Usage:
23
+ uv run python helpers/voice_levels.py <video> [--edit-dir DIR] [--edl edl.json] [--drop-db 5] [--all]
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+
34
+ from common import load_json, now_iso, save_json
35
+ from pack_transcripts import phrases_for_transcript
36
+ from render_cut import resolve_source
37
+ from speech_regions import SAMPLE_RATE, AudioError, load_mono, load_or_compute_regions
38
+ from verify_cut import weighted_median
39
+
40
+ GAIN_CAP_DB = 12.0
41
+ MIN_SPEECH_S = 0.2
42
+ HIGHPASS = "highpass=f=80"
43
+
44
+
45
+ def _db(x: float) -> float:
46
+ return float(10.0 * np.log10(x + 1e-12))
47
+
48
+
49
+ def measure_range(mono: np.ndarray, speech: list[list[float]], start: float, end: float) -> dict:
50
+ """Speech-only RMS and peak of [start, end]."""
51
+ n = len(mono)
52
+ total_sq, total_n = 0.0, 0
53
+ for s, e in speech:
54
+ a, b = max(s, start), min(e, end)
55
+ if b <= a:
56
+ continue
57
+ i, j = int(a * SAMPLE_RATE), min(int(b * SAMPLE_RATE), n)
58
+ if j <= i:
59
+ continue
60
+ seg = mono[i:j].astype(np.float64)
61
+ total_sq += float(np.dot(seg, seg))
62
+ total_n += j - i
63
+ i0, j0 = int(max(start, 0.0) * SAMPLE_RATE), min(int(end * SAMPLE_RATE), n)
64
+ peak = float(np.max(np.abs(mono[i0:j0]))) if j0 > i0 else 0.0
65
+ speech_s = total_n / SAMPLE_RATE
66
+ return {
67
+ "speech_s": round(speech_s, 2),
68
+ "level_db": round(_db(total_sq / total_n), 1) if speech_s >= MIN_SPEECH_S else None,
69
+ "peak_db": round(20.0 * np.log10(peak + 1e-9), 1),
70
+ }
71
+
72
+
73
+ def _source_audio(path: Path, edit_dir: Path, cache: dict) -> tuple[np.ndarray, list[list[float]]]:
74
+ key = str(path)
75
+ if key not in cache:
76
+ regions = load_or_compute_regions(path, edit_dir)
77
+ mono, _ = load_mono(path, af=HIGHPASS)
78
+ cache[key] = (mono, regions["speech"])
79
+ return cache[key]
80
+
81
+
82
+ def ranges_from_edl(edl_path: Path, edit_dir: Path) -> list[dict]:
83
+ edl = load_json(edl_path)
84
+ if not isinstance(edl, dict) or not isinstance(edl.get("ranges"), list):
85
+ raise ValueError(f"EDL inválido (sem 'ranges'): {edl_path}")
86
+ sources = edl.get("sources") or {}
87
+ out = []
88
+ for i, r in enumerate(edl["ranges"]):
89
+ stem = r.get("source")
90
+ src = sources.get(stem)
91
+ if not src:
92
+ raise ValueError(f"o range {r.get('id', i + 1)} usa a fonte '{stem}', que não está em 'sources'")
93
+ path = resolve_source(str(src), edl_path.parent)
94
+ out.append({"id": r.get("id") or f"r{i + 1}", "source": stem, "path": path,
95
+ "start": float(r["start"]), "end": float(r["end"]),
96
+ "text": r.get("quote") or r.get("beat") or "", "edl_gain_db": float(r.get("gain_db") or 0.0)})
97
+ return out
98
+
99
+
100
+ def ranges_from_video(video: Path, edit_dir: Path) -> tuple[list[dict], str]:
101
+ transcript = load_json(edit_dir / "transcripts" / f"{video.stem}.json")
102
+ if isinstance(transcript, dict) and transcript.get("words"):
103
+ phrases = phrases_for_transcript(transcript)
104
+ return [{"id": f"f{i + 1}", "source": video.stem, "path": video, "start": p["start"], "end": p["end"],
105
+ "text": p["text"]} for i, p in enumerate(phrases)], "phrases"
106
+ regions = load_or_compute_regions(video, edit_dir)
107
+ merged: list[list[float]] = []
108
+ for s, e in regions["speech"]:
109
+ if merged and s - merged[-1][1] < 0.5:
110
+ merged[-1][1] = e
111
+ else:
112
+ merged.append([s, e])
113
+ return [{"id": f"t{i + 1}", "source": video.stem, "path": video, "start": s, "end": e, "text": ""}
114
+ for i, (s, e) in enumerate(merged)], "regions"
115
+
116
+
117
+ def main() -> None:
118
+ ap = argparse.ArgumentParser(description="Compara o volume da voz de cada frase (ou range do EDL) com o nível de referência da pessoa.")
119
+ ap.add_argument("video", type=Path, nargs="?", help="vídeo (opcional com --edl: sem ele mede todas as fontes)")
120
+ ap.add_argument("--edit-dir", type=Path, default=None, help="pasta edit (padrão: pasta do EDL ou <pasta do vídeo>/edit)")
121
+ ap.add_argument("--edl", type=Path, default=None, help="edl.json: mede os ranges do corte em vez das frases")
122
+ ap.add_argument("--drop-db", type=float, default=5.0, help="avisa trechos pelo menos isso abaixo (ou acima, no EDL) da referência (padrão 5)")
123
+ ap.add_argument("--all", action="store_true", help="lista todos os trechos, não só os baixos")
124
+ args = ap.parse_args()
125
+
126
+ if args.video is None and args.edl is None:
127
+ sys.exit("informe o vídeo ou --edl edit/edl.json")
128
+ video = args.video.absolute() if args.video else None
129
+ if video is not None and not video.exists():
130
+ sys.exit(f"vídeo não encontrado: {video}")
131
+ edl_path = None
132
+ if args.edl is not None:
133
+ edl_path = args.edl.absolute()
134
+ if not edl_path.exists() and video is not None and (video.parent / "edit" / args.edl).exists():
135
+ edl_path = (video.parent / "edit" / args.edl).absolute()
136
+ if not edl_path.exists():
137
+ sys.exit(f"EDL não encontrado: {edl_path}")
138
+ if args.edit_dir:
139
+ edit_dir = args.edit_dir.absolute()
140
+ elif edl_path is not None:
141
+ edit_dir = edl_path.parent
142
+ else:
143
+ edit_dir = video.parent / "edit"
144
+
145
+ try:
146
+ if edl_path is not None:
147
+ ranges, mode = ranges_from_edl(edl_path, edit_dir), "edl"
148
+ else:
149
+ ranges, mode = ranges_from_video(video, edit_dir)
150
+ except (ValueError, KeyError, TypeError) as exc:
151
+ sys.exit(str(exc))
152
+ if not ranges:
153
+ sys.exit("nenhum trecho para medir (sem fala detectada)")
154
+
155
+ cache: dict = {}
156
+ try:
157
+ for r in ranges:
158
+ if not Path(r["path"]).exists():
159
+ sys.exit(f"fonte não encontrada: {r['path']}")
160
+ mono, speech = _source_audio(Path(r["path"]), edit_dir, cache)
161
+ r.update(measure_range(mono, speech, r["start"], r["end"]))
162
+ except AudioError as exc:
163
+ sys.exit(str(exc))
164
+
165
+ edl_mode = mode == "edl"
166
+ for r in ranges:
167
+ # EDL mode: the level heard in the cut includes the gain already in the EDL.
168
+ current = float(r.get("edl_gain_db") or 0.0) if edl_mode else 0.0
169
+ r["current_gain_db"] = current
170
+ r["heard_db"] = None if r["level_db"] is None else round(r["level_db"] + current, 1)
171
+ measured = [r for r in ranges if r["heard_db"] is not None]
172
+ if not measured:
173
+ sys.exit("nenhum trecho tem fala suficiente para medir")
174
+ reference = weighted_median([r["heard_db"] for r in measured], [r["speech_s"] for r in measured])
175
+ for r in ranges:
176
+ if r["heard_db"] is None:
177
+ r.update(diff_db=None, low=False, high=False, gain_db=r["current_gain_db"])
178
+ continue
179
+ diff = round(r["heard_db"] - reference, 1)
180
+ low = diff <= -args.drop_db
181
+ high = edl_mode and diff >= args.drop_db
182
+ final = r["current_gain_db"] - diff if (low or high) else r["current_gain_db"]
183
+ r.update(diff_db=diff, low=low, high=high, gain_db=round(min(GAIN_CAP_DB, max(-GAIN_CAP_DB, final)), 1))
184
+
185
+ shown = [r for r in ranges if video is None or r["source"] == video.stem]
186
+ unit = {"edl": ("range do EDL", "ranges do EDL"), "phrases": ("frase", "frases"),
187
+ "regions": ("trecho de fala", "trechos de fala")}[mode][0 if len(shown) == 1 else 1]
188
+ scope = video.stem if video is not None else "todas as fontes"
189
+ gain_note = ", com o gain_db do EDL" if edl_mode else ""
190
+ print(f"Nível de voz · {scope} · {len(shown)} {unit} · referência {reference:.1f} dB "
191
+ f"(RMS só da fala{gain_note}; mediana pesada pelo tempo de fala)")
192
+ if mode == "regions":
193
+ print(" (sem transcrição: medi os trechos de fala; rode transcribe.py para medir por frase)")
194
+ listed = shown if args.all else [r for r in shown if r["low"] or r["high"]]
195
+ for r in listed:
196
+ where = f"{r['id']} {r['source']} " if edl_mode else ""
197
+ if r["heard_db"] is None:
198
+ print(f" {where}[{r['start']:06.2f}-{r['end']:06.2f}] sem fala suficiente para medir")
199
+ continue
200
+ tip = ""
201
+ if r["low"] or r["high"]:
202
+ tip = (f" gain_db final {r['gain_db']:+.1f} (hoje {r['current_gain_db']:+.1f})" if edl_mode
203
+ else f" gain_db {r['gain_db']:+.1f} se usar no EDL")
204
+ text = f' "{r["text"][:60]}"' if r["text"] else ""
205
+ print(f" {where}[{r['start']:06.2f}-{r['end']:06.2f}] {r['heard_db']:6.1f} dB ({r['diff_db']:+.1f}){tip}{text}")
206
+ n_low = sum(1 for r in shown if r["low"])
207
+ n_high = sum(1 for r in shown if r["high"])
208
+ if n_low + n_high == 0:
209
+ both = "abaixo ou acima" if edl_mode else "abaixo"
210
+ print(f" nenhum trecho {args.drop_db:g} dB ou mais {both} da referência")
211
+ elif not args.all:
212
+ parts = []
213
+ if n_low:
214
+ parts.append(f"{n_low} {'trecho baixo' if n_low == 1 else 'trechos baixos'}")
215
+ if n_high:
216
+ parts.append(f"{n_high} {'trecho alto' if n_high == 1 else 'trechos altos'}")
217
+ print(f" {' e '.join(parts)}. No EDL, troque o gain_db do range pelo valor final mostrado "
218
+ f"(máximo {GAIN_CAP_DB:g} dB para cima ou para baixo).")
219
+
220
+ out = edit_dir / "verify" / "voice_levels.json"
221
+ save_json(out, {
222
+ "mode": mode,
223
+ "edl": str(edl_path) if edl_path else None,
224
+ "reference_db": round(reference, 1),
225
+ "drop_db": args.drop_db,
226
+ "measured_at": now_iso(),
227
+ "ranges": [{k: (str(v) if k == "path" else v) for k, v in r.items()} for r in ranges],
228
+ })
229
+ print(f" detalhes em {out}")
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
@@ -0,0 +1,386 @@
1
+ """Saturação e eco de sala na voz de cada vídeo, medidos na onda (sem ouvir). O noise_check.py chama este.
2
+
3
+ Saturação (a voz bateu no teto do microfone ou do arquivo). Lido no áudio original, na taxa dele, canal a canal,
4
+ de uma vez só:
5
+ - amontoado no teto: teto = o nível que só 0,1% das amostras da fala passa; conta quanto da fala fica a menos de
6
+ 1 dB dele contra quanto fica de 3 a 4 dB abaixo. Voz natural rareia perto do topo (fica abaixo de 0,5) e um
7
+ limitador bem forte chega a uns 0,7; voz que bateu no teto se espreme nele (2 ou mais), mesmo quando o volume
8
+ foi abaixado depois e o arquivo não chega a 0 dBFS;
9
+ - platôs: 4 ou mais amostras seguidas exatamente iguais longe do zero (corte digital em arquivo sem compressão);
10
+ - acima de 0 dBFS: o AAC de um áudio cortado no teto devolve amostras acima de 1,0 (uma voz alta e limpa passa
11
+ em um ou dois pontos; dez pontos ou mais é corte);
12
+ - momentos no teto: janelas de 10 ms a menos de 1 dB do teto, juntadas quando estão a menos de 50 ms (os tempos
13
+ para ouvir).
14
+ Diagnóstico: "saturada", "possível" (ouça os tempos listados antes de afirmar) ou "ok".
15
+
16
+ Eco de sala (a voz continua soando na sala depois que a pessoa para). Duas medidas:
17
+ - cauda nas pausas: em cada fim de frase seguido de 0,25 s de pausa, a queda do volume (banda da voz, janelas de
18
+ 5 ms) é ajustada numa reta entre 5 dB e 25 dB abaixo do fim da fala (ou até 5 dB do chão de ruído), como um
19
+ tempo de reverberação: quanto tempo a voz levaria para cair 60 dB. Voz perto do microfone cai em menos de
20
+ 0,25 s; sala vazia e parede lisa passam de 0,4 s. Mediana dos fins de frase (pelo menos 3, com 15 dB de folga
21
+ acima do ruído);
22
+ - repetição: o cepstro médio dos trechos com fala (janelas de 1 s) mostra um pico quando o som volta igual
23
+ depois de 35 a 500 ms (parede longe, sala grande, eco de caixa). Pico 60 vezes acima da variação normal conta.
24
+ Diagnóstico: "forte" (cauda de 0,6 s ou mais, ou repetição muito marcada), "leve" (cauda de 0,4 a 0,6 s, ou
25
+ repetição), "não" ou "não deu para medir".
26
+
27
+ Os números são medida, não escuta: diga "medi" e, para decidir o tratamento, ouça os tempos listados.
28
+
29
+ Uso:
30
+ uv run python helpers/voz_check.py <video ou áudio> [...] [--json]
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import math
38
+ import subprocess
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+
44
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
45
+ from common import probe_video, require_binary # noqa: E402
46
+
47
+ WIN_S = 0.010
48
+ NEAR_PEAK_DB = 1.0
49
+ MOMENT_GAP_S = 0.05
50
+ PLATEAU_RUN = 4 # amostras iguais seguidas
51
+ PLATEAU_MIN = 0.1 # longe do zero (silêncio digital também é "igual")
52
+ LOUD_BELOW_DB = 20.0 # janelas de fala: até 20 dB abaixo da fala forte
53
+ HIST_LO_DB, HIST_HI_DB, HIST_STEP = -40.0, 6.0, 0.25
54
+ RMS_LO_DB, RMS_HI_DB = -100.0, 10.0
55
+ TOP_SHARE = 0.001 # teto = nível que só 0,1% das amostras da fala passa
56
+ SAT_PILEUP = 1.5 # medido: voz limpa 0,35; limitador forte 0,7; saturada (mesmo abaixada) 2,3 a 6
57
+ MAYBE_PILEUP = 1.0
58
+ PLATEAU_EVENTS = 20
59
+ MAYBE_PLATEAUS = 3
60
+ OVER_EVENTS = 10 # o AAC de uma voz alta e limpa passa de 0 dBFS em um ou dois pontos
61
+ MAYBE_OVER = 4
62
+ ECHO_SR = 16000
63
+ ECHO_HOP_S = 0.005
64
+ ECHO_PAUSE_S = 0.25
65
+ ECHO_MIN_RANGE_DB = 15.0
66
+ ECHO_MIN_OFFSETS = 3
67
+ ECHO_LEVE_S = 0.40
68
+ ECHO_FORTE_S = 0.60
69
+ CEPS_N = 16384 # 1 s a 16 kHz
70
+ CEPS_HOP = 4096
71
+ CEPS_RANGE_S = (0.035, 0.5) # abaixo de 35 ms mora a altura da voz e os harmônicos dela
72
+ CEPS_BASE_S = 0.02
73
+ CEPS_REPEAT_Z = 60.0
74
+ CEPS_STRONG_Z = 250.0
75
+
76
+
77
+ def utf8_console() -> None:
78
+ for stream in (sys.stdout, sys.stderr):
79
+ try:
80
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
81
+ except (AttributeError, ValueError, OSError):
82
+ pass
83
+
84
+
85
+ def fmt_t(t: float) -> str:
86
+ m, s = divmod(max(0.0, t), 60)
87
+ return f"{int(m)}:{s:04.1f}"
88
+
89
+
90
+ # ------------------------------------------------------------------ saturação
91
+
92
+
93
+ def measure_clipping(path: Path, info: dict) -> dict:
94
+ sr = int(info.get("sample_rate") or 48000)
95
+ ch = max(1, int(info.get("audio_channels") or 1))
96
+ w = max(1, int(round(sr * WIN_S)))
97
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-vn", "-map", "0:a:0", "-f", "f32le", "-"]
98
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
99
+ assert proc.stdout is not None
100
+ nb = int((HIST_HI_DB - HIST_LO_DB) / HIST_STEP)
101
+ nr = int(RMS_HI_DB - RMS_LO_DB)
102
+ # amostras por nível da amostra (0,25 dB) e pelo volume da janela de 10 ms em que ela está (1 dB): no fim só
103
+ # as janelas de fala contam, sem ler o arquivo duas vezes
104
+ hist = np.zeros((nr, nb), dtype=np.int64)
105
+ per_rms = np.zeros(nr, dtype=np.int64)
106
+ win_max: list[np.ndarray] = []
107
+ plateaus = over = 0
108
+ over_windows: list[int] = []
109
+ frame_bytes = w * ch * 4
110
+ rest = b""
111
+ done = 0
112
+ while True:
113
+ chunk = proc.stdout.read(frame_bytes * 1000)
114
+ if not chunk:
115
+ break
116
+ data = rest + chunk
117
+ usable = len(data) - len(data) % frame_bytes
118
+ rest = data[usable:]
119
+ if not usable:
120
+ continue
121
+ x = np.frombuffer(data[:usable], dtype=np.float32).reshape(-1, ch)
122
+ per = np.abs(x).astype(np.float64).reshape(-1, w * ch)
123
+ win_max.append(per.max(axis=1))
124
+ rb = np.clip((20 * np.log10(np.sqrt((per ** 2).mean(axis=1)) + 1e-12) - RMS_LO_DB).astype(np.int64), 0, nr - 1)
125
+ per_rms += np.bincount(rb, minlength=nr) * w * ch
126
+ rows, cols = np.nonzero(per > 10 ** (HIST_LO_DB / 20))
127
+ if rows.size:
128
+ idx = np.clip(((20 * np.log10(per[rows, cols]) - HIST_LO_DB) / HIST_STEP).astype(np.int64), 0, nb - 1)
129
+ np.add.at(hist, (rb[rows], idx), 1)
130
+ above = per > 1.0
131
+ if above.any():
132
+ over += int(above.sum())
133
+ over_windows += list(np.flatnonzero(above.any(axis=1)) + done)
134
+ for c in range(ch):
135
+ col = x[:, c]
136
+ eq = (np.diff(col) == 0) & (np.abs(col[1:]) >= PLATEAU_MIN)
137
+ if eq.any():
138
+ d = np.diff(np.concatenate([[0], eq.astype(np.int8), [0]]))
139
+ starts, ends = np.flatnonzero(d == 1), np.flatnonzero(d == -1)
140
+ plateaus += int(((ends - starts) >= PLATEAU_RUN - 1).sum())
141
+ done += per.shape[0]
142
+ err = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
143
+ proc.wait()
144
+ if proc.returncode != 0 or not win_max:
145
+ return {"error": f"não consegui ler o áudio: {err.strip()[-200:]}"}
146
+ wmax = np.concatenate(win_max)
147
+ peak = float(wmax.max())
148
+ if peak <= 1e-6:
149
+ return {"error": "áudio mudo"}
150
+ peak_db = 20 * math.log10(peak)
151
+ # janelas de fala: até LOUD_BELOW_DB abaixo da fala forte (percentil 95 do volume das janelas)
152
+ cum = np.cumsum(per_rms)
153
+ loud_bin = int(np.searchsorted(cum, 0.95 * cum[-1]))
154
+ first = max(0, loud_bin - int(LOUD_BELOW_DB))
155
+ h = hist[first:].sum(axis=0)
156
+ speech_samples = int(per_rms[first:].sum())
157
+ # teto: o nível que só 0,1% das amostras da fala passa
158
+ from_top = np.cumsum(h[::-1])
159
+ k_top = nb - 1 - int(np.searchsorted(from_top, TOP_SHARE * speech_samples))
160
+ ceil_db = HIST_LO_DB + (k_top + 1) * HIST_STEP
161
+ step = int(round(1.0 / HIST_STEP))
162
+ near = int(h[max(0, k_top + 1 - step):].sum())
163
+ band = int(h[max(0, k_top + 1 - 4 * step): max(0, k_top + 1 - 3 * step)].sum())
164
+ pileup = near / max(1, band)
165
+ fraction = near / max(1, speech_samples)
166
+ hot = np.flatnonzero(20 * np.log10(wmax + 1e-12) >= ceil_db - NEAR_PEAK_DB)
167
+ moments: list[list[int]] = []
168
+ gap = int(round(MOMENT_GAP_S / WIN_S))
169
+ for i in hot:
170
+ if moments and i - moments[-1][1] <= gap:
171
+ moments[-1][1] = int(i)
172
+ else:
173
+ moments.append([int(i), int(i)])
174
+ over_moments = 0
175
+ last = -10 ** 9
176
+ for i in over_windows:
177
+ if i - last > gap:
178
+ over_moments += 1
179
+ last = i
180
+ if plateaus >= PLATEAU_EVENTS or over_moments >= OVER_EVENTS or pileup >= SAT_PILEUP:
181
+ level = "saturada"
182
+ elif plateaus >= MAYBE_PLATEAUS or over_moments >= MAYBE_OVER or pileup >= MAYBE_PILEUP:
183
+ level = "possivel"
184
+ else:
185
+ level = "ok"
186
+ return {"level": level, "peak_dbfs": round(peak_db, 2), "ceiling_dbfs": round(ceil_db, 2),
187
+ "pileup": round(pileup, 2), "near_ceiling_fraction": round(fraction, 5), "moments": len(moments),
188
+ "moment_times": [round(a * WIN_S, 2) for a, _ in moments][:12], "plateaus": plateaus,
189
+ "over_full_scale": over, "over_moments": over_moments,
190
+ "speech_s": round(speech_samples / ch / sr, 2)}
191
+
192
+
193
+ # ------------------------------------------------------------------ eco
194
+
195
+
196
+ def echo_envelope(path: Path) -> np.ndarray:
197
+ """dB em janelas de 5 ms da banda da voz (200 Hz a 5 kHz), mono a 16 kHz."""
198
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-vn", "-map", "0:a:0", "-af",
199
+ f"aresample={ECHO_SR}:async=1:first_pts=0,highpass=f=200,lowpass=f=5000", "-ac", "1", "-f", "f32le", "-"]
200
+ raw = subprocess.run(cmd, capture_output=True).stdout
201
+ x = np.frombuffer(raw, dtype=np.float32).astype(np.float64)
202
+ w = int(ECHO_SR * ECHO_HOP_S)
203
+ n = len(x) // w
204
+ if n == 0:
205
+ return np.zeros(0)
206
+ return 10 * np.log10((x[: n * w].reshape(n, w) ** 2).mean(axis=1) + 1e-12)
207
+
208
+
209
+ def repetition(path: Path) -> dict | None:
210
+ """Pico do cepstro médio da fala entre 35 e 500 ms: o som que volta igual depois de um tempo."""
211
+ raw = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-vn", "-map", "0:a:0", "-af",
212
+ f"aresample={ECHO_SR}", "-ac", "1", "-f", "f32le", "-"], capture_output=True).stdout
213
+ x = np.frombuffer(raw, dtype=np.float32).astype(np.float64)
214
+ starts = list(range(0, len(x) - CEPS_N, CEPS_HOP))
215
+ if len(starts) < 4:
216
+ return None
217
+ rms_db = np.array([10 * math.log10(float(np.mean(x[i:i + CEPS_N] ** 2)) + 1e-12) for i in starts])
218
+ loud = float(np.percentile(rms_db, 90))
219
+ win = np.hanning(CEPS_N)
220
+ acc = np.zeros(CEPS_N // 2)
221
+ used = 0
222
+ for i, level in zip(starts, rms_db):
223
+ if level < loud - 15:
224
+ continue
225
+ spec = np.abs(np.fft.rfft(x[i:i + CEPS_N] * win)) ** 2
226
+ acc += np.fft.irfft(np.log(spec + 1e-10))[: CEPS_N // 2]
227
+ used += 1
228
+ if used < 3:
229
+ return None
230
+ c = acc / used
231
+ lo, hi = int(CEPS_RANGE_S[0] * ECHO_SR), int(CEPS_RANGE_S[1] * ECHO_SR)
232
+ half = int(CEPS_BASE_S * ECHO_SR) // 2
233
+ seg = c[lo - half: hi + half]
234
+ base = np.array([np.median(seg[k - half: k + half + 1]) for k in range(half, len(seg) - half)])
235
+ r = c[lo:hi] - base
236
+ mad = float(np.median(np.abs(r))) + 1e-12
237
+ z = r / mad
238
+ k = int(np.argmax(z))
239
+ return {"z": round(float(z[k]), 1), "atraso_s": round((lo + k) / ECHO_SR, 3), "janelas": used}
240
+
241
+
242
+ def measure_echo(path: Path) -> dict:
243
+ rep_ = repetition(path)
244
+ res = measure_tail(path)
245
+ if rep_ is not None:
246
+ res["repeticao"] = rep_
247
+ if rep_["z"] >= CEPS_REPEAT_Z:
248
+ strong = rep_["z"] >= CEPS_STRONG_Z
249
+ if res["level"] in ("sem_medida", "nao") or (strong and res["level"] == "leve"):
250
+ res["level"] = "forte" if strong else "leve"
251
+ res["por"] = "repeticao" if "rt60_s" not in res or res["rt60_s"] < ECHO_LEVE_S else "cauda_e_repeticao"
252
+ return res
253
+
254
+
255
+ def measure_tail(path: Path) -> dict:
256
+ env = echo_envelope(path)
257
+ if env.size < int(2.0 / ECHO_HOP_S):
258
+ return {"level": "sem_medida", "why": "áudio curto demais"}
259
+ floor = float(np.percentile(env, 10))
260
+ loud = float(np.percentile(env, 95))
261
+ speech_th = max(floor + 15.0, loud - 25.0)
262
+ pause_n = int(round(ECHO_PAUSE_S / ECHO_HOP_S))
263
+ look = int(round(0.6 / ECHO_HOP_S))
264
+ is_speech = env >= speech_th
265
+ rows = []
266
+ i = 0
267
+ n = len(env)
268
+ while i < n - pause_n:
269
+ # último quadro de fala seguido de pausa (nada de fala por ECHO_PAUSE_S)
270
+ if is_speech[i] and not is_speech[i + 1: i + 1 + pause_n].any():
271
+ lo = max(0, i - 10)
272
+ top = lo + int(np.argmax(env[lo: i + 1]))
273
+ l0 = float(env[top])
274
+ stop_db = max(l0 - 25.0, floor + 5.0)
275
+ if l0 - stop_db >= ECHO_MIN_RANGE_DB:
276
+ seg = env[top: min(n, top + look)]
277
+ below5 = np.flatnonzero(seg <= l0 - 5.0)
278
+ below_end = np.flatnonzero(seg <= stop_db)
279
+ if below5.size and below_end.size and below_end[0] > below5[0] + 1:
280
+ a, b = int(below5[0]), int(below_end[0])
281
+ t = np.arange(a, b + 1) * ECHO_HOP_S
282
+ slope = float(np.polyfit(t, seg[a: b + 1], 1)[0])
283
+ if slope < -1.0:
284
+ rows.append({"em": round((top) * ECHO_HOP_S, 2), "rt60": round(min(5.0, 60.0 / -slope), 3),
285
+ "queda_db": round(l0 - stop_db, 1)})
286
+ i += pause_n
287
+ continue
288
+ i += 1
289
+ if len(rows) < ECHO_MIN_OFFSETS:
290
+ why = ("ruído alto demais perto da voz" if loud - floor < ECHO_MIN_RANGE_DB + 10
291
+ else f"só {len(rows)} fim(ns) de frase com pausa para medir")
292
+ return {"level": "sem_medida", "why": why, "offsets": len(rows), "floor_db": round(floor, 1)}
293
+ rt = float(np.median([r["rt60"] for r in rows]))
294
+ level = "forte" if rt >= ECHO_FORTE_S else "leve" if rt >= ECHO_LEVE_S else "nao"
295
+ worst = sorted(rows, key=lambda r: -r["rt60"])[:5]
296
+ return {"level": level, "rt60_s": round(rt, 3), "offsets": len(rows),
297
+ "worst_times": [r["em"] for r in worst], "floor_db": round(floor, 1)}
298
+
299
+
300
+ # ------------------------------------------------------------------ juntos
301
+
302
+
303
+ def measure_voice(path: Path) -> dict:
304
+ try:
305
+ info = probe_video(path)
306
+ except Exception as exc: # noqa: BLE001
307
+ return {"error": f"não consegui ler: {exc}"}
308
+ if not info.get("has_audio"):
309
+ return {"error": "sem áudio"}
310
+ return {"saturacao": measure_clipping(path, info), "eco": measure_echo(path)}
311
+
312
+
313
+ def clip_text(c: dict) -> str:
314
+ if c.get("error"):
315
+ return f"saturação: {c['error']}"
316
+ when = ", ".join(fmt_t(t) for t in c.get("moment_times", [])[:6])
317
+ extra = []
318
+ if c["pileup"] >= MAYBE_PILEUP:
319
+ extra.append(f"{c['near_ceiling_fraction']:.1%} da fala espremida no teto")
320
+ if c["plateaus"]:
321
+ extra.append(f"{c['plateaus']} platôs")
322
+ if c["over_moments"]:
323
+ extra.append(f"passou de 0 dBFS em {c['over_moments']} pontos")
324
+ more = f" ({', '.join(extra)})" if extra else ""
325
+ if c["level"] == "saturada":
326
+ return (f"voz SATURADA em {c['moments']} momentos ({when}...){more}. Não se recupera por inteiro: dá para "
327
+ "suavizar, não para desfazer. Se der, grave de novo com o microfone mais longe ou o ganho mais baixo")
328
+ if c["level"] == "possivel":
329
+ return f"possível saturação{more}: ouça {when} antes de dizer que saturou"
330
+ return f"sem saturação (pico {c['peak_dbfs']:.1f} dBFS)"
331
+
332
+
333
+ def echo_text(e: dict) -> str:
334
+ if e.get("error"):
335
+ return ""
336
+ rt = str(round(e["rt60_s"], 2)).replace(".", ",") if "rt60_s" in e else None
337
+ if e["level"] == "sem_medida":
338
+ return f"eco não medido ({e['why']})"
339
+ if e["level"] == "nao":
340
+ return f"sem eco de sala (a voz some em {rt} s nas pausas)"
341
+ what = "eco de sala forte" if e["level"] == "forte" else "eco de sala leve"
342
+ parts = []
343
+ if rt and e["rt60_s"] >= ECHO_LEVE_S:
344
+ when = ", ".join(fmt_t(t) for t in e.get("worst_times", [])[:4])
345
+ parts.append(f"a voz leva uns {rt} s para sumir nas pausas (ouça em {when})")
346
+ rp = e.get("repeticao") or {}
347
+ if rp.get("z", 0) >= CEPS_REPEAT_Z:
348
+ parts.append(f"o som volta repetido uns {round(rp['atraso_s'] * 1000)} ms depois")
349
+ return (f"{what}: {'; '.join(parts)}. Trate leve e compare antes e depois no mesmo volume percebido "
350
+ "(references/revisao.md, Voz)")
351
+
352
+
353
+ def line_for(name: str, r: dict) -> str:
354
+ if r.get("error"):
355
+ return f" {name}: {r['error']}"
356
+ parts = [clip_text(r["saturacao"]), echo_text(r["eco"])]
357
+ return f" {name} · " + " · ".join(p for p in parts if p)
358
+
359
+
360
+ def main() -> int:
361
+ ap = argparse.ArgumentParser(description="Mede saturação e eco de sala na voz de cada vídeo.")
362
+ ap.add_argument("paths", nargs="+", type=Path, help="vídeos ou áudios")
363
+ ap.add_argument("--json", action="store_true", help="imprime o JSON")
364
+ args = ap.parse_args()
365
+ utf8_console()
366
+ require_binary("ffmpeg")
367
+ require_binary("ffprobe")
368
+ out = {}
369
+ for p in args.paths:
370
+ p = p.expanduser().absolute()
371
+ if not p.is_file():
372
+ print(f"erro: não encontrado: {p}", file=sys.stderr)
373
+ return 2
374
+ key = p.stem if p.stem not in out and not any(q != p and q.stem == p.stem for q in args.paths) else p.name
375
+ out[key] = measure_voice(p)
376
+ if args.json:
377
+ print(json.dumps(out, ensure_ascii=False, indent=2))
378
+ return 0
379
+ print("Voz (saturação e eco de sala, medidos na onda):")
380
+ for name, r in out.items():
381
+ print(line_for(name, r))
382
+ return 0
383
+
384
+
385
+ if __name__ == "__main__":
386
+ sys.exit(main())