1. A necessidade de refinar transcrições brutas
Modelos automáticos de reconhecimento de fala, como o FireRedASR-AED-L, convertem áudio em sequências textuais contínuas. A saída bruta, embora lexicamente precisa, raramente está pronta para uso direto: carece de sinais de pontuação, não separa tópicos e conserva hesitações próprias da fala espontânea.
Esse material precisa passar por uma etapa de pós-processamento para se tornar legível, arquivável ou editável. O objetivo deste guia é construir um pipeline leve, executável localmente, que adicione pontuação e divida o texto em parágrafos coerentes, sem depender de modelos pesados ou treinamentos adicionais.
2. Módulo de restauração de pontuação
A estratégia adotada combina regras linguísticas com uma camada opcional de refinamento por modelo compacto. A regra cobre os casos mais frequentes; o modelo entra em ação apenas quando a confiança da heurística é baixa.
# formatador_prosodico.py
import re
from typing import List
class MarcadorProsodico:
def __init__(self, refinar_com_modelo: bool = False):
self.refinar = refinar_com_modelo
self.interrogativos = [
"qual", "quem", "quando", "onde", "por que", "como",
"pode", "será", "deveria", "está", "foi"
]
self.exclamativos = [
"excelente", "incrível", "urgente", "parabéns", "atenção"
]
self.conectores = [
"então", "portanto", "contudo", "porém",
"além disso", "enfim", "resumindo", "a propósito"
]
self.enchimentos = [
r"\b(éh|ahm|hum|né|tipo|assim|sabe|ó)\b"
]
def aplicar(self, texto: str) -> str:
texto = self._remover_hesitacoes(texto)
fragmentos = self._quebrar_em_fragmentos(texto)
marcados = [self._colocar_sinal(f) for f in fragmentos]
resultado = " ".join(marcados)
return self._refinacao_leve(resultado) if self.refinar else resultado
def _remover_hesitacoes(self, texto: str) -> str:
for padrao in self.enchimentos:
texto = re.sub(padrao, "", texto, flags=re.IGNORECASE)
return re.sub(r"\s+", " ", texto).strip()
def _quebrar_em_fragmentos(self, texto: str) -> List[str]:
padrao = r"(?=\s+(?:então|portanto|contudo|porém|além disso|enfim|resumindo))"
partes = re.split(padrao, texto)
partes = [p.strip() for p in partes if p.strip()]
blocos = []
acumulado = ""
for p in partes:
acumulado = (acumulado + " " + p).strip()
if len(acumulado) > 25 or any(c in acumulado.lower() for c in self.conectores):
blocos.append(acumulado)
acumulado = ""
if acumulado:
blocos.append(acumulado)
return blocos
def _colocar_sinal(self, frase: str) -> str:
frase = frase.strip()
if not frase:
return frase
if any(re.search(rf"\b{p}\b", frase, re.IGNORECASE) for p in self.interrogativos):
return frase + "?"
if any(re.search(rf"\b{p}\b", frase, re.IGNORECASE) for p in self.exclamativos):
return frase + "!"
return frase + "." if len(frase) >= 6 else frase
def _refinacao_leve(self, texto: str) -> str:
# Espaço reservado para um modelo compacto, como um BERT-tiny
# ajustado para previsão de pontuação em português.
return texto
O funcionamento é dividido em três etapas. Primeiro, retiram-se vogais alongadas e outras hesitações comuns na fala espontânea. Em seguida, o texto é particionado em fragmentos próximos às pausas naturais do locutor. Por fim, cada fragmento recebe o sinal adequado com base em palavras-chave de interrogação ou exclamação; fragmentos curtos demais são mantidos sem sinal final para evitar pontuação excessiva.
3. Módulo de segmentação semântica
Dividir o texto em parágrafos não deve seguir apenas um tamanho fixo. A quebra deve respeitar mudanças de tema, mantendo juntas as sentenças que compartilham vocabulário relevante.
# segmentador_contextual.py
import re
from typing import List
class SegmentadorContextual:
def __init__(self, comprimento_maximo: int = 180, limiar_tema: float = 0.55):
self.comprimento_maximo = comprimento_maximo
self.limiar_tema = limiar_tema
self.palavras_vazias = {
"de", "a", "o", "que", "e", "do", "da", "em", "um", "para",
"é", "com", "não", "uma", "os", "no", "se", "na", "por", "mais"
}
def agrupar(self, texto: str) -> List[str]:
sentencas = re.split(r"(?<=[.!?])\s+", texto)
sentencas = [s.strip() for s in sentencas if s.strip()]
paragrafos = []
atual = []
tamanho_atual = 0
for i, sentenca in enumerate(sentencas):
atual.append(sentenca)
tamanho_atual += len(sentenca)
proxima = sentencas[i + 1] if i + 1 < len(sentencas) else ""
deve_quebrar = (
tamanho_atual >= self.comprimento_maximo or
(proxima and self._mudanca_de_tema(sentenca, proxima))
)
if deve_quebrar:
paragrafos.append(" ".join(atual))
atual = []
tamanho_atual = 0
if atual:
paragrafos.append(" ".join(atual))
return paragrafos
def _extrair_termos(self, sentenca: str) -> set:
tokens = re.findall(r"\b[a-zA-Zà-úÀ-Ú]{3,}\b", sentenca.lower())
return set(t for t in tokens if t not in self.palavras_vazias)
def _mudanca_de_tema(self, anterior: str, seguinte: str) -> bool:
termos_antes = self._extrair_termos(anterior)
termos_depois = self._extrair_termos(seguinte)
if not termos_antes or not termos_depois:
return False
intersecao = len(termos_antes & termos_depois)
uniao = len(termos_antes | termos_depois)
similaridade = intersecao / uniao if uniao else 0.0
return similaridade < self.limiar_tema
A lógica verifica duas condições a cada sentença. Se o parágrafo corrente atingir o limite de caracteres, acontece uma quebra forçada para garentir legibilidade. Caso contrário, calcula-se a sobreposição de termos relevantes entre a sentença atual e a seguinte. Quando a similaridade de Jaccard cai abaixo do limiar, entende-se que o tema mudou e inicia-se um novo parágrafo.
4. Integração ao pipeline do FireRedASR
Supondo que o texto reconhecido esteja disponível em uma variável, basta encadear os dois módulos antes de exibir ou exportar o resultado. O exemplo abaixo usa Streamlit como interface mínima.
# fluxo_principal.py
import streamlit as st
from formatador_prosodico import MarcadorProsodico
from segmentador_contextual import SegmentadorContextual
texto_bruto = st.text_area("Transcrição bruta", height=150)
formatador = MarcadorProsodico(refinar_com_modelo=False)
segmentador = SegmentadorContextual(comprimento_maximo=150, limiar_tema=0.55)
if st.button("Refinar transcrição"):
texto_pontuado = formatador.aplicar(texto_bruto)
paragrafos = segmentador.agrupar(texto_pontuado)
texto_final = "\n\n".join(paragrafos)
st.text_area("Resultado estruturado", texto_final, height=300)
st.download_button(
label="Baixar transcrição",
data=texto_final,
file_name="transcricao_otimizada.txt",
mime="text/plain"
)
Para uma entrada como hoje vamos discutir o orçamento do projeto primeiro rever os gastos do trimestre passado houve um excesso de dez por cento depois vamos falar sobre cortes no marketing, o pipeline produz parágrafos claros, com pontuação adequada e separação entre o tema orçamentário e a discussão de cortes.
5. Configurações por tipo de conteúdo
Parâmetros diferentes funcionam melhor para diferentes gêneros de áudio. Reuniões formais, entrevistas e palestras possuem ritmos distintos de fala e mudança de assunto.
# ajustes_por_cenario.py
from formatador_prosodico import MarcadorProsodico
from segmentador_contextual import SegmentadorContextual
configuracoes = {
"reuniao_formal": {
"formatador": MarcadorProsodico(refinar_com_modelo=True),
"segmentador": SegmentadorContextual(
comprimento_maximo=200,
limiar_tema=0.50
)
},
"entrevista": {
"formatador": MarcadorProsodico(refinar_com_modelo=False),
"segmentador": SegmentadorContextual(
comprimento_maximo=100,
limiar_tema=0.75
)
},
"palestra": {
"formatador": MarcadorProsodico(refinar_com_modelo=True),
"segmentador": SegmentadorContextual(
comprimento_maximo=280,
limiar_tema=0.45
)
}
}
def processar(texto: str, cenario: str) -> str:
cfg = configuracoes[cenario]
pontuado = cfg["formatador"].aplicar(texto)
paragrafos = cfg["segmentador"].agrupar(pontuado)
return "\n\n".join(paragrafos)
tipo = st.selectbox("Tipo de conteúdo", list(configuracoes.keys()))
resultado = processar(texto_bruto, tipo)
Em reuniões, o limiar baixo torna o segmentador sensível a mudanças de pauta. Em entrevistas, o limiar alto evita que cada pergunta e resposta sejam fragmentadas em dezenas de parágrafos. Em palestras, parágrafos maiores preservam a continuidade argumentativa do expositor.