Skip to content
Tutorials8 min read

Anotar transcripciones de ASR: un ejemplo resuelto

Un recorrido completo desde una carpeta de salida de Whisper hasta turnos de hablante etiquetados: elegir la unidad de anotación, resolver la diarización, escribir la configuración, poner la tarea en marcha y exportar con la alineación temporal intacta.

Potato Team

Este recorrido sigue un proyecto de principio a fin: 40 entrevistas de investigación grabadas, ya transcritas con Whisper, que hay que codificar por tema y por quién dijo qué. Es la versión concreta de las dos guías de referencia, con cada decisión tomada en lugar de descrita.

Si lo que quieres es el detalle formato por formato, eso está en Formatos de transcripción.

De una carpeta de salida de ASR a turnos etiquetados, en cuatro pasosComprueba qué has conservado, decide quién etiqueta los hablantes, construye la configuración y exporta con los tiempos

Paso 0: mira qué tienes en realidad

Antes que nada, averigua qué hay en la carpeta. Lleva diez segundos y ahorra un día:

bash
potato transcripts ./whisper_out --dry-run
text
Scanned 40 file(s):
  interview_01.json      Whisper JSON      42 turns    891.4s  undiarized
  interview_02.json      Whisper JSON      51 turns   1120.8s  undiarized
  interview_03.txt       plain text         1 turns      0.0s  undiarized
  ...

40 item(s), 1683 turn(s).

Dos cosas que se leen ahí. Todos los archivos están sin diarizar, así que nada en este corpus sabe quién habla. Y interview_03.txt ha entrado como un único turno de duración cero, porque un .txt de Whisper contiene texto y nada más. No hay tiempos dentro que recuperar.

A ese tercer archivo hay que encontrarle su .json, o volver a pasar el audio. Nada de lo que venga después lo va a arreglar.

Paso 1: elige la unidad de anotación antes que las etiquetas

La cuestión de la unidad decide más sobre tus cifras de acuerdo que el propio conjunto de etiquetas.

Los segmentos de Whisper tienen aproximadamente el tamaño de un enunciado, y cortan en las pausas y no en nada gramatical. Para codificar entrevistas esa suele ser la unidad correcta: la respuesta de un entrevistado llega repartida en varios segmentos, y codificar cada uno por separado te da un registro más fino que codificar la respuesta entera de una vez.

Donde esto se tuerce es con los subtítulos automáticos de una plataforma de vídeo, donde las fronteras de cada rótulo caen justo donde se llenó la caja de subtítulos. Pedir a los anotadores que valoren «cada frase» sobre rótulos así produce desacuerdo sobre dónde están las frases, no sobre lo que querías medir. Si esa es tu entrada, consulta Cómo anotar subtítulos de YouTube.

Aquí los segmentos se pueden usar tal cual, así que la unidad es el turno.

Paso 2: decide quién asigna los hablantes

Whisper no diariza. Tres opciones, y esta es una decisión de verdad y no un trámite:

Vuelve a pasarlo con WhisperX. Automático, rápido y equivocado en el habla solapada con la frecuencia suficiente como para que alguien tenga que revisarlo igualmente.

bash
whisperx interview_01.mp3 --model medium --diarize --output_format json

Usa una API en la nube con la diarización activada. Deepgram con diarize=true, AssemblyAI con speaker_labels, AWS Transcribe o Rev.ai. Potato lee los cuatro de forma nativa.

Deja que los anotadores asignen los hablantes mientras escuchan. Para 40 entrevistas de dos personas, esta es la opción que elegiríamos. Dos hablantes con papeles claramente distintos es el caso fácil para una persona y no siempre el caso fácil para un modelo, y el anotador va a escuchar el audio de todas formas.

Nos quedamos con la tercera. Los turnos sin diarizar se muestran como Unassigned con un selector, y la asignación se guarda junto con las anotaciones.

Turnos de transcripción con fondo gris rayado, cada uno etiquetado como Unassigned con un desplegableLos turnos sin diarizar llegan como Unassigned, con un selector en cada uno

Paso 3: construye el archivo de datos

bash
potato transcripts ./whisper_out --media-dir ./audio -o data/interviews.json

Las transcripciones se emparejan con su audio por el nombre base, así que interview_01.json encuentra interview_01.mp3. El nombrado doble de Whisper, interview_01.mp3.json, está contemplado, y el identificador del elemento sale como interview_01.

El resultado:

json
{
  "id": "interview_01",
  "conversation": {
    "audio": "audio/interview_01.mp3",
    "turns": [
      {"turn_id": "t0", "speaker": null, "start": 0.0, "end": 6.5,
       "text": "So I want to start with how the team was structured."}
    ]
  }
}

