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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Endpoints/DiscoveryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ public static void MapDiscoveryEndpoints(this WebApplication app)

ComfyModelsPath = string.IsNullOrWhiteSpace(currentSettings.ComfyModelsPath) && !string.IsNullOrWhiteSpace(detected.ComfyUi.ModelsDirectory)
? detected.ComfyUi.ModelsDirectory
: currentSettings.ComfyModelsPath
: currentSettings.ComfyModelsPath,

AudioEngineExecutablePath = string.IsNullOrWhiteSpace(currentSettings.AudioEngineExecutablePath) && !string.IsNullOrWhiteSpace(detected.AudioEngine?.ExecutablePath)
? detected.AudioEngine.ExecutablePath
: currentSettings.AudioEngineExecutablePath
};

settingsService.SaveSettings(updatedSettings);
Expand Down Expand Up @@ -121,6 +125,11 @@ public static void MapDiscoveryEndpoints(this WebApplication app)
results[nameof(request.OllamaExecutablePath)] = discoveryService.ValidatePath(request.OllamaExecutablePath, PathTargetType.Executable);
}

if (request.AudioEngineExecutablePath != null)
{
results[nameof(request.AudioEngineExecutablePath)] = discoveryService.ValidatePath(request.AudioEngineExecutablePath, PathTargetType.Executable);
}

var allValid = results.Values.All(r => r.IsValid);
return Results.Ok(new ValidatePathsResponse(results, allValid));
});
Expand Down
61 changes: 61 additions & 0 deletions Endpoints/EngineEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,66 @@ public static void MapEngineEndpoints(this WebApplication app)
await orchestrator.FreeComfyUiVramAsync();
return Results.Ok(new { message = "ComfyUI VRAM freed" });
});

app.MapPost("/api/audio/start", async (IAiEngineManager engineManager, ISettingsService settingsService, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("EngineEndpoints");
var settings = settingsService.LoadSettings();
var execPath = string.IsNullOrWhiteSpace(settings.AudioEngineExecutablePath) ? @"C:\AI\Kokoro-FastAPI\main.py" : settings.AudioEngineExecutablePath;

if (!execPath.TrimStart().StartsWith("docker", StringComparison.OrdinalIgnoreCase))
{
if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(Program.ResolvePath(execPath, @"C:\AI\Kokoro-FastAPI\main.py")))
{
return Results.BadRequest(new { message = $"Invalid or unsafe executable path: {execPath}" });
}
}

var success = await engineManager.StartAudioEngineAsync(execPath, logger);
if (success)
{
return Results.Ok(new { message = "Audio Engine Started", pid = engineManager.AudioProcess?.Id });
}
return Results.Problem("Failed to start Audio Engine process");
});

app.MapPost("/api/audio/stop", async (IAiEngineManager engineManager, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("EngineEndpoints");
await engineManager.StopAudioEngineAsync(logger);
return Results.Ok(new { message = "Audio Engine Stopped" });
});

app.MapGet("/api/audio/voices", async (ISettingsService settingsService, System.Net.Http.IHttpClientFactory clientFactory) =>
{
var settings = settingsService.LoadSettings();
var baseUrl = (string.IsNullOrWhiteSpace(settings.AudioEngineUrl) ? "http://127.0.0.1:8880" : settings.AudioEngineUrl).TrimEnd('/');

try
{
var client = clientFactory.CreateClient();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(3));

var response = await client.GetAsync($"{baseUrl}/v1/audio/voices", cts.Token);
if (!response.IsSuccessStatusCode)
{
response = await client.GetAsync($"{baseUrl}/voices", cts.Token);
}

if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cts.Token);
return Results.Content(json, "application/json");
}
}
catch { }

var defaultVoices = new[]
{
"af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky",
"am_adam", "am_michael", "bf_emma", "bf_isabella", "bm_george", "bm_fable"
};
return Results.Ok(new { voices = defaultVoices, preferred = settings.PreferredAudioVoice });
});
}
}
55 changes: 55 additions & 0 deletions Endpoints/ModelProxyEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Nodes;
using LocalLLMServerManager.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

Expand Down Expand Up @@ -141,5 +142,59 @@ public static void MapModelProxyEndpoints(this WebApplication app)
return Results.Problem(ex.Message);
}
});

