From 59355909f2de9236af8168a26c70bcf6caa3b285 Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 20:21:47 -0300 Subject: Import video pipeline as it was --- scripts/build_blender.py | 58 +++++++ scripts/build_video.py | 93 ++++++++++ scripts/gen_audio.py | 119 +++++++++++++ scripts/music.py | 166 ++++++++++++++++++ scripts/narration.py | 431 +++++++++++++++++++++++++++++++++++++++++++++++ scripts/numspell.py | 57 +++++++ scripts/regen.py | 46 +++++ scripts/repick.py | 48 ++++++ scripts/verify.py | 53 ++++++ scripts/verify_final.py | 50 ++++++ 10 files changed, 1121 insertions(+) create mode 100644 scripts/build_blender.py create mode 100644 scripts/build_video.py create mode 100644 scripts/gen_audio.py create mode 100644 scripts/music.py create mode 100644 scripts/narration.py create mode 100644 scripts/numspell.py create mode 100644 scripts/regen.py create mode 100644 scripts/repick.py create mode 100644 scripts/verify.py create mode 100644 scripts/verify_final.py (limited to 'scripts') diff --git a/scripts/build_blender.py b/scripts/build_blender.py new file mode 100644 index 0000000..41731cf --- /dev/null +++ b/scripts/build_blender.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Arma el video final de las escenas hechas en Blender. + +Compone en una sola pasada de ffmpeg: + fondo punteado + secuencia PNG con alfa (3D) + overlay de Manim + audio +El overlay trae chip, barra de progreso y subtitulos, o sea que el estilo sale +del mismo codigo que los videos de Manim. +""" +import glob, os, subprocess, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +import build_video + +FPS = 30 + + +def componer(name, con_musica=True): + fondo = os.path.join(ROOT, "media", "images", "overlay", "fondo.png") + carpeta = os.path.join(ROOT, "render", name) + seq = os.path.join(carpeta, "f%04d.png") + overlay = os.path.join(ROOT, "media", "videos", "overlay", "1920p30", f"{name}.mov") + for p in (fondo, overlay): + if not os.path.exists(p): + raise SystemExit(f"falta {p}") + n = len(glob.glob(os.path.join(carpeta, "f*.png"))) + if n < 30: + raise SystemExit(f"[{name}] solo hay {n} frames renderizados") + + wav = build_video.build_track(name, con_musica) + out = os.path.join(ROOT, "out", f"{name}.mp4") + cmd = [ + "ffmpeg", "-v", "error", "-y", + "-loop", "1", "-framerate", str(FPS), "-i", fondo, + "-framerate", str(FPS), "-start_number", "1", "-i", seq, + "-i", overlay, + "-i", wav, + "-filter_complex", + "[0:v][1:v]overlay=format=auto[a];[a][2:v]overlay=format=auto,format=yuv420p[v]", + "-map", "[v]", "-map", "3:a", + "-filter:a", "loudnorm=I=-14:TP=-1.5:LRA=11,aresample=48000", + "-frames:v", str(n), + "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p", + "-preset", "slow", "-crf", "20", "-r", str(FPS), + "-c:a", "aac", "-b:a", "192k", "-ac", "2", + "-movflags", "+faststart", "-shortest", out, + ] + subprocess.run(cmd, check=True) + d = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", out], capture_output=True, text=True).stdout.strip() + print(f"[{name}] {out} {float(d):.1f}s {os.path.getsize(out)/1e6:.1f} MB " + f"({n} frames 3D)", flush=True) + return out + + +if __name__ == "__main__": + for n in (sys.argv[1:] or ["bolapeluda", "caos", "diferencial"]): + componer(n) diff --git a/scripts/build_video.py b/scripts/build_video.py new file mode 100644 index 0000000..572e3b5 --- /dev/null +++ b/scripts/build_video.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""Arma la pista final (voz + piano con ducking) siguiendo el timeline de Manim +y la pega al video renderizado.""" +import json, os, subprocess, sys, wave +import numpy as np + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +import music + +SR = 44100 +SCENE_DIR = {"montyhall": "v1_montyhall", "nueves": "v2_nueves", "cumple": "v3_cumple", + "luna": "v4_luna", "japones": "v5_japones", + "benford": "v6_benford", "hilbert": "v7_hilbert", "reuleaux": "v8_reuleaux", + "regla72": "v9_regla72", "buffon": "v10_buffon", + "collatz": "v11_collatz", "simpson": "v12_simpson", + "cuerda": "v13_cuerda", "gabriel": "v14_gabriel", + "mobius": "v15_mobius", "bayes": "v16_bayes", + "cicloide": "v17_cicloide", "dados": "v18_dados"} + + +def read_wav(p): + with wave.open(p) as w: + a = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + ch, sr = w.getnchannels(), w.getframerate() + a = a.astype(np.float32) / 32768.0 + if ch > 1: + a = a.reshape(-1, ch).mean(axis=1) + return a, sr + + +def to_sr(a, sr_in, sr_out=SR): + if sr_in == sr_out: + return a + n = int(round(len(a) * sr_out / sr_in)) + return np.interp(np.linspace(0, len(a) - 1, n), np.arange(len(a)), a).astype(np.float32) + + +def build_track(name, with_music=True): + tl = json.load(open(os.path.join(ROOT, "out", f"{name}_timeline.json"))) + segs = json.load(open(os.path.join(ROOT, "audio", name, "segments.json")))["segments"] + total = tl["total"] + 0.25 + speech = np.zeros(int(total * SR), dtype=np.float32) + + for beat in tl["beats"]: + a, sr = read_wav(segs[beat["i"]]["wav"]) + a = to_sr(a, sr) + off = int(beat["start"] * SR) + end = min(off + len(a), len(speech)) + speech[off:end] += a[:end - off] + + speech *= min(1.0, 0.90 / (np.abs(speech).max() + 1e-9)) + st = np.stack([speech, speech], axis=1) + + if with_music: + bed = music.bed(name, total, gain=0.32, seed=abs(hash(name)) % 9999) + if len(bed) < len(st): + bed = np.pad(bed, ((0, len(st) - len(bed)), (0, 0))) + bed = music.duck(bed[:len(st)], st) + mix = st * 0.96 + bed + else: + mix = st + + mix = np.clip(mix / max(1.0, np.abs(mix).max() / 0.97), -1, 1) + path = os.path.join(ROOT, "tmp", f"{name}_mix.wav") + with wave.open(path, "w") as w: + w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) + w.writeframes((mix * 32767).astype(np.int16).tobytes()) + return path + + +def mux(name, video=None, with_music=True): + if video is None: + video = os.path.join(ROOT, "media", "videos", SCENE_DIR[name], "1920p30", f"{name}.mp4") + wav = build_track(name, with_music) + out = os.path.join(ROOT, "out", f"{name}.mp4") + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-i", video, "-i", wav, + "-filter:a", "loudnorm=I=-14:TP=-1.5:LRA=11,aresample=48000", + "-map", "0:v:0", "-map", "1:a:0", + "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p", + "-preset", "slow", "-crf", "20", "-r", "30", + "-c:a", "aac", "-b:a", "192k", "-ac", "2", + "-movflags", "+faststart", "-shortest", out], check=True) + d = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", out], capture_output=True, text=True).stdout.strip() + print(f"[{name}] {out} {float(d):.1f}s {os.path.getsize(out)/1e6:.1f} MB", flush=True) + return out + + +if __name__ == "__main__": + for n in (sys.argv[1:] or list(SCENE_DIR)): + mux(n) diff --git a/scripts/gen_audio.py b/scripts/gen_audio.py new file mode 100644 index 0000000..9dbbb47 --- /dev/null +++ b/scripts/gen_audio.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Genera un WAV por segmento con qwen-tts (voz clonada rioplatense), +verifica cada uno transcribiendo con whisper y reintenta los que salen mal.""" +import json, os, re, shutil, subprocess, sys, unicodedata, wave +from difflib import SequenceMatcher + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from narration import SCRIPTS +from numspell import expand + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TTS = os.path.expanduser("~/GIT-MIRRO/qwentts.cpp/build/qwen-tts") +MODEL = os.path.expanduser("~/GIT-MIRRO/qwentts.cpp/models/qwen-talker-1.7b-base-Q8_0.gguf") +CODEC = os.path.expanduser("~/GIT-MIRRO/qwentts.cpp/models/qwen-tokenizer-12hz-Q8_0.gguf") +REF = dict(spk="/tmp/Firefox/output.spk", rvq="/tmp/Firefox/output.rvq", txt="/tmp/Firefox/output.txt") + +MIN_RATIO = 0.90 # similitud minima aceptada contra la transcripcion +TIMEOUT = 150 # segundos por segmento; mas que eso es generacion desbocada +MAX_ROUNDS = 4 + +def norm(s): + s = expand(s) + s = unicodedata.normalize("NFD", s.lower()) + s = "".join(c for c in s if unicodedata.category(c) != "Mn") + s = re.sub(r"[^a-z0-9ñ ]+", " ", s) + return " ".join(s.split()) + +def dur(path): + with wave.open(path) as w: + return w.getnframes() / w.getframerate() + +def synth(text, out, seed, timeout=TIMEOUT): + cmd = [TTS, "--model", MODEL, "--codec", CODEC, + "--ref-spk", REF["spk"], "--ref-rvq", REF["rvq"], "--ref-text", REF["txt"], + "--lang", "spanish", "--seed", str(seed), "--temp", "0.85", "--top-p", "0.95", + "-o", out] + subprocess.run(cmd, input=text.encode(), stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True, timeout=timeout) + + +def synth_seguro(text, out, seed, etiqueta=""): + """A veces el modelo no emite fin y genera hasta el infinito: corto y cambio semilla.""" + for k in range(3): + try: + synth(text, out, seed + 7919 * k) + return seed + 7919 * k + except (subprocess.TimeoutExpired, subprocess.CalledProcessError): + print(f" {etiqueta} se colgo con semilla {seed + 7919 * k}, reintento", flush=True) + raise RuntimeError(f"{etiqueta}: tres intentos colgados") + +def transcribe(files, workdir): + """Una sola invocacion de whisper para todos los archivos (carga el modelo una vez).""" + if not files: + return {} + subprocess.run(["conda", "run", "-n", "stt", "--no-capture-output", + "whisper-ctranslate2", "--language", "es", "--model", "small", + "--output_dir", workdir, "--output_format", "txt", *files], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + out = {} + for f in files: + t = os.path.join(workdir, os.path.splitext(os.path.basename(f))[0] + ".txt") + out[f] = open(t, encoding="utf-8").read().strip() if os.path.exists(t) else "" + return out + +def build(name): + spec = SCRIPTS[name] + outdir = os.path.join(ROOT, "audio", name) + work = os.path.join(ROOT, "tmp", name) + os.makedirs(outdir, exist_ok=True); os.makedirs(work, exist_ok=True) + + segs = spec["segments"] + paths = [os.path.join(outdir, f"{i:02d}.wav") for i in range(len(segs))] + seeds = [1000 + i for i in range(len(segs))] + pend = list(range(len(segs))) + score = {} + best = {} # i -> (ratio, ruta del mejor intento) + keep = os.path.join(work, "best") + os.makedirs(keep, exist_ok=True) + + for rnd in range(MAX_ROUNDS): + if not pend: + break + for i in pend: + seeds[i] = synth_seguro(segs[i][0], paths[i], seeds[i], f"[{name}] {i:02d}") + tr = transcribe([paths[i] for i in pend], work) + nxt = [] + for i in pend: + r = SequenceMatcher(None, norm(segs[i][0]), norm(tr[paths[i]])).ratio() + score[i] = r + if i not in best or r > best[i][0]: + b = os.path.join(keep, f"{i:02d}.wav") + shutil.copy(paths[i], b) + best[i] = (r, b) + flag = "ok " if r >= MIN_RATIO else "RE " + print(f" [{name}] {i:02d} r={r:.2f} {flag}{dur(paths[i]):5.2f}s | {tr[paths[i]][:60]}", flush=True) + if r < MIN_RATIO: + seeds[i] += 7919 + nxt.append(i) + pend = nxt + if pend: + print(f" [{name}] ronda {rnd+2}: reintentando {pend}", flush=True) + + for i, (r, b) in best.items(): # me quedo con el mejor intento, no el ultimo + shutil.copy(b, paths[i]) + score[i] = r + + data = {"name": name, "title": spec["title"], "segments": [ + {"i": i, "tts": segs[i][0], "sub": segs[i][1], "wav": paths[i], + "dur": round(dur(paths[i]), 3), "score": round(score.get(i, 0), 3)} + for i in range(len(segs))]} + data["total_audio"] = round(sum(s["dur"] for s in data["segments"]), 2) + with open(os.path.join(outdir, "segments.json"), "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + bad = [s["i"] for s in data["segments"] if s["score"] < MIN_RATIO] + print(f"[{name}] listo. audio={data['total_audio']}s sin_verificar={bad}", flush=True) + +if __name__ == "__main__": + for n in (sys.argv[1:] or list(SCRIPTS)): + build(n) diff --git a/scripts/music.py b/scripts/music.py new file mode 100644 index 0000000..a3aee16 --- /dev/null +++ b/scripts/music.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +"""Cama musical original hecha con las muestras de piano de keysound. + +Muestra NN => nota MIDI NN+20 (01-A-大字2组 = A0 = MIDI 21) +Las progresiones son propias, para que el audio sea libre de derechos. +""" +import glob, os, wave +import numpy as np + +PIANO_DIR = "/mnt/Data/GitHub/keysound/audio/piano" +SR = 44100 + +_paths, _cache = {}, {} +for _p in glob.glob(os.path.join(PIANO_DIR, "*.wav")): + _paths[int(os.path.basename(_p).split("-")[0]) + 20] = _p + +NOTE = {"C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, + "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11} +TRIAD = {"maj": [0, 4, 7], "min": [0, 3, 7]} + + +def midi(name, octave): + return 12 * (octave + 1) + NOTE[name] + + +def load(m): + m = max(21, min(108, m)) + if m not in _cache: + with wave.open(_paths[m], "rb") as w: + d = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + d = d.reshape(-1, w.getnchannels()).astype(np.float32) / 32768.0 + if d.shape[1] == 1: + d = np.repeat(d, 2, axis=1) + _cache[m] = d + return _cache[m] + + +def chord(sym): + """'Am' -> (bajo, triada). Soporta sostenidos y sufijo m.""" + q = "min" if sym.endswith("m") else "maj" + root = sym[:-1] if sym.endswith("m") else sym + r = NOTE[root] + bass = 12 * (2 + 1) + r # octava 2 + triad = [12 * (4 + 1) + r + i for i in TRIAD[q]] + return bass, triad + + +# progresion + tempo por video +BEDS = { + "montyhall": (["Am", "F", "C", "G"], 92), + "nueves": (["Em", "C", "G", "D"], 88), + "cumple": (["C", "G", "Am", "F"], 96), + "luna": (["Dm", "A#", "F", "C"], 82), + "japones": (["G", "D", "Em", "C"], 100), + "benford": (["Am", "Em", "F", "G"], 94), + "hilbert": (["C", "Em", "Am", "F"], 80), + "reuleaux": (["F", "C", "Dm", "A#"], 98), + "regla72": (["G", "Em", "C", "D"], 104), + "buffon": (["Am", "G", "F", "E"], 86), + "collatz": (["Am", "Em", "Dm", "E"], 90), + "simpson": (["Cm", "G#", "A#", "Gm"], 96), + "cuerda": (["F", "Am", "Dm", "C"], 84), + "gabriel": (["Em", "Bm", "C", "G"], 76), + "mobius": (["D", "A", "Bm", "G"], 102), + "bayes": (["Dm", "Gm", "A#", "A"], 86), + "cicloide": (["A", "E", "F#m", "D"], 106), + "dados": (["Em", "Am", "D", "G"], 94), + "bolapeluda": (["C", "Am", "Dm", "G"], 100), + "caos": (["Am", "F", "Dm", "E"], 88), + "diferencial": (["Bm", "G", "D", "A"], 112, "motor"), + "galton": (["D", "Bm", "G", "A"], 96), + "bloques": (["E", "C#m", "A", "B"], 100), + "domino": (["G", "D", "Em", "C"], 104, "motor"), +} + +# melodias propias: por compas, (tiempo en negras, nota MIDI, duracion en negras) +MELODIAS = { + "motor": [ + [(0, 78, 1.5), (1.5, 74, 0.5), (2, 76, 1), (3, 78, 1)], # Bm + [(0, 79, 1.5), (1.5, 78, 0.5), (2, 76, 1), (3, 74, 1)], # G + [(0, 78, 1), (1, 81, 1), (2, 78, 0.5), (2.5, 76, 0.5), (3, 74, 1)], # D + [(0, 73, 2), (2, 76, 1), (3, 69, 1)], # A + ], +} + + +def bed(name, total_s, gain=0.30, seed=0): + prog, bpm = BEDS[name][:2] + estilo = BEDS[name][2] if len(BEDS[name]) > 2 else None + beat = 60.0 / bpm + measure = 4 * beat + eighth = beat / 2 + n = int((total_s + 3.0) * SR) + buf = np.zeros((n, 2), dtype=np.float32) + rng = np.random.default_rng(seed) + + def hit(m, t, g, max_len=None): + s = load(m) * g + if max_len is not None: + k = min(len(s), int(max_len * SR)) + s = s[:k].copy() + f = min(int(0.06 * SR), k) + s[-f:] *= np.linspace(1, 0, f)[:, None] + i = int(t * SR) + if i >= n: + return + buf[i:i + len(s)] += s[:n - i] + + k = 0 + t = 0.0 + while t < total_s + 0.5: + sym = prog[k % len(prog)] + bass, triad = chord(sym) + if estilo == "motor": + # bajo en corcheas, como un motor; acorde en 1 y 3; melodia arriba + for j in range(8): + hit(bass + (12 if j % 2 else 0), t + j * eighth, + 0.34 if j % 2 == 0 else 0.20, max_len=eighth * 0.9) + for b_ in (0, 2): + for nota in triad: + hit(nota - 12, t + b_ * beat, 0.13, max_len=beat * 1.8) + vuelta = (k // 4) % 2 # la segunda vuelta, una octava arriba + for (b_, nota, d) in MELODIAS[estilo][k % 4]: + hit(nota + 12 * vuelta, t + b_ * beat, 0.30, max_len=d * beat * 1.05) + t += measure + k += 1 + continue + hit(bass, t, 0.50, max_len=measure * 0.95) + hit(bass + 12, t + 2 * beat, 0.26, max_len=beat * 1.6) + pattern = [triad[0], triad[1], triad[2], triad[1], + triad[2], triad[1], triad[2], triad[0]] + for j, note in enumerate(pattern): + g = 0.30 if j % 2 == 0 else 0.20 + hit(note, t + j * eighth, g, max_len=beat * 1.15) + if k % 4 == 3: # brillito al cerrar la vuelta + hit(triad[2] + 12, t + 3.5 * beat, 0.16, max_len=beat) + t += measure + k += 1 + + buf = buf[:int(total_s * SR)] + peak = np.abs(buf).max() + 1e-9 + buf *= gain / peak + # entrada y salida suaves + fi, fo = int(1.2 * SR), int(2.0 * SR) + buf[:fi] *= np.linspace(0, 1, fi)[:, None] + buf[-fo:] *= np.linspace(1, 0, fo)[:, None] + return buf + + +def duck(music, speech, sr=SR, floor_db=-11.0, block=512, release=0.45): + """Baja la musica cuando hay voz (sidechain simple).""" + mono = np.abs(speech).mean(axis=1) if speech.ndim > 1 else np.abs(speech) + nb = len(mono) // block + env = mono[:nb * block].reshape(nb, block).max(axis=1) + rel = np.exp(-block / (release * sr)) + out = np.zeros(nb, dtype=np.float32) + acc = 0.0 + for i, v in enumerate(env): + acc = v if v > acc else acc * rel + v * (1 - rel) + out[i] = acc + g_floor = 10 ** (floor_db / 20) + active = np.clip(out / 0.03, 0, 1) + gain = 1.0 - (1.0 - g_floor) * active + idx = np.linspace(0, nb - 1, len(music)) + g = np.interp(idx, np.arange(nb), gain).astype(np.float32) + return music * g[:, None] diff --git a/scripts/narration.py b/scripts/narration.py new file mode 100644 index 0000000..d151fa8 --- /dev/null +++ b/scripts/narration.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +"""Guiones de los 5 videos. 'tts' = texto que se locuta (numeros en letras), +'sub' = subtitulo en pantalla (mas corto y legible).""" + +SCRIPTS = { + +"montyhall": { + "title": "El juego que casi todos pierden", + "segments": [ + ("Tres puertas. Atras de una hay un auto cero kilometro.", "3 puertas.\nAtrás de una: un auto 0 km."), + ("Atras de las otras dos, una cabra.", "Atrás de las otras dos:\nuna cabra."), + ("Vos elegis la puerta uno.", "Elegís la puerta 1."), + ("El conductor, que sabe donde esta el auto, abre la puerta tres. Cabra.", "El conductor abre la 3.\nCabra."), + ("Y te pregunta: te quedas con la uno, o cambias a la dos?", "¿Te quedás con la 1\no cambiás a la 2?"), + ("Casi todos dicen que da igual. Cincuenta y cincuenta. Y estan mal.", "Casi todos dicen 50 y 50.\nEstán mal."), + ("Cuando elegiste, tenias un tercio de chance de acertar. Eso nunca cambio.", "Cuando elegiste tenías 1/3.\nEso no cambió."), + ("O sea que las otras dos puertas juntas suman dos tercios.", "Las otras dos juntas: 2/3."), + ("El conductor te saco la mala. Todo ese dos tercios se mudo a la puerta dos.", "Él te sacó la mala.\nLos 2/3 se mudan a la 2."), + ("Si cambias, ganas el sesenta y siete por ciento de las veces. El doble.", "Cambiando ganás el 67%.\nEl doble."), + ("Cambia siempre. Y escribi abajo si vos tambien decias cincuenta y cincuenta.", "Cambiá siempre.\n¿Vos qué decías?"), + ], +}, + +"nueves": { + "title": "0.999... es igual a 1", + "segments": [ + ("Cero coma nueve, nueve, nueve, infinitos nueves.", "0,999999...\ninfinitos nueves"), + ("Eso es exactamente igual a uno. Y te vas a enojar.", "Eso es exactamente = 1.\nY te vas a enojar."), + ("Ya se lo que pensas: le falta un poquito.", "«Le falta un poquito»"), + ("Perfecto. Decime cuanto le falta. Escribi ese numero.", "Decime cuánto le falta.\nEscribilo."), + ("No podes. Porque si existiera un numero en el medio, yo le agrego mas nueves y te lo paso.", "No podés.\nSiempre agrego otro 9."), + ("Y dos numeros sin nada en el medio son el mismo numero.", "Dos números sin nada\nen el medio son el mismo."), + ("Mira la prueba mas linda. Un tercio es cero coma tres periodico.", "1/3 = 0,3333..."), + ("Multiplicamos los dos lados por tres.", "×3 de los dos lados"), + ("Tres tercios es uno. Y cero coma tres periodico por tres es cero coma nueve periodico.", "3/3 = 1\n0,333... × 3 = 0,999..."), + ("Listo. Son el mismo numero. No se acerca a uno: es uno.", "Son el mismo número.\nNo se acerca: ES 1."), + ("Deci en los comentarios si te convenci o si me queres pelear.", "¿Te convencí\no me querés pelear?"), + ], +}, + +"cumple": { + "title": "La paradoja del cumpleaños", + "segments": [ + ("Cuanta gente necesitas en una pieza para que dos cumplan el mismo dia?", "¿Cuánta gente para que dos\ncumplan el mismo día?"), + ("La mayoria dice ciento ochenta y pico. La respuesta es veintitres.", "La mayoría dice 180.\nLa respuesta: 23."), + ("Con veintitres personas ya tenes cincuenta por ciento de chance. Mitad y mitad.", "Con 23 personas: 50%.\nMitad y mitad."), + ("El error es pensar que te comparas vos contra todos los demas.", "El error: pensar que sos\nvos contra todos."), + ("Pero no. Se comparan todos contra todos.", "Se comparan\ntodos contra todos."), + ("Con veintitres personas hay doscientos cincuenta y tres parejas posibles.", "23 personas =\n253 parejas posibles."), + ("Cada pareja tiene una chance chiquita. Pero doscientas cincuenta y tres veces.", "Chance chica,\npero 253 veces."), + ('Con treinta personas, ya es el setenta por ciento.', '30 personas → 70%'), + ('Con cincuenta personas, el noventa y siete por ciento.', '50 personas → 97%'), + ('Con setenta personas, el noventa y nueve coma nueve. Practicamente seguro.', '70 personas → 99,9%'), + ("Probalo en tu grupo del trabajo. Despues me contas.", "Probalo en tu grupo.\nDespués contame."), + ], +}, + +"luna": { + "title": "Doblar un papel hasta la Luna", + "segments": [ + ("Agarra una hoja de papel y doblala cuarenta y dos veces. Llegas a la Luna.", "Doblá una hoja 42 veces\ny llegás a la Luna."), + ("Suena a mentira. Hagamos la cuenta juntos.", "Suena a mentira.\nHagamos la cuenta."), + ("Una hoja mide un decimo de milimetro de espesor.", "1 hoja = 0,1 mm"), + ("Un doblez, dos decimos. Dos dobleces, cuatro decimos.", "1 doblez: 0,2 mm\n2 dobleces: 0,4 mm"), + ("Con diez dobleces tenes diez centimetros. Un ladrillo.", "10 dobleces → 10 cm"), + ("Con veinte dobleces, cien metros. Un edificio de treinta pisos.", "20 dobleces → 100 m"), + ("Con treinta dobleces, cien kilometros. Ya saliste de la atmosfera.", "30 dobleces → 100 km\n(fuera de la atmósfera)"), + ("Con cuarenta dobleces, cien mil kilometros.", "40 dobleces → 100.000 km"), + ("Con cuarenta y dos, cuatrocientos cuarenta mil kilometros.", "42 dobleces →\n440.000 km"), + ("Y la Luna esta a trescientos ochenta y cuatro mil. Te pasaste de largo.", "La Luna está a 384.000 km.\nTe pasaste."), + ("Eso es crecimiento exponencial. Por eso nadie lo ve venir.", "Eso es crecimiento\nexponencial."), + ], +}, + +"japones": { + "title": "Multiplicar con líneas", + "segments": [ + ("Asi multiplican en Japon y no necesitas saberte las tablas.", "Así multiplican en Japón.\nSin tablas."), + ("Vamos con veintiuno por trece.", "21 × 13"), + ("El veintiuno lo dibujas asi: dos lineas, espacio, una linea.", "21 → 2 líneas\ny 1 línea"), + ('El trece va cruzado. Una linea, y despues tres lineas.', '13 → 1 línea\ny 3 líneas'), + ('Ahora contas los cruces por zona. La zona izquierda tiene dos cruces.', 'Zona izquierda:\n2 cruces'), + ("Zona del medio: seis mas uno. Siete cruces.", "Zona del medio:\n6 + 1 = 7"), + ("Zona derecha: tres cruces.", "Zona derecha:\n3 cruces"), + ("Dos, siete, tres. Doscientos setenta y tres.", "2 | 7 | 3\n= 273"), + ('Y efectivamente, veintiuno por trece da doscientos setenta y tres.', '21 × 13 = 273 ✓'), + ("Si una zona te da mas de nueve, llevas una a la de al lado. Igual que siempre.", "¿Más de 9?\nLlevás una al lado."), + ("Guardalo para cuando tus hijos te pidan ayuda con la tarea.", "Guardalo para\nla tarea de tus hijos."), + ], +}, + + +"benford": { + "title": 'Así se detecta el fraude', + "segments": [ + ('Agarra cualquier lista de numeros reales. Facturas, poblaciones, sueldos.', 'Cualquier lista real:\nfacturas, sueldos, ciudades'), + ('Fijate con que digito empiezan. Uno, dos, tres.', '¿Con qué dígito\nempiezan?'), + ('Vos dirias que todos aparecen mas o menos lo mismo. Once por ciento cada uno.', 'Dirías: 11% cada uno.'), + ('Y no. El uno aparece el treinta por ciento de las veces.', 'El 1 aparece el 30%\nde las veces.'), + ('El dos, el diecisiete. Y va bajando hasta el nueve, que aparece el cuatro por ciento.', 'El 2: 17%.\nEl 9: apenas 4%.'), + ('Se llama ley de Benford, y pasa en casi todo lo que crece.', 'Ley de Benford'), + ('Poblaciones, precios, likes, el largo de los rios.', 'Poblaciones, precios,\nlikes, ríos…'), + ('Y aca viene lo bueno. Cuando alguien inventa numeros, los inventa parejos.', 'El que inventa números\nlos inventa parejos.'), + ('Entonces el patron se rompe, y eso se ve de lejos.', 'El patrón se rompe.'), + ('Asi agarraron fraudes contables y elecciones dibujadas, de verdad.', 'Así agarraron fraudes\ncontables reales.'), + ('Proba con los gastos de tu tarjeta. Te va a dar.', 'Probá con tu\nresumen de tarjeta.'), + ], +}, + +"hilbert": { + "title": 'El hotel infinito', + "segments": [ + ('Un hotel con infinitas habitaciones, y todas ocupadas.', 'Hotel con infinitas\nhabitaciones. Lleno.'), + ('Llega un huesped mas. Hay lugar?', 'Llega uno más.\n¿Hay lugar?'), + ('Si. El de la uno se pasa a la dos, el de la dos a la tres, y asi.', 'El 1 pasa a la 2,\nel 2 a la 3…'), + ('Todos se corren uno. La habitacion uno queda libre.', 'Todos se corren uno.\nLa 1 queda libre.'), + ('Ahora llegan infinitos huespedes de golpe. Ahi ya no entra nadie, no?', 'Ahora llegan INFINITOS.\n¿Ahí sí no entran?'), + ('Entran. Cada uno se pasa a la habitacion del doble de su numero.', 'Cada uno pasa\nal doble de su número.'), + ('El uno va a la dos, el dos a la cuatro, el tres a la seis.', '1→2, 2→4, 3→6…'), + ('Quedan libres todas las impares. Infinitas habitaciones vacias.', 'Quedan libres\ntodas las impares.'), + ('El hotel estaba lleno y le entraron infinitas personas mas.', 'Estaba lleno y entraron\ninfinitos más.'), + ('Eso es el infinito. No es un numero grande, es otra cosa.', 'El infinito no es\nun número grande.'), + ('Se llama hotel de Hilbert. Contaselo a alguien y mirale la cara.', 'Hotel de Hilbert.\nContáselo a alguien.'), + ], +}, + +"reuleaux": { + "title": 'Por qué son redondas', + "segments": [ + ('Por que las tapas de alcantarilla son redondas?', '¿Por qué las tapas\nson redondas?'), + ('Porque son las unicas que no se pueden caer adentro del pozo.', 'Porque no se pueden\ncaer adentro.'), + ('Una tapa cuadrada, girada en diagonal, se cae.', 'La cuadrada,\nen diagonal, se cae.'), + ('Porque la diagonal del cuadrado es mas larga que el lado.', 'La diagonal es más\nlarga que el lado.'), + ('El circulo no tiene ese problema. Mide lo mismo en todas las direcciones.', 'El círculo mide igual\nen toda dirección.'), + ('Pero ojo, que no es la unica figura que cumple eso.', 'No es la única\nque lo cumple.'), + ('Mira este triangulo con los lados curvos.', 'Mirá este triángulo\nde lados curvos.'), + ('Se llama triangulo de Reuleaux, y tambien mide lo mismo en todas las direcciones.', 'Triángulo de Reuleaux'), + ('Rueda perfecto entre dos paredes, aunque no sea redondo.', 'Rueda perfecto,\naunque no sea redondo.'), + ('Si lo usas de rueda, la carga de arriba no sube ni baja.', 'De rueda: la carga\nno sube ni baja.'), + ('Ahora ya sabes por que son redondas. Contalo en el proximo asado.', 'Contalo en\nel próximo asado.'), + ], +}, + +"regla72": { + "title": 'La regla del 72', + "segments": [ + ('Cuanto tarda tu plata en duplicarse? Hay una cuenta que te lo dice en dos segundos.', '¿Cuánto tarda tu plata\nen duplicarse?'), + ('Se llama la regla del setenta y dos.', 'La regla del 72'), + ('Dividis setenta y dos por la tasa anual, y te da los años.', '72 ÷ tasa anual\n= años'), + ('Al seis por ciento anual: setenta y dos dividido seis, doce años.', '6% anual →\n72 ÷ 6 = 12 años'), + ('Al nueve por ciento: ocho años.', '9% anual →\n8 años'), + ('Al doce por ciento: seis años.', '12% anual →\n6 años'), + ('Y lo mejor es que funciona para el otro lado tambien.', 'También funciona\nal revés.'), + ('Con treinta por ciento de inflacion anual, tu plata vale la mitad en dos años y medio.', '30% de inflación:\nvale la mitad en 2,4 años'), + ('Setenta y dos dividido treinta: dos coma cuatro.', '72 ÷ 30 = 2,4'), + ('Es una aproximacion, pero se clava.', 'Es aproximada,\npero se clava.'), + ('Guardate esta. Es la cuenta que mas veces vas a usar en tu vida.', 'Guardala.\nLa vas a usar siempre.'), + ], +}, + +"buffon": { + "title": 'Sacar pi tirando agujas', + "segments": [ + ('Te tiro agujas al piso y te saco el numero pi. En serio.', 'Tiro agujas al piso\ny sale π.'), + ('Piso rayado. La distancia entre rayas es igual al largo de la aguja.', 'Rayas separadas\nel largo de la aguja.'), + ('Ahora tiras agujas al azar. Muchas.', 'Tirás agujas\nal azar.'), + ('Algunas van a cruzar una raya. Otras no.', 'Algunas cruzan.\nOtras no.'), + ('Contas cuantas tiraste, y cuantas cruzaron.', 'Contás cuántas\ny cuántas cruzan.'), + ('Y dividis el doble del total por las que cruzaron.', '2 × total ÷ cruces'), + ('Y el resultado que te da es el numero pi.', '= π'), + ('Con sesenta agujas ya se acerca. Con doscientas, se acerca mucho mas.', '60 agujas: se acerca.\n200: mucho más.'), + ('Tres coma uno cuatro uno cinco.', '3,1415…'), + ('Y no hay ningun circulo en la escena. Igual aparece pi.', 'No hay ningún círculo.\nY aparece π.'), + ('Se llama aguja de Buffon. Lo podes hacer en tu casa con fosforos.', 'Aguja de Buffon.\nHacelo con fósforos.'), + ], +}, +"collatz": { + "title": "El problema que nadie pudo resolver", + "segments": [ + ('Agarra cualquier numero entero. El que se te ocurra.', 'Agarrá cualquier número.'), + ('Si es par, lo dividis por dos. Si es impar, lo multiplicas por tres y le sumas uno.', 'Par → ÷2\nImpar → ×3+1'), + ('Y repetis para siempre. Probemos con el seis.', 'Y repetís.\nProbemos con el 6.'), + ('Seis, tres, diez, cinco, dieciseis, ocho, cuatro, dos, uno.', '6 → 3 → 10 → 5 → 16\n→ 8 → 4 → 2 → 1'), + ('Cayo al uno. Probemos ahora con el siete.', 'Cayó al 1.\nAhora el 7.'), + ('Sube, baja, vuelve a subir mucho mas alto, y al final tambien termina en uno.', 'Sube, baja, sube más...\ny termina en 1.'), + ('El veintisiete es el mas rebelde. Trepa hasta nueve mil doscientos treinta y dos.', 'El 27 trepa\nhasta 9.232.'), + ('Pero despues de ciento once pasos, tambien se cae al uno.', 'Y en 111 pasos\ncae al 1.'), + ('Lo probaron con computadoras hasta numeros de veinte cifras. Todos, absolutamente todos, caen al uno.', 'Probado hasta\n20 cifras. Todos caen.'), + ('Pero nadie en el mundo consiguio demostrarlo. Hace casi noventa años que el problema sigue abierto.', 'Nadie pudo demostrarlo.\n90 años abierto.'), + ('Se llama conjetura de Collatz. Deja tu numero en los comentarios y lo probamos.', 'Conjetura de Collatz.\nDejá tu número.'), + ], +}, + +"simpson": { + "title": "El promedio te está mintiendo", + "segments": [ + ('Dos tratamientos para la misma enfermedad. Les decimos A y B.', 'Dos tratamientos: A y B.'), + ('En los casos leves, el A cura al noventa y tres por ciento. El B, al ochenta y siete.', 'Casos leves:\nA 93% · B 87%'), + ('Gana el A. Y en los casos graves, el A cura al setenta y tres por ciento. El B, al sesenta y nueve.', 'Gana A.\nGraves: A 73% · B 69%'), + ('Tambien gana el A. Entonces el A es mejor siempre, no?', 'Gana A otra vez.\nA es mejor, ¿no?'), + ('Sumamos todos los pacientes. El A cura al setenta y ocho por ciento. El B, al ochenta y tres.', 'Todos juntos:\nA 78% · B 83%'), + ('Gana el B. Y no hay ningun error en las cuentas.', 'Gana B.\nY no hay ningún error.'), + ('Esto se llama la paradoja de Simpson, y pasa por como estan repartidos los pacientes.', 'Paradoja de Simpson.'), + ('Casi todos los casos graves, que son los dificiles de curar, se los dieron al tratamiento A.', 'A recibió casi todos\nlos casos graves.'), + ('Al B le mandaron casi todos los leves, que son faciles. Por eso su promedio sube.', 'B recibió los leves.\nPor eso su promedio sube.'), + ('El promedio total esta mezclando dos grupos distintos. Y esos numeros son reales, salen de un estudio medico.', 'El promedio mezcla\ndos grupos distintos.'), + ('Cada vez que te muestren un promedio, pregunta: promedio de quienes? Mandaselo al que te tira estadisticas.', 'Preguntá siempre:\n¿promedio de quiénes?'), + ], +}, + +"cuerda": { + "title": "Una cuerda alrededor de la Tierra", + "segments": [ + ('Imaginate una cuerda que da toda la vuelta a la Tierra, pegadita al suelo, por el ecuador.', 'Una cuerda que rodea\nla Tierra entera.'), + ('Son unos cuarenta mil kilometros de cuerda.', '40.000 km de cuerda.'), + ('Ahora le agregamos un metro. Un metro solo.', 'Le agregamos 1 metro.'), + ('Y la levantamos pareja, a la misma altura en todo el planeta.', 'Y la levantamos pareja\nen todo el planeta.'), + ('Cuanto se separa del suelo? Casi todos dicen que nada, un pelo.', '¿Cuánto se separa?\nCasi nadie acierta.'), + ('Se separa quince coma nueve centimetros. Te pasa un gato caminando por abajo.', '15,9 cm.\nPasa un gato por abajo.'), + ('La cuenta es de una sola linea. El radio de un circulo es su perimetro dividido dos pi.', 'radio = perímetro ÷ 2π'), + ('Si al perimetro le sumas un metro, el radio crece un metro dividido dos pi.', '+1 m de perímetro\n→ +1÷2π de radio'), + ('Eso da quince coma nueve centimetros. Y fijate bien: el tamaño de la Tierra no aparece en la cuenta.', '= 15,9 cm\nLa Tierra ni aparece.'), + ('O sea que si haces lo mismo con una pelota de tenis, la cuerda tambien se levanta quince coma nueve centimetros.', 'Con una pelota de tenis:\ntambién 15,9 cm.'), + ('El tamaño no importa. Mandaselo al que te dijo que no se levantaba nada.', 'El tamaño no importa.\nMandáselo al que dudó.'), + ], +}, + +"gabriel": { + "title": "La figura que no se puede pintar", + "segments": [ + ('Esta figura rompe la cabeza. Se llama el cuerno de Gabriel.', 'El cuerno de Gabriel.'), + ('Agarras la curva uno dividido equis, y la haces girar como en un torno.', 'Tomás y = 1÷x\ny la hacés girar.'), + ('Te queda un cuerno infinito, cada vez mas finito, que nunca termina.', 'Un cuerno infinito,\ncada vez más fino.'), + ('Primera pregunta: cuanta pintura entra adentro?', '¿Cuánta pintura\nentra adentro?'), + ('El volumen da exactamente pi. Un numero finito. Tres litros y monedas.', 'Volumen = π\n≈ 3,14 litros'), + ('Lo llenas con un balde chico y te sobra.', 'Lo llenás con un balde\ny te sobra.'), + ('Segunda pregunta: y si lo queres pintar por fuera?', '¿Y si lo pintás\npor fuera?'), + ('La superficie es infinita. No te alcanza toda la pintura del universo.', 'La superficie\nes INFINITA.'), + ('O sea: podes llenarlo de pintura por dentro, pero no podes pintarlo.', 'Podés llenarlo,\npero no pintarlo.'), + ('La trampa es que la pintura de verdad tiene espesor, y el cuerno se hace mas fino que cualquier espesor.', 'La trampa: la pintura\nreal tiene espesor.'), + ('Con el infinito, la intuicion se rompe. Segui para mas matematica rara.', 'Con el infinito\nla intuición se rompe.'), + ], +}, + +"mobius": { + "title": "Cortá esto y no vas a creer", + "segments": [ + ('Agarra una tira de papel. Cualquiera sirve.', 'Agarrá una tira de papel.'), + ('Le das media vuelta a una punta, y recien ahi pegas los dos extremos.', 'Media vuelta\ny pegá las puntas.'), + ('Eso es una cinta de Mobius, y tiene una sola cara.', 'Cinta de Möbius.\nUna sola cara.'), + ('Si apoyas el dedo y lo arrastras sin levantarlo, pintas todo. El adentro y el afuera son lo mismo.', 'Pintás todo\nsin levantar el dedo.'), + ('Tambien tiene un solo borde. Es un camino unico que da dos vueltas enteras.', 'Y un solo borde.'), + ('Ahora hace esto: cortala al medio, a lo largo, hasta volver al principio.', 'Cortala al medio,\na lo largo.'), + ('Esperarias que queden dos cintas. Pero no. Queda una sola, el doble de larga.', 'No salen dos.\nSale una, el doble de larga.'), + ('Y esa cinta nueva ya tiene dos caras. Perdio la magia.', 'Y esa ya tiene\ndos caras.'), + ('Probemos de nuevo, con otra cinta. Pero ahora cortando a un tercio del borde.', 'Otra vez, pero cortando\na 1/3 del borde.'), + ('Salen dos cintas de distinto tamaño, y quedan enganchadas una con la otra.', 'Salen 2 cintas,\nenganchadas entre sí.'), + ('Agarra una hoja y la cinta de embalar y hacelo ahora. Te espero en los comentarios.', 'Hacelo en casa.\nContame cómo te fue.'), + ], +}, + + +"bayes": { + "title": "Tu test dio positivo", + "segments": [ + ('Te haces un analisis para detectar una enfermedad. El test acierta el noventa y nueve por ciento de las veces.', 'Un test que acierta\nel 99% de las veces.'), + ('Te da positivo. Que chance hay de que estes enfermo de verdad?', 'Te da POSITIVO.\n¿Qué chance hay\nde estar enfermo?'), + ('Casi todos contestan noventa y nueve por ciento. Un monton de medicos tambien.', 'Casi todos dicen 99%.\nMuchos médicos también.'), + ('Es nueve por ciento. Y el test no fallo: esta funcionando perfecto.', 'Es 9%.\nY el test no falló.'), + ('Mira. Esta enfermedad la tiene una persona de cada mil.', 'La enfermedad la tiene\n1 de cada 1000.'), + ('Juntamos mil personas. Una sola esta enferma.', '1000 personas.\nUna está enferma.'), + ('Les hacemos el test a todas. A la enferma la encuentra.', 'El test la encuentra.\n1 positivo verdadero.'), + ('Pero con las novecientas noventa y nueve sanas se equivoca en el uno por ciento.', 'Con las 999 sanas\nse equivoca en el 1%.'), + ('Son diez personas sanas que dieron positivo.', '10 personas sanas\ncon test positivo.'), + ('Once positivos en total, y una sola enferma. Uno sobre once es nueve por ciento.', '11 positivos.\n1 enferma. 1/11 = 9%'), + ('Si la enfermedad es rara, el primer positivo casi siempre es falso. Por eso el test se repite. Mandaselo a alguien que se asusta con los numeros.', 'Si es rara, el 1er positivo\ncasi siempre es falso.'), + ], +}, + +"cicloide": { + "title": "¿Cuál pelota llega primero?", + "segments": [ + ('Dos pelotas. Salen de la misma altura y llegan al mismo punto.', 'Dos pelotas.\nMisma altura, misma meta.'), + ('Una baja por la recta. La otra por esta curva rara.', 'Una por la recta.\nLa otra por esta curva.'), + ('Cual llega primero? Decidilo antes de que largue.', '¿Cuál llega primero?'), + ('La recta es el camino mas corto entre dos puntos. Eso no se discute.', 'La recta es el camino\nmás CORTO.'), + ('Pero el mas corto no es el mas rapido. Largamos.', 'Corto no es lo mismo\nque rápido.'), + ('Gano la curva, y por bastante. Encima baja mas abajo que la meta y vuelve a subir.', 'Ganó la curva.\nY hasta baja de más.'), + ('El truco es que cae de una al principio, agarra velocidad temprano y la usa todo el resto del camino.', 'Cae fuerte al principio\ny agarra velocidad.'), + ('La curva se llama cicloide, y es la que dibuja un clavito clavado en la rueda de una bici.', 'Se llama CICLOIDE.'), + ('Y tiene un regalo aparte. Soltamos tres pelotas desde tres alturas distintas.', 'Bonus: 3 pelotas,\n3 alturas distintas.'), + ('Llegan al fondo exactamente al mismo tiempo. Siempre.', 'Llegan al fondo\nAL MISMO TIEMPO.'), + ('Bernoulli tiro este problema en mil seiscientos noventa y seis. Newton lo resolvio esa misma noche. Vos le habias pegado?', '1696: Bernoulli desafía.\nNewton lo resuelve\nen una noche.'), + ], +}, + + + +"dados": { + "title": "Elegí primero y perdés igual", + "segments": [ + ('Mira estos tres dados. Elegis el que quieras, y despues elijo yo.', 'Tres dados raros.\nElegís primero.'), + ('Tiramos los dos y gana el numero mas alto. Suena a que te di ventaja.', 'Tiramos.\nGana el número más alto.'), + ('Elijas el que elijas, yo agarro otro y te gano el cincuenta y seis por ciento de las veces.', 'Elijas el que elijas,\nte gano el 56%.'), + ('Mira las caras. El rojo tiene dos, cuatro y nueve, cada numero repetido en dos caras.', 'Rojo: 2, 4 y 9.'), + ('El celeste tiene uno, seis y ocho. Y el verde, tres, cinco y siete.', 'Celeste: 1, 6 y 8.\nVerde: 3, 5 y 7.'), + ('Cruzamos rojo contra celeste. Nueve duelos posibles, y el rojo gana cinco.', 'Rojo vs celeste:\ngana rojo, 5 de 9.'), + ('Ahora celeste contra verde. Otra vez cinco de nueve, esta vez para el celeste.', 'Celeste vs verde:\ngana celeste, 5 de 9.'), + ('Entonces el rojo le tiene que ganar al verde, no? No.', 'Entonces rojo le gana\nal verde, ¿no?'), + ('El verde le gana al rojo, tambien cinco de nueve. La cadena se muerde la cola.', 'NO.\nVerde le gana al rojo.'), + ('No existe un dado invencible. Se ganan en circulo, como piedra, papel o tijera.', 'No hay dado invencible.\nSe ganan en círculo.'), + ('Por eso lo mejor es elegir segundo. Deci en los comentarios cual hubieses agarrado vos.', 'Por eso lo mejor\nes elegir SEGUNDO.'), + ], +}, + + + +"bolapeluda": { + "title": "No podés peinar una esfera", + "segments": [ + ('Esta esfera esta llena de pelos. Uno en cada punto.', 'Una esfera\nllena de pelos.'), + ('Tu mision es peinarlos todos, sin dejar ni uno parado.', 'Peinalos todos.\nNinguno parado.'), + ('Arrancamos. Todos para el mismo lado, dando la vuelta.', 'Todos para\nel mismo lado.'), + ('Mira los polos. Ahi quedaron dos remolinos.', 'Arriba y abajo:\ndos remolinos.') + , + ('Probemos otro peinado, de arriba hacia abajo.', 'Probamos otro\npeinado.'), + ('Otra vez. Quedan dos puntos donde el pelo queda parado.', 'Otra vez quedan\npuntos parados.'), + ('No sos vos. Es un teorema, y lo probo Brouwer en mil novecientos doce.', 'No sos vos:\nes un teorema.'), + ('Peines como peines una esfera, siempre queda al menos un remolino.', 'Siempre queda\nal menos uno.'), + ('Por eso ahora mismo hay un punto del planeta sin nada de viento horizontal.', 'Por eso siempre hay\nun punto sin viento.'), + ('Y en una rosquilla? Ahi si se puede peinar todo, sin un solo remolino.', 'En una rosquilla\nSÍ se puede.'), + ('El numero que las separa cuenta agujeros: la esfera da dos, la rosquilla da cero. Contame si vos le encontras la vuelta.', 'Esfera 2, rosquilla 0.\n¿Le encontrás la vuelta?'), + ], +}, + +"caos": { + "title": "Un milímetro cambia todo", + "segments": [ + ('Tres pendulos dobles, exactamente iguales.', 'Tres péndulos dobles\nexactamente iguales.'), + ('Bueno, casi. El rosa arranca un milimetro mas arriba que el amarillo.', 'Casi: el rosa arranca\n1 mm más arriba.'), + ('Y el celeste, un milimetro mas que el rosa.', 'Y el celeste, 1 mm\nmás que el rosa.'), + ('Los soltamos a los tres en el mismo instante.', 'Los soltamos\nal mismo tiempo.'), + ('Al principio se mueven como si fueran uno solo.', 'Al principio\nse mueven juntos.'), + ('A los dos segundos ya se empiezan a separar.', 'A los 2 segundos\nse separan.'), + ('Y a los cinco no tienen nada que ver entre si.', 'A los 5 ya no\ntienen nada que ver.'), + ('Nadie los toco. La ecuacion es la misma para los tres.', 'Nadie los tocó.\nMisma ecuación.'), + ('Eso es caos: el error del arranque se multiplica solo, cada vez mas rapido.', 'Eso es CAOS:\nel error se multiplica.'), + ('Por eso el pronostico del tiempo se cae despues de una semana. No faltan datos: falta precision infinita.', 'Por eso el clima\nno se predice a 15 días.'), + ('Para acertarle al mes que viene habria que medir hoy con infinitos decimales. Volve al principio y fijate en que segundo se separan.', 'Volvé al principio:\n¿en qué segundo\nse separan?'), + ], +}, + +"diferencial": { + "title": "Por qué tu auto puede doblar", + "segments": [ + ('Cuando un auto dobla, la rueda de afuera recorre mas camino que la de adentro.', 'Al doblar, la rueda de afuera\nrecorre más camino.'), + ('Si las dos estuvieran clavadas al mismo eje, una tendria que patinar.', 'Con un eje rígido,\nuna tiene que patinar.'), + ('El invento que lo arregla lo tenes abajo del auto: el diferencial.', 'Eso lo arregla\nel DIFERENCIAL.'), + ('Son cuatro engranajes conicos metidos en una jaula.', 'Cuatro engranajes cónicos\nen una jaula.'), + ('Dos van a las ruedas, uno de cada lado.', 'Dos van a las ruedas.'), + ('Los otros dos los conectan, y viajan montados en la jaula.', 'Otros dos\nlos conectan.'), + ('El motor hace girar la jaula entera. Eso es lo unico que entra.', 'El motor hace girar\nla jaula.'), + ('En recta, los del medio no giran sobre si mismos, y las dos ruedas van igual.', 'En recta: las dos\nruedas van igual.'), + ('Cuando doblas, la rueda de adentro se frena, los del medio empiezan a girar y le pasan esa vuelta a la de afuera.', 'Al doblar: una frena\ny la otra acelera.'), + ('Lo que pierde una lo gana la otra. La suma siempre da lo mismo.', 'Lo que pierde una,\nlo gana la otra.'), + ('Lo patentaron en mil ochocientos veintisiete y lo tenes abajo del auto ahora mismo. Guardatelo para la proxima charla de asado.', 'Patentado en 1827.\nLo tenés abajo del auto.'), + ], +}, + + + +"galton": { + "title": "La campana que nadie dibuja", + "segments": [ + ('Quinientas bolitas de acero contra un tablero lleno de clavos.', '500 bolitas de acero\ncontra un tablero de clavos.'), + ('En cada clavo, la bolita rebota para un lado o para el otro. Al azar.', 'En cada clavo rebota\npara un lado o el otro.'), + ('Nadie puede adivinar donde va a caer una sola.', 'Nadie adivina dónde\ncae UNA bolita.'), + ('Pero mira lo que forman todas juntas.', 'Pero mirá lo que forman\ntodas juntas.'), + ('Una campana. Y sale siempre la misma forma, tires las veces que tires.', 'Una campana.\nSiempre la misma forma.'), + ('Para terminar en una punta, la bolita tiene que rebotar para el mismo lado doce veces seguidas.', 'Para caer en la punta:\n12 rebotes al mismo lado.'), + ('Para caer en el medio hay novecientos veinticuatro caminos distintos. Por eso se amontonan ahi.', 'Para caer al medio:\n924 caminos distintos.'), + ('Sumar muchas cosas chiquitas al azar siempre da esta campana. Se llama distribucion normal.', 'Muchas cosas al azar:\ndistribución NORMAL.'), + ('Por eso la altura de la gente o los errores de una medicion tienen la misma forma.', 'Alturas, errores de medición:\nla misma campana.'), + ('Ninguna bolita esta animada a mano. Cada rebote que ves es fisica pura.', 'Nada animado a mano:\ncada rebote es física.'), + ('Fijate la columna del medio y comenta cuantas bolitas pensas que tiene.', '¿Cuántas bolitas tiene\nla columna del medio?'), + ], +}, + +"bloques": { + "title": "Bloques que flotan fuera de la mesa", + "segments": [ + ('Bloques iguales y una mesa. Se pueden apilar para que el de arriba quede entero en el aire?', '¿Se puede apilar para que\nel de arriba quede en el aire?'), + ('Lo obvio es correr cada uno la mitad. Y se viene abajo.', 'Lo obvio: correr cada uno\nla mitad. Y se cae.'), + ('El truco es armarla desde arriba.', 'El truco: armarla\ndesde arriba.'), + ('El de arriba sobresale medio bloque. El siguiente, un cuarto. Despues un sexto, un octavo y un decimo.', '1/2, 1/4, 1/6,\n1/8, 1/10...'), + ('Con cinco bloques, el de arriba ya quedo entero afuera de la mesa. Y no se cae.', 'Con 5 bloques, el de arriba\nya está en el aire.'), + ('Cada bloque sostiene el centro de todo lo que tiene encima justo en su borde.', 'Cada uno sostiene lo de arriba\njusto en su borde.'), + ('Si seguis agregando, sobresale cada vez mas, sin limite. Es la serie armonica, que nunca deja de crecer.', 'Crece sin límite:\nla serie armónica.'), + ('Pero muy despacio: para sobresalir tres bloques enteros necesitas doscientos veintisiete.', 'Para sobresalir 3 bloques:\n¡227 bloques!'), + ('Eso si, esta al limite. Le apoyo un solo bloque mas arriba y...', 'Está al límite.\nUn bloque más y...'), + ('Y se derrumba. Un solo bloque de mas alcanza para tirarla.', 'Se derrumba.\nUn bloque de más alcanza.'), + ('Proba con libros en tu casa y conta en los comentarios hasta donde llegaste.', 'Probalo con libros\ny contá hasta dónde llegaste.'), + ], +}, + + +"domino": { + "title": "Una ficha de 5 mm que tira un edificio", + "segments": [ + ('Esta ficha de domino mide cinco milimetros. Y puede tirar un edificio.', 'Esta ficha mide 5 mm.\nY puede tirar un edificio.'), + ('El truco: una ficha puede voltear a otra una vez y media mas grande que ella.', 'Una ficha puede tirar\na otra 1,5 veces más grande.'), + ('Asi que armo trece fichas, cada una una vez y media mas grande que la anterior.', '13 fichas, cada una\n1,5 veces más grande.'), + ('La ultima ya mide sesenta y cinco centimetros. Le doy un toquecito a la primera.', 'La última mide 65 cm.\nUn toquecito a la primera...'), + ('Y mira como se cae todo.', 'Y mirá.'), + ('Otra vez, en camara lenta: cada ficha cae con mas fuerza que la anterior, y el empujon se multiplica.', 'En cámara lenta:\ncada caída empuja más fuerte.'), + ('La primera pesa menos que un grano de arroz. La ultima, quince kilos.', 'La primera: menos que un arroz.\nLa última: 15 kilos.'), + ('Eso es crecimiento exponencial: multiplicar por uno coma cinco, una y otra vez.', 'Crecimiento EXPONENCIAL:\n×1,5 una y otra vez.'), + ('Un fisico lo probo en mil novecientos ochenta y tres, con fichas de verdad. Y funciona.', 'Probado en 1983\ncon fichas de verdad.'), + ('Ahora imaginate que seguimos agregando fichas.', 'Ahora imaginá\nque seguimos.'), + ('La ficha dieciseis ya es mas alta que vos.', 'La ficha 16:\nmás alta que vos.'), + ('La veinticinco es mas alta que el Obelisco.', 'La 25: más alta\nque el Obelisco.'), + ('La veintinueve pasa al Empire State.', 'La 29: más alta\nque el Empire State.'), + ('Y la treinta y seis es mas alta que el Aconcagua.', 'La 36: más alta\nque el Aconcagua.'), + ('Treinta y seis fichas. Y a la primera la tiras con un soplido.', '36 fichas.\nLa primera, con un soplido.'), + ('Asi es una reaccion en cadena: algo chiquito que se multiplica sin parar.', 'Reacción en cadena:\nalgo chico que se multiplica.'), + ('Guardalo para cuando alguien te diga que un empujoncito no cambia nada.', 'Guardalo para cuando digan\nque un empujón no cambia nada.'), + ('Una casa mide unos ocho metros. Comenta en que ficha pensas que se cae.', 'Una casa: 8 metros.\n¿En qué ficha se cae?'), + ], +}, + +} diff --git a/scripts/numspell.py b/scripts/numspell.py new file mode 100644 index 0000000..2e416e3 --- /dev/null +++ b/scripts/numspell.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Pasa los numeros de una transcripcion a palabras, para poder compararla +con el guion (que escribe todo en letras).""" +import re + +U = ["cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", + "diez", "once", "doce", "trece", "catorce", "quince", "dieciseis", "diecisiete", + "dieciocho", "diecinueve", "veinte", "veintiuno", "veintidos", "veintitres", + "veinticuatro", "veinticinco", "veintiseis", "veintisiete", "veintiocho", "veintinueve"] +D = {3: "treinta", 4: "cuarenta", 5: "cincuenta", 6: "sesenta", + 7: "setenta", 8: "ochenta", 9: "noventa"} +C = {1: "ciento", 2: "doscientos", 3: "trescientos", 4: "cuatrocientos", 5: "quinientos", + 6: "seiscientos", 7: "setecientos", 8: "ochocientos", 9: "novecientos"} + + +def _lt1000(n): + if n < 30: + return U[n] + if n < 100: + d, u = divmod(n, 10) + return D[d] + (" y " + U[u] if u else "") + if n == 100: + return "cien" + c, r = divmod(n, 100) + return C[c] + (" " + _lt1000(r) if r else "") + + +def spell_int(n): + if n < 1000: + return _lt1000(n) + if n < 1000000: + m, r = divmod(n, 1000) + pre = "mil" if m == 1 else _lt1000(m) + " mil" + return pre + (" " + _lt1000(r) if r else "") + m, r = divmod(n, 1000000) + pre = "un millon" if m == 1 else _lt1000(m) + " millones" + return pre + (" " + spell_int(r) if r else "") + + +def _tok(m): + s = m.group(0) + s = re.sub(r"\.(?=\d{3}\b)", "", s) # 384.000 -> 384000 + if "," in s: + a, b = s.split(",", 1) + ent = spell_int(int(a or 0)) + dec = " ".join(U[int(c)] for c in b if c.isdigit()) + return f"{ent} coma {dec}" + if "." in s: # 0.5 estilo ingles + a, b = s.split(".", 1) + return f"{spell_int(int(a or 0))} coma " + " ".join(U[int(c)] for c in b if c.isdigit()) + return spell_int(int(s)) + + +def expand(text): + t = text.replace("%", " por ciento ").replace("º", "") + t = re.sub(r"\d+(?:[.,]\d+)*", _tok, t) + return re.sub(r"\s+", " ", t).strip() diff --git a/scripts/regen.py b/scripts/regen.py new file mode 100644 index 0000000..7ff69a8 --- /dev/null +++ b/scripts/regen.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""Regenera segmentos puntuales probando varias semillas y quedandose con la mejor.""" +import json, os, subprocess, sys, shutil +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from narration import SCRIPTS +from gen_audio import synth_seguro, transcribe, dur, ROOT +from verify import norm +from difflib import SequenceMatcher + +TRIES = 5 + + +def main(name, idxs): + outdir = os.path.join(ROOT, "audio", name) + work = os.path.join(ROOT, "tmp", name + "_rg") + os.makedirs(work, exist_ok=True) + data = json.load(open(os.path.join(outdir, "segments.json"), encoding="utf-8")) + segs = SCRIPTS[name]["segments"] + + for i in idxs: + text = segs[i][0] + cands = [] + for k in range(TRIES): + c = os.path.join(work, f"{i:02d}_{k}.wav") + synth_seguro(text, c, 4242 + 977 * k, f"[{name}] {i:02d}") + cands.append(c) + tr = transcribe(cands, work) + scored = sorted(((SequenceMatcher(None, norm(text, False), + norm(tr[c], False)).ratio(), c) for c in cands), + reverse=True) + for r, c in scored: + print(f" {i:02d} seed{cands.index(c)} r={r:.2f} {dur(c):.2f}s | {tr[c][:70]}") + best_r, best = scored[0] + shutil.copy(best, os.path.join(outdir, f"{i:02d}.wav")) + data["segments"][i].update(tts=text, sub=segs[i][1], + dur=round(dur(best), 3), score=round(best_r, 3)) + print(f" -> {i:02d} elegido r={best_r:.2f}\n") + + data["total_audio"] = round(sum(s["dur"] for s in data["segments"]), 2) + json.dump(data, open(os.path.join(outdir, "segments.json"), "w", encoding="utf-8"), + ensure_ascii=False, indent=2) + print(f"[{name}] actualizado, audio={data['total_audio']}s") + + +if __name__ == "__main__": + main(sys.argv[1], [int(x) for x in sys.argv[2:]]) diff --git a/scripts/repick.py b/scripts/repick.py new file mode 100644 index 0000000..5f75bcf --- /dev/null +++ b/scripts/repick.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""Vuelve a elegir el mejor candidato ya sintetizado, comparando numeros en palabras.""" +import json, os, shutil, sys, wave +from difflib import SequenceMatcher + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +from narration import SCRIPTS +from verify import norm + + +def dur(p): + with wave.open(p) as w: + return w.getnframes() / w.getframerate() + + +def main(name, idxs): + work = os.path.join(ROOT, "tmp", name + "_rg") + outdir = os.path.join(ROOT, "audio", name) + data = json.load(open(os.path.join(outdir, "segments.json"), encoding="utf-8")) + segs = SCRIPTS[name]["segments"] + for i in idxs: + cands = [] + for k in range(10): + w = os.path.join(work, f"{i:02d}_{k}.wav") + t = os.path.join(work, f"{i:02d}_{k}.txt") + if os.path.exists(w) and os.path.exists(t): + got = open(t, encoding="utf-8").read().strip() + r = SequenceMatcher(None, norm(segs[i][0]), norm(got)).ratio() + cands.append((r, k, w, got)) + if not cands: + print(f" {i:02d} sin candidatos"); continue + cands.sort(reverse=True) + for r, k, w, got in cands: + print(f" {i:02d} seed{k} r={r:.2f} {dur(w):.2f}s | {got[:72]}") + r, k, w, got = cands[0] + shutil.copy(w, os.path.join(outdir, f"{i:02d}.wav")) + data["segments"][i].update(tts=segs[i][0], sub=segs[i][1], + dur=round(dur(w), 3), score=round(r, 3)) + print(f" -> {i:02d} elegido seed{k} r={r:.2f}\n") + data["total_audio"] = round(sum(s["dur"] for s in data["segments"]), 2) + json.dump(data, open(os.path.join(outdir, "segments.json"), "w", encoding="utf-8"), + ensure_ascii=False, indent=2) + print(f"[{name}] audio={data['total_audio']}s") + + +if __name__ == "__main__": + main(sys.argv[1], [int(x) for x in sys.argv[2:]]) diff --git a/scripts/verify.py b/scripts/verify.py new file mode 100644 index 0000000..b35eb44 --- /dev/null +++ b/scripts/verify.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Transcribe los WAV finales y compara ignorando la forma de escribir numeros.""" +import json, os, re, subprocess, sys, unicodedata +from difflib import SequenceMatcher + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +from narration import SCRIPTS +from numspell import expand + +NUM = set("""cero uno una dos tres cuatro cinco seis siete ocho nueve diez once doce trece +catorce quince dieciseis diecisiete dieciocho diecinueve veinte veintiuno veintidos veintitres +treinta cuarenta cincuenta sesenta setenta ochenta noventa cien ciento doscientos trescientos +cuatrocientos quinientos mil millon millones coma periodico por ciento y tercio tercios +primero segundo""".split()) + + +def norm(s, drop_numbers=False): + s = expand(s) + s = unicodedata.normalize("NFD", s.lower()) + s = "".join(c for c in s if unicodedata.category(c) != "Mn") + s = re.sub(r"[^a-z0-9ñ ]+", " ", s) + toks = s.split() + if drop_numbers: + toks = [t for t in toks if not t.isdigit() and t not in NUM] + return " ".join(toks) + + +def main(names): + for name in names: + d = json.load(open(os.path.join(ROOT, "audio", name, "segments.json"))) + wavs = [s["wav"] for s in d["segments"]] + work = os.path.join(ROOT, "tmp", name + "_v") + os.makedirs(work, exist_ok=True) + subprocess.run(["conda", "run", "-n", "stt", "--no-capture-output", + "whisper-ctranslate2", "--language", "es", "--model", "small", + "--output_dir", work, "--output_format", "txt", *wavs], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + print(f"\n=== {name} ===") + bad = [] + for s in d["segments"]: + t = os.path.join(work, os.path.splitext(os.path.basename(s["wav"]))[0] + ".txt") + got = open(t, encoding="utf-8").read().strip() if os.path.exists(t) else "" + r = SequenceMatcher(None, norm(s["tts"]), norm(got)).ratio() + flag = "OK " if r >= 0.88 else "REV" + if r < 0.88: + bad.append(s["i"]) + print(f" {s['i']:02d} {flag} {r:.2f} | {got}") + print(f" -> revisar: {bad}") + + +if __name__ == "__main__": + main(sys.argv[1:] or list(SCRIPTS)) diff --git a/scripts/verify_final.py b/scripts/verify_final.py new file mode 100644 index 0000000..7be410b --- /dev/null +++ b/scripts/verify_final.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""QA sobre el MP4 terminado: extrae el audio ya mezclado con la musica, +lo corta segun el timeline y lo transcribe. Prueba que se entiende la voz +por encima de la cama, no solo que el WAV suelto estaba bien.""" +import json, os, subprocess, sys +from difflib import SequenceMatcher + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +from narration import SCRIPTS +from verify import norm +from gen_audio import transcribe + +LIMITE = 0.88 + + +def main(names): + peor = [] + for name in names: + mp4 = os.path.join(ROOT, "out", f"{name}.mp4") + tl = json.load(open(os.path.join(ROOT, "out", f"{name}_timeline.json"))) + segs = json.load(open(os.path.join(ROOT, "audio", name, "segments.json")))["segments"] + work = os.path.join(ROOT, "tmp", name + "_fin") + os.makedirs(work, exist_ok=True) + entero = os.path.join(work, "todo.wav") + subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", mp4, "-vn", + "-ac", "1", "-ar", "16000", entero], check=True) + + trozos = [] + for bt in tl["beats"]: + p = os.path.join(work, f"{bt['i']:02d}.wav") + subprocess.run(["ffmpeg", "-v", "error", "-y", "-