From 9bfa0fdac2cac369b903f2a522bf7f881a704f38 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 12 Aug 2026 21:37:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(short):=20la=20voz=20se=20midi=C3=B3=20con?= =?UTF-8?q?=20una=20sola=20frase,=20y=20por=20eso=20el=20bot=20reescrib?= =?UTF-8?q?=C3=ADa=20Shorts=20que=20ya=20cab=C3=ADan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NARRATION_CHARS_PER_SECOND` era 14,2, sacado de una única línea de 82 caracteres. Sintetizando de verdad las 28 líneas que el bot ha escrito hasta hoy — mismo Piper, mismo modelo, mismas banderas deterministas — la voz lee a 18,5 car/s y se calla 0,25 s en cada punto. Contar las frases aparte es lo que arregla el caso raro: "Witness identities. Sensor details. Locations redacted." son tres cuartos de segundo de silencio que un modelo de caracteres a secas regala. El error del modelo viejo era de cuatro a seis segundos sobre un Short entero, siempre por arriba, y con eso el aviso de duración saltaba en vídeos que estaban dentro del objetivo. Contrastado ahora contra los tres MP4 que hay renderizados: 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40 reales. Dos cosas más, del mismo tirón: - Un margen de 1,5 s antes de avisar. La estimación acierta dentro de un segundo por línea, así que medio segundo de exceso puede ser del estimador y no del spec; la sesión 168 se llevó una generación entera por ochocientas milésimas. El objetivo sigue siendo 20-45. - El consejo va en palabras, no en "recorta narración", y señala el plano que más habla. Las tres veces que saltó, el modelo devolvió un spec que seguía pasándose: no sabía cuánto. Los segundos medidos entran en los tests como tabla, no como número redondo. Co-Authored-By: Claude Opus 5 --- docs/shortsmith-phases-4-6.md | 11 ++++ src/generator/spec_contract.py | 99 ++++++++++++++++++++++++++---- tests/test_spec_contract.py | 109 ++++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 13 deletions(-) diff --git a/docs/shortsmith-phases-4-6.md b/docs/shortsmith-phases-4-6.md index 689b8e4..d3c7fb1 100644 --- a/docs/shortsmith-phases-4-6.md +++ b/docs/shortsmith-phases-4-6.md @@ -86,6 +86,17 @@ What actually landed, and where it differs from the plan below: thing that order revealed: with the voice repeating on-screen figures, claims had to be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or every narrated Short would double its own review report. +- **researchowl's estimate of the voice had to be measured, not assumed** (2026-08-12). + It shipped with 14.2 characters per second, taken from a single line, and that + overshot every narration by about a fifth — four to six seconds on a whole Short, + enough to make the spec writer rewrite videos that were already inside the target. + Every generation since narration shipped had spent all three attempts on it. + Synthesizing the 28 narration lines the bot had actually written gave 18.5 char/s + **plus 0.25 s at every full stop**, which is the term that matters: Piper's + `SENTENCE_SILENCE` is per sentence, so "Witness identities. Sensor details. Locations + redacted." costs three quarters of a second that a characters-only model gives away. + Estimates now land within half a second of the three rendered MP4s. The lesson is the + older one restated: a constant taken from one sample is a guess with a decimal point. Original plan, kept for the record: diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 42e35dc..040065a 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -28,6 +28,7 @@ __all__ = [ "validate_spec", "editorial_notes", "estimated_duration", + "spoken_seconds", "describe_templates", "TARGET_MIN_DURATION", "TARGET_MAX_DURATION", @@ -47,6 +48,15 @@ META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") TARGET_MIN_DURATION = 20.0 TARGET_MAX_DURATION = 45.0 +#: Lo que se le perdona al objetivo antes de gastar una reescritura. La +#: estimación de la voz acierta dentro de un segundo por línea, así que un +#: exceso de medio segundo puede ser del estimador y no del spec — y una +#: reescritura cuesta cuatro céntimos y un minuto para ahorrar un segundo que +#: nadie ve. El objetivo sigue siendo 20-45: esto sólo decide cuándo vale la +#: pena decirlo. Sin este margen, la sesión 168 (45,8 s estimados) se llevaba +#: una generación entera por ochocientas milésimas. +TARGET_GRACE = 1.5 + class SpecInvalid(Exception): """El spec no cumple el contrato. `errors` son rutas + motivo, verbatim.""" @@ -321,12 +331,47 @@ def validate_spec(spec: Any, templates: dict[str, dict], raise SpecInvalid(errors) -#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale -#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí -#: lo que shortsmith sabrá exacto al sintetizar. -NARRATION_CHARS_PER_SECOND = 14.2 +#: Caracteres por segundo de la voz, sin contar las pausas. Medido el +#: 2026-08-12 sintetizando de verdad las 28 líneas de narración que el bot ha +#: escrito hasta hoy con el mismo Piper y las mismas banderas que usa shortsmith +#: (`en_US-lessac-medium`, length_scale 1.0, --noise_scale 0 --noise_w 0): +#: 2429 caracteres en 140,91 s de audio. +NARRATION_CHARS_PER_SECOND = 18.5 +#: Piper añade este silencio DESPUÉS DE CADA FRASE, no sólo al final de la +#: línea, y es un valor que shortsmith fija a propósito (`voice.SENTENCE_SILENCE`). +#: Contarlo por separado es lo que arregla el caso raro: "Witness identities. +#: Sensor details. Locations redacted." son tres frases cortas que valen 0,75 s +#: de pausa, y un modelo de caracteres a secas las da por rápidas. +NARRATION_SENTENCE_SILENCE = 0.25 #: El respiro que shortsmith deja tras cada línea antes de permitir el corte. NARRATION_PAD = 0.45 +#: Palabras por segundo de la misma medida (387 palabras en 140,91 s). Sólo se +#: usa para traducir un exceso de segundos a palabras en el aviso: al modelo se +#: le pide que recorte texto, no tiempo. +NARRATION_WORDS_PER_SECOND = 2.75 + +#: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada. +#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás. +_SENTENCE_END = re.compile(r"[.!?](?=\s|$)") + + +def spoken_seconds(line: str) -> float: + """Lo que tarda la voz en decir una línea, sin el respiro final. + + Dos términos porque la voz tiene dos: lee a ritmo casi constante y se calla + un cuarto de segundo en cada punto. La versión anterior sólo tenía el + primero y con un ritmo medido sobre una única frase — 14,2 car/s —, así que + sobreestimaba cada línea alrededor de un 20 %. Sobre un Short entero eso son + de cuatro a seis segundos de duración que no existen, suficientes para que + el bucle de reescritura se disparara con vídeos que estaban dentro del + objetivo. + """ + line = " ".join(line.split()) + if not line: + return 0.0 + sentences = max(1, len(_SENTENCE_END.findall(line))) + return (len(line) / NARRATION_CHARS_PER_SECOND + + sentences * NARRATION_SENTENCE_SILENCE) def estimated_duration(spec: dict) -> float: @@ -336,6 +381,9 @@ def estimated_duration(spec: dict) -> float: si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots, les colgaría narración a todos y recibiría un Short de 55 s sin que nada le hubiera avisado — el aviso llegaría del render, cuando ya está pagado. + + Contrastada contra los tres MP4 que hay renderizados (sesiones 166, 167 y + 168): 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40 reales. """ total = 0.0 for shot in spec.get("shots") or []: @@ -345,8 +393,7 @@ def estimated_duration(spec: dict) -> float: declared = float(declared) if isinstance(declared, (int, float)) else 0.0 narration = shot.get("narration") if isinstance(narration, str) and narration.strip(): - spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD - declared = max(declared, spoken) + declared = max(declared, spoken_seconds(narration) + NARRATION_PAD) total += declared return total @@ -366,18 +413,48 @@ def editorial_notes(spec: dict) -> list[str]: f"({declared:.1f}s de shots)" if stretched else f"la duración total son {total:.1f}s") - if total < TARGET_MIN_DURATION: + if total < TARGET_MIN_DURATION - TARGET_GRACE: notes.append(f"{how} y el objetivo es " f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: " "queda corto, añade un shot o alarga los que tienes") - elif total > TARGET_MAX_DURATION: - fix = ("recorta narración: la voz manda sobre la duración declarada" - if stretched else "recorta shots o acorta duraciones") + elif total > TARGET_MAX_DURATION + TARGET_GRACE: notes.append(f"{how} y el objetivo es " - f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}") + f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: " + + _how_to_trim(spec, total - TARGET_MAX_DURATION, stretched)) return notes +def _how_to_trim(spec: dict, excess: float, stretched: bool) -> str: + """El consejo, en la unidad en la que el modelo puede obedecerlo. + + "Recorta narración" no dice cuánta, y las tres veces que se ha disparado + esto el modelo devolvió un spec que seguía pasándose. Un exceso en segundos + tampoco le sirve, porque no escribe segundos: escribe frases. Así que el + aviso va en palabras y señala DÓNDE están las más largas. + """ + if not stretched: + return (f"sobran {excess:.1f}s: recorta un shot o baja las duraciones " + "declaradas") + + words = max(3, round(excess * NARRATION_WORDS_PER_SECOND)) + advice = (f"sobran {excess:.1f}s, unas {words} palabras de narración — la voz " + "manda sobre la duración declarada, así que acortar los shots no " + "quita ni un segundo") + + spoken = sorted( + ((i, len((s.get("narration") or "").split())) + for i, s in enumerate(spec.get("shots") or []) if isinstance(s, dict)), + key=lambda pair: -pair[1]) + spoken = [pair for pair in spoken if pair[1]] + if not spoken: + return advice + # Sólo las que de verdad son largas: señalar una línea de dos palabras al + # lado de una de veinte convierte el consejo en ruido. + named = [f"shots.{i} ({n} palabras)" + for i, n in spoken[:2] if n * 2 >= spoken[0][1]] + return advice + f"; {'las líneas más largas son' if len(named) > 1 else 'la línea más larga es'} {' y '.join(named)}" + + # --- el contrato en prosa, para el prompt ----------------------------------- def _describe_field(name: str, schema: dict, required: bool, defs: dict, diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index fb1b7ea..180a272 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -283,15 +283,73 @@ def test_an_unknown_shot_key_still_names_the_valid_ones(): assert any("narration" in e for e in errors_of(doc)) +#: Líneas de narración de specs que se renderizaron de verdad, con lo que tarda +#: Piper en decirlas. Medido el 2026-08-12 con el binario, el modelo y las +#: banderas de shortsmith (`en_US-lessac-medium`, length_scale 1.0, +#: --noise_scale 0 --noise_w 0), que son deterministas: estos segundos se +#: reproducen. Se eligieron los extremos del muestreo de 28 líneas — la más +#: rápida, la más lenta y las dos más largas — porque son las que rompen un +#: modelo mal calibrado; la media la aguanta cualquiera. +MEASURED = [ + ("Eight FBI witness interviews. Five digital renderings. All describe the " + "same shape flying across America for twenty-four years.", 7.809), + ("The files are public now, but sections remain blacked out. Witness " + "identities. Sensor details. Locations redacted.", 8.140), + ("Three hundred seventy-eight files released. Hundreds of incidents " + "documented. And the government still cannot explain what those shapes " + "were.", 7.681), + ("The files came out. The numbers stayed classified.", 3.310), + ("Nothing should have been able to hold station beside them up there.", 3.396), + ("The Air Force's own investigators called it unexplained.", 2.990), +] + + +@pytest.mark.parametrize("line,real", MEASURED) +def test_the_estimate_lands_within_a_second_of_the_voice(line, real): + """La estimación es lo único que separa un aviso útil de una reescritura + inventada, así que se contrasta contra audio medido, no contra sí misma. + + El margen es un segundo. Más apretado sería falso — esto estima, no + sintetiza — y más ancho deja de decir nada: el error del modelo anterior + sobre un Short entero era de cuatro a seis segundos, y de ahí salían los + tres intentos que se gastaban en cada generación. + """ + from src.generator.spec_contract import spoken_seconds + + assert spoken_seconds(line) == pytest.approx(real, abs=1.0) + + +def test_a_line_of_short_sentences_is_not_taken_for_fast_prose(): + """Piper calla un cuarto de segundo en cada punto. Cuatro frases cortas son + un segundo de silencio, y contarlas como texto corrido las da por rápidas: + es el caso donde más se equivocaba el modelo de sólo caracteres.""" + from src.generator.spec_contract import spoken_seconds + + chopped = "The files are public now, but sections remain blacked out. " \ + "Witness identities. Sensor details. Locations redacted." + flowing = "The files are public now but sections remain blacked out with " \ + "witness identities sensor details and locations redacted" + + assert len(chopped) < len(flowing) + assert spoken_seconds(chopped) > spoken_seconds(flowing) + + +def test_a_decimal_point_is_not_the_end_of_a_sentence(): + from src.generator.spec_contract import spoken_seconds + + assert spoken_seconds("It climbed to 1.5 miles") == \ + pytest.approx(spoken_seconds("It climbed to 155 miles")) + + def test_the_estimate_counts_the_voice_not_just_the_declared_seconds(): """La duración declarada es un suelo: shortsmith estira el shot si la frase no cabe, y el modelo tiene que enterarse ANTES de pagar el render.""" from src.generator.spec_contract import estimated_duration doc = spec_with(shot(duration=3.0)) - doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz + doc["shots"][0]["narration"] = MEASURED[0][0] # 7,81 s de voz medidos - assert estimated_duration(doc) > 10.0 + assert estimated_duration(doc) > 8.0 def test_a_shot_with_room_for_its_line_is_estimated_as_declared(): @@ -320,6 +378,53 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration(): assert "narración" in note and "estimada" in note +def test_a_second_over_the_target_is_not_worth_a_rewrite(): + """El objetivo sigue siendo 45 s, pero la estimación tiene un segundo de + error por línea: avisar por medio segundo es avisar del estimador. Caso + real — la sesión 168 salió a 45,4 s y se pagó una generación por ello.""" + from src.generator.spec_contract import TARGET_GRACE, TARGET_MAX_DURATION + + justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1)) + pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1)) + + assert editorial_notes(justo) == [] + assert editorial_notes(pasado) + # Y el consejo se mide contra el objetivo, no contra el margen: se pide + # bajar hasta 45, no hasta 46,5. + assert "sobran 1.6s" in editorial_notes(pasado)[0] + + +def test_the_grace_works_at_both_ends(): + from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION + + assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION + - TARGET_GRACE + 0.1))) == [] + assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION + - TARGET_GRACE - 0.1))) + + +def test_the_advice_says_how_much_to_cut_and_from_where(): + """"Recorta narración" no dice cuánta, y las tres veces que saltó este aviso + el modelo devolvió un spec que seguía pasándose. El exceso va en palabras + porque es lo que el modelo escribe, y señalando el plano que más habla.""" + doc = spec_with(shot(duration=4.0), shot(duration=4.0)) + doc["shots"][0]["narration"] = "Short line." + doc["shots"][1]["narration"] = " ".join(["word"] * 200) + + note = editorial_notes(doc)[0] + + assert "palabras de narración" in note + assert "shots.1" in note and "shots.0" not in note + + +def test_the_advice_for_a_silent_spec_never_mentions_narration(): + """Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no + existe: lo que sobra son duraciones declaradas.""" + note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] + + assert "narración" not in note and "duraciones declaradas" in note + + def test_a_spec_without_narration_keeps_the_old_wording(): note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] assert "duración total" in note and "estimada" not in note