Skip to content

Seguir la letra mientras suena la canción - #40

Merged
Isma-L154 merged 2 commits into
mainfrom
letras-sincronizadas
Sep 12, 2026
Merged

Isma-L154 merged 2 commits into
mainfrom
letras-sincronizadas

Conversation

@Isma-L154

Copy link
Copy Markdown
Owner

!lyrics respondía con la canción entera de golpe — Rap God son 7.936
caracteres y llegaban en tres mensajes. Y era estática: no decía nada de por
dónde iba la canción.

Ahora es un solo mensaje que sigue la música, estilo karaoke.

427 tests (antes 411).

Cómo se ve

🎤 BbY WOW — KAROL G
 ¿Terapia pa qué? ¿Pa qué sacarte del pecho
 Si ni queriendo yo me olvido de ti?
**▶ ¿Arreglo de qué? Yo quiero estar pecho a pecho**
 Como la noche en la que te conocí
 Hoy te vi, las ganas siguen ahí
[0:20 / 3:46]

El mismo mensaje se va editando solo mientras suena, y al pasar a la siguiente
canción carga su letra y sigue
. Se detiene con el botón ⏹, con !stop, al
vaciarse la cola o si el player muere.

De dónde salen los tiempos

Genius no tiene marcas de tiempo, así que no puede hacer esto.
LRCLIB sí: gratis, sin API key, cuerpos LRC con marcas
[mm:ss.xx]. Lo comprobé contra la música que este bot reproduce de verdad
antes de construir nada
— KAROL G, Bad Bunny, Queen y Eminem, todas
sincronizadas.

Las consultas mandan también la duración, que es lo que evita emparejar con
una versión en directo cuyos tiempos no cuadran con el audio que suena. Genius se
queda como respaldo para lo que LRCLIB no tenga.

Rendimiento

Dos decisiones tomadas por la caja pequeña donde corre:

  • No sondea. El seguidor calcula cuándo empieza la línea siguiente y duerme
    hasta ahí, con tope de 2 s para notar pausas y saltos. Medido en el servidor:
    0,018 s de CPU en 12 segundos siguiendo la canción = 0,15% de un núcleo.
  • Sin caché. Llegué a diseñarla y la quité: el seguidor solo recarga cuando
    player.current cambia de identidad, y loop track reproduce el mismo
    objeto. Nunca se habría leído.

Las ediciones van limitadas — Discord permite unas 5 por 5 s por canal y una
canción rápida cambia de línea más a menudo. El primer umbral que probé, 2,5 s,
lo cazó un test como incorrecto
: al estar por encima del tope de despertar de
2 s, retrasaba cada cambio de línea normal un despertar entero, cosa que en una
letra se ve a simple vista. Quedó en 1,5 s, y hay un test que afirma que las dos
constantes mantienen ese orden.

Los efectos de velocidad habrían roto la sincronía

nightcore va a 1,25x mientras elapsed cuenta reloj de pared: la canción
avanza un cuarto más rápido que el reloj y la letra se iría desviando más cada
minuto. Así que Effect gana un campo rate, el player expone una position
que lo tiene en cuenta, y —porque un rate declarado que no case con su filtro
es peor que ninguno— un test lo mide contra FFmpeg real en vez de fiarse del
número:

assert speed_factor(effect.filter, 44100) == pytest.approx(effect.rate, rel=0.05)

El camino estático también se arregla

Es a donde caen las canciones sin tiempos, así que importa: un mensaje con
botones ◀ ▶
en lugar de varios. Y las páginas ahora cortan entre líneas —
la versión anterior troceaba cada 4000 caracteres exactos y partía palabras por
la mitad.

Dos bugs que salieron al probar contra lo real

  • lyrics_api.fetch recibía un argumento loop, y el cog le pasaba
    self.bot.loop — que lanza excepción si el gateway aún no está levantado.
    El parámetro no hacía falta: dentro de una corutina get_running_loop() ya es
    la respuesta. Eliminado.
  • Un payload puede traer tiempos y no traer copia en texto plano, lo que
    habría pintado una página vacía en !lyrics <busqueda>. Ahora las palabras se
    derivan de las líneas con marca.

Tests

El parser y el renderizador son funciones puras y cargan con los casos
incómodos: varias marcas en una línea ([00:10][01:20] estribillo), metadatos
[ar:], marcas desordenadas, y un hueco instrumental que debe seguir siendo un
hueco
en vez de dejar la línea anterior puesta durante todo el break.

