From 27a6178981532c335034223d6df88828f4b22ac9 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Mon, 31 Aug 2026 05:06:58 -0400 Subject: [PATCH 01/11] Basic external renderer API --- .../Example.ExternalRenderer.csproj | 14 + .../ExternalRenderer/ExampleRendererPlugin.cs | 134 ++++ Examples/ExternalRenderer/README.md | 76 ++ OpenUtau.Core/Classic/ClassicRenderer.cs | 19 +- OpenUtau.Core/Classic/ExeResampler.cs | 2 +- OpenUtau.Core/Classic/ResamplerItem.cs | 4 +- OpenUtau.Core/Classic/ResamplerManifest.cs | 39 + OpenUtau.Core/Classic/ToolsManager.cs | 2 + OpenUtau.Core/Commands/TrackCommands.cs | 10 +- .../Render/ExternalRendererPlugin.cs | 169 +++++ .../Render/ExternalRendererRegistry.cs | 668 ++++++++++++++++++ OpenUtau.Core/Render/RenderPhrase.cs | 2 +- OpenUtau.Core/Render/Renderers.cs | 44 +- OpenUtau.Core/Ustx/UTrack.cs | 36 +- .../Render/ExternalRendererRegistryTest.cs | 521 ++++++++++++++ OpenUtau/Strings/Strings.axaml | 2 + OpenUtau/ViewModels/TrackHeaderViewModel.cs | 19 +- OpenUtau/Views/SingersDialog.axaml | 2 + OpenUtau/Views/SingersDialog.axaml.cs | 45 ++ 19 files changed, 1776 insertions(+), 32 deletions(-) create mode 100644 Examples/ExternalRenderer/Example.ExternalRenderer.csproj create mode 100644 Examples/ExternalRenderer/ExampleRendererPlugin.cs create mode 100644 Examples/ExternalRenderer/README.md create mode 100644 OpenUtau.Core/Render/ExternalRendererPlugin.cs create mode 100644 OpenUtau.Core/Render/ExternalRendererRegistry.cs create mode 100644 OpenUtau.Test/Render/ExternalRendererRegistryTest.cs diff --git a/Examples/ExternalRenderer/Example.ExternalRenderer.csproj b/Examples/ExternalRenderer/Example.ExternalRenderer.csproj new file mode 100644 index 000000000..4cc9fb3c8 --- /dev/null +++ b/Examples/ExternalRenderer/Example.ExternalRenderer.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + ..\..\OpenUtau.Core\OpenUtau.Core.csproj + + + + + + + + diff --git a/Examples/ExternalRenderer/ExampleRendererPlugin.cs b/Examples/ExternalRenderer/ExampleRendererPlugin.cs new file mode 100644 index 000000000..7d6fc3b5c --- /dev/null +++ b/Examples/ExternalRenderer/ExampleRendererPlugin.cs @@ -0,0 +1,134 @@ +using System.Text; +using OpenUtau.Core; +using OpenUtau.Classic; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; + +namespace OpenUtau.Examples.ExternalRenderer; + +[ExternalRenderer("org.openutau.example.external-renderer", "Example External Renderer")] +public sealed class ExampleRendererPlugin : IOpenUtauRendererPlugin { + public int ApiVersion => ExternalRendererRegistry.ApiVersion; + + public RendererPluginMetadata Metadata => new() { + Capabilities = new RendererCapabilitiesManifest { + cancellation = true, + parallelism = 1, + }, + Expressions = new Dictionary { + ["tone"] = new("Tone", "tone", -100, 100, 0) { + type = UExpressionType.Curve, + }, + }, + AnalysisFormats = new Dictionary { + ["example"] = new() { + name = "Example source analysis", + path = "{wav_dir}/{wav_stem}.example-analysis", + canGenerate = true, + }, + }, + }; + + public IRenderer CreateRenderer(RendererPluginContext context) => + new ExampleRenderer(context.RendererName, context.Logger); + + public IRendererAnalysisProvider CreateAnalysisProvider(RendererPluginContext context) => + new ExampleAnalysisProvider(context.Analysis); +} + +/// +/// A deliberately simple renderer which emits a quiet 220 Hz test tone for +/// every phrase. Replace this class with an engine-specific implementation. +/// +public sealed class ExampleRenderer : IRenderer { + readonly string name; + readonly Serilog.ILogger logger; + + public ExampleRenderer(string name, Serilog.ILogger logger) { + this.name = name; + this.logger = logger; + } + + public USingerType SingerType => USingerType.Classic; + public bool SupportsRenderPitch => false; + + public bool SupportsExpression(UExpressionDescriptor descriptor) => + string.Equals(descriptor.abbr, "tone", StringComparison.OrdinalIgnoreCase); + + public RenderResult Layout(RenderPhrase phrase) => new() { + leadingMs = phrase.leadingMs, + positionMs = phrase.positionMs, + estimatedLengthMs = phrase.durationMs + phrase.leadingMs, + }; + + public Task Render( + RenderPhrase phrase, + Progress progress, + int trackNo, + CancellationTokenSource cancellation, + bool isPreRender = false) { + cancellation.Token.ThrowIfCancellationRequested(); + var result = Layout(phrase); + var sampleCount = Math.Max(0, (int)(result.estimatedLengthMs / 1000 * 44100)); + result.samples = new float[sampleCount]; + var toneCurve = phrase.curves.FirstOrDefault(curve => + string.Equals(curve.Item1, "tone", StringComparison.OrdinalIgnoreCase))?.Item2; + for (var i = 0; i < sampleCount; i++) { + if ((i & 4095) == 0) cancellation.Token.ThrowIfCancellationRequested(); + var curveIndex = toneCurve == null || toneCurve.Length == 0 + ? 0 + : Math.Min(toneCurve.Length - 1, (int)((long)i * toneCurve.Length / sampleCount)); + var curveValue = toneCurve == null || toneCurve.Length == 0 ? 0 : toneCurve[curveIndex]; + var frequency = 220 * MathF.Pow(2, curveValue / 1200); + result.samples[i] = 0.1f * MathF.Sin(2 * MathF.PI * frequency * i / 44100); + } + logger.Debug("Rendered example phrase {PhraseHash:x16} for track {TrackNo}", phrase.hash, trackNo); + return Task.FromResult(result); + } + + public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => null!; + + public UExpressionDescriptor[] GetSuggestedExpressions( + USinger singer, + URenderSettings renderSettings) => Array.Empty(); + + public override string ToString() => name; +} + +/// +/// Demonstrates renderer-owned source analysis. Real engines would write their +/// spectral model or frequency map instead of this small descriptive sidecar. +/// +public sealed class ExampleAnalysisProvider : IRendererAnalysisProvider { + readonly RendererAnalysisService analysis; + + public ExampleAnalysisProvider(RendererAnalysisService analysis) { + this.analysis = analysis; + } + + public Task> GenerateAsync( + IReadOnlyList requests, + IProgress progress, + CancellationToken cancellation) { + var results = new List(requests.Count); + for (var i = 0; i < requests.Count; i++) { + var request = requests[i]; + cancellation.ThrowIfCancellationRequested(); + try { + File.WriteAllText(request.OutputFile, + $"source={Path.GetFileName(request.SourceFile)}\n", Encoding.UTF8); + results.Add(new(request, RendererAnalysisOutcome.Generated)); + } catch (Exception error) when (error is not OperationCanceledException) { + results.Add(new(request, RendererAnalysisOutcome.Failed, error.Message)); + } + progress.Report(i + 1); + } + return Task.FromResult>(results); + } + + public ValueTask ValidateAsync( + RendererAnalysisRequest request, CancellationToken cancellation) { + cancellation.ThrowIfCancellationRequested(); + return ValueTask.FromResult(analysis.GetBasicState(request.Format, request.SourceFile)); + } +} diff --git a/Examples/ExternalRenderer/README.md b/Examples/ExternalRenderer/README.md new file mode 100644 index 000000000..e295a7ec4 --- /dev/null +++ b/Examples/ExternalRenderer/README.md @@ -0,0 +1,76 @@ +# OpenUtau external renderer example + +This is a small but complete external renderer bridge. It demonstrates phrase +layout and rendering, a custom curve, cooperative cancellation, host logging, +renderer-owned source analysis, progress and per-file errors. It produces a +quiet test tone rather than useful singing. The directory is intentionally +self-contained so it can become its own repository. + +## Build + +Inside the OpenUtau repository, the project references `OpenUtau.Core` directly: + +```sh +dotnet build -c Release +``` + +In a standalone checkout, either provide a source checkout: + +```sh +dotnet build -c Release -p:OpenUtauCoreProject=/path/to/OpenUtau/OpenUtau.Core/OpenUtau.Core.csproj +``` + +or compile against `OpenUtau.Core.dll` from the OpenUtau release you target: + +```sh +dotnet build -c Release -p:OpenUtauCorePath=/path/to/OpenUtau.Core.dll +``` + +Copy this file into OpenUtau's `Resamplers` directory: + +- `bin/Release/net10.0/Example.ExternalRenderer.dll` + +Do not copy `OpenUtau.Core.dll`; the host supplies that shared assembly. Restart +OpenUtau, choose a classic singer, and select **Example External Renderer** from +the track's renderer menu. + +The `[ExternalRenderer]` attribute declares the renderer identity, so no YAML +sidecar is needed. Attribute-only discovery instantiates the entrypoint once to +read `ApiVersion` and `Metadata`; keep its constructor and metadata getter fast, +deterministic, and free of engine initialization. Use a YAML manifest when the +host must discover all metadata without executing plugin code. + +## Compatibility and lifecycle + +- API version 1 is a preview contract. A host only loads a plugin when its + `ApiVersion` exactly matches the host's supported version. +- Build against the oldest OpenUtau release you intend to support and test the + resulting DLL against every supported release. Do not distribute + `OpenUtau.Core.dll`; the host supplies its shared copy. +- OpenUtau creates fresh entrypoint, analysis-provider and renderer instances. + Any of them may implement `IDisposable`; dispose native handles and unmanaged + state there. Do not retain host objects in static fields. +- `Render` may run away from the UI thread. Observe the supplied cancellation + token and honor declared `parallelism`; do not access Avalonia UI objects. +- Return renderer-owned failures as analysis results when one source fails. + Throw cancellation and fatal initialization errors. OpenUtau records discovery + failures in its log and continues loading other renderers. +- `RendererAnalysisService` owns analysis paths and basic freshness checks. + `RendererCacheService` is for final phrase outputs, not intermediate wavtool + files. A renderer should keep temporary phrase state in memory. + +Metadata declared in C# is authoritative for an attribute-only plugin. For a +YAML-discovered plugin, YAML metadata is authoritative and the runtime renderer +must agree with the declared capabilities and expression definitions. + +## Packaging native engines + +A pure C# renderer only needs its bridge DLL and private managed dependencies. +For a native engine, ship or extract the correct `.dll`, `.so`, or `.dylib` for +each supported runtime, resolve it relative to `context.PluginDirectory`, and +release its handle from `Dispose`. Keep architecture-specific files in distinct +subdirectories to avoid filename collisions. + +The example analysis format is optional, so it appears in the singer dialog's +analysis-generation menu but is not required before rendering. Set `required` +only when rendering cannot proceed without a valid analysis file. diff --git a/OpenUtau.Core/Classic/ClassicRenderer.cs b/OpenUtau.Core/Classic/ClassicRenderer.cs index 407580e4b..54c15d3e3 100644 --- a/OpenUtau.Core/Classic/ClassicRenderer.cs +++ b/OpenUtau.Core/Classic/ClassicRenderer.cs @@ -14,6 +14,15 @@ namespace OpenUtau.Classic { public class ClassicRenderer : IRenderer { + readonly IResampler? resamplerOverride; + readonly string rendererName; + + public ClassicRenderer() : this(null, Renderers.CLASSIC) { } + + public ClassicRenderer(IResampler? resampler, string rendererName) { + resamplerOverride = resampler; + this.rendererName = rendererName; + } static readonly HashSet supportedExp = new HashSet(){ Ustx.DYN, Ustx.PITD, @@ -48,7 +57,7 @@ public RenderResult Layout(RenderPhrase phrase) { } public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { - if (phrase.wavtool == SharpWavtool.nameConvergence || phrase.wavtool == SharpWavtool.nameSimple) { + if (resamplerOverride != null || phrase.wavtool == SharpWavtool.nameConvergence || phrase.wavtool == SharpWavtool.nameSimple) { return RenderInternal(phrase, progress, trackNo, cancellation, isPreRender); } else { return RenderExternal(phrase, progress, trackNo, cancellation, isPreRender); @@ -58,7 +67,7 @@ public Task Render(RenderPhrase phrase, Progress progress, int tra public Task RenderInternal(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { var resamplerItems = new List(); foreach (var phone in phrase.phones) { - resamplerItems.Add(new ResamplerItem(phrase, phone)); + resamplerItems.Add(new ResamplerItem(phrase, phone, resamplerOverride)); } var task = Task.Run(() => { Parallel.ForEach(source: resamplerItems, parallelOptions: new ParallelOptions() { @@ -99,7 +108,7 @@ public Task RenderInternal(RenderPhrase phrase, Progress progress, public Task RenderExternal(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { var resamplerItems = new List(); foreach (var phone in phrase.phones) { - resamplerItems.Add(new ResamplerItem(phrase, phone)); + resamplerItems.Add(new ResamplerItem(phrase, phone, resamplerOverride)); } var task = Task.Run(() => { string progressInfo = $"Track {trackNo + 1} : {phrase.wavtool} \"{string.Join(" ", phrase.phones.Select(p => p.phoneme))}\""; @@ -142,13 +151,13 @@ public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) { } public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings) { - var manifest= renderSettings.Resampler.Manifest; + var manifest = resamplerOverride?.Manifest ?? renderSettings.Resampler?.Manifest; if (manifest == null) { return new UExpressionDescriptor[] { }; } return manifest.expressions.Values.ToArray(); } - public override string ToString() => Renderers.CLASSIC; + public override string ToString() => rendererName; } } diff --git a/OpenUtau.Core/Classic/ExeResampler.cs b/OpenUtau.Core/Classic/ExeResampler.cs index 14a2ce89c..8309f822b 100644 --- a/OpenUtau.Core/Classic/ExeResampler.cs +++ b/OpenUtau.Core/Classic/ExeResampler.cs @@ -11,7 +11,7 @@ using Serilog; namespace OpenUtau.Classic { - internal class ExeResampler : IResampler { + public class ExeResampler : IResampler { public string Name { get; private set; } public string FilePath { get; private set; } public bool isLegalPlugin => _isLegalPlugin; diff --git a/OpenUtau.Core/Classic/ResamplerItem.cs b/OpenUtau.Core/Classic/ResamplerItem.cs index 4c87b3bd4..314148a1b 100644 --- a/OpenUtau.Core/Classic/ResamplerItem.cs +++ b/OpenUtau.Core/Classic/ResamplerItem.cs @@ -38,11 +38,11 @@ public class ResamplerItem { public ulong hash; - public ResamplerItem(RenderPhrase phrase, RenderPhone phone) { + public ResamplerItem(RenderPhrase phrase, RenderPhone phone, IResampler? resamplerOverride = null) { this.phrase = phrase; this.phone = phone; - resampler = ToolsManager.Inst.GetResampler(phone.resampler); + resampler = resamplerOverride ?? ToolsManager.Inst.GetResampler(phone.resampler); inputFile = phone.oto.File; inputTemp = VoicebankFiles.Inst.GetSourceTempPath(phrase.singer.Id, phone.oto, ".wav"); tone = phone.tone; diff --git a/OpenUtau.Core/Classic/ResamplerManifest.cs b/OpenUtau.Core/Classic/ResamplerManifest.cs index 4e3d3a635..5d59a276b 100644 --- a/OpenUtau.Core/Classic/ResamplerManifest.cs +++ b/OpenUtau.Core/Classic/ResamplerManifest.cs @@ -9,6 +9,8 @@ namespace OpenUtau.Classic { public class ResamplerManifest { public Dictionary expressions = new Dictionary { }; public bool expressionFilter = false; + public RendererManifest renderer; + public AnalysisManifest analysis; public ResamplerManifest() { } @@ -16,6 +18,7 @@ public static ResamplerManifest Load(string path) { var manifest = Yaml.DefaultDeserializer.Deserialize( File.ReadAllText(path, encoding: Encoding.UTF8) ); + manifest.expressions ??= new Dictionary(); manifest.expressions = manifest.expressions .GroupBy(kvp => kvp.Key.ToLower()) .ToDictionary( @@ -25,4 +28,40 @@ public static ResamplerManifest Load(string path) { return manifest; } } + + public class RendererManifest { + public bool enabled = false; + public string id; + public string name; + public RendererBridgeManifest bridge; + public RendererCapabilitiesManifest capabilities; + } + + public class RendererBridgeManifest { + public string assembly; + public string type; + public int apiVersion = 1; + } + + public class RendererCapabilitiesManifest { + // These must match the corresponding runtime IRenderer properties. + public bool renderedPitch = false; + public bool realCurves = false; + // True means Render cooperatively observes its CancellationTokenSource. + public bool cancellation = false; + // Maximum simultaneous Render calls. Zero leaves scheduling to the host. + public int parallelism = 0; + } + + public class AnalysisManifest { + public Dictionary formats = new Dictionary(); + } + + public class AnalysisFormatManifest { + public string name; + public string path; + public bool required = false; + public bool canGenerate = false; + public bool shared = false; + } } diff --git a/OpenUtau.Core/Classic/ToolsManager.cs b/OpenUtau.Core/Classic/ToolsManager.cs index 5ffa07b19..b3926f259 100644 --- a/OpenUtau.Core/Classic/ToolsManager.cs +++ b/OpenUtau.Core/Classic/ToolsManager.cs @@ -4,6 +4,7 @@ using System.Linq; using OpenUtau.Core; using OpenUtau.Core.Util; +using OpenUtau.Core.Render; using Serilog; namespace OpenUtau.Classic { @@ -90,6 +91,7 @@ public void SearchResamplers() { foreach (var resampler in resamplers) { resamplersMap[resampler.ToString()] = resampler; } + ExternalRendererRegistry.Discover(basePath); } public void SearchWavtools() { diff --git a/OpenUtau.Core/Commands/TrackCommands.cs b/OpenUtau.Core/Commands/TrackCommands.cs index 7d2eb1718..fcd1ff551 100644 --- a/OpenUtau.Core/Commands/TrackCommands.cs +++ b/OpenUtau.Core/Commands/TrackCommands.cs @@ -154,12 +154,14 @@ public TrackChangeRenderSettingCommand(UProject project, UTrack track, URenderSe } public override string ToString() { return "Change render setting"; } public override void Execute() { - track.RendererSettings = newSettings.Clone(); - track.RendererSettings.Validate(track); + var settings = newSettings.Clone(); + settings.Validate(track, fallbackUnavailableRenderer: false); + track.RendererSettings = settings; } public override void Unexecute() { - track.RendererSettings = oldSettings.Clone(); - track.RendererSettings.Validate(track); + var settings = oldSettings.Clone(); + settings.Validate(track); + track.RendererSettings = settings; } } } diff --git a/OpenUtau.Core/Render/ExternalRendererPlugin.cs b/OpenUtau.Core/Render/ExternalRendererPlugin.cs new file mode 100644 index 000000000..1c41e99cc --- /dev/null +++ b/OpenUtau.Core/Render/ExternalRendererPlugin.cs @@ -0,0 +1,169 @@ +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.Ustx; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.Render { + public interface IExternalRendererIdentity { + string Id { get; } + string Name { get; } + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class ExternalRendererAttribute : Attribute { + public string Id { get; } + public string Name { get; } + public USingerType SingerType { get; } + + public ExternalRendererAttribute(string id, string name, USingerType singerType = USingerType.Classic) { + Id = id; + Name = name; + SingerType = singerType; + } + } + + public sealed class RendererPluginMetadata { + public RendererCapabilitiesManifest Capabilities { get; init; } = new RendererCapabilitiesManifest(); + public IReadOnlyDictionary AnalysisFormats { get; init; } + = new Dictionary(); + public IReadOnlyDictionary Expressions { get; init; } + = new Dictionary(); + } + + /// + /// Versioned entry point implemented by external renderer bridge assemblies. + /// + public interface IOpenUtauRendererPlugin { + int ApiVersion { get; } + RendererPluginMetadata Metadata => new RendererPluginMetadata(); + IRenderer CreateRenderer(RendererPluginContext context); + IRendererAnalysisProvider? CreateAnalysisProvider(RendererPluginContext context) => null; + } + + public enum RendererAnalysisState { + Valid, + Missing, + Stale, + Invalid, + } + + public sealed record RendererAnalysisRequest( + string Format, string SourceFile, string OutputFile, bool Overwrite); + + public enum RendererAnalysisOutcome { + Generated, + AlreadyValid, + Failed, + } + + public sealed record RendererAnalysisResult( + RendererAnalysisRequest Request, + RendererAnalysisOutcome Outcome, + string Message = null); + + /// Owns engine-specific validation and generation of reusable source + /// analysis. The host handles paths, fallback timestamp checks and orchestration. + public interface IRendererAnalysisProvider { + Task> GenerateAsync( + IReadOnlyList requests, + IProgress progress, + CancellationToken cancellation); + ValueTask ValidateAsync( + RendererAnalysisRequest request, + CancellationToken cancellation); + } + + public sealed class RendererPluginContext { + public int ApiVersion => ExternalRendererRegistry.ApiVersion; + public Version HostVersion => typeof(IRenderer).Assembly.GetName().Version ?? new Version(); + public string RendererId { get; } + public string RendererName { get; } + public string PluginDirectory { get; } + public string ManifestPath { get; } + public string CacheDirectory => PathManager.Inst.CachePath; + public ILogger Logger { get; } + public ResamplerManifest Manifest { get; } + public RendererPluginMetadata Metadata { get; } + public RendererAnalysisService Analysis { get; } + public RendererCacheService Cache { get; } + + public RendererPluginContext( + string rendererId, + string rendererName, + string pluginDirectory, + string manifestPath, + ResamplerManifest manifest, + RendererPluginMetadata metadata = null, + ILogger logger = null) { + RendererId = rendererId; + RendererName = rendererName; + PluginDirectory = pluginDirectory; + ManifestPath = manifestPath; + Manifest = manifest; + Metadata = metadata ?? new RendererPluginMetadata(); + Logger = logger ?? Log.Logger; + Analysis = new RendererAnalysisService(Metadata.AnalysisFormats); + Cache = new RendererCacheService(rendererId); + } + } + + /// Resolves renderer-declared source analysis files without coupling + /// plugins to OpenUtau's render-output cache. + public sealed class RendererAnalysisService { + readonly IReadOnlyDictionary formats; + + internal RendererAnalysisService(IReadOnlyDictionary formats) { + this.formats = formats; + } + + public IReadOnlyDictionary Formats => formats; + + public string GetPath(string format, string sourceFile) { + if (!formats.TryGetValue(format, out var descriptor)) { + throw new KeyNotFoundException($"Unknown renderer analysis format '{format}'."); + } + var fullSource = Path.GetFullPath(sourceFile); + var directory = Path.GetDirectoryName(fullSource) ?? string.Empty; + var stem = Path.GetFileNameWithoutExtension(fullSource); + return descriptor.path + .Replace("{wav_dir}", directory, StringComparison.Ordinal) + .Replace("{wav_stem}", stem, StringComparison.Ordinal) + .Replace("{wav_name}", Path.GetFileName(fullSource), StringComparison.Ordinal); + } + + public RendererAnalysisState GetBasicState(string format, string sourceFile) { + var outputFile = GetPath(format, sourceFile); + if (!File.Exists(outputFile)) return RendererAnalysisState.Missing; + if (!File.Exists(sourceFile)) return RendererAnalysisState.Invalid; + return File.GetLastWriteTimeUtc(outputFile) < File.GetLastWriteTimeUtc(sourceFile) + ? RendererAnalysisState.Stale + : RendererAnalysisState.Valid; + } + + } + + /// Provides namespaced final-output cache paths. Intermediate engine + /// state belongs in memory; reusable source analysis belongs beside the source. + public sealed class RendererCacheService { + readonly string rendererKey; + internal RendererCacheService(string rendererId) { + rendererKey = string.Concat(rendererId.Select(character => + char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-')); + } + + public string GetPhrasePath(RenderPhrase phrase, string extension = ".wav") { + if (string.IsNullOrEmpty(extension)) extension = ".wav"; + if (!extension.StartsWith('.')) extension = "." + extension; + var path = Path.Combine(PathManager.Inst.CachePath, + $"renderer-{rendererKey}-{phrase.hash:x16}{extension}"); + phrase.AddCacheFile(path); + return path; + } + } +} diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs new file mode 100644 index 000000000..8f7343238 --- /dev/null +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -0,0 +1,668 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.Loader; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Classic; +using OpenUtau.Core.Ustx; +using Serilog; + +namespace OpenUtau.Core.Render { + public sealed record RendererAnalysisOption( + string RendererId, string RendererName, string Format, string FormatName); + + public sealed record RendererDiscoveryDiagnostic( + string Path, string Message, string Details); + + public sealed class ExternalRendererDescriptor { + public string Id { get; } + public string Name { get; } + public USingerType SingerType { get; } + public string ManifestPath { get; } + public ResamplerManifest Manifest { get; } + public RendererPluginMetadata Metadata { get; } + internal string AssemblyPath { get; } + internal string TypeName { get; } + + internal ExternalRendererDescriptor( + string id, string name, USingerType singerType, string manifestPath, + ResamplerManifest manifest, RendererPluginMetadata metadata = null, + string assemblyPath = null, string typeName = null) { + Id = id; + Name = name; + SingerType = singerType; + ManifestPath = manifestPath; + Manifest = manifest; + Metadata = metadata ?? new RendererPluginMetadata(); + AssemblyPath = assemblyPath; + TypeName = typeName; + } + } + + /// + /// Discovers renderer metadata declared by manifests or attributed plugin classes, + /// and creates a fresh plugin instance when its renderer is selected. + /// + public static class ExternalRendererRegistry { + public const int ApiVersion = 1; + static readonly object locker = new object(); + static IReadOnlyList renderers = Array.Empty(); + static IReadOnlyList diagnostics = + Array.Empty(); + + public static IReadOnlyList Renderers { + get { lock (locker) { return renderers.ToArray(); } } + } + public static IReadOnlyList Diagnostics { + get { lock (locker) { return diagnostics.ToArray(); } } + } + + public static void Discover(string basePath) { + var discovered = new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var discoveryDiagnostics = new List(); + try { + Directory.CreateDirectory(basePath); + foreach (var path in Directory.EnumerateFiles(basePath, "*.yaml", new EnumerationOptions { + RecurseSubdirectories = true, + }).OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) { + try { + var manifest = ResamplerManifest.Load(path); + var renderer = manifest.renderer; + if (renderer == null || !renderer.enabled) { + continue; + } + Validate(renderer, path); + var metadata = NormalizeMetadata(new RendererPluginMetadata { + Capabilities = renderer.capabilities ?? new RendererCapabilitiesManifest(), + AnalysisFormats = manifest.analysis?.formats + ?? new Dictionary(), + Expressions = manifest.expressions, + }); + ValidateMetadata(metadata, path); + if (ids.Contains(renderer.id)) { + Log.Warning("Ignoring external renderer with duplicate id {Id} in {ManifestPath}", renderer.id, path); + continue; + } + if (names.Contains(renderer.name)) { + Log.Warning("Ignoring external renderer with duplicate name {Name} in {ManifestPath}", renderer.name, path); + continue; + } + ids.Add(renderer.id); + names.Add(renderer.name); + discovered.Add(new ExternalRendererDescriptor( + renderer.id, renderer.name, USingerType.Classic, path, manifest, metadata)); + } catch (Exception e) { + Log.Error(e, "Failed to discover external renderer manifest {ManifestPath}", path); + discoveryDiagnostics.Add(new(path, + "Failed to discover external renderer manifest.", e.Message)); + } + } + foreach (var path in Directory.EnumerateFiles(basePath, "*.dll", new EnumerationOptions { + RecurseSubdirectories = true, + }).OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) { + DiscoverAssembly(path, discovered, ids, names, discoveryDiagnostics); + } + } catch (Exception e) { + Log.Error(e, "Failed to search external renderers in {BasePath}", basePath); + discoveryDiagnostics.Add(new(basePath, + "Failed to search external renderers.", e.Message)); + } + lock (locker) { + renderers = discovered; + diagnostics = discoveryDiagnostics; + } + } + + static void DiscoverAssembly( + string path, + List discovered, + HashSet ids, + HashSet names, + List discoveryDiagnostics) { + if (!HasExternalRendererAttribute(path, out var inspectionError)) { + if (inspectionError != null) { + Log.Warning(inspectionError, "Failed to inspect renderer metadata in {AssemblyPath}", path); + discoveryDiagnostics.Add(new(path, + "Failed to inspect renderer assembly metadata.", inspectionError.Message)); + } + return; + } + RendererLoadContext loadContext = null; + try { + loadContext = new RendererLoadContext(path); + var assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(path)); + foreach (var type in assembly.GetExportedTypes()) { + var attribute = type.GetCustomAttribute(); + if (attribute == null || type.IsAbstract + || !typeof(IOpenUtauRendererPlugin).IsAssignableFrom(type)) { + continue; + } + var plugin = (IOpenUtauRendererPlugin)Activator.CreateInstance(type)!; + try { + if (plugin.ApiVersion != ApiVersion) { + throw new InvalidDataException( + $"Renderer {attribute.Id} implements API {plugin.ApiVersion}; host supports API {ApiVersion}."); + } + var metadata = NormalizeMetadata(plugin.Metadata); + ValidateMetadata(metadata, path); + if (ids.Contains(attribute.Id) || names.Contains(attribute.Name)) { + Log.Warning("Ignoring duplicate external renderer {Id} in {AssemblyPath}", attribute.Id, path); + continue; + } + var manifest = CreateSyntheticManifest(attribute, metadata, path, type); + discovered.Add(new ExternalRendererDescriptor( + attribute.Id, attribute.Name, attribute.SingerType, path, manifest, + metadata, path, type.FullName)); + ids.Add(attribute.Id); + names.Add(attribute.Name); + } finally { + if (plugin is IDisposable disposable) disposable.Dispose(); + } + } + } catch (Exception e) { + Log.Warning(e, "Failed to inspect external renderer assembly {AssemblyPath}", path); + discoveryDiagnostics.Add(new(path, + "Failed to load external renderer metadata.", e.Message)); + } finally { + loadContext?.Unload(); + } + } + + static bool HasExternalRendererAttribute(string path, out Exception error) { + error = null; + try { + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream, PEStreamOptions.LeaveOpen); + if (!pe.HasMetadata) return false; + var reader = pe.GetMetadataReader(); + foreach (var typeHandle in reader.TypeDefinitions) { + var type = reader.GetTypeDefinition(typeHandle); + foreach (var attributeHandle in type.GetCustomAttributes()) { + var attribute = reader.GetCustomAttribute(attributeHandle); + EntityHandle owner = default; + if (attribute.Constructor.Kind == HandleKind.MemberReference) { + owner = reader.GetMemberReference( + (MemberReferenceHandle)attribute.Constructor).Parent; + } else if (attribute.Constructor.Kind == HandleKind.MethodDefinition) { + owner = reader.GetMethodDefinition( + (MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); + } + string name = owner.Kind switch { + HandleKind.TypeReference => reader.GetString( + reader.GetTypeReference((TypeReferenceHandle)owner).Name), + HandleKind.TypeDefinition => reader.GetString( + reader.GetTypeDefinition((TypeDefinitionHandle)owner).Name), + _ => string.Empty, + }; + if (name == nameof(ExternalRendererAttribute)) return true; + } + } + return false; + } catch (BadImageFormatException) { + return false; + } catch (Exception exception) { + error = exception; + return false; + } + } + + static ResamplerManifest CreateSyntheticManifest( + ExternalRendererAttribute attribute, + RendererPluginMetadata metadata, + string path, + Type type) { + return new ResamplerManifest { + renderer = new RendererManifest { + enabled = true, + id = attribute.Id, + name = attribute.Name, + capabilities = metadata.Capabilities, + bridge = new RendererBridgeManifest { + assembly = Path.GetFileName(path), + type = type.FullName, + apiVersion = ApiVersion, + }, + }, + expressions = metadata.Expressions.ToDictionary(pair => pair.Key, pair => pair.Value), + analysis = new AnalysisManifest { + formats = metadata.AnalysisFormats.ToDictionary(pair => pair.Key, pair => pair.Value), + }, + }; + } + + public static IRenderer CreateRenderer(string name) { + ExternalRendererDescriptor descriptor; + lock (locker) { + descriptor = renderers.FirstOrDefault(item => + string.Equals(item.Id, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + } + return descriptor == null ? null : LoadRenderer(descriptor); + } + + public static IReadOnlyList GetAnalysisOptions() { + lock (locker) { + return renderers.SelectMany(renderer => renderer.Metadata.AnalysisFormats + .Where(pair => pair.Value.canGenerate) + .Select(pair => new RendererAnalysisOption( + renderer.Id, renderer.Name, pair.Key, pair.Value.name ?? pair.Key))) + .ToArray(); + } + } + + public static async Task> GenerateAnalysisAsync( + string rendererId, + string format, + IReadOnlyList sourceFiles, + bool overwrite, + IProgress progress, + CancellationToken cancellation) { + ExternalRendererDescriptor descriptor; + lock (locker) { + descriptor = renderers.FirstOrDefault(renderer => + string.Equals(renderer.Id, rendererId, StringComparison.OrdinalIgnoreCase)); + } + if (descriptor == null) throw new KeyNotFoundException($"Renderer '{rendererId}' was not found."); + if (!descriptor.Metadata.AnalysisFormats.TryGetValue(format, out var analysis) || + !analysis.canGenerate) { + throw new InvalidOperationException( + $"Renderer '{rendererId}' cannot generate analysis format '{format}'."); + } + var (plugin, context, loadContext) = LoadPlugin(descriptor); + try { + var provider = plugin.CreateAnalysisProvider(context) + ?? throw new NotSupportedException( + $"Renderer '{rendererId}' declares '{format}' as generatable but returned no analysis provider."); + try { + var allRequests = sourceFiles.Distinct(StringComparer.OrdinalIgnoreCase) + .Select(source => new RendererAnalysisRequest( + format, Path.GetFullPath(source), context.Analysis.GetPath(format, source), overwrite)) + .ToArray(); + var skipped = allRequests.Where(request => !overwrite && File.Exists(request.OutputFile)) + .Select(request => new RendererAnalysisResult( + request, RendererAnalysisOutcome.AlreadyValid)).ToArray(); + var requests = allRequests.Where(request => overwrite || !File.Exists(request.OutputFile)).ToArray(); + var generated = await RunProviderAsync(provider, requests, progress, cancellation); + return skipped.Concat(generated).ToArray(); + } finally { + if (provider is IDisposable disposable) disposable.Dispose(); + } + } finally { + if (plugin is IDisposable pluginDisposable) pluginDisposable.Dispose(); + loadContext.Unload(); + } + } + + static async Task> RunProviderAsync( + IRendererAnalysisProvider provider, + IReadOnlyList requests, + IProgress progress, + CancellationToken cancellation) { + if (requests.Count == 0) return Array.Empty(); + var results = await provider.GenerateAsync(requests, progress, cancellation) + ?? Array.Empty(); + var byOutput = results.GroupBy(result => Path.GetFullPath(result.Request.OutputFile), + StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + return requests.Select(request => { + if (!byOutput.TryGetValue(Path.GetFullPath(request.OutputFile), out var result)) { + return new RendererAnalysisResult(request, RendererAnalysisOutcome.Failed, + "The renderer did not report a result for this request."); + } + if (result.Outcome is RendererAnalysisOutcome.Generated or RendererAnalysisOutcome.AlreadyValid + && !File.Exists(request.OutputFile)) { + return new RendererAnalysisResult(request, RendererAnalysisOutcome.Failed, + "The renderer reported success but did not create the output file."); + } + return result with { Request = request }; + }).ToArray(); + } + + public static async Task PrepareRequiredAnalysisAsync( + string rendererId, + IReadOnlyList sourceFiles, + IProgress progress, + CancellationToken cancellation) { + ExternalRendererDescriptor descriptor; + lock (locker) { + descriptor = renderers.FirstOrDefault(renderer => + string.Equals(renderer.Id, rendererId, StringComparison.OrdinalIgnoreCase)); + } + if (descriptor == null) throw new KeyNotFoundException($"Renderer '{rendererId}' was not found."); + var (plugin, context, loadContext) = LoadPlugin(descriptor); + try { + var provider = plugin.CreateAnalysisProvider(context); + try { + await PrepareRequiredAnalysisAsync(provider, context, sourceFiles, progress, cancellation); + } finally { + if (provider is IDisposable providerDisposable) providerDisposable.Dispose(); + } + } finally { + if (plugin is IDisposable disposable) disposable.Dispose(); + loadContext.Unload(); + } + } + + static async Task PrepareRequiredAnalysisAsync( + IRendererAnalysisProvider? provider, + RendererPluginContext context, + IReadOnlyList sourceFiles, + IProgress progress, + CancellationToken cancellation) { + var required = context.Analysis.Formats.Where(pair => pair.Value.required).ToArray(); + if (required.Length == 0) return; + var requests = new List(); + foreach (var source in sourceFiles.Distinct(StringComparer.OrdinalIgnoreCase)) { + foreach (var pair in required) { + cancellation.ThrowIfCancellationRequested(); + var request = new RendererAnalysisRequest(pair.Key, Path.GetFullPath(source), + context.Analysis.GetPath(pair.Key, source), true); + var state = provider == null + ? context.Analysis.GetBasicState(pair.Key, request.SourceFile) + : await provider.ValidateAsync(request, cancellation); + if (state == RendererAnalysisState.Valid) continue; + if (!pair.Value.canGenerate) { + throw new InvalidDataException( + $"Required analysis '{pair.Key}' for '{source}' is {state.ToString().ToLowerInvariant()} " + + $"and renderer '{context.RendererId}' cannot generate it."); + } + requests.Add(request); + } + } + if (requests.Count == 0) return; + if (provider == null) { + throw new NotSupportedException( + $"Renderer '{context.RendererId}' declares required analysis as generatable but returned no provider."); + } + var results = await RunProviderAsync(provider, requests, progress, cancellation); + var failures = results.Where(result => result.Outcome != RendererAnalysisOutcome.Generated + && result.Outcome != RendererAnalysisOutcome.AlreadyValid).ToArray(); + if (failures.Length > 0) { + throw new InvalidDataException( + $"Renderer '{context.RendererId}' failed to generate {failures.Length} required analysis file(s): " + + string.Join("; ", failures.Select(result => + $"{result.Request.SourceFile}: {result.Message ?? result.Outcome.ToString()}"))); + } + foreach (var request in requests) { + cancellation.ThrowIfCancellationRequested(); + var state = await provider.ValidateAsync(request, cancellation); + if (state != RendererAnalysisState.Valid) { + throw new InvalidDataException( + $"Renderer '{context.RendererId}' generated '{request.OutputFile}', but validation returned {state}."); + } + } + } + + static void Validate(RendererManifest renderer, string path) { + if (string.IsNullOrWhiteSpace(renderer.id)) { + throw new InvalidDataException($"Renderer id is missing in {path}."); + } + if (string.IsNullOrWhiteSpace(renderer.name)) { + throw new InvalidDataException($"Renderer name is missing in {path}."); + } + if (renderer.bridge == null || string.IsNullOrWhiteSpace(renderer.bridge.assembly) + || string.IsNullOrWhiteSpace(renderer.bridge.type)) { + throw new InvalidDataException($"Renderer bridge assembly or type is missing in {path}."); + } + if (renderer.bridge.apiVersion != ApiVersion) { + throw new InvalidDataException( + $"Renderer {renderer.id} requests API {renderer.bridge.apiVersion}; host supports API {ApiVersion}."); + } + if (renderer.capabilities?.parallelism < 0) { + throw new InvalidDataException( + $"Renderer {renderer.id} declares negative parallelism in {path}."); + } + } + + static void ValidateMetadata(RendererPluginMetadata metadata, string origin) { + var abbreviations = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pair in metadata.Expressions) { + if (pair.Value == null || string.IsNullOrWhiteSpace(pair.Value.abbr)) { + throw new InvalidDataException( + $"Renderer expression '{pair.Key}' has no descriptor or abbreviation in {origin}."); + } + if (!string.Equals(pair.Key, pair.Value.abbr, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidDataException( + $"Renderer expression key '{pair.Key}' does not match descriptor abbreviation " + + $"'{pair.Value.abbr}' in {origin}."); + } + if (!abbreviations.Add(pair.Value.abbr)) { + throw new InvalidDataException( + $"Renderer expression abbreviation '{pair.Value.abbr}' is duplicated in {origin}."); + } + } + foreach (var pair in metadata.AnalysisFormats) { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value == null) { + throw new InvalidDataException( + $"Renderer analysis format has no key or descriptor in {origin}."); + } + if (string.IsNullOrWhiteSpace(pair.Value.path)) { + throw new InvalidDataException( + $"Renderer analysis format '{pair.Key}' has no path in {origin}."); + } + } + if (metadata.Capabilities.parallelism < 0) { + throw new InvalidDataException( + $"Renderer declares negative parallelism in {origin}."); + } + } + + static RendererPluginMetadata NormalizeMetadata(RendererPluginMetadata? metadata) => new() { + Capabilities = metadata?.Capabilities ?? new RendererCapabilitiesManifest(), + AnalysisFormats = metadata?.AnalysisFormats + ?? new Dictionary(), + Expressions = metadata?.Expressions + ?? new Dictionary(), + }; + + static IRenderer LoadRenderer(ExternalRendererDescriptor descriptor) { + var (plugin, context, loadContext) = LoadPlugin(descriptor); + IRenderer renderer; + try { + renderer = plugin.CreateRenderer(context) + ?? throw new InvalidOperationException($"Renderer plugin {descriptor.Id} returned null."); + } catch { + if (plugin is IDisposable disposable) disposable.Dispose(); + loadContext.Unload(); + throw; + } + try { + if (renderer.SingerType != descriptor.SingerType) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} declared singer type {descriptor.SingerType} but returned {renderer.SingerType}."); + } + var capabilities = descriptor.Metadata.Capabilities; + ValidateMetadata(descriptor.Metadata, descriptor.ManifestPath); + if (capabilities.renderedPitch != renderer.SupportsRenderPitch) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} declares renderedPitch={capabilities.renderedPitch}, " + + $"but its IRenderer reports SupportsRenderPitch={renderer.SupportsRenderPitch}."); + } + if (capabilities.realCurves != renderer.SupportsRealCurve) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} declares realCurves={capabilities.realCurves}, " + + $"but its IRenderer reports SupportsRealCurve={renderer.SupportsRealCurve}."); + } + if (capabilities.parallelism < 0) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} declares negative parallelism."); + } + return new ExternalRendererProxy(descriptor, plugin, renderer, context, loadContext); + } catch { + if (renderer is IDisposable rendererDisposable) rendererDisposable.Dispose(); + if (!ReferenceEquals(plugin, renderer) && plugin is IDisposable pluginDisposable) + pluginDisposable.Dispose(); + loadContext.Unload(); + throw; + } + } + + static (IOpenUtauRendererPlugin Plugin, RendererPluginContext Context, + RendererLoadContext LoadContext) LoadPlugin( + ExternalRendererDescriptor descriptor) { + var bridge = descriptor.Manifest.renderer.bridge; + var pluginDirectory = Path.GetDirectoryName(descriptor.AssemblyPath ?? descriptor.ManifestPath)!; + var assemblyPath = descriptor.AssemblyPath ?? Path.GetFullPath(bridge.assembly, pluginDirectory); + if (!File.Exists(assemblyPath)) { + throw new FileNotFoundException("External renderer bridge assembly was not found.", assemblyPath); + } + var loadContext = new RendererLoadContext(assemblyPath); + try { + var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); + var type = assembly.GetType(descriptor.TypeName ?? bridge.type, throwOnError: true)!; + if (Activator.CreateInstance(type) is not IOpenUtauRendererPlugin plugin) { + throw new InvalidCastException( + $"{bridge.type} does not implement {nameof(IOpenUtauRendererPlugin)}."); + } + if (plugin.ApiVersion != ApiVersion) { + if (plugin is IDisposable disposable) disposable.Dispose(); + throw new InvalidDataException( + $"Renderer {descriptor.Id} implements API {plugin.ApiVersion}; host supports API {ApiVersion}."); + } + var context = new RendererPluginContext( + descriptor.Id, descriptor.Name, pluginDirectory, descriptor.ManifestPath, + descriptor.Manifest, descriptor.Metadata); + return (plugin, context, loadContext); + } catch { + loadContext.Unload(); + throw; + } + } + + sealed class ExternalRendererProxy : IRenderer, IExternalRendererIdentity, IDisposable { + readonly ExternalRendererDescriptor descriptor; + readonly IOpenUtauRendererPlugin plugin; + readonly IRenderer renderer; + readonly RendererPluginContext context; + readonly RendererLoadContext loadContext; + readonly IRendererAnalysisProvider? analysisProvider; + readonly SemaphoreSlim analysisLock = new(1, 1); + readonly SemaphoreSlim? renderSlots; + public ExternalRendererProxy(ExternalRendererDescriptor descriptor, + IOpenUtauRendererPlugin plugin, IRenderer renderer, + RendererPluginContext context, RendererLoadContext loadContext) { + this.descriptor = descriptor; + this.plugin = plugin; + this.renderer = renderer; + this.context = context; + this.loadContext = loadContext; + if (context.Analysis.Formats.Any(pair => pair.Value.required)) { + analysisProvider = plugin.CreateAnalysisProvider(context); + } + var parallelism = descriptor.Metadata.Capabilities.parallelism; + if (parallelism > 0) renderSlots = new SemaphoreSlim(parallelism, parallelism); + } + public USingerType SingerType => renderer.SingerType; + public string Id => descriptor.Id; + public string Name => descriptor.Name; + public bool SupportsRenderPitch => renderer.SupportsRenderPitch; + public bool SupportsRealCurve => renderer.SupportsRealCurve; + public bool SupportsExpression(UExpressionDescriptor expression) => + descriptor.Metadata.Expressions.Keys.Any(abbr => + string.Equals(abbr, expression.abbr, StringComparison.OrdinalIgnoreCase)) || + renderer.SupportsExpression(expression); + public RenderResult Layout(RenderPhrase phrase) => renderer.Layout(phrase); + public async Task Render(RenderPhrase phrase, Progress progress, int trackNo, + CancellationTokenSource cancellation, bool isPreRender = false) { + cancellation.Token.ThrowIfCancellationRequested(); + if (renderSlots != null) await renderSlots.WaitAsync(cancellation.Token); + try { + var sources = phrase.phones.Where(phone => !phone.direct) + .Select(phone => phone.oto?.File).Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => path!).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + await analysisLock.WaitAsync(cancellation.Token); + try { + await PrepareRequiredAnalysisAsync(analysisProvider, context, sources, + new Progress(), cancellation.Token); + } finally { + analysisLock.Release(); + } + var result = await renderer.Render(phrase, progress, trackNo, cancellation, isPreRender); + cancellation.Token.ThrowIfCancellationRequested(); + return result; + } finally { + renderSlots?.Release(); + } + } + public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => renderer.LoadRenderedPitch(phrase); + public List LoadRenderedRealCurves(RenderPhrase phrase) => renderer.LoadRenderedRealCurves(phrase); + public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings settings) { + var declared = descriptor.Metadata.Expressions.Values.ToDictionary( + expression => expression.abbr, StringComparer.OrdinalIgnoreCase); + var runtime = renderer.GetSuggestedExpressions(singer, settings) + ?? Array.Empty(); + foreach (var expression in runtime) { + if (expression == null || string.IsNullOrWhiteSpace(expression.abbr)) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} returned an expression without an abbreviation."); + } + if (declared.TryGetValue(expression.abbr, out var staticExpression)) { + if (!ExpressionsEqual(staticExpression, expression)) { + throw new InvalidDataException( + $"Renderer {descriptor.Id} returned a runtime definition for expression " + + $"'{expression.abbr}' that conflicts with its declared metadata."); + } + } else { + declared.Add(expression.abbr, expression); + } + } + return declared.Values.ToArray(); + } + static bool ExpressionsEqual(UExpressionDescriptor left, UExpressionDescriptor right) => + string.Equals(left.name, right.name, StringComparison.Ordinal) && + string.Equals(left.abbr, right.abbr, StringComparison.OrdinalIgnoreCase) && + left.type == right.type && left.min == right.min && left.max == right.max && + left.defaultValue == right.defaultValue && + left.CustomDefaultValue == right.CustomDefaultValue && + left.isFlag == right.isFlag && + string.Equals(left.flag, right.flag, StringComparison.Ordinal) && + (left.options ?? Array.Empty()).SequenceEqual(right.options ?? Array.Empty()) && + left.skipOutputIfDefault == right.skipOutputIfDefault; + public void Dispose() { + if (renderer is IDisposable rendererDisposable) rendererDisposable.Dispose(); + if (analysisProvider is IDisposable providerDisposable) providerDisposable.Dispose(); + if (!ReferenceEquals(plugin, renderer) && plugin is IDisposable pluginDisposable) + pluginDisposable.Dispose(); + analysisLock.Dispose(); + renderSlots?.Dispose(); + loadContext.Unload(); + } + public override string ToString() => descriptor.Name; + } + + sealed class RendererLoadContext : AssemblyLoadContext { + readonly AssemblyDependencyResolver resolver; + static readonly HashSet sharedAssemblyNames = new(StringComparer.OrdinalIgnoreCase) { + typeof(IOpenUtauRendererPlugin).Assembly.GetName().Name!, + "Serilog", + "NAudio.Core", + }; + + public RendererLoadContext(string pluginPath) : base(isCollectible: true) { + resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly Load(AssemblyName assemblyName) { + if (sharedAssemblyNames.Contains(assemblyName.Name ?? string.Empty)) { + return Default.Assemblies.FirstOrDefault(assembly => + string.Equals(assembly.GetName().Name, assemblyName.Name, StringComparison.OrdinalIgnoreCase)) + ?? Default.LoadFromAssemblyName(assemblyName); + } + var path = resolver.ResolveAssemblyToPath(assemblyName); + return path == null ? null : LoadFromAssemblyPath(path); + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { + var path = resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return path == null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path); + } + } + } +} diff --git a/OpenUtau.Core/Render/RenderPhrase.cs b/OpenUtau.Core/Render/RenderPhrase.cs index 048fd64f6..d00810caa 100644 --- a/OpenUtau.Core/Render/RenderPhrase.cs +++ b/OpenUtau.Core/Render/RenderPhrase.cs @@ -491,7 +491,7 @@ private ulong Hash(bool postEffect) { using (var stream = new MemoryStream()) { using (var writer = new BinaryWriter(stream)) { writer.Write(singer.Id); - writer.Write(renderer?.ToString() ?? ""); + writer.Write(renderer == null ? "" : Renderers.GetRendererId(renderer)); writer.Write(wavtool ?? ""); writer.Write(timeAxis.Timestamp); foreach (var phone in phones) { diff --git a/OpenUtau.Core/Render/Renderers.cs b/OpenUtau.Core/Render/Renderers.cs index 469a6ba84..df25f97f1 100644 --- a/OpenUtau.Core/Render/Renderers.cs +++ b/OpenUtau.Core/Render/Renderers.cs @@ -7,6 +7,8 @@ using OpenUtau.Core.Util; namespace OpenUtau.Core.Render { + public readonly record struct RendererOption(string Id, string Name); + public static class Renderers { public const string CLASSIC = "CLASSIC"; public const string WORLDLINE_R = "WORLDLINE-R"; @@ -24,27 +26,39 @@ public static class Renderers { static readonly string[] noRenderers = new string[0]; public static string[] GetSupportedRenderers(USingerType singerType) { + return GetSupportedRendererOptions(singerType).Select(option => option.Id).ToArray(); + } + + public static RendererOption[] GetSupportedRendererOptions(USingerType singerType) { + if (singerType == USingerType.Classic) { + return classicRenderers.Select(name => new RendererOption(name, name)).Concat( + ExternalRendererRegistry.Renderers + .Where(renderer => renderer.SingerType == singerType) + .Select(renderer => new RendererOption(renderer.Id, renderer.Name))).ToArray(); + } + string[] names; switch (singerType) { - case USingerType.Classic: - return classicRenderers; case USingerType.Enunu: - return enunuRenderers; + names = enunuRenderers; break; case USingerType.Vogen: - return vogenRenderers; + names = vogenRenderers; break; case USingerType.DiffSinger: - return diffSingerRenderers; + names = diffSingerRenderers; break; case USingerType.Voicevox: - return voicevoxRenderers; + names = voicevoxRenderers; break; default: - return noRenderers; + names = noRenderers; break; } + return names.Select(name => new RendererOption(name, name)).ToArray(); } public static List getRendererOptions() { - return new List { + var options = new List { "WORLDLINE-R", "Classic" }; + options.AddRange(ExternalRendererRegistry.Renderers.Select(renderer => renderer.Id)); + return options; } public static string GetDefaultRenderer(USingerType singerType) { @@ -71,7 +85,19 @@ public static IRenderer CreateRenderer(string renderer) { } else if (renderer == VOICEVOX) { return new Voicevox.VoicevoxRenderer(); } - return null; + return ExternalRendererRegistry.CreateRenderer(renderer); + } + + public static string GetRendererId(IRenderer renderer) => + renderer is IExternalRendererIdentity external ? external.Id : renderer?.ToString(); + + public static bool IsRenderer(string idOrName, IRenderer renderer) { + if (renderer == null) return false; + if (renderer is IExternalRendererIdentity external) { + return string.Equals(idOrName, external.Id, StringComparison.OrdinalIgnoreCase) || + string.Equals(idOrName, external.Name, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(idOrName, renderer.ToString(), StringComparison.Ordinal); } readonly static ConcurrentDictionary cacheLockMap diff --git a/OpenUtau.Core/Ustx/UTrack.cs b/OpenUtau.Core/Ustx/UTrack.cs index d668f0e9b..f5d2d6564 100644 --- a/OpenUtau.Core/Ustx/UTrack.cs +++ b/OpenUtau.Core/Ustx/UTrack.cs @@ -16,8 +16,9 @@ public class URenderSettings { [YamlIgnore] public IRenderer Renderer { get; set; } [YamlIgnore] public IResampler Resampler { get; set; } [YamlIgnore] public IWavtool Wavtool { get; set; } + [YamlIgnore] public string RendererLoadError { get; private set; } - public void Validate(UTrack track) { + public void Validate(UTrack track, bool fallbackUnavailableRenderer = true) { if (track.Singer == null || !track.Singer.Found) { renderer = null; Renderer = null; @@ -25,15 +26,42 @@ public void Validate(UTrack track) { Resampler = null; wavtool = null; Wavtool = null; + RendererLoadError = null; return; } if (string.IsNullOrEmpty(renderer)) { renderer = Renderers.GetDefaultRenderer(track.Singer.SingerType); } - if (renderer != Renderer?.ToString()) { - Renderer = Renderers.CreateRenderer(renderer); + if (!Renderers.IsRenderer(renderer, Renderer)) { + var requestedRenderer = renderer; + IRenderer nextRenderer; + try { + nextRenderer = Renderers.CreateRenderer(requestedRenderer); + if (nextRenderer == null) { + throw new KeyNotFoundException( + $"Renderer '{requestedRenderer}' is not installed."); + } + RendererLoadError = null; + } catch (Exception exception) { + if (!fallbackUnavailableRenderer) throw; + RendererLoadError = exception.Message; + Log.Warning(exception, + "Renderer {RendererId} is unavailable; using the default renderer for this session.", + requestedRenderer); + var fallback = Renderers.GetDefaultRenderer(track.Singer.SingerType); + nextRenderer = Renderers.CreateRenderer(fallback) + ?? throw new InvalidOperationException( + $"Default renderer '{fallback}' could not be created.", exception); + } + if (Renderer is IDisposable disposable) { + disposable.Dispose(); + } + Renderer = nextRenderer; + if (RendererLoadError == null) { + renderer = Renderers.GetRendererId(Renderer); + } } - if (renderer == Renderers.CLASSIC) { + if (Renderers.GetRendererId(Renderer) == Renderers.CLASSIC) { if (string.IsNullOrEmpty(resampler)) { if (!Util.Preferences.Default.DefaultResamplers.TryGetValue(renderer, out resampler)) { resampler = null; diff --git a/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs new file mode 100644 index 000000000..0919ce277 --- /dev/null +++ b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs @@ -0,0 +1,521 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using OpenUtau.Core; +using OpenUtau.Classic; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; +using Xunit; + +namespace OpenUtau.Test.Render; + +public class ExternalRendererRegistryTest { + [Fact] + public void MissingSavedRendererFallsBackWithoutReplacingStableId() { + var track = new UTrack { Singer = new TestSinger() }; + var settings = new URenderSettings { + renderer = "org.openutau.test.not-installed", + }; + + settings.Validate(track); + + Assert.Equal("org.openutau.test.not-installed", settings.renderer); + Assert.NotNull(settings.Renderer); + Assert.Equal(Renderers.WORLDLINE_R, settings.Renderer.ToString()); + Assert.Contains("not installed", settings.RendererLoadError); + } + + [Fact] + public void ExplicitMissingRendererSelectionDoesNotSilentlyFallback() { + var track = new UTrack { Singer = new TestSinger() }; + var settings = new URenderSettings { + renderer = "org.openutau.test.not-installed", + }; + + Assert.Throws(() => + settings.Validate(track, fallbackUnavailableRenderer: false)); + + Assert.Null(settings.Renderer); + } + + [Fact] + public void IgnoresUnrelatedDllWithoutLoadingIt() { + var directory = CreateDirectory(); + try { + File.WriteAllBytes(Path.Combine(directory, "native-or-unrelated.dll"), + new byte[] { 0, 1, 2, 3 }); + + ExternalRendererRegistry.Discover(directory); + + Assert.Empty(ExternalRendererRegistry.Renderers); + Assert.Empty(ExternalRendererRegistry.Diagnostics); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void RetainsManifestDiscoveryDiagnostics() { + var directory = CreateDirectory(); + try { + var path = Path.Combine(directory, "broken.yaml"); + File.WriteAllText(path, "renderer: [not valid"); + + ExternalRendererRegistry.Discover(directory); + + var diagnostic = Assert.Single(ExternalRendererRegistry.Diagnostics); + Assert.Equal(path, diagnostic.Path); + Assert.Contains("manifest", diagnostic.Message); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void InvalidManifestDoesNotReserveRendererIdentity() { + var directory = CreateDirectory(); + try { + File.WriteAllText(Path.Combine(directory, "a-invalid.yaml"), """ + renderer: + enabled: true + id: org.openutau.test.reused + name: Invalid Renderer + bridge: + assembly: missing.dll + type: Missing.Plugin + api_version: 1 + analysis: + formats: + broken: + can_generate: true + """); + File.WriteAllText(Path.Combine(directory, "b-valid.yaml"), """ + renderer: + enabled: true + id: org.openutau.test.reused + name: Valid Renderer + bridge: + assembly: missing.dll + type: Missing.Plugin + api_version: 1 + """); + + ExternalRendererRegistry.Discover(directory); + + Assert.Equal("Valid Renderer", Assert.Single(ExternalRendererRegistry.Renderers).Name); + Assert.Single(ExternalRendererRegistry.Diagnostics); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void DuplicateNameDoesNotReserveUnusedId() { + var directory = CreateDirectory(); + try { + static string Manifest(string id, string name) => $$""" + renderer: + enabled: true + id: {{id}} + name: {{name}} + bridge: + assembly: missing.dll + type: Missing.Plugin + api_version: 1 + """; + File.WriteAllText(Path.Combine(directory, "a.yaml"), + Manifest("org.openutau.test.first", "Shared Name")); + File.WriteAllText(Path.Combine(directory, "b.yaml"), + Manifest("org.openutau.test.second", "Shared Name")); + File.WriteAllText(Path.Combine(directory, "c.yaml"), + Manifest("org.openutau.test.second", "Unique Name")); + + ExternalRendererRegistry.Discover(directory); + + Assert.Equal(2, ExternalRendererRegistry.Renderers.Count); + Assert.Contains(ExternalRendererRegistry.Renderers, + renderer => renderer.Name == "Unique Name"); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task GeneratesRendererDeclaredAnalysis() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + var source = Path.Combine(directory, "sample.wav"); + File.WriteAllBytes(source, new byte[] { 1 }); + ExternalRendererRegistry.Discover(directory); + + var option = Assert.Single(ExternalRendererRegistry.GetAnalysisOptions()); + var results = await ExternalRendererRegistry.GenerateAnalysisAsync( + option.RendererId, option.Format, new[] { source }, true, + new Progress(), CancellationToken.None); + + Assert.Equal(RendererAnalysisOutcome.Generated, Assert.Single(results).Outcome); + Assert.True(File.Exists(Path.Combine(directory, "sample.test-analysis"))); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task ReportsPerFileAnalysisFailuresAndContinuesBatch() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + var good = Path.Combine(directory, "good.wav"); + var bad = Path.Combine(directory, "fail.wav"); + File.WriteAllText(good, "source"); + File.WriteAllText(bad, "source"); + ExternalRendererRegistry.Discover(directory); + + var results = await ExternalRendererRegistry.GenerateAnalysisAsync( + "org.openutau.test.renderer", "test", new[] { bad, good }, true, + new Progress(), CancellationToken.None); + + Assert.Equal(2, results.Count); + Assert.Contains(results, result => result.Request.SourceFile == bad + && result.Outcome == RendererAnalysisOutcome.Failed); + Assert.Contains(results, result => result.Request.SourceFile == good + && result.Outcome == RendererAnalysisOutcome.Generated); + Assert.True(File.Exists(Path.Combine(directory, "good.test-analysis"))); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task GeneratesMissingRequiredAnalysisBeforeRendering() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + var source = Path.Combine(directory, "sample.wav"); + File.WriteAllText(source, "source"); + ExternalRendererRegistry.Discover(directory); + + await ExternalRendererRegistry.PrepareRequiredAnalysisAsync( + "org.openutau.test.renderer", new[] { source }, + new Progress(), CancellationToken.None); + + Assert.True(File.Exists(Path.Combine(directory, "sample.test-analysis"))); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task RegeneratesStaleRequiredAnalysis() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + var source = Path.Combine(directory, "sample.wav"); + var analysis = Path.Combine(directory, "sample.test-analysis"); + File.WriteAllText(source, "source"); + File.WriteAllText(analysis, "stale"); + File.SetLastWriteTimeUtc(analysis, DateTime.UtcNow.AddMinutes(-2)); + File.SetLastWriteTimeUtc(source, DateTime.UtcNow.AddMinutes(-1)); + ExternalRendererRegistry.Discover(directory); + + await ExternalRendererRegistry.PrepareRequiredAnalysisAsync( + "org.openutau.test.renderer", new[] { source }, + new Progress(), CancellationToken.None); + + Assert.Equal("ok", File.ReadAllText(analysis)); + Assert.True(File.GetLastWriteTimeUtc(analysis) >= File.GetLastWriteTimeUtc(source)); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void DiscoversMetadataFromAttributedAssemblyWithoutManifest() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + + ExternalRendererRegistry.Discover(directory); + + var descriptor = Assert.Single(ExternalRendererRegistry.Renderers); + Assert.Equal("org.openutau.test.renderer", descriptor.Id); + Assert.Equal("Test External Renderer", descriptor.Name); + Assert.Equal(typeof(TestRendererPlugin).FullName, descriptor.Manifest.renderer.bridge.type); + var renderer = ExternalRendererRegistry.CreateRenderer(descriptor.Id); + var expression = Assert.Single(renderer.GetSuggestedExpressions(null, null)); + Assert.Equal("test-expression", expression.abbr); + Assert.True(renderer.SupportsExpression(expression)); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void RuntimeRendererOwnsACollectibleLoadContext() { + var directory = CreateDirectory(); + try { + File.Copy(typeof(TestRendererPlugin).Assembly.Location, + Path.Combine(directory, "renderer-plugin.dll")); + ExternalRendererRegistry.Discover(directory); + var renderer = ExternalRendererRegistry.CreateRenderer( + "org.openutau.test.renderer"); + var field = renderer.GetType().GetField( + "loadContext", BindingFlags.Instance | BindingFlags.NonPublic); + var context = Assert.IsAssignableFrom( + field?.GetValue(renderer)); + + Assert.True(context.IsCollectible); + + Assert.IsAssignableFrom(renderer).Dispose(); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void DiscoversManifestWithoutLoadingAssembly() { + var directory = CreateDirectory(); + try { + File.WriteAllText(Path.Combine(directory, "renderer.yaml"), """ + renderer: + enabled: true + id: org.openutau.test.missing + name: Missing Assembly Renderer + bridge: + assembly: does-not-exist.dll + type: Missing.Plugin + api_version: 1 + """); + + ExternalRendererRegistry.Discover(directory); + + var descriptor = Assert.Single(ExternalRendererRegistry.Renderers); + Assert.Equal("org.openutau.test.missing", descriptor.Id); + Assert.Equal("Missing Assembly Renderer", descriptor.Name); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void LoadsPluginWhenRendererIsCreated() { + var directory = CreateDirectory(); + try { + var assembly = typeof(TestRendererPlugin).Assembly.Location.Replace("\\", "/"); + File.WriteAllText(Path.Combine(directory, "renderer.yaml"), $$""" + renderer: + enabled: true + id: org.openutau.test.renderer + name: Test External Renderer + bridge: + assembly: "{{assembly}}" + type: OpenUtau.Test.Render.TestRendererPlugin + api_version: 1 + """); + ExternalRendererRegistry.Discover(directory); + + var renderer = ExternalRendererRegistry.CreateRenderer("Test External Renderer"); + + Assert.NotNull(renderer); + Assert.Equal("Test External Renderer", renderer.ToString()); + Assert.Equal("org.openutau.test.renderer", Renderers.GetRendererId(renderer)); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void RejectsRuntimeCapabilityMismatch() { + var directory = CreateDirectory(); + try { + var assembly = typeof(TestRendererPlugin).Assembly.Location.Replace("\\", "/"); + File.WriteAllText(Path.Combine(directory, "renderer.yaml"), $$""" + renderer: + enabled: true + id: org.openutau.test.renderer + name: Test External Renderer + bridge: + assembly: "{{assembly}}" + type: OpenUtau.Test.Render.TestRendererPlugin + api_version: 1 + capabilities: + rendered_pitch: true + """); + ExternalRendererRegistry.Discover(directory); + + var exception = Assert.Throws(() => + ExternalRendererRegistry.CreateRenderer("org.openutau.test.renderer")); + + Assert.Contains("SupportsRenderPitch=False", exception.Message); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void RejectsConflictingRuntimeExpressionDefinition() { + var directory = CreateDirectory(); + try { + var assembly = typeof(TestRendererPlugin).Assembly.Location.Replace("\\", "/"); + File.WriteAllText(Path.Combine(directory, "renderer.yaml"), $$""" + renderer: + enabled: true + id: org.openutau.test.conflicting-expression + name: Conflict Renderer + bridge: + assembly: "{{assembly}}" + type: OpenUtau.Test.Render.TestRendererPlugin + api_version: 1 + expressions: + test-expression: + name: Static Definition + abbr: test-expression + type: Numerical + min: 0 + max: 100 + default_value: 0 + """); + ExternalRendererRegistry.Discover(directory); + var renderer = ExternalRendererRegistry.CreateRenderer( + "org.openutau.test.conflicting-expression"); + + var exception = Assert.Throws(() => + renderer.GetSuggestedExpressions(null, null)); + + Assert.Contains("conflicts with its declared metadata", exception.Message); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void ParsesCapabilitiesAndAnalysisMetadata() { + var directory = CreateDirectory(); + try { + File.WriteAllText(Path.Combine(directory, "renderer.yaml"), """ + renderer: + enabled: true + id: org.openutau.test.metadata + name: Metadata Renderer + bridge: + assembly: renderer.dll + type: Renderer.Plugin + api_version: 1 + capabilities: + cancellation: true + parallelism: 2 + analysis: + formats: + llsm2: + name: LLSM2 + path: "{wav_dir}/{wav_stem}.llsm2" + required: true + can_generate: true + shared: false + """); + + ExternalRendererRegistry.Discover(directory); + + var manifest = Assert.Single(ExternalRendererRegistry.Renderers).Manifest; + Assert.True(manifest.renderer.capabilities.cancellation); + Assert.Equal(2, manifest.renderer.capabilities.parallelism); + Assert.True(manifest.analysis.formats["llsm2"].required); + Assert.True(manifest.analysis.formats["llsm2"].canGenerate); + } finally { + Directory.Delete(directory, true); + } + } + + static string CreateDirectory() { + var path = Path.Combine(Path.GetTempPath(), $"openutau-renderer-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } +} + +public sealed class TestSinger : USinger { + public TestSinger() { + found = true; + loaded = true; + } + public override string Id => "test-singer"; + public override string Name => "Test Singer"; + public override USingerType SingerType => USingerType.Classic; +} + +[ExternalRenderer("org.openutau.test.renderer", "Test External Renderer")] +public sealed class TestRendererPlugin : IOpenUtauRendererPlugin { + public int ApiVersion => 1; + public RendererPluginMetadata Metadata => new() { + AnalysisFormats = new Dictionary { + ["test"] = new() { + name = "Test analysis", + path = "{wav_dir}/{wav_stem}.test-analysis", + required = true, + canGenerate = true, + }, + }, + Expressions = new Dictionary { + ["test-expression"] = new("Test Expression", "test-expression", 0, 100, 0), + }, + }; + public IRenderer CreateRenderer(RendererPluginContext context) => + new TestRenderer(context.Manifest.renderer.name); + public IRendererAnalysisProvider CreateAnalysisProvider(RendererPluginContext context) => + new TestAnalysisProvider(context.Analysis); +} + +public sealed class TestAnalysisProvider : IRendererAnalysisProvider { + readonly RendererAnalysisService analysis; + public TestAnalysisProvider(RendererAnalysisService analysis) => this.analysis = analysis; + public Task> GenerateAsync( + IReadOnlyList requests, + IProgress progress, CancellationToken cancellation) { + var results = new List(); + for (int i = 0; i < requests.Count; ++i) { + if (Path.GetFileName(requests[i].SourceFile) == "fail.wav") { + results.Add(new RendererAnalysisResult( + requests[i], RendererAnalysisOutcome.Failed, "Expected test failure.")); + } else { + File.WriteAllText(requests[i].OutputFile, "ok"); + results.Add(new RendererAnalysisResult( + requests[i], RendererAnalysisOutcome.Generated)); + } + progress.Report(i + 1); + } + return Task.FromResult>(results); + } + public ValueTask ValidateAsync( + RendererAnalysisRequest request, CancellationToken cancellation) => + ValueTask.FromResult(analysis.GetBasicState(request.Format, request.SourceFile)); +} + +public sealed class TestRenderer : IRenderer { + readonly string name; + public TestRenderer(string name) => this.name = name; + public USingerType SingerType => USingerType.Classic; + public bool SupportsRenderPitch => false; + public bool SupportsExpression(UExpressionDescriptor descriptor) => false; + public RenderResult Layout(RenderPhrase phrase) => new(); + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, + CancellationTokenSource cancellation, bool isPreRender = false) => + Task.FromResult(new RenderResult { samples = Array.Empty() }); + public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => null; + public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings) => + name == "Conflict Renderer" + ? new[] { new UExpressionDescriptor( + "Runtime Definition", "test-expression", 0, 100, 0) } + : Array.Empty(); + public override string ToString() => name; +} diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index c1c5ac853..66622661e 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -694,6 +694,8 @@ The voicebank may not work on another OS. Goto Source File Regenerate FRQ Regenerating FRQ + Generate renderer analysis + Generating renderer analysis Reset Otos Save Otos Search Alias diff --git a/OpenUtau/ViewModels/TrackHeaderViewModel.cs b/OpenUtau/ViewModels/TrackHeaderViewModel.cs index c9ed951f8..dc6639346 100644 --- a/OpenUtau/ViewModels/TrackHeaderViewModel.cs +++ b/OpenUtau/ViewModels/TrackHeaderViewModel.cs @@ -107,8 +107,15 @@ public TrackHeaderViewModel(UTrack track) { renderer = name, }; DocManager.Inst.StartUndoGroup("command.track.setting"); - DocManager.Inst.ExecuteCmd(new TrackChangeRenderSettingCommand(DocManager.Inst.Project, track, settings)); - DocManager.Inst.EndUndoGroup(); + try { + DocManager.Inst.ExecuteCmd(new TrackChangeRenderSettingCommand(DocManager.Inst.Project, track, settings)); + DocManager.Inst.EndUndoGroup(); + } catch (Exception e) { + Log.Error(e, "Failed to select renderer {Renderer}", name); + DocManager.Inst.RollBackUndoGroup(); + DocManager.Inst.EndUndoGroup(); + DocManager.Inst.ExecuteCmd(new ErrorMessageNotification($"Failed to load renderer {name}.", e)); + } this.RaisePropertyChanged(nameof(Renderer)); }); @@ -453,11 +460,11 @@ public void RefreshPhonemizers() { public void RefreshRenderers() { var items = new List(); if (track != null && track.Singer != null && track.Singer.Found) { - items.AddRange(Core.Render.Renderers.GetSupportedRenderers(track.Singer.SingerType) - .Select(name => new MenuItemViewModel() { - Header = name, + items.AddRange(Core.Render.Renderers.GetSupportedRendererOptions(track.Singer.SingerType) + .Select(option => new MenuItemViewModel() { + Header = option.Name, Command = SelectRendererCommand, - CommandParameter = name, + CommandParameter = option.Id, })); } RenderersMenuItems = items.ToArray(); diff --git a/OpenUtau/Views/SingersDialog.axaml b/OpenUtau/Views/SingersDialog.axaml index af69702ed..957d39c33 100644 --- a/OpenUtau/Views/SingersDialog.axaml +++ b/OpenUtau/Views/SingersDialog.axaml @@ -136,6 +136,8 @@ + diff --git a/OpenUtau/Views/SingersDialog.axaml.cs b/OpenUtau/Views/SingersDialog.axaml.cs index f93c76c95..5b9f2f415 100644 --- a/OpenUtau/Views/SingersDialog.axaml.cs +++ b/OpenUtau/Views/SingersDialog.axaml.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Input; @@ -14,6 +15,7 @@ using OpenUtau.Classic; using OpenUtau.Core; using OpenUtau.Core.Ustx; +using OpenUtau.Core.Render; using Serilog; namespace OpenUtau.App.Views { @@ -25,9 +27,23 @@ public partial class SingersDialog : Window, ICmdSubscriber { public SingersDialog() { InitializeComponent(); + PopulateRendererAnalysisMenu(); DocManager.Inst.AddSubscriber(this); } + void PopulateRendererAnalysisMenu() { + RendererAnalysisMenu.Items.Clear(); + foreach (var option in ExternalRendererRegistry.GetAnalysisOptions()) { + var item = new MenuItem { + Header = $"{option.RendererName}: {option.FormatName}", + Tag = option, + }; + item.Click += GenerateRendererAnalysis; + RendererAnalysisMenu.Items.Add(item); + } + RendererAnalysisMenu.IsVisible = RendererAnalysisMenu.Items.Count > 0; + } + protected override void OnClosed(EventArgs e) { base.OnClosed(e); DocManager.Inst.RemoveSubscriber(this); @@ -388,6 +404,35 @@ sender is Control control && } } + async void GenerateRendererAnalysis(object? sender, RoutedEventArgs args) { + if (OtoGrid == null || sender is not Control { Tag: RendererAnalysisOption option }) return; + var files = OtoGrid.SelectedItems.Cast() + .Select(oto => oto.File).Distinct().ToArray(); + if (files.Length == 0) return; + var text = ThemeManager.GetString("singers.editoto.rendereranalysis.generating"); + try { + IReadOnlyList results = Array.Empty(); + await MessageBox.ShowProcessing(this, text, text, (message, cancellation) => { + var progress = new Progress(count => + message.SetText($"{text}\n{count} / {files.Length}")); + results = ExternalRendererRegistry.GenerateAnalysisAsync( + option.RendererId, option.Format, files, true, progress, cancellation) + .GetAwaiter().GetResult(); + }); + var failures = results.Where(result => result.Outcome == RendererAnalysisOutcome.Failed).ToArray(); + if (failures.Length > 0) { + await MessageBox.ShowError(this, new InvalidDataException( + $"{failures.Length} of {results.Count} analysis files failed:\n" + + string.Join("\n", failures.Select(result => + $"{Path.GetFileName(result.Request.SourceFile)}: {result.Message}")))); + } + } catch (OperationCanceledException) { + // Closing the progress dialog is an expected user cancellation. + } catch (Exception exception) { + await MessageBox.ShowError(this, exception); + } + } + void DrawOto(UOto? oto) { if (oto == null) { wavPath = null; From 8f95966e60c4c9a5d4d981cf43b2f88db0b3a028 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Mon, 31 Aug 2026 05:24:49 -0400 Subject: [PATCH 02/11] move examples to seperate repo --- .../Example.ExternalRenderer.csproj | 14 -- .../ExternalRenderer/ExampleRendererPlugin.cs | 134 ------------------ Examples/ExternalRenderer/README.md | 76 ---------- 3 files changed, 224 deletions(-) delete mode 100644 Examples/ExternalRenderer/Example.ExternalRenderer.csproj delete mode 100644 Examples/ExternalRenderer/ExampleRendererPlugin.cs delete mode 100644 Examples/ExternalRenderer/README.md diff --git a/Examples/ExternalRenderer/Example.ExternalRenderer.csproj b/Examples/ExternalRenderer/Example.ExternalRenderer.csproj deleted file mode 100644 index 4cc9fb3c8..000000000 --- a/Examples/ExternalRenderer/Example.ExternalRenderer.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - net10.0 - enable - enable - ..\..\OpenUtau.Core\OpenUtau.Core.csproj - - - - - - - - diff --git a/Examples/ExternalRenderer/ExampleRendererPlugin.cs b/Examples/ExternalRenderer/ExampleRendererPlugin.cs deleted file mode 100644 index 7d6fc3b5c..000000000 --- a/Examples/ExternalRenderer/ExampleRendererPlugin.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Text; -using OpenUtau.Core; -using OpenUtau.Classic; -using OpenUtau.Core.Render; -using OpenUtau.Core.Ustx; - -namespace OpenUtau.Examples.ExternalRenderer; - -[ExternalRenderer("org.openutau.example.external-renderer", "Example External Renderer")] -public sealed class ExampleRendererPlugin : IOpenUtauRendererPlugin { - public int ApiVersion => ExternalRendererRegistry.ApiVersion; - - public RendererPluginMetadata Metadata => new() { - Capabilities = new RendererCapabilitiesManifest { - cancellation = true, - parallelism = 1, - }, - Expressions = new Dictionary { - ["tone"] = new("Tone", "tone", -100, 100, 0) { - type = UExpressionType.Curve, - }, - }, - AnalysisFormats = new Dictionary { - ["example"] = new() { - name = "Example source analysis", - path = "{wav_dir}/{wav_stem}.example-analysis", - canGenerate = true, - }, - }, - }; - - public IRenderer CreateRenderer(RendererPluginContext context) => - new ExampleRenderer(context.RendererName, context.Logger); - - public IRendererAnalysisProvider CreateAnalysisProvider(RendererPluginContext context) => - new ExampleAnalysisProvider(context.Analysis); -} - -/// -/// A deliberately simple renderer which emits a quiet 220 Hz test tone for -/// every phrase. Replace this class with an engine-specific implementation. -/// -public sealed class ExampleRenderer : IRenderer { - readonly string name; - readonly Serilog.ILogger logger; - - public ExampleRenderer(string name, Serilog.ILogger logger) { - this.name = name; - this.logger = logger; - } - - public USingerType SingerType => USingerType.Classic; - public bool SupportsRenderPitch => false; - - public bool SupportsExpression(UExpressionDescriptor descriptor) => - string.Equals(descriptor.abbr, "tone", StringComparison.OrdinalIgnoreCase); - - public RenderResult Layout(RenderPhrase phrase) => new() { - leadingMs = phrase.leadingMs, - positionMs = phrase.positionMs, - estimatedLengthMs = phrase.durationMs + phrase.leadingMs, - }; - - public Task Render( - RenderPhrase phrase, - Progress progress, - int trackNo, - CancellationTokenSource cancellation, - bool isPreRender = false) { - cancellation.Token.ThrowIfCancellationRequested(); - var result = Layout(phrase); - var sampleCount = Math.Max(0, (int)(result.estimatedLengthMs / 1000 * 44100)); - result.samples = new float[sampleCount]; - var toneCurve = phrase.curves.FirstOrDefault(curve => - string.Equals(curve.Item1, "tone", StringComparison.OrdinalIgnoreCase))?.Item2; - for (var i = 0; i < sampleCount; i++) { - if ((i & 4095) == 0) cancellation.Token.ThrowIfCancellationRequested(); - var curveIndex = toneCurve == null || toneCurve.Length == 0 - ? 0 - : Math.Min(toneCurve.Length - 1, (int)((long)i * toneCurve.Length / sampleCount)); - var curveValue = toneCurve == null || toneCurve.Length == 0 ? 0 : toneCurve[curveIndex]; - var frequency = 220 * MathF.Pow(2, curveValue / 1200); - result.samples[i] = 0.1f * MathF.Sin(2 * MathF.PI * frequency * i / 44100); - } - logger.Debug("Rendered example phrase {PhraseHash:x16} for track {TrackNo}", phrase.hash, trackNo); - return Task.FromResult(result); - } - - public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => null!; - - public UExpressionDescriptor[] GetSuggestedExpressions( - USinger singer, - URenderSettings renderSettings) => Array.Empty(); - - public override string ToString() => name; -} - -/// -/// Demonstrates renderer-owned source analysis. Real engines would write their -/// spectral model or frequency map instead of this small descriptive sidecar. -/// -public sealed class ExampleAnalysisProvider : IRendererAnalysisProvider { - readonly RendererAnalysisService analysis; - - public ExampleAnalysisProvider(RendererAnalysisService analysis) { - this.analysis = analysis; - } - - public Task> GenerateAsync( - IReadOnlyList requests, - IProgress progress, - CancellationToken cancellation) { - var results = new List(requests.Count); - for (var i = 0; i < requests.Count; i++) { - var request = requests[i]; - cancellation.ThrowIfCancellationRequested(); - try { - File.WriteAllText(request.OutputFile, - $"source={Path.GetFileName(request.SourceFile)}\n", Encoding.UTF8); - results.Add(new(request, RendererAnalysisOutcome.Generated)); - } catch (Exception error) when (error is not OperationCanceledException) { - results.Add(new(request, RendererAnalysisOutcome.Failed, error.Message)); - } - progress.Report(i + 1); - } - return Task.FromResult>(results); - } - - public ValueTask ValidateAsync( - RendererAnalysisRequest request, CancellationToken cancellation) { - cancellation.ThrowIfCancellationRequested(); - return ValueTask.FromResult(analysis.GetBasicState(request.Format, request.SourceFile)); - } -} diff --git a/Examples/ExternalRenderer/README.md b/Examples/ExternalRenderer/README.md deleted file mode 100644 index e295a7ec4..000000000 --- a/Examples/ExternalRenderer/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# OpenUtau external renderer example - -This is a small but complete external renderer bridge. It demonstrates phrase -layout and rendering, a custom curve, cooperative cancellation, host logging, -renderer-owned source analysis, progress and per-file errors. It produces a -quiet test tone rather than useful singing. The directory is intentionally -self-contained so it can become its own repository. - -## Build - -Inside the OpenUtau repository, the project references `OpenUtau.Core` directly: - -```sh -dotnet build -c Release -``` - -In a standalone checkout, either provide a source checkout: - -```sh -dotnet build -c Release -p:OpenUtauCoreProject=/path/to/OpenUtau/OpenUtau.Core/OpenUtau.Core.csproj -``` - -or compile against `OpenUtau.Core.dll` from the OpenUtau release you target: - -```sh -dotnet build -c Release -p:OpenUtauCorePath=/path/to/OpenUtau.Core.dll -``` - -Copy this file into OpenUtau's `Resamplers` directory: - -- `bin/Release/net10.0/Example.ExternalRenderer.dll` - -Do not copy `OpenUtau.Core.dll`; the host supplies that shared assembly. Restart -OpenUtau, choose a classic singer, and select **Example External Renderer** from -the track's renderer menu. - -The `[ExternalRenderer]` attribute declares the renderer identity, so no YAML -sidecar is needed. Attribute-only discovery instantiates the entrypoint once to -read `ApiVersion` and `Metadata`; keep its constructor and metadata getter fast, -deterministic, and free of engine initialization. Use a YAML manifest when the -host must discover all metadata without executing plugin code. - -## Compatibility and lifecycle - -- API version 1 is a preview contract. A host only loads a plugin when its - `ApiVersion` exactly matches the host's supported version. -- Build against the oldest OpenUtau release you intend to support and test the - resulting DLL against every supported release. Do not distribute - `OpenUtau.Core.dll`; the host supplies its shared copy. -- OpenUtau creates fresh entrypoint, analysis-provider and renderer instances. - Any of them may implement `IDisposable`; dispose native handles and unmanaged - state there. Do not retain host objects in static fields. -- `Render` may run away from the UI thread. Observe the supplied cancellation - token and honor declared `parallelism`; do not access Avalonia UI objects. -- Return renderer-owned failures as analysis results when one source fails. - Throw cancellation and fatal initialization errors. OpenUtau records discovery - failures in its log and continues loading other renderers. -- `RendererAnalysisService` owns analysis paths and basic freshness checks. - `RendererCacheService` is for final phrase outputs, not intermediate wavtool - files. A renderer should keep temporary phrase state in memory. - -Metadata declared in C# is authoritative for an attribute-only plugin. For a -YAML-discovered plugin, YAML metadata is authoritative and the runtime renderer -must agree with the declared capabilities and expression definitions. - -## Packaging native engines - -A pure C# renderer only needs its bridge DLL and private managed dependencies. -For a native engine, ship or extract the correct `.dll`, `.so`, or `.dylib` for -each supported runtime, resolve it relative to `context.PluginDirectory`, and -release its handle from `Dispose`. Keep architecture-specific files in distinct -subdirectories to avoid filename collisions. - -The example analysis format is optional, so it appears in the singer dialog's -analysis-generation menu but is not required before rendering. Set `required` -only when rendering cannot proceed without a valid analysis file. From 437d7852bd3b19581d8e0dae85e648a5064b048a Mon Sep 17 00:00:00 2001 From: Astel123457 <41224955+Astel123457@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:31 -0400 Subject: [PATCH 03/11] fix possible issue where external renderers aren't set as default Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Render/ExternalRendererRegistry.cs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs index 8f7343238..6f56ac6b5 100644 --- a/OpenUtau.Core/Render/ExternalRendererRegistry.cs +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -194,15 +194,19 @@ static bool HasExternalRendererAttribute(string path, out Exception error) { owner = reader.GetMethodDefinition( (MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); } - string name = owner.Kind switch { - HandleKind.TypeReference => reader.GetString( - reader.GetTypeReference((TypeReferenceHandle)owner).Name), - HandleKind.TypeDefinition => reader.GetString( - reader.GetTypeDefinition((TypeDefinitionHandle)owner).Name), - _ => string.Empty, + (string name, string ns) = owner.Kind switch { + HandleKind.TypeReference => ( + reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Name), + reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Namespace)), + HandleKind.TypeDefinition => ( + reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Name), + reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Namespace)), + _ => (string.Empty, string.Empty), }; - if (name == nameof(ExternalRendererAttribute)) return true; - } + if (name == nameof(ExternalRendererAttribute) && + ns == typeof(ExternalRendererAttribute).Namespace) { + return true; + } } return false; } catch (BadImageFormatException) { From dc51fc159f814392e4c8b46d649800b5b41bd276 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Mon, 31 Aug 2026 16:17:09 -0400 Subject: [PATCH 04/11] Fix external renderer metadata inspection build --- .../Render/ExternalRendererRegistry.cs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs index 6f56ac6b5..1586bab45 100644 --- a/OpenUtau.Core/Render/ExternalRendererRegistry.cs +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -194,19 +194,20 @@ static bool HasExternalRendererAttribute(string path, out Exception error) { owner = reader.GetMethodDefinition( (MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); } - (string name, string ns) = owner.Kind switch { - HandleKind.TypeReference => ( - reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Name), - reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Namespace)), - HandleKind.TypeDefinition => ( - reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Name), - reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Namespace)), - _ => (string.Empty, string.Empty), + (string name, string ns) = owner.Kind switch { + HandleKind.TypeReference => ( + reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Name), + reader.GetString(reader.GetTypeReference((TypeReferenceHandle)owner).Namespace)), + HandleKind.TypeDefinition => ( + reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Name), + reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)owner).Namespace)), + _ => (string.Empty, string.Empty), }; - if (name == nameof(ExternalRendererAttribute) && - ns == typeof(ExternalRendererAttribute).Namespace) { - return true; - } + if (name == nameof(ExternalRendererAttribute) && + ns == typeof(ExternalRendererAttribute).Namespace) { + return true; + } + } } return false; } catch (BadImageFormatException) { From 1667bef61526ef818868ffe598309621cc59cb64 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Mon, 31 Aug 2026 16:25:36 -0400 Subject: [PATCH 05/11] Avoid locking renderer bridge assemblies on Windows --- OpenUtau.Core/Render/ExternalRendererRegistry.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs index 1586bab45..e8363a349 100644 --- a/OpenUtau.Core/Render/ExternalRendererRegistry.cs +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -137,7 +137,7 @@ static void DiscoverAssembly( RendererLoadContext loadContext = null; try { loadContext = new RendererLoadContext(path); - var assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(path)); + var assembly = loadContext.LoadPluginAssembly(path); foreach (var type in assembly.GetExportedTypes()) { var attribute = type.GetCustomAttribute(); if (attribute == null || type.IsAbstract @@ -520,7 +520,7 @@ static IRenderer LoadRenderer(ExternalRendererDescriptor descriptor) { } var loadContext = new RendererLoadContext(assemblyPath); try { - var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); + var assembly = loadContext.LoadPluginAssembly(assemblyPath); var type = assembly.GetType(descriptor.TypeName ?? bridge.type, throwOnError: true)!; if (Activator.CreateInstance(type) is not IOpenUtauRendererPlugin plugin) { throw new InvalidCastException( @@ -654,6 +654,14 @@ public RendererLoadContext(string pluginPath) : base(isCollectible: true) { resolver = new AssemblyDependencyResolver(pluginPath); } + public Assembly LoadPluginAssembly(string path) { + // Loading from a path keeps the bridge DLL locked on Windows until + // the collectible context is finalized. A stream preserves normal + // dependency resolution while allowing immediate plugin updates. + using var stream = new MemoryStream(File.ReadAllBytes(Path.GetFullPath(path)), writable: false); + return LoadFromStream(stream); + } + protected override Assembly Load(AssemblyName assemblyName) { if (sharedAssemblyNames.Contains(assemblyName.Name ?? string.Empty)) { return Default.Assemblies.FirstOrDefault(assembly => From 8f3f9ed5f733b9af57b9fdf145c2993056b1e833 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Tue, 1 Sep 2026 02:33:32 -0400 Subject: [PATCH 06/11] Retry CI after xUnit runner failure From c833f91a5cf5f71efc13556566c404136782df61 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Tue, 1 Sep 2026 02:40:29 -0400 Subject: [PATCH 07/11] Support renderer phrase events --- OpenUtau.Core/Classic/ClassicRenderer.cs | 4 +++- OpenUtau.Core/Render/ExternalRendererRegistry.cs | 11 +++++++++-- OpenUtau.Test/Render/ExternalRendererRegistryTest.cs | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/OpenUtau.Core/Classic/ClassicRenderer.cs b/OpenUtau.Core/Classic/ClassicRenderer.cs index 54c15d3e3..8a79c6753 100644 --- a/OpenUtau.Core/Classic/ClassicRenderer.cs +++ b/OpenUtau.Core/Classic/ClassicRenderer.cs @@ -56,7 +56,9 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, + CancellationTokenSource cancellation, bool isPreRender, + RenderPhraseEvents? renderEvents = null) { if (resamplerOverride != null || phrase.wavtool == SharpWavtool.nameConvergence || phrase.wavtool == SharpWavtool.nameSimple) { return RenderInternal(phrase, progress, trackNo, cancellation, isPreRender); } else { diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs index e8363a349..46a45b343 100644 --- a/OpenUtau.Core/Render/ExternalRendererRegistry.cs +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -575,7 +575,8 @@ public bool SupportsExpression(UExpressionDescriptor expression) => renderer.SupportsExpression(expression); public RenderResult Layout(RenderPhrase phrase) => renderer.Layout(phrase); public async Task Render(RenderPhrase phrase, Progress progress, int trackNo, - CancellationTokenSource cancellation, bool isPreRender = false) { + CancellationTokenSource cancellation, bool isPreRender = false, + RenderPhraseEvents? renderEvents = null) { cancellation.Token.ThrowIfCancellationRequested(); if (renderSlots != null) await renderSlots.WaitAsync(cancellation.Token); try { @@ -589,7 +590,8 @@ await PrepareRequiredAnalysisAsync(analysisProvider, context, sources, } finally { analysisLock.Release(); } - var result = await renderer.Render(phrase, progress, trackNo, cancellation, isPreRender); + var result = await renderer.Render( + phrase, progress, trackNo, cancellation, isPreRender, renderEvents); cancellation.Token.ThrowIfCancellationRequested(); return result; } finally { @@ -597,7 +599,12 @@ await PrepareRequiredAnalysisAsync(analysisProvider, context, sources, } } public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => renderer.LoadRenderedPitch(phrase); + public RenderPitchResult LoadRenderedPitch( + RenderPhrase phrase, HashSet selectedNotePositions) => + renderer.LoadRenderedPitch(phrase, selectedNotePositions); public List LoadRenderedRealCurves(RenderPhrase phrase) => renderer.LoadRenderedRealCurves(phrase); + public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) => + renderer.ScheduleRealCurveRefresh(project, part, command); public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings settings) { var declared = descriptor.Metadata.Expressions.Values.ToDictionary( expression => expression.abbr, StringComparer.OrdinalIgnoreCase); diff --git a/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs index 0919ce277..32fdac2e2 100644 --- a/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs +++ b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs @@ -509,7 +509,8 @@ public sealed class TestRenderer : IRenderer { public bool SupportsExpression(UExpressionDescriptor descriptor) => false; public RenderResult Layout(RenderPhrase phrase) => new(); public Task Render(RenderPhrase phrase, Progress progress, int trackNo, - CancellationTokenSource cancellation, bool isPreRender = false) => + CancellationTokenSource cancellation, bool isPreRender = false, + RenderPhraseEvents renderEvents = null) => Task.FromResult(new RenderResult { samples = Array.Empty() }); public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) => null; public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings) => From c0f41052213514f30d9e5e6c0031eb719769fb5e Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Sun, 6 Sep 2026 04:35:40 -0400 Subject: [PATCH 08/11] add track settings dialog --- OpenUtau.Core/Commands/TrackCommands.cs | 10 ++- .../Render/ExternalRendererPlugin.cs | 15 ++++ .../Render/ExternalRendererRegistry.cs | 38 +++++++++- OpenUtau.Core/Render/RenderPhrase.cs | 8 +++ OpenUtau.Core/Ustx/UProject.cs | 3 +- OpenUtau.Core/Ustx/UTrack.cs | 24 +++++++ OpenUtau/ViewModels/ExpSelectorViewModel.cs | 3 +- OpenUtau/ViewModels/TrackSettingsViewModel.cs | 71 +++++++++++++++++-- OpenUtau/Views/TrackSettingsDialog.axaml | 17 ++++- 9 files changed, 177 insertions(+), 12 deletions(-) diff --git a/OpenUtau.Core/Commands/TrackCommands.cs b/OpenUtau.Core/Commands/TrackCommands.cs index fcd1ff551..386879e3c 100644 --- a/OpenUtau.Core/Commands/TrackCommands.cs +++ b/OpenUtau.Core/Commands/TrackCommands.cs @@ -156,12 +156,20 @@ public TrackChangeRenderSettingCommand(UProject project, UTrack track, URenderSe public override void Execute() { var settings = newSettings.Clone(); settings.Validate(track, fallbackUnavailableRenderer: false); - track.RendererSettings = settings; + ReplaceSettings(settings); } public override void Unexecute() { var settings = oldSettings.Clone(); settings.Validate(track); + ReplaceSettings(settings); + } + void ReplaceSettings(URenderSettings settings) { + if (!ReferenceEquals(track.RendererSettings.Renderer, settings.Renderer) && + track.RendererSettings.Renderer is IDisposable disposable) { + disposable.Dispose(); + } track.RendererSettings = settings; + track.RegisterRendererExpressions(project); } } } diff --git a/OpenUtau.Core/Render/ExternalRendererPlugin.cs b/OpenUtau.Core/Render/ExternalRendererPlugin.cs index 1c41e99cc..94a883c7b 100644 --- a/OpenUtau.Core/Render/ExternalRendererPlugin.cs +++ b/OpenUtau.Core/Render/ExternalRendererPlugin.cs @@ -34,6 +34,21 @@ public sealed class RendererPluginMetadata { = new Dictionary(); public IReadOnlyDictionary Expressions { get; init; } = new Dictionary(); + public IReadOnlyDictionary Settings { get; init; } + = new Dictionary(); + } + + public enum RendererSettingType { Integer, Number, Boolean, Text, Choice } + + public sealed class RendererSettingDescriptor { + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public RendererSettingType Type { get; init; } = RendererSettingType.Text; + public string DefaultValue { get; init; } = string.Empty; + public double? Min { get; init; } + public double? Max { get; init; } + public double? Step { get; init; } + public IReadOnlyList Choices { get; init; } = Array.Empty(); } /// diff --git a/OpenUtau.Core/Render/ExternalRendererRegistry.cs b/OpenUtau.Core/Render/ExternalRendererRegistry.cs index 46a45b343..996b975f7 100644 --- a/OpenUtau.Core/Render/ExternalRendererRegistry.cs +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -313,9 +313,15 @@ static async Task> RunProviderAsync( if (requests.Count == 0) return Array.Empty(); var results = await provider.GenerateAsync(requests, progress, cancellation) ?? Array.Empty(); - var byOutput = results.GroupBy(result => Path.GetFullPath(result.Request.OutputFile), + var byOutput = results + .Where(result => result?.Request != null && + !string.IsNullOrWhiteSpace(result.Request.OutputFile)) + .Select(result => (Result: result, Output: TryGetFullPath(result.Request.OutputFile))) + .Where(item => item.Output != null) + .GroupBy(item => item.Output!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + .ToDictionary(group => group.Key, group => group.First().Result, + StringComparer.OrdinalIgnoreCase); return requests.Select(request => { if (!byOutput.TryGetValue(Path.GetFullPath(request.OutputFile), out var result)) { return new RendererAnalysisResult(request, RendererAnalysisOutcome.Failed, @@ -330,6 +336,15 @@ static async Task> RunProviderAsync( }).ToArray(); } + static string? TryGetFullPath(string path) { + try { + return Path.GetFullPath(path); + } catch (Exception exception) when (exception is ArgumentException or + NotSupportedException or PathTooLongException) { + return null; + } + } + public static async Task PrepareRequiredAnalysisAsync( string rendererId, IReadOnlyList sourceFiles, @@ -453,6 +468,23 @@ static void ValidateMetadata(RendererPluginMetadata metadata, string origin) { $"Renderer analysis format '{pair.Key}' has no path in {origin}."); } } + foreach (var pair in metadata.Settings) { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value == null || + string.IsNullOrWhiteSpace(pair.Value.Name)) { + throw new InvalidDataException( + $"Renderer setting has no key, descriptor, or name in {origin}."); + } + if (pair.Value.Type == RendererSettingType.Choice && + (pair.Value.Choices.Count == 0 || + !pair.Value.Choices.Contains(pair.Value.DefaultValue))) { + throw new InvalidDataException( + $"Renderer choice setting '{pair.Key}' has an invalid default in {origin}."); + } + if (pair.Value.Min > pair.Value.Max) { + throw new InvalidDataException( + $"Renderer setting '{pair.Key}' has min greater than max in {origin}."); + } + } if (metadata.Capabilities.parallelism < 0) { throw new InvalidDataException( $"Renderer declares negative parallelism in {origin}."); @@ -465,6 +497,8 @@ static void ValidateMetadata(RendererPluginMetadata metadata, string origin) { ?? new Dictionary(), Expressions = metadata?.Expressions ?? new Dictionary(), + Settings = metadata?.Settings + ?? new Dictionary(), }; static IRenderer LoadRenderer(ExternalRendererDescriptor descriptor) { diff --git a/OpenUtau.Core/Render/RenderPhrase.cs b/OpenUtau.Core/Render/RenderPhrase.cs index d00810caa..7488b72d0 100644 --- a/OpenUtau.Core/Render/RenderPhrase.cs +++ b/OpenUtau.Core/Render/RenderPhrase.cs @@ -193,6 +193,7 @@ public class RenderPhrase { internal readonly IRenderer renderer; public readonly string wavtool; + public readonly IReadOnlyDictionary rendererSettings; private List cacheFiles = new List(); @@ -221,6 +222,8 @@ internal RenderPhrase(UProject project, UTrack track, UVoicePart part, IEnumerab singer = track.Singer; renderer = track.RendererSettings.Renderer; wavtool = track.RendererSettings.wavtool; + rendererSettings = new Dictionary(track.RendererSettings.rendererSettings ?? + new Dictionary()); timeAxis = project.timeAxis.Clone(); position = part.position + phonemes.First().position; @@ -493,6 +496,11 @@ private ulong Hash(bool postEffect) { writer.Write(singer.Id); writer.Write(renderer == null ? "" : Renderers.GetRendererId(renderer)); writer.Write(wavtool ?? ""); + foreach (var setting in rendererSettings.OrderBy(pair => pair.Key, + StringComparer.Ordinal)) { + writer.Write(setting.Key); + writer.Write(setting.Value ?? string.Empty); + } writer.Write(timeAxis.Timestamp); foreach (var phone in phones) { writer.Write(phone.hash); diff --git a/OpenUtau.Core/Ustx/UProject.cs b/OpenUtau.Core/Ustx/UProject.cs index 1ed36edee..b25cf2392 100644 --- a/OpenUtau.Core/Ustx/UProject.cs +++ b/OpenUtau.Core/Ustx/UProject.cs @@ -80,7 +80,8 @@ public UProject() { } public void RegisterExpression(UExpressionDescriptor descriptor) { - if (!expressions.ContainsKey(descriptor.abbr)) { + if (!expressions.Keys.Any(abbr => string.Equals( + abbr, descriptor.abbr, StringComparison.OrdinalIgnoreCase))) { expressions.Add(descriptor.abbr, descriptor); } } diff --git a/OpenUtau.Core/Ustx/UTrack.cs b/OpenUtau.Core/Ustx/UTrack.cs index f5d2d6564..34543d92b 100644 --- a/OpenUtau.Core/Ustx/UTrack.cs +++ b/OpenUtau.Core/Ustx/UTrack.cs @@ -12,6 +12,7 @@ public class URenderSettings { public string renderer; public string resampler; public string wavtool; + public Dictionary rendererSettings = new Dictionary(); [YamlIgnore] public IRenderer Renderer { get; set; } [YamlIgnore] public IResampler Resampler { get; set; } @@ -19,6 +20,7 @@ public class URenderSettings { [YamlIgnore] public string RendererLoadError { get; private set; } public void Validate(UTrack track, bool fallbackUnavailableRenderer = true) { + rendererSettings ??= new Dictionary(); if (track.Singer == null || !track.Singer.Found) { renderer = null; Renderer = null; @@ -83,6 +85,14 @@ public void Validate(UTrack track, bool fallbackUnavailableRenderer = true) { } else { wavtool = null; Wavtool = null; + var descriptor = ExternalRendererRegistry.Renderers.FirstOrDefault(item => + string.Equals(item.Id, Renderers.GetRendererId(Renderer), + StringComparison.OrdinalIgnoreCase)); + if (descriptor != null) { + foreach (var setting in descriptor.Metadata.Settings) { + rendererSettings.TryAdd(setting.Key, setting.Value.DefaultValue); + } + } } } @@ -91,6 +101,7 @@ public URenderSettings Clone() { renderer = renderer, resampler = resampler, wavtool = wavtool, + rendererSettings = new Dictionary(rendererSettings ?? new Dictionary()), }; } } @@ -191,6 +202,7 @@ public void Validate(ValidateOptions options, UProject project) { RendererSettings = new URenderSettings(); } RendererSettings.Validate(this); + RegisterRendererExpressions(project); if (project.expressions.TryGetValue(Format.Ustx.CLR, out var descriptor)) { if (VoiceColorExp == null && Singer != null && Singer.Found && Singer.Loaded) { VoiceColorExp = descriptor.Clone(); @@ -201,6 +213,18 @@ public void Validate(ValidateOptions options, UProject project) { } } + public void RegisterRendererExpressions(UProject project) { + if (RendererSettings.Renderer == null || Singer == null || !Singer.Found) { + return; + } + foreach (var descriptor in RendererSettings.Renderer.GetSuggestedExpressions( + Singer, RendererSettings) ?? Array.Empty()) { + if (descriptor != null && !string.IsNullOrWhiteSpace(descriptor.abbr)) { + project.RegisterExpression(descriptor); + } + } + } + public bool ValidateVoiceColor(out string[] oldColors, out string[] newColors) { bool discrepancy = false; oldColors = VoiceColorNames.ToArray(); diff --git a/OpenUtau/ViewModels/ExpSelectorViewModel.cs b/OpenUtau/ViewModels/ExpSelectorViewModel.cs index d14b2836c..2144416d2 100644 --- a/OpenUtau/ViewModels/ExpSelectorViewModel.cs +++ b/OpenUtau/ViewModels/ExpSelectorViewModel.cs @@ -88,7 +88,8 @@ void SelectionChanged(UExpressionDescriptor? descriptor) { public void OnNext(UCommand cmd, bool isUndo) { if (cmd is LoadProjectNotification || cmd is LoadPartNotification || - cmd is ConfigureExpressionsCommand) { + cmd is ConfigureExpressionsCommand || + cmd is TrackChangeRenderSettingCommand) { OnListChange(); } else if (cmd is SelectExpressionNotification) { OnSelectExp((SelectExpressionNotification)cmd); diff --git a/OpenUtau/ViewModels/TrackSettingsViewModel.cs b/OpenUtau/ViewModels/TrackSettingsViewModel.cs index e14c88df4..d8b02f1d2 100644 --- a/OpenUtau/ViewModels/TrackSettingsViewModel.cs +++ b/OpenUtau/ViewModels/TrackSettingsViewModel.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using System.Globalization; +using System.Collections.Generic; using DynamicData.Binding; using OpenUtau.Classic; using OpenUtau.Core; @@ -20,6 +22,8 @@ partial class TrackSettingsViewModel : ViewModelBase { [Reactive] public partial IWavtool? Wavtool { get; set; } [Reactive] public partial bool NeedsWavtool { get; set; } [Reactive] public partial bool IsNotClassic { get; set; } + public ObservableCollectionExtended RendererSettings { get; } = new(); + [Reactive] public partial bool HasRendererSettings { get; set; } ObservableCollectionExtended resamplers = new ObservableCollectionExtended(); @@ -49,7 +53,18 @@ public TrackSettingsViewModel(UTrack track) { Wavtool = ToolsManager.Inst.GetWavtool(wavtoolName); NeedsResampler = Renderers.CLASSIC == renderer; NeedsWavtool = Renderers.CLASSIC == renderer; - IsNotClassic = Renderers.CLASSIC != renderer; + var metadata = ExternalRendererRegistry.Renderers + .FirstOrDefault(item => string.Equals(item.Id, renderer, + StringComparison.OrdinalIgnoreCase))?.Metadata; + if (metadata != null) { + foreach (var pair in metadata.Settings) { + Track.RendererSettings.rendererSettings.TryGetValue(pair.Key, out var value); + RendererSettings.Add(new RendererSettingViewModel( + pair.Key, pair.Value, value ?? pair.Value.DefaultValue)); + } + } + HasRendererSettings = RendererSettings.Count > 0; + IsNotClassic = Renderers.CLASSIC != renderer && !HasRendererSettings; } this.WhenAnyValue(x => x.Resampler) .OfType() @@ -94,15 +109,59 @@ public void SetDefaultWavtool() { } public void Finish() { - if (Renderers.CLASSIC != Track.RendererSettings.renderer) { - return; - } DocManager.Inst.StartUndoGroup("command.track.setting"); var settings = Track.RendererSettings.Clone(); - settings.resampler = Resampler?.ToString() ?? string.Empty; - settings.wavtool = Wavtool?.ToString() ?? string.Empty; + if (Renderers.CLASSIC == Track.RendererSettings.renderer) { + settings.resampler = Resampler?.ToString() ?? string.Empty; + settings.wavtool = Wavtool?.ToString() ?? string.Empty; + } + settings.rendererSettings = RendererSettings.ToDictionary(row => row.Key, row => row.Value); DocManager.Inst.ExecuteCmd(new TrackChangeRenderSettingCommand(DocManager.Inst.Project, Track, settings)); DocManager.Inst.EndUndoGroup(); } } + + public sealed class RendererSettingViewModel { + public string Key { get; } + public string Name { get; } + public string Description { get; } + public bool IsBoolean { get; } + public bool IsNumber { get; } + public bool IsInteger { get; } + public bool IsChoice { get; } + public bool IsText => !IsBoolean && !IsNumber && !IsChoice; + public decimal Minimum { get; } + public decimal Maximum { get; } + public IReadOnlyList Choices { get; } + public bool BoolValue { get; set; } + public string NumberText { get; set; } + public string TextValue { get; set; } + public string ChoiceValue { get; set; } + public string Value => IsBoolean ? BoolValue.ToString().ToLowerInvariant() : + IsNumber ? NormalizeNumber() : + IsChoice ? ChoiceValue : TextValue; + + public RendererSettingViewModel(string key, RendererSettingDescriptor descriptor, string value) { + Key = key; Name = descriptor.Name; Description = descriptor.Description; + IsBoolean = descriptor.Type == RendererSettingType.Boolean; + IsNumber = descriptor.Type is RendererSettingType.Integer or RendererSettingType.Number; + IsInteger = descriptor.Type == RendererSettingType.Integer; + IsChoice = descriptor.Type == RendererSettingType.Choice; + Minimum = (decimal)(descriptor.Min ?? -1000000); + Maximum = (decimal)(descriptor.Max ?? 1000000); + Choices = descriptor.Choices; + BoolValue = bool.TryParse(value, out var boolean) && boolean; + NumberText = value; + TextValue = value; + ChoiceValue = Choices.Contains(value) ? value : descriptor.DefaultValue; + } + + string NormalizeNumber() { + if (!decimal.TryParse(NumberText, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) { + number = 0; + } + if (IsInteger) number = decimal.Round(number, 0); + return Math.Clamp(number, Minimum, Maximum).ToString(CultureInfo.InvariantCulture); + } + } } diff --git a/OpenUtau/Views/TrackSettingsDialog.axaml b/OpenUtau/Views/TrackSettingsDialog.axaml index 6af201e64..b84624c37 100644 --- a/OpenUtau/Views/TrackSettingsDialog.axaml +++ b/OpenUtau/Views/TrackSettingsDialog.axaml @@ -6,10 +6,25 @@ x:Class="OpenUtau.App.Views.TrackSettingsDialog" Icon="/Assets/open-utau.ico" Title="{DynamicResource dialogs.tracksettings.caption}" - Height="184" Width="320" WindowStartupLocation="CenterOwner" CanResize="False"> + MinHeight="184" MaxHeight="640" Width="360" SizeToContent="Height" + WindowStartupLocation="CenterOwner" CanResize="False"> + + + + + + + + + + + + + Date: Sun, 6 Sep 2026 04:41:35 -0400 Subject: [PATCH 09/11] fix issue with a missing curly brace --- OpenUtau/Views/SingersDialog.axaml.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/OpenUtau/Views/SingersDialog.axaml.cs b/OpenUtau/Views/SingersDialog.axaml.cs index a08faf534..7610a48df 100644 --- a/OpenUtau/Views/SingersDialog.axaml.cs +++ b/OpenUtau/Views/SingersDialog.axaml.cs @@ -423,11 +423,6 @@ sender is Control control && }, scheduler); } } - async void DrawOto(UOto? oto) { - _otoLoadCts?.Cancel(); - _otoLoadCts = new CancellationTokenSource(); - var token = _otoLoadCts.Token; - async void GenerateRendererAnalysis(object? sender, RoutedEventArgs args) { if (OtoGrid == null || sender is not Control { Tag: RendererAnalysisOption option }) return; var files = OtoGrid.SelectedItems.Cast() @@ -457,7 +452,11 @@ await MessageBox.ShowProcessing(this, text, text, (message, cancellation) => { } } - void DrawOto(UOto? oto) { + async void DrawOto(UOto? oto) { + _otoLoadCts?.Cancel(); + _otoLoadCts = new CancellationTokenSource(); + var token = _otoLoadCts.Token; + if (oto == null) { wavPath = null; wav = null; From ac23fe3027db7c62f2582906ed4eb60406654285 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Wed, 9 Sep 2026 20:23:48 -0400 Subject: [PATCH 10/11] Remove trailing whitespace after upstream merge --- OpenUtau.Core/Classic/ClassicSinger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenUtau.Core/Classic/ClassicSinger.cs b/OpenUtau.Core/Classic/ClassicSinger.cs index d4d3b8f70..fd2612e12 100644 --- a/OpenUtau.Core/Classic/ClassicSinger.cs +++ b/OpenUtau.Core/Classic/ClassicSinger.cs @@ -161,7 +161,7 @@ public override void Save() { otoWatcher.Paused = false; } } - + public void Dispose() { otoWatcher?.Dispose(); otoWatcher = null; From 9a3683580a40e9f600ee0096efdb32c0d6f75308 Mon Sep 17 00:00:00 2001 From: Astel123457 Date: Wed, 9 Sep 2026 20:30:18 -0400 Subject: [PATCH 11/11] Rerun CI after flaky phonemizer tests