Como gerar legenda automática em SRT a partir do áudio
A API devolve cada palavra com início e fim em segundos. Este post mostra o código que transforma isso em SRT e em VTT, e onde a legenda automática costuma sair errada.
Legenda automática não é um formato de saída da transcrição: é o que você faz
com os timestamps dela. A API devolve cada palavra com start e end em
segundos decimais, e SRT é um arquivo de texto com regras simples. O trabalho
inteiro é agrupar palavras em blocos e imprimir.
O que vale discutir é o agrupamento. É ele que decide se a legenda fica legível ou se ela pisca na tela.
O que a API devolve
"words": [
{ "start": 0.32, "end": 0.78, "text": "Bom", "speaker": null },
{ "start": 0.78, "end": 1.04, "text": "dia", "speaker": null },
{ "start": 1.04, "end": 1.51, "text": "a", "speaker": null },
{ "start": 1.51, "end": 2.06, "text": "todos.", "speaker": null }
]start e end são segundos, em decimal: 1.51 é um segundo e meio, não 1510
milissegundos. Esse detalhe já derrubou integração de gente experiente, porque
boa parte das bibliotecas de legenda espera milissegundos inteiros. Multiplique
por 1000 antes de formatar.
O restante da resposta está documentado em transcrições.
As regras que decidem a legibilidade
Nenhuma regra de legenda é lei, mas as convenções que quase todo player e plataforma tolera bem são estas quatro:
- No máximo duas linhas por bloco, com cerca de 42 caracteres cada.
- Cada bloco na tela por 1 a 7 segundos.
- Quebrar o bloco quando há uma pausa perceptível entre palavras, algo em torno de 0,4 segundo.
- Quebrar também depois de ponto final, interrogação ou exclamação.
Fala espontânea brasileira encosta nesses limites o tempo todo, porque a velocidade varia muito dentro do mesmo áudio. É por isso que a pausa vale mais que a contagem de caracteres: cortar no silêncio produz bloco que o espectador lê inteiro, cortar no meio de um sintagma produz bloco que ele relê.
O código
Este é o gerador completo, sem dependência:
type Word = { start: number; end: number; text: string; speaker: string | null };
const MAX_CHARS = 84;
const MAX_SECONDS = 7;
const PAUSE = 0.4;
function group(words: Word[]): Word[][] {
const blocks: Word[][] = [];
let current: Word[] = [];
for (const word of words) {
const previous = current.at(-1);
const tooLong = current.reduce((n, w) => n + w.text.length + 1, 0) + word.text.length > MAX_CHARS;
const tooSlow = current.length > 0 && word.end - current[0].start > MAX_SECONDS;
const paused = previous !== undefined && word.start - previous.end > PAUSE;
const ended = previous !== undefined && /[.!?]$/.test(previous.text);
if (current.length > 0 && (tooLong || tooSlow || paused || ended)) {
blocks.push(current);
current = [];
}
current.push(word);
}
if (current.length > 0) blocks.push(current);
return blocks;
}
function stamp(seconds: number, separator: string) {
const ms = Math.round(seconds * 1000);
const pad = (n: number, size = 2) => String(n).padStart(size, "0");
return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)}${separator}${pad(ms % 1000, 3)}`;
}
function wrap(text: string) {
if (text.length <= MAX_CHARS / 2) return text;
const middle = text.lastIndexOf(" ", Math.floor(text.length / 2));
return middle === -1 ? text : `${text.slice(0, middle)}\n${text.slice(middle + 1)}`;
}
export function toSrt(words: Word[]) {
return group(words)
.map((block, index) => {
const text = wrap(block.map((w) => w.text).join(" "));
return `${index + 1}\n${stamp(block[0].start, ",")} --> ${stamp(block.at(-1)!.end, ",")}\n${text}\n`;
})
.join("\n");
}Saída:
1
00:00:00,320 --> 00:00:02,060
Bom dia a todos.
2
00:00:02,440 --> 00:00:03,370
Vamos começar.VTT muda três coisas
WebVTT é o formato que o elemento track do HTML consome nativamente. A partir
do mesmo agrupamento:
- O arquivo começa com a linha
WEBVTTe uma linha em branco. - O separador dos milissegundos é ponto, não vírgula (
stamp(x, ".")). - O número sequencial do bloco é opcional.
export function toVtt(words: Word[]) {
const cues = group(words).map((block) => {
const text = wrap(block.map((w) => w.text).join(" "));
return `${stamp(block[0].start, ".")} --> ${stamp(block.at(-1)!.end, ".")}\n${text}\n`;
});
return `WEBVTT\n\n${cues.join("\n")}`;
}Legenda que identifica quem fala
Pedindo speakers na criação, cada palavra volta com um rótulo ("A", "B").
Duas mudanças no agrupamento: quebrar o bloco sempre que o rótulo muda, e
prefixar a linha com o nome.
const paused = previous !== undefined && (word.start - previous.end > PAUSE || word.speaker !== previous.speaker);Os rótulos são anônimos: "A" é "a primeira voz", não uma pessoa. Amarrar
rótulo a nome vem de fora do áudio, e o critério mais confiável costuma ser a
ordem de fala combinada com quem você sabe que estava na gravação. Se isso é
central no seu produto, vale ler
o que é diarização e quando você precisa dela
antes, porque a separação erra mais em turno curto.
Onde a legenda automática erra
O timestamp em si é bom: o erro mediano contra referência externa fica entre 69 e 86 ms, que é cerca de dois quadros a 24 fps. Ninguém percebe.
O que se percebe é a cauda. Num podcast, 1,8% das palavras saem com mais de 1 segundo de desvio; num vlog com música de fundo, 3,1%. Uma legenda por bloco de sete segundos dilui isso, mas num bloco curto o desvio aparece como legenda adiantada ou atrasada. Os números por fatia estão em precisão e qualidade.
Três casos que valem conhecer antes de publicar legenda sem revisão:
- Música de fundo às vezes vira texto. A letra é transcrita como fala. Em vídeo com trilha cantada, revise.
- Fala sobreposta recebe um rótulo só. Duas pessoas falando ao mesmo tempo viram um falante na legenda.
- Nome próprio e marca são o erro mais comum. Corrija com
glossaryna requisição, passando o que o sistema ouve e o que deve escrever. É a maior melhora de qualidade por linha de código escrita.
O que isso custa
US$ 0,03 por hora de áudio, proporcional ao segundo. Um vídeo de 12 minutos sai por US$ 0,006. Legendar um canal inteiro com 300 vídeos de 10 minutos dá 50 horas, US$ 1,50. Com identificação de falante, US$ 2,50. A tabela completa está em preços e limites.
Não existe um endpoint que devolva SRT pronto, e é de propósito: quebra de linha e duração de bloco são decisão editorial de quem publica, e todo produto que já legendou de verdade acabou querendo a sua. As 40 linhas acima são o formato; o resto é sua regra.