Skip to content

Commit 754bca2

Browse files
committed
Add barge-in and AddVoiceAgent DI to the pipeline
Phase 4a of the AWS.Speech.MEAI stack. Completes the pipeline's usability with interruption support and first-class dependency injection. The Nova Sonic backend swap and AsIRealtimeClient() adapter follow in the next PR. Barge-in: - VoiceAgentPipeline is now an instance runner holding the current turn's cancellation source under a lock, so the STT consumer can interrupt the reasoning worker's in-flight turn without a data race between the two producer tasks. - When EnableBargeIn is set, a qualifying STT partial arriving during an active turn cancels that turn's per-turn token. The reasoning worker observes the cancellation, keeps the partial reply in history so conversation state stays coherent, emits a Cancelled update, and moves on to the next turn. Turn lifecycle and audio are written with the loop token, so a cancelled turn's Cancelled update is still delivered. - BargeInDetector.ShouldInterrupt is a pure noise-threshold predicate (minimum trimmed partial length), kept separate so the threshold is testable. Dependency injection: - AddVoiceAgent(IServiceCollection, Action<VoiceAgentOptions>) registers the three AWS clients with TryAddAWSService and the three MEAI clients plus VoiceAgent with TryAddSingleton, so a caller's own registration of any leg wins. A pre-built client on VoiceAgentOptions wins over the AWS client for that leg. Mirrors the AWS.AgentCore.Hosting registration pattern. - The DI packages (AWSSDK.Extensions.NETCore.Setup, Microsoft.Extensions.DependencyInjection.Abstractions) are referenced only on net8.0, so the down-level Polly TTS surface stays lean. Testing: 7 BargeInDetector cases, 2 barge-in pipeline tests (a deterministic gated-stub interruption that cancels turn 1 and drives turn 2, plus a barge-in-disabled path), and 3 DI tests (prebuilt-client composition, pre-registered IChatClient wins, null-arg guard). All Speech tests pass on net8.0 (36) and net472 (7, unchanged). Bedrock/Nova unchanged (243 + 173); full solution builds 0/0.
1 parent 7d5876e commit 754bca2

8 files changed

Lines changed: 720 additions & 165 deletions

src/AWS.Speech.MEAI/AWS.Speech.MEAI.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@
4242
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.300" PrivateAssets="All" />
4343
</ItemGroup>
4444

45-
<!-- Amazon Transcribe streaming requires HTTP/2, which is unavailable on net472/netstandard2.0. -->
45+
<!-- Amazon Transcribe streaming requires HTTP/2, which is unavailable on net472/netstandard2.0.
46+
The VoiceAgent loop and its AddVoiceAgent DI helper are net8-only for the same reason, so the
47+
DI packages are referenced only there and never burden the down-level Polly TTS surface. -->
4648
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
4749
<PackageReference Include="AWSSDK.TranscribeStreaming" Version="4.0.101.1" />
50+
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="4.0.3.35" />
51+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
4852
</ItemGroup>
4953

5054
<ItemGroup>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
namespace AWS.Speech.MEAI;
6+
7+
/// <summary>
8+
/// Decides whether a speech-to-text partial arriving while the assistant is speaking is a real
9+
/// interruption (barge-in) rather than noise. Kept as a pure predicate so the threshold is testable
10+
/// without driving the whole loop.
11+
/// </summary>
12+
internal static class BargeInDetector
13+
{
14+
/// <summary>Minimum trimmed partial length, in characters, that counts as a real interruption.</summary>
15+
internal const int MinPartialChars = 3;
16+
17+
/// <summary>Returns <see langword="true"/> if <paramref name="partialText"/> clears the noise threshold.</summary>
18+
public static bool ShouldInterrupt(string? partialText) =>
19+
partialText is not null && partialText.Trim().Length >= MinPartialChars;
20+
}
21+
#endif

src/AWS.Speech.MEAI/VoiceAgentPipeline.cs

