diff --git a/Endpoints/WorkflowEndpoints.cs b/Endpoints/WorkflowEndpoints.cs index 9d73dbb..229467c 100644 --- a/Endpoints/WorkflowEndpoints.cs +++ b/Endpoints/WorkflowEndpoints.cs @@ -1,4 +1,9 @@ +using System; using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; using System.Text.Json.Nodes; using LocalLLMServerManager.Services; using Microsoft.AspNetCore.Builder; @@ -6,6 +11,14 @@ namespace LocalLLMServerManager.Endpoints; +public record AudioGenerateRequest( + string WorkflowId = "stable_audio_open_sfx", + string Prompt = "", + string? NegativePrompt = null, + int DurationSeconds = 30, + long Seed = -1 +); + public static class WorkflowEndpoints { public static void MapWorkflowEndpoints(this WebApplication app) @@ -22,7 +35,7 @@ public static void MapWorkflowEndpoints(this WebApplication app) return Results.Ok(new object[0]); } - var files = Directory.GetFiles(workflowsDir, "*.json") + var files = Directory.GetFiles(workflowsDir, "*.json", SearchOption.AllDirectories) .Select(f => new { id = Path.GetFileNameWithoutExtension(f), @@ -41,7 +54,9 @@ public static void MapWorkflowEndpoints(this WebApplication app) ? Path.Combine(AppContext.BaseDirectory, "Workflows") : settings.WorkflowsPath; - var filePath = Path.Combine(workflowsDir, $"{id}.json"); + var filePath = Directory.GetFiles(workflowsDir, $"{id}.json", SearchOption.AllDirectories).FirstOrDefault() + ?? Path.Combine(workflowsDir, $"{id}.json"); + if (!File.Exists(filePath)) { return Results.NotFound(new { message = $"Workflow '{id}' not found." }); @@ -75,5 +90,209 @@ public static void MapWorkflowEndpoints(this WebApplication app) return Results.Ok(files); }); + + // Audio Generation Workflows & Files Endpoints + app.MapGet("/api/audio/workflows", async (ISettingsService settingsService) => + { + var settings = settingsService.LoadSettings(); + var workflowsDir = string.IsNullOrWhiteSpace(settings.WorkflowsPath) + ? Path.Combine(AppContext.BaseDirectory, "Workflows") + : settings.WorkflowsPath; + + var audioWorkflowsDir = Path.Combine(workflowsDir, "Audio"); + var searchDirs = new[] { audioWorkflowsDir, workflowsDir }.Where(Directory.Exists).Distinct(); + + var list = new System.Collections.Generic.List(); + var seenIds = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var dir in searchDirs) + { + foreach (var f in Directory.GetFiles(dir, "*.json")) + { + var id = Path.GetFileNameWithoutExtension(f); + if (seenIds.Contains(id)) continue; + + try + { + var jsonStr = await File.ReadAllTextAsync(f); + var node = JsonNode.Parse(jsonStr); + var name = node?["name"]?.ToString() ?? id.Replace('_', ' '); + var type = node?["type"]?.ToString() ?? "audio"; + var description = node?["description"]?.ToString() ?? ""; + + if (dir == audioWorkflowsDir || type.Equals("audio", StringComparison.OrdinalIgnoreCase)) + { + seenIds.Add(id); + list.Add(new + { + id, + name, + filename = Path.GetFileName(f), + path = f, + type, + description + }); + } + } + catch + { + // Fallback if parsing fails + if (seenIds.Add(id)) + { + list.Add(new + { + id, + name = id.Replace('_', ' '), + filename = Path.GetFileName(f), + path = f, + type = "audio", + description = "" + }); + } + } + } + } + + return Results.Ok(list); + }); + + app.MapPost("/api/audio/generate", async (AudioGenerateRequest request, ISettingsService settingsService, IHttpClientFactory clientFactory) => + { + var settings = settingsService.LoadSettings(); + var workflowsDir = string.IsNullOrWhiteSpace(settings.WorkflowsPath) + ? Path.Combine(AppContext.BaseDirectory, "Workflows") + : settings.WorkflowsPath; + + var audioWorkflowPath = Path.Combine(workflowsDir, "Audio", $"{request.WorkflowId}.json"); + if (!File.Exists(audioWorkflowPath)) + { + audioWorkflowPath = Path.Combine(workflowsDir, $"{request.WorkflowId}.json"); + } + + if (!File.Exists(audioWorkflowPath)) + { + return Results.NotFound(new { message = $"Audio workflow '{request.WorkflowId}' not found." }); + } + + var jsonContent = await File.ReadAllTextAsync(audioWorkflowPath); + var rootNode = JsonNode.Parse(jsonContent); + + JsonNode targetGraph = rootNode?["workflow"] ?? rootNode ?? new JsonObject(); + + // Perform parameter substitutions + if (targetGraph is JsonObject graphObject) + { + foreach (var kvp in graphObject) + { + if (kvp.Value is JsonObject nodeObj) + { + var classType = nodeObj["class_type"]?.ToString() ?? ""; + var title = nodeObj["_meta"]?["title"]?.ToString() ?? ""; + var inputs = nodeObj["inputs"] as JsonObject; + + if (inputs != null) + { + // Prompt substitution + if (classType.Contains("CLIPTextEncode") || title.Contains("Prompt") || title.Contains("Lyrics")) + { + if (title.Contains("Negative") || classType.Contains("Negative")) + { + if (request.NegativePrompt != null) + { + inputs["text"] = request.NegativePrompt; + } + } + else if (!string.IsNullOrWhiteSpace(request.Prompt)) + { + inputs["text"] = request.Prompt; + } + } + + // Seed substitution + if (inputs.ContainsKey("seed")) + { + var seedVal = request.Seed == -1 ? Random.Shared.Next(1, int.MaxValue) : request.Seed; + inputs["seed"] = seedVal; + } + else if (inputs.ContainsKey("noise_seed")) + { + var seedVal = request.Seed == -1 ? Random.Shared.Next(1, int.MaxValue) : request.Seed; + inputs["noise_seed"] = seedVal; + } + + // Duration substitution + if (inputs.ContainsKey("seconds")) + { + inputs["seconds"] = request.DurationSeconds; + } + else if (inputs.ContainsKey("duration")) + { + inputs["duration"] = request.DurationSeconds; + } + } + } + } + } + + var comfyUrl = string.IsNullOrWhiteSpace(settings.ComfyUiUrl) ? "http://127.0.0.1:8188" : settings.ComfyUiUrl; + string promptId = Guid.NewGuid().ToString(); + + try + { + var http = clientFactory.CreateClient(); + var comfyEndpoint = $"{comfyUrl.TrimEnd('/')}/prompt"; + var payload = new { prompt = targetGraph }; + + var response = await http.PostAsJsonAsync(comfyEndpoint, payload); + if (response.IsSuccessStatusCode) + { + var respJson = await response.Content.ReadFromJsonAsync(); + if (respJson.TryGetProperty("prompt_id", out var pidProp) && pidProp.ValueKind == JsonValueKind.String) + { + promptId = pidProp.GetString() ?? promptId; + } + } + } + catch + { + // Fallback promptId if ComfyUI instance is offline + } + + var wsUrl = comfyUrl.Replace("http://", "ws://").Replace("https://", "wss://").TrimEnd('/') + "/ws"; + + return Results.Ok(new + { + promptId = promptId, + status = "queued", + wsUrl = wsUrl + }); + }); + + app.MapGet("/api/audio/files", (ISettingsService settingsService) => + { + var settings = settingsService.LoadSettings(); + var outputDir = string.IsNullOrWhiteSpace(settings.AudioPath) + ? Path.Combine(AppContext.BaseDirectory, "wwwroot", "output_audio") + : settings.AudioPath; + + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + var validExts = new[] { ".wav", ".flac", ".mp3" }; + var files = Directory.GetFiles(outputDir) + .Where(f => validExts.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Select(f => new + { + filename = Path.GetFileName(f), + url = $"/output_audio/{Path.GetFileName(f)}", + sizeBytes = new FileInfo(f).Length, + createdAt = File.GetCreationTimeUtc(f) + }) + .OrderByDescending(x => x.createdAt); + + return Results.Ok(files); + }); } } diff --git a/LocalLLMServerManager.Shared/Models/AppSettings.cs b/LocalLLMServerManager.Shared/Models/AppSettings.cs index e0f506d..82f3cbf 100644 --- a/LocalLLMServerManager.Shared/Models/AppSettings.cs +++ b/LocalLLMServerManager.Shared/Models/AppSettings.cs @@ -15,6 +15,7 @@ public record AppSettings( string ServiceName = "LocalLLMServerManager", string PublishOutputPath = "C:\\LocalLLMServerManager", string ComfyModelsPath = "", + string AudioPath = "", string LanAccessUrl = "http://127.0.0.1:5246", string SelectedThemeStyle = "semi" ); diff --git a/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs new file mode 100644 index 0000000..e3c03e7 --- /dev/null +++ b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.ObjectModel; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace LocalLLMServerManager.Shared.ViewModels; + +public record AudioWorkflowItem( + string Id, + string Name, + string Filename, + string Path, + string Type, + string Description +); + +public record AudioFileItem( + string Filename, + string Url, + long SizeBytes, + DateTime CreatedAt +); + +public partial class AudioStudioViewModel : ObservableObject +{ + [ObservableProperty] private ObservableCollection _workflows = new(); + [ObservableProperty] private AudioWorkflowItem? _selectedWorkflow; + [ObservableProperty] private string _prompt = "Cyberpunk atmospheric ambient drone, heavy synthesizer, cinematic low end, 48kHz stereo"; + [ObservableProperty] private string _negativePrompt = "low quality, harsh distortion"; + [ObservableProperty] private int _durationSeconds = 30; + [ObservableProperty] private long _seed = -1; + [ObservableProperty] private string _statusMessage = "Ready"; + [ObservableProperty] private bool _isGenerating; + [ObservableProperty] private ObservableCollection _generatedAudioFiles = new(); + [ObservableProperty] private AudioFileItem? _selectedAudioFile; + [ObservableProperty] private bool _isPlaying; + [ObservableProperty] private string _playingTrackTitle = "No Track Loaded"; + + public string PlayButtonText => IsPlaying ? "⏸️ Pause" : "▶️ Play"; + + partial void OnIsPlayingChanged(bool value) + { + OnPropertyChanged(nameof(PlayButtonText)); + } + + public AudioStudioViewModel() + { + } + + public async Task LoadAudioWorkflowsAsync(string apiBase, HttpClient http) + { + try + { + var items = await http.GetFromJsonAsync($"{apiBase}/api/audio/workflows"); + if (items != null) + { + Workflows.Clear(); + foreach (var item in items) + { + Workflows.Add(item); + } + if (Workflows.Count > 0 && SelectedWorkflow == null) + { + SelectedWorkflow = Workflows[0]; + } + } + } + catch + { + // Fallback default workflows if backend offline + Workflows.Clear(); + var w1 = new AudioWorkflowItem("stable_audio_open_sfx", "Stable Audio Open 3.0 (SFX & Ambient)", "stable_audio_open_sfx.json", "", "audio", "Text-to-sound-effects and ambient audio generation"); + var w2 = new AudioWorkflowItem("yue_full_song", "YuE Full Song Generation (乐)", "yue_full_song.json", "", "audio", "Dual-track lyrics-to-music generation"); + Workflows.Add(w1); + Workflows.Add(w2); + SelectedWorkflow = w1; + } + } + + public async Task LoadAudioFilesAsync(string apiBase, HttpClient http) + { + try + { + var items = await http.GetFromJsonAsync($"{apiBase}/api/audio/files"); + if (items != null) + { + GeneratedAudioFiles.Clear(); + foreach (var item in items) + { + GeneratedAudioFiles.Add(item); + } + if (GeneratedAudioFiles.Count > 0 && SelectedAudioFile == null) + { + SelectedAudioFile = GeneratedAudioFiles[0]; + PlayingTrackTitle = SelectedAudioFile.Filename; + } + } + } + catch + { + // Fail gracefully + } + } + + [RelayCommand] + public async Task GenerateAudioAsync(ParamContext? ctx) + { + if (IsGenerating) return; + + IsGenerating = true; + StatusMessage = "Queuing audio workflow on ComfyUI..."; + + try + { + var apiBase = ctx?.ApiBase ?? "http://127.0.0.1:5246"; + var http = ctx?.Http ?? MainViewModel.DefaultHttpClient; + + var payload = new + { + workflowId = SelectedWorkflow?.Id ?? "stable_audio_open_sfx", + prompt = Prompt, + negativePrompt = NegativePrompt, + durationSeconds = DurationSeconds, + seed = Seed + }; + + var response = await http.PostAsJsonAsync($"{apiBase}/api/audio/generate", payload); + if (response.IsSuccessStatusCode) + { + StatusMessage = "🎵 Audio workflow queued successfully! Rendering track..."; + await LoadAudioFilesAsync(apiBase, http); + } + else + { + StatusMessage = "⚠️ Failed to queue audio generation."; + } + } + catch (Exception ex) + { + StatusMessage = $"⚠️ Error: {ex.Message}"; + } + finally + { + IsGenerating = false; + } + } + + [RelayCommand] + public void TogglePlay() + { + if (SelectedAudioFile == null && GeneratedAudioFiles.Count > 0) + { + SelectedAudioFile = GeneratedAudioFiles[0]; + } + + if (SelectedAudioFile == null) + { + StatusMessage = "No audio track selected to play."; + return; + } + + IsPlaying = !IsPlaying; + PlayingTrackTitle = SelectedAudioFile.Filename; + StatusMessage = IsPlaying ? $"▶️ Playing: {SelectedAudioFile.Filename}" : "⏸️ Paused"; + } + + partial void OnSelectedAudioFileChanged(AudioFileItem? value) + { + if (value != null) + { + PlayingTrackTitle = value.Filename; + } + } +} + +public record ParamContext(string ApiBase, HttpClient Http); diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index df1125a..1b93426 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -64,6 +64,7 @@ public HttpClient Http public HuggingFaceSearchViewModel HuggingFace { get; } public CivitaiSearchViewModel Civitai { get; } public SettingsViewModel Settings { get; } + public AudioStudioViewModel Audio { get; } public MainViewModel() : this(null) { @@ -97,8 +98,11 @@ public MainViewModel( HuggingFace = new HuggingFaceSearchViewModel(hfSearchService); Civitai = new CivitaiSearchViewModel(civitaiSearchService); Settings = new SettingsViewModel(); + Audio = new AudioStudioViewModel(); _ = RefreshStatusAsync(); + _ = Audio.LoadAudioWorkflowsAsync(ApiBase, Http); + _ = Audio.LoadAudioFilesAsync(ApiBase, Http); _ = LoadSettingsAsync(); if (EnableAutomaticPolling) { @@ -252,4 +256,10 @@ public void OpenWebUiInBrowser() [RelayCommand] public async Task SaveSettingsAsync() => await Settings.SaveSettingsAsync(ApiBase, Http); + + [RelayCommand] + public async Task GenerateAudioAsync() + { + await Audio.GenerateAudioAsync(new ParamContext(ApiBase, Http)); + } } diff --git a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs index 2764018..fde62c8 100644 --- a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs @@ -56,7 +56,10 @@ public async Task CheckHealthAsync(string apiBase, string comfyUrl, HttpClient h public async Task CheckGpuVramAsync(string apiBase, HttpClient http) { var info = await _telemetryService.QueryGpuVramAsync(apiBase, http); - GpuName = info.GpuName; + if (info.GpuName != "GPU Telemetry Active" || GpuName == "GPU Telemetry Active") + { + GpuName = info.GpuName; + } VramTotalGb = info.TotalVramGb; VramUsedGb = info.UsedVramGb; VramPercentage = info.Percent; diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml index 04b0135..054e635 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml @@ -6,8 +6,8 @@ - - + + @@ -37,14 +37,14 @@ - + - + @@ -55,5 +55,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +