File: OpenAITextToSpeechClientTests.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.AI.OpenAI.Tests\Microsoft.Extensions.AI.OpenAI.Tests.csproj (Microsoft.Extensions.AI.OpenAI.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenAI;
using OpenAI.Audio;
using Xunit;
 
#pragma warning disable S103 // Lines should not be too long
#pragma warning disable OPENAI001 // Experimental OpenAI APIs
 
namespace Microsoft.Extensions.AI;
 
public class OpenAITextToSpeechClientTests
{
    [Fact]
    public void AsITextToSpeechClient_InvalidArgs_Throws()
    {
        Assert.Throws<ArgumentNullException>("audioClient", () => ((AudioClient)null!).AsITextToSpeechClient());
    }
 
    [Fact]
    public void AsITextToSpeechClient_AudioClient_ProducesExpectedMetadata()
    {
        Uri endpoint = new("http://localhost/some/endpoint");
        string model = "tts-1";
 
        var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint });
 
        ITextToSpeechClient ttsClient = client.GetAudioClient(model).AsITextToSpeechClient();
        var metadata = ttsClient.GetService<TextToSpeechClientMetadata>();
        Assert.Equal("openai", metadata?.ProviderName);
        Assert.Equal(endpoint, metadata?.ProviderUri);
        Assert.Equal(model, metadata?.DefaultModelId);
    }
 
    [Fact]
    public void GetService_AudioClient_SuccessfullyReturnsUnderlyingClient()
    {
        AudioClient audioClient = new OpenAIClient(new ApiKeyCredential("key")).GetAudioClient("tts-1");
        ITextToSpeechClient ttsClient = audioClient.AsITextToSpeechClient();
        Assert.Same(ttsClient, ttsClient.GetService<ITextToSpeechClient>());
        Assert.Same(audioClient, ttsClient.GetService<AudioClient>());
        using var factory = LoggerFactory.Create(b => b.AddFakeLogging());
        using ITextToSpeechClient pipeline = ttsClient
            .AsBuilder()
            .UseLogging(factory)
            .Build();
 
        Assert.NotNull(pipeline.GetService<LoggingTextToSpeechClient>());
 
        Assert.Same(audioClient, pipeline.GetService<AudioClient>());
        Assert.IsType<LoggingTextToSpeechClient>(pipeline.GetService<ITextToSpeechClient>());
    }
 
    [Fact]
    public async Task GetAudioAsync_DefaultVoice_BasicRequestResponse()
    {
        const string Input = """
                {
                    "model": "tts-1",
                    "input": "Hello world",
                    "voice": "alloy"
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        var response = await client.GetAudioAsync("Hello world");
 
        Assert.NotNull(response);
        Assert.Equal("tts-1", response.ModelId);
        Assert.NotNull(response.RawRepresentation);
        Assert.Single(response.Contents);
        var content = Assert.IsType<DataContent>(response.Contents[0]);
        Assert.Equal("audio/mpeg", content.MediaType);
        Assert.True(content.Data.Length > 0);
    }
 
    [Fact]
    public async Task GetAudioAsync_CustomVoice_SetsVoice()
    {
        const string Input = """
                {
                    "model": "tts-1",
                    "input": "Hello world",
                    "voice": "nova"
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions
        {
            VoiceId = "nova"
        });
 
        Assert.NotNull(response);
        Assert.Single(response.Contents);
        var content = Assert.IsType<DataContent>(response.Contents[0]);
        Assert.Equal("audio/mpeg", content.MediaType);
    }
 
    [Fact]
    public async Task GetAudioAsync_SpeedMapping_SetsSpeedRatio()
    {
        const string Input = """
                {
                    "model": "tts-1",
                    "input": "Hello world",
                    "voice": "alloy",
                    "speed": 1.5
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions
        {
            Speed = 1.5f
        });
 
        Assert.NotNull(response);
        Assert.Single(response.Contents);
    }
 
    [Theory]
    [InlineData("opus", "audio/opus")]
    [InlineData("wav", "audio/wav")]
    [InlineData("mp3", "audio/mpeg")]
    [InlineData("aac", "audio/aac")]
    [InlineData("flac", "audio/flac")]
    [InlineData("pcm", "audio/l16")]
    public async Task GetAudioAsync_AudioFormat_SetsFormatAndMediaType(string audioFormat, string expectedMediaType)
    {
        string input = $$"""
                {
                    "model": "tts-1",
                    "input": "Hello world",
                    "voice": "alloy",
                    "response_format": "{{audioFormat}}"
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions
        {
            AudioFormat = audioFormat
        });
 
        Assert.NotNull(response);
        Assert.Single(response.Contents);
        var content = Assert.IsType<DataContent>(response.Contents[0]);
        Assert.Equal(expectedMediaType, content.MediaType);
    }
 
    [Fact]
    public async Task GetAudioAsync_StronglyTypedOptions_AllSent()
    {
        const string Input = """
                {
                    "model": "tts-1",
                    "input": "Hello world",
                    "voice": "echo",
                    "speed": 1.5,
                    "response_format": "opus"
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        var response = await client.GetAudioAsync("Hello world", new()
        {
            VoiceId = "echo",
            RawRepresentationFactory = (s) =>
            new SpeechGenerationOptions
            {
                SpeedRatio = 1.5f,
                ResponseFormat = GeneratedSpeechFormat.Opus
            }
        });
 
        Assert.NotNull(response);
        Assert.Single(response.Contents);
        var content = Assert.IsType<DataContent>(response.Contents[0]);
        Assert.Equal("audio/opus", content.MediaType);
    }
 
    [Fact]
    public async Task GetAudioAsync_Cancelled_Throws()
    {
        using HttpClient httpClient = new();
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        using var cancellationTokenSource = new CancellationTokenSource();
        cancellationTokenSource.Cancel();
 
        await Assert.ThrowsAsync<TaskCanceledException>(()
            => client.GetAudioAsync("Hello world", cancellationToken: cancellationTokenSource.Token));
    }
 
    [Fact]
    public async Task GetStreamingAudioAsync_Cancelled_Throws()
    {
        using HttpClient httpClient = new();
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        using var cancellationTokenSource = new CancellationTokenSource();
        cancellationTokenSource.Cancel();
 
        await Assert.ThrowsAsync<TaskCanceledException>(()
            => client
                .GetStreamingAudioAsync("Hello world", cancellationToken: cancellationTokenSource.Token)
                .GetAsyncEnumerator()
                .MoveNextAsync()
                .AsTask());
    }
 
    [Fact]
    public async Task GetStreamingAudioAsync_FallsBackToNonStreaming()
    {
        const string Input = """
                {
                    "model": "tts-1",
                    "input": "Hello streaming",
                    "voice": "alloy"
                }
                """;
 
        const string Output = "fake-audio-bytes";
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1");
 
        int updateCount = 0;
        await foreach (var update in client.GetStreamingAudioAsync("Hello streaming"))
        {
            updateCount++;
            Assert.NotNull(update);
            Assert.NotNull(update.RawRepresentation);
            Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdated, update.Kind);
            var content = update.Contents.OfType<DataContent>().Single();
            Assert.Equal("audio/mpeg", content.MediaType);
            Assert.True(content.Data.Length > 0);
        }
 
        Assert.Equal(1, updateCount);
    }
 
    [Fact]
    public async Task GetStreamingAudioAsync_StreamingModel_YieldsMultipleChunksAndUsage()
    {
        const string Input = """
                {
                    "model": "gpt-4o-mini-tts",
                    "input": "Hello streaming",
                    "voice": "alloy",
                    "stream_format": "sse"
                }
                """;
 
        const string Output = """
                event: speech.audio.delta
                data: {"type":"speech.audio.delta","audio":"AQIDBA=="}
 
                event: speech.audio.delta
                data: {"type":"speech.audio.delta","audio":"BQYHCA=="}
 
                event: speech.audio.done
                data: {"type":"speech.audio.done","usage":{"input_tokens":5,"output_tokens":10,"total_tokens":15}}
 
                data: [DONE]
 
                """;
 
        using VerbatimHttpHandler handler = new(Input, Output);
        using HttpClient httpClient = new(handler);
        using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "gpt-4o-mini-tts");
 
        List<TextToSpeechResponseUpdate> updates = [];
        await foreach (var update in client.GetStreamingAudioAsync("Hello streaming"))
        {
            updates.Add(update);
        }
 
        Assert.Equal(3, updates.Count);
 
        // First chunk: bytes 1,2,3,4
        Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdating, updates[0].Kind);
        Assert.Equal("gpt-4o-mini-tts", updates[0].ModelId);
        Assert.IsType<StreamingSpeechAudioDeltaUpdate>(updates[0].RawRepresentation);
        var data0 = updates[0].Contents.OfType<DataContent>().Single();
        Assert.Equal("audio/mpeg", data0.MediaType);
        Assert.Equal(new byte[] { 1, 2, 3, 4 }, data0.Data.ToArray());
 
        // Second chunk: bytes 5,6,7,8
        Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdating, updates[1].Kind);
        Assert.IsType<StreamingSpeechAudioDeltaUpdate>(updates[1].RawRepresentation);
        var data1 = updates[1].Contents.OfType<DataContent>().Single();
        Assert.Equal("audio/mpeg", data1.MediaType);
        Assert.Equal(new byte[] { 5, 6, 7, 8 }, data1.Data.ToArray());
 
        // Done event with usage
        Assert.Equal(TextToSpeechResponseUpdateKind.SessionClose, updates[2].Kind);
        Assert.Equal("gpt-4o-mini-tts", updates[2].ModelId);
        Assert.IsType<StreamingSpeechAudioDoneUpdate>(updates[2].RawRepresentation);
        var usage = updates[2].Contents.OfType<UsageContent>().Single();
        Assert.Equal(5, usage.Details.InputTokenCount);
        Assert.Equal(10, usage.Details.OutputTokenCount);
        Assert.Equal(15, usage.Details.TotalTokenCount);
    }
 
    private static ITextToSpeechClient CreateTextToSpeechClient(HttpClient httpClient, string modelId) =>
        new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) })
            .GetAudioClient(modelId)
            .AsITextToSpeechClient();
}