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.
- 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 +11 -2
- 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/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- 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,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
|