Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.SemanticKernel.Embeddings;
using Microsoft.SemanticKernel.TextGeneration;
using Microsoft.SemanticKernel.TextToAudio;
using Microsoft.SemanticKernel.TextToImage;

namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Extensions;
Expand Down Expand Up @@ -89,6 +90,25 @@ public void KernelBuilderAddAzureOpenAITextEmbeddingGenerationAddsValidService(I

#endregion

#region Text to audio

[Fact]
public void KernelBuilderAddAzureOpenAITextToAudioAddsValidService()
{
// Arrange
var sut = Kernel.CreateBuilder();

// Act
var service = sut.AddAzureOpenAITextToAudio("deployment-name", "https://endpoint", "api-key")
.Build()
.GetRequiredService<ITextToAudioService>();

// Assert
Assert.IsType<AzureOpenAITextToAudioService>(service);
}

#endregion

#region Text to image

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.SemanticKernel.Embeddings;
using Microsoft.SemanticKernel.TextGeneration;
using Microsoft.SemanticKernel.TextToAudio;
using Microsoft.SemanticKernel.TextToImage;

namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Extensions;
Expand Down Expand Up @@ -89,6 +90,25 @@ public void ServiceCollectionAddAzureOpenAITextEmbeddingGenerationAddsValidServi

#endregion

#region Text to audio

[Fact]
public void ServiceCollectionAddAzureOpenAITextToAudioAddsValidService()
{
// Arrange
var sut = new ServiceCollection();

// Act
var service = sut.AddAzureOpenAITextToAudio("deployment-name", "https://endpoint", "api-key")
.BuildServiceProvider()
.GetRequiredService<ITextToAudioService>();

// Assert
Assert.IsType<AzureOpenAITextToAudioService>(service);
Comment thread
SergeyMenshykh marked this conversation as resolved.
}

#endregion

#region Text to image

[Theory]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Moq;

namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Services;

/// <summary>
/// Unit tests for <see cref="AzureOpenAITextToAudioService"/> class.
/// </summary>
public sealed class AzureOpenAITextToAudioServiceTests : IDisposable
{
private readonly HttpMessageHandlerStub _messageHandlerStub;
private readonly HttpClient _httpClient;
private readonly Mock<ILoggerFactory> _mockLoggerFactory;

public AzureOpenAITextToAudioServiceTests()
{
this._messageHandlerStub = new HttpMessageHandlerStub();
this._httpClient = new HttpClient(this._messageHandlerStub, false);
this._mockLoggerFactory = new Mock<ILoggerFactory>();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void ConstructorsAddRequiredMetadata(bool includeLoggerFactory)
{
// Arrange & Act
var service = includeLoggerFactory ?
new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", loggerFactory: this._mockLoggerFactory.Object) :
new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id");

// Assert
Assert.Equal("model-id", service.Attributes["ModelId"]);
Assert.Equal("deployment-name", service.Attributes["DeploymentName"]);
}

[Fact]
public void ItThrowsIfModelIdIsNotProvided()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AzureOpenAITextToAudioService(null!, "https://endpoint", "api-key"));
Assert.Throws<ArgumentException>(() => new AzureOpenAITextToAudioService("", "https://endpoint", "api-key"));
Assert.Throws<ArgumentException>(() => new AzureOpenAITextToAudioService(" ", "https://endpoint", "api-key"));
}

[Fact]
public async Task GetAudioContentWithInvalidSettingsThrowsExceptionAsync()
{
// Arrange
var settingsWithInvalidVoice = new AzureOpenAITextToAudioExecutionSettings("");

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient);
await using var stream = new MemoryStream(new byte[] { 0x00, 0x00, 0xFF, 0x7F });

this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};

// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(() => service.GetAudioContentsAsync("Some text", settingsWithInvalidVoice));
}

[Fact]
public async Task GetAudioContentByDefaultWorksCorrectlyAsync()
{
// Arrange
var expectedByteArray = new byte[] { 0x00, 0x00, 0xFF, 0x7F };

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient);
await using var stream = new MemoryStream(expectedByteArray);

this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};

// Act
var result = await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings("Nova"));

// Assert
var audioData = result[0].Data!.Value;
Assert.False(audioData.IsEmpty);
Assert.True(audioData.Span.SequenceEqual(expectedByteArray));
}

[Theory]
[InlineData("echo", "wav")]
[InlineData("fable", "opus")]
[InlineData("onyx", "flac")]
[InlineData("nova", "aac")]
[InlineData("shimmer", "pcm")]
public async Task GetAudioContentVoicesWorksCorrectlyAsync(string voice, string format)
{
// Arrange
byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F];

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient);
await using var stream = new MemoryStream(expectedByteArray);

this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};

// Act
var result = await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings(voice) { ResponseFormat = format });

// Assert
var requestBody = JsonSerializer.Deserialize<JsonObject>(this._messageHandlerStub.RequestContent!);
Assert.NotNull(requestBody);
Assert.Equal(voice, requestBody["voice"]?.ToString());
Assert.Equal(format, requestBody["response_format"]?.ToString());

var audioData = result[0].Data!.Value;
Assert.False(audioData.IsEmpty);
Assert.True(audioData.Span.SequenceEqual(expectedByteArray));
}

[Fact]
public async Task GetAudioContentThrowsWhenVoiceIsNotSupportedAsync()
{
// Arrange
byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F];

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient);

// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(async () => await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings("voice")));
}

[Fact]
public async Task GetAudioContentThrowsWhenFormatIsNotSupportedAsync()
{
// Arrange
byte[] expectedByteArray = [0x00, 0x00, 0xFF, 0x7F];

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", "api-key", "model-id", this._httpClient);

// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(async () => await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings() { ResponseFormat = "not supported" }));
}

