Skip to content

Commit afe43e9

Browse files
committed
Add the Nova Sonic backend swap and AsIRealtimeClient adapter
Phase 4b of the AWS.Speech.MEAI stack. Adds the two realtime-contract adapters that let a team move between the pipeline and native Nova Sonic, or expose either behind MEAI's IRealtimeClient, without changing call sites. - RealtimeMessageMapper: shared, pure translation between VoiceAgentUpdate and MEAI's realtime messages, both directions. Assistant audio round-trips through the base64 string on OutputTextAudioRealtimeServerMessage.Audio. - NovaSonicRunner: drives VoiceAgentBackend.NovaSonic. Opens a realtime session on an IRealtimeClient (Bedrock Nova Sonic), pumps microphone PCM in as InputAudioBufferAppend messages plus a final commit, and maps the session's server messages back to VoiceAgentUpdates so RunAsync yields the same stream as the pipeline backend. - VoiceAgentRealtimeClient: exposes a VoiceAgent through IRealtimeClient. A session feeds pushed audio into the pipeline via a System.IO.Pipelines.Pipe and maps the resulting updates to server messages. Reuses the shared RealtimeAudioProtocol single-enumeration guard from AWS.Bedrock.MEAI. - VoiceAgent: Create now builds a Nova IRealtimeClient for the NovaSonic backend (previously threw NotSupportedException); RunAsync routes by backend; AsIRealtimeClient(defaultModelId) is implemented. The provider-neutral constructor stays pipeline-only and RunAsync throws NotSupportedException if asked for NovaSonic without Create. The message types match across backends; timing and cadence differ, as the design notes call out. Testing: mapper round-trips (incl. audio via base64) and unknown-type skip; a NovaSonicRunner test with a fake IRealtimeClient/session (deterministic: server messages gated on the input-audio commit) asserting audio pump, commit, session dispose, mapped updates, and session-option wiring; an AsIRealtimeClient test driving a stub-backed pipeline end to end through the realtime contract; and the constructor NovaSonic guard. All Speech tests pass on net8.0 (43) and net472 (7, unchanged). Bedrock/Nova unchanged (243 + 173); solution builds 0/0.
1 parent a694a39 commit afe43e9

8 files changed

