Construye un agente de desarrollo .NET, Parte 2: dale voz

La primera vez que mi agente de desarrollo me respondió en voz alta, tardó sesenta y ocho segundos en decirme que habían fallado cuatro tests.

La respuesta era correcta, y completa: los cuatro nombres de test leídos enteros, AddedLines_reports_new_file_numbers_across_hunks incluido, cada uno con su número de línea y su valor esperado, y al final un párrafo ofreciendo revisar el parser. En pantalla, esa respuesta se lee de un vistazo. Con audífonos, es un minuto entero quieto, esperando la única frase que importaba, que llegó al final: todos los fallos estaban desfasados por uno.

Una onda de audio larga en cuatro tramos apagados, con la única frase útil encendida justo al final.

Conectar dictado y síntesis de voz a un agente lleva una tarde. Este post trata de lo que pasa después de esa tarde, cuando descubres dos cosas. Una respuesta escrita para leerse tiene la forma equivocada para escucharse. Y la transcripción que recibe tu agente se equivoca más o menos en una de cada dos palabras. Los dos son problemas de diseño, no de audio, y todo lo que sigue está medido sobre el agente de la Parte 1.

Dónde dejó la Parte 1 al agente

Construye un agente de desarrollo .NET, Parte 1: enséñale tu flujo de trabajo real montó tres herramientas tipadas sobre un ciclo de tool_use de Claude: git_status, dotnet_build y dotnet_test, cada una filtrando la salida de su comando hasta dejar solo lo que un modelo necesita. Las tres pasan tal cual, con una excepción: el método RunAsync de la Parte 1 tenía un bug que solo aparece en una máquina con dos SDKs de .NET, y escribir este post lo destapó. La Parte 1 ya tiene la corrección; los detalles están cerca del final.

Este es el ciclo completo:

  1. Mantienes una tecla, hablas, sueltas.
  2. Un Whisper local convierte el audio en texto.
  3. El agente trabaja con las herramientas de la Parte 1 y una nueva.
  4. Una voz de Piper, también local, lee la respuesta, y lo que no conviene leer va a la pantalla.

Todo lo que toca audio corre en tu máquina, con modelos de código abierto, y no necesitas cuenta en ningún servicio salvo el de Claude.

Dos contenedores

El dictado lo hace whisper-server, de whisper.cpp: recibe un WAV por HTTP y devuelve JSON. La síntesis la hace Piper, cuyo servidor HTTP recibe JSON y devuelve un WAV. Los dos corren en CPU.

services: whisper: # v1.9.5. La imagen se etiqueta por commit; ":main" cambia todos los días. image: ghcr.io/ggml-org/whisper.cpp:main-d1be6fde11ac6e0407606b4e42fe72d34add8037 # El entrypoint de la imagen es `bash -c`, que solo ejecuta su PRIMER argumento. Un # texto plano se parte en palabras: cada flag después de "whisper-server" se pierde en # silencio, y arranca con el modelo que trae la imagen, escuchando en 127.0.0.1. # Una lista de un solo elemento mantiene la línea de comando entera como ese argumento. command: ["whisper-server --host 0.0.0.0 --port 8080 --model /models/ggml-${WHISPER_MODEL:-tiny.en}.bin --threads 4"] volumes: - ./models:/models:ro ports: - "127.0.0.1:8080:8080" piper: build: ./piper ports: - "127.0.0.1:5000:5000" 

Piper no tiene una imagen oficial con su servidor HTTP, así que le toca un piper/Dockerfile de cinco líneas. La voz se descarga al construir la imagen, para que el contenedor arranque sin red:

FROM python:3.12-slim RUN pip install --no-cache-dir "piper-tts[http]==1.8.0" WORKDIR /voices # La voz queda dentro de la imagen, así el contenedor arranca sin red. RUN python -m piper.download_voices en_US-lessac-medium --data-dir /voices EXPOSE 5000 CMD ["python", "-m", "piper.http_server", "--host", "0.0.0.0", "--port", "5000", "--data-dir", "/voices", "-m", "en_US-lessac-medium"] 

El modelo de Whisper es un archivo que descargas una vez en models/:

curl -L -o models/ggml-tiny.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin docker compose up -d 