Puedes saltarte este paso por completo si prefieres mantener las transcripciones como archivos. Un archivo de datos puede apuntar directamente a ellas, y Potato las lee y las normaliza al mostrarlas:

json
{"id": "interview_01", "conversation": {"audio": "audio/interview_01.mp3",
                                        "transcript": "whisper_out/interview_01.json"}}

Paso 4: escribe la configuración

yaml
annotation_task_name: "Interview Coding"
task_dir: .
data_files:
  - data/interviews.json
 
item_properties:
  id_key: id
  text_key: conversation
 
instance_display:
  fields:
    - key: conversation
      type: audio_dialogue
      label: "Interview"
      span_target: true
      display_options:
        show_timestamps: true
        scroll_height: 520px
        allow_speaker_assignment: auto
        speakers:
          - id: interviewer
            name: "Interviewer"
            color: "#7c3aed"
            side: left
          - id: participant
            name: "Participant"
            color: "#059669"
            side: right
 
annotation_schemes:
  - annotation_type: radio
    name: turn_topic
    description: "What is this turn about?"
    labels: [structure, workload, tooling, morale, other]
 
  - annotation_type: span
    name: quotes
    description: "Highlight anything quotable in the writeup"
    labels:
      - name: quotable
        key_value: "q"

Aquí hay tres cosas haciendo trabajo.

La lista de hablantes le da a los dos papeles nombres, colores y lados estables antes de que nadie haya asignado un solo turno. Sin ella, los hablantes siguen recibiendo colores, pero se asignan de forma determinista dentro de cada transcripción y no de forma consistente en todo el corpus.

turn_level: true junto con turn_binding engancha la pregunta de tema a cada turno en lugar de a la entrevista entera. Eso es lo que convierte al turno en la unidad de anotación en la práctica.

span_target: true más el esquema span permite que un resaltado cruce las fronteras entre turnos, lo que importa cuando el pasaje citable abarca una pregunta y su respuesta. Los desplazamientos se mantienen estables cuando se reasigna un hablante.

Ponlo en marcha:

bash
python potato/flask_server.py start config.yaml -p 8000

Turnos de transcripción mostrados como burbujas de hablante de colores, con botones de reproducción por turno y una pregunta de etiquetado en líneaCada turno tiene un botón de reproducción que reproduce solo ese turno, más su propia pregunta de etiquetado

Cada burbuja tiene un botón de reproducción que reproduce solo ese turno y se para. En la práctica esa es la función que comentan los anotadores: comprobar una línea concreta contra el audio deja de ser un ejercicio de rebobinar a mano.

Paso 5: mide el acuerdo sobre lo que toca

Dos anotadores por entrevista, y ahora tienes dos tipos de acuerdo que mirar.

Las etiquetas de tema son acuerdo categórico corriente a nivel de turno. Como los identificadores de turno son deterministas, el mismo archivo produce siempre los mismos identificadores, y las etiquetas de los dos anotadores encajan sin ningún paso de alineación.

La asignación de hablantes merece una comprobación aparte. Si tus dos anotadores discrepan sobre quién habla en el 15 % de los turnos, eso es una señal sobre el audio, y significa que la diarización automática se habría equivocado al menos con esa frecuencia sin decírtelo.

Consulta La concordancia entre anotadores para las medidas, y Acuerdo para tramos para los resaltados, que necesitan otro tratamiento porque los anotadores eligen también las fronteras, y no solo las etiquetas.

Paso 6: exporta

JSON, JSONL y CSV estándar funcionan como siempre. Cuando quieras que la alineación temporal sobreviva hasta una herramienta de análisis del habla, exporta las anotaciones por capas a ELAN o Praat:

bash
python -m potato.export --config config.yaml --format eaf --output ./out/
python -m potato.export --config config.yaml --format textgrid --output ./out/

Los dos hacen el viaje de ida y vuelta, porque Potato lee EAF y TextGrid también como entrada. Anota aquí, refina en ELAN y vuelve a leer el resultado.

Las cuatro cosas que salen mal

Alguien conservó el .txt. Sin tiempos, no recuperable, hay que volver a pasarlo. --dry-run lo detecta antes de que hayas construido nada encima.

Los tiempos están 1000 veces mal. Algo, antes en la cadena, mezcló segundos y milisegundos. Whisper y Deepgram emiten segundos en coma flotante; AssemblyAI, los desplazamientos de whisper.cpp y el TSV de Whisper emiten milisegundos enteros.

Esquemas a nivel de frase sobre una entrada basada en rótulos. Ya visto arriba, y el más caro de los cuatro porque no te enteras hasta que calculas el acuerdo.

Fiarte de una diarización que nadie revisó. Un error de diarización se propaga a todas las etiquetas enganchadas a ese turno, y parece desacuerdo entre anotadores cuando vas a buscar la causa.

Lecturas adicionales