Lines changed: 759 additions & 30 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#if NET8_0_OR_GREATER
5+
using Microsoft.Extensions.AI;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.IO;
9+
using System.Runtime.CompilerServices;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
13+
namespace AWS.Speech.MEAI;
14+
15+
/// <summary>
16+
/// Drives the <see cref="VoiceAgentBackend.NovaSonic"/> backend: opens a realtime session on the
17+
/// supplied <see cref="IRealtimeClient"/> (Amazon Bedrock Nova Sonic), streams microphone PCM into it
18+
/// as input-audio messages, and maps the session's realtime server messages back to
19+
/// <see cref="VoiceAgentUpdate"/>s so the caller sees the same stream as the pipeline backend.
20+
/// </summary>
21+
internal static class NovaSonicRunner
22+
{
23+
private const int AudioChunkBytes = 8192;
24+
25+
public static async IAsyncEnumerable<VoiceAgentUpdate> RunAsync(
26+
IRealtimeClient client, VoiceAgentOptions options, Stream microphonePcm,
27+
[EnumeratorCancellation] CancellationToken cancellationToken)
28+
{
29+
var sessionOptions = new RealtimeSessionOptions
30+
{
31+
Model = options.ModelId,
32+
Instructions = options.Instructions,
33+
Voice = options.Voice.Value,
34+
InputAudioFormat = new RealtimeAudioFormat("audio/lpcm", options.InputSampleRateHertz),
35+
OutputAudioFormat = new RealtimeAudioFormat("audio/lpcm", options.OutputSampleRateHertz),
36+
};
37+
38+
var session = await client.CreateSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
39+
using var pumpCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
40+
41+
var pump = Task.Run(() => PumpAudioAsync(session, microphonePcm, pumpCts.Token), pumpCts.Token);
42+
try
43+
{
44+
await foreach (var message in session.GetStreamingResponseAsync(cancellationToken).ConfigureAwait(false))
45+
{
46+
if (RealtimeMessageMapper.ToVoiceAgentUpdate(message) is { } update)
47+
{
48+
yield return update;
49+
}
50+
}
51+
}
52+
finally
53+
{
54+
pumpCts.Cancel();
55+
try { await pump.ConfigureAwait(false); }
56+
catch (OperationCanceledException) { /* expected on teardown */ }
57+
await session.DisposeAsync().ConfigureAwait(false);
58+
}
59+
}
60+
61+
private static async Task PumpAudioAsync(IRealtimeClientSession session, Stream microphonePcm, CancellationToken token)
62+
{
63+
var buffer = new byte[AudioChunkBytes];
64+
int read;
65+
while ((read = await microphonePcm.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0)
66+
{
67+
var chunk = new byte[read];
68+
Array.Copy(buffer, 0, chunk, 0, read);
69+
var message = new InputAudioBufferAppendRealtimeClientMessage(new DataContent(chunk, "audio/lpcm"));
70+
await session.SendAsync(message, token).ConfigureAwait(false);
71+
}
72+
73+
// End of the caller's audio: commit so the model can finalize the current utterance.
74+
await session.SendAsync(new InputAudioBufferCommitRealtimeClientMessage(), token).ConfigureAwait(false);
75+
}
76+
}
77+
#endif
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#if NET8_0_OR_GREATER
5+
using Microsoft.Extensions.AI;
6+
using System;
7+
8+
namespace AWS.Speech.MEAI;
9+
10+
/// <summary>
11+
/// Translates between the <see cref="VoiceAgentUpdate"/> stream and MEAI's realtime message vocabulary,
12+
/// so the pipeline can be exposed as an <see cref="IRealtimeClient"/> and a Nova Sonic session can be
13+
/// consumed as a <see cref="VoiceAgent"/>. The message types line up; timing and cadence do not.
14+
/// </summary>
15+
internal static class RealtimeMessageMapper
16+
{
17+
/// <summary>Maps a realtime server message to a <see cref="VoiceAgentUpdate"/>, or <see langword="null"/> to skip it.</summary>
18+
public static VoiceAgentUpdate? ToVoiceAgentUpdate(RealtimeServerMessage message)
19+
{
20+
var type = message.Type.Value;
21+
22+
if (type == RealtimeServerMessageType.InputAudioTranscriptionDelta.Value &&
23+
message is InputAudioTranscriptionRealtimeServerMessage partial)
24+
{
25+
return new VoiceAgentUpdate { Kind = VoiceAgentUpdateKind.UserTranscriptPartial, Text = partial.Transcription };
26+
}
27+
28+
if (type == RealtimeServerMessageType.InputAudioTranscriptionCompleted.Value &&
29+
message is InputAudioTranscriptionRealtimeServerMessage final)
30+
{
31+
return new VoiceAgentUpdate { Kind = VoiceAgentUpdateKind.UserTranscriptFinal, Text = final.Transcription, IsFinal = true };
32+
}
33+
34+
if (type == RealtimeServerMessageType.ResponseCreated.Value && message is ResponseCreatedRealtimeServerMessage created)
35+
{
36+
return new VoiceAgentUpdate { Kind = VoiceAgentUpdateKind.TurnStarted, ResponseId = created.ResponseId };
37+
}
38+
39+
if (type == RealtimeServerMessageType.OutputTextDelta.Value && message is OutputTextAudioRealtimeServerMessage text)
40+
{
41+
return new VoiceAgentUpdate { Kind = VoiceAgentUpdateKind.AssistantText, Text = text.Text, ResponseId = text.ResponseId };
42+
}
43+
44+
if (type == RealtimeServerMessageType.OutputAudioDelta.Value && message is OutputTextAudioRealtimeServerMessage audio)
45+
{
46+
var bytes = string.IsNullOrEmpty(audio.Audio) ? Array.Empty<byte>() : Convert.FromBase64String(audio.Audio);
47+
return new VoiceAgentUpdate { Kind = VoiceAgentUpdateKind.AssistantAudio, Audio = bytes, ResponseId = audio.ResponseId };
48+
}
49+
50+
if (type == RealtimeServerMessageType.ResponseDone.Value && message is ResponseCreatedRealtimeServerMessage done)
51+
{
52+
var cancelled = string.Equals(done.Status, RealtimeResponseStatus.Cancelled, StringComparison.Ordinal);
53+
return new VoiceAgentUpdate
54+
{
55+
Kind = cancelled ? VoiceAgentUpdateKind.Cancelled : VoiceAgentUpdateKind.TurnComplete,
56+
Usage = done.Usage,
57+
ResponseId = done.ResponseId,
58+
};
59+
}
60+
61+
return null;
62+
}
63+
64+
/// <summary>Maps a <see cref="VoiceAgentUpdate"/> to a realtime server message, or <see langword="null"/> to skip it.</summary>
65+
public static RealtimeServerMessage? ToServerMessage(VoiceAgentUpdate update)
66+
{
67+
switch (update.Kind)
68+
{
69+
case VoiceAgentUpdateKind.UserTranscriptPartial:
70+
return new InputAudioTranscriptionRealtimeServerMessage(RealtimeServerMessageType.InputAudioTranscriptionDelta)
71+
{
72+
Transcription = update.Text,
73+
};
74+
75+
case VoiceAgentUpdateKind.UserTranscriptFinal:
76+
return new InputAudioTranscriptionRealtimeServerMessage(RealtimeServerMessageType.InputAudioTranscriptionCompleted)
77+
{
78+
Transcription = update.Text,
79+
};
80+
81+
case VoiceAgentUpdateKind.TurnStarted:
82+
return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseCreated)
83+
{
84+
ResponseId = update.ResponseId,
85+
};
86+
87+
case VoiceAgentUpdateKind.AssistantText:
88+
return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDelta)
89+
{
90+
Text = update.Text,
91+
ResponseId = update.ResponseId,
92+
};
93+
94+
case VoiceAgentUpdateKind.AssistantAudio:
95+
return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputAudioDelta)
96+
{
97+
Audio = update.Audio is { } pcm ? Convert.ToBase64String(pcm.ToArray()) : string.Empty,
98+
ResponseId = update.ResponseId,
99+
};
100+
101+
case VoiceAgentUpdateKind.TurnComplete:
102+
return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone)
103+
{
104+
Status = RealtimeResponseStatus.Completed,
105+
Usage = update.Usage,
106+
ResponseId = update.ResponseId,
107+
};
108+
109+
case VoiceAgentUpdateKind.Cancelled:
110+
return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone)
111+
{
112+
Status = RealtimeResponseStatus.Cancelled,
113+
ResponseId = update.ResponseId,
114+
};
115+
116+
default:
117+
return null;
118+
}
119+
}
120+
}
121+
#endif

