Podcast

Como transcrever podcast por API, do RSS ao acervo inteiro

O enclosure do feed já é uma URL pública, então o podcast é o caso mais fácil de transcrever. O trabalho está no acervo antigo, no nome dos convidados e no que fazer com o texto.

Podcast é o áudio mais fácil de transcrever por API, e por um motivo bobo: o feed RSS já entrega uma URL pública para cada episódio. Não tem upload, não tem bucket, não tem arquivo local. O enclosure do item é exatamente o que o campo url da requisição espera.

curl https://api.transcrevo.com/v1/transcripts \
  -H "Authorization: Bearer $TRANSCREVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://cdn.exemplo.com/ep-142.mp3",
    "speakers": true,
    "language": "pt",
    "glossary": { "gatu": "Gatto", "bertual": "Bertuol" },
    "webhookUrl": "https://seu-servidor.com/hooks/transcrevo"
  }'

O resto do post é sobre o que fazer em volta disso: o acervo antigo, os nomes próprios e o texto que sai.

O acervo antigo, num laço

Um podcast semanal de três anos tem uns 150 episódios. A quarenta minutos cada, são 100 horas de áudio. A US$ 0,03 por hora, US$ 3,00 para o acervo inteiro. Com separação de falantes, US$ 5,00.

O laço é direto, e o único cuidado é o teto de 120 criações por minuto:

const API = "https://api.transcrevo.com";
const headers = {
	Authorization: `Bearer ${process.env.TRANSCREVO_API_KEY}`,
	"Content-Type": "application/json",
};

for (const episode of episodes) {
	const res = await fetch(`${API}/v1/transcripts`, {
		method: "POST",
		// A chave de idempotência é o id do episódio: repetir o script não paga de novo.
		headers: { ...headers, "Idempotency-Key": `ep-${episode.guid}` },
		body: JSON.stringify({
			url: episode.enclosure,
			speakers: true,
			language: "pt",
			glossary: episode.glossary,
			webhookUrl: "https://seu-servidor.com/hooks/transcrevo",
		}),
	});

	if (res.status === 429) {
		await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 5) * 1000));
		continue;
	}

	const { data } = await res.json();
	await save(episode.guid, data.id);
}

O Idempotency-Key é o que separa um script que você pode rodar de novo de um script que cobra duas vezes. Mesma chave, mesmo corpo, devolve a mesma transcrição, quantas vezes você repetir. Chave repetida com corpo diferente devolve 409 idempotency_conflict, que é o aviso de que a chave foi reaproveitada por engano.

O 429 rate_limited traz Retry-After com a espera real em segundos. Um acervo inteiro destravando de uma vez encosta no teto com facilidade; espalhar a rajada pela espera é mais simples que qualquer fila. Os limites estão em preços e limites, e os códigos em erros.

O nome dos convidados é onde o texto erra

Nome próprio, marca e anglicismo são o erro mais comum de qualquer sistema de transcrição. Não é falha específica: são as palavras que menos aparecem em qualquer treino. E podcast é feito disso. Todo episódio tem um convidado com sobrenome que o sistema nunca viu, uma empresa citada quinze vezes, um termo do nicho que só existe naquele meio.

Você sabe disso antes de mandar o áudio. O campo glossary é a correção, e ela vale só para aquele episódio:

{
	"url": "https://cdn.exemplo.com/ep-142.mp3",
	"glossary": {
		"mote gruto": "Mateus Gruto",
		"cubernetes": "Kubernetes",
		"fintéquis": "fintechs"
	}
}

Até 100 entradas, de até 80 caracteres cada. A correção vale por sequência de palavras, então trocar duas palavras por duas funciona, e o intervalo de tempo é repartido entre as palavras trocadas. Na prática, o glossário de um podcast é duas listas: uma fixa, com os nomes dos apresentadores e do programa, e uma por episódio, com o convidado da vez. A referência completa está em transcrições.

O que fazer com o transcrito

O texto corrido serve para a página do episódio e para busca. O que vale mais está em words, que traz cada palavra com start e end em segundos decimais.

  • Busca dentro do episódio. Indexar as palavras com o timestamp permite que o resultado da busca leve o ouvinte para o minuto certo, e não para o episódio inteiro. É a diferença entre "este episódio fala sobre isso" e "fala sobre isso aos 34min12".
  • Corte para clipe. O trecho que virou clipe de trinta segundos é um par de timestamps. O erro mediano do timestamp de palavra fica entre 69 e 86 ms, então cortar na palavra é seguro.
  • Capítulo. Uma pausa longa entre turnos costuma ser troca de assunto. Não é perfeito, mas é um rascunho melhor que a página em branco.

O que não confiar sem revisão

Publicar transcrito sem ler é decisão de custo, e ela precisa de números. Os nossos: 6,34% de WER em 2.000 trechos de fala espontânea brasileira, medido contra transcrição humana. Em cem palavras, seis erradas. Para busca e para descoberta, isso não atrapalha. Para publicar como texto oficial do episódio, alguém lê.

Dois casos específicos de podcast:

  • Música de fundo às vezes vira texto. Vinheta cantada e trilha com letra são transcritas como fala. Se o seu programa tem abertura musical, os primeiros segundos do transcrito vão precisar de corte.
  • Fala sobreposta recebe um rótulo só. Apresentador e convidado rindo por cima um do outro viram um falante. A diarização tem DER de 14,8% nessa fatia, contra 4,6% no geral. Podcast de duas pessoas com microfone individual é o cenário bom; mesa de quatro pessoas num microfone só é o cenário ruim. O detalhamento está em precisão e qualidade, e o que a separação de falantes faz e não faz está em o que é diarização.

Episódio novo, no dia da publicação

Quem publica semanalmente não roda script de acervo: roda um gancho. O feed atualiza, você pega o enclosure do item novo e cria a transcrição com webhookUrl. Quando o aviso chega, você busca o transcrito uma vez pelo id e publica junto com o episódio.

O corpo do webhook traz id, status, durationSeconds e cost, mas não traz text nem words: num episódio de duas horas esses campos passam de 10 MB, e webhook não é lugar de trafegar isso. Cada entrega vem assinada no header Transcrevo-Signature, com o segredo de webhook da conta (whsec_...), que o painel mostra e rotaciona sem mexer nas chaves de API.

Um episódio de quarenta minutos custa US$ 0,03 com falantes, ou US$ 0,02 sem. É menos que o armazenamento do próprio mp3.

Todos os posts