primocode 10.16.0 → 10.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +28 -8
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/mcp.js +105 -7
- package/lib/nuvem.js +11 -1
- package/lib/outra-pasta.js +1 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +29 -12
- package/lib/protecao.js +134 -0
- package/lib/sandbox.js +11 -4
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- 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())
|