El comentario sobre command: está ahí porque me costó una hora. La imagen de whisper.cpp usa bash -c como entrypoint, y bash -c solo ejecuta su primer argumento. Si escribes el comando como texto plano, Compose lo parte en palabras: bash -c recibe whisper-server como script y el resto de los flags como parámetros que nadie lee. El servidor arranca igual, con el modelo que trae la imagen y escuchando en 127.0.0.1, donde nada fuera del contenedor puede alcanzarlo. Si lo escribes como texto de varias líneas, falla de forma más discreta: el salto de línea termina el comando, así que el servidor escucha donde le pediste, pero carga el modelo de la imagen. Mi primera prueba pidió large-v3-turbo, recibió base.en, y pasé un buen rato culpando a mi acento. La lista de un solo elemento mantiene la línea de comando entera.

Fija también la imagen por commit. :main se reconstruye a diario, y la etiqueta de arriba es la que se construyó desde la versión v1.9.5.

Captura: pulsar para hablar, en el formato que pide Whisper

Del lado de .NET hace falta una sola dependencia nativa para el audio. PortAudioSharp2 trae los binarios de PortAudio para Windows, Linux y macOS dentro del paquete, así que no hay nada que instalar:

dotnet new console -n VoiceDevAgent dotnet add package Anthropic --version 12.57.0 dotnet add package PortAudioSharp2 --version 1.0.6 

La grabación es a 16 kHz, mono y 16 bits, exactamente lo que consume Whisper, así que nadie remuestrea nada. PortAudio entrega el audio desde su propio hilo mediante un callback, y ese callback tiene un único trabajo: copiar las muestras a algún lado y volver, sin esperar nunca al resto del programa. Un Channel<T> es el lugar indicado, porque escribir en uno sin límite nunca bloquea.

