From 542702a053445e20fb16e407e19553b1f208b83e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:03:22 +0000 Subject: [PATCH] Support independent VST instruments for melody and chords The VST2 playback engine sent both melody and chord notes to one loaded plugin instance, unlike the bundled SoundFont path which already lets melody and chords use independent General MIDI instruments (they're multi-timbral across MIDI channels; most VST2 instrument plugins are not, so a single shared plugin couldn't actually give melody and chords distinct sounds). - AudioEngineDialog now has separate Melody/Chords VST plugin path fields; leaving Chords blank reuses the melody plugin for both parts (preserves today's single-plugin behavior as the default). - BassVstSynthesizer optionally loads a second BASSVST channel for the chord plugin, routing NoteOn/NoteOff/ProgramChange by ScoreMidiSchedule.MelodyChannel/ChordChannel; falls back to one shared handle when the chord path is empty or the same file, so the common case doesn't pay for a second plugin instance. - AppTheme persists VstMelodyPluginPath/VstChordPluginPath instead of one VstPluginPath, migrating an existing settings.json's old field to VstMelodyPluginPath on load so a previously configured plugin isn't silently dropped. Verified by compiling the real, unmodified source via `dotnet build` against the true NuGet/WinForms graph, then executing that same built assembly's IL under Mono/Xvfb for the full existing multi-tab/playback test suite -- no regressions. The BASSVST native audio path itself isn't exercised (no VST plugin files or audio hardware in this sandbox); please confirm on a real Windows machine with an actual VST2 instrument plugin. --- JianpuEditor/AppBootstrapper.cs | 8 +- JianpuEditor/MainForm.cs | 19 +++-- JianpuEditor/Rendering/AppTheme.cs | 37 ++++++--- JianpuEditor/Services/BassVstSynthesizer.cs | 85 +++++++++++++++----- JianpuEditor/Views/AudioEngineDialog.cs | 87 +++++++++++++++------ README.md | 2 +- 6 files changed, 172 insertions(+), 66 deletions(-) diff --git a/JianpuEditor/AppBootstrapper.cs b/JianpuEditor/AppBootstrapper.cs index 7f77cc3..c865384 100644 --- a/JianpuEditor/AppBootstrapper.cs +++ b/JianpuEditor/AppBootstrapper.cs @@ -65,16 +65,16 @@ public static ServiceProvider ConfigureServices() private static IMidiOutput CreateMidiOutput() { - var vstPluginPath = AppTheme.VstPluginPath; - if (!string.IsNullOrWhiteSpace(vstPluginPath)) + var melodyVstPluginPath = AppTheme.VstMelodyPluginPath; + if (!string.IsNullOrWhiteSpace(melodyVstPluginPath)) { try { - return new BassVstSynthesizer(vstPluginPath); + return new BassVstSynthesizer(melodyVstPluginPath, AppTheme.VstChordPluginPath); } catch (Exception ex) { - AppLog.Exception("Failed to initialize BASSVST plugin '" + vstPluginPath + "', falling back to bundled SoundFont", ex); + AppLog.Exception("Failed to initialize BASSVST plugin(s) (melody='" + melodyVstPluginPath + "', chords='" + AppTheme.VstChordPluginPath + "'), falling back to bundled SoundFont", ex); } } diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index bcd317a..b67dbbb 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -1954,26 +1954,33 @@ private void ShowInstrumentDialog() private void ShowAudioEngineDialog() { - using (var dialog = new AudioEngineDialog(AppTheme.VstPluginPath, _midiOutput.EngineName)) + using (var dialog = new AudioEngineDialog(AppTheme.VstMelodyPluginPath, AppTheme.VstChordPluginPath, _midiOutput.EngineName)) { if (dialog.ShowDialog(this) != DialogResult.OK) { return; } - var selectedPath = dialog.SelectedVstPluginPath; - if (!string.IsNullOrWhiteSpace(selectedPath) && !File.Exists(selectedPath)) + var selectedMelodyPath = dialog.SelectedMelodyVstPluginPath; + var selectedChordPath = dialog.SelectedChordVstPluginPath; + if (!string.IsNullOrWhiteSpace(selectedMelodyPath) && !File.Exists(selectedMelodyPath)) { - MessageBox.Show("VST plugin file not found: " + selectedPath, "Audio Engine", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show("VST plugin file not found: " + selectedMelodyPath, "Audio Engine", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - if (AppTheme.VstPluginPath == selectedPath) + if (!string.IsNullOrWhiteSpace(selectedChordPath) && !File.Exists(selectedChordPath)) { + MessageBox.Show("VST plugin file not found: " + selectedChordPath, "Audio Engine", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - AppTheme.SetVstPluginPath(selectedPath); + if (AppTheme.VstMelodyPluginPath == selectedMelodyPath && AppTheme.VstChordPluginPath == selectedChordPath) + { + return; + } + + AppTheme.SetVstPluginPaths(selectedMelodyPath, selectedChordPath); MessageBox.Show( "Audio engine setting saved. Restart Jianpu Editor for this to take effect.", "Audio Engine", diff --git a/JianpuEditor/Rendering/AppTheme.cs b/JianpuEditor/Rendering/AppTheme.cs index 89cf37d..e470ea0 100644 --- a/JianpuEditor/Rendering/AppTheme.cs +++ b/JianpuEditor/Rendering/AppTheme.cs @@ -51,8 +51,14 @@ public static bool IsDarkMode /// to the Chinese/Western convention (lines below) this renderer defaults to. public static bool UnderlinesAbove { get; private set; } - /// Path to a VST2 instrument plugin DLL to use for playback instead of the bundled SoundFont, or empty to use the SoundFont. - public static string VstPluginPath { get; private set; } = string.Empty; + /// Path to a VST2 instrument plugin DLL to use for the melody part instead of the + /// bundled SoundFont, or empty to use the SoundFont. Required for VST playback; the chord + /// part falls back to this same plugin when is empty. + public static string VstMelodyPluginPath { get; private set; } = string.Empty; + + /// Path to a separate VST2 instrument plugin DLL for the chord part, or empty to + /// reuse for chords too. + public static string VstChordPluginPath { get; private set; } = string.Empty; public static event Action ThemeChanged; @@ -69,14 +75,18 @@ public static void Load() var settings = JsonConvert.DeserializeObject(json); Current = (settings?.DarkMode ?? false) ? Theme.ManuscriptDark : Theme.ManuscriptLight; FillMeasurePlaceholdersOnAdd = settings?.FillMeasurePlaceholdersOnAdd ?? true; - VstPluginPath = settings?.VstPluginPath ?? string.Empty; + // VstMelodyPluginPath falls back to the pre-dual-instrument VstPluginPath field so + // a setting saved by an older build isn't silently dropped. + VstMelodyPluginPath = settings?.VstMelodyPluginPath ?? settings?.VstPluginPath ?? string.Empty; + VstChordPluginPath = settings?.VstChordPluginPath ?? string.Empty; UnderlinesAbove = settings?.UnderlinesAbove ?? false; } catch { Current = Theme.ManuscriptLight; FillMeasurePlaceholdersOnAdd = true; - VstPluginPath = string.Empty; + VstMelodyPluginPath = string.Empty; + VstChordPluginPath = string.Empty; UnderlinesAbove = false; } } @@ -128,15 +138,17 @@ public static void SetUnderlinesAbove(bool enabled, bool persist = true) ThemeChanged?.Invoke(); } - public static void SetVstPluginPath(string path, bool persist = true) + public static void SetVstPluginPaths(string melodyPath, string chordPath, bool persist = true) { - path = path ?? string.Empty; - if (VstPluginPath == path) + melodyPath = melodyPath ?? string.Empty; + chordPath = chordPath ?? string.Empty; + if (VstMelodyPluginPath == melodyPath && VstChordPluginPath == chordPath) { return; } - VstPluginPath = path; + VstMelodyPluginPath = melodyPath; + VstChordPluginPath = chordPath; if (persist) { Save(); @@ -248,7 +260,8 @@ private static void Save() { DarkMode = Current.IsDark, FillMeasurePlaceholdersOnAdd = FillMeasurePlaceholdersOnAdd, - VstPluginPath = VstPluginPath, + VstMelodyPluginPath = VstMelodyPluginPath, + VstChordPluginPath = VstChordPluginPath, UnderlinesAbove = UnderlinesAbove }, Formatting.Indented); @@ -266,8 +279,14 @@ private sealed class ThemeSettings public bool FillMeasurePlaceholdersOnAdd { get; set; } = true; + /// Legacy field from before melody/chord VST plugins were separate; read as a + /// fallback for VstMelodyPluginPath, never written. public string VstPluginPath { get; set; } = string.Empty; + public string VstMelodyPluginPath { get; set; } = string.Empty; + + public string VstChordPluginPath { get; set; } = string.Empty; + public bool UnderlinesAbove { get; set; } } } diff --git a/JianpuEditor/Services/BassVstSynthesizer.cs b/JianpuEditor/Services/BassVstSynthesizer.cs index 074ee0a..48ba827 100644 --- a/JianpuEditor/Services/BassVstSynthesizer.cs +++ b/JianpuEditor/Services/BassVstSynthesizer.cs @@ -8,9 +8,14 @@ namespace JianpuEditor.Services { /// - /// Hosts a VST2 instrument plugin (via BASSVST) as the playback engine, as an alternative to - /// the bundled SoundFont. Both the melody and chord parts are sent to this one plugin instance - /// on separate MIDI channels (0 and 1), the same way BassMidiSynthesizer uses one SoundFont for both. + /// Hosts one or two VST2 instrument plugins (via BASSVST) as the playback engine, as an + /// alternative to the bundled SoundFont. Most VST2 instruments present a single sound + /// regardless of MIDI channel (unlike a GM SoundFont, which is multi-timbral across channels), + /// so genuinely independent melody/chord instruments need two separately loaded plugin + /// instances -- one per part, routed by / + /// . When only a melody plugin is configured (or + /// the chord plugin path is the same file), both parts share that one loaded instance instead, + /// matching the single-plugin behavior this class originally had. /// internal sealed class BassVstSynthesizer : IMidiOutput { @@ -18,16 +23,26 @@ internal sealed class BassVstSynthesizer : IMidiOutput private const int SampleRate = 44100; private const int MidiChannelCount = 16; - private readonly int _vstHandle; + private readonly int _melodyHandle; + private readonly int _chordHandle; + private readonly bool _sharedHandle; private bool _disposed; public string EngineName { get; } - public BassVstSynthesizer(string vstPluginPath) + public BassVstSynthesizer(string melodyPluginPath, string chordPluginPath) { - if (string.IsNullOrWhiteSpace(vstPluginPath) || !File.Exists(vstPluginPath)) + if (string.IsNullOrWhiteSpace(melodyPluginPath) || !File.Exists(melodyPluginPath)) { - throw new FileNotFoundException("VST plugin file not found.", vstPluginPath); + throw new FileNotFoundException("VST plugin file not found.", melodyPluginPath); + } + + _sharedHandle = string.IsNullOrWhiteSpace(chordPluginPath) + || string.Equals(Path.GetFullPath(chordPluginPath), Path.GetFullPath(melodyPluginPath), StringComparison.OrdinalIgnoreCase); + + if (!_sharedHandle && !File.Exists(chordPluginPath)) + { + throw new FileNotFoundException("VST plugin file not found.", chordPluginPath); } if (!Bass.Init()) @@ -35,42 +50,67 @@ public BassVstSynthesizer(string vstPluginPath) throw new InvalidOperationException("Failed to initialize BASS audio output. Error: " + Bass.LastError); } - _vstHandle = BassVst.ChannelCreate(SampleRate, OutputChannels, vstPluginPath, BassFlags.Default); - if (_vstHandle == 0) + _melodyHandle = LoadPlugin(melodyPluginPath); + _chordHandle = _sharedHandle ? _melodyHandle : LoadPlugin(chordPluginPath); + + EngineName = _sharedHandle + ? "VST2: " + Path.GetFileName(melodyPluginPath) + : "VST2: " + Path.GetFileName(melodyPluginPath) + " / " + Path.GetFileName(chordPluginPath); + AppLog.Info("BassVstSynthesizer initialized: melody=" + melodyPluginPath + ", chords=" + (_sharedHandle ? "(same)" : chordPluginPath)); + } + + private static int LoadPlugin(string pluginPath) + { + var handle = BassVst.ChannelCreate(SampleRate, OutputChannels, pluginPath, BassFlags.Default); + if (handle == 0) { + var error = Bass.LastError; Bass.Free(); - throw new InvalidOperationException("Failed to load VST plugin: " + vstPluginPath + ". Error: " + Bass.LastError); + throw new InvalidOperationException("Failed to load VST plugin: " + pluginPath + ". Error: " + error); } - if (!Bass.ChannelPlay(_vstHandle)) + if (!Bass.ChannelPlay(handle)) { - AppLog.Error("Failed to start BASSVST channel playback. Error: " + Bass.LastError); + AppLog.Error("Failed to start BASSVST channel playback for '" + pluginPath + "'. Error: " + Bass.LastError); } - EngineName = "VST2: " + Path.GetFileName(vstPluginPath); - AppLog.Info("BassVstSynthesizer initialized: " + vstPluginPath); + return handle; + } + + private int HandleFor(int channel) + { + return channel == ScoreMidiSchedule.ChordChannel ? _chordHandle : _melodyHandle; } public void NoteOn(int channel, int note, int velocity) { - BassVst.ProcessEvent(_vstHandle, channel, (int)MidiEventType.Note, note | (velocity << 8)); + BassVst.ProcessEvent(HandleFor(channel), channel, (int)MidiEventType.Note, note | (velocity << 8)); } public void NoteOff(int channel, int note) { - BassVst.ProcessEvent(_vstHandle, channel, (int)MidiEventType.Note, note); + BassVst.ProcessEvent(HandleFor(channel), channel, (int)MidiEventType.Note, note); } public void ProgramChange(int channel, int program) { - BassVst.ProcessEvent(_vstHandle, channel, (int)MidiEventType.Program, program); + BassVst.ProcessEvent(HandleFor(channel), channel, (int)MidiEventType.Program, program); } public void AllNotesOff() + { + AllNotesOff(_melodyHandle); + if (!_sharedHandle) + { + AllNotesOff(_chordHandle); + } + } + + private static void AllNotesOff(int handle) { for (var channel = 0; channel < MidiChannelCount; channel++) { - BassVst.ProcessEvent(_vstHandle, channel, (int)MidiEventType.NotesOff, 0); + BassVst.ProcessEvent(handle, channel, (int)MidiEventType.NotesOff, 0); } } @@ -91,9 +131,14 @@ public void Dispose() AppLog.Exception("Failed to send AllNotesOff before closing BASSVST", ex); } - if (_vstHandle != 0) + if (_melodyHandle != 0) + { + BassVst.ChannelFree(_melodyHandle); + } + + if (!_sharedHandle && _chordHandle != 0) { - BassVst.ChannelFree(_vstHandle); + BassVst.ChannelFree(_chordHandle); } Bass.Free(); diff --git a/JianpuEditor/Views/AudioEngineDialog.cs b/JianpuEditor/Views/AudioEngineDialog.cs index 48ffc5a..460ad04 100644 --- a/JianpuEditor/Views/AudioEngineDialog.cs +++ b/JianpuEditor/Views/AudioEngineDialog.cs @@ -7,10 +7,12 @@ public sealed class AudioEngineDialog : Form { private readonly RadioButton _soundFontOption; private readonly RadioButton _vstOption; - private readonly TextBox _vstPathBox; - private readonly Button _browseButton; + private readonly TextBox _melodyVstPathBox; + private readonly Button _melodyBrowseButton; + private readonly TextBox _chordVstPathBox; + private readonly Button _chordBrowseButton; - public AudioEngineDialog(string currentVstPluginPath, string activeEngineName) + public AudioEngineDialog(string currentMelodyVstPluginPath, string currentChordVstPluginPath, string activeEngineName) { Text = "Audio Engine"; FormBorderStyle = FormBorderStyle.FixedDialog; @@ -18,9 +20,9 @@ public AudioEngineDialog(string currentVstPluginPath, string activeEngineName) MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; - ClientSize = new Size(420, 214); + ClientSize = new Size(420, 268); - var hasVstPath = !string.IsNullOrWhiteSpace(currentVstPluginPath); + var hasVstPath = !string.IsNullOrWhiteSpace(currentMelodyVstPluginPath); var activeLabel = new Label { @@ -40,51 +42,77 @@ public AudioEngineDialog(string currentVstPluginPath, string activeEngineName) _vstOption = new RadioButton { - Text = "VST2 instrument plugin:", + Text = "VST2 instrument plugin(s):", Location = new Point(16, 68), AutoSize = true, Checked = hasVstPath }; - _vstPathBox = new TextBox + var melodyLabel = new Label { Text = "Melody:", Location = new Point(36, 98), AutoSize = true }; + _melodyVstPathBox = new TextBox { - Location = new Point(36, 94), - Width = 300, - Text = currentVstPluginPath ?? string.Empty, + Location = new Point(100, 94), + Width = 236, + Text = currentMelodyVstPluginPath ?? string.Empty, Enabled = hasVstPath }; - - _browseButton = new Button + _melodyBrowseButton = new Button { Text = "Browse...", Location = new Point(340, 92), Width = 64, Enabled = hasVstPath }; - _browseButton.Click += OnBrowseClicked; + _melodyBrowseButton.Click += (s, e) => BrowseInto(_melodyVstPathBox); + + var chordLabel = new Label { Text = "Chords:", Location = new Point(36, 126), AutoSize = true }; + _chordVstPathBox = new TextBox + { + Location = new Point(100, 122), + Width = 236, + Text = currentChordVstPluginPath ?? string.Empty, + Enabled = hasVstPath + }; + _chordBrowseButton = new Button + { + Text = "Browse...", + Location = new Point(340, 120), + Width = 64, + Enabled = hasVstPath + }; + _chordBrowseButton.Click += (s, e) => BrowseInto(_chordVstPathBox); _vstOption.CheckedChanged += (s, e) => { - _vstPathBox.Enabled = _vstOption.Checked; - _browseButton.Enabled = _vstOption.Checked; + _melodyVstPathBox.Enabled = _vstOption.Checked; + _melodyBrowseButton.Enabled = _vstOption.Checked; + _chordVstPathBox.Enabled = _vstOption.Checked; + _chordBrowseButton.Enabled = _vstOption.Checked; }; var hintLabel = new Label { - Text = "Hosts a VST2 instrument DLL (not VST3) via BASSVST for both melody and chords.\nTakes effect after restarting Jianpu Editor. If the configured plugin fails to\nload, playback silently falls back to the bundled SoundFont.", - Location = new Point(16, 126), - Size = new Size(388, 50), + Text = "Hosts VST2 instrument DLLs (not VST3) via BASSVST -- one for melody, one for\n" + + "chords. Leave Chords blank to use the melody plugin for both. Takes effect\n" + + "after restarting Jianpu Editor. If a configured plugin fails to load, playback\n" + + "silently falls back to the bundled SoundFont.", + Location = new Point(16, 152), + Size = new Size(388, 66), ForeColor = Color.DimGray }; - var okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(236, 176), Width = 76 }; - var cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(320, 176), Width = 76 }; + var okButton = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(236, 230), Width = 76 }; + var cancelButton = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(320, 230), Width = 76 }; Controls.Add(activeLabel); Controls.Add(_soundFontOption); Controls.Add(_vstOption); - Controls.Add(_vstPathBox); - Controls.Add(_browseButton); + Controls.Add(melodyLabel); + Controls.Add(_melodyVstPathBox); + Controls.Add(_melodyBrowseButton); + Controls.Add(chordLabel); + Controls.Add(_chordVstPathBox); + Controls.Add(_chordBrowseButton); Controls.Add(hintLabel); Controls.Add(okButton); Controls.Add(cancelButton); @@ -93,12 +121,19 @@ public AudioEngineDialog(string currentVstPluginPath, string activeEngineName) } /// Empty string means "use the bundled SoundFont". - public string SelectedVstPluginPath + public string SelectedMelodyVstPluginPath + { + get { return _vstOption.Checked ? (_melodyVstPathBox.Text ?? string.Empty).Trim() : string.Empty; } + } + + /// Empty string means "reuse the melody plugin for chords too" (or "use the + /// bundled SoundFont", if is also empty). + public string SelectedChordVstPluginPath { - get { return _vstOption.Checked ? (_vstPathBox.Text ?? string.Empty).Trim() : string.Empty; } + get { return _vstOption.Checked ? (_chordVstPathBox.Text ?? string.Empty).Trim() : string.Empty; } } - private void OnBrowseClicked(object sender, System.EventArgs e) + private void BrowseInto(TextBox targetBox) { using (var dialog = new OpenFileDialog { @@ -108,7 +143,7 @@ private void OnBrowseClicked(object sender, System.EventArgs e) { if (dialog.ShowDialog(this) == DialogResult.OK) { - _vstPathBox.Text = dialog.FileName; + targetBox.Text = dialog.FileName; } } } diff --git a/README.md b/README.md index 480d7e3..be99042 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - `Edit → Instruments...` (or the toolbar "Instruments..." button) picks a General MIDI instrument for the melody and for chords independently - Applies to both live playback and MIDI export, so the exported file sounds the same as in-app playback; saved with the score (`MelodyInstrument`/`ChordInstrument`, default Acoustic Grand Piano) - **VST instrument plugin (optional)** - - `Edit → Audio Engine...` can point playback at a VST2 instrument plugin DLL instead of the bundled SoundFont, hosted via BASSVST; both melody and chords are sent to the one loaded plugin + - `Edit → Audio Engine...` can point playback at VST2 instrument plugin DLLs instead of the bundled SoundFont, hosted via BASSVST, with independent melody and chord plugins (leave Chords blank to reuse the melody plugin for both) -- most VST2 instruments aren't multi-timbral across MIDI channels the way the bundled SoundFont is, so a single shared plugin can't otherwise give melody and chords distinct sounds - This is a machine-local app preference (not saved in the score file, since a plugin path isn't portable between machines) and takes effect after restarting the app - VST2 only, not VST3; MIDI export is unaffected (always raw MIDI regardless of playback engine) - The toolbar shows an **"Engine: ..."** indicator (next to "Instruments...") naming whichever engine is actually active — click it to open `Edit → Audio Engine...`, which also lists the active engine at the top. If a configured VST plugin fails to load, playback silently falls back to the bundled SoundFont; the indicator and dialog reflect that fallback rather than the (non-functional) configured path