chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
public class AudioOptions
{
// Audio configuration constants, not part of appsettings, not intended to be changed
public const int SampleRate = 16000;
public const int Channels = 1;
public const int BitsPerSample = 16;
public const int BufferMilliseconds = 20;
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel.DataAnnotations;
public class ChatOptions
{
public const string SectionName = "Chat";
[Required]
public string SystemMessage { get; set; } = string.Empty;
// Chat response streaming constants
public int StreamingChunkSizeThreshold { get; set; } = 100;
public double Temperature { get; set; } = 0.7;
public int MaxTokens { get; set; } = 500;
public double TopP { get; set; } = 0.9;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel.DataAnnotations;
public class OpenAIOptions
{
public const string SectionName = "OpenAI";
[Required]
public string ApiKey { get; set; } = string.Empty;
[Required]
public string ChatModelId { get; set; } = "gpt-4";
[Required]
public string TranscriptionModelId { get; set; } = "gpt-4o-transcribe";
[Required]
public string SpeechModelId { get; set; } = "gpt-4o-mini-tts";
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
global using AudioChunkEvent = PipelineEvent<byte[]>;
global using AudioEvent = PipelineEvent<AudioData>;
global using ChatEvent = PipelineEvent<string>;
global using SpeechEvent = PipelineEvent<byte[]>;
global using TranscriptionEvent = PipelineEvent<string?>;
public readonly struct PipelineEvent<T>(int turnId, CancellationToken cancellationToken, T payload) : IEquatable<PipelineEvent<T>>
{
public int TurnId { get; } = turnId;
public CancellationToken CancellationToken { get; } = cancellationToken;
public T Payload { get; } = payload;
public static bool IsValid(PipelineEvent<T> evt, int currentTurnId, Func<T, bool>? payloadPredicate = null)
=> evt.Payload != null
&& evt.TurnId == currentTurnId
&& !evt.CancellationToken.IsCancellationRequested
&& (payloadPredicate?.Invoke(evt.Payload) ?? true);
public override bool Equals(object obj)
{
throw new NotImplementedException();
}
public override int GetHashCode()
{
throw new NotImplementedException();
}
public static bool operator ==(PipelineEvent<T> left, PipelineEvent<T> right)
{
return left.Equals(right);
}
public static bool operator !=(PipelineEvent<T> left, PipelineEvent<T> right)
{
return !(left == right);
}
public bool Equals(PipelineEvent<T> other)
{
throw new NotImplementedException();
}
}
public record AudioData(byte[] Data, int SampleRate, int Channels, int BitsPerSample)
{
public TimeSpan Duration => TimeSpan.FromSeconds((double)this.Data.Length / (this.SampleRate * this.Channels * this.BitsPerSample / 8));
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
public class TurnManager : IDisposable
{
private int _currentTurnId = 0;
private CancellationTokenSource _cts = new();
private readonly object _lock = new();
public int CurrentTurnId { get { lock (this._lock) { return this._currentTurnId; } } }
public CancellationToken CurrentToken { get { lock (this._lock) { return this._cts.Token; } } }
public void Interrupt()
{
lock (this._lock)
{
this._currentTurnId++;
this._cts.Cancel();
this._cts.Dispose();
this._cts = new CancellationTokenSource();
}
}
public void Dispose() => this._cts?.Dispose();
}
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks.Dataflow;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public class VoiceChatPipeline : IDisposable
{
// Pipeline configuration constants
private const int MaxDegreeOfParallelism = 1; // Number of parallel operations in dataflow blocks
private const int BoundedCapacity = 5; // Maximum capacity for dataflow block buffers
private const bool EnsureOrdered = true; // Ensure order preservation in pipeline
// Dataflow options fields - initialized inline
private readonly ExecutionDataflowBlockOptions _executionOptions = new()
{
MaxDegreeOfParallelism = MaxDegreeOfParallelism,
BoundedCapacity = BoundedCapacity,
EnsureOrdered = EnsureOrdered
};
private readonly DataflowLinkOptions _linkOptions = new() { PropagateCompletion = true };
private readonly ILogger<VoiceChatPipeline> _logger;
private readonly AudioPlaybackService _audioPlaybackService;
private readonly SpeechToTextService _speechToTextService;
private readonly TextToSpeechService _textToSpeechService;
private readonly ChatService _chatService;
private readonly TurnManager _turnManager;
private readonly VadService _vadService;
private readonly AudioSourceService _audioSourceService;
private CancellationTokenSource? _cancellationTokenSource;
public VoiceChatPipeline(
ILogger<VoiceChatPipeline> logger,
AudioPlaybackService audioPlaybackService,
SpeechToTextService speechToTextService,
TextToSpeechService textToSpeechService,
ChatService chatService,
VadService vadService,
AudioSourceService audioSourceService,
TurnManager turnManager,
IOptions<AudioOptions> audioOptions)
{
this._logger = logger;
this._audioPlaybackService = audioPlaybackService;
this._speechToTextService = speechToTextService;
this._textToSpeechService = textToSpeechService;
this._chatService = chatService;
this._vadService = vadService;
this._audioSourceService = audioSourceService;
this._turnManager = turnManager;
}
public async Task RunAsync(CancellationToken cancellationToken = default)
{
this._cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Create pipeline blocks - VAD now accepts raw audio chunks directly
var vadBlock = new TransformManyBlock<byte[], AudioEvent>(this._vadService.Transform, this._executionOptions);
var sttBlock = new TransformBlock<AudioEvent, TranscriptionEvent>(this._speechToTextService.TransformAsync, this._executionOptions);
var chatBlock = new TransformManyBlock<TranscriptionEvent, ChatEvent>(this._chatService.TransformAsync, this._executionOptions);
var ttsBlock = new TransformBlock<ChatEvent, SpeechEvent>(this._textToSpeechService.TransformAsync, this._executionOptions);
var playbackBlock = new ActionBlock<SpeechEvent>(this._audioPlaybackService.PipelineActionAsync, this._executionOptions);
// Connect the blocks in the pipeline
this.Link(vadBlock, sttBlock, "VAD", audioData => audioData.Data.Length > 0);
this.Link(sttBlock, chatBlock, "STT", t => !string.IsNullOrEmpty(t));
this.Link(chatBlock, ttsBlock, "Chat", t => !string.IsNullOrEmpty(t));
this.Link(ttsBlock, playbackBlock, "TTS", t => t.Length > 0);
this._logger.LogInformation("Voice Chat started. You can start conversation now, or press Ctrl+C to exit.");
try
{
// Keep feeding audio chunks into the VAD pipeline block till RunAsync is not cancelled
await foreach (var audioChunk in this._audioSourceService.GetAudioChunksAsync(this._cancellationTokenSource.Token))
{
await vadBlock.SendAsync(audioChunk, this._cancellationTokenSource.Token);
}
}
catch (OperationCanceledException)
{
this._logger.LogInformation("Voice Chat pipeline stopping due to cancellation...");
}
finally
{
vadBlock.Complete();
await playbackBlock.Completion;
}
}
public void Dispose()
{
this._vadService?.Dispose();
this._cancellationTokenSource?.Dispose();
}
// Generic filter methods for pipeline events
private bool Filter<T>(PipelineEvent<T> evt, string blockName, Func<T, bool> predicate, IDataflowBlock block)
{
var valid = PipelineEvent<T>.IsValid(evt, this._turnManager.CurrentTurnId, predicate);
if (!valid)
{
this._logger.LogWarning($"{blockName} block: Event filtered out due to cancellation or empty payload.");
}
return valid;
}
private bool FilterDiscarded<T>(PipelineEvent<T> evt, string blockName)
{
this._logger.LogWarning($"{blockName} block: Event filtered out due to cancellation or empty.");
return true;
}
private void Link<T>(
ISourceBlock<PipelineEvent<T>> source,
ITargetBlock<PipelineEvent<T>> target,
string blockName,
Func<T, bool> predicate)
{
source.LinkTo(target, this._linkOptions, evt => this.Filter(evt, blockName, predicate, source));
this.DiscardFiltered(source, blockName);
}
private void DiscardFiltered<T>(ISourceBlock<PipelineEvent<T>> block, string blockName) => block.LinkTo(DataflowBlock.NullTarget<PipelineEvent<T>>(), this._linkOptions, evt => this.FilterDiscarded(evt, blockName));
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.SemanticKernel;
internal static class Program
{
internal static async Task Main(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddUserSecrets<OpenAIOptions>();
// Adding configuration from appsettings.json and environment variables
builder.Services.ConfigureOptions<OpenAIOptions>(OpenAIOptions.SectionName);
builder.Services.ConfigureOptions<ChatOptions>(ChatOptions.SectionName);
// Configure Semantic Kernel in DI container
builder.Services
.AddKernel()
.AddOpenAIChatCompletion(
modelId: builder.Configuration[$"{OpenAIOptions.SectionName}:ChatModelId"]!,
apiKey: builder.Configuration[$"{OpenAIOptions.SectionName}:ApiKey"]!
);
// Register audio chat pipeline services
builder.Services.AddSingleton<AudioPlaybackService>();
builder.Services.AddSingleton<SpeechToTextService>();
builder.Services.AddSingleton<TextToSpeechService>();
builder.Services.AddSingleton<ChatService>();
builder.Services.AddSingleton<TurnManager>();
builder.Services.AddSingleton<VadService>();
builder.Services.AddSingleton<AudioSourceService>();
// Register audio chat pipeline
builder.Services.AddTransient<VoiceChatPipeline>();
using var host = builder.Build();
// Setting up graceful shutdown on Ctrl+C
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
// Run the voice chat pipeline
using var pipeline = host.Services.GetRequiredService<VoiceChatPipeline>();
await pipeline.RunAsync(cts.Token);
}
private static void ConfigureOptions<TOptions>(this IServiceCollection services, string sectionName) where TOptions : class =>
services
.AddOptions<TOptions>()
.BindConfiguration(sectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
}
+56
View File
@@ -0,0 +1,56 @@
# Voice Chat
This sample demonstrates a simple voice chat application built with Semantic Kernel and OpenAIs API for speech-to-text (STT), chat completion, and text-to-speech (TTS).
It captures audio from the microphone, processes it through a pipeline, and plays back the AI-generated responses:
Microphone → VAD → STT → Chat (Semantic Kernel) → TTS → Speaker
## Purpose
This is not a complete application, but a **starting point** that shows how an audio pipeline can be built using Semantic Kernel and the .NET DataFlow library.
Its intended to help you understand how to structure audio processing with SK, rather than provide a production-ready chat app.
## Voice Activity Detection
This demo uses **WebRTC VAD** to detect when the user starts and stops speaking.
Other model-based approaches can also be used, such as **[Silero VAD](https://github.com/snakers4/silero-vad/tree/master/examples/csharp)**, which may provide higher accuracy.
## Known Limitations
- **Latency**
This demo processes audio in discrete steps (non-streaming). Response times are therefore large, sometimes over 20 seconds.
To reduce latency, you should use **streaming STT and TTS services** (see below).
- **OpenAI Free Tier Rate Limits**
Very high latencies can also be caused by OpenAIs rate limits, especially on free-tier accounts. See the OpenAI [rate limits documentation](https://platform.openai.com/docs/guides/rate-limits) for more details.
- **Latency Resources**
For more on latency in voice AI pipelines, see this resource: [Latency in LLM Voice Pipelines](https://voiceaiandvoiceagents.com/#latency-llm).
## Suggested Streaming Services
To reduce latency in real-world scenarios, you can integrate with streaming speech services such as:
- **Speech-to-Text (STT)**
- [OpenAI Realtime API (Whisper streaming)](https://platform.openai.com/docs/guides/realtime)
- [Azure Cognitive Services Speech-to-Text](https://learn.microsoft.com/azure/cognitive-services/speech-service/speech-to-text)
- [Deepgram Streaming STT](https://developers.deepgram.com/docs/streaming)
- [AssemblyAI Streaming STT](https://www.assemblyai.com/docs/real-time-speech-recognition)
- **Text-to-Speech (TTS)**
- [OpenAI Realtime API (TTS streaming)](https://platform.openai.com/docs/guides/realtime)
- [Azure Cognitive Services Text-to-Speech](https://learn.microsoft.com/azure/cognitive-services/speech-service/text-to-speech)
- [Amazon Polly Neural TTS](https://docs.aws.amazon.com/polly/latest/dg/what-is.html)
## How to Run
1. Store your API key securely with [.NET user-secrets](https://learn.microsoft.com/aspnet/core/security/app-secrets):
dotnet user-secrets set "OpenAI:ApiKey" "your-openai-api-key"
2. Build and run the sample:
dotnet run --project samples/Demos/VoiceChat
3. Speak into your microphone and listen for the AI response through your speakers.
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
using NAudio.Wave;
public class AudioPlaybackService(ILogger<AudioPlaybackService> logger) : IDisposable
{
private readonly ILogger<AudioPlaybackService> _logger = logger;
private WaveOutEvent? _waveOut;
private bool _isPlaying;
public Task PipelineActionAsync(SpeechEvent evt) => this.PlayAudioAsync(evt.Payload, evt.CancellationToken);
public void Dispose() => this._waveOut?.Dispose();
private async Task PlayAudioAsync(byte[] audioData, CancellationToken cancellationToken = default)
{
if (this._isPlaying)
{
this._logger.LogError("Ignoring audio playback. Already playing.");
return;
}
this._logger.LogInformation("Starting audio playback...");
try
{
using var audioStream = new MemoryStream(audioData);
using var audioFileReader = new Mp3FileReader(audioStream);
this._waveOut = new WaveOutEvent();
var tcs = new TaskCompletionSource();
this._waveOut.PlaybackStopped += (sender, e) =>
{
this._isPlaying = false;
tcs.TrySetResult();
if (e.Exception != null)
{
this._logger.LogWarning($"Playback error occurred: {e.Exception.Message}");
}
};
this._waveOut.Init(audioFileReader);
this._isPlaying = true;
this._waveOut.Play();
this._logger.LogInformation("Audio chunk playback started. You can speak to interrupt.");
// Wait for playback to complete or cancellation
await using (cancellationToken.Register(() =>
{
this._logger.LogInterrupted();
this?._waveOut.Stop();
tcs.TrySetCanceled();
}))
{
await tcs.Task.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
this._logger.LogInterrupted();
this._isPlaying = false;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error during audio playback");
this._isPlaying = false;
throw;
}
finally
{
this._waveOut?.Dispose();
this._waveOut = null;
}
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using NAudio.Wave;
public class AudioSourceService
{
private const int BitsPerByte = 8;
private const int MillisecondsPerSecond = 1000;
private readonly ILogger<AudioSourceService> _logger;
private readonly int _frameBytes;
private readonly WaveFormat _waveFormat;
public AudioSourceService(ILogger<AudioSourceService> logger)
{
this._logger = logger;
// Calculate frame size in bytes: (samples/sec * channels * bits/sample * milliseconds) / (bits/byte * ms/sec)
this._frameBytes = (AudioOptions.SampleRate * AudioOptions.Channels * AudioOptions.BitsPerSample * AudioOptions.BufferMilliseconds) / (BitsPerByte * MillisecondsPerSecond);
this._waveFormat = new WaveFormat(AudioOptions.SampleRate, AudioOptions.BitsPerSample, AudioOptions.Channels);
}
// Generate audio chunks from the microphone input.
public async IAsyncEnumerable<byte[]> GetAudioChunksAsync([EnumeratorCancellation] CancellationToken token = default)
{
var chunks = new Queue<byte[]>();
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var semaphore = new SemaphoreSlim(0);
using var waveIn = new WaveInEvent
{
WaveFormat = this._waveFormat,
BufferMilliseconds = AudioOptions.BufferMilliseconds
};
waveIn.RecordingStopped += (_, e) => tcs.TrySetResult();
waveIn.DataAvailable += (_, e) =>
{
if (e.Buffer.Length == this._frameBytes)
{
chunks.Enqueue(e.Buffer);
semaphore.Release();
}
else
{
this._logger.LogWarning($"Ignoring received audio data of unexpected length: {e.Buffer.Length} bytes. Expected {this._frameBytes}");
}
};
waveIn.StartRecording();
try
{
while (!token.IsCancellationRequested)
{
await semaphore.WaitAsync(token).ConfigureAwait(false);
if (chunks.TryDequeue(out var chunk))
{
yield return chunk;
}
}
}
finally
{
waveIn.StopRecording();
await tcs.Task.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
public class ChatService
{
private readonly ILogger<ChatService> _logger;
private readonly IChatCompletionService _chatCompletionService;
private readonly ChatHistory _chatHistory;
private readonly OpenAIPromptExecutionSettings _options;
private readonly ChatOptions _chatOptions;
public ChatService(ILogger<ChatService> logger, IChatCompletionService chatCompletionService, IOptions<ChatOptions> chatOptions)
{
this._logger = logger;
this._chatCompletionService = chatCompletionService;
this._chatOptions = chatOptions.Value;
this._options = new OpenAIPromptExecutionSettings
{
Temperature = this._chatOptions.Temperature,
MaxTokens = this._chatOptions.MaxTokens,
TopP = this._chatOptions.TopP
};
// Initialize chat history with system message from configuration
this._chatHistory = [];
this._chatHistory.AddSystemMessage(this._chatOptions.SystemMessage);
}
/// <summary>
/// Pipeline integration method for processing transcription events into chat responses.
/// </summary>
public async IAsyncEnumerable<ChatEvent> TransformAsync(TranscriptionEvent evt)
{
await foreach (var response in this.GetResponseStreamAsync(evt.Payload!, evt.CancellationToken).ConfigureAwait(false))
{
yield return new ChatEvent(evt.TurnId, evt.CancellationToken, response);
}
}
private async IAsyncEnumerable<string> GetResponseStreamAsync(
string input,
[EnumeratorCancellation] CancellationToken token = default)
{
if (string.IsNullOrWhiteSpace(input))
{
yield break;
}
var buffer = "";
this._logger.LogInformation($"USER: {input}");
this._chatHistory.AddUserMessage(input);
await foreach (var result in this._chatCompletionService.GetStreamingChatMessageContentsAsync(this._chatHistory, this._options, cancellationToken: token))
{
buffer += result?.Content ?? string.Empty;
if (buffer.Length >= this._chatOptions.StreamingChunkSizeThreshold && (buffer[^1] == '.' || buffer[^1] == '?' || buffer[^1] == '!'))
{
this._logger.LogInformation($"LLM delta: {buffer}");
yield return buffer;
buffer = string.Empty;
}
}
if (!string.IsNullOrWhiteSpace(buffer))
{
this._logger.LogInformation($"LLM delta: {buffer}");
yield return buffer;
}
}
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenAI.Audio;
public class SpeechToTextService
{
private const float TranscriptionTemperature = 0f; // OpenAI transcription temperature for deterministic results
private const string TranscriptionLanguage = "en"; // Language code for English transcription
private const string TempAudioFileName = "audio.wav"; // Temporary filename for audio processing
private readonly ILogger<SpeechToTextService> _logger;
private readonly AudioClient _audioClient;
private readonly AudioTranscriptionOptions _transcriptionOptions;
public SpeechToTextService(ILogger<SpeechToTextService> logger, IOptions<OpenAIOptions> openAIOptions)
{
this._logger = logger;
var options = openAIOptions.Value;
this._audioClient = new AudioClient(options.TranscriptionModelId, options.ApiKey);
// Initialize transcription options as a field
this._transcriptionOptions = new AudioTranscriptionOptions
{
Temperature = TranscriptionTemperature,
Language = TranscriptionLanguage,
};
}
public async Task<TranscriptionEvent> TransformAsync(AudioEvent evt) =>
new(evt.TurnId, evt.CancellationToken, await this.TranscribeAsync(evt.Payload, evt.CancellationToken));
private async Task<string?> TranscribeAsync(AudioData audioData, CancellationToken cancellationToken = default)
{
return await Tools.ExecutePipelineOperationAsync(
operation: async () =>
{
var wavData = ConvertToWav(audioData);
using var ms = new MemoryStream(wavData);
AudioTranscription result = await this._audioClient.TranscribeAudioAsync(ms, TempAudioFileName, this._transcriptionOptions, cancellationToken);
return result.Text;
},
operationName: "STT",
logger: this._logger,
cancellationToken: cancellationToken,
defaultValue: string.Empty,
resultFormatter: text => text ?? "No text transcribed"
);
}
private static byte[] ConvertToWav(AudioData audioData)
{
using var ms = new MemoryStream();
var waveFormat = new NAudio.Wave.WaveFormat(audioData.SampleRate, audioData.BitsPerSample, audioData.Channels);
using (var writer = new NAudio.Wave.WaveFileWriter(ms, waveFormat))
{
writer.Write(audioData.Data, 0, audioData.Data.Length);
}
return ms.ToArray();
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenAI.Audio;
public class TextToSpeechService
{
// Text-to-Speech synthesis constants
private static readonly GeneratedSpeechVoice s_defaultSpeechVoice = GeneratedSpeechVoice.Alloy; // OpenAI voice selection for TTS
private readonly ILogger<TextToSpeechService> _logger;
private readonly AudioClient _audioClient;
public TextToSpeechService(ILogger<TextToSpeechService> logger, IOptions<OpenAIOptions> openAIOptions)
{
this._logger = logger;
var options = openAIOptions.Value;
this._audioClient = new AudioClient(options.SpeechModelId, options.ApiKey);
}
// Pipeline integration method for transforming chat events into speech events.
public async Task<SpeechEvent> TransformAsync(ChatEvent evt) =>
new(evt.TurnId, evt.CancellationToken, await this.SynthesizeAsync(evt.Payload, evt.CancellationToken));
// Synthesizes speech from text using OpenAI's TTS API.
private Task<byte[]> SynthesizeAsync(string text, CancellationToken token) =>
Tools.ExecutePipelineOperationAsync(
operation: async () =>
{
BinaryData speech = await this._audioClient.GenerateSpeechAsync(text, s_defaultSpeechVoice, null, token);
return speech.ToArray();
},
operationName: "TTS",
logger: this._logger,
cancellationToken: token,
defaultValue: Array.Empty<byte>(),
resultFormatter: audioData => $"text: {text}. Audio size: {audioData.Length} bytes"
);
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using WebRtcVadSharp;
public class VadService : IDisposable
{
// Voice Activity Detection Constants
private const int MaxPrerollFrames = 10; // Maximum number of frames to keep before speech detection
private const int SilenceThresholdFrames = 20; // Number of consecutive silent frames to end speech segment
private const double MinSpeechDurationSeconds = 0.8; // Minimum duration in seconds for valid speech utterance
private readonly WebRtcVad _vad = new() { OperatingMode = OperatingMode.VeryAggressive };
private readonly TurnManager _turnManager;
// State for pipeline processing
private readonly Queue<byte[]> _preroll = new();
private readonly List<byte> _speech = [];
private int _silenceFrames = 0;
private bool _inSpeech = false;
public VadService(TurnManager turnManager)
{
this._turnManager = turnManager;
}
/// <summary>
/// Pipeline integration method for processing audio chunk events into speech segments.
/// This method handles the pipeline event creation and processing.
/// </summary>
/// <param name="audioChunkEvent">Audio chunk event from the pipeline.</param>
/// <returns>Audio events when speech segments are detected.</returns>
public IEnumerable<AudioEvent> Transform(AudioChunkEvent audioChunkEvent)
{
foreach (var audioEvent in this.ProcessAudioChunk(audioChunkEvent.Payload))
{
yield return audioEvent;
}
}
/// <summary>
/// Creates an AudioChunkEvent from raw audio data for pipeline processing.
/// </summary>
/// <param name="audioChunk">Raw audio chunk from microphone.</param>
/// <returns>AudioChunkEvent ready for pipeline processing.</returns>
public AudioChunkEvent CreateAudioChunkEvent(byte[] audioChunk)
{
return new AudioChunkEvent(this._turnManager.CurrentTurnId, this._turnManager.CurrentToken, audioChunk);
}
/// <summary>
/// Legacy pipeline integration method for processing raw audio chunks into speech segments.
/// </summary>
/// <param name="audioChunk">Raw audio chunk from microphone.</param>
/// <returns>Audio events when speech segments are detected.</returns>
public IEnumerable<AudioEvent> Transform(byte[] audioChunk)
{
foreach (var audioEvent in this.ProcessAudioChunk(audioChunk))
{
yield return audioEvent;
}
}
/// <summary>
/// Core audio processing logic for speech detection and segmentation.
/// </summary>
/// <param name="audioChunk">Raw audio chunk to process.</param>
/// <returns>Audio events when speech segments are detected.</returns>
private IEnumerable<AudioEvent> ProcessAudioChunk(byte[] audioChunk)
{
bool voiced = this.HasSpeech(audioChunk); // audioChunk expected to be in 20ms chunks
if (!this._inSpeech)
{
this._preroll.Enqueue(audioChunk);
while (this._preroll.Count > MaxPrerollFrames)
{
this._preroll.Dequeue();
}
if (voiced)
{
this._inSpeech = true;
while (this._preroll.Count > 0)
{
this._speech.AddRange(this._preroll.Dequeue());
this._silenceFrames = 0;
}
}
}
else
{
this._speech.AddRange(audioChunk);
this._silenceFrames = voiced ? 0 : this._silenceFrames + 1;
if (this._silenceFrames >= SilenceThresholdFrames)
{
var audio = new AudioData(this._speech.ToArray(), AudioOptions.SampleRate, AudioOptions.Channels, AudioOptions.BitsPerSample);
if (audio.Duration.TotalSeconds > MinSpeechDurationSeconds)
{
this._turnManager.Interrupt();
yield return new AudioEvent(this._turnManager.CurrentTurnId, this._turnManager.CurrentToken, audio);
}
this._speech.Clear();
this._inSpeech = false;
this._silenceFrames = 0;
}
}
}
public bool HasSpeech(byte[] frame20ms) => this._vad.HasSpeech(frame20ms, SampleRate.Is16kHz, FrameLength.Is20ms);
public void Dispose() => this._vad.Dispose();
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Extensions.Logging;
public static class Tools
{
// logs a warning message indicating that an operation (such as playback) was interrupted by user voice.
public static void LogInterrupted(this ILogger logger) => logger.LogWarning("Operation is cancelled by user interrupt.");
// Executes a pipeline operation with latency logging and error handling
public static async Task<T> ExecutePipelineOperationAsync<T>(
Func<Task<T>> operation,
string operationName,
ILogger logger,
CancellationToken cancellationToken = default,
T? defaultValue = default,
Func<T, string>? resultFormatter = null)
{
var timer = Stopwatch.StartNew();
logger.LogInformation("{OperationName} starting...", operationName);
try
{
var result = await operation().ConfigureAwait(false);
timer.Stop();
var resultInfo = resultFormatter?.Invoke(result) ?? result?.ToString() ?? "";
if (string.IsNullOrEmpty(resultInfo))
{
logger.LogInformation("{OperationName} completed in {Duration:F4}sec", operationName, timer.Elapsed.TotalSeconds);
}
else
{
logger.LogInformation("{OperationName} completed in {Duration:F4}sec: {Result}", operationName, timer.Elapsed.TotalSeconds, resultInfo);
}
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogInterrupted();
return defaultValue!;
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogInterrupted();
return defaultValue!;
}
catch (Exception ex)
{
logger.LogError(ex, "Error during {OperationName}", operationName);
return defaultValue!;
}
}
public static async Task ExecutePipelineOperationAsync(
Func<Task> operation,
string operationName,
ILogger logger,
CancellationToken cancellationToken = default)
{
await ExecutePipelineOperationAsync<object?>(
async () =>
{
await operation().ConfigureAwait(false);
return null; // Return null for void operations
},
operationName,
logger,
cancellationToken,
defaultValue: null
).ConfigureAwait(false);
}
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>x64</PlatformTarget>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);VSTHRD111,CA2007,CA2254,CS8618,CS1591,CA1050,CA1063,CA1052,CA1810,CS8765,CS0718,CA1065,CA1816,CA1068,CA1815,CS0718,CA1000,RCS1036,RCS1102,SKEXP0001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<ProjectReference Include="..\..\..\src\SemanticKernel.Core\SemanticKernel.Core.csproj" />
<ProjectReference Include="..\..\..\src\Connectors\Connectors.OpenAI\Connectors.OpenAI.csproj" />
<PackageReference Include="OpenAI" />
<PackageReference Include="NAudio" />
<PackageReference Include="WebRtcVadSharp" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VoiceChat", "VoiceChat.csproj", "{39FA2091-D1DA-6DFD-03A3-A0C6781B9BDD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{39FA2091-D1DA-6DFD-03A3-A0C6781B9BDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{39FA2091-D1DA-6DFD-03A3-A0C6781B9BDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{39FA2091-D1DA-6DFD-03A3-A0C6781B9BDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{39FA2091-D1DA-6DFD-03A3-A0C6781B9BDD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7B98B6CB-A086-44A8-83BE-0FC651A5CB8E}
EndGlobalSection
EndGlobal
@@ -0,0 +1,22 @@
{
"OpenAI": {
"ApiKey": "",
"ChatModelId": "gpt-4.1", // gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-nano, o4-mini, etc.
"TranscriptionModelId": "gpt-4o-mini-transcribe", // gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1
"SpeechModelId": "gpt-4o-mini-tts" // tts-1, tts-1-hd
},
"Chat": {
"SystemMessage": "You are a helpful voice assistant. Keep your responses concise, conversational yet short, up to 4-5 sentences, as they will be spoken aloud. Avoid using special characters or formatting that doesn't translate well to speech. If you need to list items, use natural language like 'first, second, third' instead of bullet points. Also note that you may be interrupted by the user. Such as user can ask you to stop. In the case just say something short, like `OK, sure.`",
"StreamingChunkSizeThreshold": 100,
"Temperature": 0.7,
"MaxTokens": 500,
"TopP": 0.9
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}