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,940 @@
|
|
|
1
|
+
"""Sound effects for editor-video (Contract 8): the built-in effect registry and the procedural generator.
|
|
2
|
+
|
|
3
|
+
Built-in effects (the 24 names in NAMES: whoosh, pop, click, tick, rise, cash, coin, notification, success,
|
|
4
|
+
error, count, keyboard, snap, mouse_click, bass_drop, metal_hit, synth_hit, flash, charge, metal_riser, wind,
|
|
5
|
+
rewind, gears, data) are listed in assets/sfx/sfx.json: for each name the file (relative to assets/sfx), the
|
|
6
|
+
anchor (`anchor_s`, seconds from the file start to the moment the sound hits full strength, which
|
|
7
|
+
compose_final puts on the event frame), a Portuguese `label` and `aliases` (resolve_name: "grave" ->
|
|
8
|
+
bass_drop, "estalo de dedo" -> snap; accents, case, spaces and hyphens do not matter), where it came from
|
|
9
|
+
and its measured levels. An entry points either to a recording in assets/sfx/ (the `source` field says where
|
|
10
|
+
it came from) or to a procedural file in assets/sfx/sinteticos/.
|
|
11
|
+
|
|
12
|
+
The procedural generator writes assets/sfx/sinteticos/<name>.wav for SYNTH_NAMES with numpy only: 48 kHz,
|
|
13
|
+
16-bit stereo, deterministic (fixed random seeds), so re-running gives the same bytes. The first seven peak at
|
|
14
|
+
about -3 dBFS with the attack at t=0; the eight sounds added on 16/09/2026 (KIT_LEVELS: error, bass_drop,
|
|
15
|
+
synth_hit, flash, charge, wind, rewind, data) come out already at their kit level (kit_gain_db: 200 ms target
|
|
16
|
+
at sfx.volume 1.0, never above -20 LUFS on the 3 s window) and are used as they are by sfx.json.
|
|
17
|
+
assets/sfx/sfx_sinteticos.json is the registry of the procedural set alone (`synthetic_registry()`).
|
|
18
|
+
|
|
19
|
+
`measure()` gives the numbers stored in sfx.json (numpy only): K-weighted (BS.1770) loudness maxima over
|
|
20
|
+
200 ms, 400 ms (momentary) and 3 s (short-term) windows, the sample peak and the anchor (first moment the
|
|
21
|
+
50 ms K-weighted power envelope comes within 2 dB of its maximum).
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
uv run python helpers/make_sfx.py [--out-dir assets/sfx/sinteticos] [--only whoosh,pop] [--peak-db -3]
|
|
25
|
+
uv run python helpers/make_sfx.py --measure [arquivo.wav ...] (sem arquivo: os efeitos prontos)
|
|
26
|
+
uv run python helpers/make_sfx.py --synthetic-registry (grava assets/sfx/sfx_sinteticos.json)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import unicodedata
|
|
38
|
+
import wave
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
44
|
+
from common import SFX_DIR # noqa: E402
|
|
45
|
+
|
|
46
|
+
SR = 48000
|
|
47
|
+
PEAK_DB = -3.0
|
|
48
|
+
# every built-in effect name, in the order of assets/sfx/sfx.json (the first seven are the 15/09/2026 set)
|
|
49
|
+
NAMES = ("whoosh", "pop", "click", "tick", "rise", "cash", "coin",
|
|
50
|
+
"notification", "success", "error", "count", "keyboard", "snap", "mouse_click",
|
|
51
|
+
"bass_drop", "metal_hit", "synth_hit", "flash", "charge", "metal_riser", "wind", "rewind", "gears", "data")
|
|
52
|
+
# the procedural set: what this file generates into assets/sfx/sinteticos/
|
|
53
|
+
SYNTH_NAMES = ("whoosh", "pop", "click", "tick", "rise", "cash", "coin",
|
|
54
|
+
"error", "bass_drop", "synth_hit", "flash", "charge", "wind", "rewind", "data")
|
|
55
|
+
SYNTH_DIR = SFX_DIR / "sinteticos" # procedural files (make_sfx output)
|
|
56
|
+
MANIFEST = SFX_DIR / "sfx.json" # built-in name -> file, anchor_s, source, levels
|
|
57
|
+
SYNTH_REGISTRY = SFX_DIR / "sfx_sinteticos.json" # the same for the procedural set only (the package's sfx.json)
|
|
58
|
+
SYNTH_SOUNDS = { # PT-BR description of each procedural sound (duration added)
|
|
59
|
+
"whoosh": "vento rápido, sintético",
|
|
60
|
+
"pop": "bolha estourando, sintético",
|
|
61
|
+
"click": "clique curto, sintético",
|
|
62
|
+
"tick": "toque seco e curto de madeira",
|
|
63
|
+
"rise": "subida curta que cresce até o topo, sintética",
|
|
64
|
+
"cash": "caixa registradora: estalo mecânico e logo depois o sino, o ching",
|
|
65
|
+
"coin": "uma moeda tilintando, sintética",
|
|
66
|
+
"error": "buzina de erro: uma nota curta e outra mais grave e mais longa",
|
|
67
|
+
"bass_drop": "grave que cai: um tom de 150 Hz descendo até 26 Hz",
|
|
68
|
+
"synth_hit": "batida sintética: um zap descendo que termina num grave 808",
|
|
69
|
+
"flash": "flash: ar subindo, estouro brilhante com baque grave e um brilho que apaga",
|
|
70
|
+
"charge": "carga de energia: estalo elétrico e um apito que sobe e some",
|
|
71
|
+
"wind": "vento suave que passa da esquerda para a direita",
|
|
72
|
+
"rewind": "fita rebobinando: som embolado que acelera e para de repente",
|
|
73
|
+
"data": "dados de computador: bipes agudos e rápidos",
|
|
74
|
+
}
|
|
75
|
+
SFX_CALIBRATION = 1.4 # linear gain of an effect at sfx.volume 1.0 over a voice at the target loudness (compose_final)
|
|
76
|
+
ANCHOR_WINDOW_S = 0.05 # anchor: 50 ms K-weighted power envelope ...
|
|
77
|
+
ANCHOR_DROP_DB = 2.0 # ... first reaches 2 dB under its maximum
|
|
78
|
+
# Level rule of the kit, at sfx.volume 1.0 over a -14 LUFS voice: short UI accents -14 LUFS on the loudest 200 ms
|
|
79
|
+
# window, a soft whoosh -12, impacts -10, and never above -20 LUFS on the 3 s window (long sounds, like the risers,
|
|
80
|
+
# are set by that window alone: 6 dB under the voice).
|
|
81
|
+
LONG_CAP_LUFS = -20.0
|
|
82
|
+
KIT_PEAK_MAX_DB = -1.0
|
|
83
|
+
# the procedural sounds of 16/09/2026 come out of the generator already at their kit level (200 ms target or None)
|
|
84
|
+
KIT_LEVELS = {"error": -14.0, "bass_drop": -10.0, "synth_hit": -10.0, "flash": -10.0, "charge": None,
|
|
85
|
+
"wind": -12.0, "rewind": None, "data": None}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------- building blocks
|
|
89
|
+
|
|
90
|
+
def _t(dur: float) -> np.ndarray:
|
|
91
|
+
return np.arange(int(round(dur * SR))) / SR
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _attack_env(n: int, attack_s: float, decay_s: float, hold_s: float = 0.0) -> np.ndarray:
|
|
95
|
+
"""Fast linear attack from 0, optional hold, exponential decay (time constant decay_s)."""
|
|
96
|
+
t = np.arange(n) / SR
|
|
97
|
+
env = np.ones(n)
|
|
98
|
+
a = max(1, int(attack_s * SR))
|
|
99
|
+
env[:a] = np.linspace(0.0, 1.0, a, endpoint=False) ** 0.7
|
|
100
|
+
tail = t - attack_s - hold_s
|
|
101
|
+
env = np.where(tail > 0, env * np.exp(-np.maximum(tail, 0) / max(decay_s, 1e-4)), env)
|
|
102
|
+
return env
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fade_tail(x: np.ndarray, ms: float = 12.0) -> np.ndarray:
|
|
106
|
+
n = min(len(x), int(SR * ms / 1000))
|
|
107
|
+
if n > 1:
|
|
108
|
+
x = x.copy()
|
|
109
|
+
x[-n:] *= np.linspace(1.0, 0.0, n) ** 2
|
|
110
|
+
return x
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _svf_bandpass(x: np.ndarray, cutoff: np.ndarray, q: float) -> np.ndarray:
|
|
114
|
+
"""Chamberlin state-variable band-pass with a per-sample cutoff (Hz)."""
|
|
115
|
+
y = np.zeros_like(x)
|
|
116
|
+
low = band = 0.0
|
|
117
|
+
damp = 1.0 / q
|
|
118
|
+
f = 2.0 * np.sin(np.pi * np.clip(cutoff, 20.0, SR / 6.5) / SR)
|
|
119
|
+
for i in range(len(x)):
|
|
120
|
+
high = x[i] - low - damp * band
|
|
121
|
+
band += f[i] * high
|
|
122
|
+
low += f[i] * band
|
|
123
|
+
y[i] = band
|
|
124
|
+
return y
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _one_pole_lowpass(x: np.ndarray, hz: float) -> np.ndarray:
|
|
128
|
+
a = math.exp(-2.0 * math.pi * hz / SR)
|
|
129
|
+
y = np.zeros_like(x)
|
|
130
|
+
acc = 0.0
|
|
131
|
+
for i in range(len(x)):
|
|
132
|
+
acc = (1 - a) * x[i] + a * acc
|
|
133
|
+
y[i] = acc
|
|
134
|
+
return y
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _highpass(x: np.ndarray, hz: float) -> np.ndarray:
|
|
138
|
+
return x - _one_pole_lowpass(x, hz)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize(stereo: np.ndarray, peak_db: float) -> np.ndarray:
|
|
142
|
+
peak = float(np.max(np.abs(stereo))) or 1.0
|
|
143
|
+
return stereo * (10 ** (peak_db / 20.0) / peak)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------- sounds
|
|
147
|
+
|
|
148
|
+
def whoosh() -> np.ndarray:
|
|
149
|
+
"""Airy swish: noise through a band-pass sweeping down, quick attack, 0.5 s tail."""
|
|
150
|
+
rng = np.random.default_rng(1207)
|
|
151
|
+
dur = 0.55
|
|
152
|
+
n = int(dur * SR)
|
|
153
|
+
t = np.arange(n) / SR
|
|
154
|
+
p = t / dur
|
|
155
|
+
sweep = 5200.0 * (380.0 / 5200.0) ** (p ** 0.6)
|
|
156
|
+
chans = []
|
|
157
|
+
for side, detune in ((0, 1.0), (1, 1.07)):
|
|
158
|
+
noise = rng.standard_normal(n)
|
|
159
|
+
band = _svf_bandpass(noise, sweep * detune, q=1.6)
|
|
160
|
+
air = _highpass(rng.standard_normal(n), 3000.0) * 0.12
|
|
161
|
+
env = _attack_env(n, 0.006, 0.16, hold_s=0.02)
|
|
162
|
+
chans.append((band + air) * env)
|
|
163
|
+
x = np.stack(chans)
|
|
164
|
+
return np.stack([_fade_tail(c, 30) for c in x])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pop() -> np.ndarray:
|
|
168
|
+
"""Bubble pop: sine with a fast pitch drop plus a tiny click on top."""
|
|
169
|
+
rng = np.random.default_rng(88)
|
|
170
|
+
dur = 0.16
|
|
171
|
+
t = _t(dur)
|
|
172
|
+
n = len(t)
|
|
173
|
+
freq = 240.0 + 820.0 * np.exp(-t / 0.018)
|
|
174
|
+
phase = 2 * np.pi * np.cumsum(freq) / SR
|
|
175
|
+
body = np.sin(phase) * _attack_env(n, 0.0015, 0.045)
|
|
176
|
+
click = _highpass(rng.standard_normal(n), 2500.0) * _attack_env(n, 0.0005, 0.004) * 0.35
|
|
177
|
+
mono = _fade_tail(body + click, 20)
|
|
178
|
+
return np.stack([mono, mono])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def click() -> np.ndarray:
|
|
182
|
+
"""Short UI click: bright filtered noise burst with a 2.9 kHz blip."""
|
|
183
|
+
rng = np.random.default_rng(5)
|
|
184
|
+
dur = 0.06
|
|
185
|
+
t = _t(dur)
|
|
186
|
+
n = len(t)
|
|
187
|
+
noise = _highpass(rng.standard_normal(n), 1800.0) * _attack_env(n, 0.0004, 0.006)
|
|
188
|
+
blip = np.sin(2 * np.pi * 2900.0 * t) * _attack_env(n, 0.0006, 0.012) * 0.6
|
|
189
|
+
low = np.sin(2 * np.pi * 180.0 * t) * _attack_env(n, 0.001, 0.01) * 0.35
|
|
190
|
+
mono = _fade_tail(noise + blip + low, 10)
|
|
191
|
+
return np.stack([mono, mono])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def tick() -> np.ndarray:
|
|
195
|
+
"""Woody tick: two inharmonic partials with a very short decay."""
|
|
196
|
+
dur = 0.07
|
|
197
|
+
t = _t(dur)
|
|
198
|
+
n = len(t)
|
|
199
|
+
mono = (np.sin(2 * np.pi * 1750.0 * t) + 0.55 * np.sin(2 * np.pi * 3620.0 * t + 0.6)
|
|
200
|
+
+ 0.25 * np.sin(2 * np.pi * 820.0 * t)) * _attack_env(n, 0.0008, 0.011)
|
|
201
|
+
mono = _fade_tail(mono, 10)
|
|
202
|
+
return np.stack([mono, mono])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def rise() -> np.ndarray:
|
|
206
|
+
"""Upward swell: a pitched hit at t=0, then a tone and noise band climbing for 0.9 s."""
|
|
207
|
+
rng = np.random.default_rng(314)
|
|
208
|
+
dur = 0.9
|
|
209
|
+
t = _t(dur)
|
|
210
|
+
n = len(t)
|
|
211
|
+
p = t / dur
|
|
212
|
+
freq = 220.0 * (4.0 ** (p ** 1.4))
|
|
213
|
+
phase = 2 * np.pi * np.cumsum(freq) / SR
|
|
214
|
+
tone = (np.sin(phase) + 0.3 * np.sin(2 * phase)) * 0.5
|
|
215
|
+
swell = 0.35 + 0.65 * (np.sin(np.pi * np.clip(p / 0.8, 0, 1) / 2.0) ** 2)
|
|
216
|
+
swell *= np.where(p > 0.8, np.exp(-(p - 0.8) * 18.0), 1.0)
|
|
217
|
+
start = np.linspace(0.0, 1.0, int(0.003 * SR), endpoint=False)
|
|
218
|
+
swell[: len(start)] *= start
|
|
219
|
+
# the onset at t=0: a short pitched thump with a bright edge, as loud as the swell peak
|
|
220
|
+
hit_f = 170.0 + 330.0 * np.exp(-t / 0.03)
|
|
221
|
+
hit = np.sin(2 * np.pi * np.cumsum(hit_f) / SR) * _attack_env(n, 0.002, 0.07) * 1.25
|
|
222
|
+
chans = []
|
|
223
|
+
for side in (0, 1):
|
|
224
|
+
noise = _svf_bandpass(rng.standard_normal(n), 900.0 + 5200.0 * p ** 1.5, q=2.0) * 0.8
|
|
225
|
+
edge = _highpass(rng.standard_normal(n), 2200.0) * _attack_env(n, 0.001, 0.02) * 0.5
|
|
226
|
+
chans.append(_fade_tail((tone + noise) * swell + hit + edge, 25))
|
|
227
|
+
return np.stack(chans)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _partial(t: np.ndarray, hz: float, amp: float, decay_s: float, phase: float = 0.0,
|
|
231
|
+
attack_s: float = 0.0012) -> np.ndarray:
|
|
232
|
+
"""One struck metallic partial: sine with a short attack and an exponential decay."""
|
|
233
|
+
return amp * np.sin(2 * np.pi * hz * t + phase) * _attack_env(len(t), attack_s, decay_s)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _at(x: np.ndarray, seconds: float, n: int) -> np.ndarray:
|
|
237
|
+
"""Place a short sound x at `seconds` inside a silent buffer of n samples."""
|
|
238
|
+
out = np.zeros(n)
|
|
239
|
+
i = int(round(seconds * SR))
|
|
240
|
+
if i < n:
|
|
241
|
+
m = min(len(x), n - i)
|
|
242
|
+
out[i:i + m] = x[:m]
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _clack(rng: np.random.Generator, center_hz: float, dur: float, decay_s: float,
|
|
247
|
+
body_hz: float, body_amp: float) -> np.ndarray:
|
|
248
|
+
"""Mechanical clack: band-passed noise burst plus a short resonant knock."""
|
|
249
|
+
t = _t(dur)
|
|
250
|
+
n = len(t)
|
|
251
|
+
burst = _svf_bandpass(rng.standard_normal(n), np.full(n, center_hz), q=1.3) * _attack_env(n, 0.0003, decay_s)
|
|
252
|
+
grit = _highpass(rng.standard_normal(n), 3500.0) * _attack_env(n, 0.0002, decay_s * 0.4) * 0.5
|
|
253
|
+
knock = np.sin(2 * np.pi * body_hz * t) * _attack_env(n, 0.0005, decay_s * 2.2) * body_amp
|
|
254
|
+
return burst + grit + knock
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
CASH_CHING_AT = 0.085 # seconds from the "ka" clack to the bell strike
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cash() -> np.ndarray:
|
|
261
|
+
"""Cash register ka-ching: drawer clack at t=0, bright bell 85 ms later, a light coin rattle."""
|
|
262
|
+
rng = np.random.default_rng(1971)
|
|
263
|
+
dur = 1.15
|
|
264
|
+
t = _t(dur)
|
|
265
|
+
n = len(t)
|
|
266
|
+
|
|
267
|
+
# "ka": two quick mechanical clacks (lever and drawer latch) over a low thump, done by ~60 ms
|
|
268
|
+
ka = _at(_clack(rng, 2300.0, 0.05, 0.006, 760.0, 0.35), 0.0, n)
|
|
269
|
+
ka += _at(_clack(rng, 1500.0, 0.035, 0.007, 520.0, 0.3) * 0.6, 0.024, n)
|
|
270
|
+
thump_t = _t(0.06)
|
|
271
|
+
thump_f = 105.0 + 120.0 * np.exp(-thump_t / 0.012)
|
|
272
|
+
thump = np.sin(2 * np.pi * np.cumsum(thump_f) / SR) * _attack_env(len(thump_t), 0.0008, 0.018)
|
|
273
|
+
ka += _at(_fade_tail(thump, 8), 0.0, n) * 0.6
|
|
274
|
+
# the cabinet keeps a faint ring into the bell, so the gap is not digital silence
|
|
275
|
+
ka += _at(np.sin(2 * np.pi * 610.0 * _t(0.12)) * _attack_env(int(0.12 * SR), 0.001, 0.03) * 0.05, 0.0, n)
|
|
276
|
+
ka = _fade_tail(ka[: int(0.12 * SR)], 20)
|
|
277
|
+
ka = np.concatenate([ka, np.zeros(n - len(ka))]) * 1.2
|
|
278
|
+
|
|
279
|
+
# "ching": inharmonic bell partials between 2 and 6 kHz; detuned pairs beat slowly, highs die first.
|
|
280
|
+
# Same pitches on both sides (a phone plays it in mono), only the phases differ.
|
|
281
|
+
bell_t = _t(dur - CASH_CHING_AT)
|
|
282
|
+
partials = ( # (Hz, amplitude, decay time constant in s)
|
|
283
|
+
(2217.0, 1.00, 0.62), (2221.5, 0.30, 0.58),
|
|
284
|
+
(3046.0, 0.70, 0.50),
|
|
285
|
+
(3981.0, 0.58, 0.42), (3987.0, 0.22, 0.40),
|
|
286
|
+
(4870.0, 0.44, 0.33),
|
|
287
|
+
(5723.0, 0.34, 0.26),
|
|
288
|
+
)
|
|
289
|
+
# coins settling in the drawer: a few tiny high clinks, quieter each time, alternating sides
|
|
290
|
+
clinks = []
|
|
291
|
+
when = CASH_CHING_AT + 0.17
|
|
292
|
+
for j in range(6):
|
|
293
|
+
when += float(rng.uniform(0.035, 0.07))
|
|
294
|
+
clinks.append((when, float(rng.uniform(5600.0, 7400.0)), 0.16 * (0.8 ** j)))
|
|
295
|
+
chans = []
|
|
296
|
+
for side in (0, 1):
|
|
297
|
+
bell = np.zeros(len(bell_t))
|
|
298
|
+
for k, (hz, amp, decay) in enumerate(partials):
|
|
299
|
+
bell += _partial(bell_t, hz, amp, decay, phase=0.7 * k + 0.5 * side * (k % 2))
|
|
300
|
+
strike = _highpass(rng.standard_normal(len(bell_t)), 4200.0) * _attack_env(len(bell_t), 0.0003, 0.0025) * 0.9
|
|
301
|
+
ching = _at(bell * 0.42 + strike, CASH_CHING_AT, n)
|
|
302
|
+
rattle = np.zeros(n)
|
|
303
|
+
ct = _t(0.05)
|
|
304
|
+
for j, (at_s, f1, amp) in enumerate(clinks):
|
|
305
|
+
clink = (_partial(ct, f1, 1.0, 0.012, attack_s=0.0004)
|
|
306
|
+
+ _partial(ct, f1 * 1.47, 0.6, 0.008, attack_s=0.0004))
|
|
307
|
+
rattle += _at(clink * amp * (1.0 if (j + side) % 2 else 0.55), at_s, n)
|
|
308
|
+
chans.append(_fade_tail(ka + ching + rattle, 180))
|
|
309
|
+
return np.stack(chans)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def coin() -> np.ndarray:
|
|
313
|
+
"""Single coin clink: a tiny strike and three high inharmonic partials, about 0.3 s."""
|
|
314
|
+
rng = np.random.default_rng(424)
|
|
315
|
+
dur = 0.32
|
|
316
|
+
t = _t(dur)
|
|
317
|
+
n = len(t)
|
|
318
|
+
chans = []
|
|
319
|
+
for side in (0, 1):
|
|
320
|
+
# same pitches on both sides (no beating when the phone plays it in mono), phases differ
|
|
321
|
+
ring = (_partial(t, 3890.0, 1.00, 0.110, phase=0.3 * side, attack_s=0.0005)
|
|
322
|
+
+ _partial(t, 5930.0, 0.62, 0.070, phase=0.4 + 0.9 * side, attack_s=0.0005)
|
|
323
|
+
+ _partial(t, 8610.0, 0.34, 0.040, phase=2.0 - 0.7 * side, attack_s=0.0005))
|
|
324
|
+
strike = _highpass(rng.standard_normal(n), 5000.0) * _attack_env(n, 0.0002, 0.0018) * 1.2
|
|
325
|
+
chans.append(_fade_tail(ring + strike, 60))
|
|
326
|
+
return np.stack(chans)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------- helpers for the 16/09/2026 sounds
|
|
330
|
+
|
|
331
|
+
def _pad_filter(x: np.ndarray, gain) -> np.ndarray:
|
|
332
|
+
"""Zero-phase filter in the frequency domain (gain: freqs -> linear gain), padded with 0.25 s of silence."""
|
|
333
|
+
x = np.asarray(x, dtype=np.float64)
|
|
334
|
+
pad = int(0.25 * SR)
|
|
335
|
+
y = np.pad(x, [(0, 0)] * (x.ndim - 1) + [(pad, pad)])
|
|
336
|
+
n = y.shape[-1]
|
|
337
|
+
out = np.fft.irfft(np.fft.rfft(y, axis=-1) * gain(np.fft.rfftfreq(n, 1.0 / SR)), n=n, axis=-1)
|
|
338
|
+
return out[..., pad:pad + x.shape[-1]]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _lp(x: np.ndarray, hz: float, order: int = 2) -> np.ndarray:
|
|
342
|
+
"""Butterworth-shaped low-pass magnitude, zero phase."""
|
|
343
|
+
return _pad_filter(x, lambda f: 1.0 / np.sqrt(1.0 + (f / hz) ** (2 * order)))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _hp(x: np.ndarray, hz: float, order: int = 2) -> np.ndarray:
|
|
347
|
+
"""Butterworth-shaped high-pass magnitude, zero phase."""
|
|
348
|
+
return _pad_filter(x, lambda f: 1.0 / np.sqrt(1.0 + (hz / np.maximum(f, 1e-3)) ** (2 * order)))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _bp(x: np.ndarray, lo: float, hi: float, order: int = 2) -> np.ndarray:
|
|
352
|
+
return _lp(_hp(x, lo, order), hi, order)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _env_db(t: np.ndarray, points) -> np.ndarray:
|
|
356
|
+
"""Piecewise-linear envelope in dB: points = ((seconds, dB), ...)."""
|
|
357
|
+
return 10 ** (np.interp(t, [p[0] for p in points], [p[1] for p in points]) / 20.0)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _phase(freq: np.ndarray) -> np.ndarray:
|
|
361
|
+
return 2 * np.pi * np.cumsum(freq) / SR
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _one_pole_lowpass_var(x: np.ndarray, hz: np.ndarray) -> np.ndarray:
|
|
365
|
+
"""One-pole low-pass with a per-sample cutoff (Hz)."""
|
|
366
|
+
a = np.exp(-2.0 * np.pi * np.clip(hz, 1.0, SR / 2.5) / SR)
|
|
367
|
+
y = np.zeros_like(x)
|
|
368
|
+
acc = 0.0
|
|
369
|
+
for i in range(len(x)):
|
|
370
|
+
acc = (1 - a[i]) * x[i] + a[i] * acc
|
|
371
|
+
y[i] = acc
|
|
372
|
+
return y
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _unit(x: np.ndarray) -> np.ndarray:
|
|
376
|
+
return x / (float(np.max(np.abs(x))) or 1.0)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _fade_in(x: np.ndarray, ms: float) -> np.ndarray:
|
|
380
|
+
n = min(x.shape[-1], max(1, int(SR * ms / 1000)))
|
|
381
|
+
x = x.copy()
|
|
382
|
+
x[..., :n] *= np.linspace(0.0, 1.0, n)
|
|
383
|
+
return x
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------- sounds added on 16/09/2026
|
|
387
|
+
|
|
388
|
+
ERROR_FIRST_GAIN, ERROR_BELL, ERROR_HP = 0.55, 1.0, 200.0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def error() -> np.ndarray:
|
|
392
|
+
"""Denied buzz: a short note, then a longer one a fourth lower; odd harmonics pushed toward 400-900 Hz."""
|
|
393
|
+
dur = 0.66
|
|
394
|
+
t = _t(dur)
|
|
395
|
+
|
|
396
|
+
def note(f0: float, start: float, length: float, fall_db: float) -> np.ndarray:
|
|
397
|
+
tt = t - start
|
|
398
|
+
ph = 2 * np.pi * f0 * np.maximum(tt, 0.0)
|
|
399
|
+
wave = np.zeros_like(t)
|
|
400
|
+
for k in range(1, 60, 2): # band-limited square wave
|
|
401
|
+
if k * f0 < 9000.0:
|
|
402
|
+
wave += np.sin(k * ph) / k
|
|
403
|
+
env = (np.clip(tt / 0.006, 0, 1) * np.clip((length - tt) / 0.02, 0, 1)
|
|
404
|
+
* 10 ** (fall_db * np.clip(tt / length, 0, 1) / 20.0))
|
|
405
|
+
return np.where((tt >= 0) & (tt < length), wave * env, 0.0)
|
|
406
|
+
|
|
407
|
+
x = ERROR_FIRST_GAIN * note(139.0, 0.0, 0.15, -3.0) + note(104.0, 0.18, 0.46, -30.0)
|
|
408
|
+
|
|
409
|
+
def shape(f):
|
|
410
|
+
bell = 1.0 + ERROR_BELL * np.exp(-0.5 * (np.log2(np.maximum(f, 1.0) / 560.0) / 0.5) ** 2)
|
|
411
|
+
return bell / np.sqrt(1.0 + (ERROR_HP / np.maximum(f, 1e-3)) ** 4) / np.sqrt(1.0 + (f / 2600.0) ** 4)
|
|
412
|
+
|
|
413
|
+
mono = _fade_tail(_pad_filter(x, shape), 15)
|
|
414
|
+
return np.stack([mono, mono])
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def bass_drop() -> np.ndarray:
|
|
418
|
+
"""Sub drop: a sine gliding from 150 Hz to 26 Hz with light saturation (heard on phones) and a soft click."""
|
|
419
|
+
rng = np.random.default_rng(2604)
|
|
420
|
+
dur = 1.05
|
|
421
|
+
t = _t(dur)
|
|
422
|
+
freq = 26.0 + (150.0 - 26.0) * np.exp(-t / 0.075)
|
|
423
|
+
body = np.tanh(2.2 * np.sin(_phase(freq))) / np.tanh(2.2)
|
|
424
|
+
body *= _env_db(t, ((0, -60), (0.003, -3), (0.05, 0), (0.12, -5), (0.3, -7), (0.5, -14), (0.65, -15),
|
|
425
|
+
(0.85, -21), (1.0, -27), (1.05, -40)))
|
|
426
|
+
n = int(0.015 * SR)
|
|
427
|
+
click = _bp(rng.standard_normal(n), 400.0, 1200.0) * np.exp(-np.arange(n) / (0.004 * SR))
|
|
428
|
+
body[:n] += _unit(click) * 10 ** (-30 / 20)
|
|
429
|
+
mono = _fade_tail(_fade_in(body, 0.5), 60)
|
|
430
|
+
return np.stack([mono, mono])
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def synth_hit() -> np.ndarray:
|
|
434
|
+
"""Synth hit: a sawtooth zap sliding down for 0.33 s straight into a saturated 808 slide (108 -> 32 Hz)."""
|
|
435
|
+
rng = np.random.default_rng(808)
|
|
436
|
+
dur, zap_len = 1.5, 0.33
|
|
437
|
+
x = np.zeros((2, int(dur * SR)))
|
|
438
|
+
nz = int(zap_len * SR)
|
|
439
|
+
tz = np.arange(nz) / SR
|
|
440
|
+
fz = 480.0 + (850.0 - 480.0) * np.exp(-tz / 0.1)
|
|
441
|
+
ph = _phase(fz)
|
|
442
|
+
saw = np.zeros(nz)
|
|
443
|
+
for k in range(1, 16):
|
|
444
|
+
saw += np.where(k * fz < 7000.0, 1.0 / k, 0.0) * np.sin(k * ph)
|
|
445
|
+
saw = _unit(_lp(saw, 3000.0)) * _env_db(tz, ((0, -34), (zap_len * 0.8, -6), (zap_len - 0.01, 0), (zap_len, -40)))
|
|
446
|
+
saw *= 10 ** (-6 / 20)
|
|
447
|
+
x[0, :nz] += saw
|
|
448
|
+
x[1, :nz] += np.roll(saw, int(0.0007 * SR))
|
|
449
|
+
tb = np.arange(x.shape[1] - nz) / SR
|
|
450
|
+
fb = 32.0 + (108.0 - 32.0) * np.exp(-tb / 0.8)
|
|
451
|
+
body = np.tanh(2.2 * np.sin(_phase(fb))) / np.tanh(2.2)
|
|
452
|
+
body *= _env_db(tb, ((0, -60), (0.004, 0), (0.55, -3), (0.8, -18), (1.05, -45), (dur, -60)))
|
|
453
|
+
n = int(0.010 * SR)
|
|
454
|
+
click = _bp(rng.standard_normal(n), 1500.0, 6000.0) * np.exp(-np.arange(n) / (0.002 * SR))
|
|
455
|
+
body[:n] += _unit(click) * 10 ** (-18 / 20)
|
|
456
|
+
x[:, nz:] += body
|
|
457
|
+
return np.stack([_fade_tail(_fade_in(c, 0.5), 50) for c in x])
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
FLASH_HIT_AT = 0.28 # seconds from the start of the flash sound to the burst
|
|
461
|
+
FLASH_DRIVE = 2.5
|
|
462
|
+
FLASH_MIX_DB = (-5.0, -7.0, -2.0, -4.0, -10.0) # thump, swell, burst, shimmer, low tail
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def flash() -> np.ndarray:
|
|
466
|
+
"""Flash: a noise swell into a bright burst with a low thump, a shimmer that darkens and a short low tail."""
|
|
467
|
+
rng = np.random.default_rng(1826)
|
|
468
|
+
dur, pre = 1.4, FLASH_HIT_AT
|
|
469
|
+
t = _t(dur)
|
|
470
|
+
n = len(t)
|
|
471
|
+
th = t - pre
|
|
472
|
+
after = th >= 0
|
|
473
|
+
thp = np.maximum(th, 0.0)
|
|
474
|
+
f = 52.0 + 45.0 * np.exp(-thp / 0.06)
|
|
475
|
+
thump = np.tanh(1.5 * np.sin(_phase(f))) / np.tanh(1.5)
|
|
476
|
+
thump = np.where(after, thump * _env_db(th, ((0, -60), (0.004, 0), (0.05, -5), (0.11, -16), (0.16, -24),
|
|
477
|
+
(0.4, -34), (0.6, -60), (dur, -60))), 0.0)
|
|
478
|
+
swell, burst, shimmer, tail = (np.zeros((2, n)) for _ in range(4))
|
|
479
|
+
sweep = 350.0 * (3200.0 / 350.0) ** np.clip(t / pre, 0, 1)
|
|
480
|
+
swell_env = _env_db(t, ((0, -50), (pre * 0.5, -30), (pre * 0.9, -6), (pre, 0), (pre + 0.03, -70), (dur, -70)))
|
|
481
|
+
tail_env = np.where(after, _env_db(th, ((0, -70), (0.06, -10), (0.14, 0), (0.35, -8), (0.65, -18),
|
|
482
|
+
(dur - pre, -40))), 0.0)
|
|
483
|
+
common = rng.standard_normal((4, n))
|
|
484
|
+
|
|
485
|
+
def noise() -> np.ndarray: # partly shared between the sides, so the flash stays full in mono
|
|
486
|
+
return 0.77 * common[k] + 0.64 * rng.standard_normal(n)
|
|
487
|
+
|
|
488
|
+
for side in (0, 1):
|
|
489
|
+
k = 0
|
|
490
|
+
swell[side] = _svf_bandpass(noise(), sweep, q=1.2) * swell_env
|
|
491
|
+
k = 1
|
|
492
|
+
burst[side] = (_bp(noise(), 500.0, 14000.0)
|
|
493
|
+
* np.where(after, np.exp(-thp / 0.035) * np.clip(th / 0.002, 0, 1), 0.0))
|
|
494
|
+
gate = np.zeros(n)
|
|
495
|
+
for at in pre + np.sort(rng.exponential(0.22, 80)):
|
|
496
|
+
i = int(at * SR)
|
|
497
|
+
if i < n:
|
|
498
|
+
m = min(n - i, int(0.05 * SR))
|
|
499
|
+
gate[i:i + m] += rng.uniform(0.3, 1.0) * np.exp(-np.arange(m) / (0.008 * SR))
|
|
500
|
+
k = 2
|
|
501
|
+
grains = _hp(noise(), 1500.0) * gate
|
|
502
|
+
shimmer[side] = (_one_pole_lowpass_var(grains, 2500.0 + 9500.0 * np.exp(-thp / 0.25))
|
|
503
|
+
* np.where(after, np.exp(-thp / 0.35), 0.0))
|
|
504
|
+
k = 3
|
|
505
|
+
tail[side] = _bp(noise(), 45.0, 220.0) * tail_env
|
|
506
|
+
w = FLASH_MIX_DB
|
|
507
|
+
x = (10 ** (w[0] / 20) * _unit(thump) + 10 ** (w[1] / 20) * _unit(swell) + 10 ** (w[2] / 20) * _unit(burst)
|
|
508
|
+
+ 10 ** (w[3] / 20) * _unit(shimmer) + 10 ** (w[4] / 20) * _unit(tail))
|
|
509
|
+
x = np.tanh(FLASH_DRIVE * _unit(x)) / np.tanh(FLASH_DRIVE) # soft saturation: a denser, louder hit
|
|
510
|
+
return np.stack([_fade_tail(_fade_in(c, 0.5), 60) for c in x])
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
CHARGE_OVERTONES, CHARGE_SNAP_TAU, CHARGE_SNAP_GAIN = (0.8, 0.5), 0.08, 2.5
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def charge() -> np.ndarray:
|
|
517
|
+
"""Energy charge: an electric snap, then a whine that climbs from 3.8 to 10 kHz with two overtones and fades."""
|
|
518
|
+
rng = np.random.default_rng(7)
|
|
519
|
+
dur = 2.4
|
|
520
|
+
t = _t(dur)
|
|
521
|
+
f0 = 3800.0 * (10000.0 / 3800.0) ** (t / dur)
|
|
522
|
+
ph = _phase(f0)
|
|
523
|
+
tone = np.zeros_like(t)
|
|
524
|
+
for k, amp in ((1, 1.0), (2, CHARGE_OVERTONES[0]), (3, CHARGE_OVERTONES[1])):
|
|
525
|
+
tone += amp * np.sin(k * ph) * np.clip((19000.0 - k * f0) / 2000.0, 0, 1)
|
|
526
|
+
tone *= np.exp(-t / 1.1) * (1 - np.exp(-t / 0.004))
|
|
527
|
+
burst = _bp(rng.standard_normal(len(t)), 800.0, 7000.0, 2) * np.exp(-t / CHARGE_SNAP_TAU)
|
|
528
|
+
mono = _fade_tail(0.55 * tone + CHARGE_SNAP_GAIN * burst, 30)
|
|
529
|
+
return np.stack([mono, mono])
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
WIND_PEAK_AT = 0.42 # seconds to the top of the wind swell
|
|
533
|
+
WIND_BODY, WIND_AIR = (30.0, 110.0), 0.08
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def wind() -> np.ndarray:
|
|
537
|
+
"""Soft wind pass: a deep noise swell with an airy top at its peak, moving from left to right."""
|
|
538
|
+
rng = np.random.default_rng(11)
|
|
539
|
+
dur, pk = 1.3, WIND_PEAK_AT
|
|
540
|
+
t = _t(dur)
|
|
541
|
+
n = len(t)
|
|
542
|
+
rise = np.clip(t / pk, 0, 1) ** 2.2
|
|
543
|
+
fall = np.where(t > pk, np.exp(-(t - pk) / 0.13) * 0.85 + 0.15 * np.exp(-(t - pk) / 0.5), 1.0)
|
|
544
|
+
env = rise * fall
|
|
545
|
+
noise = rng.standard_normal(n)
|
|
546
|
+
body = _bp(noise, WIND_BODY[0], WIND_BODY[1], 2)
|
|
547
|
+
cutoff = 300.0 + 5200.0 * np.exp(-((t - pk) / 0.12) ** 2)
|
|
548
|
+
air = _hp(_one_pole_lowpass_var(rng.standard_normal(n), cutoff), 200.0, 1)
|
|
549
|
+
mono = _hp(_unit(body) + WIND_AIR * _unit(air), 40.0, 4)
|
|
550
|
+
x = np.stack([mono, mono])
|
|
551
|
+
pan = np.clip((t - 0.1) / 0.7, 0, 1)
|
|
552
|
+
x[0] *= np.cos(pan * np.pi / 2) * 0.6 + 0.4
|
|
553
|
+
x[1] *= np.sin(pan * np.pi / 2) * 0.6 + 0.4
|
|
554
|
+
x *= env
|
|
555
|
+
return np.stack([_fade_tail(c, 50) for c in x])
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def rewind() -> np.ndarray:
|
|
559
|
+
"""Tape rewind: garbled audio that speeds up (pitch 80 -> 900 Hz) and gets louder, then a fast tape stop."""
|
|
560
|
+
rng = np.random.default_rng(1985)
|
|
561
|
+
dur, stop = 1.5, 0.22
|
|
562
|
+
body_len = dur - stop
|
|
563
|
+
t = _t(dur)
|
|
564
|
+
n = len(t)
|
|
565
|
+
u = np.clip(t / body_len, 0, 1)
|
|
566
|
+
fall = np.clip((t - body_len) / stop, 0, 1)
|
|
567
|
+
f0 = 80.0 * (900.0 / 80.0) ** (u ** 0.8) * (0.09 ** fall)
|
|
568
|
+
knots = rng.uniform(-1, 1, int(dur * 60) + 2)
|
|
569
|
+
wob = _unit(_lp(np.interp(t, np.linspace(0, dur, len(knots)), knots), 25.0))
|
|
570
|
+
ph = _phase(f0 * (1 + 0.35 * wob))
|
|
571
|
+
tone = np.zeros(n)
|
|
572
|
+
for h, amp in ((1, 1.0), (2, 0.55), (3, 0.35), (4, 0.22), (5, 0.14), (6, 0.09), (8, 0.05)):
|
|
573
|
+
tone += amp * np.sin(h * ph + rng.uniform(0, 2 * np.pi))
|
|
574
|
+
tone *= 0.55 + 0.45 * np.abs(np.sin(_phase(6 + 20 * u) + 0.6 * wob))
|
|
575
|
+
whirr = _svf_bandpass(rng.standard_normal(n), 400.0 + 3200.0 * u, q=0.9)
|
|
576
|
+
whirr = _hp(whirr * (0.7 + 0.3 * np.sin(_phase(4 + 14 * u))), 150.0)
|
|
577
|
+
sib = _bp(rng.standard_normal(n), 3000.0, 9000.0)
|
|
578
|
+
gate = (np.sin(_phase(8 + 14 * u) + 3 * wob) > 0.35).astype(float)
|
|
579
|
+
sib *= _one_pole_lowpass(gate, 60.0)
|
|
580
|
+
|
|
581
|
+
def rms(v):
|
|
582
|
+
return float(np.sqrt(np.mean(v ** 2))) or 1.0
|
|
583
|
+
|
|
584
|
+
x = tone / rms(tone) + 0.25 * whirr / rms(whirr) + 0.6 * sib / rms(sib)
|
|
585
|
+
x = _hp(_lp(x, 12000.0, 1), 70.0)
|
|
586
|
+
tk = t - (dur - stop * 0.55)
|
|
587
|
+
x += 0.35 * float(np.max(np.abs(x))) * np.where(tk > 0, np.sin(2 * np.pi * 70 * tk) * np.exp(-tk / 0.05), 0.0)
|
|
588
|
+
env = np.minimum(1, t / 0.12) ** 1.5 * 10 ** ((-9.0 + 9.0 * u ** 1.5) / 20.0)
|
|
589
|
+
env *= np.where(t > body_len, np.exp(-((t - body_len) / (stop * 0.45)) ** 2), 1.0)
|
|
590
|
+
mono = _fade_tail(x * env, 10)
|
|
591
|
+
return np.stack([mono, mono])
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _data_stream(dur: float, seed: int) -> np.ndarray:
|
|
595
|
+
rng = np.random.default_rng(seed)
|
|
596
|
+
n = int(dur * SR)
|
|
597
|
+
x = np.zeros((2, n))
|
|
598
|
+
freqs = np.array([8300.0, 10900.0, 10900.0, 11600.0, 12200.0, 12900.0, 14500.0, 14500.0])
|
|
599
|
+
pos = 0
|
|
600
|
+
while pos < n:
|
|
601
|
+
ln = int(rng.uniform(0.022, 0.055) * SR)
|
|
602
|
+
if rng.random() < 0.8:
|
|
603
|
+
f = float(rng.choice(freqs)) * rng.uniform(0.995, 1.005)
|
|
604
|
+
k = np.arange(min(ln, n - pos))
|
|
605
|
+
seg = np.sin(2 * np.pi * f * k / SR)
|
|
606
|
+
g = np.ones_like(seg)
|
|
607
|
+
r = min(48, len(g) // 2)
|
|
608
|
+
if r:
|
|
609
|
+
g[:r] = np.linspace(0, 1, r)
|
|
610
|
+
g[-r:] = np.linspace(1, 0, r)
|
|
611
|
+
p = rng.uniform(0.15, 0.85)
|
|
612
|
+
amp = rng.uniform(0.5, 1.0)
|
|
613
|
+
x[0, pos:pos + len(k)] += seg * g * amp * np.cos(p * np.pi / 2)
|
|
614
|
+
x[1, pos:pos + len(k)] += seg * g * amp * np.sin(p * np.pi / 2)
|
|
615
|
+
pos += ln + int(rng.uniform(0.0, 0.012) * SR)
|
|
616
|
+
return x
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def data() -> np.ndarray:
|
|
620
|
+
"""Computer data: two streams of short, random high beeps (8 to 14.5 kHz), one leaning to each side."""
|
|
621
|
+
a, b = _data_stream(2.0, 23), _data_stream(2.0, 24)
|
|
622
|
+
x = np.stack([a[0] + 0.25 * b[0], b[1] + 0.25 * a[1]])
|
|
623
|
+
return np.stack([_fade_tail(_fade_in(c, 10), 150) for c in x])
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
BUILDERS = {"whoosh": whoosh, "pop": pop, "click": click, "tick": tick, "rise": rise, "cash": cash, "coin": coin,
|
|
627
|
+
"error": error, "bass_drop": bass_drop, "synth_hit": synth_hit, "flash": flash, "charge": charge,
|
|
628
|
+
"wind": wind, "rewind": rewind, "data": data}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ---------------------------------------------------------------- io
|
|
632
|
+
|
|
633
|
+
def write_wav(path: Path, stereo: np.ndarray) -> None:
|
|
634
|
+
data = np.clip(stereo, -1.0, 1.0)
|
|
635
|
+
pcm = (data.T * 32767.0).round().astype("<i2")
|
|
636
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
638
|
+
with wave.open(str(tmp), "wb") as w:
|
|
639
|
+
w.setnchannels(2)
|
|
640
|
+
w.setsampwidth(2)
|
|
641
|
+
w.setframerate(SR)
|
|
642
|
+
w.writeframes(pcm.tobytes())
|
|
643
|
+
tmp.replace(path)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def kit_gain_db(sound, target_200ms: float | None) -> tuple[float, str]:
|
|
647
|
+
"""Gain (dB) that puts a sound at the kit level at sfx.volume 1.0, and the window that set it.
|
|
648
|
+
|
|
649
|
+
target_200ms: the LUFS wanted on the loudest 200 ms window (None: long sound, set by the 3 s window alone).
|
|
650
|
+
The 3 s window never goes above LONG_CAP_LUFS."""
|
|
651
|
+
m = measure(sound)
|
|
652
|
+
up = 20 * math.log10(SFX_CALIBRATION)
|
|
653
|
+
gain = LONG_CAP_LUFS - (m["short_term_max_lufs"] + up)
|
|
654
|
+
window = "short_term_max_lufs"
|
|
655
|
+
if target_200ms is not None:
|
|
656
|
+
g200 = target_200ms - (m["loudness_200ms_lufs"] + up)
|
|
657
|
+
if g200 <= gain:
|
|
658
|
+
gain, window = g200, "loudness_200ms_lufs"
|
|
659
|
+
return gain, window
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def render(name: str, peak_db: float = PEAK_DB) -> np.ndarray:
|
|
663
|
+
"""The procedural sound as written to disk: peak at peak_db, or at its kit level for the KIT_LEVELS names."""
|
|
664
|
+
raw = BUILDERS[name]()
|
|
665
|
+
if name in KIT_LEVELS: # DC out, and the first and last samples at zero
|
|
666
|
+
raw = raw - raw.mean(axis=1, keepdims=True)
|
|
667
|
+
raw = np.stack([_fade_tail(_fade_in(c, 1.0), 5) for c in raw])
|
|
668
|
+
stereo = _normalize(raw, PEAK_DB if name in KIT_LEVELS else peak_db)
|
|
669
|
+
if name in KIT_LEVELS:
|
|
670
|
+
gain, _ = kit_gain_db(stereo, KIT_LEVELS[name])
|
|
671
|
+
stereo = stereo * 10 ** (gain / 20.0)
|
|
672
|
+
peak = 20 * math.log10(float(np.max(np.abs(stereo))))
|
|
673
|
+
if peak > KIT_PEAK_MAX_DB + 1e-6:
|
|
674
|
+
raise ValueError(f"{name}: o pico ficaria em {peak:.2f} dBFS no nível do kit")
|
|
675
|
+
return stereo
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def make(name: str, out_dir: Path, peak_db: float = PEAK_DB) -> dict:
|
|
679
|
+
"""Write out_dir/<name>.wav. peak_db only applies to the first seven; the KIT_LEVELS ones come out at kit level."""
|
|
680
|
+
stereo = render(name, peak_db)
|
|
681
|
+
path = out_dir / f"{name}.wav"
|
|
682
|
+
write_wav(path, stereo)
|
|
683
|
+
peak = float(np.max(np.abs(stereo)))
|
|
684
|
+
first = int(np.argmax(np.max(np.abs(stereo), axis=0) >= 0.5 * peak))
|
|
685
|
+
return {"name": name, "file": str(path), "duration": stereo.shape[1] / SR,
|
|
686
|
+
"peak_db": 20 * math.log10(peak), "attack_ms": 1000.0 * first / SR}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def ensure_sfx(names=SYNTH_NAMES, out_dir: Path = SYNTH_DIR) -> dict[str, Path]:
|
|
690
|
+
"""Create any missing procedural SFX file in out_dir. Returns name -> path."""
|
|
691
|
+
out = {}
|
|
692
|
+
for name in names:
|
|
693
|
+
if name not in BUILDERS:
|
|
694
|
+
continue
|
|
695
|
+
path = Path(out_dir) / f"{name}.wav"
|
|
696
|
+
if not path.exists():
|
|
697
|
+
make(name, Path(out_dir))
|
|
698
|
+
out[name] = path
|
|
699
|
+
return out
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ---------------------------------------------------------------- built-in registry
|
|
703
|
+
|
|
704
|
+
def load_manifest(path: Path = MANIFEST) -> dict:
|
|
705
|
+
"""assets/sfx/sfx.json as a dict (empty when missing or unreadable)."""
|
|
706
|
+
try:
|
|
707
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
708
|
+
except (OSError, ValueError):
|
|
709
|
+
return {}
|
|
710
|
+
return data if isinstance(data, dict) else {}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def name_key(text: str) -> str:
|
|
714
|
+
"""How a name is compared: lower case, no accents, spaces and hyphens as '_' ("Estalo de Dedo" -> estalo_de_dedo)."""
|
|
715
|
+
text = unicodedata.normalize("NFKD", str(text).strip().lower())
|
|
716
|
+
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
|
717
|
+
return re.sub(r"[\s\-]+", "_", text)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def alias_index(manifest: dict) -> dict[str, str]:
|
|
721
|
+
"""name_key(id, label or alias) -> id. An id always wins over another effect's label or alias."""
|
|
722
|
+
entries = {k: v for k, v in manifest.items() if isinstance(v, dict)}
|
|
723
|
+
out = {name_key(name): name for name in entries}
|
|
724
|
+
for name, entry in entries.items():
|
|
725
|
+
for alias in [entry.get("label")] + list(entry.get("aliases") or []):
|
|
726
|
+
if isinstance(alias, str) and alias.strip():
|
|
727
|
+
out.setdefault(name_key(alias), name)
|
|
728
|
+
return out
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def resolve_name(name, manifest: dict | None = None) -> str | None:
|
|
732
|
+
"""The sfx.json id a name points to (the id itself, its Portuguese label or an alias), or None."""
|
|
733
|
+
manifest = load_manifest() if manifest is None else manifest
|
|
734
|
+
if not isinstance(name, str) or not name.strip():
|
|
735
|
+
return None
|
|
736
|
+
if isinstance(manifest.get(name), dict):
|
|
737
|
+
return name
|
|
738
|
+
return alias_index(manifest).get(name_key(name))
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def builtin_sfx(names, sfx_dir: Path = SFX_DIR) -> dict[str, dict]:
|
|
742
|
+
"""name -> {"path": Path, "anchor_s": float, "id": str} for every built-in effect in names whose file is there.
|
|
743
|
+
|
|
744
|
+
A name can be the id ("bass_drop"), its Portuguese label or an alias ("grave"); "id" is the sfx.json entry
|
|
745
|
+
it resolved to. A procedural file (under sinteticos/) that is missing is generated on the spot. Names that
|
|
746
|
+
are not built-in effects, or whose recording is missing, are left out (the caller warns)."""
|
|
747
|
+
sfx_dir = Path(sfx_dir)
|
|
748
|
+
manifest = load_manifest(sfx_dir / "sfx.json")
|
|
749
|
+
synth = (sfx_dir / "sinteticos").resolve()
|
|
750
|
+
out: dict[str, dict] = {}
|
|
751
|
+
for name in names:
|
|
752
|
+
effect = resolve_name(name, manifest)
|
|
753
|
+
entry = manifest.get(effect) if effect else None
|
|
754
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("file"), str):
|
|
755
|
+
continue
|
|
756
|
+
path = (sfx_dir / entry["file"]).resolve()
|
|
757
|
+
if not path.exists() and path.parent == synth and path.stem in BUILDERS:
|
|
758
|
+
make(path.stem, synth)
|
|
759
|
+
if not path.exists():
|
|
760
|
+
continue
|
|
761
|
+
try:
|
|
762
|
+
anchor = max(0.0, float(entry.get("anchor_s") or 0.0))
|
|
763
|
+
except (TypeError, ValueError):
|
|
764
|
+
anchor = 0.0
|
|
765
|
+
out[name] = {"path": path, "anchor_s": anchor, "id": effect}
|
|
766
|
+
return out
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ---------------------------------------------------------------- measurement
|
|
770
|
+
|
|
771
|
+
# BS.1770 K-weighting at 48 kHz: high shelf, then the RLB high-pass
|
|
772
|
+
_K_STAGES = (((1.53512485958697, -2.69169618940638, 1.19839281085285), (1.0, -1.69065929318241, 0.73248077421585)),
|
|
773
|
+
((1.0, -2.0, 1.0), (1.0, -1.99004745483398, 0.99007225036621)))
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def read_audio(path: Path) -> np.ndarray:
|
|
777
|
+
"""(channels, samples) float in [-1, 1] at 48 kHz: 16-bit WAV read directly, anything else through ffmpeg."""
|
|
778
|
+
path = Path(path)
|
|
779
|
+
try:
|
|
780
|
+
with wave.open(str(path), "rb") as w:
|
|
781
|
+
if w.getsampwidth() == 2 and w.getframerate() == SR:
|
|
782
|
+
ch = w.getnchannels()
|
|
783
|
+
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64) / 32768.0
|
|
784
|
+
return pcm.reshape(-1, ch).T
|
|
785
|
+
except (wave.Error, EOFError, OSError):
|
|
786
|
+
pass
|
|
787
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(path), "-vn", "-f", "f32le",
|
|
788
|
+
"-ac", "2", "-ar", str(SR), "-"], capture_output=True)
|
|
789
|
+
if proc.returncode != 0:
|
|
790
|
+
raise ValueError(f"não consegui ler o som: {path}")
|
|
791
|
+
return np.frombuffer(proc.stdout[: len(proc.stdout) // 8 * 8], dtype="<f4").reshape(-1, 2).T.astype(np.float64)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def k_weighted(x: np.ndarray) -> np.ndarray:
|
|
795
|
+
"""K-weighting applied in the frequency domain (x: channels x samples, 48 kHz, already padded with silence)."""
|
|
796
|
+
n = x.shape[1]
|
|
797
|
+
zinv = np.exp(-2j * np.pi * np.fft.rfftfreq(n, 1.0 / SR) / SR)
|
|
798
|
+
h = np.ones_like(zinv)
|
|
799
|
+
for b, a in _K_STAGES:
|
|
800
|
+
h *= (b[0] + b[1] * zinv + b[2] * zinv * zinv) / (a[0] + a[1] * zinv + a[2] * zinv * zinv)
|
|
801
|
+
return np.fft.irfft(np.fft.rfft(x, axis=1) * h, n=n, axis=1)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def measure(sound) -> dict:
|
|
805
|
+
"""Levels and anchor of a sound (a path or a channels x samples array at 48 kHz).
|
|
806
|
+
|
|
807
|
+
loudness_200ms_lufs / momentary_max_lufs / short_term_max_lufs: the loudest 200 ms, 400 ms and 3 s window
|
|
808
|
+
(K-weighted, channels summed, BS.1770 offset -0.691). anchor_s: the first moment the 50 ms K-weighted
|
|
809
|
+
power envelope (centered) is within 2 dB of its maximum, i.e. when the sound reaches full strength."""
|
|
810
|
+
x = read_audio(sound) if not isinstance(sound, np.ndarray) else np.atleast_2d(np.asarray(sound, dtype=np.float64))
|
|
811
|
+
n = x.shape[1]
|
|
812
|
+
pad = 3 * SR
|
|
813
|
+
y = k_weighted(np.concatenate([np.zeros((x.shape[0], pad)), x, np.zeros((x.shape[0], pad))], axis=1))
|
|
814
|
+
power = (y * y).sum(axis=0)
|
|
815
|
+
c = np.concatenate([[0.0], np.cumsum(power)])
|
|
816
|
+
|
|
817
|
+
def window_max(seconds: float) -> float:
|
|
818
|
+
w = int(round(seconds * SR))
|
|
819
|
+
return -0.691 + 10 * math.log10(max(float(((c[w:] - c[:-w]) / w).max()), 1e-20))
|
|
820
|
+
|
|
821
|
+
w = int(ANCHOR_WINDOW_S * SR)
|
|
822
|
+
lo = np.arange(pad, pad + n) - w // 2
|
|
823
|
+
env = 10 * np.log10(np.maximum((c[lo + w] - c[lo]) / w, 1e-20))
|
|
824
|
+
anchor = int(np.argmax(env >= env.max() - ANCHOR_DROP_DB))
|
|
825
|
+
peak = float(np.max(np.abs(x))) if n else 0.0
|
|
826
|
+
return {"duration_s": round(n / SR, 3), "channels": int(x.shape[0]), "anchor_s": round(anchor / SR, 3),
|
|
827
|
+
"peak_dbfs": round(20 * math.log10(max(peak, 1e-9)), 2),
|
|
828
|
+
"loudness_200ms_lufs": round(window_max(0.2), 2), "momentary_max_lufs": round(window_max(0.4), 2),
|
|
829
|
+
"short_term_max_lufs": round(window_max(3.0), 2)}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
LEVEL_KEYS = ("loudness_200ms_lufs", "momentary_max_lufs", "short_term_max_lufs")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def synthetic_registry(sfx_dir: Path = SFX_DIR) -> dict:
|
|
836
|
+
"""The sfx.json entries for the procedural set alone: every name -> sinteticos/<name>.wav, measured.
|
|
837
|
+
|
|
838
|
+
Stored as assets/sfx/sfx_sinteticos.json (--synthetic-registry). It is the alternative made by this
|
|
839
|
+
project's own code, shipped alongside the CC0 kit that sfx.json points at by default."""
|
|
840
|
+
up = 20 * math.log10(SFX_CALIBRATION)
|
|
841
|
+
out = {}
|
|
842
|
+
for name in SYNTH_NAMES:
|
|
843
|
+
path = ensure_sfx([name], Path(sfx_dir) / "sinteticos")[name]
|
|
844
|
+
m = measure(path)
|
|
845
|
+
anchor = m.pop("anchor_s")
|
|
846
|
+
dur = f"{m['duration_s']:.2f}".rstrip("0").rstrip(".").replace(".", ",")
|
|
847
|
+
out[name] = {"file": f"sinteticos/{name}.wav", "anchor_s": anchor, "sound": f"{SYNTH_SOUNDS[name]} ({dur} s)",
|
|
848
|
+
"source": "sintético, helpers/make_sfx.py", "gain_db": 0.0,
|
|
849
|
+
"level_rule": kit_level_rule(name), "measured": m,
|
|
850
|
+
"at_volume_1": {k: round(m[k] + up, 2) for k in LEVEL_KEYS}}
|
|
851
|
+
return out
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _lufs_text(value: float) -> str:
|
|
855
|
+
return f"{value:.1f}".replace(".", ",")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def kit_level_rule(name: str) -> str:
|
|
859
|
+
"""PT-BR text of how the generator sets the level of a procedural sound (sfx.json `level_rule`)."""
|
|
860
|
+
if name not in KIT_LEVELS:
|
|
861
|
+
return "sem ajuste: pico em -3 dBFS, como sai do gerador"
|
|
862
|
+
target = KIT_LEVELS[name]
|
|
863
|
+
_, window = kit_gain_db(render(name), target)
|
|
864
|
+
cap = f"janela de 3 s em {_lufs_text(LONG_CAP_LUFS)} LUFS com volume 1.0"
|
|
865
|
+
if target is None:
|
|
866
|
+
return f"acertado pelo gerador: {cap} (som longo: 6 dB abaixo da voz de -14 LUFS)"
|
|
867
|
+
if window == "short_term_max_lufs":
|
|
868
|
+
return (f"acertado pelo gerador: {cap}, o teto dos sons longos (com os {_lufs_text(target)} LUFS da janela "
|
|
869
|
+
f"de 200 ms a janela de 3 s passaria do teto)")
|
|
870
|
+
return (f"acertado pelo gerador: janela de 200 ms em {_lufs_text(target)} LUFS com volume 1.0, sem passar de "
|
|
871
|
+
f"{_lufs_text(LONG_CAP_LUFS)} na janela de 3 s")
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _print_measure(label: str, m: dict) -> None:
|
|
875
|
+
up = 20 * math.log10(SFX_CALIBRATION)
|
|
876
|
+
print(f"{label}: {m['duration_s']:.2f}s ponto forte em {m['anchor_s']:.3f}s pico {m['peak_dbfs']:.1f} dBFS "
|
|
877
|
+
f"200 ms {m['loudness_200ms_lufs']:.1f} LUFS (volume 1.0: {m['loudness_200ms_lufs'] + up:.1f}) "
|
|
878
|
+
f"400 ms {m['momentary_max_lufs']:.1f} ({m['momentary_max_lufs'] + up:.1f}) "
|
|
879
|
+
f"3 s {m['short_term_max_lufs']:.1f} ({m['short_term_max_lufs'] + up:.1f})")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def main(argv: list[str] | None = None) -> int:
|
|
883
|
+
ap = argparse.ArgumentParser(description=f"Gera os efeitos sonoros sintéticos ({', '.join(SYNTH_NAMES)}) em WAV 48 kHz, "
|
|
884
|
+
"ou mede o volume e o ponto forte de efeitos (--measure).")
|
|
885
|
+
ap.add_argument("--out-dir", default=str(SYNTH_DIR), help="pasta de saída (padrão: assets/sfx/sinteticos da skill)")
|
|
886
|
+
ap.add_argument("--only", help="só estes, separados por vírgula (ex: whoosh,pop)")
|
|
887
|
+
ap.add_argument("--peak-db", type=float, default=PEAK_DB,
|
|
888
|
+
help="pico em dBFS (padrão -3; os sons de 16/09/2026 saem sempre no nível do kit)")
|
|
889
|
+
ap.add_argument("--measure", nargs="*", metavar="ARQUIVO",
|
|
890
|
+
help="mede volume (janelas de 200 ms, 400 ms e 3 s) e ponto forte; sem arquivo, mede os efeitos prontos")
|
|
891
|
+
ap.add_argument("--synthetic-registry", action="store_true",
|
|
892
|
+
help="mede os efeitos sintéticos e grava assets/sfx/sfx_sinteticos.json (o sfx.json do pacote de venda)")
|
|
893
|
+
args = ap.parse_args(argv)
|
|
894
|
+
|
|
895
|
+
if args.synthetic_registry:
|
|
896
|
+
text = json.dumps(synthetic_registry(), ensure_ascii=False, indent=2) + "\n"
|
|
897
|
+
SYNTH_REGISTRY.write_text(text, encoding="utf-8")
|
|
898
|
+
print(f"ok: {SYNTH_REGISTRY}")
|
|
899
|
+
return 0
|
|
900
|
+
|
|
901
|
+
if args.measure is not None:
|
|
902
|
+
if args.measure:
|
|
903
|
+
items = [(p, Path(p)) for p in args.measure]
|
|
904
|
+
else:
|
|
905
|
+
found = builtin_sfx(NAMES)
|
|
906
|
+
items = [(name, found[name]["path"]) for name in NAMES if name in found]
|
|
907
|
+
for label, path in items:
|
|
908
|
+
if not Path(path).exists():
|
|
909
|
+
print(f"erro: arquivo não encontrado: {path}", file=sys.stderr)
|
|
910
|
+
return 2
|
|
911
|
+
try:
|
|
912
|
+
_print_measure(str(label), measure(Path(path)))
|
|
913
|
+
except ValueError as e:
|
|
914
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
915
|
+
return 2
|
|
916
|
+
return 0
|
|
917
|
+
|
|
918
|
+
names = [n.strip() for n in args.only.split(",")] if args.only else list(SYNTH_NAMES)
|
|
919
|
+
bad = [n for n in names if n not in BUILDERS]
|
|
920
|
+
if bad:
|
|
921
|
+
print(f"erro: efeito desconhecido: {', '.join(bad)}. Opções: {', '.join(SYNTH_NAMES)}", file=sys.stderr)
|
|
922
|
+
return 2
|
|
923
|
+
if not -20.0 <= args.peak_db <= 0.0:
|
|
924
|
+
print("erro: --peak-db precisa ficar entre -20 e 0", file=sys.stderr)
|
|
925
|
+
return 2
|
|
926
|
+
out_dir = Path(args.out_dir).resolve()
|
|
927
|
+
try:
|
|
928
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
929
|
+
except OSError as e:
|
|
930
|
+
print(f"erro: não consegui criar a pasta {out_dir}: {e}", file=sys.stderr)
|
|
931
|
+
return 2
|
|
932
|
+
for name in names:
|
|
933
|
+
info = make(name, out_dir, args.peak_db)
|
|
934
|
+
print(f"ok: {name}.wav {info['duration']:.2f}s pico {info['peak_db']:.1f} dBFS ataque em {info['attack_ms']:.1f} ms")
|
|
935
|
+
print(f"pasta: {out_dir}")
|
|
936
|
+
return 0
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
if __name__ == "__main__":
|
|
940
|
+
sys.exit(main())
|