Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MIDI playback support with custom SoundFonts #3613

Merged
merged 5 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ repos:
hooks:
- id: mypy
args: ["--pretty"]
additional_dependencies: ["pygobject-stubs", "types-PyYAML", "types-Markdown", "types-requests", "types-pycurl", "types-chardet", "pytest-stub", "types-orjson", "pathvalidate", "requirements-parser", "icoextract", "fvs", "patool", "git+https://gitlab.com/TheEvilSkeleton/vkbasalt-cli.git@main"]
additional_dependencies: ["pygobject-stubs", "types-PyYAML", "types-Markdown", "types-requests", "types-pycurl", "types-chardet", "pytest-stub", "types-orjson", "pathvalidate", "requirements-parser", "icoextract", "fvs", "patool", "pyfluidsynth", "git+https://gitlab.com/TheEvilSkeleton/vkbasalt-cli.git@main"]
1 change: 1 addition & 0 deletions bottles/backend/managers/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ def get_programs(self, config: BottleConfig) -> list[dict]:
"pre_script": _program.get("pre_script"),
"post_script": _program.get("post_script"),
"folder": _program.get("folder", program_folder),
"midi_soundfont": _program.get("midi_soundfont"),
"dxvk": _program.get("dxvk"),
"vkd3d": _program.get("vkd3d"),
"dxvk_nvapi": _program.get("dxvk_nvapi"),
Expand Down
1 change: 1 addition & 0 deletions bottles/backend/utils/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ bottles_sources = [
'display.py',
'gpu.py',
'manager.py',
'midi.py',
'vulkan.py',
'terminal.py',
'file.py',
Expand Down
110 changes: 110 additions & 0 deletions bottles/backend/utils/midi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# midi.py
#
# Copyright 2025 The Bottles Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, in version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from typing import Self

from ctypes import c_void_p
from fluidsynth import cfunc, Synth # type: ignore [import-untyped]

from bottles.backend.logger import Logger
from bottles.backend.models.config import BottleConfig
from bottles.backend.wine.reg import Reg

logging = Logger()


class FluidSynth:
"""FluidSynth instance bounded to a unique SoundFont (.sf2, .sf3) file."""

__active_instances: dict[int, Self] = {}

@classmethod
def find_or_create(cls, soundfont_path: str) -> Self:
"""
Search for running FluidSynth instance and return it.
If nonexistent, create and add it to active ones beforehand.
"""

for fs in cls.__active_instances.values():
if fs.soundfont_path == soundfont_path:
fs.program_count += 1
return fs

fs = cls(soundfont_path)
cls.__active_instances[fs.id] = fs
return fs

def __init__(self, soundfont_path: str):
"""Build a new FluidSynth object from SoundFont file path."""
self.soundfont_path = soundfont_path
self.id = self.__get_vacant_id()
self.__start()
self.program_count = 1

@classmethod
def __get_vacant_id(cls) -> int:
"""Get smallest 0-indexed ID currently not in use by a SoundFont."""
n = len(cls.__active_instances)
return next(i for i in range(n + 1) if i not in cls.__active_instances)

def __start(self):
"""Start FluidSynth synthetizer with loaded SoundFont."""
logging.info(
"Starting new FluidSynth server with SoundFont"
f" #{self.id} ('{self.soundfont_path}')…"
)
synth = Synth(channels=16)
synth.start()
sfid = synth.sfload(self.soundfont_path)
synth.program_select(0, sfid, 0, 0)
self.synth = synth

def register_as_current(self, config: BottleConfig):
"""
Update Wine registry with this instance's ID, instructing
MIDI mapping to load the correct instrument set on program startup.
"""
reg = Reg(config)
reg.add(
key="HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Multimedia\\MIDIMap",
value="CurrentInstrument",
data=f"#{self.id}",
value_type="REG_SZ",
)

def decrement_program_counter(self):
"""Decrement program counter; if it reaches zero, delete this instance."""
self.program_count -= 1
if self.program_count == 0:
self.__delete()

def __delete(self):
"""Kill underlying synthetizer and remove FluidSynth instance from dict."""

def __delete_synth(synth: Synth):
"""Bind missing function and run deletion routines."""
delete_fluid_midi_driver = cfunc(
"delete_fluid_midi_driver", c_void_p, ("driver", c_void_p, 1)
)
delete_fluid_midi_driver(synth.midi_driver)
synth.delete()

logging.info(
"Killing FluidSynth server with SoundFont"
f" #{self.id} ('{self.soundfont_path}')…"
)
__delete_synth(self.synth)
self.__active_instances.pop(self.id)
19 changes: 19 additions & 0 deletions bottles/backend/wine/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from bottles.backend.models.config import BottleConfig
from bottles.backend.models.result import Result
from bottles.backend.utils.manager import ManagerUtils
from bottles.backend.utils.midi import FluidSynth
from bottles.backend.wine.cmd import CMD
from bottles.backend.wine.explorer import Explorer
from bottles.backend.wine.msiexec import MsiExec
Expand All @@ -33,6 +34,7 @@ def __init__(
pre_script: str | None = None,
post_script: str | None = None,
cwd: str | None = None,
midi_soundfont: str | None = None,
monitoring: list | None = None,
program_dxvk: bool | None = None,
program_vkd3d: bool | None = None,
Expand Down Expand Up @@ -62,12 +64,20 @@ def __init__(
self.pre_script = pre_script
self.post_script = post_script
self.cwd = self.__get_cwd(cwd)
self.midi_soundfont = midi_soundfont
self.monitoring = monitoring
self.use_gamescope = program_gamescope
self.use_virt_desktop = program_virt_desktop

env_dll_overrides = []

self.fluidsynth = None
if (soundfont_path := midi_soundfont) not in (None, ""):
TheEvilSkeleton marked this conversation as resolved.
Show resolved Hide resolved
# FluidSynth instance is bound to WineExecutor as a member to control
# the former's lifetime (deleted when no more references from executors)
self.fluidsynth = FluidSynth.find_or_create(soundfont_path)
self.fluidsynth.register_as_current(config)

# None = use global DXVK value
if program_dxvk is not None:
# DXVK is globally activated, but disabled for the program
Expand Down Expand Up @@ -122,6 +132,7 @@ def run_program(cls, config: BottleConfig, program: dict, terminal: bool = False
pre_script=program.get("pre_script"),
post_script=program.get("post_script"),
cwd=program.get("folder"),
midi_soundfont=program.get("midi_soundfont"),
terminal=terminal,
program_dxvk=program.get("dxvk"),
program_vkd3d=program.get("vkd3d"),
Expand Down Expand Up @@ -212,6 +223,7 @@ def run_cli(self):
pre_script=self.pre_script,
post_script=self.post_script,
cwd=self.cwd,
midi_soundfont=self.midi_soundfont,
)
return Result(status=True, data={"output": res})

Expand Down Expand Up @@ -278,6 +290,7 @@ def __launch_exe(self):
pre_script=self.pre_script,
post_script=self.post_script,
cwd=self.cwd,
midi_soundfont=self.midi_soundfont,
)
res = winecmd.run()
self.__set_monitors()
Expand Down Expand Up @@ -316,6 +329,7 @@ def __launch_with_starter(self):
pre_script=self.pre_script,
post_script=self.post_script,
cwd=self.cwd,
midi_soundfont=self.midi_soundfont,
)
self.__set_monitors()
return Result(status=True, data={"output": res})
Expand Down Expand Up @@ -349,3 +363,8 @@ def __set_monitors(self):
winedbg = WineDbg(self.config, silent=True)
for m in self.monitoring:
winedbg.wait_for_process(name=m)

def __del__(self):
"""On exit, kill FluidSynth instance if this was the last executor using it."""
if self.fluidsynth:
self.fluidsynth.decrement_program_counter()
2 changes: 2 additions & 0 deletions bottles/backend/wine/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def run(
pre_script: str | None = None,
post_script: str | None = None,
cwd: str | None = None,
midi_soundfont: str | None = None,
):
winepath = WinePath(self.config)

Expand All @@ -40,6 +41,7 @@ def run(
pre_script=pre_script,
post_script=post_script,
cwd=cwd,
midi_soundfont=midi_soundfont,
minimal=False,
action_name="run",
)
4 changes: 3 additions & 1 deletion bottles/backend/wine/winecommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def __init__(
pre_script: str | None = None,
post_script: str | None = None,
cwd: str | None = None,
midi_soundfont: str | None = None,
):
_environment = environment.copy()
self.config = self._get_config(config)
Expand All @@ -112,7 +113,7 @@ def __init__(
else self.config.Parameters.gamescope
)
self.command = self.get_cmd(
command, pre_script, post_script, environment=_environment
command, pre_script, post_script, midi_soundfont, environment=_environment
)
self.terminal = terminal
self.env = self.get_env(_environment)
Expand Down Expand Up @@ -488,6 +489,7 @@ def get_cmd(
command,
pre_script: str | None = None,
post_script: str | None = None,
midi_soundfont: str | None = None,
return_steam_cmd: bool = False,
return_clean_cmd: bool = False,
environment: dict | None = None,
Expand Down
2 changes: 2 additions & 0 deletions bottles/backend/wine/wineprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def launch(
pre_script: str | None = None,
post_script: str | None = None,
cwd: str | None = None,
midi_soundfont: str | None = None,
action_name: str = "launch",
):
if environment is None:
Expand Down Expand Up @@ -72,6 +73,7 @@ def launch(
pre_script=pre_script,
post_script=post_script,
cwd=cwd,
midi_soundfont=midi_soundfont,
arguments=program_args,
)

Expand Down
1 change: 1 addition & 0 deletions bottles/frontend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ def run_program(self):
program.get("pre_script", None)
program.get("post_script", None)
program.get("folder", None)
program.get("midi_soundfont", None)

program.get("dxvk")
program.get("vkd3d")
Expand Down
40 changes: 24 additions & 16 deletions bottles/frontend/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,45 @@

from gettext import gettext as _

from gi.repository import Gtk
from gi.repository import Gio, GObject, Gtk


def add_executable_filters(dialog):
filter = Gtk.FileFilter()
filter.set_name(_("Supported Executables"))
# TODO: Investigate why `filter.add_mime_type(...)` does not show filter in all distributions.
# Intended MIME types are:
# - `application/x-ms-dos-executable`
# - `application/x-msi`
filter.add_pattern("*.exe")
filter.add_pattern("*.msi")
__set_filter(dialog, _("Supported Executables"), ["*.exe", "*.msi"])


dialog.add_filter(filter)
def add_soundfont_filters(dialog):
__set_filter(dialog, _("Supported SoundFonts"), ["*.sf2", "*.sf3"])


def add_yaml_filters(dialog):
filter = Gtk.FileFilter()
filter.set_name("YAML")
# TODO: Investigate why `filter.add_mime_type(...)` does not show filter in all distributions.
# Intended MIME types are:
# - `application/yaml`
filter.add_pattern("*.yml")
filter.add_pattern("*.yaml")

dialog.add_filter(filter)
__set_filter(dialog, "YAML", ["*.yaml", "*.yml"])


def add_all_filters(dialog):
filter = Gtk.FileFilter()
filter.set_name(_("All Files"))
filter.add_pattern("*")
__set_filter(dialog, _("All Files"), ["*"])


dialog.add_filter(filter)
def __set_filter(dialog: GObject.Object, name: str, patterns: list[str]):
"""Set dialog named file filter from list of extension patterns."""

filter = Gtk.FileFilter()
filter.set_name(name)
for pattern in patterns:
filter.add_pattern(pattern)

if isinstance(dialog, Gtk.FileDialog):
filters = dialog.get_filters() or Gio.ListStore.new(Gtk.FileFilter)
filters.append(filter)
dialog.set_filters(filters)
elif isinstance(dialog, Gtk.FileChooserNative):
dialog.add_filter(filter)
else:
raise TypeError
31 changes: 31 additions & 0 deletions bottles/frontend/launch-options-dialog.blp
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,37 @@ template $LaunchOptionsDialog: Adw.Window {
}
}
}

Adw.ActionRow action_midi_soundfont {
activatable-widget: btn_midi_soundfont;
title: _("MIDI SoundFont");
subtitle: _("Choose a custom SoundFont for MIDI playback.");

Box {
spacing: 6;

Button btn_midi_soundfont_reset {
tooltip-text: _("Reset to Default");
valign: center;
visible: false;
icon-name: "edit-undo-symbolic";

styles [
"flat",
]
}

Button btn_midi_soundfont {
tooltip-text: _("Choose a SoundFont");
valign: center;
icon-name: "document-open-symbolic";

styles [
"flat",
]
}
}
}
}

Adw.PreferencesGroup {
Expand Down
Loading
Loading