diff --git a/OpenUtau.Core/Classic/ClassicRenderer.cs b/OpenUtau.Core/Classic/ClassicRenderer.cs index 7f19360da..59017b6eb 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, @@ -49,8 +58,10 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { - if (phrase.wavtool == SharpWavtool.nameConvergence || phrase.wavtool == SharpWavtool.nameSimple) { + 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 { return RenderExternal(phrase, progress, trackNo, cancellation, isPreRender); @@ -60,7 +71,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 +110,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))}\""; @@ -140,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/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; 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 14b472c4b..23d30f018 100644 --- a/OpenUtau.Core/Commands/TrackCommands.cs +++ b/OpenUtau.Core/Commands/TrackCommands.cs @@ -178,12 +178,22 @@ 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); + ReplaceSettings(settings); } public override void Unexecute() { - track.RendererSettings = oldSettings.Clone(); - track.RendererSettings.Validate(track); + 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/Pipeline/PhraseSource.cs b/OpenUtau.Core/Pipeline/PhraseSource.cs index 30e84a2d7..219f96bd2 100644 --- a/OpenUtau.Core/Pipeline/PhraseSource.cs +++ b/OpenUtau.Core/Pipeline/PhraseSource.cs @@ -322,6 +322,7 @@ public sealed class PhraseSource { public readonly IRenderer Renderer; public readonly string Resampler; public readonly string Wavtool; + public readonly IReadOnlyDictionary RendererSettings; public readonly ClassicSinger ClassicSinger; public readonly bool ModpSupported; @@ -348,6 +349,8 @@ internal PhraseSource( Renderer = track.RendererSettings.Renderer; Resampler = track.RendererSettings.resampler; Wavtool = track.RendererSettings.wavtool; + RendererSettings = new Dictionary( + track.RendererSettings.rendererSettings ?? new Dictionary()); ClassicSinger = Singer as ClassicSinger; ModpSupported = track.TryGetExpDescriptor(project, Format.Ustx.MODP, out var modp) && Renderer.SupportsExpression(modp); diff --git a/OpenUtau.Core/Render/ExternalRendererPlugin.cs b/OpenUtau.Core/Render/ExternalRendererPlugin.cs new file mode 100644 index 000000000..94a883c7b --- /dev/null +++ b/OpenUtau.Core/Render/ExternalRendererPlugin.cs @@ -0,0 +1,184 @@ +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(); + 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(); + } + + /// + /// 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..996b975f7 --- /dev/null +++ b/OpenUtau.Core/Render/ExternalRendererRegistry.cs @@ -0,0 +1,722 @@ +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.LoadPluginAssembly(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, 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; + } + } + } + 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 + .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().Result, + 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(); + } + + 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, + 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}."); + } + } + 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}."); + } + } + + static RendererPluginMetadata NormalizeMetadata(RendererPluginMetadata? metadata) => new() { + Capabilities = metadata?.Capabilities ?? new RendererCapabilitiesManifest(), + AnalysisFormats = metadata?.AnalysisFormats + ?? new Dictionary(), + Expressions = metadata?.Expressions + ?? new Dictionary(), + Settings = metadata?.Settings + ?? 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.LoadPluginAssembly(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, + RenderPhraseEvents? renderEvents = null) { + 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, renderEvents); + cancellation.Token.ThrowIfCancellationRequested(); + return result; + } finally { + renderSlots?.Release(); + } + } + 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); + 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); + } + + 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 => + 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 2a51a1715..3f3832b1e 100644 --- a/OpenUtau.Core/Render/RenderPhrase.cs +++ b/OpenUtau.Core/Render/RenderPhrase.cs @@ -196,6 +196,7 @@ public class RenderPhrase { internal readonly IRenderer renderer; public readonly string wavtool; + public readonly IReadOnlyDictionary rendererSettings; /// /// The [startMs, endMs) range (absolute ms) of the rendered phrase @@ -242,6 +243,7 @@ internal RenderPhrase(Pipeline.PhraseSource source, int phraseStart, int phraseE singer = source.Singer; renderer = source.Renderer; wavtool = source.Wavtool; + rendererSettings = source.RendererSettings; timeAxis = source.Axis; position = source.PartPosition + phrasePhonemes.First().Position; @@ -520,8 +522,13 @@ 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 ?? ""); + 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/Render/Renderers.cs b/OpenUtau.Core/Render/Renderers.cs index 64ff7f30f..044302dd5 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); } // One instance per renderer id. Renderers are stateless or globally 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 796014879..4be8f7281 100644 --- a/OpenUtau.Core/Ustx/UTrack.cs +++ b/OpenUtau.Core/Ustx/UTrack.cs @@ -12,12 +12,15 @@ 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; } [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) { + rendererSettings ??= new Dictionary(); if (track.Singer == null || !track.Singer.Found) { renderer = null; Renderer = null; @@ -25,15 +28,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.GetOrCreate(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; @@ -55,6 +85,14 @@ public void Validate(UTrack track) { } 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); + } + } } } @@ -63,6 +101,7 @@ public URenderSettings Clone() { renderer = renderer, resampler = resampler, wavtool = wavtool, + rendererSettings = new Dictionary(rendererSettings ?? new Dictionary()), }; } } @@ -170,6 +209,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(); @@ -190,6 +230,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.Test/Render/ExternalRendererRegistryTest.cs b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs new file mode 100644 index 000000000..32fdac2e2 --- /dev/null +++ b/OpenUtau.Test/Render/ExternalRendererRegistryTest.cs @@ -0,0 +1,522 @@ +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, + RenderPhraseEvents renderEvents = null) => + 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 c363785ac..c1727e30c 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -754,6 +754,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/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/TrackHeaderViewModel.cs b/OpenUtau/ViewModels/TrackHeaderViewModel.cs index 515186468..d7b92af80 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)); }); @@ -489,11 +496,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/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/SingersDialog.axaml b/OpenUtau/Views/SingersDialog.axaml index 5dd3594b0..d2ea6e36f 100644 --- a/OpenUtau/Views/SingersDialog.axaml +++ b/OpenUtau/Views/SingersDialog.axaml @@ -141,6 +141,8 @@ + diff --git a/OpenUtau/Views/SingersDialog.axaml.cs b/OpenUtau/Views/SingersDialog.axaml.cs index 5f22b1128..7610a48df 100644 --- a/OpenUtau/Views/SingersDialog.axaml.cs +++ b/OpenUtau/Views/SingersDialog.axaml.cs @@ -15,6 +15,7 @@ using OpenUtau.Classic; using OpenUtau.Core; using OpenUtau.Core.Ustx; +using OpenUtau.Core.Render; using Serilog; namespace OpenUtau.App.Views { @@ -26,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); @@ -408,6 +423,35 @@ sender is Control control && }, scheduler); } } + 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); + } + } + async void DrawOto(UOto? oto) { _otoLoadCts?.Cancel(); _otoLoadCts = new CancellationTokenSource(); 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"> + + + + + + + + + + + + +