diff --git a/.gitignore b/.gitignore index 2e31c64..b1d6357 100644 --- a/.gitignore +++ b/.gitignore @@ -339,3 +339,4 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb .vscode +*.dubbed.* diff --git a/ElevenLabs-DotNet-Proxy/ElevenLabs-DotNet-Proxy.csproj b/ElevenLabs-DotNet-Proxy/ElevenLabs-DotNet-Proxy.csproj index 8265666..7608b23 100644 --- a/ElevenLabs-DotNet-Proxy/ElevenLabs-DotNet-Proxy.csproj +++ b/ElevenLabs-DotNet-Proxy/ElevenLabs-DotNet-Proxy.csproj @@ -1,34 +1,36 @@  - net6.0 + net8.0 disable disable - True - false - false Stephen Hodgson + ElevenLabs API Proxy ElevenLabs-DotNet-Proxy + ElevenLabs-DotNet-Proxy + ElevenLabs.Proxy A simple Proxy API gateway for ElevenLabs-DotNet to make authenticated requests from a front end application without exposing your API keys. 2024 https://github.com/RageAgainstThePixel/ElevenLabs-DotNet https://github.com/RageAgainstThePixel/ElevenLabs-DotNet ElevenLabs, AI, ML, API, api-proxy, proxy, gateway - ElevenLabs API Proxy - ElevenLabs-DotNet-Proxy - 2.2.2 - ElevenLabs.Proxy - Version 2.2.2 + ElevenLabsIcon.png + Readme.md + LICENSE + true + true + false + false + 3.0.0 + +Version 3.0.0 +- Renamed ElevenLabsProxyStartup to ElevenLabsProxy +- Deprecated ValidateAuthentication +Version 2.2.2 - Updated EndpointRouteBuilder with optional route prefix parameter Version 2.2.1 - Refactor with modern WebApplication builder - Added ElevenLabs.Proxy.EndpointRouteBuilder - True - false - Readme.md - True - ElevenLabsIcon.png - LICENSE @@ -36,17 +38,17 @@ Version 2.2.1 - True + true \ - True + true \ - True + true \ diff --git a/ElevenLabs-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs b/ElevenLabs-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs index 37a4f12..8e423c0 100644 --- a/ElevenLabs-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs +++ b/ElevenLabs-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs @@ -1,7 +1,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; namespace ElevenLabs.Proxy { @@ -9,7 +9,7 @@ namespace ElevenLabs.Proxy public abstract class AbstractAuthenticationFilter : IAuthenticationFilter { /// - public abstract void ValidateAuthentication(IHeaderDictionary request); + public virtual void ValidateAuthentication(IHeaderDictionary request) { } /// public abstract Task ValidateAuthenticationAsync(IHeaderDictionary request); diff --git a/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxy.cs b/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxy.cs new file mode 100644 index 0000000..89014eb --- /dev/null +++ b/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxy.cs @@ -0,0 +1,114 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System; +using System.Threading.Tasks; + +namespace ElevenLabs.Proxy +{ + /// + /// Used in ASP.NET Core WebApps to start your own ElevenLabs web api proxy. + /// + public class ElevenLabsProxy + { + private ElevenLabsClient elevenLabsClient; + private IAuthenticationFilter authenticationFilter; + + /// + /// Configures the and services. + /// + /// + public void ConfigureServices(IServiceCollection services) + => SetupServices(services.BuildServiceProvider()); + + /// + /// Configures the to handle requests and forward them to OpenAI API. + /// + /// . + /// . + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + SetupServices(app.ApplicationServices); + + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/health", HealthEndpoint); + endpoints.MapElevenLabsEndpoints(elevenLabsClient, authenticationFilter); + }); + } + + /// + /// Creates a new that acts as a proxy web api for ElevenLabs. + /// + /// type to use to validate your custom issued tokens. + /// Startup args. + /// with configured and . + public static IHost CreateDefaultHost(string[] args, ElevenLabsClient elevenLabsClient) where T : class, IAuthenticationFilter + => Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + webBuilder.ConfigureKestrel(ConfigureKestrel); + }) + .ConfigureServices(services => + { + services.AddSingleton(elevenLabsClient); + services.AddSingleton(); + }).Build(); + + /// + /// Creates a new that acts as a proxy web api for OpenAI. + /// + /// type to use to validate your custom issued tokens. + /// Startup args. + /// with configured and . + public static WebApplication CreateWebApplication(string[] args, ElevenLabsClient elevenLabsClient) where T : class, IAuthenticationFilter + { + var builder = WebApplication.CreateBuilder(args); + builder.WebHost.ConfigureKestrel(ConfigureKestrel); + builder.Services.AddSingleton(elevenLabsClient); + builder.Services.AddSingleton(); + var app = builder.Build(); + var startup = new ElevenLabsProxy(); + startup.Configure(app, app.Environment); + return app; + } + + private static void ConfigureKestrel(KestrelServerOptions options) + { + options.AllowSynchronousIO = false; + options.Limits.MinRequestBodyDataRate = null; + options.Limits.MinResponseDataRate = null; + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10); + options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); + } + + private void SetupServices(IServiceProvider serviceProvider) + { + elevenLabsClient = serviceProvider.GetRequiredService(); + authenticationFilter = serviceProvider.GetRequiredService(); + } + + private static async Task HealthEndpoint(HttpContext context) + { + // Respond with a 200 OK status code and a plain text message + context.Response.StatusCode = StatusCodes.Status200OK; + const string contentType = "text/plain"; + context.Response.ContentType = contentType; + const string content = "OK"; + await context.Response.WriteAsync(content); + } + } +} diff --git a/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxyStartup.cs b/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxyStartup.cs index e8fab3f..1ec478f 100644 --- a/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxyStartup.cs +++ b/ElevenLabs-DotNet-Proxy/Proxy/ElevenLabsProxyStartup.cs @@ -2,113 +2,22 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System; -using System.Threading.Tasks; namespace ElevenLabs.Proxy { - /// - /// Used in ASP.NET Core WebApps to start your own ElevenLabs web api proxy. - /// + [Obsolete("Use ElevenLabsProxy")] public class ElevenLabsProxyStartup { - private ElevenLabsClient elevenLabsClient; - private IAuthenticationFilter authenticationFilter; + public void ConfigureServices(IServiceCollection services) { } + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { } - /// - /// Configures the and services. - /// - /// - public void ConfigureServices(IServiceCollection services) - => SetupServices(services.BuildServiceProvider()); + public static IHost CreateDefaultHost(string[] args, ElevenLabsClient elevenLabsClient) + where T : class, IAuthenticationFilter => null; - /// - /// Configures the to handle requests and forward them to OpenAI API. - /// - /// . - /// . - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - SetupServices(app.ApplicationServices); - - app.UseHttpsRedirection(); - app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/health", HealthEndpoint); - endpoints.MapElevenLabsEndpoints(elevenLabsClient, authenticationFilter); - }); - } - - /// - /// Creates a new that acts as a proxy web api for ElevenLabs. - /// - /// type to use to validate your custom issued tokens. - /// Startup args. - /// with configured and . - public static IHost CreateDefaultHost(string[] args, ElevenLabsClient elevenLabsClient) where T : class, IAuthenticationFilter - => Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - webBuilder.ConfigureKestrel(ConfigureKestrel); - }) - .ConfigureServices(services => - { - services.AddSingleton(elevenLabsClient); - services.AddSingleton(); - }).Build(); - - /// - /// Creates a new that acts as a proxy web api for OpenAI. - /// - /// type to use to validate your custom issued tokens. - /// Startup args. - /// with configured and . - public static WebApplication CreateWebApplication(string[] args, ElevenLabsClient elevenLabsClient) where T : class, IAuthenticationFilter - { - var builder = WebApplication.CreateBuilder(args); - builder.WebHost.ConfigureKestrel(ConfigureKestrel); - builder.Services.AddSingleton(elevenLabsClient); - builder.Services.AddSingleton(); - var app = builder.Build(); - var startup = new ElevenLabsProxyStartup(); - startup.Configure(app, app.Environment); - return app; - } - - private static void ConfigureKestrel(KestrelServerOptions options) - { - options.AllowSynchronousIO = false; - options.Limits.MinRequestBodyDataRate = null; - options.Limits.MinResponseDataRate = null; - options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10); - options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); - } - - private void SetupServices(IServiceProvider serviceProvider) - { - elevenLabsClient = serviceProvider.GetRequiredService(); - authenticationFilter = serviceProvider.GetRequiredService(); - } - - private static async Task HealthEndpoint(HttpContext context) - { - // Respond with a 200 OK status code and a plain text message - context.Response.StatusCode = StatusCodes.Status200OK; - const string contentType = "text/plain"; - context.Response.ContentType = contentType; - const string content = "OK"; - await context.Response.WriteAsync(content); - } + public static WebApplication CreateWebApplication(string[] args, ElevenLabsClient elevenLabsClient) + where T : class, IAuthenticationFilter => null; } -} +} \ No newline at end of file diff --git a/ElevenLabs-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs b/ElevenLabs-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs index c3ebfbd..995a02c 100644 --- a/ElevenLabs-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs +++ b/ElevenLabs-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs @@ -57,9 +57,10 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) { try { +#pragma warning disable CS0618 // Type or member is obsolete // ReSharper disable once MethodHasAsyncOverload - // just in case either method is implemented we call it twice. authenticationFilter.ValidateAuthentication(httpContext.Request.Headers); +#pragma warning restore CS0618 // Type or member is obsolete await authenticationFilter.ValidateAuthenticationAsync(httpContext.Request.Headers); var method = new HttpMethod(httpContext.Request.Method); @@ -70,6 +71,11 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) using var request = new HttpRequestMessage(method, uri); request.Content = new StreamContent(httpContext.Request.Body); + if (httpContext.Request.Body.CanSeek) + { + httpContext.Request.Body.Position = 0; + } + if (httpContext.Request.ContentType != null) { request.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(httpContext.Request.ContentType); @@ -80,21 +86,13 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) foreach (var (key, value) in proxyResponse.Headers) { - if (excludedHeaders.Contains(key)) - { - continue; - } - + if (excludedHeaders.Contains(key)) { continue; } httpContext.Response.Headers[key] = value.ToArray(); } foreach (var (key, value) in proxyResponse.Content.Headers) { - if (excludedHeaders.Contains(key)) - { - continue; - } - + if (excludedHeaders.Contains(key)) { continue; } httpContext.Response.Headers[key] = value.ToArray(); } @@ -103,6 +101,7 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) if (httpContext.Response.ContentType.Equals(streamingContent)) { + using var reader = new StreamReader(await request.Content.ReadAsStreamAsync()); var stream = await proxyResponse.Content.ReadAsStreamAsync(); await WriteServerStreamEventsAsync(httpContext, stream); } @@ -119,7 +118,7 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) catch (Exception e) { httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; - var response = JsonSerializer.Serialize(new { error = new { e.Message, e.StackTrace } }); + var response = JsonSerializer.Serialize(new { error = new { message = e.Message, stackTrace = e.StackTrace } }); await httpContext.Response.WriteAsync(response); } diff --git a/ElevenLabs-DotNet-Proxy/Proxy/IAuthenticationFilter.cs b/ElevenLabs-DotNet-Proxy/Proxy/IAuthenticationFilter.cs index 2b692e8..f3e6e28 100644 --- a/ElevenLabs-DotNet-Proxy/Proxy/IAuthenticationFilter.cs +++ b/ElevenLabs-DotNet-Proxy/Proxy/IAuthenticationFilter.cs @@ -1,6 +1,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using Microsoft.AspNetCore.Http; +using System; using System.Security.Authentication; using System.Threading.Tasks; @@ -17,6 +18,7 @@ public interface IAuthenticationFilter /// /// /// + [Obsolete("Use Async overload")] void ValidateAuthentication(IHeaderDictionary request); /// diff --git a/ElevenLabs-DotNet-Proxy/Readme.md b/ElevenLabs-DotNet-Proxy/Readme.md index 8aa3ae8..2f707e2 100644 --- a/ElevenLabs-DotNet-Proxy/Readme.md +++ b/ElevenLabs-DotNet-Proxy/Readme.md @@ -49,6 +49,7 @@ In this example, we demonstrate how to set up and use `ElevenLabsProxyStartup` i 1. Create a new [ASP.NET Core minimal web API](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-6.0) project. 2. Add the ElevenLabs-DotNet nuget package to your project. - Powershell install: `Install-Package ElevenLabs-DotNet-Proxy` + - Dotnet install: `dotnet add package ElevenLabs-DotNet-Proxy` - Manually editing .csproj: `` 3. Create a new class that inherits from `AbstractAuthenticationFilter` and override the `ValidateAuthentication` method. This will implement the `IAuthenticationFilter` that you will use to check user session token against your internal server. 4. In `Program.cs`, create a new proxy web application by calling `ElevenLabsProxyStartup.CreateWebApplication` method, passing your custom `AuthenticationFilter` as a type argument. @@ -59,16 +60,6 @@ public partial class Program { private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request["xi-api-key"].ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { await Task.CompletedTask; // remote resource call diff --git a/ElevenLabs-DotNet-Tests-Proxy/ElevenLabs-DotNet-Tests-Proxy.csproj b/ElevenLabs-DotNet-Tests-Proxy/ElevenLabs-DotNet-Tests-Proxy.csproj index c818a32..c523cdf 100644 --- a/ElevenLabs-DotNet-Tests-Proxy/ElevenLabs-DotNet-Tests-Proxy.csproj +++ b/ElevenLabs-DotNet-Tests-Proxy/ElevenLabs-DotNet-Tests-Proxy.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 enable false false diff --git a/ElevenLabs-DotNet-Tests-Proxy/Program.cs b/ElevenLabs-DotNet-Tests-Proxy/Program.cs index 37d5d1d..7e77714 100644 --- a/ElevenLabs-DotNet-Tests-Proxy/Program.cs +++ b/ElevenLabs-DotNet-Tests-Proxy/Program.cs @@ -18,16 +18,6 @@ public partial class Program // ReSharper disable once ClassNeverInstantiated.Local private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request["xi-api-key"].ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { await Task.CompletedTask; // remote resource call @@ -43,9 +33,9 @@ public override async Task ValidateAuthenticationAsync(IHeaderDictionary request public static void Main(string[] args) { - var auth = ElevenLabsAuthentication.LoadFromEnv(); + var auth = ElevenLabsAuthentication.LoadFromEnvironment(); var client = new ElevenLabsClient(auth); - ElevenLabsProxyStartup.CreateWebApplication(args, client).Run(); + ElevenLabsProxy.CreateWebApplication(args, client).Run(); } } } \ No newline at end of file diff --git a/ElevenLabs-DotNet-Tests/AbstractTestFixture.cs b/ElevenLabs-DotNet-Tests/AbstractTestFixture.cs index 8681f7f..01a7810 100644 --- a/ElevenLabs-DotNet-Tests/AbstractTestFixture.cs +++ b/ElevenLabs-DotNet-Tests/AbstractTestFixture.cs @@ -31,6 +31,7 @@ protected AbstractTestFixture() var settings = new ElevenLabsClientSettings(domain: domain); var auth = new ElevenLabsAuthentication(TestUserToken); ElevenLabsClient = new ElevenLabsClient(auth, settings, HttpClient); + // ElevenLabsClient.EnableDebug = true; } } } \ No newline at end of file diff --git a/ElevenLabs-DotNet-Tests/ElevenLabs-DotNet-Tests.csproj b/ElevenLabs-DotNet-Tests/ElevenLabs-DotNet-Tests.csproj index f849cd1..d96b7fe 100644 --- a/ElevenLabs-DotNet-Tests/ElevenLabs-DotNet-Tests.csproj +++ b/ElevenLabs-DotNet-Tests/ElevenLabs-DotNet-Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 ElevenLabs disable disable diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_000_Proxy.cs b/ElevenLabs-DotNet-Tests/TestFixture_000_Proxy.cs similarity index 93% rename from ElevenLabs-DotNet-Tests/Test_Fixture_000_Proxy.cs rename to ElevenLabs-DotNet-Tests/TestFixture_000_Proxy.cs index 6bbd79d..a4108a0 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_000_Proxy.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_000_Proxy.cs @@ -8,12 +8,12 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_000_Proxy : AbstractTestFixture + internal class TestFixture_000_Proxy : AbstractTestFixture { [Test] public async Task Test_01_Health() { - var response = await HttpClient.GetAsync("/health"); + using var response = await HttpClient.GetAsync("/health"); var responseAsString = await response.Content.ReadAsStringAsync(); Console.WriteLine($"[{response.StatusCode}] {responseAsString}"); Assert.IsTrue(HttpStatusCode.OK == response.StatusCode); diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_00_Authentication.cs b/ElevenLabs-DotNet-Tests/TestFixture_00_Authentication.cs similarity index 87% rename from ElevenLabs-DotNet-Tests/Test_Fixture_00_Authentication.cs rename to ElevenLabs-DotNet-Tests/TestFixture_00_Authentication.cs index 51f6c68..e068dce 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_00_Authentication.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_00_Authentication.cs @@ -8,29 +8,29 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_00_Authentication + internal class TestFixture_00_Authentication { [SetUp] public void Setup() { var authJson = new AuthInfo("key-test12"); var authText = JsonSerializer.Serialize(authJson); - File.WriteAllText(".elevenlabs", authText); + File.WriteAllText(ElevenLabsAuthentication.CONFIG_FILE, authText); } [Test] - public void Test_01_GetAuthFromEnv() + public void Test_01_GetAuthFromEnvironment() { - var auth = ElevenLabsAuthentication.LoadFromEnv(); + var auth = ElevenLabsAuthentication.LoadFromEnvironment(); Assert.IsNotNull(auth); Assert.IsNotNull(auth.ApiKey); Assert.IsNotEmpty(auth.ApiKey); } [Test] - public void Test_02_GetAuthFromFile() + public void Test_02_GetAuthFromPath() { - var auth = ElevenLabsAuthentication.LoadFromDirectory(); + var auth = ElevenLabsAuthentication.LoadFromPath(ElevenLabsAuthentication.CONFIG_FILE); Assert.IsNotNull(auth); Assert.IsNotNull(auth.ApiKey); Assert.AreEqual("key-test12", auth.ApiKey); @@ -119,9 +119,9 @@ public void Test_09_CustomDomainConfigurationSettings() [TearDown] public void TearDown() { - if (File.Exists(".elevenlabs")) + if (File.Exists(ElevenLabsAuthentication.CONFIG_FILE)) { - File.Delete(".elevenlabs"); + File.Delete(ElevenLabsAuthentication.CONFIG_FILE); } } } diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_01_UserEndpoint.cs b/ElevenLabs-DotNet-Tests/TestFixture_01_UserEndpoint.cs similarity index 91% rename from ElevenLabs-DotNet-Tests/Test_Fixture_01_UserEndpoint.cs rename to ElevenLabs-DotNet-Tests/TestFixture_01_UserEndpoint.cs index 82609d4..b4fc2d6 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_01_UserEndpoint.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_01_UserEndpoint.cs @@ -5,7 +5,7 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_01_UserEndpoint : AbstractTestFixture + internal class TestFixture_01_UserEndpoint : AbstractTestFixture { [Test] public async Task Test_01_GetUserInfo() diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_06_Models.cs b/ElevenLabs-DotNet-Tests/TestFixture_02_Models.cs similarity index 92% rename from ElevenLabs-DotNet-Tests/Test_Fixture_06_Models.cs rename to ElevenLabs-DotNet-Tests/TestFixture_02_Models.cs index 4114ba3..2d9b8a2 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_06_Models.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_02_Models.cs @@ -6,7 +6,7 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_06_Models : AbstractTestFixture + internal class TestFixture_02_Models : AbstractTestFixture { [Test] public async Task Test_01_GetModels() diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_02_VoicesEndpoint.cs b/ElevenLabs-DotNet-Tests/TestFixture_03_VoicesEndpoint.cs similarity index 89% rename from ElevenLabs-DotNet-Tests/Test_Fixture_02_VoicesEndpoint.cs rename to ElevenLabs-DotNet-Tests/TestFixture_03_VoicesEndpoint.cs index 9d0f4e4..1fd9290 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_02_VoicesEndpoint.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_03_VoicesEndpoint.cs @@ -10,10 +10,10 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_02_VoicesEndpoint : AbstractTestFixture + internal class TestFixture_03_VoicesEndpoint : AbstractTestFixture { [Test] - public async Task Test_01_GetVoices() + public async Task Test_01_01_GetVoices() { Assert.NotNull(ElevenLabsClient.VoicesEndpoint); var results = await ElevenLabsClient.VoicesEndpoint.GetAllVoicesAsync(); @@ -26,6 +26,20 @@ public async Task Test_01_GetVoices() } } + [Test] + public async Task Test_01_02_GetSharedVoices() + { + Assert.NotNull(ElevenLabsClient.SharedVoicesEndpoint); + var results = await ElevenLabsClient.SharedVoicesEndpoint.GetSharedVoicesAsync(); + Assert.NotNull(results); + Assert.IsNotEmpty(results.Voices); + + foreach (var voice in results.Voices) + { + Console.WriteLine($"{voice.OwnerId} | {voice.VoiceId} | {voice.Date} | {voice.Name}"); + } + } + [Test] public async Task Test_02_GetDefaultVoiceSettings() { @@ -79,7 +93,8 @@ public async Task Test_05_AddVoice() { "accent", "american" } }; var clipPath = Path.GetFullPath("../../../Assets/test_sample_01.ogg"); - var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync("Test Voice", new[] { clipPath }, testLabels); + var request = new VoiceRequest("Test Voice", clipPath, testLabels); + var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync(request); Assert.NotNull(result); Console.WriteLine($"{result.Name}"); Assert.IsNotEmpty(result.Samples); @@ -95,7 +110,8 @@ public async Task Test_06_AddVoiceFromByteArray() }; var clipPath = Path.GetFullPath("../../../Assets/test_sample_01.ogg"); var clipData = await File.ReadAllBytesAsync(clipPath); - var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync("Test Voice", new[] { clipData }, testLabels); + var request = new VoiceRequest("Test Voice", clipData, testLabels); + var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync(request); Assert.NotNull(result); Console.WriteLine($"{result.Name}"); Assert.IsNotEmpty(result.Samples); @@ -113,7 +129,8 @@ public async Task Test_07_AddVoiceFromStream() var clipPath = Path.GetFullPath("../../../Assets/test_sample_01.ogg"); await using var fs = File.OpenRead(clipPath); - var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync("Test Voice", new[] { fs }, testLabels); + var request = new VoiceRequest("Test Voice", fs, testLabels); + var result = await ElevenLabsClient.VoicesEndpoint.AddVoiceAsync(request); Assert.NotNull(result); Console.WriteLine($"{result.Name}"); Assert.IsNotEmpty(result.Samples); @@ -134,7 +151,7 @@ public async Task Test_08_EditVoice() { "key", "value" } }; var clipPath = Path.GetFullPath("../../../Assets/test_sample_01.ogg"); - var result = await ElevenLabsClient.VoicesEndpoint.EditVoiceAsync(voiceToEdit, new[] { clipPath }, testLabels); + var result = await ElevenLabsClient.VoicesEndpoint.EditVoiceAsync(voiceToEdit, [clipPath], testLabels); Assert.NotNull(result); Assert.IsTrue(result); } diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_03_TextToSpeechEndpoint.cs b/ElevenLabs-DotNet-Tests/TestFixture_04_TextToSpeechEndpoint.cs similarity index 95% rename from ElevenLabs-DotNet-Tests/Test_Fixture_03_TextToSpeechEndpoint.cs rename to ElevenLabs-DotNet-Tests/TestFixture_04_TextToSpeechEndpoint.cs index dbd31d8..545f487 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_03_TextToSpeechEndpoint.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_04_TextToSpeechEndpoint.cs @@ -8,7 +8,7 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_03_TextToSpeechEndpoint : AbstractTestFixture + internal class TestFixture_04_TextToSpeechEndpoint : AbstractTestFixture { [Test] public async Task Test_01_TextToSpeech() diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_05_VoiceGeneration.cs b/ElevenLabs-DotNet-Tests/TestFixture_05_VoiceGeneration.cs similarity index 96% rename from ElevenLabs-DotNet-Tests/Test_Fixture_05_VoiceGeneration.cs rename to ElevenLabs-DotNet-Tests/TestFixture_05_VoiceGeneration.cs index dd67898..87ce52d 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_05_VoiceGeneration.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_05_VoiceGeneration.cs @@ -9,7 +9,7 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_05_VoiceGeneration : AbstractTestFixture + internal class TestFixture_05_VoiceGeneration : AbstractTestFixture { [Test] public async Task Test_01_GetVoiceGenerationOptions() diff --git a/ElevenLabs-DotNet-Tests/TestFixture_06_SoundGeneration.cs b/ElevenLabs-DotNet-Tests/TestFixture_06_SoundGeneration.cs new file mode 100644 index 0000000..472f5b2 --- /dev/null +++ b/ElevenLabs-DotNet-Tests/TestFixture_06_SoundGeneration.cs @@ -0,0 +1,96 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.SoundGeneration; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace ElevenLabs.Tests +{ + internal class TestFixture_06_SoundGeneration : AbstractTestFixture + { + [Test] + public async Task Test_01_GenerateSound() + { + Assert.NotNull(ElevenLabsClient.SoundGenerationEndpoint); + var request = new SoundGenerationRequest("Star Wars Light Saber parry"); + var clip = await ElevenLabsClient.SoundGenerationEndpoint.GenerateSoundAsync(request); + Assert.NotNull(clip); + Assert.IsFalse(clip.ClipData.IsEmpty); + Assert.IsFalse(string.IsNullOrWhiteSpace(clip.Text)); + } + + //[Test] + //public async Task Test_02_01_GetSoundGenerationHistory() + //{ + // Assert.NotNull(ElevenLabsClient.SoundGenerationEndpoint); + // var historyInfo = await ElevenLabsClient.SoundGenerationEndpoint.GetHistoryAsync(pageSize: 20); + // Assert.NotNull(historyInfo); + // Assert.IsNotEmpty(historyInfo.HistoryItems); + + // foreach (var item in historyInfo.HistoryItems) + // { + // Console.WriteLine($"{item.Id} | {item.Text} | {item.CreatedAt}"); + // } + //} + + //[Test] + //public async Task Test_02_02_GetHistoryAudio() + //{ + // Assert.NotNull(ElevenLabsClient.SoundGenerationEndpoint); + // var historyInfo = await ElevenLabsClient.SoundGenerationEndpoint.GetHistoryAsync(pageSize: 20); + // Assert.NotNull(historyInfo); + // Assert.IsNotEmpty(historyInfo.HistoryItems); + // var downloadItem = historyInfo.HistoryItems.OrderByDescending(item => item.CreatedAt).FirstOrDefault(); + // Assert.NotNull(downloadItem); + // Console.WriteLine($"Downloading {downloadItem!.Id}..."); + // var soundClip = await ElevenLabsClient.SoundGenerationEndpoint.DownloadSoundAudioAsync(downloadItem); + // Assert.NotNull(soundClip); + // Assert.IsFalse(soundClip.ClipData.IsEmpty); + //} + + //[Test] + //public async Task Test_02_03_DownloadAllHistoryItems() + //{ + // Assert.NotNull(ElevenLabsClient.SoundGenerationEndpoint); + // var historyInfo = await ElevenLabsClient.SoundGenerationEndpoint.GetHistoryAsync(pageSize: 20); + // Assert.NotNull(historyInfo); + // Assert.IsNotEmpty(historyInfo.HistoryItems); + // var singleItem = historyInfo.HistoryItems.FirstOrDefault(); + // var singleItemResult = await ElevenLabsClient.SoundGenerationEndpoint.DownloadHistoryItemsAsync(new List { singleItem }); + // Assert.NotNull(singleItemResult); + // Assert.IsNotEmpty(singleItemResult); + // var downloadItems = historyInfo.HistoryItems.Select(item => item.Id).ToList(); + // var soundClips = await ElevenLabsClient.SoundGenerationEndpoint.DownloadHistoryItemsAsync(downloadItems); + // Assert.NotNull(soundClips); + // Assert.IsNotEmpty(soundClips); + //} + + //[Test] + //public async Task Test_02_04_DeleteHistoryItem() + //{ + // Assert.NotNull(ElevenLabsClient.SoundGenerationEndpoint); + // var historyInfo = await ElevenLabsClient.SoundGenerationEndpoint.GetHistoryAsync(pageSize: 20); + // Assert.NotNull(historyInfo); + // Assert.IsNotEmpty(historyInfo.HistoryItems); + // var itemsToDelete = historyInfo.HistoryItems.Where(item => item.Text.Contains("Star Wars Light Saber parry")).ToList(); + // Assert.NotNull(itemsToDelete); + // Assert.IsNotEmpty(itemsToDelete); + + // foreach (var historyItem in itemsToDelete) + // { + // Console.WriteLine($"Deleting {historyItem.Id}..."); + // var result = await ElevenLabsClient.SoundGenerationEndpoint.DeleteHistoryItemAsync(historyItem.Id); + // Assert.IsTrue(result); + // } + + // var updatedHistoryInfo = await ElevenLabsClient.SoundGenerationEndpoint.GetHistoryAsync(); + // Assert.NotNull(updatedHistoryInfo); + // Assert.That(updatedHistoryInfo.HistoryItems, Has.None.EqualTo(itemsToDelete)); + + // foreach (var item in updatedHistoryInfo.HistoryItems.OrderBy(item => item.CreatedAt)) + // { + // Console.WriteLine($"[{item.Id}] {item.CreatedAt} | {item.Text}"); + // } + //} + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet-Tests/Test_Fixture_04_HistoryEndpoint.cs b/ElevenLabs-DotNet-Tests/TestFixture_07_HistoryEndpoint.cs similarity index 96% rename from ElevenLabs-DotNet-Tests/Test_Fixture_04_HistoryEndpoint.cs rename to ElevenLabs-DotNet-Tests/TestFixture_07_HistoryEndpoint.cs index 8356833..49fe437 100644 --- a/ElevenLabs-DotNet-Tests/Test_Fixture_04_HistoryEndpoint.cs +++ b/ElevenLabs-DotNet-Tests/TestFixture_07_HistoryEndpoint.cs @@ -8,7 +8,7 @@ namespace ElevenLabs.Tests { - internal class Test_Fixture_04_HistoryEndpoint : AbstractTestFixture + internal class TestFixture_07_HistoryEndpoint : AbstractTestFixture { [Test] public async Task Test_01_GetHistory() @@ -36,6 +36,7 @@ public async Task Test_02_GetHistoryAudio() Console.WriteLine($"Downloading {downloadItem!.Id}..."); var voiceClip = await ElevenLabsClient.HistoryEndpoint.DownloadHistoryAudioAsync(downloadItem); Assert.NotNull(voiceClip); + Assert.IsFalse(voiceClip.ClipData.IsEmpty); } [Test] @@ -64,7 +65,6 @@ public async Task Test_04_DeleteHistoryItem() Assert.IsNotEmpty(historyInfo.HistoryItems); var itemsToDelete = historyInfo.HistoryItems.Where(item => item.Text.Contains("The quick brown fox jumps over the lazy dog.")).ToList(); Assert.NotNull(itemsToDelete); - Assert.IsNotEmpty(itemsToDelete); foreach (var historyItem in itemsToDelete) { diff --git a/ElevenLabs-DotNet-Tests/TestFixture_08_DubbingEndpoint.cs b/ElevenLabs-DotNet-Tests/TestFixture_08_DubbingEndpoint.cs new file mode 100644 index 0000000..933001a --- /dev/null +++ b/ElevenLabs-DotNet-Tests/TestFixture_08_DubbingEndpoint.cs @@ -0,0 +1,107 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.Dubbing; +using NUnit.Framework; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace ElevenLabs.Tests +{ + internal class TestFixture_08_DubbingEndpoint : AbstractTestFixture + { + [Test] + public async Task Test_01_Dubbing_File() + { + Assert.NotNull(ElevenLabsClient.DubbingEndpoint); + var filePath = Path.GetFullPath("../../../Assets/test_sample_01.ogg"); + var request = new DubbingRequest(filePath, "es", "en", 1); + var metadata = await ElevenLabsClient.DubbingEndpoint.DubAsync(request, progress: new Progress(metadata => + { + switch (metadata.Status) + { + case "dubbing": + Console.WriteLine($"Dubbing for {metadata.DubbingId} in progress... Expected Duration: {metadata.ExpectedDurationSeconds:0.00} seconds"); + break; + case "dubbed": + Console.WriteLine($"Dubbing for {metadata.DubbingId} complete in {metadata.TimeCompleted.TotalSeconds:0.00} seconds!"); + break; + default: + Console.WriteLine($"Status: {metadata.Status}"); + break; + } + })); + Assert.IsFalse(string.IsNullOrEmpty(metadata.DubbingId)); + Assert.IsTrue(metadata.ExpectedDurationSeconds > 0); + + var srcFile = new FileInfo(filePath); + var dubbedPath = new FileInfo($"{srcFile.FullName}.dubbed.{request.TargetLanguage}{srcFile.Extension}"); + { + await using var fs = File.Open(dubbedPath.FullName, FileMode.Create); + await foreach (var chunk in ElevenLabsClient.DubbingEndpoint.GetDubbedFileAsync(metadata.DubbingId, request.TargetLanguage)) + { + await fs.WriteAsync(chunk); + } + } + Assert.IsTrue(dubbedPath.Exists); + Assert.IsTrue(dubbedPath.Length > 0); + + var transcriptPath = new FileInfo($"{srcFile.FullName}.dubbed.{request.TargetLanguage}.srt"); + { + var transcriptFile = await ElevenLabsClient.DubbingEndpoint.GetTranscriptForDubAsync(metadata.DubbingId, request.TargetLanguage); + await File.WriteAllTextAsync(transcriptPath.FullName, transcriptFile); + } + Assert.IsTrue(transcriptPath.Exists); + Assert.IsTrue(transcriptPath.Length > 0); + + await ElevenLabsClient.DubbingEndpoint.DeleteDubbingProjectAsync(metadata.DubbingId); + } + + [Test] + public async Task Test_02_Dubbing_Url() + { + Assert.NotNull(ElevenLabsClient.DubbingEndpoint); + + var request = new DubbingRequest(new Uri("https://youtu.be/Zo5-rhYOlNk"), "ja", "en", 1, true); + var metadata = await ElevenLabsClient.DubbingEndpoint.DubAsync(request, progress: new Progress(metadata => + { + switch (metadata.Status) + { + case "dubbing": + Console.WriteLine($"Dubbing for {metadata.DubbingId} in progress... Expected Duration: {metadata.ExpectedDurationSeconds:0.00} seconds"); + break; + case "dubbed": + Console.WriteLine($"Dubbing for {metadata.DubbingId} complete in {metadata.TimeCompleted.TotalSeconds:0.00} seconds!"); + break; + default: + Console.WriteLine($"Status: {metadata.Status}"); + break; + } + })); + Assert.IsFalse(string.IsNullOrEmpty(metadata.DubbingId)); + Assert.IsTrue(metadata.ExpectedDurationSeconds > 0); + + var assetsDir = Path.GetFullPath("../../../Assets"); + var dubbedPath = new FileInfo(Path.Combine(assetsDir, $"online.dubbed.{request.TargetLanguage}.mp4")); + { + await using var fs = File.Open(dubbedPath.FullName, FileMode.Create); + await foreach (var chunk in ElevenLabsClient.DubbingEndpoint.GetDubbedFileAsync(metadata.DubbingId, request.TargetLanguage)) + { + await fs.WriteAsync(chunk); + } + } + Assert.IsTrue(dubbedPath.Exists); + Assert.IsTrue(dubbedPath.Length > 0); + + var transcriptPath = new FileInfo(Path.Combine(assetsDir, $"online.dubbed.{request.TargetLanguage}.srt")); + { + var transcriptFile = await ElevenLabsClient.DubbingEndpoint.GetTranscriptForDubAsync(metadata.DubbingId, request.TargetLanguage); + await File.WriteAllTextAsync(transcriptPath.FullName, transcriptFile); + } + Assert.IsTrue(transcriptPath.Exists); + Assert.IsTrue(transcriptPath.Length > 0); + + await ElevenLabsClient.DubbingEndpoint.DeleteDubbingProjectAsync(metadata.DubbingId); + } + } +} diff --git a/ElevenLabs-DotNet/AuthInfo.cs b/ElevenLabs-DotNet/Authentication/AuthInfo.cs similarity index 100% rename from ElevenLabs-DotNet/AuthInfo.cs rename to ElevenLabs-DotNet/Authentication/AuthInfo.cs diff --git a/ElevenLabs-DotNet/Authentication/ElevenLabsAuthentication.cs b/ElevenLabs-DotNet/Authentication/ElevenLabsAuthentication.cs index 18ecbf1..687461e 100644 --- a/ElevenLabs-DotNet/Authentication/ElevenLabsAuthentication.cs +++ b/ElevenLabs-DotNet/Authentication/ElevenLabsAuthentication.cs @@ -12,6 +12,7 @@ namespace ElevenLabs public sealed class ElevenLabsAuthentication { internal const string CONFIG_FILE = ".elevenlabs"; + private const string ELEVENLABS_API_KEY = nameof(ELEVENLABS_API_KEY); private const string ELEVEN_LABS_API_KEY = nameof(ELEVEN_LABS_API_KEY); private readonly AuthInfo authInfo; @@ -53,13 +54,16 @@ public static ElevenLabsAuthentication Default var auth = LoadFromDirectory() ?? LoadFromDirectory(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)) ?? - LoadFromEnv(); + LoadFromEnvironment(); cachedDefault = auth; return auth; } internal set => cachedDefault = value; } + [Obsolete("Use LoadFromEnvironment")] + public static ElevenLabsAuthentication LoadFromEnv() => LoadFromEnvironment(); + /// /// Attempts to load api keys from environment variables, as "ELEVEN_LABS_API_KEY" /// @@ -67,10 +71,15 @@ public static ElevenLabsAuthentication Default /// Returns the loaded any api keys were found, /// or if there were no matching environment vars. /// - public static ElevenLabsAuthentication LoadFromEnv() + public static ElevenLabsAuthentication LoadFromEnvironment() { var apiKey = Environment.GetEnvironmentVariable(ELEVEN_LABS_API_KEY); + if (string.IsNullOrWhiteSpace(apiKey)) + { + apiKey = Environment.GetEnvironmentVariable(ELEVENLABS_API_KEY); + } + return string.IsNullOrEmpty(apiKey) ? null : new ElevenLabsAuthentication(apiKey); } @@ -150,6 +159,7 @@ public static ElevenLabsAuthentication LoadFromDirectory(string directory = null switch (part) { + case ELEVENLABS_API_KEY: case ELEVEN_LABS_API_KEY: apiKey = nextPart.Trim(); break; diff --git a/ElevenLabs-DotNet/Common/ElevenLabsBaseEndPoint.cs b/ElevenLabs-DotNet/Common/ElevenLabsBaseEndPoint.cs index 1c950f1..94807c3 100644 --- a/ElevenLabs-DotNet/Common/ElevenLabsBaseEndPoint.cs +++ b/ElevenLabs-DotNet/Common/ElevenLabsBaseEndPoint.cs @@ -28,7 +28,7 @@ protected string GetUrl(string endpoint = "", Dictionary queryPa if (queryParameters is { Count: not 0 }) { - result += $"?{string.Join("&", queryParameters.Select(parameter => $"{parameter.Key}={parameter.Value}"))}"; + result += $"?{string.Join('&', queryParameters.Select(parameter => $"{parameter.Key}={parameter.Value}"))}"; } return result; diff --git a/ElevenLabs-DotNet/Common/GeneratedClip.cs b/ElevenLabs-DotNet/Common/GeneratedClip.cs new file mode 100644 index 0000000..d450ee9 --- /dev/null +++ b/ElevenLabs-DotNet/Common/GeneratedClip.cs @@ -0,0 +1,38 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.Extensions; +using System; + +namespace ElevenLabs +{ + public class GeneratedClip + { + internal GeneratedClip(string id, string text, ReadOnlyMemory clipData) + { + Id = id; + Text = text; + TextHash = $"{id}{text}".GenerateGuid().ToString(); + ClipData = clipData; + } + + /// + /// The unique id of this clip. + /// + public string Id { get; } + + /// + /// The text input that generated this clip. + /// + public string Text { get; } + + /// + /// Hash string of id and text. + /// + public string TextHash { get; } + + /// + /// The ray clip data. + /// + public ReadOnlyMemory ClipData { get; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/Common/VoiceClip.cs b/ElevenLabs-DotNet/Common/VoiceClip.cs index 3f76866..8f75dbd 100644 --- a/ElevenLabs-DotNet/Common/VoiceClip.cs +++ b/ElevenLabs-DotNet/Common/VoiceClip.cs @@ -1,30 +1,17 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using ElevenLabs.Extensions; using ElevenLabs.Voices; using System; namespace ElevenLabs { - public sealed class VoiceClip + public sealed class VoiceClip : GeneratedClip { - internal VoiceClip(string id, string text, Voice voice, ReadOnlyMemory clipData) + internal VoiceClip(string id, string text, Voice voice, ReadOnlyMemory clipData) : base(id, text, clipData) { - Id = id; - Text = text; Voice = voice; - TextHash = $"{id}{text}".GenerateGuid().ToString(); - ClipData = clipData; } - public string Id { get; } - - public string Text { get; } - public Voice Voice { get; } - - public string TextHash { get; } - - public ReadOnlyMemory ClipData { get; } } } diff --git a/ElevenLabs-DotNet/Dubbing/DubbingEndpoint.cs b/ElevenLabs-DotNet/Dubbing/DubbingEndpoint.cs new file mode 100644 index 0000000..c9d0e98 --- /dev/null +++ b/ElevenLabs-DotNet/Dubbing/DubbingEndpoint.cs @@ -0,0 +1,222 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.Extensions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace ElevenLabs.Dubbing +{ + /// + /// Access to dubbing an audio or video file into a given language. + /// + public sealed class DubbingEndpoint(ElevenLabsClient client) : ElevenLabsBaseEndPoint(client) + { + private const string DubbingId = "dubbing_id"; + private const string ExpectedDurationSecs = "expected_duration_sec"; + + protected override string Root => "dubbing"; + + /// + /// Dubs provided audio or video file into given language. + /// + /// The containing dubbing configuration and files. + /// + /// Optional, . + /// + /// + /// . + public async Task DubAsync(DubbingRequest request, int? maxRetries = null, TimeSpan? pollingInterval = null, IProgress progress = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + using var payload = new MultipartFormDataContent(); + + try + { + foreach (var (fileName, mediaType, stream) in request.Files) + { + await payload.AppendFileToFormAsync("file", stream, fileName, new(mediaType), cancellationToken); + } + + if (!string.IsNullOrEmpty(request.ProjectName)) + { + payload.Add(new StringContent(request.ProjectName), "name"); + } + + if (request.SourceUrl != null) + { + payload.Add(new StringContent(request.SourceUrl.ToString()), "source_url"); + } + + if (!string.IsNullOrEmpty(request.SourceLanguage)) + { + payload.Add(new StringContent(request.SourceLanguage), "source_lang"); + } + + if (!string.IsNullOrEmpty(request.TargetLanguage)) + { + payload.Add(new StringContent(request.TargetLanguage), "target_lang"); + } + + if (request.NumberOfSpeakers.HasValue) + { + payload.Add(new StringContent(request.NumberOfSpeakers.Value.ToString(CultureInfo.InvariantCulture)), "num_speakers"); + } + + if (request.Watermark.HasValue) + { + payload.Add(new StringContent(request.Watermark.Value.ToString()), "watermark"); + } + + if (request.StartTime.HasValue) + { + payload.Add(new StringContent(request.StartTime.Value.ToString(CultureInfo.InvariantCulture)), "start_time"); + } + + if (request.EndTime.HasValue) + { + payload.Add(new StringContent(request.EndTime.Value.ToString(CultureInfo.InvariantCulture)), "end_time"); + } + + if (request.HighestResolution.HasValue) + { + payload.Add(new StringContent(request.HighestResolution.Value.ToString()), "highest_resolution"); + } + } + finally + { + request.Dispose(); + } + + using var response = await client.Client.PostAsync(GetUrl(), payload, cancellationToken).ConfigureAwait(false); + var responseBody = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + var dubResponse = JsonSerializer.Deserialize(responseBody); + var metadata = await WaitForDubbingCompletionAsync(dubResponse, maxRetries ?? 60, pollingInterval ?? TimeSpan.FromSeconds(dubResponse.ExpectedDurationSeconds), pollingInterval == null, progress, cancellationToken); + return metadata; + } + + private async Task WaitForDubbingCompletionAsync(DubbingResponse dubbingResponse, int maxRetries, TimeSpan pollingInterval, bool adjustInterval, IProgress progress = null, CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + for (var i = 1; i < maxRetries + 1; i++) + { + var metadata = await GetDubbingProjectMetadataAsync(dubbingResponse, cancellationToken).ConfigureAwait(false); + metadata.ExpectedDurationSeconds = dubbingResponse.ExpectedDurationSeconds; + + if (metadata.Status.Equals("dubbed", StringComparison.Ordinal)) + { + stopwatch.Stop(); + metadata.TimeCompleted = stopwatch.Elapsed; + progress?.Report(metadata); + return metadata; + } + + progress?.Report(metadata); + + if (metadata.Status.Equals("dubbing", StringComparison.Ordinal)) + { + if (EnableDebug) + { + Console.WriteLine($"Dubbing for {dubbingResponse.DubbingId} in progress... Will check status again in {pollingInterval.TotalSeconds} seconds."); + } + + if (adjustInterval) + { + pollingInterval = TimeSpan.FromSeconds(dubbingResponse.ExpectedDurationSeconds / Math.Pow(2, i)); + } + + await Task.Delay(pollingInterval, cancellationToken).ConfigureAwait(false); + } + else + { + throw new Exception($"Dubbing for {dubbingResponse.DubbingId} failed: {metadata.Error}"); + } + } + + throw new TimeoutException($"Dubbing for {dubbingResponse.DubbingId} timed out or exceeded expected duration."); + } + + /// + /// Returns metadata about a dubbing project, including whether it’s still in progress or not. + /// + /// + /// Optional, . + /// . + public async Task GetDubbingProjectMetadataAsync(string dubbingId, CancellationToken cancellationToken = default) + { + using var response = await client.Client.GetAsync(GetUrl($"/{dubbingId}"), cancellationToken).ConfigureAwait(false); + var responseBody = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(responseBody); + } + + /// + /// Returns transcript for the dub in the specified format (SRT or WebVTT). + /// + /// The ID of the dubbing project. + /// The language code of the transcript. + /// Optional. The format type of the transcript file, either 'srt' or 'webvtt'. + /// Optional, . + /// + /// A task representing the asynchronous operation. The task completes with the transcript content + /// as a string in the specified format. + /// + /// + /// If is not specified, the method retrieves the transcript in its default format. + /// + public async Task GetTranscriptForDubAsync(string dubbingId, string languageCode, DubbingFormat formatType = DubbingFormat.Srt, CancellationToken cancellationToken = default) + { + var @params = new Dictionary { { "format_type", formatType.ToString().ToLower() } }; + using var response = await client.Client.GetAsync(GetUrl($"/{dubbingId}/transcript/{languageCode}", @params), cancellationToken).ConfigureAwait(false); + return await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + } + + /// + /// Returns dubbed file as a streamed file. + /// + /// The ID of the dubbing project. + /// The language code of the dubbed content. + /// The size of the buffer used to read data from the response stream. Default is 8192 bytes. + /// Optional, . + /// + /// An asynchronous enumerable of byte arrays representing the dubbed file content. Each byte array + /// contains a chunk of the dubbed file data. + /// + /// + /// This method streams the dubbed file content in chunks to optimize memory usage and improve performance. + /// Adjust the parameter based on your specific requirements to achieve optimal performance. + /// + public async IAsyncEnumerable GetDubbedFileAsync(string dubbingId, string languageCode, int bufferSize = 8192, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var response = await client.Client.GetAsync(GetUrl($"/{dubbingId}/audio/{languageCode}"), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var buffer = new byte[bufferSize]; + int bytesRead; + + while ((bytesRead = await responseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + var chunk = new byte[bytesRead]; + Array.Copy(buffer, chunk, bytesRead); + yield return chunk; + } + } + + /// + /// Deletes a dubbing project. + /// + /// The ID of the dubbing project. + /// Optional, . + public async Task DeleteDubbingProjectAsync(string dubbingId, CancellationToken cancellationToken = default) + { + using var response = await client.Client.DeleteAsync(GetUrl($"/{dubbingId}"), cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/ElevenLabs-DotNet/Dubbing/DubbingFormat.cs b/ElevenLabs-DotNet/Dubbing/DubbingFormat.cs new file mode 100644 index 0000000..a4a749c --- /dev/null +++ b/ElevenLabs-DotNet/Dubbing/DubbingFormat.cs @@ -0,0 +1,14 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Runtime.Serialization; + +namespace ElevenLabs.Dubbing +{ + public enum DubbingFormat + { + [EnumMember(Value = "srt")] + Srt, + [EnumMember(Value = "webvtt")] + WebVtt + } +} diff --git a/ElevenLabs-DotNet/Dubbing/DubbingProjectMetadata.cs b/ElevenLabs-DotNet/Dubbing/DubbingProjectMetadata.cs new file mode 100644 index 0000000..556bc77 --- /dev/null +++ b/ElevenLabs-DotNet/Dubbing/DubbingProjectMetadata.cs @@ -0,0 +1,37 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ElevenLabs.Dubbing +{ + public sealed class DubbingProjectMetadata + { + [JsonInclude] + [JsonPropertyName("dubbing_id")] + public string DubbingId { get; private set; } + + [JsonInclude] + [JsonPropertyName("name")] + public string Name { get; private set; } + + [JsonInclude] + [JsonPropertyName("status")] + public string Status { get; private set; } + + [JsonInclude] + [JsonPropertyName("target_languages")] + public List TargetLanguages { get; private set; } + + [JsonInclude] + [JsonPropertyName("error")] + public string Error { get; private set; } + + [JsonIgnore] + public float ExpectedDurationSeconds { get; internal set; } + + [JsonIgnore] + public TimeSpan TimeCompleted { get; internal set; } + } +} diff --git a/ElevenLabs-DotNet/Dubbing/DubbingRequest.cs b/ElevenLabs-DotNet/Dubbing/DubbingRequest.cs new file mode 100644 index 0000000..f00afa5 --- /dev/null +++ b/ElevenLabs-DotNet/Dubbing/DubbingRequest.cs @@ -0,0 +1,218 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace ElevenLabs.Dubbing +{ + public sealed class DubbingRequest : IDisposable + { + public DubbingRequest( + string filePath, + string targetLanguage, + string sourceLanguage = null, + int? numberOfSpeakers = null, + bool? watermark = null, + int? startTime = null, + int? endTime = null, + bool? highestResolution = null, + bool? dropBackgroundAudio = null, + string projectName = null) + : this([filePath], targetLanguage, sourceLanguage, numberOfSpeakers, watermark, startTime, endTime, highestResolution, dropBackgroundAudio, projectName) + { + } + + public DubbingRequest( + IEnumerable filePaths, + string targetLanguage, + string sourceLanguage = null, + int? numberOfSpeakers = null, + bool? watermark = null, + int? startTime = null, + int? endTime = null, + bool? highestResolution = null, + bool? dropBackgroundAudio = null, + string projectName = null) + : this(targetLanguage, null, filePaths, sourceLanguage, numberOfSpeakers, watermark, startTime, endTime, highestResolution, dropBackgroundAudio, projectName) + { + } + + public DubbingRequest( + Uri sourceUrl, + string targetLanguage, + string sourceLanguage = null, + int? numberOfSpeakers = null, + bool? watermark = null, + int? startTime = null, + int? endTime = null, + bool? highestResolution = null, + bool? dropBackgroundAudio = null, + string projectName = null) + : this(targetLanguage, sourceUrl, null, sourceLanguage, numberOfSpeakers, watermark, startTime, endTime, highestResolution, dropBackgroundAudio, projectName) + { + } + + private DubbingRequest( + string targetLanguage, + Uri sourceUrl = null, + IEnumerable filePaths = null, + string sourceLanguage = null, + int? numberOfSpeakers = null, + bool? watermark = null, + int? startTime = null, + int? endTime = null, + bool? highestResolution = null, + bool? dropBackgroundAudio = null, + string projectName = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetLanguage); + TargetLanguage = targetLanguage; + + if (filePaths == null && sourceUrl == null) + { + throw new ArgumentException("Either sourceUrl or filePaths must be provided."); + } + + var files = new List<(string, string, Stream)>(); + + if (filePaths != null) + { + foreach (var filePath in filePaths) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path cannot be empty."); + } + + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"File not found: {filePath}"); + } + + var stream = fileInfo.OpenRead(); + var extension = fileInfo.Extension.ToLowerInvariant(); + var mediaType = extension switch + { + ".3gp" => "video/3gpp", + ".acc" => "audio/aac", + ".avi" => "video/x-msvideo", + ".flac" => "audio/flac", + ".ogg" => "audio/ogg", + ".mov" => "video/quicktime", + ".mp3" => "audio/mp3", + ".mp4" => "video/mp4", + ".raw" => "audio/raw", + ".wav" => "audio/wav", + ".webm" => "video/webm", + _ => "application/octet-stream" + }; + files.Add((fileInfo.Name, mediaType, stream)); + } + } + + Files = files; + SourceUrl = sourceUrl; + SourceLanguage = sourceLanguage; + NumberOfSpeakers = numberOfSpeakers; + Watermark = watermark; + StartTime = startTime; + EndTime = endTime; + HighestResolution = highestResolution; + DropBackgroundAudio = dropBackgroundAudio; + ProjectName = projectName; + } + + ~DubbingRequest() => Dispose(false); + + /// + /// Files to dub. + /// + public IReadOnlyList<(string, string, Stream)> Files { get; } + + /// + /// URL of the source video/audio file. + /// + public Uri SourceUrl { get; } + + /// + /// Source language. + /// + /// + /// A list of supported languages can be found at: https://elevenlabs.io/docs/api-reference/how-to-dub-a-video#list-of-supported-languages-for-dubbing + /// + public string SourceLanguage { get; } + + /// + /// The Target language to dub the content into. Can be none if dubbing studio editor is enabled and running manual mode + /// + /// + /// A list of supported languages can be found at: https://elevenlabs.io/docs/api-reference/how-to-dub-a-video#list-of-supported-languages-for-dubbing + /// + public string TargetLanguage { get; } + + /// + /// Number of speakers to use for the dubbing. Set to 0 to automatically detect the number of speakers + /// + public int? NumberOfSpeakers { get; } + + /// + /// Whether to apply watermark to the output video. + /// + public bool? Watermark { get; } + + /// + /// Start time of the source video/audio file. + /// + public int? StartTime { get; } + + /// + /// End time of the source video/audio file. + /// + public int? EndTime { get; } + + /// + /// Whether to use the highest resolution available. + /// + public bool? HighestResolution { get; } + + /// + /// An advanced setting. Whether to drop background audio from the final dub. + /// This can improve dub quality where it's known that audio shouldn't have a background track such as for speeches or monologues. + /// + public bool? DropBackgroundAudio { get; } + + /// + /// Name of the dubbing project. + /// + public string ProjectName { get; } + + private void Dispose(bool disposing) + { + if (disposing) + { + if (Files == null) { return; } + foreach (var (_, _, stream) in Files) + { + try + { + stream?.Close(); + stream?.Dispose(); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/ElevenLabs-DotNet/Dubbing/DubbingResponse.cs b/ElevenLabs-DotNet/Dubbing/DubbingResponse.cs new file mode 100644 index 0000000..0b18f5d --- /dev/null +++ b/ElevenLabs-DotNet/Dubbing/DubbingResponse.cs @@ -0,0 +1,19 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Serialization; + +namespace ElevenLabs.Dubbing +{ + public sealed class DubbingResponse + { + [JsonInclude] + [JsonPropertyName("dubbing_id")] + public string DubbingId { get; private set; } + + [JsonInclude] + [JsonPropertyName("expected_duration_sec")] + public float ExpectedDurationSeconds { get; private set; } + + public static implicit operator string(DubbingResponse response) => response?.DubbingId; + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/ElevenLabs-DotNet.csproj b/ElevenLabs-DotNet/ElevenLabs-DotNet.csproj index 6c7771d..f79ef7d 100644 --- a/ElevenLabs-DotNet/ElevenLabs-DotNet.csproj +++ b/ElevenLabs-DotNet/ElevenLabs-DotNet.csproj @@ -1,21 +1,41 @@ - net6.0 + net8.0 latest - true - ElevenLabs - disable - disable - https://github.com/RageAgainstThePixel/ElevenLabs-DotNet - README.md - https://github.com/RageAgainstThePixel/ElevenLabs-DotNet - Stephen Hodgson ElevenLabs-DotNet + ElevenLabs-DotNet + ElevenLabs-DotNet + ElevenLabs RageAgainstThePixel + Stephen Hodgson + A non-official Eleven Labs voice synthesis RESTful client. +I am not affiliated with Eleven Labs and an account with api access is required. +All copyrights, trademarks, logos, and assets are the property of their respective owners. + 2024 + true + https://github.com/RageAgainstThePixel/ElevenLabs-DotNet + https://github.com/RageAgainstThePixel/ElevenLabs-DotNet ElevenLabs, AI, ML, Voice, TTS - 2.2.1 - Version 2.2.1 + false + README.md + LICENSE + Assets\ElevenLabsIcon.png + true + false + true + true + 3.0.0 + +Version 3.0.0 +- Updated to .NET 8.0 +- Added ability to specify fully customizable domain proxies +- Added environment variable parsing for ELEVENLABS_API_KEY +- Added SoundEffects API endpoints +- Added Dubbing API endpoints +- Updated default models +- Fixed adding and editing voices +Version 2.2.1 - Misc formatting changes Version 2.2.0 - Changed ElevenLabsClient to be IDisposable @@ -110,24 +130,18 @@ Version 1.0.1 Version 1.0.0 - Initial Release! - LICENSE - A non-official Eleven Labs voice synthesis RESTful client. -I am not affiliated with Eleven Labs and an account with api access is required. -All copyrights, trademarks, logos, and assets are the property of their respective owners. - ElevenLabsIcon.png - True - - True + + true \ - True + true \ - True + true \ diff --git a/ElevenLabs-DotNet/ElevenLabsClient.cs b/ElevenLabs-DotNet/ElevenLabsClient.cs index a064398..8efcedc 100644 --- a/ElevenLabs-DotNet/ElevenLabsClient.cs +++ b/ElevenLabs-DotNet/ElevenLabsClient.cs @@ -1,7 +1,9 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. +using ElevenLabs.Dubbing; using ElevenLabs.History; using ElevenLabs.Models; +using ElevenLabs.SoundGeneration; using ElevenLabs.TextToSpeech; using ElevenLabs.User; using ElevenLabs.VoiceGeneration; @@ -19,11 +21,11 @@ public sealed class ElevenLabsClient : IDisposable /// /// Creates a new client for the Eleven Labs API, handling auth and allowing for access to various API endpoints. /// - /// The API authentication information to use for API calls, + /// The API authentication information to use for API calls, /// or to attempt to use the , /// potentially loading from environment vars or from a config file. /// - /// + /// /// Optional, for specifying a proxy domain. /// /// Optional, . @@ -34,10 +36,10 @@ public sealed class ElevenLabsClient : IDisposable /// This internal HttpClient is disposed of when ElevenLabsClient is disposed of. /// If you provide an external HttpClient instance to ElevenLabsClient, you are responsible for managing its disposal. /// - public ElevenLabsClient(ElevenLabsAuthentication elevenLabsAuthentication = null, ElevenLabsClientSettings clientSettings = null, HttpClient httpClient = null) + public ElevenLabsClient(ElevenLabsAuthentication authentication = null, ElevenLabsClientSettings settings = null, HttpClient httpClient = null) { - ElevenLabsAuthentication = elevenLabsAuthentication ?? ElevenLabsAuthentication.Default; - ElevenLabsClientSettings = clientSettings ?? ElevenLabsClientSettings.Default; + ElevenLabsAuthentication = authentication ?? ElevenLabsAuthentication.Default; + ElevenLabsClientSettings = settings ?? ElevenLabsClientSettings.Default; if (string.IsNullOrWhiteSpace(ElevenLabsAuthentication?.ApiKey)) { @@ -62,10 +64,13 @@ public ElevenLabsClient(ElevenLabsAuthentication elevenLabsAuthentication = null UserEndpoint = new UserEndpoint(this); VoicesEndpoint = new VoicesEndpoint(this); + SharedVoicesEndpoint = new SharedVoicesEndpoint(this); ModelsEndpoint = new ModelsEndpoint(this); HistoryEndpoint = new HistoryEndpoint(this); TextToSpeechEndpoint = new TextToSpeechEndpoint(this); VoiceGenerationEndpoint = new VoiceGenerationEndpoint(this); + SoundGenerationEndpoint = new SoundGenerationEndpoint(this); + DubbingEndpoint = new DubbingEndpoint(this); } ~ElevenLabsClient() @@ -129,6 +134,8 @@ private void Dispose(bool disposing) public VoicesEndpoint VoicesEndpoint { get; } + public SharedVoicesEndpoint SharedVoicesEndpoint { get; } + public ModelsEndpoint ModelsEndpoint { get; } public HistoryEndpoint HistoryEndpoint { get; } @@ -136,5 +143,9 @@ private void Dispose(bool disposing) public TextToSpeechEndpoint TextToSpeechEndpoint { get; } public VoiceGenerationEndpoint VoiceGenerationEndpoint { get; } + + public SoundGenerationEndpoint SoundGenerationEndpoint { get; } + + public DubbingEndpoint DubbingEndpoint { get; } } } diff --git a/ElevenLabs-DotNet/Extensions/HttpResponseMessageExtensions.cs b/ElevenLabs-DotNet/Extensions/HttpResponseMessageExtensions.cs index 2b4e7fb..a7eaaa3 100644 --- a/ElevenLabs-DotNet/Extensions/HttpResponseMessageExtensions.cs +++ b/ElevenLabs-DotNet/Extensions/HttpResponseMessageExtensions.cs @@ -1,8 +1,16 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -10,30 +18,178 @@ namespace ElevenLabs.Extensions { internal static class HttpResponseMessageExtensions { - public static async Task ReadAsStringAsync(this HttpResponseMessage response, bool debugResponse = false, CancellationToken cancellationToken = default, [CallerMemberName] string methodName = null) + private static readonly JsonSerializerOptions debugJsonOptions = new() { - var responseAsString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; - if (!response.IsSuccessStatusCode) + internal static async Task CheckResponseAsync(this HttpResponseMessage response, bool debug, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) + { + if (!response.IsSuccessStatusCode || debug) { - throw new HttpRequestException($"{methodName} Failed!\n{response.RequestMessage}\n[{response.StatusCode}] {responseAsString}", null, response.StatusCode); + await response.ReadAsStringAsync(debug, null, null, cancellationToken, methodName).ConfigureAwait(false); } + } - if (debugResponse) + internal static async Task CheckResponseAsync(this HttpResponseMessage response, bool debug, HttpContent requestContent, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) + { + if (!response.IsSuccessStatusCode || debug) { - Console.WriteLine($"{response.RequestMessage}\n[{response.StatusCode}] {responseAsString}"); + await response.ReadAsStringAsync(debug, requestContent, null, cancellationToken, methodName).ConfigureAwait(false); } + } - return responseAsString; + internal static async Task CheckResponseAsync(this HttpResponseMessage response, bool debug, HttpContent requestContent, MemoryStream responseStream, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) + { + if (!response.IsSuccessStatusCode || debug) + { + await response.ReadAsStringAsync(debug, requestContent, responseStream, cancellationToken, methodName).ConfigureAwait(false); + } } - internal static async Task CheckResponseAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default, [CallerMemberName] string methodName = null) + internal static async Task ReadAsStringAsync(this HttpResponseMessage response, bool debugResponse, HttpContent requestContent, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) + => await response.ReadAsStringAsync(debugResponse, requestContent, null, cancellationToken, methodName).ConfigureAwait(false); + + internal static async Task ReadAsStringAsync(this HttpResponseMessage response, bool debugResponse, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) + => await response.ReadAsStringAsync(debugResponse, null, null, cancellationToken, methodName).ConfigureAwait(false); + + internal static async Task ReadAsStringAsync(this HttpResponseMessage response, bool debugResponse, HttpContent requestContent, MemoryStream responseStream, CancellationToken cancellationToken, [CallerMemberName] string methodName = null) { + var responseAsString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var debugMessage = new StringBuilder(); + + if (!response.IsSuccessStatusCode || debugResponse) + { + if (!string.IsNullOrWhiteSpace(methodName)) + { + debugMessage.Append($"{methodName} -> "); + } + + var debugMessageObject = new Dictionary>(); + + if (response.RequestMessage != null) + { + debugMessage.Append($"[{response.RequestMessage.Method}:{(int)response.StatusCode}] {response.RequestMessage.RequestUri}\n"); + + debugMessageObject["Request"] = new Dictionary + { + ["Headers"] = response.RequestMessage.Headers.ToDictionary(pair => pair.Key, pair => pair.Value), + }; + } + + if (requestContent != null) + { + debugMessageObject["Request"]["Body-Headers"] = requestContent.Headers.ToDictionary(pair => pair.Key, pair => pair.Value); + string requestAsString; + + if (requestContent is MultipartFormDataContent multipartFormData) + { + var stringContents = multipartFormData.Select(content => + { + var headers = content.Headers.ToDictionary(pair => pair.Key, pair => pair.Value); + switch (content) + { + case StringContent stringContent: + var valueAsString = stringContent.ReadAsStringAsync(cancellationToken).Result; + object value; + + try + { + value = JsonNode.Parse(valueAsString); + } + catch + { + value = valueAsString; + } + + return new { headers, value }; + default: + return new { headers }; + } + }); + requestAsString = JsonSerializer.Serialize(stringContents); + } + else + { + requestAsString = await requestContent.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(requestAsString)) + { + try + { + debugMessageObject["Request"]["Body"] = JsonNode.Parse(requestAsString); + } + catch + { + debugMessageObject["Request"]["Body"] = requestAsString; + } + } + } + + debugMessageObject["Response"] = new() + { + ["Headers"] = response.Headers.ToDictionary(pair => pair.Key, pair => pair.Value), + }; + + if (responseStream != null || !string.IsNullOrWhiteSpace(responseAsString)) + { + debugMessageObject["Response"]["Body"] = new Dictionary(); + } + + if (responseStream != null) + { + var body = Encoding.UTF8.GetString(responseStream.ToArray()); + + try + { + ((Dictionary)debugMessageObject["Response"]["Body"])["Events"] = JsonNode.Parse(body); + } + catch + { + ((Dictionary)debugMessageObject["Response"]["Body"])["Events"] = body; + } + } + + if (!string.IsNullOrWhiteSpace(responseAsString)) + { + try + { + ((Dictionary)debugMessageObject["Response"]["Body"])["Content"] = JsonNode.Parse(responseAsString); + } + catch + { + ((Dictionary)debugMessageObject["Response"]["Body"])["Content"] = responseAsString; + } + } + + debugMessage.Append(JsonSerializer.Serialize(debugMessageObject, debugJsonOptions)); + Console.WriteLine(debugMessage.ToString()); + } + if (!response.IsSuccessStatusCode) { - var responseAsString = await response.Content.ReadAsStringAsync(cancellationToken); - throw new HttpRequestException($"{methodName} Failed! HTTP status code: {response.StatusCode} | Response body: {responseAsString}", null, response.StatusCode); + throw new HttpRequestException(message: $"{methodName} Failed! HTTP status code: {response.StatusCode} | Response body: {responseAsString}", null, statusCode: response.StatusCode); } + + return responseAsString; + } + + internal static async Task AppendFileToFormAsync(this MultipartFormDataContent content, string name, Stream stream, string fileName, MediaTypeHeaderValue mediaType = null, CancellationToken cancellationToken = default) + { + using var audioData = new MemoryStream(); + await stream.CopyToAsync(audioData, cancellationToken).ConfigureAwait(false); + var fileContent = new ByteArrayContent(audioData.ToArray()); + const string formData = "form-data"; + fileContent.Headers.ContentDisposition = new ContentDispositionHeaderValue(formData) + { + Name = name, + FileName = fileName + }; + const string contentType = "application/octet-stream"; + fileContent.Headers.ContentType = mediaType ?? new MediaTypeHeaderValue(contentType); + content.Add(fileContent); } } } diff --git a/ElevenLabs-DotNet/History/HistoryEndpoint.cs b/ElevenLabs-DotNet/History/HistoryEndpoint.cs index a97b0a6..b4f8106 100644 --- a/ElevenLabs-DotNet/History/HistoryEndpoint.cs +++ b/ElevenLabs-DotNet/History/HistoryEndpoint.cs @@ -30,8 +30,8 @@ public HistoryEndpoint(ElevenLabsClient client) : base(client) { } /// /// Optional, the id of the item to start after. /// Optional, . - /// . - public async Task GetHistoryAsync(int? pageSize = null, string startAfterId = null, CancellationToken cancellationToken = default) + /// . + public async Task> GetHistoryAsync(int? pageSize = null, string startAfterId = null, CancellationToken cancellationToken = default) { var parameters = new Dictionary(); @@ -45,9 +45,9 @@ public async Task GetHistoryAsync(int? pageSize = null, string star parameters.Add("start_after_history_item_id", startAfterId); } - var response = await client.Client.GetAsync(GetUrl(queryParameters: parameters), cancellationToken); + using var response = await client.Client.GetAsync(GetUrl(queryParameters: parameters), cancellationToken); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken); - return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); + return JsonSerializer.Deserialize>(responseAsString, ElevenLabsClient.JsonSerializationOptions); } /// @@ -58,11 +58,23 @@ public async Task GetHistoryAsync(int? pageSize = null, string star /// public async Task GetHistoryItemAsync(string id, CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl($"/{id}"), cancellationToken); + using var response = await client.Client.GetAsync(GetUrl($"/{id}"), cancellationToken); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } + /// + /// Download audio of a history item. + /// + /// or . + /// Optional, . + /// . + public async Task DownloadHistoryAudioAsync(string id, CancellationToken cancellationToken = default) + { + var historyItem = await GetHistoryItemAsync(id, cancellationToken).ConfigureAwait(false); + return await DownloadHistoryAudioAsync(historyItem, cancellationToken).ConfigureAwait(false); + } + /// /// Download audio of a history item. /// @@ -72,8 +84,8 @@ public async Task GetHistoryItemAsync(string id, CancellationToken public async Task DownloadHistoryAudioAsync(HistoryItem historyItem, CancellationToken cancellationToken = default) { var voice = await client.VoicesEndpoint.GetVoiceAsync(historyItem.VoiceId, cancellationToken: cancellationToken).ConfigureAwait(false); - var response = await client.Client.GetAsync(GetUrl($"/{historyItem.Id}/audio"), cancellationToken).ConfigureAwait(false); - await response.CheckResponseAsync(cancellationToken); + using var response = await client.Client.GetAsync(GetUrl($"/{historyItem.Id}/audio"), cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken); var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var memoryStream = new MemoryStream(); byte[] clipData; @@ -100,7 +112,7 @@ public async Task DownloadHistoryAudioAsync(HistoryItem historyItem, /// True, if history item was successfully deleted. public async Task DeleteHistoryItemAsync(string id, CancellationToken cancellationToken = default) { - var response = await client.Client.DeleteAsync(GetUrl($"/{id}"), cancellationToken); + using var response = await client.Client.DeleteAsync(GetUrl($"/{id}"), cancellationToken); await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken); return response.IsSuccessStatusCode; } @@ -117,15 +129,15 @@ public async Task DeleteHistoryItemAsync(string id, CancellationToken canc public async Task> DownloadHistoryItemsAsync(List historyItemIds = null, CancellationToken cancellationToken = default) { historyItemIds ??= (await GetHistoryAsync(cancellationToken: cancellationToken)).HistoryItems.Select(item => item.Id).ToList(); - var voiceClips = new ConcurrentBag(); + var clips = new ConcurrentBag(); - async Task DownloadItem(string historyItemId) + async Task DownloadItem(string id) { try { - var historyItem = await GetHistoryItemAsync(historyItemId, cancellationToken).ConfigureAwait(false); - var voiceClip = await DownloadHistoryAudioAsync(historyItem, cancellationToken).ConfigureAwait(false); - voiceClips.Add(voiceClip); + var historyItem = await GetHistoryItemAsync(id, cancellationToken).ConfigureAwait(false); + var clip = await DownloadHistoryAudioAsync(historyItem, cancellationToken).ConfigureAwait(false); + clips.Add(clip); } catch (Exception e) { @@ -134,7 +146,7 @@ async Task DownloadItem(string historyItemId) } await Task.WhenAll(historyItemIds.Select(DownloadItem)).ConfigureAwait(false); - return voiceClips.ToList(); + return clips.ToList(); } } } diff --git a/ElevenLabs-DotNet/History/HistoryInfo.cs b/ElevenLabs-DotNet/History/HistoryInfo.cs index 3657c2d..af17474 100644 --- a/ElevenLabs-DotNet/History/HistoryInfo.cs +++ b/ElevenLabs-DotNet/History/HistoryInfo.cs @@ -5,11 +5,11 @@ namespace ElevenLabs.History { - public sealed class HistoryInfo + public sealed class HistoryInfo { [JsonInclude] [JsonPropertyName("history")] - public IReadOnlyList HistoryItems { get; private set; } + public IReadOnlyList HistoryItems { get; private set; } [JsonInclude] [JsonPropertyName("last_history_item_id")] diff --git a/ElevenLabs-DotNet/History/HistoryItem.cs b/ElevenLabs-DotNet/History/HistoryItem.cs index 6aa81f1..5db2e8f 100644 --- a/ElevenLabs-DotNet/History/HistoryItem.cs +++ b/ElevenLabs-DotNet/History/HistoryItem.cs @@ -7,8 +7,6 @@ namespace ElevenLabs.History { public sealed class HistoryItem { - public static implicit operator string(HistoryItem historyItem) => historyItem.Id; - [JsonInclude] [JsonPropertyName("history_item_id")] public string Id { get; private set; } @@ -47,5 +45,7 @@ public sealed class HistoryItem [JsonInclude] [JsonPropertyName("state")] public string State { get; private set; } + + public static implicit operator string(HistoryItem historyItem) => historyItem?.Id; } } diff --git a/ElevenLabs-DotNet/Models/Model.cs b/ElevenLabs-DotNet/Models/Model.cs index 06b965c..41a3d1f 100644 --- a/ElevenLabs-DotNet/Models/Model.cs +++ b/ElevenLabs-DotNet/Models/Model.cs @@ -51,13 +51,22 @@ public Model(string id) #region Predefined Models [JsonIgnore] - public static Model MonoLingualV1 { get; } = new Model("eleven_monolingual_v1"); + public static Model MonoLingualV1 { get; } = new("eleven_monolingual_v1"); [JsonIgnore] - public static Model MultiLingualV1 { get; } = new Model("eleven_multilingual_v1"); + public static Model MultiLingualV1 { get; } = new("eleven_multilingual_v1"); [JsonIgnore] - public static Model MultiLingualV2 { get; } = new Model("eleven_multilingual_v2"); + public static Model MultiLingualV2 { get; } = new("eleven_multilingual_v2"); + + [JsonIgnore] + public static Model TurboV2 { get; } = new("eleven_turbo_v2"); + + [JsonIgnore] + public static Model EnglishSpeechToSpeechV2 { get; } = new("eleven_english_sts_v2"); + + [JsonIgnore] + public static Model MultilingualSpeechToSpeechV2 { get; } = new("eleven_multilingual_sts_v2"); #endregion Predefined Models } diff --git a/ElevenLabs-DotNet/Models/ModelsEndpoint.cs b/ElevenLabs-DotNet/Models/ModelsEndpoint.cs index de49022..78f2c5d 100644 --- a/ElevenLabs-DotNet/Models/ModelsEndpoint.cs +++ b/ElevenLabs-DotNet/Models/ModelsEndpoint.cs @@ -3,6 +3,7 @@ using ElevenLabs.Extensions; using System.Collections.Generic; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; namespace ElevenLabs.Models @@ -17,10 +18,10 @@ public ModelsEndpoint(ElevenLabsClient client) : base(client) { } /// Access the different models available to the platform. /// /// A list of s you can use. - public async Task> GetModelsAsync() + public async Task> GetModelsAsync(CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl()); - var responseAsString = await response.ReadAsStringAsync(EnableDebug); + using var response = await client.Client.GetAsync(GetUrl(), cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize>(responseAsString, ElevenLabsClient.JsonSerializationOptions); } } diff --git a/ElevenLabs-DotNet/SoundGeneration/GenerationSettings.cs b/ElevenLabs-DotNet/SoundGeneration/GenerationSettings.cs new file mode 100644 index 0000000..c6b22b3 --- /dev/null +++ b/ElevenLabs-DotNet/SoundGeneration/GenerationSettings.cs @@ -0,0 +1,17 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Serialization; + +namespace ElevenLabs.SoundGeneration +{ + public sealed class GenerationSettings + { + [JsonInclude] + [JsonPropertyName("duration_seconds")] + public float? Duration { get; private set; } + + [JsonInclude] + [JsonPropertyName("prompt_influence")] + public float PromptInfluence { get; private set; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/SoundGeneration/SoundGenerationConfig.cs b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationConfig.cs new file mode 100644 index 0000000..4a5a74d --- /dev/null +++ b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationConfig.cs @@ -0,0 +1,21 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Serialization; + +namespace ElevenLabs.SoundGeneration +{ + public sealed class SoundGenerationConfig + { + [JsonInclude] + [JsonPropertyName("number_of_generations")] + public int NumberOfGenerations { get; private set; } + + [JsonInclude] + [JsonPropertyName("generation_settings")] + public GenerationSettings GenerationSettings { get; private set; } + + [JsonInclude] + [JsonPropertyName("text")] + public string Text { get; private set; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/SoundGeneration/SoundGenerationEndpoint.cs b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationEndpoint.cs new file mode 100644 index 0000000..5b5f408 --- /dev/null +++ b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationEndpoint.cs @@ -0,0 +1,163 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.Extensions; +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace ElevenLabs.SoundGeneration +{ + public sealed class SoundGenerationEndpoint : ElevenLabsBaseEndPoint + { + public SoundGenerationEndpoint(ElevenLabsClient client) : base(client) { } + + protected override string Root => "sound-generation"; + + /// + /// converts text into sounds & uses the most advanced AI audio model ever. + /// Create sound effects for your videos, voice-overs or video games. + /// + /// . + /// Optional, . + /// . + public async Task GenerateSoundAsync(SoundGenerationRequest request, CancellationToken cancellationToken = default) + { + using var payload = JsonSerializer.Serialize(request, ElevenLabsClient.JsonSerializationOptions).ToJsonStringContent(); + using var response = await client.Client.PostAsync(GetUrl(), payload, cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false); + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var memoryStream = new MemoryStream(); + await responseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + return new GeneratedClip(Guid.NewGuid().ToString(), request.Text, memoryStream.ToArray()); + } + + ///// + ///// Get metadata about all your generated sounds. + ///// + ///// + ///// Optional, number of items to return. Cannot exceed 1000.
+ ///// Default: 100 + ///// + ///// Optional, the id of the item to start after. + ///// Optional, . + ///// . + //public async Task> GetHistoryAsync(int? pageSize = null, string startAfterId = null, CancellationToken cancellationToken = default) + //{ + // var parameters = new Dictionary(); + + // if (pageSize.HasValue) + // { + // parameters.Add("page_size", pageSize.ToString()); + // } + + // if (!string.IsNullOrWhiteSpace(startAfterId)) + // { + // parameters.Add("start_after_history_item_id", startAfterId); + // } + + // var response = await client.Client.GetAsync(GetUrl("/history", queryParameters: parameters), cancellationToken).ConfigureAwait(false); + // var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken).ConfigureAwait(false); + // return JsonSerializer.Deserialize>(responseAsString, ElevenLabsClient.JsonSerializationOptions); + //} + + ///// + ///// Get a sound by history item id. + ///// + ///// or . + ///// Optional, . + ///// . + //public async Task GetSoundAsync(string id, CancellationToken cancellationToken = default) + //{ + // var response = await client.Client.GetAsync(GetUrl($"/history/{id}"), cancellationToken).ConfigureAwait(false); + // var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken).ConfigureAwait(false); + // return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); + //} + + ///// + ///// Download audio of a sound history item. + ///// + ///// or . + ///// Optional, . + ///// . + //public async Task DownloadSoundAudioAsync(string id, CancellationToken cancellationToken = default) + //{ + // var historyItem = await GetSoundAsync(id, cancellationToken).ConfigureAwait(false); + // return await DownloadSoundAudioAsync(historyItem, cancellationToken).ConfigureAwait(false); + //} + + ///// + ///// Download audio of a sound history item. + ///// + ///// . + ///// Optional, . + ///// . + //public async Task DownloadSoundAudioAsync(SoundHistoryItem historyItem, CancellationToken cancellationToken = default) + //{ + // using var response = await client.Client.GetAsync(GetUrl($"/history/{historyItem.Id}/audio"), cancellationToken).ConfigureAwait(false); + // await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + // var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + // var memoryStream = new MemoryStream(); + // byte[] clipData; + + // try + // { + // await responseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + // clipData = memoryStream.ToArray(); + // } + // finally + // { + // await responseStream.DisposeAsync().ConfigureAwait(false); + // await memoryStream.DisposeAsync().ConfigureAwait(false); + // } + + // return new GeneratedClip(historyItem.Id, historyItem.Text, clipData); + //} + + ///// + ///// Delete a history item by its id. + ///// + ///// or . + ///// Optional, . + ///// True, if history item was successfully deleted. + //public async Task DeleteHistoryItemAsync(string id, CancellationToken cancellationToken = default) + //{ + // using var response = await client.Client.DeleteAsync(GetUrl($"/history/{id}"), cancellationToken).ConfigureAwait(false); + // await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + // return response.IsSuccessStatusCode; + //} + + ///// + ///// Download one or more history items.
+ ///// If no ids are specified, then the last 100 history items are downloaded.
+ ///// If one history item id is provided, we will return a single audio file.
+ ///// If more than one history item ids are provided multiple audio files will be downloaded. + /////
+ ///// Optional, One or more history item ids queued for download. + ///// Optional, . + ///// A list of voice clips downloaded by the request. + //public async Task> DownloadHistoryItemsAsync(List historyItemIds = null, CancellationToken cancellationToken = default) + //{ + // historyItemIds ??= (await GetHistoryAsync(cancellationToken: cancellationToken)).HistoryItems.Select(item => item.Id).ToList(); + //var clips = new ConcurrentBag(); + + //async Task DownloadItem(string id) + //{ + // try + // { + // var historyItem = await GetSoundAsync(id, cancellationToken).ConfigureAwait(false); + // var clip = await DownloadSoundAudioAsync(historyItem, cancellationToken).ConfigureAwait(false); + // clips.Add(clip); + // } + // catch (Exception e) + // { + // Console.WriteLine(e); + // } + //} + + //await Task.WhenAll(historyItemIds.Select(DownloadItem)).ConfigureAwait(false); + // return clips.ToList(); + //} + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/SoundGeneration/SoundGenerationRequest.cs b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationRequest.cs new file mode 100644 index 0000000..9bf41ec --- /dev/null +++ b/ElevenLabs-DotNet/SoundGeneration/SoundGenerationRequest.cs @@ -0,0 +1,69 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Text.Json.Serialization; + +namespace ElevenLabs.SoundGeneration +{ + public sealed class SoundGenerationRequest + { + /// + /// + /// + /// + /// The text that will get converted into a sound effect. + /// + /// + /// The duration of the sound which will be generated in seconds. + /// Must be at least 0.5 and at most 22. + /// If set to None we will guess the optimal duration using the prompt. + /// Defaults to None. + /// + /// + /// A higher prompt influence makes your generation follow the prompt more closely while also making generations less variable. + /// Must be a value between 0 and 1. + /// Defaults to 0.3. + /// + public SoundGenerationRequest(string text, float? duration = null, float? promptInfluence = null) + { + Text = text; + + if (duration is > 22f or < 0.5f) + { + throw new ArgumentOutOfRangeException(nameof(duration), "Duration must be a value between 0.5 and 22."); + } + + Duration = duration; + + if (promptInfluence is > 1f or < 0f) + { + throw new ArgumentOutOfRangeException(nameof(promptInfluence), "Prompt influence must be a value between 0 and 1."); + } + + PromptInfluence = promptInfluence; + } + + /// + /// The text that will get converted into a sound effect. + /// + [JsonPropertyName("text")] + public string Text { get; } + + /// + /// The duration of the sound which will be generated in seconds. + /// Must be at least 0.5 and at most 22. + /// If set to None we will guess the optimal duration using the prompt. + /// Defaults to None. + /// + [JsonPropertyName("duration_seconds")] + public float? Duration { get; } + + /// + /// A higher prompt influence makes your generation follow the prompt more closely while also making generations less variable. + /// Must be a value between 0 and 1. + /// Defaults to 0.3. + /// + [JsonPropertyName("prompt_influence")] + public float? PromptInfluence { get; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/SoundGeneration/SoundHistoryItem.cs b/ElevenLabs-DotNet/SoundGeneration/SoundHistoryItem.cs new file mode 100644 index 0000000..8a2d73c --- /dev/null +++ b/ElevenLabs-DotNet/SoundGeneration/SoundHistoryItem.cs @@ -0,0 +1,35 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Text.Json.Serialization; + +namespace ElevenLabs.SoundGeneration +{ + public sealed class SoundHistoryItem + { + [JsonInclude] + [JsonPropertyName("sound_generation_history_item_id")] + public string Id { get; private set; } + + [JsonInclude] + [JsonPropertyName("text")] + public string Text { get; private set; } + + [JsonInclude] + [JsonPropertyName("created_at_unix")] + public int CreatedAtUnixTimeSeconds { get; private set; } + + [JsonIgnore] + public DateTime CreatedAt => DateTimeOffset.FromUnixTimeSeconds(CreatedAtUnixTimeSeconds).DateTime; + + [JsonInclude] + [JsonPropertyName("content_type")] + public string ContentType { get; private set; } + + [JsonInclude] + [JsonPropertyName("generation_config")] + public SoundGenerationConfig SoundGenerationConfig { get; private set; } + + public static implicit operator string(SoundHistoryItem item) => item?.Id; + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/TextToSpeech/TextToSpeechEndpoint.cs b/ElevenLabs-DotNet/TextToSpeech/TextToSpeechEndpoint.cs index c32b5fc..e036fa1 100644 --- a/ElevenLabs-DotNet/TextToSpeech/TextToSpeechEndpoint.cs +++ b/ElevenLabs-DotNet/TextToSpeech/TextToSpeechEndpoint.cs @@ -77,7 +77,7 @@ public async Task TextToSpeechAsync(string text, Voice voice, VoiceSe } var defaultVoiceSettings = voiceSettings ?? voice.Settings ?? await client.VoicesEndpoint.GetDefaultVoiceSettingsAsync(cancellationToken); - var payload = JsonSerializer.Serialize(new TextToSpeechRequest(text, model, defaultVoiceSettings)).ToJsonStringContent(); + using var payload = JsonSerializer.Serialize(new TextToSpeechRequest(text, model, defaultVoiceSettings)).ToJsonStringContent(); var parameters = new Dictionary { { OutputFormatParameter, outputFormat.ToString().ToLower() } @@ -93,8 +93,8 @@ public async Task TextToSpeechAsync(string text, Voice voice, VoiceSe var requestOption = partialClipCallback == null ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead; - var response = await client.Client.SendAsync(postRequest, requestOption, cancellationToken); - await response.CheckResponseAsync(cancellationToken).ConfigureAwait(false); + using var response = await client.Client.SendAsync(postRequest, requestOption, cancellationToken); + await response.CheckResponseAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false); var clipId = response.Headers.GetValues(HistoryItemId).FirstOrDefault(); if (string.IsNullOrWhiteSpace(clipId)) diff --git a/ElevenLabs-DotNet/Users/UserEndpoint.cs b/ElevenLabs-DotNet/Users/UserEndpoint.cs index bb0451f..a019602 100644 --- a/ElevenLabs-DotNet/Users/UserEndpoint.cs +++ b/ElevenLabs-DotNet/Users/UserEndpoint.cs @@ -2,6 +2,7 @@ using ElevenLabs.Extensions; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; namespace ElevenLabs.User @@ -18,20 +19,20 @@ public UserEndpoint(ElevenLabsClient client) : base(client) { } /// /// Gets information about your user account. /// - public async Task GetUserInfoAsync() + public async Task GetUserInfoAsync(CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl()); - var responseAsString = await response.ReadAsStringAsync(EnableDebug); + using var response = await client.Client.GetAsync(GetUrl(), cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } /// /// Gets your subscription info. /// - public async Task GetSubscriptionInfoAsync() + public async Task GetSubscriptionInfoAsync(CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl("/subscription")); - var responseAsString = await response.ReadAsStringAsync(EnableDebug); + using var response = await client.Client.GetAsync(GetUrl("/subscription"), cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } } diff --git a/ElevenLabs-DotNet/VoiceGeneration/VoiceGenerationEndpoint.cs b/ElevenLabs-DotNet/VoiceGeneration/VoiceGenerationEndpoint.cs index e4ec9bb..1ad1fc6 100644 --- a/ElevenLabs-DotNet/VoiceGeneration/VoiceGenerationEndpoint.cs +++ b/ElevenLabs-DotNet/VoiceGeneration/VoiceGenerationEndpoint.cs @@ -24,8 +24,8 @@ public VoiceGenerationEndpoint(ElevenLabsClient client) : base(client) { } /// . public async Task GetVoiceGenerationOptionsAsync(CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl("/generate-voice/parameters"), cancellationToken); - var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken); + using var response = await client.Client.GetAsync(GetUrl("/generate-voice/parameters"), cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } @@ -37,9 +37,9 @@ public async Task GetVoiceGenerationOptionsAsync(Cancella /// Tuple with generated voice id and audio data. public async Task>> GenerateVoicePreviewAsync(GeneratedVoicePreviewRequest generatedVoicePreviewRequest, CancellationToken cancellationToken = default) { - var payload = JsonSerializer.Serialize(generatedVoicePreviewRequest, ElevenLabsClient.JsonSerializationOptions).ToJsonStringContent(); - var response = await client.Client.PostAsync(GetUrl("/generate-voice"), payload, cancellationToken); - await response.CheckResponseAsync(cancellationToken); + using var payload = JsonSerializer.Serialize(generatedVoicePreviewRequest, ElevenLabsClient.JsonSerializationOptions).ToJsonStringContent(); + using var response = await client.Client.PostAsync(GetUrl("/generate-voice"), payload, cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false); var generatedVoiceId = response.Headers.FirstOrDefault(pair => pair.Key == "generated_voice_id").Value.FirstOrDefault(); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var memoryStream = new MemoryStream(); @@ -64,9 +64,9 @@ public async Task>> GenerateVoicePreviewAsync /// . public async Task CreateVoiceAsync(CreateVoiceRequest createVoiceRequest, CancellationToken cancellationToken = default) { - var payload = JsonSerializer.Serialize(createVoiceRequest).ToJsonStringContent(); - var response = await client.Client.PostAsync(GetUrl("/create-voice"), payload, cancellationToken); - var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken); + using var payload = JsonSerializer.Serialize(createVoiceRequest).ToJsonStringContent(); + using var response = await client.Client.PostAsync(GetUrl("/create-voice"), payload, cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken: cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } } diff --git a/ElevenLabs-DotNet/Voices/SharedVoiceInfo.cs b/ElevenLabs-DotNet/Voices/SharedVoiceInfo.cs new file mode 100644 index 0000000..fd4098e --- /dev/null +++ b/ElevenLabs-DotNet/Voices/SharedVoiceInfo.cs @@ -0,0 +1,121 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Text.Json.Serialization; + +namespace ElevenLabs.Voices +{ + public sealed class SharedVoiceInfo + { + [JsonInclude] + [JsonPropertyName("public_owner_id")] + public string OwnerId { get; private set; } + + [JsonInclude] + [JsonPropertyName("voice_id")] + public string VoiceId { get; private set; } + + [JsonInclude] + [JsonPropertyName("date_unix")] + public int DateUnix { get; private set; } + + [JsonIgnore] + public DateTime Date => DateTimeOffset.FromUnixTimeSeconds(DateUnix).DateTime; + + [JsonInclude] + [JsonPropertyName("name")] + public string Name { get; private set; } + + [JsonInclude] + [JsonPropertyName("accent")] + public string Accent { get; private set; } + + [JsonInclude] + [JsonPropertyName("gender")] + public string Gender { get; private set; } + + [JsonInclude] + [JsonPropertyName("age")] + public string Age { get; private set; } + + [JsonInclude] + [JsonPropertyName("descriptive")] + public string Descriptive { get; private set; } + + [JsonInclude] + [JsonPropertyName("use_case")] + public string UseCase { get; private set; } + + [JsonInclude] + [JsonPropertyName("category")] + public string Category { get; private set; } + + [JsonInclude] + [JsonPropertyName("language")] + public string Language { get; private set; } + + [JsonInclude] + [JsonPropertyName("description")] + public string Description { get; private set; } + + [JsonInclude] + [JsonPropertyName("preview_url")] + public string PreviewUrl { get; private set; } + + [JsonInclude] + [JsonPropertyName("usage_character_count_1y")] + public int UsageCharacterCount1Y { get; private set; } + + [JsonInclude] + [JsonPropertyName("usage_character_count_7d")] + public int UsageCharacterCount7D { get; private set; } + + [JsonInclude] + [JsonPropertyName("play_api_usage_character_count_1y")] + public int PlayApiUsageCharacterCount1Y { get; private set; } + + [JsonInclude] + [JsonPropertyName("cloned_by_count")] + public int ClonedByCount { get; private set; } + + [JsonInclude] + [JsonPropertyName("rate")] + public float Rate { get; private set; } + + [JsonInclude] + [JsonPropertyName("free_users_allowed")] + public bool FreeUsersAllowed { get; private set; } + + [JsonInclude] + [JsonPropertyName("live_moderation_enabled")] + public bool LiveModerationEnabled { get; private set; } + + [JsonInclude] + [JsonPropertyName("featured")] + public bool Featured { get; private set; } + + [JsonInclude] + [JsonPropertyName("notice_period")] + public int? NoticePeriod { get; private set; } + + [JsonInclude] + [JsonPropertyName("instagram_username")] + public string InstagramUsername { get; private set; } + + [JsonInclude] + [JsonPropertyName("twitter_username")] + public string TwitterUsername { get; private set; } + + [JsonInclude] + [JsonPropertyName("youtube_username")] + public string YoutubeUsername { get; private set; } + + [JsonInclude] + [JsonPropertyName("tiktok_username")] + public string TikTokUsername { get; private set; } + + [JsonInclude] + [JsonPropertyName("image_url")] + public string ImageUrl { get; private set; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/Voices/SharedVoiceList.cs b/ElevenLabs-DotNet/Voices/SharedVoiceList.cs new file mode 100644 index 0000000..fa3c34c --- /dev/null +++ b/ElevenLabs-DotNet/Voices/SharedVoiceList.cs @@ -0,0 +1,22 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ElevenLabs.Voices +{ + public sealed class SharedVoiceList + { + [JsonInclude] + [JsonPropertyName("voices")] + public IReadOnlyList Voices { get; private set; } + + [JsonInclude] + [JsonPropertyName("has_more")] + public bool HasMore { get; private set; } + + [JsonInclude] + [JsonPropertyName("last_sort_id")] + public string LastId { get; private set; } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/Voices/SharedVoiceQuery.cs b/ElevenLabs-DotNet/Voices/SharedVoiceQuery.cs new file mode 100644 index 0000000..bf27a15 --- /dev/null +++ b/ElevenLabs-DotNet/Voices/SharedVoiceQuery.cs @@ -0,0 +1,114 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; + +namespace ElevenLabs.Voices +{ + public sealed class SharedVoiceQuery + { + public int? PageSize { get; set; } = null; + + public string Category { get; set; } = null; + + public string Gender { get; set; } = null; + + public string Age { get; set; } = null; + + public string Accent { get; set; } = null; + + public string Language { get; set; } = null; + + public string SearchTerms { get; set; } = null; + + public List UseCases { get; set; } = null; + + public List Descriptives { get; set; } = null; + + public bool? Featured { get; set; } = null; + + public bool? ReaderAppEnabled { get; set; } = null; + + public string OwnerId { get; set; } = null; + + public string Sort { get; set; } = null; + + public int? Page { get; set; } = null; + + public Dictionary ToQueryParams() + { + var parameters = new Dictionary(); + + if (PageSize.HasValue) + { + parameters.Add("page_size", PageSize.Value.ToString()); + } + + if (!string.IsNullOrWhiteSpace(Category)) + { + parameters.Add("category", Category); + } + + if (!string.IsNullOrWhiteSpace(Gender)) + { + parameters.Add("gender", Gender); + } + + if (!string.IsNullOrWhiteSpace(Age)) + { + parameters.Add("age", Age); + } + + if (!string.IsNullOrWhiteSpace(Accent)) + { + parameters.Add("accent", Accent); + } + + if (!string.IsNullOrWhiteSpace(Language)) + { + parameters.Add("language", Language); + } + + if (!string.IsNullOrWhiteSpace(SearchTerms)) + { + parameters.Add("search", SearchTerms); + } + + if (UseCases is { Count: > 0 }) + { + parameters.Add("use_cases", string.Join(',', UseCases)); + } + + if (Descriptives is { Count: > 0 }) + { + parameters.Add("descriptives", string.Join(',', Descriptives)); + } + + if (Featured.HasValue) + { + parameters.Add("featured", Featured.Value.ToString()); + } + + if (ReaderAppEnabled.HasValue) + { + parameters.Add("reader_app_enabled", ReaderAppEnabled.Value.ToString()); + } + + if (!string.IsNullOrWhiteSpace(OwnerId)) + { + parameters.Add("owner_id", OwnerId); + } + + if (!string.IsNullOrWhiteSpace(Sort)) + { + parameters.Add("sort", Sort); + } + + if (Page.HasValue) + { + parameters.Add("page", Page.Value.ToString()); + } + + return parameters; + } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/Voices/SharedVoicesEndpoint.cs b/ElevenLabs-DotNet/Voices/SharedVoicesEndpoint.cs new file mode 100644 index 0000000..de8ade0 --- /dev/null +++ b/ElevenLabs-DotNet/Voices/SharedVoicesEndpoint.cs @@ -0,0 +1,29 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using ElevenLabs.Extensions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace ElevenLabs.Voices +{ + public sealed class SharedVoicesEndpoint : ElevenLabsBaseEndPoint + { + public SharedVoicesEndpoint(ElevenLabsClient client) : base(client) { } + + protected override string Root => "shared-voices"; + + /// + /// Gets a list of shared voices. + /// + /// Optional, . + /// Optional, . + /// . + public async Task GetSharedVoicesAsync(SharedVoiceQuery query = null, CancellationToken cancellationToken = default) + { + using var response = await client.Client.GetAsync(GetUrl(queryParameters: query?.ToQueryParams()), cancellationToken); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken); + return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); + } + } +} \ No newline at end of file diff --git a/ElevenLabs-DotNet/Voices/VoiceRequest.cs b/ElevenLabs-DotNet/Voices/VoiceRequest.cs new file mode 100644 index 0000000..b22fa32 --- /dev/null +++ b/ElevenLabs-DotNet/Voices/VoiceRequest.cs @@ -0,0 +1,97 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ElevenLabs.Voices +{ + public sealed class VoiceRequest : IDisposable + { + public VoiceRequest(string name, string samplePath, IReadOnlyDictionary labels = null, string description = null) + : this(name, [samplePath], labels, description) + { + } + + public VoiceRequest(string name, IEnumerable samples, IReadOnlyDictionary labels = null, string description = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(samples); + + Name = name; + Description = description; + Labels = labels; + Samples = samples.ToDictionary(Path.GetFileName, File.OpenRead); + } + + public VoiceRequest(string name, byte[] sample, IReadOnlyDictionary labels = null, string description = null) + : this(name, [sample], labels, description) + { + } + + public VoiceRequest(string name, IEnumerable samples, IReadOnlyDictionary labels = null, string description = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(samples); + + Name = name; + Description = description; + Labels = labels; + var count = 0; + Samples = samples.ToDictionary(_ => $"file-{count++}", sample => new MemoryStream(sample)); + } + + public VoiceRequest(string name, Stream sample, IReadOnlyDictionary labels = null, string description = null) + : this(name, [sample], labels, description) + { + } + + public VoiceRequest(string name, IEnumerable samples, IReadOnlyDictionary labels = null, string description = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(samples); + + Name = name; + Description = description; + Labels = labels; + var count = 0; + Samples = samples.ToDictionary(_ => $"file-{count++}", sample => sample); + } + + ~VoiceRequest() => Dispose(false); + + public string Name { get; } + + public string Description { get; } + + public IReadOnlyDictionary Labels { get; } + + public IReadOnlyDictionary Samples { get; } + + private void Dispose(bool disposing) + { + if (disposing) + { + foreach (var (_, sample) in Samples) + { + try + { + sample?.Close(); + sample?.Dispose(); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/ElevenLabs-DotNet/Voices/VoicesEndpoint.cs b/ElevenLabs-DotNet/Voices/VoicesEndpoint.cs index 252aab8..fb0b742 100644 --- a/ElevenLabs-DotNet/Voices/VoicesEndpoint.cs +++ b/ElevenLabs-DotNet/Voices/VoicesEndpoint.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -41,8 +42,8 @@ public VoicesEndpoint(ElevenLabsClient client) : base(client) { } ///
/// /// of s. - public Task> GetAllVoicesAsync(CancellationToken cancellationToken = default) - => GetAllVoicesAsync(true, cancellationToken); + public async Task> GetAllVoicesAsync(CancellationToken cancellationToken = default) + => await GetAllVoicesAsync(true, cancellationToken).ConfigureAwait(false); /// /// Gets a list of all available voices for a user. @@ -52,7 +53,7 @@ public Task> GetAllVoicesAsync(CancellationToken cancellati /// of s. public async Task> GetAllVoicesAsync(bool downloadSettings, CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl(), cancellationToken).ConfigureAwait(false); + using var response = await client.Client.GetAsync(GetUrl(), cancellationToken).ConfigureAwait(false); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); var voices = JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions).Voices; @@ -62,7 +63,7 @@ public async Task> GetAllVoicesAsync(bool downloadSettings, foreach (var voice in voices) { - voiceSettingsTasks.Add(Task.Run(LocalGetVoiceSettingsAsync, cancellationToken)); + voiceSettingsTasks.Add(LocalGetVoiceSettingsAsync()); async Task LocalGetVoiceSettingsAsync() { @@ -83,7 +84,7 @@ async Task LocalGetVoiceSettingsAsync() /// . public async Task GetDefaultVoiceSettingsAsync(CancellationToken cancellationToken = default) { - var response = await client.Client.GetAsync(GetUrl("/settings/default"), cancellationToken).ConfigureAwait(false); + using var response = await client.Client.GetAsync(GetUrl("/settings/default"), cancellationToken).ConfigureAwait(false); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } @@ -101,7 +102,7 @@ public async Task GetVoiceSettingsAsync(string voiceId, Cancellat throw new ArgumentNullException(nameof(voiceId)); } - var response = await client.Client.GetAsync(GetUrl($"/{voiceId}/settings"), cancellationToken).ConfigureAwait(false); + using var response = await client.Client.GetAsync(GetUrl($"/{voiceId}/settings"), cancellationToken).ConfigureAwait(false); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } @@ -120,7 +121,7 @@ public async Task GetVoiceAsync(string voiceId, bool withSettings = false throw new ArgumentNullException(nameof(voiceId)); } - var response = await client.Client.GetAsync(GetUrl($"/{voiceId}?with_settings={withSettings}"), cancellationToken).ConfigureAwait(false); + using var response = await client.Client.GetAsync(GetUrl($"/{voiceId}?with_settings={withSettings.ToString().ToLower()}"), cancellationToken).ConfigureAwait(false); var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); } @@ -139,8 +140,8 @@ public async Task EditVoiceSettingsAsync(string voiceId, VoiceSettings voi throw new ArgumentNullException(nameof(voiceId)); } - var payload = JsonSerializer.Serialize(voiceSettings).ToJsonStringContent(); - var response = await client.Client.PostAsync(GetUrl($"/{voiceId}/settings/edit"), payload, cancellationToken).ConfigureAwait(false); + using var payload = JsonSerializer.Serialize(voiceSettings).ToJsonStringContent(); + using var response = await client.Client.PostAsync(GetUrl($"/{voiceId}/settings/edit"), payload, cancellationToken).ConfigureAwait(false); await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } @@ -152,54 +153,9 @@ public async Task EditVoiceSettingsAsync(string voiceId, VoiceSettings voi /// Collection of file paths to use as samples for the new voice. /// Optional, labels for the new voice. /// Optional, . - public async Task AddVoiceAsync(string name, IEnumerable samplePaths = null, IReadOnlyDictionary labels = null, CancellationToken cancellationToken = default) - { - var form = new MultipartFormDataContent(); - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - form.Add(new StringContent(name), "name"); - - if (samplePaths != null) - { - var paths = samplePaths.Where(path => !string.IsNullOrWhiteSpace(path)).ToList(); - - if (paths.Any()) - { - foreach (var sample in paths) - { - if (!File.Exists(sample)) - { - Console.WriteLine($"No sample clip found at {sample}!"); - continue; - } - - try - { - var fileBytes = await File.ReadAllBytesAsync(sample, cancellationToken); - form.Add(new ByteArrayContent(fileBytes), "files", Path.GetFileName(sample)); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - } - } - - if (labels != null) - { - form.Add(new StringContent(JsonSerializer.Serialize(labels)), "labels"); - } - - var response = await client.Client.PostAsync(GetUrl("/add"), form, cancellationToken).ConfigureAwait(false); - var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); - var voiceResponse = JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); - return await GetVoiceAsync(voiceResponse.VoiceId, cancellationToken: cancellationToken).ConfigureAwait(false); - } + [Obsolete("Use new overload with VoiceRequest.")] + public async Task AddVoiceAsync(string name, IEnumerable samplePaths, IReadOnlyDictionary labels = null, CancellationToken cancellationToken = default) + => await AddVoiceAsync(new VoiceRequest(name, samplePaths, labels), cancellationToken).ConfigureAwait(false); /// /// Add a new voice to your collection of voices in VoiceLab from a stream @@ -208,46 +164,9 @@ public async Task AddVoiceAsync(string name, IEnumerable samplePa /// Collection of samples as an array of bytes to be used for the new voice /// Optional, labels for the new voice. /// Optional, . + [Obsolete("Use new overload with VoiceRequest.")] public async Task AddVoiceAsync(string name, IEnumerable samples, IReadOnlyDictionary labels = null, CancellationToken cancellationToken = default) - { - var form = new MultipartFormDataContent(); - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (samples == null) - { - throw new ArgumentNullException(nameof(samples)); - } - - form.Add(new StringContent(name), "name"); - - var fileItr = 0; - - foreach (var content in samples) - { - try - { - form.Add(new ByteArrayContent(content), "files", $"file-{fileItr++}"); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - if (labels != null) - { - form.Add(new StringContent(JsonSerializer.Serialize(labels)), "labels"); - } - - var response = await client.Client.PostAsync(GetUrl("/add"), form, cancellationToken).ConfigureAwait(false); - var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); - var voiceResponse = JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); - return await GetVoiceAsync(voiceResponse.VoiceId, cancellationToken: cancellationToken).ConfigureAwait(false); - } + => await AddVoiceAsync(new VoiceRequest(name, samples, labels), cancellationToken).ConfigureAwait(false); /// /// Add a new voice to your collection of voices in VoiceLab from a stream @@ -256,42 +175,41 @@ public async Task AddVoiceAsync(string name, IEnumerable samples, /// Collection of samples as a stream to be used for the new voice /// Optional, labels for the new voice. /// Optional, . + [Obsolete("Use new overload with VoiceRequest.")] public async Task AddVoiceAsync(string name, IEnumerable sampleStreams, IReadOnlyDictionary labels = null, CancellationToken cancellationToken = default) - { - var form = new MultipartFormDataContent(); + => await AddVoiceAsync(new VoiceRequest(name, sampleStreams, labels), cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + /// + /// Add a new voice to your collection of voices in VoiceLab. + /// + /// . + /// Optional, . + /// . + public async Task AddVoiceAsync(VoiceRequest request, CancellationToken cancellationToken = default) + { + using var payload = new MultipartFormDataContent(); - if (sampleStreams == null) + try { - throw new ArgumentNullException(nameof(sampleStreams)); - } - - form.Add(new StringContent(name), "name"); - var fileItr = 0; + payload.Add(new StringContent(request.Name), "name"); - foreach (var voiceStream in sampleStreams) - { - try + foreach (var (fileName, sample) in request.Samples) { - form.Add(new StreamContent(voiceStream), "files", $"file-{fileItr++}"); + await payload.AppendFileToFormAsync("files", sample, fileName, null, cancellationToken); } - catch (Exception e) + + if (request.Labels != null) { - Console.WriteLine(e); + payload.Add(new StringContent(JsonSerializer.Serialize(request.Labels)), "labels"); } } - - if (labels != null) + finally { - form.Add(new StringContent(JsonSerializer.Serialize(labels)), "labels"); + request.Dispose(); } - var response = await client.Client.PostAsync(GetUrl("/add"), form, cancellationToken).ConfigureAwait(false); - var responseAsString = await response.ReadAsStringAsync(EnableDebug, cancellationToken).ConfigureAwait(false); + using var response = await client.Client.PostAsync(GetUrl("/add"), payload, cancellationToken).ConfigureAwait(false); + var responseAsString = await response.ReadAsStringAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false); var voiceResponse = JsonSerializer.Deserialize(responseAsString, ElevenLabsClient.JsonSerializationOptions); return await GetVoiceAsync(voiceResponse.VoiceId, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -306,49 +224,51 @@ public async Task AddVoiceAsync(string name, IEnumerable sampleSt /// True, if voice was successfully edited. public async Task EditVoiceAsync(Voice voice, IEnumerable samplePaths = null, IReadOnlyDictionary labels = null, CancellationToken cancellationToken = default) { - var form = new MultipartFormDataContent(); + using var payload = new MultipartFormDataContent(); - if (voice == null) - { - throw new ArgumentNullException(nameof(voice)); - } + ArgumentNullException.ThrowIfNull(voice, nameof(voice)); + ArgumentNullException.ThrowIfNull(samplePaths, nameof(samplePaths)); - form.Add(new StringContent(voice.Name), "name"); + payload.Add(new StringContent(voice.Name), "name"); - if (samplePaths != null) - { - var paths = samplePaths.Where(path => !string.IsNullOrWhiteSpace(path)).ToList(); + var paths = samplePaths.Where(path => !string.IsNullOrWhiteSpace(path)).ToList(); - if (paths.Any()) + if (paths.Any()) + { + foreach (var sample in paths) { - foreach (var sample in paths) + if (!File.Exists(sample)) { - if (!File.Exists(sample)) - { - Console.WriteLine($"No sample clip found at {sample}!"); - continue; - } + Console.WriteLine($"No sample clip found at {sample}!"); + continue; + } - try - { - var fileBytes = await File.ReadAllBytesAsync(sample, cancellationToken); - form.Add(new ByteArrayContent(fileBytes), "files", Path.GetFileName(sample)); - } - catch (Exception e) + try + { + var fileBytes = await File.ReadAllBytesAsync(sample, cancellationToken); + var content = new ByteArrayContent(fileBytes); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { - Console.WriteLine(e); - } + Name = "files", + FileName = Path.GetFileName(sample) + }; + payload.Add(content, "files", Path.GetFileName(sample)); + } + catch (Exception e) + { + Console.WriteLine(e); } } } if (labels != null) { - form.Add(new StringContent(JsonSerializer.Serialize(labels)), "labels"); + payload.Add(new StringContent(JsonSerializer.Serialize(labels)), "labels"); } - var response = await client.Client.PostAsync(GetUrl($"/{voice.Id}/edit"), form, cancellationToken).ConfigureAwait(false); - await response.CheckResponseAsync(cancellationToken).ConfigureAwait(false); + using var response = await client.Client.PostAsync(GetUrl($"/{voice.Id}/edit"), payload, cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } @@ -365,8 +285,8 @@ public async Task DeleteVoiceAsync(string voiceId, CancellationToken cance throw new ArgumentNullException(nameof(voiceId)); } - var response = await client.Client.DeleteAsync(GetUrl($"/{voiceId}"), cancellationToken).ConfigureAwait(false); - await response.CheckResponseAsync(cancellationToken).ConfigureAwait(false); + using var response = await client.Client.DeleteAsync(GetUrl($"/{voiceId}"), cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } @@ -393,8 +313,8 @@ public async Task DownloadVoiceSampleAudioAsync(Voice voice, Sample s throw new ArgumentNullException(nameof(sample)); } - var response = await client.Client.GetAsync(GetUrl($"/{voice.Id}/samples/{sample.Id}/audio"), cancellationToken).ConfigureAwait(false); - await response.CheckResponseAsync(cancellationToken).ConfigureAwait(false); + using var response = await client.Client.GetAsync(GetUrl($"/{voice.Id}/samples/{sample.Id}/audio"), cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var memoryStream = new MemoryStream(); await responseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); @@ -420,8 +340,8 @@ public async Task DeleteVoiceSampleAsync(string voiceId, string sampleId, throw new ArgumentNullException(nameof(sampleId)); } - var response = await client.Client.DeleteAsync(GetUrl($"/{voiceId}/samples/{sampleId}"), cancellationToken); - await response.CheckResponseAsync(cancellationToken); + using var response = await client.Client.DeleteAsync(GetUrl($"/{voiceId}/samples/{sampleId}"), cancellationToken).ConfigureAwait(false); + await response.CheckResponseAsync(EnableDebug, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } diff --git a/LICENSE b/LICENSE index 6a82cb0..029dfaf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Rage Against The Pixel +Copyright (c) 2024 Rage Against The Pixel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 848f51c..de33d5f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ I am not affiliated with ElevenLabs and an account with api access is required. ***All copyrights, trademarks, logos, and assets are the property of their respective owners.*** +## Requirements + +- This library targets .NET 8.0 and above. +- It should work across console apps, winforms, wpf, asp.net, etc. +- It should also work across Windows, Linux, and Mac. + ## Getting started ### Install from NuGet @@ -21,6 +27,10 @@ Install package [`ElevenLabs-DotNet` from Nuget](https://www.nuget.org/packages/ Install-Package ElevenLabs-DotNet ``` +```terminal +dotnet add package ElevenLabs-DotNet +``` + > Looking to [use ElevenLabs in the Unity Game Engine](https://github.com/RageAgainstThePixel/com.rest.elevenlabs)? Check out our unity package on OpenUPM: > >[![openupm](https://img.shields.io/npm/v/com.rest.elevenlabs?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.rest.elevenlabs/) @@ -36,6 +46,7 @@ Install-Package ElevenLabs-DotNet - [Text to Speech](#text-to-speech) - [Stream Text To Speech](#stream-text-to-speech) - [Voices](#voices) + - [Get Shared Voices](#get-shared-voices) :new: - [Get All Voices](#get-all-voices) - [Get Default Voice Settings](#get-default-voice-settings) - [Get Voice](#get-voice) @@ -46,6 +57,13 @@ Install-Package ElevenLabs-DotNet - [Samples](#samples) - [Download Voice Sample](#download-voice-sample) - [Delete Voice Sample](#delete-voice-sample) +- [Dubbing](#dubbing) :new: + - [Dub](#dub) :new: + - [Get Dubbing Metadata](#get-dubbing-metadata) :new: + - [Get Transcript for Dub](#get-transcript-for-dub) :new: + - [Get dubbed file](#get-dubbed-file) :new: + - [Delete Dubbing Project](#delete-dubbing-project) :new: +- [SFX Generation](#sfx-generation) :new: - [History](#history) - [Get History](#get-history) - [Get History Item](#get-history-item) @@ -142,6 +160,7 @@ In this example, we demonstrate how to set up and use `ElevenLabsProxyStartup` i 1. Create a new [ASP.NET Core minimal web API](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-6.0) project. 2. Add the ElevenLabs-DotNet nuget package to your project. - Powershell install: `Install-Package ElevenLabs-DotNet-Proxy` + - Dotnet install: `dotnet add package ElevenLabs-DotNet-Proxy` - Manually editing .csproj: `` 3. Create a new class that inherits from `AbstractAuthenticationFilter` and override the `ValidateAuthentication` method. This will implement the `IAuthenticationFilter` that you will use to check user session token against your internal server. 4. In `Program.cs`, create a new proxy web application by calling `ElevenLabsProxyStartup.CreateWebApplication` method, passing your custom `AuthenticationFilter` as a type argument. @@ -152,16 +171,6 @@ public partial class Program { private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request["xi-api-key"].ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { await Task.CompletedTask; // remote resource call @@ -222,9 +231,22 @@ partialClipCallback: async (partialClip) => Access to voices created either by the user or ElevenLabs. +#### Get Shared Voices + +Gets a list of shared voices in the public voice library. + +```csharp +var api = new ElevenLabsClient(); +var results = await ElevenLabsClient.SharedVoicesEndpoint.GetSharedVoicesAsync(); +foreach (var voice in results.Voices) +{ + Console.WriteLine($"{voice.OwnerId} | {voice.VoiceId} | {voice.Date} | {voice.Name}"); +} +``` + #### Get All Voices -Gets a list of all available voices. +Gets a list of all available voices available to your account. ```csharp var api = new ElevenLabsClient(); @@ -317,6 +339,95 @@ var success = await api.VoicesEndpoint.DeleteVoiceSampleAsync(voiceId, sampleId) Console.WriteLine($"Was successful? {success}"); ``` +### [Dubbing](https://elevenlabs.io/docs/api-reference/create-dub) + +#### Dub + +Dubs provided audio or video file into given language. + +```csharp +var api = new ElevenLabsClient(); +// from URI +var request = new DubbingRequest(new Uri("https://youtu.be/Zo5-rhYOlNk"), "ja", "en", 1, true); +// from file +var request = new DubbingRequest(filePath, "es", "en", 1); +var metadata = await api.DubbingEndpoint.DubAsync(request, progress: new Progress(metadata => +{ + switch (metadata.Status) + { + case "dubbing": + Console.WriteLine($"Dubbing for {metadata.DubbingId} in progress... Expected Duration: {metadata.ExpectedDurationSeconds:0.00} seconds"); + break; + case "dubbed": + Console.WriteLine($"Dubbing for {metadata.DubbingId} complete in {metadata.TimeCompleted.TotalSeconds:0.00} seconds!"); + break; + default: + Console.WriteLine($"Status: {metadata.Status}"); + break; + } +})); +``` + +#### Get Dubbing Metadata + +Returns metadata about a dubbing project, including whether it’s still in progress or not. + +```csharp +var api = new ElevenLabsClient(); +var metadata = api.await GetDubbingProjectMetadataAsync("dubbing-id"); +``` + +#### Get Dubbed File + +Returns dubbed file as a streamed file. + +> [!NOTE] +> Videos will be returned in MP4 format and audio only dubs will be returned in MP3. + +```csharp +var assetsDir = Path.GetFullPath("../../../Assets"); +var dubbedPath = new FileInfo(Path.Combine(assetsDir, $"online.dubbed.{request.TargetLanguage}.mp4")); +{ + await using var fs = File.Open(dubbedPath.FullName, FileMode.Create); + await foreach (var chunk in ElevenLabsClient.DubbingEndpoint.GetDubbedFileAsync(metadata.DubbingId, request.TargetLanguage)) + { + await fs.WriteAsync(chunk); + } +} +``` + +#### Get Transcript for Dub + +Returns transcript for the dub in the desired format. + +```csharp +var assetsDir = Path.GetFullPath("../../../Assets"); +var transcriptPath = new FileInfo(Path.Combine(assetsDir, $"online.dubbed.{request.TargetLanguage}.srt")); +{ + var transcriptFile = await api.DubbingEndpoint.GetTranscriptForDubAsync(metadata.DubbingId, request.TargetLanguage); + await File.WriteAllTextAsync(transcriptPath.FullName, transcriptFile); +} +``` + +#### Delete Dubbing Project + +Deletes a dubbing project. + +```csharp +var api = new ElevenLabsClient(); +await api.DubbingEndpoint.DeleteDubbingProjectAsync("dubbing-id"); +``` + +### SFX Generation + +API that converts text into sounds & uses the most advanced AI audio model ever. + +```csharp +var api = new ElevenLabsClient(); +var request = new SoundGenerationRequest("Star Wars Light Saber parry"); +var clip = await api.SoundGenerationEndpoint.GenerateSoundAsync(request); +``` + ### [History](https://docs.elevenlabs.io/api-reference/history) Access to your previously synthesized audio clips including its metadata.