Lines changed: 219 additions & 164 deletions
Large diffs are not rendered by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 Amazon.BedrockRuntime;
6+
using Amazon.Polly;
7+
using Amazon.TranscribeStreaming;
8+
using AWS.Speech.MEAI;
9+
using Microsoft.Extensions.AI;
10+
using Microsoft.Extensions.DependencyInjection.Extensions;
11+
using System;
12+
using System.Diagnostics.CodeAnalysis;
13+
14+
namespace Microsoft.Extensions.DependencyInjection;
15+
16+
/// <summary>Dependency-injection extensions for registering a <see cref="VoiceAgent"/>.</summary>
17+
[Experimental("MEAI001")]
18+
public static class VoiceAgentServiceCollectionExtensions
19+
{
20+
/// <summary>Registers a <see cref="VoiceAgent"/> and the MEAI speech clients it composes.</summary>
21+
/// <param name="services">The service collection.</param>
22+
/// <param name="configure">Optional configuration for <see cref="VoiceAgentOptions"/>.</param>
23+
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
24+
/// <remarks>
25+
/// Every registration uses <c>TryAdd</c>, so a caller who has already registered an
26+
/// <see cref="IAmazonPolly"/>, <see cref="IAmazonTranscribeStreaming"/>, <see cref="IAmazonBedrockRuntime"/>,
27+
/// <see cref="ISpeechToTextClient"/>, <see cref="IChatClient"/>, or <see cref="ITextToSpeechClient"/> keeps
28+
/// their own registration. A pre-built client on <see cref="VoiceAgentOptions"/> wins over the AWS client
29+
/// for that leg. The three AWS clients come from <c>AddAWSService</c>'s default option/credential resolution;
30+
/// <see cref="VoiceAgentOptions.Region"/> and <see cref="VoiceAgentOptions.Credentials"/> are honored only by
31+
/// <see cref="VoiceAgent.Create"/>, not by this DI path, where AWS options are configured through the container.
32+
/// </remarks>
33+
/// <exception cref="ArgumentNullException"><paramref name="services"/> is <see langword="null"/>.</exception>
34+
public static IServiceCollection AddVoiceAgent(this IServiceCollection services, Action<VoiceAgentOptions>? configure = null)
35+
{
36+
if (services is null) throw new ArgumentNullException(nameof(services));
37+
38+
var options = new VoiceAgentOptions();
39+
configure?.Invoke(options);
40+
41+
RegisterSpeechToText(services, options);
42+
RegisterChat(services, options);
43+
RegisterTextToSpeech(services, options);
44+
45+
services.TryAddSingleton(sp => new VoiceAgent(
46+
sp.GetRequiredService<ISpeechToTextClient>(),
47+
sp.GetRequiredService<IChatClient>(),
48+
sp.GetRequiredService<ITextToSpeechClient>(),
49+
options));
50+
51+
return services;
52+
}
53+
54+
private static void RegisterSpeechToText(IServiceCollection services, VoiceAgentOptions options)
55+
{
56+
if (options.SpeechToTextClient is { } stt)
57+
{
58+
services.TryAddSingleton(stt);
59+
return;
60+
}
61+
62+
services.TryAddAWSService<IAmazonTranscribeStreaming>();
63+
services.TryAddSingleton<ISpeechToTextClient>(sp =>
64+
sp.GetRequiredService<IAmazonTranscribeStreaming>()
65+
.AsISpeechToTextClient(options.Language, options.InputSampleRateHertz));
66+
}
67+
68+
private static void RegisterChat(IServiceCollection services, VoiceAgentOptions options)
69+
{
70+
if (options.ChatClient is { } chat)
71+
{
72+
services.TryAddSingleton(chat);
73+
return;
74+
}
75+
76+
services.TryAddAWSService<IAmazonBedrockRuntime>();
77+
services.TryAddSingleton<IChatClient>(sp =>
78+
sp.GetRequiredService<IAmazonBedrockRuntime>().AsIChatClient(options.ModelId));
79+
}
80+
81+
private static void RegisterTextToSpeech(IServiceCollection services, VoiceAgentOptions options)
82+
{
83+
if (options.TextToSpeechClient is { } tts)
84+
{
85+
services.TryAddSingleton(tts);
86+
return;
87+
}
88+
89+
services.TryAddAWSService<IAmazonPolly>();
90+
services.TryAddSingleton<ITextToSpeechClient>(sp =>
91+
sp.GetRequiredService<IAmazonPolly>()
92+
.AsITextToSpeechClient(options.Voice, Engine.Neural, options.OutputSampleRateHertz));
93+
}
94+
}
95+
#endif

test/AWS.Speech.MEAI.UnitTests/AWS.Speech.MEAI.UnitTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
<ItemGroup>
2424
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.8.3" />
25+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
2526
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
2627
<PackageReference Include="Moq" Version="4.20.72" />
2728
<PackageReference Include="xunit.v3" Version="3.2.2" />
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 Xunit;
6+
7+
namespace AWS.Speech.MEAI;
8+
9+
public class BargeInDetectorTests
10+
{
11+
[Theory]
12+
[Trait("UnitTest", "Speech")]
13+
[InlineData("yes please stop")]
14+
[InlineData("wait")]
15+
[InlineData(" hey ")]
16+
public void ShouldInterrupt_QualifyingPartial_ReturnsTrue(string partial)
17+
{
18+
Assert.True(BargeInDetector.ShouldInterrupt(partial));
19+
}
20+
21+
[Theory]
22+
[Trait("UnitTest", "Speech")]
23+
[InlineData(null)]
24+
[InlineData("")]
25+
[InlineData(" ")]
26+
[InlineData("hi")]
27+
public void ShouldInterrupt_NoiseOrTooShort_ReturnsFalse(string? partial)
28+
{
29+
Assert.False(BargeInDetector.ShouldInterrupt(partial));
30+
}
31+
}
32+
#endif
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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.Linq;
10+
using System.Runtime.CompilerServices;
11+
using System.Text;
12+
using System.Threading;
13+
using System.Threading.Tasks;
14+
using Xunit;
15+
16+
namespace AWS.Speech.MEAI;
17+
18+
public class VoiceAgentBargeInTests
19+
{
20+
[Fact]
21+
[Trait("UnitTest", "Speech")]
22+
public async Task RunAsync_BargeInEnabled_PartialDuringTurn_CancelsAndStartsNewTurn()
23+
{
24+
// STT scripts turn 1's final, waits for the test to release the gate, then a qualifying partial
25+
// (barge-in) followed by turn 2's final. The gate is released once the caller observes TurnStarted.
26+
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
27+
var stt = new GatedStt(gate);
28+
var chat = new BlockingFirstTurnChat();
29+
var tts = new EchoTts();
30+
31+
var agent = new VoiceAgent(stt, chat, tts, new VoiceAgentOptions { EnableBargeIn = true });
32+
using var mic = new MemoryStream(new byte[] { 0 });
33+
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
34+
35+
var updates = new List<VoiceAgentUpdate>();
36+
bool released = false;
37+
await foreach (var update in agent.RunAsync(mic, timeout.Token))
38+
{
39+
updates.Add(update);
40+
if (!released && update.Kind == VoiceAgentUpdateKind.TurnStarted)
41+
{
42+
released = true;
43+
gate.SetResult(); // let the interrupting partial flow
44+
}
45+
}
46+
47+
Assert.Contains(updates, u => u.Kind == VoiceAgentUpdateKind.Cancelled);
48+
49+
// Turn 1 was cancelled before it could complete; the only TurnComplete belongs to turn 2.
50+
int cancelledIdx = updates.FindIndex(u => u.Kind == VoiceAgentUpdateKind.Cancelled);
51+
int firstCompleteIdx = updates.FindIndex(u => u.Kind == VoiceAgentUpdateKind.TurnComplete);
52+
Assert.True(firstCompleteIdx < 0 || cancelledIdx < firstCompleteIdx,
53+
"The cancelled turn must not have produced a TurnComplete before the barge-in.");
54+
55+
// The barge-in path drove a second turn to completion.
56+
Assert.Equal(2, chat.CallCount);
57+
Assert.Contains(updates, u => u.Kind == VoiceAgentUpdateKind.TurnComplete);
58+
}
59+
60+
[Fact]
61+
[Trait("UnitTest", "Speech")]
62+
public async Task RunAsync_BargeInDisabled_ShortNoisePartial_DoesNotCancel()
63+
{
64+
// Barge-in off and a below-threshold partial: the single turn completes normally.
65+
var stt = new ScriptedNoGateStt(new[]
66+
{
67+
new SpeechToTextResponseUpdate("hi") { Kind = SpeechToTextResponseUpdateKind.TextUpdating },
68+
new SpeechToTextResponseUpdate("hello") { Kind = SpeechToTextResponseUpdateKind.TextUpdated },
69+
});
70+
var chat = new QuickChat("Sure.");
71+
var tts = new EchoTts();
72+
73+
var agent = new VoiceAgent(stt, chat, tts, new VoiceAgentOptions { EnableBargeIn = false });
74+
using var mic = new MemoryStream(new byte[] { 0 });
75+
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
76+
77+
var updates = new List<VoiceAgentUpdate>();
78+
await foreach (var update in agent.RunAsync(mic, timeout.Token))
79+
{
80+
updates.Add(update);
81+
}
82+
83+
Assert.DoesNotContain(updates, u => u.Kind == VoiceAgentUpdateKind.Cancelled);
84+
Assert.Contains(updates, u => u.Kind == VoiceAgentUpdateKind.TurnComplete);
85+
}
86+
87+
// ---- test doubles ----
88+
89+
private sealed class GatedStt : ISpeechToTextClient
90+
{
91+
private readonly TaskCompletionSource _gate;
92+
public GatedStt(TaskCompletionSource gate) => _gate = gate;
93+
94+
public Task<SpeechToTextResponse> GetTextAsync(Stream a, SpeechToTextOptions? o, CancellationToken ct) =>
95+
throw new NotImplementedException();
96+
97+
public async IAsyncEnumerable<SpeechToTextResponseUpdate> GetStreamingTextAsync(
98+
Stream audioSpeechStream, SpeechToTextOptions? options,
99+
[EnumeratorCancellation] CancellationToken cancellationToken)
100+
{
101+
yield return new SpeechToTextResponseUpdate("first question") { Kind = SpeechToTextResponseUpdateKind.TextUpdated };
102+
103+
// Wait until the caller has seen turn 1 start, then interrupt it.
104+
await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
105+
106+
yield return new SpeechToTextResponseUpdate("actually wait") { Kind = SpeechToTextResponseUpdateKind.TextUpdating };
107+
yield return new SpeechToTextResponseUpdate("actually wait") { Kind = SpeechToTextResponseUpdateKind.TextUpdated };
108+
}
109+
110+
public object? GetService(System.Type serviceType, object? serviceKey = null) =>
111+
serviceType.IsInstanceOfType(this) ? this : null;
112+
113+
public void Dispose() { }
114+
}
115+
116+
private sealed class ScriptedNoGateStt : ISpeechToTextClient
117+
{
118+
private readonly SpeechToTextResponseUpdate[] _updates;
119+
public ScriptedNoGateStt(SpeechToTextResponseUpdate[] updates) => _updates = updates;
120+
121+
public Task<SpeechToTextResponse> GetTextAsync(Stream a, SpeechToTextOptions? o, CancellationToken ct) =>
122+
throw new NotImplementedException();
123+
124+
public async IAsyncEnumerable<SpeechToTextResponseUpdate> GetStreamingTextAsync(
125+
Stream audioSpeechStream, SpeechToTextOptions? options,
126+
[EnumeratorCancellation] CancellationToken cancellationToken)
127+
{
128+
foreach (var u in _updates)
129+
{
130+
cancellationToken.ThrowIfCancellationRequested();
131+
yield return u;
132+
await Task.Yield();
133+
}
134+
}
135+
136+
public object? GetService(System.Type serviceType, object? serviceKey = null) =>
137+
serviceType.IsInstanceOfType(this) ? this : null;
138+
139+
public void Dispose() { }
140+
}
141+
142+
private sealed class BlockingFirstTurnChat : IChatClient
143+
{
144+
public int CallCount { get; private set; }
145+
146+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) =>
147+
throw new NotImplementedException();
148+
149+
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
150+
IEnumerable<ChatMessage> messages, ChatOptions? options,
151+
[EnumeratorCancellation] CancellationToken cancellationToken)
152+
{
153+
CallCount++;
154+
if (CallCount == 1)
155+
{
156+
yield return new ChatResponseUpdate(ChatRole.Assistant, "Working on it. ");
157+
// Block until the turn's token is cancelled by barge-in.
158+
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
159+
yield break;
160+
}
161+
162+
yield return new ChatResponseUpdate(ChatRole.Assistant, "Second answer.");
163+
await Task.Yield();
164+
}
165+
166+
public object? GetService(System.Type serviceType, object? serviceKey = null) =>
167+
serviceType.IsInstanceOfType(this) ? this : null;
168+
169+
public void Dispose() { }
170+
}
171+
172+
private sealed class QuickChat : IChatClient
173+
{
174+
private readonly string _reply;
175+
public QuickChat(string reply) => _reply = reply;
176+
177+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) =>
178+
throw new NotImplementedException();
179+
180+
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
181+
IEnumerable<ChatMessage> messages, ChatOptions? options,
182+
[EnumeratorCancellation] CancellationToken cancellationToken)
183+
{
184+
yield return new ChatResponseUpdate(ChatRole.Assistant, _reply);
185+
await Task.Yield();
186+
}
187+
188+
public object? GetService(System.Type serviceType, object? serviceKey = null) =>
189+
serviceType.IsInstanceOfType(this) ? this : null;
190+
191+
public void Dispose() { }
192+
}
193+
194+
private sealed class EchoTts : ITextToSpeechClient
195+
{
196+
public Task<TextToSpeechResponse> GetAudioAsync(string t, TextToSpeechOptions? o, CancellationToken ct) =>
197+
throw new NotImplementedException();
198+
199+
public async IAsyncEnumerable<TextToSpeechResponseUpdate> GetStreamingAudioAsync(
200+
string text, TextToSpeechOptions? options,
201+
[EnumeratorCancellation] CancellationToken cancellationToken)
202+
{
203+
var bytes = Encoding.UTF8.GetBytes(text);
204+
yield return new TextToSpeechResponseUpdate(new List<AIContent> { new DataContent(bytes, "audio/lpcm") })
205+
{
206+
Kind = TextToSpeechResponseUpdateKind.AudioUpdating,
207+
};
208+
await Task.Yield();
209+
}
210+
211+
public object? GetService(System.Type serviceType, object? serviceKey = null) =>
212+
serviceType.IsInstanceOfType(this) ? this : null;
213+
214+
public void Dispose() { }
215+
}
216+
}
217+
#endif

0 commit comments

Comments
 (0)