Inicio / Artículos

Un agente de transcripción en vivo y resumen para participantes que se unen tarde con LiveKit Agents

Crea un agente de Python que guarde la transcripción de una llamada, transmita subtítulos en vivo al navegador y resuma de forma privada la reunión para quienes se unan tarde.

Editor: REACTAPP.TOP8 min de lectura1790 palabras

Fuentes: Building an AI agent for transcribing and summarizing audio calls

Ya existen muchos agentes y servicios de IA, así que aquí hay una idea sencilla y concreta: un agente que asiste a las personas durante una llamada de audio. Realiza tres funciones: guarda una transcripción completa de la reunión en un archivo de texto, envía subtítulos en tiempo real a todos los participantes y, cuando alguien se une tarde, le envía un resumen privado de lo que se perdió.

La implementación utiliza un servidor de medios compatible con LiveKit en el equipo local y el framework Python LiveKit Agents para el agente. Juntos se encargan de las partes más complejas del transporte de audio en tiempo real, lo que deja como única tarea escribir las funcionalidades propias del agente.

Las partes y el flujo de datos

Hay tres componentes:

  • Una sala de audio donde las personas y el bot se reúnen, alojada en un servidor local para que no sea necesario registrarse.
  • El agente de transcripción, un pequeño programa en Python que entra a cada sala como participante invisible, convierte el discurso de cada persona en texto y lo guarda.
  • Una pequeña aplicación web, una página sencilla para unirse a una sala, hablar y ver cómo aparece la transcripción.
  • Los datos se transfieren entre ellos de esta manera:

    Ejecutar la demostración

    Se requiere Python 3.10 o una versión más reciente, y para los resúmenes, una cuenta de OpenAI o AWS. La demostración funciona sin cuenta si LLM_PROVIDER permanece vacío: los subtítulos y la transcripción guardada siguen funcionando, solo se pierden los resúmenes de actualización.

    Comience clonando el repositorio:

    git clone https://github.com/openvidu-labs/transcriber-summarizer-agent.git
    cd transcriber-summarizer-agent
    

    Luego, cree un entorno virtual e instale las dependencias:

    python -m venv venv && source venv/bin/activate
    pip install -r requirements.txt
    

    A continuación, edite el archivo .env para seleccionar el proveedor de transcripción (STT_PROVIDER) y el proveedor de resumen (LLM_PROVIDER):

    # STT_PROVIDER: openai | aws | vosk (offline)
    # LLM_PROVIDER: openai | aws | (empty for no summarization)
    STT_PROVIDER=
    LLM_PROVIDER=
    
    # Required when "openai" is selected for STT_PROVIDER or LLM_PROVIDER
    OPENAI_API_KEY=
    # Required when "aws" is selected for STT_PROVIDER or LLM_PROVIDER
    AWS_ACCESS_KEY_ID=
    AWS_SECRET_ACCESS_KEY=
    AWS_DEFAULT_REGION=us-east-1
    

    Ejecuta tres componentes en terminales separadas. El primero es el servidor de medios local, instalado siguiendo sus instrucciones para auto-hospedaje. El segundo es el agente de transcripción:

    python main.py dev
    

    El tercero es la aplicación web:

    python app/server.py
    

    Abra http://localhost:8080, ingrese un nombre y únase; luego hable por un momento y la transcripción comenzará a llenarse. Cargue la página en otra pestaña con un nombre diferente y, poco después, aparecerá un panel amarillo que resume lo dicho en la primera pestaña. El registro completo con marcas de tiempo se guarda en transcripts/.

    Con AWS, las credenciales necesitan permisos para usar Amazon Transcribe y Amazon Bedrock, por ejemplo a través de esta política:

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "TranscriberSummarizer",
                "Effect": "Allow",
                "Action": [
                    "transcribe:StartStreamTranscription",
                    "bedrock:InvokeModel",
                    "bedrock:InvokeModelWithResponseStream"
                ],
                "Resource": "*"
            }
        ]
    }
    

    El punto de entrada del agente

    Un agente de LiveKit es una función que se ejecuta una vez por cada sala. Se registra en un AgentServer y la CLI se encarga del resto:

    from livekit.agents import AgentServer, JobContext, cli
    
    server = AgentServer()
    @server.rtc_session()
    async def entrypoint(ctx: JobContext):
        await ctx.connect()
        room = ctx.room
    if __name__ == "__main__":
        cli.run_app(server)
    

    Tenga en cuenta que @server.rtc_session() no tiene un agent_name. Asignar un nombre al agente hace que LiveKit espere una solicitud de envío explícita. Sin un nombre, el agente se une automáticamente a cada sala nueva, lo cual es adecuado para un transcriptor.

    Conversión de voz a texto detrás de una capa de abstracción

    Para permitir el cambio de proveedores, la elección se oculta detrás de una función sencilla. Cada plugin STT de LiveKit, ya sea Vosk, OpenAI o AWS, ofrece la misma interfaz .stream(); por lo tanto, este es el único lugar dentro del agente donde se especifica el proveedor:

    def make_stt():
        if STT_PROVIDER == "vosk":
            from livekit.plugins import vosk
            return vosk.STT(model_path=VOSK_MODEL_PATH, language="en-US", partial_results=False)
        if STT_PROVIDER == "openai":
            from livekit.plugins import openai
            return openai.STT(model="gpt-4o-mini-transcribe")
        if STT_PROVIDER == "aws":
            from livekit.plugins import aws
            return aws.STT()
        raise ValueError(f"Unknown STT_PROVIDER {STT_PROVIDER!r}")
    

    Las importaciones se encuentran dentro de cada rama, por lo que quien elija AWS instalará únicamente livekit-plugins-aws, ya que las otras ramas nunca se ejecutan. En el caso de Vosk, partial_results=False descarta las conjeturas intermedias de palabras y solo mantiene las oraciones completas.

    Hay un reconocedor compartido por todos los hablantes, y un procesador se activa cuando se suscribe a una pista de audio:

    speech_to_text = make_stt()   # one engine, shared by every speaker
    
    @room.on("track_subscribed")
    def _on_track_subscribed(track, publication, participant):
        if track.kind == rtc.TrackKind.KIND_AUDIO:
            asyncio.create_task(transcribe_track(participant, track))
    

    Cada orador recibe su propio flujo de audio y STT, y dos tareas se ejecutan simultáneamente con asyncio.gather: enviar los frames y leer las transcripciones.

    async def transcribe_track(participant, track):
        audio = rtc.AudioStream(track, sample_rate=16000, num_channels=1)
    
        async with speech_to_text.stream() as stt_stream:
            async def feed_audio():
                async for event in audio:
                    stt_stream.push_frame(event.frame)
                stt_stream.end_input()       # no more audio: let the recognizer finish
            async def emit_transcripts():
                async for event in stt_stream:
                    if event.type == stt_api.SpeechEventType.FINAL_TRANSCRIPT and event.alternatives:
                        text = event.alternatives[0].text.strip()
                        if text:
                            await record_line(participant, track, text)
            await asyncio.gather(feed_audio(), emit_transcripts())
    

    feed_audio envía los frames al reconocedor, mientras que emit_transcripts extrae las oraciones completas, y el bloque async with se encarga de la limpieza. El código reacciona únicamente a los eventos FINAL_TRANSCRIPT, que corresponden a oraciones completas y no a cada conjetura intermedia. Todo lo que recibe se envía a record_line, el cual guarda la línea en disco, la envía al navegador y la memoriza para resúmenes posteriores.

    Grabación de cada línea

    record_line hace permanente una declaración terminada. La agrega al archivo en disco, mantiene una copia en memoria para el resumen y publica el texto en el navegador:

    conversation = []  # in-memory history, used for the summary
    
    async def record_line(participant, track, text):
        speaker = participant.name or participant.identity
        timestamp = datetime.datetime.now().strftime("%H:%M:%S")
        conversation.append(f"{speaker}: {text}")
        with open(transcript_path, "a", encoding="utf-8") as f:
            f.write(f"[{timestamp}] {speaker}: {text}\n")
        # Publish on LiveKit's built-in transcription channel, attributed to the speaker.
        writer = await room.local_participant.stream_text(
            topic=TOPIC_TRANSCRIPTION,                       # "lk.transcription"
            sender_identity=participant.identity,
            attributes={
                ATTRIBUTE_TRANSCRIPTION_FINAL: "true",
                ATTRIBUTE_TRANSCRIPTION_TRACK_ID: track.sid,
                ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID: utils.shortuuid("SG_"),
            },
        )
        await writer.write(text)
        await writer.aclose()
    

    El archivo resultante se parece a una transcripción normal de reunión:

    [14:02:11] Alice: should we ship the release today
    [14:02:15] Bob: yes but let us wait for the tests to pass
    [14:02:20] Alice: agreed lets do it after lunch
    

    El paso de publicación no requiere un canal personalizado. La función de transcripción integrada en LiveKit, el tema reservado lk.transcription, se encarga de ello. Al establecer sender_identity en el orador, cualquier cliente de LiveKit recibe el texto como un segmento de transcripción ordinario y correctamente atribuido.

    Resúmenes para quienes llegan tarde

    Esta es la función más interesante. Cuando alguien se une a una llamada en curso, el agente resume lo que se perdió y se lo envía únicamente a esa persona. LiveKit dispara participant_connected cada vez que alguien llega:

    @room.on("participant_connected")
    def _on_participant_connected(participant):
        asyncio.create_task(summarize_for(participant))
    
        async def summarize_for(participant):
        await asyncio.sleep(2)          # let the newcomer's browser get ready
        if not conversation:
            return                       # nothing said yet, nothing to summarize
    
        summary = await summarize(conversation)
        await room.local_participant.send_text(
            summary,
            topic="summary",
            destination_identities=[participant.identity],
        )
    

    El LLM también se encuentra detrás de una fábrica. Cada LLM de LiveKit expone el mismo .chat(), por lo que summarize() nunca toma ramales diferentes:

    def make_llm():
        model = os.getenv("SUMMARY_MODEL")   # optional override; default per provider
        if LLM_PROVIDER == "openai":
            from livekit.plugins import openai
            return openai.LLM(model=model or "gpt-4.1")
        if LLM_PROVIDER == "aws":
            from livekit.plugins import aws
            return aws.LLM(model=model or "us.amazon.nova-2-lite-v1:0")
        raise ValueError(f"Unknown LLM_PROVIDER {LLM_PROVIDER!r}")
    
    async def summarize(conversation):
        ctx = llm.ChatContext.empty()
        ctx.add_message(role="system", content=SUMMARY_PROMPT)
        ctx.add_message(role="user", content="Transcript so far:\n" + "\n".join(conversation))
        chunks = [c async for c in make_llm().chat(chat_ctx=ctx).to_str_iterable()]
        return "".join(chunks).strip()
    

    SUMMARY_PROMPT es una única línea amigable que solicita un resumen de tres a cuatro oraciones. .to_str_iterable() convierte la respuesta en flujo en fragmentos de texto que se unen, de la misma manera para cada proveedor y sin necesidad de un SDK específico por fabricante.

    Dos detalles contribuyen a que la transmisión sea ordenada. Primero, esperar dos segundos evita conflictos: participant_connected puede activarse justo antes de que el navegador de la nueva persona establezca sus receptores. Segundo, dirigir el mensaje mediante destination_identities asegura que solo esa persona reciba el resumen, dejando tranquilos a quienes ya están conversando.

    Dado que ambos complementos provienen de LiveKit, un mismo proveedor suministra el reconocimiento de voz y el LLM en un único paquete con un conjunto de credenciales. Con openai, una sola OPENAI_API_KEY se encarga tanto de la transcripción como de los resúmenes. Con aws, un par de claves IAM cubre tanto Transcribe como Bedrock.

    También es posible una configuración sin conexión y gratuita: utilice vosk para la conversión de voz a texto y deje LLM_PROVIDER vacío. La transcripción se realizará localmente, y la función de resumen quedará desactivada, siendo reemplazada por la transcripción completa.

    El lado del navegador

    El front end está diseñado de forma sencilla: un archivo HTML, el SDK del navegador LiveKit desde un CDN y algo de JavaScript, sin ningún framework ni herramienta de empaquetado.

    Los navegadores no pueden crear sus propios tokens de acceso, ya que eso expondría la clave de la API a todos los visitantes. Un pequeño servidor de tokens desarrollado con la biblioteca estándar de Python y livekit-api cubre esta necesidad:

    from livekit.api import AccessToken, VideoGrants
    
    token = (
        AccessToken(API_KEY, API_SECRET)
        .with_identity(identity)
        .with_name(name)
        .with_grants(VideoGrants(room_join=True, room=room))
        .to_jwt()
    )
    

    En la página, el proceso de conexión requiere tres llamadas: obtener un token, conectarse y habilitar el micrófono:

    const { token, url } = await (await fetch(`/token?room=${room}&identity=${id}&name=${name}`)).json();
    const room = new LivekitClient.Room();
    await room.connect(url, token);
    await room.localParticipant.setMicrophoneEnabled(true);
    

    Mostrar la transcripción implica suscribirse al flujo de texto reservado lk.transcription de LiveKit. El manejador recibe un lector además de información sobre el remitente, que proporciona el nombre del orador, y un atributo llamado lk.transcription_final que le permite ignorar el texto que aún no está completo:

    room.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
      if (reader.info.attributes?.["lk.transcription_final"] !== "true") return;
      const text = await reader.readAll();
      addLine(nameFor(participantInfo?.identity), text, new Date().toLocaleTimeString());
    });
    

    Dado que este es el canal estándar, el mismo gestor funciona en cualquier cliente de LiveKit. El resumen de lo que se perdió se envía a través de un tema personalizado summary, y cuando llega, la página muestra ese resumen en un cuadro resaltado. Eso es todo lo que necesita el cliente.

    Qué se obtiene al final

    El agente finalizado puede manejar desde chats de dos personas hasta reuniones completas. Escribe una transcripción limpia en el disco, muestra subtítulos en tiempo real y proporciona un resumen a quienes llegan tarde, todo en aproximadamente doscientas líneas. Vosk, OpenAI o AWS pueden alimentarlo, y un único parámetro .env permite alternar entre ellos, ya que ambas partes están encapsuladas en las funciones make_stt() y make_llm(). Los siguientes pasos obvios son almacenar las transcripciones, realizar traducción en tiempo real o convertir los resúmenes en tareas concretas.

    Se mantiene breve porque las partes más complejas ya están implementadas: el transporte de audio en tiempo real, las suscripciones a pistas y las capas de comunicación con los proveedores de IA. Los agentes LiveKit y sus complementos proporcionan los componentes básicos, por lo que el código se centra en la lógica del agente en lugar de en los detalles técnicos.

    Para entornos de producción, una plataforma autohospedada basada en el mismo núcleo compatible con LiveKit permite que el agente funcione sin cambios, mientras la plataforma se encarga del escalado automático, la alta disponibilidad, el reenvío mediante TURN para redes restrictivas, la gestión de grabaciones y la capacidad de observación, todo dentro de su propia infraestructura, con datos privados y costos que no dependen de las tarifas por minuto del modelo SaaS.

    Lecturas relacionadas