diff --git a/OpenUtau.Core/DiffSinger/DiffSingerBasePhonemizer.cs b/OpenUtau.Core/DiffSinger/DiffSingerBasePhonemizer.cs index 0905a1fd..877513a2 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerBasePhonemizer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerBasePhonemizer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -49,10 +49,11 @@ private bool _executeSetSinger(USinger singer) { if (singer == null) { throw new Exception("Singer is null."); } - if(singer.Location == null){ - throw new Exception("Singer location is null."); + if (File.Exists(Path.Join(singer.Location, "dsdur", "dsconfig.yaml"))) { + rootPath = Path.Combine(singer.Location, "dsdur"); + } else { + rootPath = singer.Location; } - rootPath = Path.Combine(singer.Location, "dsdur"); //Load Config var configPath = Path.Join(rootPath, "dsconfig.yaml"); try { @@ -207,9 +208,10 @@ string GetSpeakerAtIndex(Note note, int index) { speaker = singer.Subbanks.FirstOrDefault(); } if (speaker is null) { - throw new Exception( - $"No subbanks defined for singer \"{singer.Name}\". " + - "Please check the singer's configuration."); + if (dsConfig.speakers != null && dsConfig.speakers.Count > 0) { + return dsConfig.speakers[0]; + } + return ""; } return speaker.Suffix; } diff --git a/OpenUtau.Core/DiffSinger/DiffSingerSpeakerEmbedManager.cs b/OpenUtau.Core/DiffSinger/DiffSingerSpeakerEmbedManager.cs index 876874ab..9123d7df 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerSpeakerEmbedManager.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerSpeakerEmbedManager.cs @@ -22,6 +22,7 @@ public DiffSingerSpeakerEmbedManager(DsConfig dsConfig, string rootPath) { this.dsConfig = dsConfig; this.rootPath = rootPath; } + public NDArray loadSpeakerEmbed(string speaker) { string path = Path.Join(rootPath, speaker + ".emb"); if(File.Exists(path)) { @@ -50,7 +51,7 @@ public NDArray getSpeakerEmbeds() { public bool IsVoiceColorCurve(string abbr, out int subBankId) { subBankId = 0; - if (abbr.StartsWith(VoiceColorHeader) && int.TryParse(abbr.Substring(2), out subBankId)) {; + if (abbr.StartsWith(VoiceColorHeader) && int.TryParse(abbr.Substring(2), out subBankId)) { subBankId -= 1; return true; } else { @@ -58,6 +59,8 @@ public bool IsVoiceColorCurve(string abbr, out int subBankId) { } } + static readonly HashSet warnedMissingSpeakerSuffixes = new(); + public int getSpeakerIndexBySuffix(string suffix) { var speakerIndex = dsConfig.speakers.IndexOf(suffix); if (speakerIndex >= 0) { @@ -72,69 +75,124 @@ public int getSpeakerIndexBySuffix(string suffix) { if (speakerIndex >= 0) { return speakerIndex; } - if (dsConfig.speakers.Count == 0) { + if (dsConfig.speakers == null || dsConfig.speakers.Count == 0) { throw new InvalidOperationException( "Subbanks are defined in character.yaml but \"speakers\" is empty in dsconfig.yaml."); } - Log.Warning( - $"Speaker suffix \"{suffix}\" not found in dsConfig.speakers, falling back to first speaker. " + - $"Candidates: {string.Join(',', dsConfig.speakers)}."); + var fallback = dsConfig.speakers[0]; + var warnKey = $"{rootPath}|{suffix}|{fallback}"; + lock (warnedMissingSpeakerSuffixes) { + if (warnedMissingSpeakerSuffixes.Add(warnKey)) { + Log.Warning( + "Speaker suffix \"{Suffix}\" not found in dsConfig.speakers ({Candidates}). Falling back to \"{Fallback}\".", + suffix, + string.Join(", ", dsConfig.speakers), + fallback); + } + } return 0; } //used by phonemizer (duration model) - public Tensor PhraseSpeakerEmbedByPhone(string[] speakerByPhone){ + public Tensor PhraseSpeakerEmbedByPhone(string[] speakerByPhone) { var hiddenSize = dsConfig.hiddenSize; var speakerEmbeds = getSpeakerEmbeds(); + if (speakerEmbeds == null) { + return null; + } + int speakerCount = dsConfig.speakers.Count; + var speakerEmbedArrays = new float[speakerCount][]; + for (int spk = 0; spk < speakerCount; spk++) { + speakerEmbedArrays[spk] = speakerEmbeds[":", spk].ToArray(); + } + var totalPhones = speakerByPhone.Length; - NDArray spkCurves = np.zeros(totalPhones, dsConfig.speakers.Count); - foreach(int phoneId in Enumerable.Range(0,totalPhones)) { + var result = new float[totalPhones * hiddenSize]; + + for (int phoneId = 0; phoneId < totalPhones; phoneId++) { var spkId = getSpeakerIndexBySuffix(speakerByPhone[phoneId]); - spkCurves[phoneId, spkId] = 1; + var embed = speakerEmbedArrays[spkId]; + var dest = result.AsSpan(phoneId * hiddenSize, hiddenSize); + embed.AsSpan().CopyTo(dest); } - var spkEmbedResult = np.dot(spkCurves, speakerEmbeds.T); - var spkEmbedTensor = new DenseTensor(spkEmbedResult.ToArray(), - new int[] { totalPhones, hiddenSize }) + + return new DenseTensor(result, new int[] { totalPhones, hiddenSize }) .Reshape(new int[] { 1, totalPhones, hiddenSize }); - return spkEmbedTensor; } //used by variance, pitch and acoustic - public Tensor PhraseSpeakerEmbedByFrame(RenderPhrase phrase, IList durations, float frameMs, int totalFrames, int headFrames, int tailFrames){ + public Tensor PhraseSpeakerEmbedByFrame(RenderPhrase phrase, IList durations, float frameMs, int totalFrames, int headFrames, int tailFrames) { var singer = phrase.singer; var hiddenSize = dsConfig.hiddenSize; var speakerEmbeds = getSpeakerEmbeds(); - //get default speaker for each phoneme + if (speakerEmbeds == null) { + return null; + } + // Per-frame CLR / phoneme suffix is always weight 1.0 ("100%"). + // Voice-color curves add on top; then weights are normalized to a convex mix. + // Example: CLR=A and cl_B=100% → A:B = 1:1 (not pure B). var headDefaultSpk = getSpeakerIndexBySuffix(phrase.phones[0].suffix); var tailDefaultSpk = getSpeakerIndexBySuffix(phrase.phones[^1].suffix); var defaultSpkByFrame = Enumerable.Repeat(headDefaultSpk, headFrames).ToList(); defaultSpkByFrame.AddRange(Enumerable.Range(0, phrase.phones.Length) - .SelectMany(phIndex => Enumerable.Repeat(getSpeakerIndexBySuffix(phrase.phones[phIndex].suffix), durations[phIndex+1]))); + .SelectMany(phIndex => Enumerable.Repeat( + getSpeakerIndexBySuffix(phrase.phones[phIndex].suffix), + durations[phIndex + 1]))); defaultSpkByFrame.AddRange(Enumerable.Repeat(tailDefaultSpk, tailFrames)); //get speaker curves NDArray spkCurves = np.zeros(totalFrames, dsConfig.speakers.Count); - foreach(var curve in phrase.curves) { - if(IsVoiceColorCurve(curve.Item1,out int subBankId) && subBankId < singer.Subbanks.Count) { + foreach (var curve in phrase.curves) { + if (IsVoiceColorCurve(curve.Item1, out int subBankId) && subBankId < singer.Subbanks.Count) { var spkId = getSpeakerIndexBySuffix(singer.Subbanks[subBankId].Suffix); spkCurves[":", spkId] += DiffSingerUtils.SampleCurve(phrase, curve.Item2, 0, frameMs, totalFrames, headFrames, tailFrames, x => x * 0.01f) .Select(f => (float)f).ToArray(); } } - foreach(int frameId in Enumerable.Range(0,totalFrames)) { - //standarization - var spkSum = spkCurves[frameId, ":"].ToArray().Sum(); - if (spkSum > 1) { - spkCurves[frameId, ":"] /= spkSum; - } else { - spkCurves[frameId, defaultSpkByFrame[frameId]] += 1 - spkSum; + + int speakerCount = dsConfig.speakers.Count; + var speakerEmbedArrays = new float[speakerCount][]; + for (int spk = 0; spk < speakerCount; spk++) { + speakerEmbedArrays[spk] = speakerEmbeds[":", spk].ToArray(); + } + + var result = new float[totalFrames * hiddenSize]; + var weights = new float[speakerCount]; + for (int frameId = 0; frameId < totalFrames; frameId++) { + Array.Clear(weights, 0, speakerCount); + int clrSpkId = defaultSpkByFrame[frameId]; + weights[clrSpkId] = 1f; + for (int spk = 0; spk < speakerCount; spk++) { + weights[spk] += (float)spkCurves[frameId, spk]; + } + + float weightSum = 0f; + for (int spk = 0; spk < speakerCount; spk++) { + if (weights[spk] < 0f) { + weights[spk] = 0f; + } + weightSum += weights[spk]; + } + if (weightSum < 1e-8f) { + weights[clrSpkId] = 1f; + weightSum = 1f; + } + + var dest = result.AsSpan(frameId * hiddenSize, hiddenSize); + dest.Clear(); + for (int spk = 0; spk < speakerCount; spk++) { + float w = weights[spk] / weightSum; + if (w < 1e-8f) { + continue; + } + var embed = speakerEmbedArrays[spk]; + for (int j = 0; j < dest.Length; j++) { + dest[j] += w * embed[j]; + } } } - var spkEmbedResult = np.dot(spkCurves, speakerEmbeds.T); - var spkEmbedTensor = new DenseTensor(spkEmbedResult.ToArray(), - new int[] { totalFrames, hiddenSize }) + return new DenseTensor(result, new int[] { totalFrames, hiddenSize }) .Reshape(new int[] { 1, totalFrames, hiddenSize }); - return spkEmbedTensor; } } } diff --git a/OpenUtau.Core/DiffSinger/Phonemizers/DiffSingerG2pPhonemizer.cs b/OpenUtau.Core/DiffSinger/Phonemizers/DiffSingerG2pPhonemizer.cs index 93e11de3..f3d88ed8 100644 --- a/OpenUtau.Core/DiffSinger/Phonemizers/DiffSingerG2pPhonemizer.cs +++ b/OpenUtau.Core/DiffSinger/Phonemizers/DiffSingerG2pPhonemizer.cs @@ -1,4 +1,4 @@ -using Serilog; +using Serilog; using System; using System.Collections.Generic; using System.IO; diff --git a/OpenUtauMobile/Helpers/LocalizationManager.cs b/OpenUtauMobile/Helpers/LocalizationManager.cs index 5a934ff6..70d9433e 100644 --- a/OpenUtauMobile/Helpers/LocalizationManager.cs +++ b/OpenUtauMobile/Helpers/LocalizationManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -21,7 +21,7 @@ public static class LocalizationManager public const string FollowSystemLanguageCode = "system"; private const string ExplicitLanguageFallbackCode = "en"; - private const string SystemLanguageFallbackCode = "zh-Hans"; + private const string SystemLanguageFallbackCode = "en"; /// Supported languages (language code => display name). public static readonly IReadOnlyList<(string Code, string DisplayName)> AvailableLanguages = @@ -77,7 +77,7 @@ public static void LoadLanguage(string langCode) /// /// Resolves language preference to a supported resource language code. - /// Empty or "system" preference uses system UI language and falls back to zh-Hans. + /// Empty or "system" preference uses system UI language and falls back to en. /// Explicit codes fall back to en when unsupported. /// public static string ResolveLanguagePreference(string? preferenceCode) @@ -93,7 +93,7 @@ public static string ResolveLanguagePreference(string? preferenceCode) /// /// Resolves current system language to a supported resource language code. - /// Falls back to zh-Hans when system language is not in the supported list. + /// Falls back to en when system language is not in the supported list. /// public static string ResolveSystemLanguage() { diff --git a/OpenUtauMobile/ViewModels/ClassicSingerSetupViewModel.cs b/OpenUtauMobile/ViewModels/ClassicSingerSetupViewModel.cs index ff6cad9c..d0884393 100644 --- a/OpenUtauMobile/ViewModels/ClassicSingerSetupViewModel.cs +++ b/OpenUtauMobile/ViewModels/ClassicSingerSetupViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.IO; using System.Linq; @@ -99,6 +99,22 @@ public ClassicSingerSetupViewModel(MainViewModel navigator) : base(navigator) VoicebankConfig? config = LoadCharacterYaml(ArchiveFilePath); MissingInfo = config == null || string.IsNullOrEmpty(config.SingerType); + if (!MissingInfo) + { + // character.yaml has a SingerType — use it as default selection. + var declaredType = config!.SingerType; + if (SingerTypes.Contains(declaredType)) + { + SingerType = declaredType; + } + } + else + { + // No SingerType in yaml (or no yaml at all). + // Mirror VoicebankLoader.LoadInfo heuristic: scan archive for config files. + SingerType = DetectSingerTypeFromArchive(ArchiveFilePath); + } + if (!string.IsNullOrEmpty(config?.TextFileEncoding)) { try @@ -291,6 +307,45 @@ private bool IsEncrypted(string archiveFilePath) } } + /// + /// Scans archive entries for dsconfig.yaml, enuconfig.yaml, or info.toml to infer singer type. + /// Mirrors the legacy detection heuristic in VoicebankLoader.LoadInfo and NeutrinoConfig.Load. + /// + private string DetectSingerTypeFromArchive(string archiveFilePath) + { + try + { + using (IArchive archive = ArchiveFactory.OpenArchive(archiveFilePath)) + { + bool hasDsconfig = archive.Entries.Any(e => + Path.GetFileName(e.Key) == "dsconfig.yaml"); + if (hasDsconfig) + { + return "diffsinger"; + } + + bool hasEnuconfig = archive.Entries.Any(e => + Path.GetFileName(e.Key) == "enuconfig.yaml"); + if (hasEnuconfig) + { + return "enunu"; + } + + bool hasNeutrino = archive.Entries.Any(e => + Path.GetFileName(e.Key) == "info.toml"); + if (hasNeutrino) + { + return "neutrino"; + } + } + } + catch + { + // Fall through to default + } + return "utau"; + } + private async Task InstallAsync() { string archiveFilePath = ArchiveFilePath;