app.MapPost("/v1/audio/speech", async (HttpContext context, ISettingsService settingsService, IHttpClientFactory clientFactory) =>
{
try
{
var settings = settingsService.LoadSettings();
var baseUrl = (string.IsNullOrWhiteSpace(settings.AudioEngineUrl) ? "http://127.0.0.1:8880" : settings.AudioEngineUrl).TrimEnd('/');
var targetUrl = $"{baseUrl}/v1/audio/speech";

using var reader = new StreamReader(context.Request.Body);
var requestBodyStr = await reader.ReadToEndAsync();

string outgoingJson = requestBodyStr;
if (!string.IsNullOrWhiteSpace(requestBodyStr))
{
try
{
using var doc = JsonDocument.Parse(requestBodyStr);
var root = doc.RootElement;
var hasVoice = root.TryGetProperty("voice", out var voiceProp) && !string.IsNullOrWhiteSpace(voiceProp.GetString());

if (!hasVoice)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(requestBodyStr) ?? new Dictionary<string, object>();
dict["voice"] = string.IsNullOrWhiteSpace(settings.PreferredAudioVoice) ? "af_heart" : settings.PreferredAudioVoice;
outgoingJson = JsonSerializer.Serialize(dict);
}
}
catch { }
}

var http = clientFactory.CreateClient();
using var targetReq = new HttpRequestMessage(HttpMethod.Post, targetUrl);
targetReq.Content = new StringContent(outgoingJson, System.Text.Encoding.UTF8, "application/json");

var targetResponse = await http.SendAsync(targetReq, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);

context.Response.StatusCode = (int)targetResponse.StatusCode;
var contentType = targetResponse.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
context.Response.ContentType = contentType;

await using var responseStream = await targetResponse.Content.ReadAsStreamAsync(context.RequestAborted);
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
catch (Exception ex)
{
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = ex.Message }));
}
}
});
}
}
5 changes: 4 additions & 1 deletion LocalLLMServerManager.Shared/Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public record AppSettings(
string PublishOutputPath = "C:\\LocalLLMServerManager",
string ComfyModelsPath = "",
string LanAccessUrl = "http://127.0.0.1:5246",
string SelectedThemeStyle = "semi"
string SelectedThemeStyle = "semi",
string AudioEngineExecutablePath = "",
string AudioEngineUrl = "http://127.0.0.1:8880",
string PreferredAudioVoice = "af_heart"
);

71 changes: 70 additions & 1 deletion LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _selectedThemeStyle = "semi";
[ObservableProperty] private string _serviceName = "LocalLLMServerManager";
[ObservableProperty] private string _publishOutputPath = "C:\\LocalLLMServerManager";
[ObservableProperty] private string _audioEngineExecutablePath = "";
[ObservableProperty] private string _audioEngineUrl = "http://127.0.0.1:8880";
[ObservableProperty] private string _preferredAudioVoice = "af_heart";

[ObservableProperty] private IStorageProvider? _storageProvider;
[ObservableProperty] private bool _isAutoDetecting;
Expand All @@ -38,6 +41,7 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _comfyModelsStatus = "⚠️ Missing";
[ObservableProperty] private string _threeDModelsStatus = "⚠️ Missing";
[ObservableProperty] private string _workflowsStatus = "⚠️ Missing";
[ObservableProperty] private string _audioEngineExecutableStatus = "⚠️ Missing";

public string OllamaExecutableStatus => OllamaStatus;

Expand Down Expand Up @@ -69,6 +73,7 @@ public SettingsViewModel(IThemeService themeService)
partial void OnWorkflowsPathChanged(string value) => WorkflowsStatus = EvaluateDirectoryStatus(value);
partial void OnComfyUiExecutablePathChanged(string value) => ComfyUiExecutableStatus = EvaluateExecutableStatus(value);
partial void OnForgeExecutablePathChanged(string value) => ForgeExecutableStatus = EvaluateExecutableStatus(value);
partial void OnAudioEngineExecutablePathChanged(string value) => AudioEngineExecutableStatus = EvaluateExecutableStatus(value);
partial void OnOllamaExecutablePathChanged(string value)
{
OllamaStatus = EvaluateExecutableStatus(value);
Expand Down Expand Up @@ -104,6 +109,7 @@ public void RefreshAllStatuses()
ComfyModelsStatus = EvaluateDirectoryStatus(ComfyModelsPath);
ThreeDModelsStatus = EvaluateDirectoryStatus(ThreeDModelsPath);
WorkflowsStatus = EvaluateDirectoryStatus(WorkflowsPath);
AudioEngineExecutableStatus = EvaluateExecutableStatus(AudioEngineExecutablePath);
OnPropertyChanged(nameof(OllamaExecutableStatus));
}

