Motion por código com agente|INEMA.CLUB-PRO

Aula 7 · 18 min

Áudio e legenda no código: narração local, trilha e karaokê

O mesmo CLI que renderiza também fala e escuta, tudo na sua máquina: gera a narração com uma voz local, transcreve palavra por palavra com o tempo de cada uma e entrega isso à composição. Nesta aula você monta o lado técnico do som: narração, uma trilha de ensaio e a legenda karaokê que acende cada palavra na hora em que ela é dita.

Começar a aula
Mesa de edição de som com microfone de estúdio em braço articulado, fones fechados e um monitor com forma de onda.

Nesta aula você vai

  1. 1tts: texto em WAV com a voz Kokoro, sem nuvem
  2. 2transcribe: o tempo de cada palavra num transcript.json
  3. 3<audio> com id, data-start e data-volume na composição
  4. 4Legenda de até três palavras que acende no tempo da fala

Objetivo: gerar narração local, tirar o tempo de cada palavra e renderizar um vídeo com áudio e legenda karaokê.

01Do texto ao vídeo com som

Clique em cada etapa. Nada sai da máquina.

Clique em cada bloco do diagrama para ver o que ele faz.

Voz, trilha e mixagem como escolha criativa: curso 2 (Produção). Aqui, só como pôr o som no código.

02Narração e tempo de cada palavra

Uma frase, a voz local em português e a transcrição com tempo.

Terminal · tts, duração e transcrição
K=~/projetos/formacao-cinema-ia-motion-v72/kit
export HYPERFRAMES_NO_TELEMETRY=1 NO_COLOR=1
E=${TMPDIR:-/tmp}/ensaio-motion/a7; rm -rf "$E"; mkdir -p "$E"
cp -r "$K/exemplos/a7-karaoke" "$E/karaoke"; cd "$E/karaoke"
TEXTO="A voz sai do seu computador, e cada palavra sabe a hora de aparecer."
npx -y hyperframes@0.8.176 tts "$TEXTO" --voice pf_dora --lang pt-br -o narracao.wav > tts.log 2>&1; echo "tts: $?"
ffprobe -v error -show_entries format=duration -of csv=p=0 narracao.wav
npx -y hyperframes@0.8.176 transcribe narracao.wav -m small -l pt --json > transcricao.log 2>/dev/null; echo "transcribe: $?"
python3 -c "import json; print('palavras:', len(json.load(open('transcript.json'))))"
Exemplo do repositório
tts: 0
4.074667
transcribe: 0
palavras: 14

A segunda linha é a duração da narração em segundos. O número de palavras confere o transcript, não o texto: confira as palavras abrindo o transcript.json.

03A composição com som e karaokê

Título, a narração começando em 0,5 s, uma trilha de ensaio a 25% e as legendas geradas do transcript.