[Theory]
[InlineData(true, "http://local-endpoint")]
[InlineData(false, "https://endpoint")]
public async Task GetAudioContentUsesValidBaseUrlAsync(bool useHttpClientBaseAddress, string expectedBaseAddress)
Comment thread
SergeyMenshykh marked this conversation as resolved.
{
// Arrange
var expectedByteArray = new byte[] { 0x00, 0x00, 0xFF, 0x7F };

if (useHttpClientBaseAddress)
{
this._httpClient.BaseAddress = new Uri("http://local-endpoint/path");
}

var service = new AzureOpenAITextToAudioService("deployment-name", "https://endpoint/path", "api-key", "model-id", this._httpClient);
await using var stream = new MemoryStream(expectedByteArray);

this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};

// Act
var result = await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings("Nova"));

// Assert
Assert.StartsWith(expectedBaseAddress, this._messageHandlerStub.RequestUri!.AbsoluteUri, StringComparison.InvariantCulture);
}

[Theory]
[InlineData("model-1", "model-2", "deployment", "model-2")]
[InlineData("model-1", null, "deployment", "model-1")]
[InlineData(null, "model-2", "deployment", "model-2")]
[InlineData(null, null, "deployment", "deployment")]
public async Task GetAudioContentPrioritizesModelIdOverDeploymentNameAsync(string? modelInSettings, string? modelInConstructor, string deploymentName, string expectedModel)
{
// Arrange
var expectedByteArray = new byte[] { 0x00, 0x00, 0xFF, 0x7F };

var service = new AzureOpenAITextToAudioService(deploymentName, "https://endpoint", "api-key", modelInConstructor, this._httpClient);
await using var stream = new MemoryStream(expectedByteArray);

this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};

// Act
var result = await service.GetAudioContentsAsync("Some text", new AzureOpenAITextToAudioExecutionSettings("Nova") { ModelId = modelInSettings });

// Assert
var requestBody = JsonSerializer.Deserialize<JsonObject>(this._messageHandlerStub.RequestContent!);
Assert.Equal(expectedModel, requestBody?["model"]?.ToString());
}

public void Dispose()
{
this._httpClient.Dispose();
this._messageHandlerStub.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@

<ItemGroup>
<Compile Remove="Core\ClientCore.AudioToText.cs" />
<Compile Remove="Core\ClientCore.TextToAudio.cs" />
<Compile Remove="Services\AzureOpenAIAudioToTextService.cs" />
<Compile Remove="Services\AzureOpenAITextToAudioService.cs" />
</ItemGroup>

<ItemGroup>
Expand All @@ -34,9 +32,7 @@

<ItemGroup>
<None Include="Core\ClientCore.AudioToText.cs" />
<None Include="Core\ClientCore.TextToAudio.cs" />
<None Include="Services\AzureOpenAIAudioToTextService.cs" />
<None Include="Services\AzureOpenAITextToAudioService.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,30 @@ internal partial class ClientCore
/// </summary>
/// <param name="prompt">Prompt to generate the image</param>
/// <param name="executionSettings">Text to Audio execution settings for the prompt</param>
/// <param name="modelId">Azure OpenAI model id</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>Url of the generated image</returns>
internal async Task<IReadOnlyList<AudioContent>> GetAudioContentsAsync(
string prompt,
PromptExecutionSettings? executionSettings,
string? modelId,
CancellationToken cancellationToken)
{
Verify.NotNullOrWhiteSpace(prompt);

OpenAITextToAudioExecutionSettings? audioExecutionSettings = OpenAITextToAudioExecutionSettings.FromExecutionSettings(executionSettings);
var (responseFormat, mimeType) = GetGeneratedSpeechFormatAndMimeType(audioExecutionSettings?.ResponseFormat);
AzureOpenAITextToAudioExecutionSettings audioExecutionSettings = AzureOpenAITextToAudioExecutionSettings.FromExecutionSettings(executionSettings);

var (responseFormat, mimeType) = GetGeneratedSpeechFormatAndMimeType(audioExecutionSettings.ResponseFormat);

SpeechGenerationOptions options = new()
{
ResponseFormat = responseFormat,
Speed = audioExecutionSettings?.Speed,
Speed = audioExecutionSettings.Speed,
};

ClientResult<BinaryData> response = await RunRequestAsync(() => this.Client.GetAudioClient(this.ModelId).GenerateSpeechFromTextAsync(prompt, GetGeneratedSpeechVoice(audioExecutionSettings?.Voice), options, cancellationToken)).ConfigureAwait(false);
var deploymentOrModel = this.GetModelId(audioExecutionSettings, modelId);

ClientResult<BinaryData> response = await RunRequestAsync(() => this.Client.GetAudioClient(deploymentOrModel).GenerateSpeechFromTextAsync(prompt, GetGeneratedSpeechVoice(audioExecutionSettings?.Voice), options, cancellationToken)).ConfigureAwait(false);

return [new AudioContent(response.Value.ToArray(), mimeType)];
}
Expand Down Expand Up @@ -64,4 +70,12 @@ private static (GeneratedSpeechFormat Format, string MimeType) GetGeneratedSpeec
"PCM" => (GeneratedSpeechFormat.Pcm, "audio/l16"),
_ => throw new NotSupportedException($"The format '{format}' is not supported.")
};

private string GetModelId(AzureOpenAITextToAudioExecutionSettings executionSettings, string? modelId)
{
return
!string.IsNullOrWhiteSpace(modelId) ? modelId! :
!string.IsNullOrWhiteSpace(executionSettings.ModelId) ? executionSettings.ModelId! :
this.DeploymentOrModelName;
Comment thread
SergeyMenshykh marked this conversation as resolved.
}
}
Loading