src/AWS.Speech.MEAI/VoiceAgent.cs

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.Collections.Generic;
1313
using System.Diagnostics.CodeAnalysis;
1414
using System.IO;
15+
using System.Runtime.ExceptionServices;
1516
using System.Threading;
1617
using System.Threading.Tasks;
1718

@@ -23,61 +24,75 @@ namespace AWS.Speech.MEAI;
2324
/// <see cref="VoiceAgentUpdate"/>s.
2425
/// </summary>
2526
/// <remarks>
26-
/// The default backend is <see cref="VoiceAgentBackend.Pipeline"/>: Amazon Transcribe streaming for
27-
/// STT, Amazon Bedrock via <c>AWS.Bedrock.MEAI</c> for reasoning, and Amazon Polly for TTS. Call
28-
/// <see cref="Create"/> for the one-line default-chain factory, or use the constructor to compose any
29-
/// MEAI clients you already have. Barge-in, the Nova Sonic backend swap, the <c>AsIRealtimeClient()</c>
30-
/// adapter, and DI registration are wired in a later phase.
27+
/// The default backend is <see cref="VoiceAgentBackend.Pipeline"/>: Amazon Transcribe streaming for STT,
28+
/// Amazon Bedrock via <c>AWS.Bedrock.MEAI</c> for reasoning, and Amazon Polly for TTS. Set
29+
/// <see cref="VoiceAgentOptions.Backend"/> to <see cref="VoiceAgentBackend.NovaSonic"/> on
30+
/// <see cref="Create"/> to route the same <c>RunAsync</c> stream through Amazon Bedrock Nova Sonic
31+
/// instead. <see cref="AsIRealtimeClient"/> exposes either backend through MEAI's
32+
/// <see cref="IRealtimeClient"/> contract.
3133
/// </remarks>
3234
[Experimental("MEAI001")]
3335
public sealed class VoiceAgent : IAsyncDisposable
3436
{
35-
private readonly ISpeechToTextClient _stt;
36-
private readonly IChatClient _chat;
37-
private readonly ITextToSpeechClient _tts;
37+
private readonly ISpeechToTextClient? _stt;
38+
private readonly IChatClient? _chat;
39+
private readonly ITextToSpeechClient? _tts;
40+
private readonly IRealtimeClient? _novaClient;
3841
private readonly VoiceAgentOptions _options;
3942
private readonly List<IDisposable> _ownedResources;
4043
private int _disposed;
4144

42-
/// <summary>Initializes a provider-neutral <see cref="VoiceAgent"/> around any MEAI clients.</summary>
45+
/// <summary>Initializes a provider-neutral pipeline <see cref="VoiceAgent"/> around any MEAI clients.</summary>
4346
/// <exception cref="ArgumentNullException">A client is <see langword="null"/>.</exception>
4447
public VoiceAgent(ISpeechToTextClient stt, IChatClient chat, ITextToSpeechClient tts, VoiceAgentOptions? options = null)
45-
: this(stt, chat, tts, options ?? new VoiceAgentOptions(), ownedResources: null)
48+
: this(
49+
stt ?? throw new ArgumentNullException(nameof(stt)),
50+
chat ?? throw new ArgumentNullException(nameof(chat)),
51+
tts ?? throw new ArgumentNullException(nameof(tts)),
52+
novaClient: null,
53+
options ?? new VoiceAgentOptions(),
54+
ownedResources: null)
4655
{
4756
}
4857

49-
private VoiceAgent(ISpeechToTextClient stt, IChatClient chat, ITextToSpeechClient tts,
58+
private VoiceAgent(
59+
ISpeechToTextClient? stt, IChatClient? chat, ITextToSpeechClient? tts, IRealtimeClient? novaClient,
5060
VoiceAgentOptions options, List<IDisposable>? ownedResources)
5161
{
52-
_stt = stt ?? throw new ArgumentNullException(nameof(stt));
53-
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
54-
_tts = tts ?? throw new ArgumentNullException(nameof(tts));
62+
_stt = stt;
63+
_chat = chat;
64+
_tts = tts;
65+
_novaClient = novaClient;
5566
_options = options;
5667
_ownedResources = ownedResources ?? new List<IDisposable>();
5768
}
5869

5970
/// <summary>Creates a <see cref="VoiceAgent"/> with the default AWS credential and region chains.</summary>
6071
/// <remarks>
61-
/// Constructs an Amazon Transcribe streaming client, an Amazon Bedrock runtime client (adapted via
62-
/// <c>AmazonBedrockRuntimeExtensions.AsIChatClient</c>), and an Amazon Polly client, honoring
63-
/// <see cref="VoiceAgentOptions.Credentials"/> and <see cref="VoiceAgentOptions.Region"/> when set.
64-
/// A pre-built client on the options object wins over the default AWS client for that leg. The
65-
/// returned agent owns any clients it constructed and disposes them on <see cref="DisposeAsync"/>.
72+
/// For <see cref="VoiceAgentBackend.Pipeline"/> this constructs an Amazon Transcribe streaming client,
73+
/// an Amazon Bedrock runtime client adapted to an <see cref="IChatClient"/>, and an Amazon Polly client.
74+
/// For <see cref="VoiceAgentBackend.NovaSonic"/> it constructs an Amazon Bedrock runtime client adapted
75+
/// to an <see cref="IRealtimeClient"/>. Both honor <see cref="VoiceAgentOptions.Credentials"/> and
76+
/// <see cref="VoiceAgentOptions.Region"/>, and a pre-built client on the options wins over the default
77+
/// AWS client for its leg. The returned agent owns any clients it constructed and disposes them on
78+
/// <see cref="DisposeAsync"/>.
6679
/// </remarks>
67-
/// <exception cref="NotSupportedException">The requested backend is not yet available in this preview.</exception>
6880
public static VoiceAgent Create(Action<VoiceAgentOptions>? configure = null)
6981
{
7082
var options = new VoiceAgentOptions();
7183
configure?.Invoke(options);
7284

73-
if (options.Backend != VoiceAgentBackend.Pipeline)
85+
var owned = new List<IDisposable>();
86+
87+
if (options.Backend == VoiceAgentBackend.NovaSonic)
7488
{
75-
throw new NotSupportedException(
76-
$"The {options.Backend} backend is not available yet in this preview. Use VoiceAgentBackend.Pipeline.");
89+
var bedrock = CreateBedrockClient(options.Credentials, options.Region);
90+
owned.Add(bedrock);
91+
var nova = bedrock.AsIRealtimeClient(options.ModelId);
92+
if (nova is IDisposable disposableNova) owned.Add(disposableNova);
93+
return new VoiceAgent(stt: null, chat: null, tts: null, nova, options, owned);
7794
}
7895

79-
var owned = new List<IDisposable>();
80-
8196
var stt = options.SpeechToTextClient;
8297
if (stt is null)
8398
{
@@ -102,24 +117,48 @@ public static VoiceAgent Create(Action<VoiceAgentOptions>? configure = null)
102117
tts = polly.AsITextToSpeechClient(options.Voice, Engine.Neural, options.OutputSampleRateHertz);
103118
}
104119

105-
return new VoiceAgent(stt, chat, tts, options, owned);
120+
return new VoiceAgent(stt, chat, tts, novaClient: null, options, owned);
106121
}
107122

108123
/// <summary>Runs the voice loop over the caller's microphone PCM stream.</summary>
109124
/// <param name="microphonePcm">
110-
/// Input audio at <see cref="VoiceAgentOptions.InputSampleRateHertz"/>, 16-bit signed
111-
/// little-endian mono PCM. The agent never disposes this stream.
125+
/// Input audio at <see cref="VoiceAgentOptions.InputSampleRateHertz"/>, 16-bit signed little-endian
126+
/// mono PCM. The agent never disposes this stream.
112127
/// </param>
113128
/// <param name="cancellationToken">Stops the loop; the returned enumerable then completes.</param>
114129
/// <returns>One ordered stream of <see cref="VoiceAgentUpdate"/>s.</returns>
115130
/// <exception cref="ArgumentNullException"><paramref name="microphonePcm"/> is <see langword="null"/>.</exception>
116131
/// <exception cref="ObjectDisposedException">The agent has been disposed.</exception>
132+
/// <exception cref="NotSupportedException">
133+
/// The agent is configured for <see cref="VoiceAgentBackend.NovaSonic"/> but was built with the
134+
/// provider-neutral constructor, which is pipeline-only. Use <see cref="Create"/> for Nova Sonic.
135+
/// </exception>
117136
public IAsyncEnumerable<VoiceAgentUpdate> RunAsync(Stream microphonePcm, CancellationToken cancellationToken = default)
118137
{
119138
if (microphonePcm is null) throw new ArgumentNullException(nameof(microphonePcm));
120139
ThrowIfDisposed();
121140

122-
return VoiceAgentPipeline.RunAsync(_stt, _chat, _tts, _options, microphonePcm, cancellationToken);
141+
if (_options.Backend == VoiceAgentBackend.NovaSonic)
142+
{
143+
if (_novaClient is null)
144+
{
145+
throw new NotSupportedException(
146+
"The NovaSonic backend requires VoiceAgent.Create; the provider-neutral constructor is pipeline-only.");
147+
}
148+
return NovaSonicRunner.RunAsync(_novaClient, _options, microphonePcm, cancellationToken);
149+
}
150+
151+
return VoiceAgentPipeline.RunAsync(_stt!, _chat!, _tts!, _options, microphonePcm, cancellationToken);
152+
}
153+
154+
/// <summary>Exposes this agent through MEAI's <see cref="IRealtimeClient"/> contract.</summary>
155+
/// <param name="defaultModelId">An optional default model ID recorded on the adapter.</param>
156+
/// <returns>An <see cref="IRealtimeClient"/> whose sessions drive this agent's loop.</returns>
157+
/// <exception cref="ObjectDisposedException">The agent has been disposed.</exception>
158+
public IRealtimeClient AsIRealtimeClient(string? defaultModelId = null)
159+
{
160+
ThrowIfDisposed();
161+
return new VoiceAgentRealtimeClient(this, defaultModelId);
123162
}
124163

125164
/// <summary>Returns the underlying MEAI client for the requested service type, or <see langword="null"/>.</summary>
@@ -131,6 +170,7 @@ public IAsyncEnumerable<VoiceAgentUpdate> RunAsync(Stream microphonePcm, Cancell
131170
if (serviceType == typeof(ISpeechToTextClient)) return _stt;
132171
if (serviceType == typeof(IChatClient)) return _chat;
133172
if (serviceType == typeof(ITextToSpeechClient)) return _tts;
173+
if (serviceType == typeof(IRealtimeClient)) return _novaClient;
134174
return serviceType.IsInstanceOfType(this) ? this : null;
135175
}
136176

@@ -149,7 +189,7 @@ public ValueTask DisposeAsync()
149189

150190
if (first is not null)
151191
{
152-
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(first).Throw();
192+
ExceptionDispatchInfo.Capture(first).Throw();
153193
}
154194
return default;
155195
}

0 commit comments

Comments
 (0)