Arquivo · kit/exemplos/a7-karaoke/index.html
<!doctype html>
<html lang="pt-BR">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="legendas.js"></script>
    <style>
      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body { width: 1920px; height: 1080px; overflow: hidden; background: #0c0c10; }
      #root { width: 100%; height: 100%; position: relative; font-family: ui-sans-serif, system-ui, sans-serif; }
      #luz { position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 40%, #1c1c22 0%, #0c0c10 70%); }
      #titulo { position: absolute; left: 0; right: 0; top: 300px; text-align: center; color: #f4f4f5; font-size: 110px; font-weight: 700; line-height: 1.1; }
      .legenda { position: absolute; left: 0; right: 0; top: 820px; text-align: center; font-size: 70px; font-weight: 700; line-height: 1.2; color: #a1a1aa; opacity: 0; }
      .legenda span { margin: 0 12px; }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="8" data-width="1920" data-height="1080">
      <div id="luz"></div>
      <h1 id="titulo">Som no código</h1>
      <audio id="narracao" src="narracao.wav" data-start="0.5" data-track-index="1"></audio>
      <audio id="trilha" src="trilha.wav" data-start="0" data-duration="8" data-volume="0.25" data-track-index="2"></audio>
    </div>
    <script>
      // legendas.js (gerado pelo karaoke.py) traz cada legenda com o tempo de cada palavra
      const root = document.getElementById("root");
      const tl = gsap.timeline({ paused: true });
      tl.fromTo("#titulo", { y: 30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
      window.LEGENDAS.forEach((linha, i) => {
        const el = document.createElement("p");
        el.className = "legenda";
        el.id = "legenda-" + i;
        linha.palavras.forEach((p, j) => {
          const s = document.createElement("span");
          s.id = `palavra-${i}-${j}`;
          s.textContent = p.t;
          el.appendChild(s);
          tl.set(s, { color: "#f4f4f5" }, p.inicio);   // a palavra acende quando é dita
        });
        root.appendChild(el);
        tl.set(el, { opacity: 1 }, linha.inicio);
        tl.set(el, { opacity: 0 }, linha.fim);
      });
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
Arquivo · kit/karaoke.py
#!/usr/bin/env python3
"""karaoke.py — transforma o transcript.json (palavra + tempo) em legendas.js para a composição.

Uso:  python3 karaoke.py transcript.json --inicio 0.5 --por-linha 3 > legendas.js

--inicio     segundos em que a narração começa no vídeo (o data-start do <audio>)
--por-linha  palavras por legenda (padrão 3)
Imprime na saída de erro: "palavras: N · legendas: M".
"""
import argparse
import json
import sys


def main():
    a = argparse.ArgumentParser()
    a.add_argument("transcript")
    a.add_argument("--inicio", type=float, default=0.0)
    a.add_argument("--por-linha", type=int, default=3)
    o = a.parse_args()
    palavras = json.load(open(o.transcript, encoding="utf-8"))
    linhas = []
    for i in range(0, len(palavras), o.por_linha):
        grupo = palavras[i:i + o.por_linha]
        linhas.append({
            "inicio": round(grupo[0]["start"] + o.inicio, 3),
            "fim": round(grupo[-1]["end"] + o.inicio, 3),
            "palavras": [{"t": p["text"], "inicio": round(p["start"] + o.inicio, 3)} for p in grupo],
        })
    # cada legenda sai 0,15 s depois da última palavra, mas nunca depois que a próxima entra
    for atual, prox in zip(linhas, linhas[1:] + [None]):
        atual["fim"] = round(min(atual["fim"] + 0.15, prox["inicio"]) if prox else atual["fim"] + 0.15, 3)
    print("window.LEGENDAS = " + json.dumps(linhas, ensure_ascii=False) + ";")
    print(f"palavras: {len(palavras)} · legendas: {len(linhas)}", file=sys.stderr)


if __name__ == "__main__":
    main()
Terminal · trilha, legendas, render e conferir
K=~/projetos/formacao-cinema-ia-motion-v72/kit
export HYPERFRAMES_NO_TELEMETRY=1 NO_COLOR=1
cd ${TMPDIR:-/tmp}/ensaio-motion/a7/karaoke
# trilha de ensaio: um acorde suave de 8 s gerado no ffmpeg (a sua trilha de verdade entra no lugar)
ffmpeg -v error -y -f lavfi -i "aevalsrc=0.10*sin(2*PI*220*t)+0.07*sin(2*PI*277.18*t)+0.06*sin(2*PI*329.63*t):s=48000:d=8" \
  -af "afade=t=in:d=1,afade=t=out:st=6.5:d=1.5" trilha.wav
python3 "$K/karaoke.py" transcript.json --inicio 0.5 --por-linha 3 > legendas.js
npx -y hyperframes@0.8.176 check 2>&1 | grep -E "Check passed|✗"
npx -y hyperframes@0.8.176 render --quality draft --workers 2 --quiet --output karaoke.mp4 > render.log 2>&1
echo "render: $?"
python3 "$K/conferir.py" karaoke.mp4 8 1920x1080 --audio; echo "saída: $?"
Exemplo do repositório
palavras: 14 · legendas: 5
◇  Check passed
render: 0
OK: 8,0 s · 1920×1080 · 30 fps · com áudio
saída: 0

O conferidor com --audio reprova vídeo sem faixa de som. A trilha é só um acorde gerado pelo FFmpeg para o ensaio; a sua entra no lugar do trilha.wav.

04Prática

Voz, karaokê e a sua frase.

E1 · Voz e palavras

10 min

Rode o bloco "tts, duração e transcrição". O primeiro uso baixa a voz e o modelo.

Pronto quando: tts: 0, uma linha com a duração (número maior que 0), transcribe: 0 e palavras: seguido de um número maior que 0.

E2 · Vídeo com som

10 min

Rode o bloco "trilha, legendas, render e conferir" e assista ao karaoke.mp4.

Pronto quando: ◇ Check passed, render: 0 e OK: 8,0 s · 1920×1080 · 30 fps · com áudio com saída: 0.

E3 · A sua frase

15 min

Troque TEXTO por uma frase sua de até 5 s, rode os dois blocos de novo (o segundo depois do primeiro) e confira o vídeo.

Pronto quando: python3 ~/projetos/formacao-cinema-ia-motion-v72/kit/conferir.py karaoke.mp4 8 1920x1080 --audio; echo "saída: $?" → linha terminando em com áudio e saída: 0.

05Teste da aula

A explicação aparece depois da sua resposta.

Pergunta 1 de 4

O render saiu mudo, mas o <audio> está na composição. O que conferir primeiro?

06Em três frases

Localtts (Kokoro) e transcribe (whisper.cpp) rodam na sua máquina, pelo mesmo CLI.
Tempo por palavraO transcript.json vira legendas.js antes do render, com o atraso da narração somado.
Áudio no código<audio> com id e data-start; data-volume para a trilha; o conferidor cobra a faixa.

Para ir além

Aula anteriorPróxima aula