Legendas

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:

  1. O arquivo começa com a linha WEBVTT e uma linha em branco.
  2. O separador dos milissegundos é ponto, não vírgula (stamp(x, ".")).
  3. 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 glossary na 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.

Todos os posts