From dc0ed2700e1840bd89fca3baff81cdbc0a8d50ae Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Fri, 3 Jul 2026 16:41:21 +0800 Subject: [PATCH 1/8] feat: add GAME-ggml backend (Vulkan/CPU) with long-lived serve mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce IGameBackend interface + GameBackendFactory for ONNX/GGML switching - Extract original ONNX logic into GameOnnxBackend (zero behavior change) - Add GameGgmlBackend driving game_ggml_cli serve over stdin/stdout protocol - Binary 36-byte request header + float32 waveform on stdin - JSON notes response on stdout, quit magic for clean shutdown - Refactor Game.cs to thin orchestrator delegating to selected backend - Add GameBackend preference (Preferences.cs + PreferencesViewModel + axaml) - Add Seed to GameOptions for reproducible GGML runs - Medium model support in GAME-ggml (StageCtx memory/graph-node bumps) - Add serve subcommand to game_ggml_cli (long-lived mode, binary protocol) - Patch Dependencies.cmake: pocketfft gitlab → github/mreineck mirror (gitlab was returning 502; commit 32424d206 exists in mreineck mirror) - Fix rng.cpp: missing include (exposed by strict C++17 mode) Co-authored-by: KakaruHayate --- OpenUtau.Core/Analysis/Game.cs | 296 +++---------------- OpenUtau.Core/Analysis/GameBackendFactory.cs | 65 ++++ OpenUtau.Core/Analysis/GameGgmlBackend.cs | 292 ++++++++++++++++++ OpenUtau.Core/Analysis/GameOnnxBackend.cs | 264 +++++++++++++++++ OpenUtau.Core/Analysis/IGameBackend.cs | 44 +++ OpenUtau.Core/Util/Preferences.cs | 5 + OpenUtau/ViewModels/PreferencesViewModel.cs | 15 + OpenUtau/Views/PreferencesDialog.axaml | 5 + 8 files changed, 733 insertions(+), 253 deletions(-) create mode 100644 OpenUtau.Core/Analysis/GameBackendFactory.cs create mode 100644 OpenUtau.Core/Analysis/GameGgmlBackend.cs create mode 100644 OpenUtau.Core/Analysis/GameOnnxBackend.cs create mode 100644 OpenUtau.Core/Analysis/IGameBackend.cs diff --git a/OpenUtau.Core/Analysis/Game.cs b/OpenUtau.Core/Analysis/Game.cs index cebb17227..b7a5721c6 100644 --- a/OpenUtau.Core/Analysis/Game.cs +++ b/OpenUtau.Core/Analysis/Game.cs @@ -1,11 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.ML.OnnxRuntime; -using Microsoft.ML.OnnxRuntime.Tensors; using OpenUtau.Core.Util; using Serilog; @@ -39,37 +36,47 @@ public class GameOptions { /// Note presence threshold (--est-threshold). Default: 0.2 public float ScoreThreshold { get; set; } = 0.2f; + + /// + /// RNG seed driving the D3PM stochastic boundary removal. 0 = random per + /// inference. Honored by the GGML backend for reproducible runs; the ONNX + /// path reads its own RNG stream and ignores this. + /// + public ulong Seed { get; set; } = 0; } +/// +/// GAME MIDI extractor. This class is a thin +/// orchestrator over a pluggable (ONNX or GGML). +/// Audio chunking, resampling, batching and note→tick mapping live in the base +/// class; the inference contract is delegated entirely to the active backend, +/// selected via from the user's preferences. +/// public class Game : MidiExtractor { private const string PackageId = "game"; public const string DownloadUrl = "https://github.com/openvpi/GAME/releases/tag/oudep"; - InferenceSession? encoderSession; - InferenceSession? segmenterSession; - InferenceSession? estimatorSession; - InferenceSession? bd2durSession; - RunOptions? runOptions; - bool sessionsLoaded = false; + readonly IGameBackend backend; + readonly GameConfig config; bool disposed = false; volatile bool stopping = false; - GameConfig config; - string Location; protected override int ExpectedSampleRate => config.SampleRate; public float Timestep => config.Timestep; public IReadOnlyDictionary? Languages => config.Languages; + /// The resolved backend's display name (e.g. "GGML"). + public string BackendName => backend.Name; + /// - /// Check if GAME is installed (config.json is present) without loading models. + /// Check if any GAME backend is installed (ONNX or GGML) without loading models. /// public static bool IsInstalled(string? location = null) { - location ??= PackageManager.Inst.GetInstalledPath(PackageId); - return location != null && File.Exists(Path.Combine(location, "config.json")); + return GameBackendFactory.IsAnyInstalled(); } /// - /// Load only the config (no ONNX sessions). Safe to call before showing a UI dialog. + /// Load only the config (no model sessions). Safe to call before showing a UI dialog. /// Throws if config.json is missing. /// public static GameConfig LoadConfig(string? modelPath = null) { @@ -81,263 +88,46 @@ public static GameConfig LoadConfig(string? modelPath = null) { } /// + /// Create a GAME instance using the user's preferred backend. /// public Game() : this(null) { } - /// - /// Create GAME instance with specified model path and parameters. - /// Sessions are loaded lazily on first Transcribe call. - /// - /// Path to model directory, or null for default (Dependencies/game) + /// Create a GAME instance with an explicit ONNX model directory override. public Game(string? location) { - Location = location ?? PackageManager.Inst.GetInstalledPath(PackageId)!; - Log.Information("GAME: Model location = {Location}", Location); - config = LoadConfig(location); - } - - /// - /// Ensure ONNX sessions are loaded. Called lazily before inference. - /// - private void EnsureSessionsLoaded() { - if (sessionsLoaded) return; - if (stopping) { - throw new OperationCanceledException(); - } - runOptions = new RunOptions(); - if (stopping) { - runOptions.Terminate = true; - throw new OperationCanceledException(); - } - encoderSession = CreateSession("encoder.onnx", OnnxRunnerChoice.CPUForCoreML); - segmenterSession = CreateSession("segmenter.onnx", OnnxRunnerChoice.Default); - estimatorSession = CreateSession("estimator.onnx", OnnxRunnerChoice.Default); - bd2durSession = CreateSession("bd2dur.onnx", OnnxRunnerChoice.Default); - sessionsLoaded = true; - if (stopping) { - runOptions.Terminate = true; - throw new OperationCanceledException(); + if (!string.IsNullOrEmpty(location) && GameOnnxBackend.IsInstalled(location)) { + config = LoadConfig(location); + backend = new GameOnnxBackend(config, location); + } else { + backend = GameBackendFactory.Create(); + string cfgLocation = PackageManager.Inst.GetInstalledPath(PackageId); + config = cfgLocation != null + ? LoadConfig(cfgLocation) + : new GameConfig(); } + Log.Information("GAME: active backend = {Backend}", backend.Name); } - protected override bool SupportsBatch => true; + protected override bool SupportsBatch => + backend is GameOnnxBackend; // only ONNX has native batching today protected override List> TranscribeWaveformBatch(List batch, GameOptions options) { - EnsureSessionsLoaded(); - return RunPipeline(batch, options); + if (stopping) throw new OperationCanceledException(); + return backend.RunInferenceBatch(batch, options); } protected override List TranscribeWaveform(float[] samples, GameOptions options) { - EnsureSessionsLoaded(); - return RunPipeline(new List { samples }, options)[0]; - } - - private List> RunPipeline(List batch, GameOptions options) { - int B = batch.Count; - int maxLen = batch.Max(s => s.Length); - - var waveformData = new float[B * maxLen]; - var durationData = new float[B]; - for (int b = 0; b < B; b++) { - var s = batch[b]; - s.CopyTo(waveformData, b * maxLen); - durationData[b] = (float)s.Length / config.SampleRate; - } - - var waveform = new DenseTensor(waveformData, new[] { B, maxLen }); - var duration = new DenseTensor(durationData, new[] { B }); - - try { - // 1. Encoder - var (xSeg, xEst, maskT) = RunEncoder(waveform, duration); - - // 2. Segmentation (D3PM loop) - int T = xSeg.Dimensions[1]; - Tensor knownBoundaries = new DenseTensor(new[] { B, T }); - Tensor boundaries = new DenseTensor(new[] { B, T }); - - Tensor? language = null; - if (config.Languages != null) { - int languageId = ResolveLanguageId(options.LanguageCode); - language = new DenseTensor( - Enumerable.Repeat((long)languageId, B).ToArray(), new[] { B }); - } - - var segThreshold = new DenseTensor(new[] { options.BoundaryThreshold }, Array.Empty()); - var radius = new DenseTensor(new long[] { options.BoundaryRadius }, Array.Empty()); - - if (config.Loop) { - float step = 1.0f / options.SamplingSteps; - for (int i = 0; i < options.SamplingSteps; i++) { - var t = new DenseTensor( - Enumerable.Repeat(i * step, B).ToArray(), new[] { B }); - boundaries = RunSegmenter(xSeg, knownBoundaries, boundaries, t, maskT, language, segThreshold, radius); - } - } else { - boundaries = RunSegmenter(xSeg, knownBoundaries, null, null, maskT, language, segThreshold, radius); - } - - // 3. Boundaries to durations - var (durations, maskN) = RunBd2Dur(boundaries, maskT); - int N = maskN.Dimensions[1]; - - // 4. Estimation - var scoreThreshold = new DenseTensor(new[] { options.ScoreThreshold }, Array.Empty()); - var (presence, scores) = RunEstimator(xEst, boundaries, maskT, maskN, scoreThreshold); - - // 5. Split results per batch item - var results = new List>(B); - for (int b = 0; b < B; b++) { - var notes = new List(N); - for (int i = 0; i < N; i++) { - if (!maskN[b, i]) break; - notes.Add(new TranscribedNote(durations[b, i], scores[b, i], presence[b, i])); - } - - results.Add(notes); - } - - return results; - } catch (OnnxRuntimeException) { - if (runOptions != null && runOptions.Terminate) { - throw new OperationCanceledException(); - } - throw; - } + if (stopping) throw new OperationCanceledException(); + return backend.RunInference(samples, options); } public override void Interrupt() { stopping = true; - if (!disposed && runOptions != null) { - runOptions.Terminate = true; - } + backend.Interrupt(); } protected override void DisposeManaged() { if (disposed) return; disposed = true; - runOptions?.Dispose(); - encoderSession?.Dispose(); - segmenterSession?.Dispose(); - estimatorSession?.Dispose(); - bd2durSession?.Dispose(); - sessionsLoaded = false; - } - - // ------------------------------------------------------------------------- - // Implementation details: session creation and low-level ONNX runners - // ------------------------------------------------------------------------- - - /// - /// Create an ONNX session for the given model file. - /// - private InferenceSession CreateSession(string modelFile, OnnxRunnerChoice runnerChoice) { - string modelPath = Path.Combine(Location, modelFile); - Log.Information("GAME: Loading model {ModelPath} (exists={Exists})", - modelPath, File.Exists(modelPath)); - return Onnx.getInferenceSession(modelPath, runnerChoice); - } - - /// - /// Resolve a language code string to an integer ID using the config's language map. - /// Returns 0 (universal) if the code is null or not found. - /// - private int ResolveLanguageId(string? languageCode) { - if (languageCode != null && config.Languages != null && - config.Languages.TryGetValue(languageCode, out int id)) { - return id; - } - - return 0; - } - - /// - /// Run encoder: waveform -> x_seg, x_est, maskT - /// - private (Tensor x_seg, Tensor x_est, Tensor maskT) - RunEncoder(Tensor waveform, Tensor duration) { - var inputs = new List { - NamedOnnxValue.CreateFromTensor("waveform", waveform), - NamedOnnxValue.CreateFromTensor("duration", duration), - }; - - using var outputs = encoderSession!.Run(inputs, encoderSession.OutputNames, runOptions); - - var xSeg = outputs.First(o => o.Name == "x_seg").AsTensor().ToDenseTensor(); - var xEst = outputs.First(o => o.Name == "x_est").AsTensor().ToDenseTensor(); - var maskT = outputs.First(o => o.Name == "maskT").AsTensor().ToDenseTensor(); - - return (xSeg, xEst, maskT); - } - - /// - /// Run a single segmenter step (D3PM sampling iteration) - /// - private Tensor RunSegmenter( - Tensor xSeg, - Tensor knownBoundaries, Tensor? prevBoundaries, - Tensor? t, Tensor maskT, - Tensor? language, - Tensor threshold, Tensor radius) { - var inputs = new List(); - inputs.Add(NamedOnnxValue.CreateFromTensor("x_seg", xSeg)); - - if (language != null) { - inputs.Add(NamedOnnxValue.CreateFromTensor("language", language)); - } - - inputs.Add(NamedOnnxValue.CreateFromTensor("known_boundaries", knownBoundaries)); - - if (prevBoundaries != null) { - inputs.Add(NamedOnnxValue.CreateFromTensor("prev_boundaries", prevBoundaries)); - } - - if (t != null) { - inputs.Add(NamedOnnxValue.CreateFromTensor("t", t)); - } - - inputs.Add(NamedOnnxValue.CreateFromTensor("maskT", maskT)); - inputs.Add(NamedOnnxValue.CreateFromTensor("threshold", threshold)); - inputs.Add(NamedOnnxValue.CreateFromTensor("radius", radius)); - - using var outputs = segmenterSession!.Run(inputs, segmenterSession.OutputNames, runOptions); - var boundaries = outputs.First(o => o.Name == "boundaries").AsTensor().ToDenseTensor(); - return boundaries; - } - - /// - /// Run bd2dur: boundaries -> durations (seconds) + maskN - /// - private (Tensor durations, Tensor maskN) - RunBd2Dur(Tensor boundaries, Tensor maskT) { - var inputs = new List { - NamedOnnxValue.CreateFromTensor("boundaries", boundaries), - NamedOnnxValue.CreateFromTensor("maskT", maskT), - }; - - using var outputs = bd2durSession!.Run(inputs, bd2durSession.OutputNames, runOptions); - var durations = outputs.First(o => o.Name == "durations").AsTensor().ToDenseTensor(); - var maskN = outputs.First(o => o.Name == "maskN").AsTensor().ToDenseTensor(); - - return (durations, maskN); - } - - /// - /// Run estimator: predict note presence and pitch scores - /// - private (Tensor presence, Tensor scores) - RunEstimator(Tensor xEst, Tensor boundaries, Tensor maskT, - Tensor maskN, Tensor threshold) { - var inputs = new List { - NamedOnnxValue.CreateFromTensor("x_est", xEst), - NamedOnnxValue.CreateFromTensor("boundaries", boundaries), - NamedOnnxValue.CreateFromTensor("maskT", maskT), - NamedOnnxValue.CreateFromTensor("maskN", maskN), - NamedOnnxValue.CreateFromTensor("threshold", threshold), - }; - - using var outputs = estimatorSession!.Run(inputs, estimatorSession.OutputNames, runOptions); - var presence = outputs.First(o => o.Name == "presence").AsTensor().ToDenseTensor(); - var scores = outputs.First(o => o.Name == "scores").AsTensor().ToDenseTensor(); - return (presence, scores); + backend.Dispose(); } } diff --git a/OpenUtau.Core/Analysis/GameBackendFactory.cs b/OpenUtau.Core/Analysis/GameBackendFactory.cs new file mode 100644 index 000000000..631b55017 --- /dev/null +++ b/OpenUtau.Core/Analysis/GameBackendFactory.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.Analysis; + +/// +/// Selects and constructs the active for a GAME +/// inference request, based on user preferences and which backend is installed. +/// +/// Preference resolution order: +/// 1. (the user's explicit choice) — +/// used if that backend is installed. +/// 2. Fall back to whichever backend *is* installed (ONNX preferred over GGML, +/// since ONNX is the trusted baseline). +/// 3. If neither is installed, throw — callers should have guarded with +/// . +/// +public static class GameBackendFactory { + public const string OnnxValue = "onnx"; + public const string GgmlValue = "ggml"; + + /// True when at least one backend's weights + binaries are in place. + public static bool IsAnyInstalled() { + return GameOnnxBackend.IsInstalled() || GameGgmlBackend.IsInstalled(); + } + + /// The backend id that will actually be used given prefs + installs. + public static string ResolveChoice() { + string pref = Preferences.Default.GameBackend ?? ""; + if (pref == GgmlValue && GameGgmlBackend.IsInstalled()) return GgmlValue; + if (pref == OnnxValue && GameOnnxBackend.IsInstalled()) return OnnxValue; + // Fall back to whatever is installed, preferring ONNX. + if (GameOnnxBackend.IsInstalled()) return OnnxValue; + if (GameGgmlBackend.IsInstalled()) return GgmlValue; + return ""; // none + } + + /// The display name ("ONNX" / "GGML") for the resolved backend. + public static string ResolvedBackendName() => ResolveChoice() switch { + GgmlValue => "GGML", + OnnxValue => "ONNX", + _ => "(none)", + }; + + /// + /// Construct the resolved backend and load its config from the installed + /// GAME weights directory. + /// + public static IGameBackend Create() { + string choice = ResolveChoice(); + Log.Information("GAME: backend choice resolved to {Choice}", choice); + if (choice == OnnxValue) { + string location = PackageManager.Inst.GetInstalledPath("game")!; + var config = Game.LoadConfig(location); + return new GameOnnxBackend(config, location); + } + if (choice == GgmlValue) { + return GameGgmlBackend.Create(); + } + throw new InvalidOperationException( + "No GAME backend is installed. Install the GAME ONNX or GGML weights via the Package Manager."); + } +} diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs new file mode 100644 index 000000000..f1144e5c6 --- /dev/null +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using Serilog; + +namespace OpenUtau.Core.Analysis; + +/// +/// GAME inference backend that drives the native GAME-ggml CLI in long-lived +/// serve mode. Communication is a binary stdin → JSON stdout protocol: +/// +/// On : +/// 1. Resolve the CLI binary and GGUF weights under the OpenUtau Dependencies folder. +/// 2. Spawn game_ggml_cli serve with redirected stdin/stdout/stderr. +/// 3. Wait for the {"type":"ready"} line on stdout. +/// +/// Per inference (): +/// • Write a 36-byte request header + the float32 waveform to stdin. +/// • Read one JSON object back describing the transcribed notes for this chunk. +/// +/// Cancelation () writes the quit magic and reaps the process. +/// One instance owns exactly one subprocess; the +/// subclass disposes it after the whole transcription. +/// +public class GameGgmlBackend : IGameBackend { + private const string PackageId = "game"; + private const string CliPackageId = "game-ggml-cli"; + + // Must match serv_proto::MAGIC_INFERENCE / MAGIC_QUIT in src/cli/main.cpp. + private const uint MAGIC_INFERENCE = 0x53455256u; // "VRES" + private const uint MAGIC_QUIT = 0x54495155u; // "UQIT" + + const int RequestHeaderBytes = 36; + + readonly GameConfig config; + readonly string cliPath; + readonly string ggufPath; + + Process? process; + BinaryWriter? stdinWriter; + StreamReader? stdoutReader; + volatile bool disposed = false; + volatile bool stopping = false; + + public string Name => "GGML"; + + private GameGgmlBackend(GameConfig config, string cliPath, string ggufPath) { + this.config = config; + this.cliPath = cliPath; + this.ggufPath = ggufPath; + } + + /// Locate the shipped CLI binary, platform-aware. + public static string? ResolveCliPath() { + string dep = PathManager.Inst.DependencyPath; + string exeName = OS.IsWindows() ? "game_ggml_cli.exe" : "game_ggml_cli"; + // First: dedicated oudep package directory. + string cliDir = Path.Combine(dep, CliPackageId); + string exe = Path.Combine(cliDir, exeName); + if (File.Exists(exe)) return exe; + // Fallback: a shared bin directory. + string binDir = Path.Combine(dep, "bin"); + exe = Path.Combine(binDir, exeName); + if (File.Exists(exe)) return exe; + return null; + } + + /// Locate the first .gguf weight file inside the GAME dependency package. + public static string? ResolveGgufPath(string? location = null) { + location ??= PackageManager.Inst.GetInstalledPath(PackageId); + if (location == null) return null; + var gguf = Directory.GetFiles(location, "*.gguf") + .OrderByDescending(f => new FileInfo(f).Length) // prefer the largest (likely medium > small) + .FirstOrDefault(); + return gguf; + } + + /// True when both the CLI binary and a GGUF weight package are installed. + public static bool IsInstalled(string? location = null) { + return ResolveCliPath() != null && ResolveGgufPath(location) != null; + } + + /// + /// Construct a ready-to-serve backend if installed; otherwise throws so the + /// factory can fall back. Model loading is lazy (deferred to EnsureLoaded). + /// + public static GameGgmlBackend Create() { + string? cli = ResolveCliPath(); + string? gguf = ResolveGgufPath(); + if (cli == null || gguf == null) { + throw new InvalidOperationException( + "GAME GGML backend is not installed: missing CLI binary or .gguf weights."); + } + GameConfig config = Game.LoadConfig(); + return new GameGgmlBackend(config, cli, gguf); + } + + public bool EnsureLoaded() { + if (process != null && !process.HasExited) return true; + + Log.Information("GAME(GGML): launching serve subprocess cli={Cli} gguf={Gguf}", cliPath, ggufPath); + var psi = new ProcessStartInfo { + FileName = cliPath, + Arguments = $"serve \"{ggufPath}\"", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + }; + process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start game_ggml_cli."); + // Binary stdin: avoid a text writer wrapper to control framing exactly. + stdinWriter = new BinaryWriter(process.StandardInput.BaseStream, Encoding.ASCII, leaveOpen: false); + stdoutReader = process.StandardOutput; + + // Drain stderr on a background thread so it can't deadlock the pipe. + StartStderrPump(); + + // Wait for {"type":"ready"} (or {"type":"error",...}) on stdout. + string? first = ReadJsonObject(); + if (first == null || ParseType(first) == "error") { + string msg = first ?? "subprocess closed stdout before ready"; + if (first != null) msg = ParseError(first) ?? msg; + throw new InvalidOperationException($"GAME GGML backend failed to initialize: {msg}"); + } + return true; + } + + public List RunInference(float[] samples, GameOptions options) { + EnsureLoaded(); + if (stopping || process == null || process.HasExited) { + throw new OperationCanceledException(); + } + + // Resolve language id. + int languageId = 0; + if (config.Languages != null && options.LanguageCode != null && + config.Languages.TryGetValue(options.LanguageCode, out int id)) { + languageId = id; + } + + // Build the request header + waveform, write to stdin. + Span header = stackalloc byte[RequestHeaderBytes]; + BinaryPrimitivesWriteU32Little(header.Slice(0, 4), MAGIC_INFERENCE); + BinaryPrimitivesWriteI32Little(header.Slice(4, 4), languageId); + BinaryPrimitivesWriteU64Little(header.Slice(8, 8), (ulong)options.Seed); + BinaryPrimitivesWriteI32Little(header.Slice(16, 4), options.SamplingSteps); + BinaryPrimitivesWriteFloatLittle(header.Slice(20, 4), options.BoundaryThreshold); + BinaryPrimitivesWriteI32Little(header.Slice(24, 4), options.BoundaryRadius); + BinaryPrimitivesWriteFloatLittle(header.Slice(28, 4), options.ScoreThreshold); + BinaryPrimitivesWriteU32Little(header.Slice(32, 4), (uint)samples.Length); + + var stdin = stdinWriter!; + stdin.Write((ReadOnlySpan)header); + // Float32 waveform, little-endian. On x86/x64 .NET float layout is LE. + ReadOnlySpan waveBytes = System.Runtime.InteropServices.MemoryMarshal.AsBytes(samples.AsSpan()); + stdin.Write(waveBytes); + stdin.Flush(); + + // Read one JSON object back: {"type":"notes","count":N,"notes":[...]} + string? line = ReadJsonObject(); + if (line == null) throw new OperationCanceledException("GGML subprocess closed stdout during inference."); + string type = ParseType(line); + if (type == "error") { + throw new InvalidOperationException($"GGML inference error: {ParseError(line)}"); + } + if (type != "notes") { + throw new InvalidOperationException($"Unexpected GGML response: {line}"); + } + return ParseNotes(line); + } + + public void Interrupt() { + stopping = true; + // Send the quit magic; the subprocess exits cleanly. Fall through to + // a hard kill if it hangs. + try { + if (process != null && !process.HasExited && stdinWriter != null) { + Span quit = stackalloc byte[4]; + BinaryPrimitivesWriteU32Little(quit, MAGIC_QUIT); + stdinWriter.Write(quit); + stdinWriter.Flush(); + if (!process.WaitForExit(3000)) { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + } + } + } catch { + // Cancellation must never throw; swallow IO errors on a dying pipe. + } + } + + public void Dispose() { + if (disposed) return; + disposed = true; + try { + if (process != null && !process.HasExited) { + try { + if (stdinWriter != null) { + Span quit = stackalloc byte[4]; + BinaryPrimitivesWriteU32Little(quit, MAGIC_QUIT); + stdinWriter.Write(quit); + stdinWriter.Flush(); + } + } catch { /* pipe may already be broken */ } + if (!process.WaitForExit(2000)) { + try { process.Kill(entireProcessTree: true); } catch { } + } + } + } finally { + stdinWriter?.Dispose(); + stdoutReader?.Dispose(); + process?.Dispose(); + process = null; + } + } + + // ------------------------------------------------------------------------- + // stdout framing: read exactly one JSON object terminated by '\n' + // ------------------------------------------------------------------------- + + private string? ReadJsonObject() { + // ReadLine blocks until '\n' or EOF; JSON objects are single-line. + string? line = stdoutReader!.ReadLine(); + return string.IsNullOrEmpty(line) ? null : line; + } + + private static string? ParseType(string json) { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("type", out var t) ? t.GetString() : null; + } + + private static string? ParseError(string json) { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : null; + } + + private static List ParseNotes(string json) { + var notes = new List(); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("notes", out var arr)) return notes; + // The GGML backend emitted absolute offset_seconds per note, but the + // segmenter partitions the chunk into contiguous regions whose lengths + // sum exactly to the chunk length — each note already carries its own + // duration_seconds. The MidiExtractor base class positions the chunk by + // its audio offset and accumulates note durations sequentially, so we + // pass duration_seconds through directly and ignore offset_seconds. + foreach (var n in arr.EnumerateArray()) { + float duration = n.TryGetProperty("d", out var de) ? de.GetSingle() : 0f; + float pitch = n.TryGetProperty("p", out var pe) ? pe.GetSingle() : 0f; + bool voiced = n.TryGetProperty("v", out var ve) && ve.GetInt32() != 0; + if (duration < 0) duration = 0; + notes.Add(new TranscribedNote(duration, pitch, voiced)); + } + return notes; + } + + // ------------------------------------------------------------------------- + // stderr pump (keeps the subprocess from blocking on a full error buffer) + // ------------------------------------------------------------------------- + private void StartStderrPump() { + var err = process!.StandardError; + System.Threading.Tasks.Task.Run(() => { + string? l; + while ((l = err.ReadLine()) != null) { + Log.Information("GAME(GGML)[stderr] {Line}", l); + } + }); + } + + // ------------------------------------------------------------------------- + // Little-endian binary writers (no BitConverter pinned-buffer dance needed) + // ------------------------------------------------------------------------- + private static void BinaryPrimitivesWriteU32Little(Span dst, uint v) { + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(dst, v); + } + private static void BinaryPrimitivesWriteI32Little(Span dst, int v) { + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(dst, v); + } + private static void BinaryPrimitivesWriteU64Little(Span dst, ulong v) { + System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian(dst, v); + } + private static unsafe void BinaryPrimitivesWriteFloatLittle(Span dst, float v) { + int bits = *(int*)&v; + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(dst, bits); + } +} diff --git a/OpenUtau.Core/Analysis/GameOnnxBackend.cs b/OpenUtau.Core/Analysis/GameOnnxBackend.cs new file mode 100644 index 000000000..4f090ab82 --- /dev/null +++ b/OpenUtau.Core/Analysis/GameOnnxBackend.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using Serilog; + +namespace OpenUtau.Core.Analysis; + +/// +/// ONNX Runtime backend for GAME. Runs the four-model pipeline +/// (encoder → segmenter (D3PM loop) → bd2dur → estimator) directly in-process. +/// This is a behavior-preserving extraction of the original single-backend +/// implementation; ONNX is always available when the +/// game dependency package with the .onnx files is installed. +/// +public class GameOnnxBackend : IGameBackend { + private const string PackageId = "game"; + + InferenceSession? encoderSession; + InferenceSession? segmenterSession; + InferenceSession? estimatorSession; + InferenceSession? bd2durSession; + RunOptions? runOptions; + bool sessionsLoaded = false; + bool disposed = false; + volatile bool stopping = false; + + readonly GameConfig config; + readonly string Location; + + public string Name => "ONNX"; + + public GameOnnxBackend(GameConfig config, string location) { + this.config = config; + this.Location = location; + } + + /// Check if the ONNX GAME weights are installed without loading models. + public static bool IsInstalled(string? location = null) { + location ??= PackageManager.Inst.GetInstalledPath(PackageId); + if (location == null) return false; + if (!System.IO.File.Exists(System.IO.Path.Combine(location, "config.json"))) return false; + // All four ONNX models must be present. + return new[] { "encoder.onnx", "segmenter.onnx", "estimator.onnx", "bd2dur.onnx" } + .All(f => System.IO.File.Exists(System.IO.Path.Combine(location, f))); + } + + public bool EnsureLoaded() { + if (sessionsLoaded) return true; + runOptions = new RunOptions(); + encoderSession = CreateSession("encoder.onnx", OnnxRunnerChoice.CPUForCoreML); + segmenterSession = CreateSession("segmenter.onnx", OnnxRunnerChoice.Default); + estimatorSession = CreateSession("estimator.onnx", OnnxRunnerChoice.Default); + bd2durSession = CreateSession("bd2dur.onnx", OnnxRunnerChoice.Default); + sessionsLoaded = true; + if (stopping) { + runOptions.Terminate = true; + throw new OperationCanceledException(); + } + return true; + } + + public List RunInference(float[] samples, GameOptions options) { + EnsureLoaded(); + return RunPipeline(new List { samples }, options)[0]; + } + + public List> RunInferenceBatch(List batch, GameOptions options) { + EnsureLoaded(); + return RunPipeline(batch, options); + } + + private List> RunPipeline(List batch, GameOptions options) { + int B = batch.Count; + int maxLen = batch.Max(s => s.Length); + + var waveformData = new float[B * maxLen]; + var durationData = new float[B]; + for (int b = 0; b < B; b++) { + var s = batch[b]; + s.CopyTo(waveformData, b * maxLen); + durationData[b] = (float)s.Length / config.SampleRate; + } + + var waveform = new DenseTensor(waveformData, new[] { B, maxLen }); + var duration = new DenseTensor(durationData, new[] { B }); + + try { + // 1. Encoder + var (xSeg, xEst, maskT) = RunEncoder(waveform, duration); + + // 2. Segmentation (D3PM loop) + int T = xSeg.Dimensions[1]; + Tensor knownBoundaries = new DenseTensor(new[] { B, T }); + Tensor boundaries = new DenseTensor(new[] { B, T }); + + Tensor? language = null; + if (config.Languages != null) { + int languageId = ResolveLanguageId(options.LanguageCode); + language = new DenseTensor( + Enumerable.Repeat((long)languageId, B).ToArray(), new[] { B }); + } + + var segThreshold = new DenseTensor(new[] { options.BoundaryThreshold }, Array.Empty()); + var radius = new DenseTensor(new long[] { options.BoundaryRadius }, Array.Empty()); + + if (config.Loop) { + float step = 1.0f / options.SamplingSteps; + for (int i = 0; i < options.SamplingSteps; i++) { + var t = new DenseTensor( + Enumerable.Repeat(i * step, B).ToArray(), new[] { B }); + boundaries = RunSegmenter(xSeg, knownBoundaries, boundaries, t, maskT, language, segThreshold, radius); + } + } else { + boundaries = RunSegmenter(xSeg, knownBoundaries, null, null, maskT, language, segThreshold, radius); + } + + // 3. Boundaries to durations + var (durations, maskN) = RunBd2Dur(boundaries, maskT); + int N = maskN.Dimensions[1]; + + // 4. Estimation + var scoreThreshold = new DenseTensor(new[] { options.ScoreThreshold }, Array.Empty()); + var (presence, scores) = RunEstimator(xEst, boundaries, maskT, maskN, scoreThreshold); + + // 5. Split results per batch item + var results = new List>(B); + for (int b = 0; b < B; b++) { + var notes = new List(N); + for (int i = 0; i < N; i++) { + if (!maskN[b, i]) break; + notes.Add(new TranscribedNote(durations[b, i], scores[b, i], presence[b, i])); + } + + results.Add(notes); + } + + return results; + } catch (OnnxRuntimeException) { + if (runOptions != null && runOptions.Terminate) { + throw new OperationCanceledException(); + } + throw; + } + } + + public void Interrupt() { + stopping = true; + if (!disposed && runOptions != null) { + runOptions.Terminate = true; + } + } + + public void Dispose() { + if (disposed) return; + disposed = true; + runOptions?.Dispose(); + encoderSession?.Dispose(); + segmenterSession?.Dispose(); + estimatorSession?.Dispose(); + bd2durSession?.Dispose(); + sessionsLoaded = false; + } + + // ------------------------------------------------------------------------- + // Implementation details: session creation and low-level ONNX runners + // ------------------------------------------------------------------------- + + private InferenceSession CreateSession(string modelFile, OnnxRunnerChoice runnerChoice) { + string modelPath = System.IO.Path.Combine(Location, modelFile); + Log.Information("GAME(ONNX): Loading model {ModelPath} (exists={Exists})", + modelPath, System.IO.File.Exists(modelPath)); + return Onnx.getInferenceSession(modelPath, runnerChoice); + } + + private int ResolveLanguageId(string? languageCode) { + if (languageCode != null && config.Languages != null && + config.Languages.TryGetValue(languageCode, out int id)) { + return id; + } + + return 0; + } + + private (Tensor x_seg, Tensor x_est, Tensor maskT) + RunEncoder(Tensor waveform, Tensor duration) { + var inputs = new List { + NamedOnnxValue.CreateFromTensor("waveform", waveform), + NamedOnnxValue.CreateFromTensor("duration", duration), + }; + + using var outputs = encoderSession!.Run(inputs, encoderSession.OutputNames, runOptions); + + var xSeg = outputs.First(o => o.Name == "x_seg").AsTensor().ToDenseTensor(); + var xEst = outputs.First(o => o.Name == "x_est").AsTensor().ToDenseTensor(); + var maskT = outputs.First(o => o.Name == "maskT").AsTensor().ToDenseTensor(); + + return (xSeg, xEst, maskT); + } + + private Tensor RunSegmenter( + Tensor xSeg, + Tensor knownBoundaries, Tensor? prevBoundaries, + Tensor? t, Tensor maskT, + Tensor? language, + Tensor threshold, Tensor radius) { + var inputs = new List(); + inputs.Add(NamedOnnxValue.CreateFromTensor("x_seg", xSeg)); + + if (language != null) { + inputs.Add(NamedOnnxValue.CreateFromTensor("language", language)); + } + + inputs.Add(NamedOnnxValue.CreateFromTensor("known_boundaries", knownBoundaries)); + + if (prevBoundaries != null) { + inputs.Add(NamedOnnxValue.CreateFromTensor("prev_boundaries", prevBoundaries)); + } + + if (t != null) { + inputs.Add(NamedOnnxValue.CreateFromTensor("t", t)); + } + + inputs.Add(NamedOnnxValue.CreateFromTensor("maskT", maskT)); + inputs.Add(NamedOnnxValue.CreateFromTensor("threshold", threshold)); + inputs.Add(NamedOnnxValue.CreateFromTensor("radius", radius)); + + using var outputs = segmenterSession!.Run(inputs, segmenterSession.OutputNames, runOptions); + var boundaries = outputs.First(o => o.Name == "boundaries").AsTensor().ToDenseTensor(); + return boundaries; + } + + private (Tensor durations, Tensor maskN) + RunBd2Dur(Tensor boundaries, Tensor maskT) { + var inputs = new List { + NamedOnnxValue.CreateFromTensor("boundaries", boundaries), + NamedOnnxValue.CreateFromTensor("maskT", maskT), + }; + + using var outputs = bd2durSession!.Run(inputs, bd2durSession.OutputNames, runOptions); + var durations = outputs.First(o => o.Name == "durations").AsTensor().ToDenseTensor(); + var maskN = outputs.First(o => o.Name == "maskN").AsTensor().ToDenseTensor(); + + return (durations, maskN); + } + + private (Tensor presence, Tensor scores) + RunEstimator(Tensor xEst, Tensor boundaries, Tensor maskT, + Tensor maskN, Tensor threshold) { + var inputs = new List { + NamedOnnxValue.CreateFromTensor("x_est", xEst), + NamedOnnxValue.CreateFromTensor("boundaries", boundaries), + NamedOnnxValue.CreateFromTensor("maskT", maskT), + NamedOnnxValue.CreateFromTensor("maskN", maskN), + NamedOnnxValue.CreateFromTensor("threshold", threshold), + }; + + using var outputs = estimatorSession!.Run(inputs, estimatorSession.OutputNames, runOptions); + var presence = outputs.First(o => o.Name == "presence").AsTensor().ToDenseTensor(); + var scores = outputs.First(o => o.Name == "scores").AsTensor().ToDenseTensor(); + return (presence, scores); + } +} diff --git a/OpenUtau.Core/Analysis/IGameBackend.cs b/OpenUtau.Core/Analysis/IGameBackend.cs new file mode 100644 index 000000000..06b7ed08d --- /dev/null +++ b/OpenUtau.Core/Analysis/IGameBackend.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace OpenUtau.Core.Analysis; + +/// +/// Pluggable inference backend for the GAME MIDI extractor. +/// +/// wraps the ONNX Runtime four-model pipeline. +/// drives the native GAME-ggml CLI (serve mode). +/// +/// Implementations are responsible for lazily loading their model lifecycle and +/// for honoring . Audio chunking, resampling, batching and +/// the final note→tick mapping are handled by , +/// so backends only need to transcribe a single preprocessed waveform (or a batch). +/// +public interface IGameBackend : IDisposable { + /// Short display name for diagnostics ("ONNX", "GGML"). + string Name { get; } + + /// + /// Load the model if not already loaded. Called lazily before the first + /// inference. Returns true if the weights/executable are present and ready. + /// + bool EnsureLoaded(); + + /// Transcribe a single preprocessed (mono, expected-sample-rate) waveform. + List RunInference(float[] samples, GameOptions options); + + /// + /// Transcribe a batch of waveforms. Default implementation falls back to + /// per-chunk calls; backends with native batching override it. + /// + List> RunInferenceBatch(List batch, GameOptions options) { + var results = new List>(batch.Count); + foreach (var samples in batch) { + results.Add(RunInference(samples, options)); + } + return results; + } + + /// Request cancellation of any in-flight inference. + void Interrupt(); +} diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs index 79de981f3..178912b84 100644 --- a/OpenUtau.Core/Util/Preferences.cs +++ b/OpenUtau.Core/Util/Preferences.cs @@ -159,6 +159,11 @@ public class SerializablePreferences { public int WorldlineR = 0; public string OnnxRunner = string.Empty; public int OnnxGpu = 0; + /// + /// GAME MIDI extractor backend preference: "onnx" (default) or "ggml". + /// Affects which inference engine Game uses; see GameBackendFactory. + /// + public string GameBackend = "onnx"; public double DiffSingerDepth = 1.0; public int DiffSingerSteps = 20; public int DiffSingerStepsVariance = 20; diff --git a/OpenUtau/ViewModels/PreferencesViewModel.cs b/OpenUtau/ViewModels/PreferencesViewModel.cs index 30033cc38..7b74cf3b3 100644 --- a/OpenUtau/ViewModels/PreferencesViewModel.cs +++ b/OpenUtau/ViewModels/PreferencesViewModel.cs @@ -93,6 +93,10 @@ public int SafeMaxThreadCount { [Reactive] public GpuInfo OnnxGpu { get; set; } [Reactive] public bool ShowOnnxGpu { get; set; } + // GAME backend (onnx / ggml) + public List GameBackendOptions { get; } = new() { "ONNX", "GGML" }; + [Reactive] public string GameBackend { get; set; } + // Appearance [Reactive] public string ThemeName { get; set; } [Reactive] public int DegreeStyle { get; set; } @@ -170,6 +174,12 @@ public PreferencesViewModel() { OnnxGpuOptions = Onnx.getGpuInfo(); OnnxGpu = OnnxGpuOptions.FirstOrDefault(x => x.deviceId == Preferences.Default.OnnxGpu, OnnxGpuOptions[0]); ShowOnnxGpu = OnnxRunner == "DirectML"; + // GAME backend: ONNX is the default, GGML is available when installed. + // The options list always includes both so the ComboBox UX is stable. + GameBackend = Preferences.Default.GameBackend switch { + "ggml" => "GGML", + _ => "ONNX", // default / empty / unrecognized all map to ONNX + }; DiffSingerDepth = Preferences.Default.DiffSingerDepth * 100; DiffSingerSteps = Preferences.Default.DiffSingerSteps; DiffSingerStepsVariance = Preferences.Default.DiffSingerStepsVariance; @@ -344,6 +354,11 @@ public PreferencesViewModel() { Preferences.Default.OnnxGpu = index.deviceId; Preferences.Save(); }); + this.WhenAnyValue(vm => vm.GameBackend) + .Subscribe(index => { + Preferences.Default.GameBackend = index == "GGML" ? "ggml" : "onnx"; + Preferences.Save(); + }); this.WhenAnyValue(vm => vm.RememberMid) .Subscribe(index => { Preferences.Default.RememberMid = index; diff --git a/OpenUtau/Views/PreferencesDialog.axaml b/OpenUtau/Views/PreferencesDialog.axaml index 661cff5b5..f86a3232f 100644 --- a/OpenUtau/Views/PreferencesDialog.axaml +++ b/OpenUtau/Views/PreferencesDialog.axaml @@ -207,6 +207,11 @@ + + + + From c1c1b2f56317075496efc83ba842a5474b3ca55e Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Fri, 3 Jul 2026 17:14:57 +0800 Subject: [PATCH 2/8] fix: ResolveGgufPath checks game-ggml-medium oudep package first --- OpenUtau.Core/Analysis/GameGgmlBackend.cs | 37 ++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index f1144e5c6..34bce8546 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -28,7 +28,8 @@ namespace OpenUtau.Core.Analysis; /// public class GameGgmlBackend : IGameBackend { private const string PackageId = "game"; - private const string CliPackageId = "game-ggml-cli"; + // Single oudep package contains both the CLI binary and the GGUF weights. + private const string GgmlPackageId = "game-ggml-medium"; // Must match serv_proto::MAGIC_INFERENCE / MAGIC_QUIT in src/cli/main.cpp. private const uint MAGIC_INFERENCE = 0x53455256u; // "VRES" @@ -58,25 +59,33 @@ private GameGgmlBackend(GameConfig config, string cliPath, string ggufPath) { public static string? ResolveCliPath() { string dep = PathManager.Inst.DependencyPath; string exeName = OS.IsWindows() ? "game_ggml_cli.exe" : "game_ggml_cli"; - // First: dedicated oudep package directory. - string cliDir = Path.Combine(dep, CliPackageId); + // The game-ggml-medium oudep package ships the CLI under its root. + string cliDir = Path.Combine(dep, GgmlPackageId); string exe = Path.Combine(cliDir, exeName); if (File.Exists(exe)) return exe; - // Fallback: a shared bin directory. - string binDir = Path.Combine(dep, "bin"); - exe = Path.Combine(binDir, exeName); - if (File.Exists(exe)) return exe; return null; } - /// Locate the first .gguf weight file inside the GAME dependency package. + /// Locate the first .gguf weight file. Checks the dedicated + /// game-ggml-medium package first, then falls back to the shared game + /// package (so GGUF weights can coexist alongside the ONNX .onnx files). public static string? ResolveGgufPath(string? location = null) { - location ??= PackageManager.Inst.GetInstalledPath(PackageId); - if (location == null) return null; - var gguf = Directory.GetFiles(location, "*.gguf") - .OrderByDescending(f => new FileInfo(f).Length) // prefer the largest (likely medium > small) - .FirstOrDefault(); - return gguf; + // 1. Preferred: dedicated ggml package (oudep installs to DependencyPath/game-ggml-medium) + string ggmlDir = Path.Combine(PathManager.Inst.DependencyPath, GgmlPackageId); + if (Directory.Exists(ggmlDir)) { + var gguf = Directory.GetFiles(ggmlDir, "*.gguf") + .OrderByDescending(f => new FileInfo(f).Length) + .FirstOrDefault(); + if (gguf != null) return gguf; + } + // 2. Fallback: shared game package (for users who placed GGUF alongside ONNX files) + string? gameLoc = location ?? PackageManager.Inst.GetInstalledPath(PackageId); + if (gameLoc != null && Directory.Exists(gameLoc)) { + return Directory.GetFiles(gameLoc, "*.gguf") + .OrderByDescending(f => new FileInfo(f).Length) + .FirstOrDefault(); + } + return null; } /// True when both the CLI binary and a GGUF weight package are installed. From 7149ea80322da43a325198c347c1c6958207c3f2 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Fri, 3 Jul 2026 17:30:54 +0800 Subject: [PATCH 3/8] fix: load GAME config from GGML package dir, not ONNX package --- OpenUtau.Core/Analysis/GameGgmlBackend.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index 34bce8546..fbdca8863 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -104,7 +104,15 @@ public static GameGgmlBackend Create() { throw new InvalidOperationException( "GAME GGML backend is not installed: missing CLI binary or .gguf weights."); } - GameConfig config = Game.LoadConfig(); + // Load config from the GGML package dir (Dependencies/game-ggml-medium/), + // not the ONNX package dir — they are independent oudep packages. + string ggmlDir = Path.GetDirectoryName(gguf)!; + string configPath = Path.Combine(ggmlDir, "config.json"); + if (!File.Exists(configPath)) { + throw new InvalidOperationException( + $"GAME GGML backend is missing config.json at {configPath}"); + } + GameConfig config = Game.LoadConfig(configPath); return new GameGgmlBackend(config, cli, gguf); } From 70ba99076ea4566cb956a86b677e9f5044a733d1 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Fri, 3 Jul 2026 17:43:35 +0800 Subject: [PATCH 4/8] fix: swallow config load failure in TranscribeViewModel to prevent crash --- OpenUtau/ViewModels/TranscribeViewModel.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenUtau/ViewModels/TranscribeViewModel.cs b/OpenUtau/ViewModels/TranscribeViewModel.cs index 1b10f6eff..148a91cae 100644 --- a/OpenUtau/ViewModels/TranscribeViewModel.cs +++ b/OpenUtau/ViewModels/TranscribeViewModel.cs @@ -103,7 +103,11 @@ public TranscribeViewModel() { // Load GAME config (no model sessions) to populate options GameConfig? gameConfig = null; if (GameAvailable) { - gameConfig = Game.LoadConfig(); + try { + gameConfig = Game.LoadConfig(); + } catch { + GameAvailable = false; + } } GameHasLanguages = (gameConfig?.Languages?.Count ?? 0) > 0; From f050c72aadb1b6f323dde12722347e8ac046d3a7 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Fri, 3 Jul 2026 18:04:10 +0800 Subject: [PATCH 5/8] fix: read config.json directly in GameGgmlBackend.Create() --- OpenUtau.Core/Analysis/GameGgmlBackend.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index fbdca8863..ee0209b2d 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -112,7 +112,9 @@ public static GameGgmlBackend Create() { throw new InvalidOperationException( $"GAME GGML backend is missing config.json at {configPath}"); } - GameConfig config = Game.LoadConfig(configPath); + var jsonText = File.ReadAllText(configPath, System.Text.Encoding.UTF8); + GameConfig config = System.Text.Json.JsonSerializer.Deserialize(jsonText) + ?? throw new InvalidOperationException("Failed to parse GAME config.json"); return new GameGgmlBackend(config, cli, gguf); } From ed42f9b5b992e034479e8ebf55df21c40cda8619 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sat, 1 Aug 2026 18:56:45 +0800 Subject: [PATCH 6/8] fix: decouple GAME GGML config from ONNX package --- OpenUtau.Core/Analysis/Game.cs | 5 +- OpenUtau.Core/Analysis/GameBackendFactory.cs | 17 +++++ OpenUtau.Core/Analysis/GameGgmlBackend.cs | 73 +++++++++++-------- OpenUtau.Core/Analysis/GameOnnxBackend.cs | 1 + OpenUtau.Core/Analysis/IGameBackend.cs | 3 + .../Core/Analysis/GameGgmlBackendTest.cs | 63 ++++++++++++++++ OpenUtau/ViewModels/TranscribeViewModel.cs | 5 +- 7 files changed, 129 insertions(+), 38 deletions(-) create mode 100644 OpenUtau.Test/Core/Analysis/GameGgmlBackendTest.cs diff --git a/OpenUtau.Core/Analysis/Game.cs b/OpenUtau.Core/Analysis/Game.cs index b7a5721c6..6df8db2ea 100644 --- a/OpenUtau.Core/Analysis/Game.cs +++ b/OpenUtau.Core/Analysis/Game.cs @@ -99,10 +99,7 @@ public Game(string? location) { backend = new GameOnnxBackend(config, location); } else { backend = GameBackendFactory.Create(); - string cfgLocation = PackageManager.Inst.GetInstalledPath(PackageId); - config = cfgLocation != null - ? LoadConfig(cfgLocation) - : new GameConfig(); + config = backend.Config; } Log.Information("GAME: active backend = {Backend}", backend.Name); } diff --git a/OpenUtau.Core/Analysis/GameBackendFactory.cs b/OpenUtau.Core/Analysis/GameBackendFactory.cs index 631b55017..79a9c0ea9 100644 --- a/OpenUtau.Core/Analysis/GameBackendFactory.cs +++ b/OpenUtau.Core/Analysis/GameBackendFactory.cs @@ -44,6 +44,23 @@ public static string ResolveChoice() { _ => "(none)", }; + /// + /// Load the configuration belonging to the backend that will actually be used. + /// This must not fall back to the ONNX package when only GGML is installed. + /// + public static GameConfig LoadResolvedConfig() { + string choice = ResolveChoice(); + if (choice == OnnxValue) { + string location = PackageManager.Inst.GetInstalledPath("game")!; + return Game.LoadConfig(location); + } + if (choice == GgmlValue) { + return GameGgmlBackend.LoadConfig(); + } + throw new InvalidOperationException( + "No GAME backend is installed. Install the GAME ONNX or GGML weights via the Package Manager."); + } + /// /// Construct the resolved backend and load its config from the installed /// GAME weights directory. diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index ee0209b2d..22d1d0eae 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -48,6 +48,7 @@ public class GameGgmlBackend : IGameBackend { volatile bool stopping = false; public string Name => "GGML"; + public GameConfig Config => config; private GameGgmlBackend(GameConfig config, string cliPath, string ggufPath) { this.config = config; @@ -66,31 +67,50 @@ private GameGgmlBackend(GameConfig config, string cliPath, string ggufPath) { return null; } - /// Locate the first .gguf weight file. Checks the dedicated - /// game-ggml-medium package first, then falls back to the shared game - /// package (so GGUF weights can coexist alongside the ONNX .onnx files). + /// Locate the largest .gguf weight file. An explicit location is + /// honored first; otherwise checks the dedicated game-ggml-medium package, + /// then the shared game package for backward compatibility. public static string? ResolveGgufPath(string? location = null) { - // 1. Preferred: dedicated ggml package (oudep installs to DependencyPath/game-ggml-medium) - string ggmlDir = Path.Combine(PathManager.Inst.DependencyPath, GgmlPackageId); - if (Directory.Exists(ggmlDir)) { - var gguf = Directory.GetFiles(ggmlDir, "*.gguf") - .OrderByDescending(f => new FileInfo(f).Length) - .FirstOrDefault(); - if (gguf != null) return gguf; - } - // 2. Fallback: shared game package (for users who placed GGUF alongside ONNX files) - string? gameLoc = location ?? PackageManager.Inst.GetInstalledPath(PackageId); - if (gameLoc != null && Directory.Exists(gameLoc)) { - return Directory.GetFiles(gameLoc, "*.gguf") - .OrderByDescending(f => new FileInfo(f).Length) - .FirstOrDefault(); + if (location != null) { + return FindLargestGguf(location); } - return null; + string ggmlDir = Path.Combine(PathManager.Inst.DependencyPath, GgmlPackageId); + string? gguf = FindLargestGguf(ggmlDir); + if (gguf != null) return gguf; + + string? gameLoc = PackageManager.Inst.GetInstalledPath(PackageId); + return gameLoc == null ? null : FindLargestGguf(gameLoc); } - /// True when both the CLI binary and a GGUF weight package are installed. + private static string? FindLargestGguf(string directory) { + if (!Directory.Exists(directory)) return null; + return Directory.GetFiles(directory, "*.gguf") + .OrderByDescending(f => new FileInfo(f).Length) + .FirstOrDefault(); + } + + /// True when the CLI, GGUF weights and backend config are installed. public static bool IsInstalled(string? location = null) { - return ResolveCliPath() != null && ResolveGgufPath(location) != null; + string? gguf = ResolveGgufPath(location); + return ResolveCliPath() != null && + gguf != null && + File.Exists(Path.Combine(Path.GetDirectoryName(gguf)!, "config.json")); + } + + /// Load config.json next to the GGUF weights. + public static GameConfig LoadConfig(string? location = null) { + string? gguf = ResolveGgufPath(location); + if (gguf == null) { + throw new InvalidOperationException("GAME GGML backend is missing .gguf weights."); + } + string configPath = Path.Combine(Path.GetDirectoryName(gguf)!, "config.json"); + if (!File.Exists(configPath)) { + throw new InvalidOperationException( + $"GAME GGML backend is missing config.json at {configPath}"); + } + var jsonText = File.ReadAllText(configPath, Encoding.UTF8); + return JsonSerializer.Deserialize(jsonText) + ?? throw new InvalidOperationException("Failed to parse GAME config.json"); } /// @@ -104,18 +124,7 @@ public static GameGgmlBackend Create() { throw new InvalidOperationException( "GAME GGML backend is not installed: missing CLI binary or .gguf weights."); } - // Load config from the GGML package dir (Dependencies/game-ggml-medium/), - // not the ONNX package dir — they are independent oudep packages. - string ggmlDir = Path.GetDirectoryName(gguf)!; - string configPath = Path.Combine(ggmlDir, "config.json"); - if (!File.Exists(configPath)) { - throw new InvalidOperationException( - $"GAME GGML backend is missing config.json at {configPath}"); - } - var jsonText = File.ReadAllText(configPath, System.Text.Encoding.UTF8); - GameConfig config = System.Text.Json.JsonSerializer.Deserialize(jsonText) - ?? throw new InvalidOperationException("Failed to parse GAME config.json"); - return new GameGgmlBackend(config, cli, gguf); + return new GameGgmlBackend(LoadConfig(), cli, gguf); } public bool EnsureLoaded() { diff --git a/OpenUtau.Core/Analysis/GameOnnxBackend.cs b/OpenUtau.Core/Analysis/GameOnnxBackend.cs index 4f090ab82..66c1ea872 100644 --- a/OpenUtau.Core/Analysis/GameOnnxBackend.cs +++ b/OpenUtau.Core/Analysis/GameOnnxBackend.cs @@ -30,6 +30,7 @@ public class GameOnnxBackend : IGameBackend { readonly string Location; public string Name => "ONNX"; + public GameConfig Config => config; public GameOnnxBackend(GameConfig config, string location) { this.config = config; diff --git a/OpenUtau.Core/Analysis/IGameBackend.cs b/OpenUtau.Core/Analysis/IGameBackend.cs index 06b7ed08d..7c28e96ec 100644 --- a/OpenUtau.Core/Analysis/IGameBackend.cs +++ b/OpenUtau.Core/Analysis/IGameBackend.cs @@ -18,6 +18,9 @@ public interface IGameBackend : IDisposable { /// Short display name for diagnostics ("ONNX", "GGML"). string Name { get; } + /// Configuration loaded from this backend's own package. + GameConfig Config { get; } + /// /// Load the model if not already loaded. Called lazily before the first /// inference. Returns true if the weights/executable are present and ready. diff --git a/OpenUtau.Test/Core/Analysis/GameGgmlBackendTest.cs b/OpenUtau.Test/Core/Analysis/GameGgmlBackendTest.cs new file mode 100644 index 000000000..ace83d887 --- /dev/null +++ b/OpenUtau.Test/Core/Analysis/GameGgmlBackendTest.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using OpenUtau.Core.Analysis; +using Xunit; + +namespace OpenUtau.Core { + public class GameGgmlBackendTest { + [Fact] + public void ResolveGgufPathHonorsExplicitLocation() { + string directory = CreateTempDirectory(); + try { + string small = Path.Combine(directory, "small.gguf"); + string medium = Path.Combine(directory, "medium.gguf"); + File.WriteAllBytes(small, new byte[1]); + File.WriteAllBytes(medium, new byte[2]); + + Assert.Equal(medium, GameGgmlBackend.ResolveGgufPath(directory)); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void LoadConfigReadsConfigNextToGgufWithoutOnnxPackage() { + string directory = CreateTempDirectory(); + try { + File.WriteAllBytes(Path.Combine(directory, "model.gguf"), new byte[1]); + File.WriteAllText( + Path.Combine(directory, "config.json"), + "{\"samplerate\":32000,\"timestep\":0.02,\"languages\":{\"zh\":1}}"); + + GameConfig config = GameGgmlBackend.LoadConfig(directory); + + Assert.Equal(32000, config.SampleRate); + Assert.Equal(0.02f, config.Timestep); + Assert.Equal(1, config.Languages!["zh"]); + } finally { + Directory.Delete(directory, true); + } + } + + [Fact] + public void LoadConfigRejectsMissingConfigNextToGguf() { + string directory = CreateTempDirectory(); + try { + File.WriteAllBytes(Path.Combine(directory, "model.gguf"), new byte[1]); + + var error = Assert.Throws( + () => GameGgmlBackend.LoadConfig(directory)); + + Assert.Contains("config.json", error.Message); + } finally { + Directory.Delete(directory, true); + } + } + + private static string CreateTempDirectory() { + string directory = Path.Combine(Path.GetTempPath(), $"OpenUtau.GameGgmlBackendTest.{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + return directory; + } + } +} diff --git a/OpenUtau/ViewModels/TranscribeViewModel.cs b/OpenUtau/ViewModels/TranscribeViewModel.cs index 148a91cae..7503aaf2e 100644 --- a/OpenUtau/ViewModels/TranscribeViewModel.cs +++ b/OpenUtau/ViewModels/TranscribeViewModel.cs @@ -100,11 +100,12 @@ public TranscribeViewModel() { SelectedAlgorithm = TranscribeAlgorithm.SOME; } - // Load GAME config (no model sessions) to populate options + // Load config from the backend that will actually run (no model sessions). + // In particular, GGML-only installs must not probe the ONNX package. GameConfig? gameConfig = null; if (GameAvailable) { try { - gameConfig = Game.LoadConfig(); + gameConfig = GameBackendFactory.LoadResolvedConfig(); } catch { GameAvailable = false; } From cf7e6a2754355ecc3c06296580283ec00b2ed431 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sun, 2 Aug 2026 21:45:46 +0800 Subject: [PATCH 7/8] fix: harden GAME GGML cross-platform integration --- OpenUtau.Core/Analysis/Game.cs | 4 +++- OpenUtau.Core/Analysis/GameGgmlBackend.cs | 17 ++++++++++++++++- OpenUtau/Strings/Strings.axaml | 1 + OpenUtau/Views/PreferencesDialog.axaml | 5 +---- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/OpenUtau.Core/Analysis/Game.cs b/OpenUtau.Core/Analysis/Game.cs index 6df8db2ea..0713f2e88 100644 --- a/OpenUtau.Core/Analysis/Game.cs +++ b/OpenUtau.Core/Analysis/Game.cs @@ -72,7 +72,9 @@ public class Game : MidiExtractor { /// Check if any GAME backend is installed (ONNX or GGML) without loading models. /// public static bool IsInstalled(string? location = null) { - return GameBackendFactory.IsAnyInstalled(); + return location != null + ? GameOnnxBackend.IsInstalled(location) + : GameBackendFactory.IsAnyInstalled(); } /// diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index 22d1d0eae..6c799bc13 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -131,9 +131,9 @@ public bool EnsureLoaded() { if (process != null && !process.HasExited) return true; Log.Information("GAME(GGML): launching serve subprocess cli={Cli} gguf={Gguf}", cliPath, ggufPath); + EnsureExecutable(cliPath); var psi = new ProcessStartInfo { FileName = cliPath, - Arguments = $"serve \"{ggufPath}\"", UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -141,6 +141,8 @@ public bool EnsureLoaded() { CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, }; + psi.ArgumentList.Add("serve"); + psi.ArgumentList.Add(ggufPath); process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start game_ggml_cli."); // Binary stdin: avoid a text writer wrapper to control framing exactly. @@ -301,6 +303,19 @@ private void StartStderrPump() { }); } + private static void EnsureExecutable(string path) { + if (OS.IsWindows()) return; + const UnixFileMode executableMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + try { + File.SetUnixFileMode(path, executableMode); + } catch (Exception e) { + Log.Warning(e, "GAME(GGML): failed to set executable permission on {Cli}", path); + } + } + // ------------------------------------------------------------------------- // Little-endian binary writers (no BitConverter pinned-buffer dance needed) // ------------------------------------------------------------------------- diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index fe9830797..fd21315d0 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -619,6 +619,7 @@ Warning: this option removes custom presets. Use system default device Test Rendering + GAME Inference Backend Default renderer (for classic voicebanks) DiffSinger Render Depth DiffSinger Render Steps for Acoustic diff --git a/OpenUtau/Views/PreferencesDialog.axaml b/OpenUtau/Views/PreferencesDialog.axaml index f86a3232f..365b80049 100644 --- a/OpenUtau/Views/PreferencesDialog.axaml +++ b/OpenUtau/Views/PreferencesDialog.axaml @@ -207,11 +207,8 @@ - - + - From 41dce7da88aa9d84c0499ab61c5d87607e1b57a9 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sun, 16 Aug 2026 23:52:21 +0800 Subject: [PATCH 8/8] docs: add recommended GGML EP config comment (per 7-channel benchmark) --- OpenUtau.Core/Analysis/GameGgmlBackend.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/OpenUtau.Core/Analysis/GameGgmlBackend.cs b/OpenUtau.Core/Analysis/GameGgmlBackend.cs index 6c799bc13..65396f01f 100644 --- a/OpenUtau.Core/Analysis/GameGgmlBackend.cs +++ b/OpenUtau.Core/Analysis/GameGgmlBackend.cs @@ -26,6 +26,26 @@ namespace OpenUtau.Core.Analysis; /// One instance owns exactly one subprocess; the /// subclass disposes it after the whole transcription. /// +/// +/// Recommended GGML configuration (7-channel 60 s benchmark, nsteps=8, +/// frame-level metrics vs torch-CUDA fp32 no-cache baseline — all EPS ≥ 0.97 RPA): +/// +/// | platform | GPU | weights | EP offered by oudep pkg | +/// |---------------|---------------|---------|--------------------------| +/// | Windows | NVIDIA | F32 | CUDA (fallback Vulkan) | +/// | Windows | Intel/AMD/etc | F32 | Vulkan | +/// | Windows | integrated | F32/Q8 | CPU (+ DBCache): 0.44x wall | +/// | Linux | NVIDIA | F32 | CUDA (fallback Vulkan) | +/// | Linux | Nouveau/AMD | F32 | Vulkan | +/// | macOS | Apple Silicon | F32 | Metal (only EP) | +/// | macOS | Intel | F32 | Metal (cross-compiled) | +/// +/// Rules of thumb (no user config required — CLI picks backend from GGUF/EP): +/// * GPU present → F32 weights; CPU-only → CPU EP with DBCache on by default +/// * on GPU, Vulkan is our smallest-VRAM (≈+0.3 GiB) and CUDA the fastest +/// * Q8 saves VRAM/RAM (~3.4x smaller weights) at near-lossless quality but may +/// flip a boundary note on Vulkan — prefer the -full package if you need +/// bit-consistent output; both are equally valid in practice. public class GameGgmlBackend : IGameBackend { private const string PackageId = "game"; // Single oudep package contains both the CLI binary and the GGUF weights.