diff --git a/OpenUtau.Core/Api/PhonemizerRunner.cs b/OpenUtau.Core/Api/PhonemizerRunner.cs index 34c3666cc..10d496e3d 100644 --- a/OpenUtau.Core/Api/PhonemizerRunner.cs +++ b/OpenUtau.Core/Api/PhonemizerRunner.cs @@ -32,6 +32,10 @@ internal class PhonemizerRunner : IDisposable { private readonly CancellationTokenSource shutdown = new CancellationTokenSource(); private readonly BlockingCollection requests = new BlockingCollection(); private readonly object busyLock = new object(); + private readonly object pendingLock = new object(); + private int pendingRequests; + private Exception pendingException; + private List> idleWaiters = new List>(); private Thread thread; public PhonemizerRunner(TaskScheduler mainScheduler) { @@ -44,7 +48,15 @@ public PhonemizerRunner(TaskScheduler mainScheduler) { } public void Push(PhonemizerRequest request) { - requests.Add(request); + lock (pendingLock) { + pendingRequests++; + } + try { + requests.Add(request); + } catch { + CompleteRequest(); + throw; + } } void PhonemizerLoop() { @@ -60,7 +72,14 @@ void PhonemizerLoop() { } for (int i = toRun.Count - 1; i >= 0; i--) { if (parts.Remove(toRun[i].part)) { - SendResponse(Phonemize(toRun[i])); + try { + SendResponse(Phonemize(toRun[i])); + } catch (Exception e) { + Log.Error(e, "phonemizer request failed."); + CompleteRequest(e); + } + } else { + CompleteRequest(); } } parts.Clear(); @@ -73,7 +92,7 @@ void PhonemizerLoop() { } void SendResponse(PhonemizerResponse response) { - Task.Factory.StartNew(_ => { + var task = Task.Factory.StartNew(_ => { if (DocManager.Inst.Project.parts.Contains(response.part)) { response.part.SetPhonemizerResponse(response); } @@ -84,6 +103,9 @@ void SendResponse(PhonemizerResponse response) { }); DocManager.Inst.ExecuteCmd(new PhonemizedNotification()); }, null, CancellationToken.None, TaskCreationOptions.None, mainScheduler); + task.ContinueWith(t => { + CompleteRequest(t.IsFaulted ? t.Exception?.Flatten() : null); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } static PhonemizerResponse Phonemize(PhonemizerRequest request) { @@ -205,11 +227,57 @@ static PhonemizerResponse Phonemize(PhonemizerRequest request) { /// Should only be used in command line mode. /// public void WaitFinish() { - while (true) { - lock (busyLock) { - if (requests.Count == 0) { - return; + WaitForIdleAsync().GetAwaiter().GetResult(); + } + + /// + /// Waits until all phonemizer requests queued before this call have + /// produced responses and those responses have been applied. + /// + public Task WaitForIdleAsync() { + lock (pendingLock) { + if (pendingRequests == 0) { + if (pendingException != null) { + var exception = pendingException; + pendingException = null; + return Task.FromException(exception); } + return Task.CompletedTask; + } + var waiter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + idleWaiters.Add(waiter); + return waiter.Task; + } + } + + void CompleteRequest(Exception exception = null) { + List> waiters = null; + Exception completedException = null; + lock (pendingLock) { + if (exception != null && pendingException == null) { + pendingException = exception; + } + if (pendingRequests > 0) { + pendingRequests--; + } + if (pendingRequests == 0 && idleWaiters.Count > 0) { + completedException = pendingException; + pendingException = null; + waiters = idleWaiters; + idleWaiters = new List>(); + } + } + if (waiters == null) { + return; + } + if (completedException != null) { + foreach (var waiter in waiters) { + waiter.SetException(completedException); + } + } else { + foreach (var waiter in waiters) { + waiter.SetResult(null); } } } diff --git a/OpenUtau.Core/Headless/HeadlessOpenUtauHost.cs b/OpenUtau.Core/Headless/HeadlessOpenUtauHost.cs new file mode 100644 index 000000000..773bb154a --- /dev/null +++ b/OpenUtau.Core/Headless/HeadlessOpenUtauHost.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Classic; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.Headless { + public sealed class HeadlessOpenUtauHost : ICmdSubscriber, IDisposable { + private readonly HeadlessTaskScheduler scheduler; + private readonly SynchronizationContext? previousSynchronizationContext; + private readonly TextWriter? output; + private readonly List errors = new List(); + private string lastProgressInfo = string.Empty; + private bool disposed; + + public HeadlessOpenUtauHost( + HeadlessOpenUtauOptions? options = null, + TextWriter? output = null) { + this.output = output; + scheduler = new HeadlessTaskScheduler(Thread.CurrentThread); + previousSynchronizationContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new HeadlessSynchronizationContext(scheduler)); + + if (!string.IsNullOrWhiteSpace(options?.SingersPath)) { + Preferences.Default.AdditionalSingerPath = Path.GetFullPath(options.SingersPath); + } + ApplyPreferenceOverrides(options); + + Log.Information("Initializing OpenUtau headless host."); + ToolsManager.Inst.Initialize(); + SingerManager.Inst.Initialize(); + DocManager.Inst.Initialize(Thread.CurrentThread, scheduler); + DocManager.Inst.PostOnUIThread = scheduler.Post; + DocManager.Inst.AddSubscriber(this); + Log.Information("Initialized OpenUtau headless host."); + } + + private static void ApplyPreferenceOverrides(HeadlessOpenUtauOptions? options) { + if (options == null) { + return; + } + if (!string.IsNullOrWhiteSpace(options.OnnxRunner)) { + Preferences.Default.OnnxRunner = options.OnnxRunner; + } + if (options.OnnxGpu.HasValue) { + Preferences.Default.OnnxGpu = options.OnnxGpu.Value; + } + if (options.DiffSingerDepth.HasValue) { + Preferences.Default.DiffSingerDepth = options.DiffSingerDepth.Value; + } + if (options.DiffSingerSteps.HasValue) { + Preferences.Default.DiffSingerSteps = options.DiffSingerSteps.Value; + } + if (options.DiffSingerVarianceSteps.HasValue) { + Preferences.Default.DiffSingerStepsVariance = options.DiffSingerVarianceSteps.Value; + } + if (options.DiffSingerPitchSteps.HasValue) { + Preferences.Default.DiffSingerStepsPitch = options.DiffSingerPitchSteps.Value; + } + if (options.DiffSingerTensorCache.HasValue) { + Preferences.Default.DiffSingerTensorCache = options.DiffSingerTensorCache.Value; + } + } + + public T Run(Func> operation) { + if (!scheduler.IsOwnerThread) { + throw new InvalidOperationException("Headless host must be run on its owner thread."); + } + Task task; + try { + task = operation(); + } catch (Exception e) { + task = Task.FromException(e); + } + while (!task.IsCompleted) { + scheduler.RunOne(TimeSpan.FromMilliseconds(50)); + } + scheduler.RunAvailable(); + return task.GetAwaiter().GetResult(); + } + + public void ClearErrors() { + lock (errors) { + errors.Clear(); + } + } + + public string[] TakeErrors() { + lock (errors) { + var result = errors.ToArray(); + errors.Clear(); + return result; + } + } + + public void OnNext(UCommand cmd, bool isUndo) { + if (cmd is ErrorMessageNotification error) { + lock (errors) { + errors.Add(FormatError(error)); + } + } else if (cmd is ProgressBarNotification progress) { + PublishProgress(progress); + } + } + + private void PublishProgress(ProgressBarNotification progress) { + if (output == null || + progress.Progress != 0 || + string.IsNullOrWhiteSpace(progress.Info) || + progress.Info == lastProgressInfo) { + return; + } + lastProgressInfo = progress.Info; + output.WriteLine(progress.Info); + } + + private static string FormatError(ErrorMessageNotification notification) { + if (notification.e is MessageCustomizableException mce) { + var message = string.IsNullOrWhiteSpace(mce.Message) + ? mce.SubstanceException.Message + : mce.Message; + return string.IsNullOrWhiteSpace(mce.SubstanceException.Message) + ? message + : $"{message}: {mce.SubstanceException.Message}"; + } + if (!string.IsNullOrWhiteSpace(notification.message)) { + return notification.e == null + ? notification.message + : $"{notification.message}: {notification.e.Message}"; + } + return notification.e?.Message ?? notification.ToString(); + } + + public void Dispose() { + if (disposed) { + return; + } + disposed = true; + DocManager.Inst.RemoveSubscriber(this); + DocManager.Inst.PhonemizerRunner?.Dispose(); + scheduler.RunAvailable(); + SynchronizationContext.SetSynchronizationContext(previousSynchronizationContext); + scheduler.Dispose(); + } + } +} diff --git a/OpenUtau.Core/Headless/HeadlessRenderCommand.cs b/OpenUtau.Core/Headless/HeadlessRenderCommand.cs new file mode 100644 index 000000000..1b94f677f --- /dev/null +++ b/OpenUtau.Core/Headless/HeadlessRenderCommand.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Serilog; + +namespace OpenUtau.Core.Headless { + public static class HeadlessRenderCommand { + private static readonly string[] ProjectFileExtensions = new[] { + ".ustx", + ".vsqx", + ".ust", + ".mid", + ".midi", + ".ufdata", + ".musicxml", + }; + + public static bool IsCommand(string[] args) { + return args.Length > 0 && + string.Equals(args[0], "render", StringComparison.OrdinalIgnoreCase); + } + + public static int Run(string[] args, string executableName = "OpenUtau") { + if (args.Length == 0 || IsHelp(args[0])) { + PrintUsage(args.Length > 0 && IsHelp(args[0]) ? Console.Out : Console.Error, executableName); + return args.Length > 0 && IsHelp(args[0]) ? 0 : 2; + } + if (!string.Equals(args[0], "render", StringComparison.OrdinalIgnoreCase)) { + Console.Error.WriteLine($"Unknown command: {args[0]}"); + PrintUsage(Console.Error, executableName); + return 2; + } + if (args.Skip(1).Any(IsHelp)) { + PrintUsage(Console.Out, executableName); + return 0; + } + + try { + var (job, options) = ParseRenderArgs(args.Skip(1).ToArray()); + var plan = ExpandRenderJobs(job); + using var host = new HeadlessOpenUtauHost(options, Console.Out); + return host.Run(() => RunRenderPlanAsync(plan, host)); + } catch (CommandLineException e) { + Console.Error.WriteLine(e.Message); + PrintUsage(Console.Error, executableName); + return 2; + } catch (HeadlessRenderException e) { + Console.Error.WriteLine(e.Message); + Log.Error(e, "Render command failed."); + return 1; + } catch (Exception e) { + Console.Error.WriteLine(e.Message); + Log.Error(e, "Render command failed unexpectedly."); + return 1; + } + } + + internal static (RenderJob job, HeadlessOpenUtauOptions options) ParseRenderArgs(string[] args) { + var job = new RenderJob(); + var options = new HeadlessOpenUtauOptions(); + for (int i = 0; i < args.Length; i++) { + var (name, value, consumedNext) = ReadOption(args, i); + if (consumedNext) { + i++; + } + switch (name) { + case "--input": + case "-i": + job.InputPath = value; + break; + case "--output": + case "-o": + job.OutputPath = value; + break; + case "--singer": + job.Singer = value; + break; + case "--renderer": + job.Renderer = value; + break; + case "--phonemizer": + job.Phonemizer = value; + break; + case "--resampler": + job.Resampler = value; + break; + case "--wavtool": + job.Wavtool = value; + break; + case "--singers-path": + options.SingersPath = value; + break; + case "--onnx-runner": + options.OnnxRunner = ParseOnnxRunner(value); + break; + case "--onnx-gpu": + options.OnnxGpu = ParseNonNegativeInt(name, value); + break; + case "--diffsinger-depth": + options.DiffSingerDepth = ParseNonNegativeDouble(name, value); + break; + case "--diffsinger-steps": + options.DiffSingerSteps = ParsePositiveInt(name, value); + break; + case "--diffsinger-variance-steps": + options.DiffSingerVarianceSteps = ParsePositiveInt(name, value); + break; + case "--diffsinger-pitch-steps": + options.DiffSingerPitchSteps = ParsePositiveInt(name, value); + break; + case "--diffsinger-tensor-cache": + options.DiffSingerTensorCache = ParseBool(name, value); + break; + default: + throw new CommandLineException($"Unknown option: {name}"); + } + } + if (string.IsNullOrWhiteSpace(job.InputPath)) { + throw new CommandLineException("Missing required option: --input"); + } + if (string.IsNullOrWhiteSpace(job.OutputPath)) { + throw new CommandLineException("Missing required option: --output"); + } + return (job, options); + } + + internal static RenderPlan ExpandRenderJobs(RenderJob template) { + var inputPath = Path.GetFullPath(template.InputPath); + if (File.Exists(inputPath)) { + return new RenderPlan( + isBatch: false, + jobs: new[] { CloneJob(template, inputPath, Path.GetFullPath(template.OutputPath)) }); + } + if (!Directory.Exists(inputPath)) { + throw new CommandLineException($"Input project or directory not found: {inputPath}"); + } + + var outputDir = Path.GetFullPath(template.OutputPath); + if (File.Exists(outputDir)) { + throw new CommandLineException($"Batch output must be a directory: {outputDir}"); + } + var files = Directory.EnumerateFiles(inputPath) + .Where(IsProjectFile) + .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (files.Length == 0) { + throw new CommandLineException( + $"No project files found in input directory: {inputPath}"); + } + var jobs = files + .Select(file => { + var relative = Path.GetRelativePath(inputPath, file); + var outputPath = Path.Combine(outputDir, Path.ChangeExtension(relative, ".wav")); + return CloneJob(template, Path.GetFullPath(file), Path.GetFullPath(outputPath)); + }) + .ToArray(); + EnsureDistinctOutputs(jobs); + return new RenderPlan(isBatch: true, jobs); + } + + private static RenderJob CloneJob(RenderJob template, string inputPath, string outputPath) { + return new RenderJob { + InputPath = inputPath, + OutputPath = outputPath, + Singer = template.Singer, + Renderer = template.Renderer, + Phonemizer = template.Phonemizer, + Resampler = template.Resampler, + Wavtool = template.Wavtool, + }; + } + + private static bool IsProjectFile(string file) { + var ext = Path.GetExtension(file); + return ProjectFileExtensions.Any(projectExt => + string.Equals(projectExt, ext, StringComparison.OrdinalIgnoreCase)); + } + + private static void EnsureDistinctOutputs(IEnumerable jobs) { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var job in jobs) { + if (!seen.Add(job.OutputPath)) { + throw new CommandLineException( + $"Multiple input projects map to output path: {job.OutputPath}"); + } + } + } + + private static async Task RunRenderPlanAsync(RenderPlan plan, HeadlessOpenUtauHost host) { + if (!plan.IsBatch) { + var job = plan.Jobs[0]; + await HeadlessRenderer.RenderOneAsync(job, host); + Console.WriteLine($"Rendered to {job.OutputPath}"); + return 0; + } + + Console.WriteLine($"Rendering {plan.Jobs.Length} project(s)."); + var succeeded = 0; + var failed = 0; + for (var i = 0; i < plan.Jobs.Length; i++) { + var job = plan.Jobs[i]; + Console.WriteLine($"[{i + 1}/{plan.Jobs.Length}] {job.InputPath} -> {job.OutputPath}"); + try { + await HeadlessRenderer.RenderOneAsync(job, host); + succeeded++; + Console.WriteLine($"Rendered to {job.OutputPath}"); + } catch (Exception e) { + failed++; + Console.Error.WriteLine($"Failed to render {job.InputPath}: {e.Message}"); + Log.Error(e, "Batch render failed for {InputPath}.", job.InputPath); + } + } + Console.WriteLine($"Batch render complete: {succeeded} succeeded, {failed} failed."); + return failed == 0 ? 0 : 1; + } + + private static string ParseOnnxRunner(string value) { + var runner = OpenUtau.Core.Onnx.getRunnerOptions() + .FirstOrDefault(option => string.Equals(option, value, StringComparison.OrdinalIgnoreCase)); + if (runner == null) { + throw new CommandLineException( + $"Invalid value for --onnx-runner: {value}. Expected one of: {string.Join(", ", OpenUtau.Core.Onnx.getRunnerOptions())}"); + } + return runner; + } + + private static int ParsePositiveInt(string name, string value) { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var result) || + result <= 0) { + throw new CommandLineException($"Invalid value for {name}: {value}. Expected a positive integer."); + } + return result; + } + + private static int ParseNonNegativeInt(string name, string value) { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var result) || + result < 0) { + throw new CommandLineException($"Invalid value for {name}: {value}. Expected a non-negative integer."); + } + return result; + } + + private static double ParseNonNegativeDouble(string name, string value) { + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) || + result < 0) { + throw new CommandLineException($"Invalid value for {name}: {value}. Expected a non-negative number."); + } + return result; + } + + private static bool ParseBool(string name, string value) { + if (bool.TryParse(value, out var result)) { + return result; + } + if (value == "1") { + return true; + } + if (value == "0") { + return false; + } + throw new CommandLineException($"Invalid value for {name}: {value}. Expected true or false."); + } + + private static (string name, string value, bool consumedNext) ReadOption(string[] args, int index) { + var arg = args[index]; + if (!arg.StartsWith("-")) { + throw new CommandLineException($"Unexpected argument: {arg}"); + } + var equals = arg.IndexOf('='); + if (equals > 0) { + var name = arg.Substring(0, equals); + var value = arg.Substring(equals + 1); + if (string.IsNullOrEmpty(value)) { + throw new CommandLineException($"Missing value for option: {name}"); + } + return (name, value, false); + } + if (index + 1 >= args.Length || args[index + 1].StartsWith("-")) { + throw new CommandLineException($"Missing value for option: {arg}"); + } + return (arg, args[index + 1], true); + } + + private static bool IsHelp(string arg) { + return string.Equals(arg, "help", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase); + } + + private static void PrintUsage(System.IO.TextWriter writer, string executableName) { + writer.WriteLine("Usage:"); + writer.WriteLine($" {executableName} render --input --output [options]"); + writer.WriteLine(); + writer.WriteLine("If input is a directory, project files directly inside it are rendered"); + writer.WriteLine("serially to matching .wav files in the output directory."); + writer.WriteLine(); + writer.WriteLine("Options:"); + writer.WriteLine(" --singer "); + writer.WriteLine(" --renderer "); + writer.WriteLine(" --phonemizer "); + writer.WriteLine(" --resampler "); + writer.WriteLine(" --wavtool "); + writer.WriteLine(" --singers-path "); + writer.WriteLine(" --onnx-runner "); + writer.WriteLine(" --onnx-gpu "); + writer.WriteLine(" --diffsinger-depth "); + writer.WriteLine(" --diffsinger-steps "); + writer.WriteLine(" --diffsinger-variance-steps "); + writer.WriteLine(" --diffsinger-pitch-steps "); + writer.WriteLine(" --diffsinger-tensor-cache "); + } + + internal sealed class CommandLineException : Exception { + public CommandLineException(string message) : base(message) { + } + } + + internal sealed class RenderPlan { + public RenderPlan(bool isBatch, RenderJob[] jobs) { + IsBatch = isBatch; + Jobs = jobs; + } + + public bool IsBatch { get; } + public RenderJob[] Jobs { get; } + } + } +} diff --git a/OpenUtau.Core/Headless/HeadlessRenderException.cs b/OpenUtau.Core/Headless/HeadlessRenderException.cs new file mode 100644 index 000000000..fcba762c9 --- /dev/null +++ b/OpenUtau.Core/Headless/HeadlessRenderException.cs @@ -0,0 +1,11 @@ +using System; + +namespace OpenUtau.Core.Headless { + public class HeadlessRenderException : Exception { + public HeadlessRenderException(string message) : base(message) { + } + + public HeadlessRenderException(string message, Exception innerException) : base(message, innerException) { + } + } +} diff --git a/OpenUtau.Core/Headless/HeadlessRenderer.cs b/OpenUtau.Core/Headless/HeadlessRenderer.cs new file mode 100644 index 000000000..378e02d7a --- /dev/null +++ b/OpenUtau.Core/Headless/HeadlessRenderer.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Api; +using OpenUtau.Classic; +using OpenUtau.Core.Format; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; + +namespace OpenUtau.Core.Headless { + public static class HeadlessRenderer { + private static readonly string[] KnownRenderers = new[] { + Renderers.CLASSIC, + Renderers.WORLDLINE_R, + Renderers.WORLDLINE_R2, + Renderers.ENUNU, + Renderers.VOGEN, + Renderers.DIFFSINGER, + Renderers.VOICEVOX, + }; + + public static async Task RenderOneAsync( + RenderJob job, + HeadlessOpenUtauHost host, + CancellationToken cancellationToken = default) { + if (job == null) { + throw new ArgumentNullException(nameof(job)); + } + var inputPath = RequireInputPath(job.InputPath); + var outputPath = RequireOutputPath(job.OutputPath); + EnsureOutputWritable(outputPath); + + var project = Formats.ReadProject(new[] { inputPath }); + if (project == null) { + throw new HeadlessRenderException($"Failed to load project: {inputPath}"); + } + DocManager.Inst.ExecuteCmd(new LoadProjectNotification(project)); + host.ClearErrors(); + + ApplyOverrides(project, job); + project.ValidateFull(); + await DocManager.Inst.PhonemizerRunner.WaitForIdleAsync(); + cancellationToken.ThrowIfCancellationRequested(); + EnsureProjectReady(project); + + host.ClearErrors(); + await PlaybackManager.Inst.RenderMixdown(project, outputPath); + var renderErrors = host.TakeErrors(); + if (renderErrors.Length > 0) { + throw new HeadlessRenderException(string.Join(Environment.NewLine, renderErrors)); + } + if (!File.Exists(outputPath)) { + throw new HeadlessRenderException($"Render failed; output was not written: {outputPath}"); + } + if (new FileInfo(outputPath).Length <= 46) { + throw new HeadlessRenderException($"Render failed; output contains no audio data: {outputPath}"); + } + } + + private static void ApplyOverrides(UProject project, RenderJob job) { + var singer = string.IsNullOrWhiteSpace(job.Singer) + ? null + : ResolveSinger(job.Singer); + var phonemizerFactory = string.IsNullOrWhiteSpace(job.Phonemizer) + ? null + : ResolvePhonemizer(job.Phonemizer); + var renderer = string.IsNullOrWhiteSpace(job.Renderer) + ? null + : ResolveRenderer(job.Renderer); + var resampler = string.IsNullOrWhiteSpace(job.Resampler) + ? null + : ResolveResampler(job.Resampler); + var wavtool = string.IsNullOrWhiteSpace(job.Wavtool) + ? null + : ResolveWavtool(job.Wavtool); + + foreach (var track in project.tracks) { + if (track.RendererSettings == null) { + track.RendererSettings = new URenderSettings(); + } + if (singer != null) { + track.Singer = singer; + } + if (phonemizerFactory != null) { + track.Phonemizer = phonemizerFactory.Create(); + } + if (renderer != null) { + track.RendererSettings.renderer = renderer; + track.RendererSettings.Renderer = null; + } + if (resampler != null) { + track.RendererSettings.resampler = resampler; + track.RendererSettings.Resampler = null; + } + if (wavtool != null) { + track.RendererSettings.wavtool = wavtool; + track.RendererSettings.Wavtool = null; + } + } + } + + private static string RequireInputPath(string inputPath) { + if (string.IsNullOrWhiteSpace(inputPath)) { + throw new HeadlessRenderException("Missing required input path."); + } + var fullPath = Path.GetFullPath(inputPath); + if (!File.Exists(fullPath)) { + throw new HeadlessRenderException($"Input project not found: {fullPath}"); + } + return fullPath; + } + + private static string RequireOutputPath(string outputPath) { + if (string.IsNullOrWhiteSpace(outputPath)) { + throw new HeadlessRenderException("Missing required output path."); + } + return Path.GetFullPath(outputPath); + } + + private static void EnsureOutputWritable(string outputPath) { + try { + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) { + Directory.CreateDirectory(dir); + } + var exists = File.Exists(outputPath); + using (File.Open( + outputPath, + exists ? FileMode.Open : FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.ReadWrite)) { + } + if (!exists) { + File.Delete(outputPath); + } + } catch (Exception e) { + throw new HeadlessRenderException($"Output is not writable: {outputPath}", e); + } + } + + internal static void EnsureProjectReady(UProject project) { + var voiceTrackNos = project.parts + .OfType() + .Select(part => part.trackNo) + .Distinct() + .ToHashSet(); + foreach (var trackNo in voiceTrackNos) { + var track = project.tracks[trackNo]; + if (track.Singer == null || !track.Singer.Found) { + throw new HeadlessRenderException( + $"Singer not found for track {trackNo + 1}: {track.singer ?? track.Singer?.Name ?? "(none)"}"); + } + if (track.RendererSettings.Renderer == null) { + throw new HeadlessRenderException( + $"Renderer not found for track {trackNo + 1}: {track.RendererSettings.renderer ?? "(none)"}"); + } + if (track.RendererSettings.Renderer.SingerType != track.Singer.SingerType) { + throw new HeadlessRenderException( + $"Renderer {track.RendererSettings.Renderer} is not supported for singer {track.Singer.Name}."); + } + } + var staleParts = project.parts + .OfType() + .Where(part => !part.PhonemesUpToDate) + .ToArray(); + if (staleParts.Length > 0) { + throw new HeadlessRenderException("Phonemization did not complete for all voice parts."); + } + var silentParts = project.parts + .OfType() + .Where(part => HasRenderableNotes(part) && part.renderPhrases.Count == 0) + .ToArray(); + if (silentParts.Length > 0) { + var names = string.Join(", ", silentParts.Select(part => part.DisplayName)); + throw new HeadlessRenderException( + $"No render phrases were generated for voice part(s): {names}. Check singer, phonemizer, and aliases."); + } + } + + private static bool HasRenderableNotes(UVoicePart part) { + return part.notes.Any(note => + !string.IsNullOrWhiteSpace(note.lyric) && + !string.Equals(note.lyric, "R", StringComparison.OrdinalIgnoreCase)); + } + + private static USinger ResolveSinger(string value) { + var singerValue = value.Replace("%VOICE%", ""); + var singer = SingerManager.Inst.GetSinger(singerValue); + if (singer != null) { + return singer; + } + + var candidates = SingerManager.Inst.Singers.Values.Distinct().ToArray(); + singer = candidates.FirstOrDefault(s => MatchesSinger(s, singerValue)); + if (singer != null) { + return singer; + } + + if (Directory.Exists(singerValue)) { + var fullPath = Path.GetFullPath(singerValue).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + singer = candidates.FirstOrDefault(s => + SamePath(s.Location, fullPath) || + SamePath(s.BasePath, fullPath)); + if (singer != null) { + return singer; + } + } + throw new HeadlessRenderException($"Singer not found: {value}"); + } + + private static bool MatchesSinger(USinger singer, string value) { + return EqualsIgnoreCase(singer.Id, value) || + EqualsIgnoreCase(singer.Name, value) || + EqualsIgnoreCase(singer.LocalizedName, value) || + (singer.LocalizedNames?.Values.Any(name => EqualsIgnoreCase(name, value)) ?? false); + } + + internal static PhonemizerFactory ResolvePhonemizer(string value) { + var factory = PhonemizerFactory.Get(value); + if (factory != null) { + return factory; + } + factory = PhonemizerFactory.GetAll().FirstOrDefault(f => + EqualsIgnoreCase(f.name, value) || + EqualsIgnoreCase(f.tag, value) || + EqualsIgnoreCase(f.type.Name, value) || + EqualsIgnoreCase(f.type.FullName, value)); + if (factory == null) { + throw new HeadlessRenderException($"Phonemizer not found: {value}"); + } + return factory; + } + + internal static string ResolveRenderer(string value) { + var renderer = KnownRenderers.FirstOrDefault(r => EqualsIgnoreCase(r, value)); + renderer ??= value; + if (Renderers.CreateRenderer(renderer) == null) { + throw new HeadlessRenderException($"Renderer not found: {value}"); + } + return renderer; + } + + private static string ResolveResampler(string value) { + var resampler = ToolsManager.Inst.Resamplers.FirstOrDefault(r => EqualsIgnoreCase(r.ToString(), value)); + if (resampler == null) { + throw new HeadlessRenderException($"Resampler not found: {value}"); + } + return resampler.ToString(); + } + + private static string ResolveWavtool(string value) { + var wavtool = ToolsManager.Inst.Wavtools.FirstOrDefault(w => EqualsIgnoreCase(w.ToString(), value)); + if (wavtool == null) { + throw new HeadlessRenderException($"Wavtool not found: {value}"); + } + return wavtool.ToString(); + } + + private static bool EqualsIgnoreCase(string? left, string? right) { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static bool SamePath(string? left, string right) { + if (string.IsNullOrWhiteSpace(left)) { + return false; + } + var fullPath = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return EqualsIgnoreCase(fullPath, right); + } + } +} diff --git a/OpenUtau.Core/Headless/HeadlessTaskScheduler.cs b/OpenUtau.Core/Headless/HeadlessTaskScheduler.cs new file mode 100644 index 000000000..711457400 --- /dev/null +++ b/OpenUtau.Core/Headless/HeadlessTaskScheduler.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenUtau.Core.Headless { + internal sealed class HeadlessTaskScheduler : TaskScheduler, IDisposable { + private readonly Thread ownerThread; + private readonly ConcurrentQueue tasks = new ConcurrentQueue(); + private readonly AutoResetEvent signal = new AutoResetEvent(false); + private bool disposed; + + public HeadlessTaskScheduler(Thread ownerThread) { + this.ownerThread = ownerThread; + } + + public bool IsOwnerThread => Thread.CurrentThread == ownerThread; + + public void Post(Action action) { + Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.None, this); + } + + public bool RunOne(TimeSpan timeout) { + if (!tasks.TryDequeue(out var task)) { + signal.WaitOne(timeout); + if (!tasks.TryDequeue(out task)) { + return false; + } + } + TryExecuteTask(task); + return true; + } + + public void RunAvailable() { + while (tasks.TryDequeue(out var task)) { + TryExecuteTask(task); + } + } + + protected override IEnumerable GetScheduledTasks() { + return tasks.ToArray(); + } + + protected override void QueueTask(Task task) { + if (disposed) { + throw new ObjectDisposedException(nameof(HeadlessTaskScheduler)); + } + tasks.Enqueue(task); + signal.Set(); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) { + if (!IsOwnerThread || taskWasPreviouslyQueued) { + return false; + } + return TryExecuteTask(task); + } + + public void Dispose() { + disposed = true; + signal.Dispose(); + } + } + + internal sealed class HeadlessSynchronizationContext : SynchronizationContext { + private readonly HeadlessTaskScheduler scheduler; + + public HeadlessSynchronizationContext(HeadlessTaskScheduler scheduler) { + this.scheduler = scheduler; + } + + public override void Post(SendOrPostCallback d, object? state) { + scheduler.Post(() => d(state)); + } + + public override void Send(SendOrPostCallback d, object? state) { + if (scheduler.IsOwnerThread) { + d(state); + return; + } + using var done = new ManualResetEventSlim(false); + Exception? exception = null; + scheduler.Post(() => { + try { + d(state); + } catch (Exception e) { + exception = e; + } finally { + done.Set(); + } + }); + done.Wait(); + if (exception != null) { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + + public override SynchronizationContext CreateCopy() { + return new HeadlessSynchronizationContext(scheduler); + } + } +} diff --git a/OpenUtau.Core/Headless/RenderJob.cs b/OpenUtau.Core/Headless/RenderJob.cs new file mode 100644 index 000000000..5ff0dad0b --- /dev/null +++ b/OpenUtau.Core/Headless/RenderJob.cs @@ -0,0 +1,22 @@ +namespace OpenUtau.Core.Headless { + public class RenderJob { + public string InputPath { get; set; } = string.Empty; + public string OutputPath { get; set; } = string.Empty; + public string? Singer { get; set; } + public string? Renderer { get; set; } + public string? Phonemizer { get; set; } + public string? Resampler { get; set; } + public string? Wavtool { get; set; } + } + + public class HeadlessOpenUtauOptions { + public string? SingersPath { get; set; } + public string? OnnxRunner { get; set; } + public int? OnnxGpu { get; set; } + public double? DiffSingerDepth { get; set; } + public int? DiffSingerSteps { get; set; } + public int? DiffSingerVarianceSteps { get; set; } + public int? DiffSingerPitchSteps { get; set; } + public bool? DiffSingerTensorCache { get; set; } + } +} diff --git a/OpenUtau.Core/Properties/AssemblyInfo.cs b/OpenUtau.Core/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..aa105cb38 --- /dev/null +++ b/OpenUtau.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenUtau.Test")] diff --git a/OpenUtau.Test/Core/Headless/HeadlessRenderCommandTest.cs b/OpenUtau.Test/Core/Headless/HeadlessRenderCommandTest.cs new file mode 100644 index 000000000..69e941fe6 --- /dev/null +++ b/OpenUtau.Test/Core/Headless/HeadlessRenderCommandTest.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Api; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; +using OpenUtau.Plugin.Builtin; +using Xunit; + +namespace OpenUtau.Core.Headless { + public class HeadlessRenderCommandTest { + [Theory] + [InlineData("render", true)] + [InlineData("RENDER", true)] + [InlineData("open", false)] + public void IsCommandDetectsRenderCommand(string command, bool expected) { + Assert.Equal(expected, HeadlessRenderCommand.IsCommand(new[] { command })); + } + + [Fact] + public void IsCommandReturnsFalseForEmptyArgs() { + Assert.False(HeadlessRenderCommand.IsCommand(Array.Empty())); + } + + [Fact] + public void ParseRenderArgsParsesJobAndOptions() { + var (job, options) = HeadlessRenderCommand.ParseRenderArgs(new[] { + "-i", "song.ust", + "--output=out.wav", + "--singer", "Singer", + "--renderer", "WORLDLINE-R", + "--phonemizer", "JA VCV", + "--resampler", "resampler.exe", + "--wavtool", "wavtool.exe", + "--singers-path", "Singers", + "--onnx-runner", "cpu", + "--onnx-gpu", "1", + "--diffsinger-depth", "0.7", + "--diffsinger-steps", "5", + "--diffsinger-variance-steps", "6", + "--diffsinger-pitch-steps", "7", + "--diffsinger-tensor-cache", "false", + }); + + Assert.Equal("song.ust", job.InputPath); + Assert.Equal("out.wav", job.OutputPath); + Assert.Equal("Singer", job.Singer); + Assert.Equal("WORLDLINE-R", job.Renderer); + Assert.Equal("JA VCV", job.Phonemizer); + Assert.Equal("resampler.exe", job.Resampler); + Assert.Equal("wavtool.exe", job.Wavtool); + Assert.Equal("Singers", options.SingersPath); + Assert.Equal("CPU", options.OnnxRunner); + Assert.Equal(1, options.OnnxGpu); + Assert.Equal(0.7, options.DiffSingerDepth); + Assert.Equal(5, options.DiffSingerSteps); + Assert.Equal(6, options.DiffSingerVarianceSteps); + Assert.Equal(7, options.DiffSingerPitchSteps); + Assert.False(options.DiffSingerTensorCache); + } + + public static IEnumerable InvalidArguments => new[] { + new object[] { new[] { "--output", "out.wav" }, "Missing required option: --input" }, + new object[] { new[] { "--input", "song.ust" }, "Missing required option: --output" }, + new object[] { new[] { "--unknown", "value" }, "Unknown option: --unknown" }, + new object[] { new[] { "song.ust" }, "Unexpected argument: song.ust" }, + new object[] { new[] { "--input", "--output", "out.wav" }, "Missing value for option: --input" }, + new object[] { new[] { "--input", "song.ust", "--output", "out.wav", "--onnx-runner", "missing" }, "Invalid value for --onnx-runner" }, + new object[] { new[] { "--input", "song.ust", "--output", "out.wav", "--onnx-gpu", "-1" }, "Missing value for option: --onnx-gpu" }, + new object[] { new[] { "--input", "song.ust", "--output", "out.wav", "--diffsinger-steps", "0" }, "Invalid value for --diffsinger-steps" }, + new object[] { new[] { "--input", "song.ust", "--output", "out.wav", "--diffsinger-depth", "-0.1" }, "Missing value for option: --diffsinger-depth" }, + new object[] { new[] { "--input", "song.ust", "--output", "out.wav", "--diffsinger-tensor-cache", "maybe" }, "Invalid value for --diffsinger-tensor-cache" }, + }; + + [Theory] + [MemberData(nameof(InvalidArguments))] + public void ParseRenderArgsRejectsInvalidArguments(string[] args, string expectedMessage) { + var ex = Assert.Throws( + () => HeadlessRenderCommand.ParseRenderArgs(args)); + Assert.Contains(expectedMessage, ex.Message); + } + + [Fact] + public void ExpandRenderJobsCreatesSingleFilePlan() { + WithTempDirectory(dir => { + var input = Path.Join(dir, "song.ust"); + var output = Path.Join(dir, "song.wav"); + File.WriteAllText(input, string.Empty); + + var plan = HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = input, + OutputPath = output, + Singer = "Singer", + Renderer = "WORLDLINE-R", + Phonemizer = "JA VCV", + Resampler = "resampler.exe", + Wavtool = "wavtool.exe", + }); + + Assert.False(plan.IsBatch); + var job = Assert.Single(plan.Jobs); + Assert.Equal(Path.GetFullPath(input), job.InputPath); + Assert.Equal(Path.GetFullPath(output), job.OutputPath); + Assert.Equal("Singer", job.Singer); + Assert.Equal("WORLDLINE-R", job.Renderer); + Assert.Equal("JA VCV", job.Phonemizer); + Assert.Equal("resampler.exe", job.Resampler); + Assert.Equal("wavtool.exe", job.Wavtool); + }); + } + + [Fact] + public void ExpandRenderJobsCreatesBatchPlanForKnownProjectFiles() { + WithTempDirectory(dir => { + var inputDir = Path.Join(dir, "input"); + var outputDir = Path.Join(dir, "output"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(Path.Join(inputDir, "nested")); + File.WriteAllText(Path.Join(inputDir, "b.ust"), string.Empty); + File.WriteAllText(Path.Join(inputDir, "A.USTX"), string.Empty); + File.WriteAllText(Path.Join(inputDir, "ignore.txt"), string.Empty); + File.WriteAllText(Path.Join(inputDir, "nested", "nested.ust"), string.Empty); + + var plan = HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = inputDir, + OutputPath = outputDir, + Singer = "Singer", + }); + + Assert.True(plan.IsBatch); + Assert.Equal(new[] { "A.USTX", "b.ust" }, plan.Jobs.Select(job => Path.GetFileName(job.InputPath))); + Assert.Equal(new[] { "A.wav", "b.wav" }, plan.Jobs.Select(job => Path.GetFileName(job.OutputPath))); + Assert.All(plan.Jobs, job => Assert.Equal("Singer", job.Singer)); + }); + } + + [Fact] + public void ExpandRenderJobsRejectsMissingInput() { + WithTempDirectory(dir => { + var ex = Assert.Throws( + () => HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = Path.Join(dir, "missing"), + OutputPath = Path.Join(dir, "out.wav"), + })); + Assert.Contains("Input project or directory not found", ex.Message); + }); + } + + [Fact] + public void ExpandRenderJobsRejectsEmptyBatchInputDirectory() { + WithTempDirectory(dir => { + var inputDir = Path.Join(dir, "input"); + Directory.CreateDirectory(inputDir); + + var ex = Assert.Throws( + () => HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = inputDir, + OutputPath = Path.Join(dir, "output"), + })); + Assert.Contains("No project files found", ex.Message); + }); + } + + [Fact] + public void ExpandRenderJobsRejectsOutputFileForBatch() { + WithTempDirectory(dir => { + var inputDir = Path.Join(dir, "input"); + var outputFile = Path.Join(dir, "output.wav"); + Directory.CreateDirectory(inputDir); + File.WriteAllText(Path.Join(inputDir, "song.ust"), string.Empty); + File.WriteAllText(outputFile, string.Empty); + + var ex = Assert.Throws( + () => HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = inputDir, + OutputPath = outputFile, + })); + Assert.Contains("Batch output must be a directory", ex.Message); + }); + } + + [Fact] + public void ExpandRenderJobsRejectsDuplicateBatchOutputs() { + WithTempDirectory(dir => { + var inputDir = Path.Join(dir, "input"); + Directory.CreateDirectory(inputDir); + File.WriteAllText(Path.Join(inputDir, "Song.ust"), string.Empty); + File.WriteAllText(Path.Join(inputDir, "song.ustx"), string.Empty); + + var ex = Assert.Throws( + () => HeadlessRenderCommand.ExpandRenderJobs(new RenderJob { + InputPath = inputDir, + OutputPath = Path.Join(dir, "output"), + })); + Assert.Contains("Multiple input projects map to output path", ex.Message); + }); + } + + [Theory] + [InlineData("OpenUtau.Plugin.Builtin.JapaneseVCVPhonemizer")] + [InlineData("Japanese VCV Phonemizer (legacy)")] + [InlineData("JA VCV")] + [InlineData("ja vcv")] + [InlineData("JapaneseVCVPhonemizer")] + public void ResolvePhonemizerMatchesRegisteredFactory(string value) { + RegisterJapaneseVcvPhonemizer(); + + var factory = HeadlessRenderer.ResolvePhonemizer(value); + + Assert.Equal(typeof(JapaneseVCVPhonemizer), factory.type); + } + + [Fact] + public void ResolvePhonemizerRejectsUnknownValue() { + RegisterJapaneseVcvPhonemizer(); + + var ex = Assert.Throws( + () => HeadlessRenderer.ResolvePhonemizer("Missing Phonemizer")); + Assert.Contains("Phonemizer not found", ex.Message); + } + + [Theory] + [InlineData("worldline-r", "WORLDLINE-R")] + [InlineData("DIFFSINGER", "DIFFSINGER")] + public void ResolveRendererAcceptsSupportedRendererNames(string value, string expected) { + Assert.Equal(expected, HeadlessRenderer.ResolveRenderer(value)); + } + + [Fact] + public void ResolveRendererRejectsUnknownValue() { + var ex = Assert.Throws( + () => HeadlessRenderer.ResolveRenderer("Missing Renderer")); + Assert.Contains("Renderer not found", ex.Message); + } + + [Fact] + public void EnsureProjectReadyRejectsMissingSinger() { + var project = CreateProject(USinger.CreateMissing("missing-singer"), new TestRenderer(USingerType.Classic, "WORLDLINE-R")); + + var ex = Assert.Throws( + () => HeadlessRenderer.EnsureProjectReady(project)); + + Assert.Contains("Singer not found for track 1", ex.Message); + } + + [Fact] + public void EnsureProjectReadyRejectsMissingRenderer() { + var project = CreateProject(new TestSinger(USingerType.Classic, "Classic Singer"), null); + project.tracks[0].RendererSettings.renderer = "Missing Renderer"; + + var ex = Assert.Throws( + () => HeadlessRenderer.EnsureProjectReady(project)); + + Assert.Contains("Renderer not found for track 1: Missing Renderer", ex.Message); + } + + [Fact] + public void EnsureProjectReadyRejectsRendererSingerMismatch() { + var project = CreateProject( + new TestSinger(USingerType.Classic, "Classic Singer"), + new TestRenderer(USingerType.DiffSinger, "DIFFSINGER")); + + var ex = Assert.Throws( + () => HeadlessRenderer.EnsureProjectReady(project)); + + Assert.Contains("Renderer DIFFSINGER is not supported for singer Classic Singer.", ex.Message); + } + + [Fact] + public void EnsureProjectReadyRejectsRenderablePartWithoutRenderPhrases() { + var project = CreateProject( + new TestSinger(USingerType.Classic, "Classic Singer"), + new TestRenderer(USingerType.Classic, "WORLDLINE-R")); + var part = (UVoicePart)project.parts[0]; + var note = UNote.Create(); + note.lyric = "a"; + note.duration = 120; + part.notes.Add(note); + + var ex = Assert.Throws( + () => HeadlessRenderer.EnsureProjectReady(project)); + + Assert.Contains("No render phrases were generated", ex.Message); + } + + private static UProject CreateProject(USinger singer, IRenderer renderer) { + var project = new UProject(); + project.tracks[0].Singer = singer; + project.tracks[0].RendererSettings = new URenderSettings { + renderer = renderer?.ToString(), + Renderer = renderer, + }; + project.parts.Add(new UVoicePart { + trackNo = 0, + name = "Part", + }); + return project; + } + + private static void RegisterJapaneseVcvPhonemizer() { + PhonemizerFactory.Get(typeof(JapaneseVCVPhonemizer)); + PhonemizerFactory.BuildList(); + } + + private static void WithTempDirectory(Action action) { + var dir = Path.Join(Path.GetTempPath(), "OpenUtauTest", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try { + action(dir); + } finally { + if (Directory.Exists(dir)) { + Directory.Delete(dir, recursive: true); + } + } + } + + private sealed class TestSinger : USinger { + private readonly USingerType singerType; + private readonly string name; + + public TestSinger(USingerType singerType, string name) { + this.singerType = singerType; + this.name = name; + found = true; + loaded = true; + } + + public override string Id => name; + public override string Name => name; + public override USingerType SingerType => singerType; + } + + private sealed class TestRenderer : IRenderer { + private readonly string name; + + public TestRenderer(USingerType singerType, string name) { + SingerType = singerType; + this.name = name; + } + + public USingerType SingerType { get; } + public bool SupportsRenderPitch => false; + public bool SupportsExpression(UExpressionDescriptor descriptor) => false; + public RenderResult Layout(RenderPhrase phrase) => new RenderResult { samples = Array.Empty() }; + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender = false) { + return Task.FromResult(Layout(phrase)); + } + public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) { + return new RenderPitchResult { + ticks = Array.Empty(), + tones = Array.Empty(), + }; + } + public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings) { + return Array.Empty(); + } + public override string ToString() => name; + } + } +} diff --git a/OpenUtau/Program.cs b/OpenUtau/Program.cs index dab8b1029..202d65cf6 100644 --- a/OpenUtau/Program.cs +++ b/OpenUtau/Program.cs @@ -5,12 +5,14 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using System.Threading; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.ReactiveUI; using OpenUtau.App.ViewModels; using OpenUtau.Core; +using OpenUtau.Core.Headless; using Serilog; namespace OpenUtau.App { @@ -20,8 +22,21 @@ public class Program { // yet and stuff might break. [STAThread] public static void Main(string[] args) { + var isHeadlessCommand = HeadlessRenderCommand.IsCommand(args); + if (isHeadlessCommand) { + AttachToParentConsole(); + } Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); InitLogging(); + if (isHeadlessCommand) { + try { + Environment.ExitCode = RunHeadlessCommand(args); + } finally { + CleanupNetMq(); + Log.CloseAndFlush(); + } + return; + } string processName = Process.GetCurrentProcess().ProcessName; if (processName != "dotnet") { var exists = Process.GetProcessesByName(processName).Count() > 1; @@ -43,10 +58,7 @@ public static void Main(string[] args) { Run(args); Log.Information($"Exiting."); } finally { - if (!OS.IsMacOS()) { - NetMQ.NetMQConfig.Cleanup(/*block=*/false); - // Cleanup() hangs on macOS https://github.com/zeromq/netmq/issues/1018 - } + CleanupNetMq(); } Log.Information($"Exited."); } @@ -85,6 +97,37 @@ public static void Run(string[] args) .StartWithClassicDesktopLifetime( args, ShutdownMode.OnMainWindowClose); + private static int RunHeadlessCommand(string[] args) { + var exitCode = 1; + Exception? exception = null; + var thread = new Thread(() => { + try { + exitCode = HeadlessRenderCommand.Run(args); + } catch (Exception e) { + exception = e; + } + }); + try { + thread.SetApartmentState(ApartmentState.MTA); + } catch { + } + thread.Start(); + thread.Join(); + if (exception != null) { + Console.Error.WriteLine(exception.Message); + Log.Error(exception, "Headless command failed unexpectedly."); + return 1; + } + return exitCode; + } + + private static void CleanupNetMq() { + if (!OS.IsMacOS()) { + NetMQ.NetMQConfig.Cleanup(/*block=*/false); + // Cleanup() hangs on macOS https://github.com/zeromq/netmq/issues/1018 + } + } + public static void InitLogging() { Log.Logger = new LoggerConfiguration() .MinimumLevel.Verbose() @@ -101,5 +144,31 @@ public static void InitLogging() { }); Log.Information("Logging initialized."); } + + private static void AttachToParentConsole() { + if (!OS.IsWindows()) { + return; + } + AttachConsole(AttachParentProcess); + ResetConsoleStreams(); + } + + private static void ResetConsoleStreams() { + try { + var output = Console.OpenStandardOutput(); + Console.SetOut(new StreamWriter(output) { AutoFlush = true }); + } catch { + } + try { + var error = Console.OpenStandardError(); + Console.SetError(new StreamWriter(error) { AutoFlush = true }); + } catch { + } + } + + private const uint AttachParentProcess = 0xFFFFFFFF; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(uint dwProcessId); } }