Expand Down Expand Up @@ -265,6 +271,19 @@ public async Task AutoDetectToolsAsync(string apiBase, HttpClient http)
}
}

if (root.TryGetProperty("audioEngine", out var audio))
{
if (audio.TryGetProperty("executablePath", out var aExe) && aExe.ValueKind == JsonValueKind.String)
{
var val = aExe.GetString();
if (!string.IsNullOrWhiteSpace(val) && string.IsNullOrWhiteSpace(AudioEngineExecutablePath))
{
AudioEngineExecutablePath = val;
AudioEngineExecutableStatus = "🔍 Auto-Discovered";
}
}
}

ToastService.Instance.Show("Auto-detection complete.", ToastType.Success);
}
else
Expand Down Expand Up @@ -352,6 +371,50 @@ public async Task BrowseWorkflowsAsync(IStorageProvider? provider = null)
}
}

[RelayCommand]
public async Task BrowseAudioEngineExecutableAsync(IStorageProvider? provider = null)
{
var path = await PickFileAsync(provider, "Select Audio Engine Executable or Script", new[] { "*.py", "*.bat", "*.cmd", "*.exe", "*.sh" });
if (!string.IsNullOrWhiteSpace(path))
{
AudioEngineExecutablePath = path;
}
}

[RelayCommand]
public async Task TestVoiceSynthesizerAsync()
{
await TestVoiceSynthesizerAsync("http://127.0.0.1:5246", new HttpClient());
}

public async Task TestVoiceSynthesizerAsync(string apiBase, HttpClient http)
{
try
{
var payload = new
{
model = "kokoro",
input = "Local LLM Server Manager audio text-to-speech engine test.",
voice = string.IsNullOrWhiteSpace(PreferredAudioVoice) ? "af_heart" : PreferredAudioVoice,
response_format = "mp3"
};
var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json");
var response = await http.PostAsync($"{apiBase}/v1/audio/speech", content);
if (response.IsSuccessStatusCode)
{
ToastService.Instance.Show("TTS Synthesis test succeeded!", ToastType.Success);
}
else
{
ToastService.Instance.Show($"TTS Synthesis test returned status code {(int)response.StatusCode}", ToastType.Warning);
}
}
catch
{
ToastService.Instance.Show("Failed to test TTS Voice Synthesizer.", ToastType.Error);
}
}

private async Task<string?> PickFileAsync(IStorageProvider? explicitProvider, string title, string[] patterns)
{
var provider = explicitProvider ?? StorageProvider;
Expand Down Expand Up @@ -431,6 +494,9 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http)
SelectedThemeStyle = settings.SelectedThemeStyle ?? "semi";
ServiceName = settings.ServiceName ?? "LocalLLMServerManager";
PublishOutputPath = settings.PublishOutputPath ?? "C:\\LocalLLMServerManager";
AudioEngineExecutablePath = settings.AudioEngineExecutablePath ?? "";
AudioEngineUrl = settings.AudioEngineUrl ?? "http://127.0.0.1:8880";
PreferredAudioVoice = settings.PreferredAudioVoice ?? "af_heart";

RefreshAllStatuses();
}
Expand Down Expand Up @@ -462,7 +528,10 @@ public async Task SaveSettingsAsync(string apiBase, HttpClient http)
PublishOutputPath: this.PublishOutputPath,
ComfyModelsPath: this.ComfyModelsPath,
LanAccessUrl: this.LanAccessUrl,
SelectedThemeStyle: this.SelectedThemeStyle
SelectedThemeStyle: this.SelectedThemeStyle,
AudioEngineExecutablePath: this.AudioEngineExecutablePath,
AudioEngineUrl: this.AudioEngineUrl,
PreferredAudioVoice: this.PreferredAudioVoice
);

var content = new StringContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@
</Grid>
</StackPanel>

<!-- Audio Engine Executable -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="Audio Engine Executable / Script Path (Kokoro-FastAPI / AllTalk / Docker)" FontSize="13" Foreground="{StaticResource TextMutedBrush}" FontWeight="Medium"/>
<Border Classes="matte-pill" Padding="6,2">
<TextBlock Text="{Binding AudioEngineExecutableStatus}" FontSize="11" FontWeight="Bold"/>
</Border>
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding AudioEngineExecutablePath, Mode=TwoWay}" Classes="matte-input"/>
<Button Grid.Column="1" Content="📁 Browse..." Command="{Binding BrowseAudioEngineExecutableCommand}" Margin="8,0,0,0" Classes="matte-secondary"/>
</Grid>
</StackPanel>

<!-- Audio Engine URL & Voice Configuration -->
<Grid ColumnDefinitions="*,*">
<StackPanel Grid.Column="0" Spacing="4" Margin="0,0,8,0">
<TextBlock Text="Audio Engine URL" FontSize="13" Foreground="{StaticResource TextMutedBrush}" FontWeight="Medium"/>
<TextBox Text="{Binding AudioEngineUrl, Mode=TwoWay}" Classes="matte-input"/>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="4" Margin="8,0,0,0">
<TextBlock Text="Preferred Audio Voice" FontSize="13" Foreground="{StaticResource TextMutedBrush}" FontWeight="Medium"/>
<TextBox Text="{Binding PreferredAudioVoice, Mode=TwoWay}" Classes="matte-input"/>
</StackPanel>
</Grid>

<Button Content="🔊 Test Voice Synthesizer"
Command="{Binding TestVoiceSynthesizerCommand}"
Classes="matte-secondary" HorizontalAlignment="Left"/>

<!-- Forge Models -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
Expand Down
8 changes: 7 additions & 1 deletion LocalLLMServerManager.Tests/AppSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public void AppSettings_DefaultValues_HaveEmptyDynamicPathsAndSensibleDefaults()
Assert.Equal("", settings.ComfyModelsPath);
Assert.Equal("http://127.0.0.1:5246", settings.LanAccessUrl);
Assert.Equal("semi", settings.SelectedThemeStyle);
Assert.Equal("", settings.AudioEngineExecutablePath);
Assert.Equal("http://127.0.0.1:8880", settings.AudioEngineUrl);
Assert.Equal("af_heart", settings.PreferredAudioVoice);
}

[Fact]
Expand Down Expand Up @@ -75,7 +78,10 @@ public void AppSettings_SerializationAndDeserialization_PreservesData()
PublishOutputPath: @"D:\Publish",
ComfyModelsPath: @"C:\AI\ComfyUI\models",
LanAccessUrl: "http://192.168.1.50:5246",
SelectedThemeStyle: "dark"
SelectedThemeStyle: "dark",
AudioEngineExecutablePath: @"C:\AI\Kokoro-FastAPI\main.py",
AudioEngineUrl: "http://127.0.0.1:8880",
PreferredAudioVoice: "af_bella"
);

var json = JsonSerializer.Serialize(original);
Expand Down
28 changes: 28 additions & 0 deletions LocalLLMServerManager.Tests/ServerEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ public async Task ServiceEndpoints_StartAndStop_ExecutesHandler()
Assert.NotNull(updateResp);
}

[Fact]
public async Task AudioEndpoints_StartStopAndVoices_ExecuteAndReturnExpectedResponses()
{
var voicesResp = await _client.GetAsync("/api/audio/voices");
Assert.True(voicesResp.IsSuccessStatusCode);

var json = await voicesResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("voices", out _));

var stopResp = await _client.PostAsync("/api/audio/stop", null);
Assert.True(stopResp.IsSuccessStatusCode);
}

[Fact]
public async Task AudioSpeechProxyEndpoint_ReturnsResponseOrBadGateway()
{
var speechRequest = new
{
model = "kokoro",
input = "Test speech synthesis",
voice = "af_heart",
response_format = "mp3"
};

var response = await _client.PostAsJsonAsync("/v1/audio/speech", speechRequest);
Assert.NotNull(response);
}

[Fact]
public async Task ModelAndWorkflowEndpoints_ReturnDirectoryLists()
{
Expand Down
Loading
Loading