using System.Buffers.Binary; using System.Runtime.InteropServices; using System.Threading.Channels; using PortAudioSharp; using PaStream = PortAudioSharp.Stream; /// <summary>Captura al pulsar para hablar: 16 kHz, mono, 16 bits, el formato que pide Whisper, así nadie remuestrea.</summary> sealed class Microphone { public const int SampleRate = 16_000; public static async Task<byte[]> RecordAsync(Func<CancellationToken, Task> waitForStop, CancellationToken ct) { // El callback corre en el hilo de audio de PortAudio. Nunca debe esperar a nada de lo // que hace el resto del programa, así que solo copia las muestras a un canal sin // límite, cuyas escrituras nunca bloquean. Channel<short[]> chunks = Channel.CreateUnbounded<short[]>(new() { SingleReader = true, SingleWriter = true }); PaStream.Callback callback = (IntPtr input, IntPtr output, uint frameCount, ref StreamCallbackTimeInfo timeInfo, StreamCallbackFlags flags, IntPtr userData) => { short[] chunk = new short[frameCount]; Marshal.Copy(input, chunk, 0, (int)frameCount); chunks.Writer.TryWrite(chunk); return StreamCallbackResult.Continue; }; int device = PortAudio.DefaultInputDevice; if (device == PortAudio.NoDevice) throw new InvalidOperationException("No hay dispositivo de entrada."); StreamParameters parameters = new() { device = device, channelCount = 1, sampleFormat = SampleFormat.Int16, suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency, hostApiSpecificStreamInfo = IntPtr.Zero, }; using (PaStream stream = new(parameters, null, SampleRate, 0, StreamFlags.ClipOff, callback, null!)) { stream.Start(); try { await waitForStop(ct); } finally { stream.Stop(); // espera a que termine el último callback chunks.Writer.Complete(); } } List<short> samples = []; await foreach (short[] chunk in chunks.Reader.ReadAllAsync(CancellationToken.None)) samples.AddRange(chunk); return Wav.Encode([.. samples], SampleRate); } } /// <summary>Reproduce PCM mono de 16 bits y se detiene en cuanto se cancela el token.</summary> static class Speaker { static readonly short[] Silence = new short[4096]; public static async Task PlayAsync(byte[] wav, CancellationToken ct) { (short[] samples, int sampleRate) = Wav.Decode(wav); int position = 0; TaskCompletionSource finished = new(TaskCreationOptions.RunContinuationsAsynchronously); PaStream.Callback callback = (IntPtr input, IntPtr output, uint frameCount, ref StreamCallbackTimeInfo timeInfo, StreamCallbackFlags flags, IntPtr userData) => { int remaining = samples.Length - position; int count = Math.Min(remaining, (int)frameCount); if (count > 0) Marshal.Copy(samples, position, output, count); // Rellena el último búfer con silencio en vez de reproducir memoria vieja, // sin asignar memoria en el hilo de audio. for (int padded = count; padded < frameCount; ) { int n = Math.Min(Silence.Length, (int)frameCount - padded); Marshal.Copy(Silence, 0, output + padded * sizeof(short), n); padded += n; } position += count; return position >= samples.Length ? StreamCallbackResult.Complete : StreamCallbackResult.Continue; }; int device = PortAudio.DefaultOutputDevice; if (device == PortAudio.NoDevice) throw new InvalidOperationException("No hay dispositivo de salida."); StreamParameters parameters = new() { device = device, channelCount = 1, sampleFormat = SampleFormat.Int16, suggestedLatency = PortAudio.GetDeviceInfo(device).defaultHighOutputLatency, hostApiSpecificStreamInfo = IntPtr.Zero, }; using PaStream stream = new(null, parameters, sampleRate, 0, StreamFlags.ClipOff, callback, null!); stream.SetFinishedCallback(_ => finished.TrySetResult()); stream.Start(); try { await finished.Task.WaitAsync(ct); } catch (OperationCanceledException) { // Interrupción: descarta el audio del búfer en vez de reproducirlo hasta el final. // El callback de fin igual se dispara después; completar una tarea ya abandonada // no tiene efecto. stream.Abort(); } } } static class Wav { public static byte[] Encode(short[] samples, int sampleRate) { byte[] wav = new byte[44 + samples.Length * 2]; Span<byte> h = wav; "RIFF"u8.CopyTo(h); BinaryPrimitives.WriteInt32LittleEndian(h[4..], wav.Length - 8); "WAVEfmt "u8.CopyTo(h[8..]); BinaryPrimitives.WriteInt32LittleEndian(h[16..], 16); // tamaño del chunk fmt BinaryPrimitives.WriteInt16LittleEndian(h[20..], 1); // PCM BinaryPrimitives.WriteInt16LittleEndian(h[22..], 1); // mono BinaryPrimitives.WriteInt32LittleEndian(h[24..], sampleRate); BinaryPrimitives.WriteInt32LittleEndian(h[28..], sampleRate * 2); // bytes por segundo BinaryPrimitives.WriteInt16LittleEndian(h[32..], 2); // alineación de bloque BinaryPrimitives.WriteInt16LittleEndian(h[34..], 16); // bits por muestra "data"u8.CopyTo(h[36..]); BinaryPrimitives.WriteInt32LittleEndian(h[40..], samples.Length * 2); MemoryMarshal.AsBytes(samples.AsSpan()).CopyTo(h[44..]); return wav; } /// <summary>Recorre los chunks en vez de suponer una cabecera de 44 bytes: la de Piper lo es, otras no.</summary> public static (short[] Samples, int SampleRate) Decode(byte[] wav) { ReadOnlySpan<byte> s = wav; if (!s[..4].SequenceEqual("RIFF"u8) || !s[8..12].SequenceEqual("WAVE"u8)) throw new InvalidDataException("No es un archivo RIFF/WAVE."); int sampleRate = 0, offset = 12; while (offset + 8 <= s.Length) { ReadOnlySpan<byte> id = s.Slice(offset, 4); // Quien escribe en streaming pone aquí 0xFFFFFFFF ("desconocido"): toma el resto. uint declared = BinaryPrimitives.ReadUInt32LittleEndian(s[(offset + 4)..]); int size = (int)Math.Min(declared, (uint)(s.Length - offset - 8)); ReadOnlySpan<byte> body = s.Slice(offset + 8, size); if (id.SequenceEqual("fmt "u8)) { if (body.Length < 16) throw new InvalidDataException("Chunk fmt demasiado corto."); short channels = BinaryPrimitives.ReadInt16LittleEndian(body[2..]); short bits = BinaryPrimitives.ReadInt16LittleEndian(body[14..]); if (channels != 1 || bits != 16) throw new InvalidDataException($"Se esperaba mono de 16 bits; llegó {bits} bits x{channels}."); sampleRate = BinaryPrimitives.ReadInt32LittleEndian(body[4..]); } else if (id.SequenceEqual("data"u8)) { if (sampleRate == 0) throw new InvalidDataException("Chunk data antes del chunk fmt."); return (MemoryMarshal.Cast<byte, short>(body).ToArray(), sampleRate); } offset += 8 + size + (size & 1); // los chunks se alinean a 2 bytes } throw new InvalidDataException("No hay chunk data."); } public static TimeSpan Duration(byte[] wav) { (short[] samples, int rate) = Decode(wav); return TimeSpan.FromSeconds((double)samples.Length / rate); } } 

Tres detalles de ese archivo merecen una frase. stream.Stop() espera a que termine el último callback, así que el canal puede cerrarse justo después sin perder el último bloque. El callback de captura sigue asignando un arreglo pequeño por cada búfer. .NET no da garantías de tiempo real de ninguna forma; si las necesitas, graba en un búfer circular preasignado. La reproducción rellena su último búfer con un bloque de silencio preasignado. Y no hay GC.KeepAlive sobre los callbacks porque PortAudioSharp2 fija cada delegado con un GCHandle mientras vive el stream. Si escribes tu propio binding de PortAudio, fijarlos te toca a ti, o el siguiente callback salta a memoria liberada.

Speaker.PlayAsync recibe un token de cancelación para poder interrumpir (barge-in): cuando ya oíste suficiente, Enter detiene el audio con Abort(), que descarta lo que quedaba en el búfer en lugar de reproducirlo hasta el final.

Por qué pulsar para hablar y no escuchar siempre

Un micrófono siempre abierto parece más amable, y para esta herramienta es peor en tres aspectos que se pueden medir:

  • Se oye a sí mismo. Con los altavoces de la laptop, la respuesta vuelve directo al micrófono y llega como una pregunta nueva.
  • Whisper falla en los bordes del habla. Con mis ocho frases grabadas, tiny.en devolvió [BLANK_AUDIO] para dos que se oían perfectamente, y base.en convirtió una de ellas, una pregunta sobre CS1061, en "Peace. See you in the next video. Bye." Para ser justos con Whisper, se entrenó con mucho YouTube. Un micrófono siempre abierto le entrega muchos más bordes que una tecla.
  • Pierdes los límites. Al pulsar dices ahora te hablo a ti, y al soltar dices terminé. Escuchando siempre, necesitas detección de actividad de voz para adivinar las dos cosas, y cada error de esa detección es un turno.

Por eso el arnés filtra las marcas de silencio de Whisper antes de que lleguen al modelo; lo verás más abajo en VoiceAgent.IsBlank. Pulsar la tecla sin querer no cuesta nada ni ejecuta nada.

Dictado: mal en una de cada dos palabras

Los clientes HTTP son cortos:

using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; /// <summary>whisper-server de whisper.cpp: POST /inference, multipart, devuelve {"text": "..."}.</summary> sealed class WhisperClient(HttpClient http) { public async Task<string> TranscribeAsync(byte[] wav, string? vocabulary, string? language, CancellationToken ct) { using MultipartFormDataContent form = new(); ByteArrayContent file = new(wav); file.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); form.Add(file, "file", "speech.wav"); form.Add(new StringContent("json"), "response_format"); form.Add(new StringContent("0.0"), "temperature"); if (vocabulary is not null) form.Add(new StringContent(vocabulary), "prompt"); if (language is not null) form.Add(new StringContent(language), "language"); using HttpResponseMessage response = await http.PostAsync("/inference", form, ct); response.EnsureSuccessStatusCode(); WhisperResult result = await response.Content.ReadFromJsonAsync<WhisperResult>(ct) ?? throw new InvalidDataException("whisper-server devolvió una respuesta vacía."); return result.Text.Trim(); } sealed record WhisperResult([Source:https://dev.to/jgomezdev/construye-un-agente-de-desarrollo-net-parte-2-dale-voz-53an

Related posts