El seguidor va con un reloj guionizado, así que sus tests corren en microsegundos
y ninguno toca Discord ni la red.

Cómo probarlo

pytest       # 427

Verificado de extremo a extremo en el servidor contra el LRCLIB real: 69 líneas
para BbY WOW, la ventana correcta en cada posición, el respaldo de Genius
funcionando sin gateway, el seguidor parándose solo al destruir el player, y
ninguna sesión de aiohttp ni tarea pendiente al cerrar.

En Discord, tras desplegar:

  1. !play <algo con letra> → !lyrics — un mensaje que se mueve con la canción.
  2. Deja que termine y entre la siguiente — el mismo mensaje debe cambiar de letra.
  3. !nightcore mientras sigue — la letra debe seguir cuadrando, no adelantarse.
  4. Pulsa ⏹ — deja de moverse y se queda la última ventana.
  5. !lyrics Rap God — un mensaje con ◀ ▶, no tres seguidos.
  6. !lyrics zxqwvasd — "Couldn't find lyrics", sin traza.
  7. !stop con el seguidor activo — debe pararse solo en menos de 2 s.

El diseño está en docs/superpowers/specs/2026-09-12-synced-lyrics-design.md.

`!lyrics` answered with the whole song at once, which for a long track meant
several messages in a row — `Rap God` is 7,936 characters and arrived as three.
It was also static: it said nothing about where the song actually was.

It is now one message that follows the music. The line being sung is shown with
two lines of context either side, and the same message keeps editing itself as
the song moves and as the queue advances. A Stop button ends it, and so does
`!stop`, an empty queue, or the player being destroyed.

Genius has no timestamps, so it cannot do this. LRCLIB can: free, no API key,
LRC bodies with `[mm:ss.xx]` marks. It was checked against the music this bot
actually plays before anything was built — KAROL G, Bad Bunny, Queen and Eminem
all came back synced. Lookups send the duration too, which is what keeps a
query from matching a live version whose timings are wrong for the audio
playing. Genius stays as the fallback for what LRCLIB does not have.

Two decisions were made for the sake of the small host it runs on:

- **It does not poll.** The follower works out when the next line begins and
  sleeps until then, capped at two seconds so a pause or a skip is noticed
  promptly. Measured on the server: 0.018s of CPU over twelve seconds of
  following, which is 0.15% of one core.
- **There is no cache.** One was drafted and dropped: the follower reloads only
  when `player.current` changes identity, and `loop track` replays the same
  dict, so it would never have been read.

Edits are throttled — Discord allows about five per five seconds per channel and
a fast song changes lines more often than that. The first threshold tried was
2.5s, which a test caught as wrong: above the two-second wakeup cap, it delayed
ordinary line changes by a whole wakeup, plainly visible on lyrics. It is 1.5s,
and a test now asserts the two constants stay in that order.

The pitch effects would have broken the sync outright. `nightcore` plays at
1.25x while `elapsed` counts wall-clock, so the song runs a quarter ahead of the
clock and the lyrics would drift further out every minute. `Effect` gains a
`rate`, the player exposes a `position` that accounts for it, and — because a
declared rate that disagrees with its filter is worse than none — a test
measures each rate against real FFmpeg rather than trusting the number.

The static path is fixed too, since it is what songs without timings fall back
to. It is one message with page buttons instead of several, and pages now break
between lines: the old version sliced at a fixed character count and cut words
in half.

Two bugs turned up while testing against the real thing:

- `lyrics_api.fetch` took a `loop` argument, and the cog was passing
  `self.bot.loop` — which raises if the gateway is not up yet. The parameter was
  never needed; inside a coroutine `get_running_loop()` is the answer. Removed.
- A payload can carry timings and no plain copy, which would have rendered an
  empty page for `!lyrics <search>`. The words are now derived from the timed
  lines when that happens.

The parser and the renderer are pure functions and carry the awkward cases:
several timestamps on one line, `[ar:]` metadata, out-of-order marks, an
instrumental gap that must stay a gap rather than leave the previous line up
through the whole break. The follower is driven by a scripted clock, so its
tests run in microseconds and none of them touch Discord or the network.

Verified end to end on the server against the real LRCLIB: 69 lines for
BbY WOW, the right window at every position, the Genius fallback working with no
gateway, the follower stopping on destroy, and no aiohttp session or pending
task left behind.

Tests: 427, up from 411.
@Isma-L154
Isma-L154 merged commit ef4064a into main Sep 12, 2026
3 checks passed
@Isma-L154
Isma-L154 deleted the letras-sincronizadas branch September 12, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant