The first time someone asked me for "just a voice notes button," I almost reached for a separate speech SDK, with its own HttpClient, its own retry policy and its own way of faking things in tests. Then I noticed that Microsoft.Extensions.AI 10.10 already has a speech-to-text abstraction that works like the IChatClient I was already using.
That made the plumbing easy. What actually took my time was a short list of design questions that the API doesn't answer for you. Here are the six I'd want settled before a speech endpoint goes to production. Each one comes with the shape of the code, not all of it. The project I built while working through them targets .NET 10, builds with zero warnings, and has 28 xUnit tests that pass with no API key.
The complete project, with the API, the CLI demo, the offline fake and all the tests, is in Tech Skill Builder.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
1. Do I need a separate speech SDK?
No. The contract is ISpeechToTextClient, and it has two methods that matter:
Task<SpeechToTextResponse> GetTextAsync(
Stream audioSpeechStream,
SpeechToTextOptions? options = null,
CancellationToken cancellationToken = default);
IAsyncEnumerable<SpeechToTextResponseUpdate> GetStreamingTextAsync(
Stream audioSpeechStream,
SpeechToTextOptions? options = null,
CancellationToken cancellationToken = default);
There are also extension overloads that take a DataContent when the bytes are already in memory. You register it the way you'd register a chat client: services.AddSpeechToTextClient(...) returns a SpeechToTextClientBuilder, and you add ConfigureOptions, UseLogging and UseOpenTelemetry to it.
One thing to know up front: the speech types are experimental and raise diagnostic MEAI001. I suppress it in Directory.Build.props on purpose and leave a note saying why. A blanket #pragma buried in some random file would make that decision invisible.
2. Batch or streaming?
Ask what the screen needs. "Upload a memo and email me the text" is GetTextAsync: one request, one SpeechToTextResponse with Text, ResponseId, ModelId and optional StartTime/EndTime. Live captions need GetStreamingTextAsync, which yields updates tagged with a SpeechToTextResponseUpdateKind: SessionOpen, TextUpdating, TextUpdated, SessionClose and Error.
If you stream to a browser, send update.Kind.Value. Those values are lowercase strings (sessionopen, textupdating and so on), and your front end should match on those. I write NDJSON, one JSON object per line, and flush after every update so partial text shows up right away:
await foreach (var update in service.TranscribeStreamingAsync(request, ct))
{
var payload = new { kind = update.Kind.Value, text = update.Text, responseId = update.ResponseId };
await JsonSerializer.SerializeAsync(response.Body, payload, cancellationToken: ct);
await response.Body.WriteAsync("\n"u8.ToArray(), ct);
await response.Body.FlushAsync(ct);
}
// validation (400) and size (413) handling omitted here; full endpoint in the complete project
My advice: start with batch. Streaming brings reconnects and half-finished transcripts, and you don't need those until a real screen asks for partial text.
3. Where does the size limit go?
Speech providers usually bill by audio duration, and multipart uploads lie about their size. So I put a hard cap in a DelegatingSpeechToTextClient that reads the stream into a buffer and stops as soon as it goes over the limit:
public sealed class AudioSizeLimitingSpeechToTextClient : DelegatingSpeechToTextClient
{
public AudioSizeLimitingSpeechToTextClient(ISpeechToTextClient innerClient, int maxAudioBytes)
: base(innerClient) => MaxAudioBytes = maxAudioBytes;
public int MaxAudioBytes { get; }
public override async Task<SpeechToTextResponse> GetTextAsync(
Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default)
{
await using var limited = await BufferWithLimitAsync(audioSpeechStream, cancellationToken);
return await base.GetTextAsync(limited, options, cancellationToken);
}
// GetStreamingTextAsync follows the same buffer-then-forward path.
// BufferWithLimitAsync copies in chunks and throws AudioTooLargeException past the cap.
// ...full implementation in the complete project
}
The part that's easy to get wrong is the order. On the builder, the first Use is the outermost. I didn't want to trust my memory on this, so I wrote a two-middleware console app against MEAI 10.10, and the call ran first -> second -> inner. If you want oversized uploads rejected before logging, telemetry or the provider see a single byte, register the guard first:
ISpeechToTextClient client = inner
.AsBuilder()
.Use(c => new AudioSizeLimitingSpeechToTextClient(c, maxAudioBytes: 5 * 1024 * 1024)) // outermost
.ConfigureOptions(o => o.SpeechLanguage ??= "en-US")
.UseLogging(loggerFactory)
.Build();
Then map AudioTooLargeException to 413 in the endpoint, and a content type that isn't on the allow-list to 400. A 500 for "you uploaded a podcast" just turns into a support ticket saying "the AI is down."
4. Who owns the stream and the options?
The docs are clear on two points that cause real bugs:
- The client doesn't dispose your audio stream. You opened it, so you dispose it. Keep that in mind when middleware wraps the stream in something else.
-
Middleware may mutate the
SpeechToTextOptionsyou pass in.ConfigureOptionsdoes exactly that. A singleton options object shared across concurrent requests is a race waiting to happen.
So build options per call: either a fresh SpeechToTextOptions with the request's language and model, or defaults.Clone() with those fields set. And keep EnableSensitiveData off on the OpenTelemetry client in production. Log the byte length, model ID and language, never the audio.
5. How do I test it without an API key?
This is the question that decides whether CI stays honest. I use an OfflineSpeechToTextClient together with a ScriptedWav helper that writes a tiny silent WAV with the expected transcript stored in a comment chunk. The fake reads that comment back, so a test knows exactly what text it should get:
[Fact]
public async Task Rejects_Over_Limit()
{
using var inner = new OfflineSpeechToTextClient();
using var limited = new AudioSizeLimitingSpeechToTextClient(inner, maxAudioBytes: 32);
await using var stream = new MemoryStream(ScriptedWav.Create("this will be too large for thirty two bytes"));
await Assert.ThrowsAsync<AudioTooLargeException>(() => limited.GetTextAsync(stream));
Assert.Equal(0, inner.CallCount); // the provider never saw it
}
The streaming mode of the fake emits word-by-word updates, which is enough to check the NDJSON endpoint end to end. Here's what the CLI's stream command printed for the phrase "alpha beta gamma":
[sessionopen]
[textupdating] alpha
[textupdating] alpha beta
[textupdated] alpha beta gamma
[sessionclose]
6. What changes when I switch to a real provider?
If the first five answers are right, almost nothing. With OpenAI, the inner client comes from the audio client. This is the documented bridge; my CI never calls it, because the tests run on the offline fake:
ISpeechToTextClient speech = new OpenAIClient(apiKey)
.GetAudioClient("gpt-4o-transcribe")
.AsISpeechToTextClient();
You change the factory in DI, and the endpoints, validators and size guard stay the same. Before going live I'd still raise the HttpClient timeouts (audio is slower than chat), confirm the model ID your provider expects, and decide whether SpeechLanguage is a hint or a hard requirement. Keep the offline fake too. It's your regression net when the vendor changes its error format.
The short version
- One abstraction, the same builder habits as chat.
- Start with batch. Stream only when a screen needs partial text.
- Size guard first in the pipeline, 413 for too big, 400 for the wrong type.
- Per-call options. You own the stream.
- An offline fake in CI, the real provider behind one factory.
Get the complete project
The snippets above show the shape. The working version is in Tech Skill Builder: the Core library with the offline client, the scripted WAV helper and the size guard, a minimal API with multipart upload and NDJSON streaming, a CLI demo, FluentValidation rules and all 28 xUnit tests, plus a step-by-step PDF guide. It builds and runs with no API key, so you can clone it, run the tests, and then plug in your provider.
Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. 👉 Join Tech Skill Builder
If you've shipped speech in production: did you go with batch, or did a product requirement push you into streaming from day one?
Top comments (0)