├── .gitattributes ├── docs ├── img │ ├── favicon.ico │ └── gfl_logo.png ├── api_reference.md ├── README.md ├── soundfonts.md ├── ladspa_plugins.md ├── basic_usage.md └── bank_files.md ├── scripts ├── config │ ├── midi │ │ ├── funkjam.mid │ │ └── elevatorgroove.mid │ ├── sf2 │ │ ├── defaultGM.sf2 │ │ └── ModSynth_R1.sf2 │ ├── fluidpatcherconf.yaml │ └── banks │ │ ├── bank1.yaml │ │ └── bank0.yaml ├── fluidpatcher_cli.py └── fluidpatcher_gui.pyw ├── .github ├── workflows │ └── main.yaml └── ISSUE_TEMPLATE │ └── help-request.md ├── mkdocs.yaml ├── LICENSE ├── .gitignore ├── src └── patchcord.c └── fluidpatcher ├── bankfiles.py ├── __init__.py └── pfluidsynth.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/gfl_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/docs/img/gfl_logo.png -------------------------------------------------------------------------------- /scripts/config/midi/funkjam.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/scripts/config/midi/funkjam.mid -------------------------------------------------------------------------------- /scripts/config/sf2/defaultGM.sf2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/scripts/config/sf2/defaultGM.sf2 -------------------------------------------------------------------------------- /scripts/config/sf2/ModSynth_R1.sf2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/scripts/config/sf2/ModSynth_R1.sf2 -------------------------------------------------------------------------------- /scripts/config/midi/elevatorgroove.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekFunkLabs/fluidpatcher/HEAD/scripts/config/midi/elevatorgroove.mid -------------------------------------------------------------------------------- /scripts/config/fluidpatcherconf.yaml: -------------------------------------------------------------------------------- 1 | bankdir: config/banks 2 | plugindir: /usr/lib/ladspa 3 | currentbank: bank1.yaml 4 | 5 | fluidsettings: 6 | midi.autoconnect: 1 7 | player.reset-synth: 0 8 | synth.ladspa.active: 1 9 | -------------------------------------------------------------------------------- /docs/api_reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: fluidpatcher.FluidPatcher 4 | 5 | ----- 6 | 7 | The [full API reference](https://geekfunklabs.github.io/fluidpatcher/api_reference/) can be found in the [official documentation](https://geekfunklabs.github.io/fluidpatcher) -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: builddoc 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.x 15 | - run: pip install mkdocs 16 | - run: pip install "mkdocstrings[python]" 17 | - run: pip install mkdocs-material 18 | - run: mkdocs gh-deploy --force --clean --verbose 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: FluidPatcher 2 | repo_url: https://github.com/GeekFunkLabs/fluidpatcher 3 | nav: 4 | - Home: README.md 5 | - Basic Usage: basic_usage.md 6 | - Soundfonts: soundfonts.md 7 | - Creating Banks: bank_files.md 8 | - Plugins: ladspa_plugins.md 9 | - API Reference: api_reference.md 10 | theme: 11 | name: readthedocs 12 | favicon: img/gfl_logo.ico 13 | plugins: 14 | - mkdocstrings: 15 | handlers: 16 | python: 17 | options: 18 | docstring_section_style: list 19 | members_order: source 20 | show_root_heading: true 21 | show_signature_annotations: true 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help Request 3 | about: Get help with a problem or report a bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. If possible provide additional information found by following the [Troubleshooting Tips](https://github.com/albedozero/fluidpatcher/blob/master/troubleshooting.md). 18 | ``` 19 | post command-line output from following Troubleshooting Tips here 20 | ``` 21 | 22 | **Background Info** 23 | Let us know: 24 | - Which script you are using (squishbox.py, headlesspi.py, etc.) 25 | - Your platform (Windows, Linux, Raspberry Pi, Mac OS) 26 | - FluidSynth version (type `fluidsynth --version` at a command line) 27 | - any other relevant info (OS version, etc.) as you can 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bill Peterson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scripts/content/sf2/FluidR3_GM.sf2 2 | 3 | # windows installer assets 4 | fluidsynth_win*/ 5 | bundled/ 6 | *.nsi 7 | *.exe 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # celery beat schedule file 103 | celerybeat-schedule 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # FluidPatcher 2 | 3 | This package provides a python interface for controlling the versatile, cross-platform, and free [FluidSynth](https://www.fluidsynth.org) software synthesizer. Rather than simply wrapping all the C functions in the FluidSynth API, it provides a higher-level interface for loading and switching between *patches* - groups of settings including: 4 | 5 | * soundfonts and presets per channel 6 | * effect settings 7 | * MIDI routing rules 8 | * sequencers, arpeggiators, MIDI file players 9 | * LADSPA effect plugins 10 | 11 | Patches are written in YAML format in human-readable and -editable bank files. Bank files can easily be copied and used in FluidPatcher programs written for different platforms and purposes. Two programs are included in the `scripts/` directory of this repository - a command-line synth and a graphical synth/editor. FluidPatcher is the default synth engine used by the [SquishBox](https://geekfunklabs.com/products/squishbox) Raspberry Pi-based sound module. 12 | 13 | See the [official documentation](https://geekfunklabs.github.io/fluidpatcher) for full details. 14 | 15 | ## Requirements 16 | 17 | * [Python](https://python.org/downloads/) >= 3.9 18 | * [PyYAML](https://pypi.org/project/PyYAML/) python package 19 | * FluidSynth >= 2.2.0, can be obtained in various ways depending on platform: 20 | * Windows: download latest [release](https://github.com/FluidSynth/fluidsynth/releases) from github and add its location to your Windows `%PATH%`, or copy it to the same folder as your scripts 21 | * Linux, Mac OS: install using your system's [package manager](https://github.com/FluidSynth/fluidsynth/wiki/Download) 22 | * [build](https://github.com/FluidSynth/fluidsynth/wiki/BuildingWithCMake) the latest version from source 23 | 24 | ## Installation 25 | 26 | Copy the `fluidpatcher/` folder from the github [repository](https://github.com/GeekFunkLabs/fluidpatcher) to to the same directory as any python scripts that use it. For example, to use the included scripts, use the folder structure below: 27 | 28 | ```shell 29 | 📁 scripts/ 30 | ├── 📄 fluidpatcher_gui.pyw 31 | ├── 📄 fluidpatcher_cli.py 32 | ├── 📁 config/ 33 | │ ├── 📁 banks/ 34 | │ ├── 📁 midi/ 35 | │ ├── 📁 sf2/ 36 | │ └── 📄 fluidpatcherconf.yaml 37 | └── 📁 fluidpatcher/ 38 | ``` 39 | 40 | In future, a `setup.py` file or [PyPI](https://pypi.org) package may be available. 41 | 42 | ## Usage 43 | 44 | To understand how to use the included scripts and adjust config files for your system, read [Basic Usage](basic_usage.md). 45 | 46 | To learn how to add sounds and create your own patches and bank files, see [Soundfonts](soundfonts.md), [Creating Banks](bank_files.md), and [Plugins](ladspa_plugins.md). 47 | 48 | To write your own programs using FluidPatcher, study the [API Reference](api_reference.md) and the code of the included scripts. 49 | 50 | ## Support 51 | 52 | Ask questions, suggest improvements, and/or share your successes in [Discussions](github.com/GeekFunkLabs/fluidpatcher/discussions). If you think you've found a bug, report an [Issue](github.com/GeekFunkLabs/fluidpatcher/issues). 53 | -------------------------------------------------------------------------------- /docs/soundfonts.md: -------------------------------------------------------------------------------- 1 | # Soundfonts 2 | 3 | Soundfonts are a file format that contains audio samples and parameters that describe instruments based on those samples. The [soundfont specification](http://www.synthfont.com/sfspec24.pdf) defines all the parameters and how synthesizers should interpret them, so that soundfonts will sound the same when used on different software/platforms. 4 | 5 | The individual sounds that can be selected from a soundfont are called _presets_. Presets are organized into separate _banks_ each containing up to 128 presets, with _program_ numbers 0-127. Banks can be numbered from 0-16383, but generally only the first few banks are used for instruments, and bank 128 for percussion. Some soundfonts follow the [General Midi](https://www.midi.org/specifications/midi1-specifications/general-midi-specifications) (GM) specification, which defines a set list of instruments they should contain and their preset numbers. Other soundfonts have a random assortment of presets, or just one or two. 6 | 7 | ## Adding Soundfonts 8 | 9 | The soundfonts in the `bankdir` folder and its subfolders, defined in the [config](basic_usage.md#config-files) file, are those that will be available to FluidPatcher. You can add soundfonts by copying them to this folder, but to actually play the presets you must add them to a bank file. 10 | 11 | Find the `patches` item in a bank file, such as the example below. Each patch begins with an indented name. For each preset you want to use, create a new patch and add the preset with the format `: ::`. Almost all keyboards will send notes on MIDI channel 1 by default. You can find the bank and program numbers of the presets in the soundfont by opening it with an editor such as the ones listed below. The `fluidpatcher_gui.py` script will list all the presets in a soundfont and let you hear what they sound like before adding them to a patch. 12 | 13 | ```yaml 14 | patches: 15 | Bright Piano: 16 | 1: defaultGM.sf2:000:001 17 | Awesome Guitar Sound: 18 | 1: coolguitars.sf2:000:099 19 | Spacey Synth: 20 | 1: aliensounds.sf2:2:84 21 | ``` 22 | 23 | ## Obtaining Soundfonts 24 | 25 | Many soundfonts, both free and paid, are available for download on the internet. A few sites are listed below, and many more can be found with a simple web search. 26 | 27 | * [Musical Artifacts](https://musical-artifacts.com/) 28 | * [Polyphone](https://www.polyphone-soundfonts.com/download-soundfonts) 29 | * [RKHive](https://rkhive.com/) 30 | 31 | Soundfont editors such as those listed below can be used to modify existing soundfonts, or create them from scratch using audio samples. 32 | 33 | * [Swami](http://www.swamiproject.org/) 34 | * [Polyphone](https://www.polyphone-soundfonts.com/) 35 | * [Viena](https://www.softpedia.com/get/Multimedia/Audio/Other-AUDIO-Tools/Viena.shtml) 36 | 37 | The `defaultGM.sf2` soundfont included with fluidpatcher is a small GM soundfont that is used in the example bank files included in this repository. If a user desires higher-quality GM sounds, it can be easily substituted with another (below are some examples) by simply renaming the new file. Better yet, create a symbolic link (on Unix-like systems) using the following commands: 38 | 39 | ```shell 40 | mv defaultGM.sf2 liteGM.sf2 41 | ln -s defaultGM.sf2 42 | ``` 43 | 44 | * [FluidR3_GM.sf2](https://archive.org/details/fluidr3-gm-gs) (141MB) - pro-quality soundfont created by Frank Wen 45 | * [GeneralUser_GS_1.471.sf2](https://schristiancollins.com/generaluser) (30MB) - lean but high-quality soundfont by S. Christian Collins 46 | -------------------------------------------------------------------------------- /docs/ladspa_plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | [LADSPA](https://www.ladspa.org/) plugins are optional separate programs that provide additional audio effects. It is possible to compile and use them on other platforms, but at this time support is only provided for Linux usage. 4 | 5 | ## Installation 6 | 7 | To install a base LADSPA system and several batches of plugins, enter 8 | 9 | ```bash 10 | sudo apt-get install ladspa-sdk swh-plugins tap-plugins wah-plugins 11 | ``` 12 | 13 | To compile and install `patchcord.so`, which is used for mixing channels to outputs, go to the `src/` folder and enter 14 | 15 | ```bash 16 | sudo gcc -shared patchcord.c -o /usr/lib/ladspa/patchcord.so 17 | ``` 18 | 19 | ## Setup 20 | 21 | To enable plugins, set the fluidsetting `synth.ladspa.active: 1` in the config file, and set `plugindir` to the location where plugins are stored (most Linux distributions put them in `/usr/lib/ladspa` by default). The fluidsetting `synth.audio-groups` can be used to create separate effects mixing channels. The audio generated by each MIDI channel is assigned to an audio group in order, wrapping around if there are fewer groups than MIDI channels. 22 | 23 | ```yaml 24 | bankdir: config/banks 25 | plugindir: /usr/lib/ladspa 26 | fluidsettings: 27 | midi.autoconnect: 1 28 | player.reset-synth: 0 29 | synth.audio.groups: 16 30 | synth.ladspa.active: 1 31 | currentbank: bank1.yaml 32 | ``` 33 | 34 | ## Usage 35 | 36 | To add plugins to a bank file, create a `ladspafx` section as described in [Creating Banks](bank_files.md#ladspafx) and add a named section for each plugin. Setting the parameters correctly requires knowing some details about the plugin, which can be found using the `analyseplugin` command provided by the LADSPA SDK. Enter the command, followed by the full path to the plugin file. Here is the output for the `amp` plugin file: 37 | 38 | ```shell 39 | $ analyseplugin /usr/lib/ladspa/amp.so 40 | 41 | Plugin Name: "Mono Amplifier" 42 | Plugin Label: "amp_mono" 43 | Plugin Unique ID: 1048 44 | Maker: "Richard Furse (LADSPA example plugins)" 45 | Copyright: "None" 46 | Must Run Real-Time: No 47 | Has activate() Function: No 48 | Has deactivate() Function: No 49 | Has run_adding() Function: No 50 | Environment: Normal or Hard Real-Time 51 | Ports: "Gain" input, control, 0 to ..., default 1, logarithmic 52 | "Input" input, audio 53 | "Output" output, audio 54 | 55 | Plugin Name: "Stereo Amplifier" 56 | Plugin Label: "amp_stereo" 57 | Plugin Unique ID: 1049 58 | Maker: "Richard Furse (LADSPA example plugins)" 59 | Copyright: "None" 60 | Must Run Real-Time: No 61 | Has activate() Function: No 62 | Has deactivate() Function: No 63 | Has run_adding() Function: No 64 | Environment: Normal or Hard Real-Time 65 | Ports: "Gain" input, control, 0 to ..., default 1, logarithmic 66 | "Input (Left)" input, audio 67 | "Output (Left)" output, audio 68 | "Input (Right)" input, audio 69 | "Output (Right)" output, audio 70 | ``` 71 | 72 | As in the example above, some plugin files contain multiple plugins. The `plugin` parameter is used to set the label of the desired plugin. To route audio through the plugin, the `audio` parameter needs to be a list of the audio port names. List the inputs first, followed by the outputs. Part of the name can be used, as long as it is a unique match. The alias `mono` sets the ports to `Input, Output`, and `stereo` sets them to `Input L, Input R, Output L, Output R`. The control port names are used to set initial values with the `val` parameter, or connect router rules. 73 | 74 | The `group` parameter is a list of the audio groups to route through the plugin. The number of groups is set in the config file as described above, and numbering begins with 1. Multiple audio groups can be sent through a plugin and are not mixed, but each additional group increases CPU load. 75 | 76 | The bank file snippet below sets up the `amp_stereo` plugin and a router rule to control its Gain using CC# 13: 77 | 78 | ```yaml 79 | ladspafx: 80 | Stereo Amp: 81 | lib: amp 82 | plugin: amp_stereo 83 | audio: Input (L, Input (R, Output (L, Output (R 84 | vals: 85 | Gain: 0.5 86 | group: 3 87 | router_rules: 88 | - {type: cc, par1: 13, ladspafx: Stereo Amp, port: Gain, par2: 0-127=0-1} 89 | ``` 90 | 91 | If `synth.audio-groups` were set to `4`, and assuming there are 16 MIDI channels, this plugin would affect audio from channels 3, 7, 11, and 15. 92 | -------------------------------------------------------------------------------- /scripts/fluidpatcher_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """A command-line FluidPatcher synth 3 | """ 4 | from pathlib import Path 5 | import subprocess 6 | import sys 7 | import time 8 | 9 | from fluidpatcher import FluidPatcher 10 | 11 | try: 12 | import atexit 13 | import select 14 | import termios 15 | EDITOR = 'vi' 16 | stdin_fd = sys.stdin.fileno() 17 | old_term = termios.tcgetattr(stdin_fd) 18 | new_term = termios.tcgetattr(stdin_fd) 19 | new_term[3] = (new_term[3] & ~termios.ICANON & ~termios.ECHO) 20 | termios.tcsetattr(stdin_fd, termios.TCSAFLUSH, new_term) 21 | def restoreterm(): 22 | termios.tcsetattr(stdin_fd, termios.TCSAFLUSH, old_term) 23 | atexit.register(restoreterm) 24 | def pollkeyb(): 25 | dr,dw,de = select.select([sys.stdin], [], [], 0) 26 | if not dr == []: 27 | return sys.stdin.read(1) 28 | return None 29 | except ImportError: # must be Windows 30 | import msvcrt 31 | EDITOR = 'notepad' 32 | def pollkeyb(): 33 | if msvcrt.kbhit(): 34 | return msvcrt.getch().decode() 35 | return None 36 | 37 | MENU = "Options: N)ext Patch P)rev Patch L)oad Next Bank M)idi Monitor E)dit Bank Q)uit" 38 | POLL_TIME = 0.025 39 | MSG_TYPES = 'note', 'noteoff', 'kpress', 'cc', 'prog', 'pbend', 'cpress' 40 | MSG_NAMES = "Note On", "Note Off", "Key Pressure", "Control Change", "Program Change", "Pitch Bend", "Aftertouch" 41 | s = type('State', (), dict(pno=0, monitor=False)) 42 | 43 | def load_bank(bfile): 44 | lastbank = fp.currentbank 45 | lastpatch = fp.patches[s.pno] if fp.patches else "" 46 | print(f"Loading bank '{bfile}' .. ", end="") 47 | try: 48 | fp.load_bank(bfile) 49 | except Exception as e: 50 | print(f"failed\n{str(e)}") 51 | sys.exit() 52 | print("done") 53 | fp.write_config() 54 | if fp.currentbank == lastbank and lastpatch in fp.patches: 55 | s.pno = fp.patches.index(lastpatch) 56 | else: 57 | s.pno = 0 58 | fp.apply_patch(s.pno) 59 | print(f"Selected patch {s.pno + 1}/{len(fp.patches)}: {fp.patches[s.pno]}") 60 | 61 | def sig_handler(sig): 62 | if hasattr(sig, 'patch'): 63 | if sig.patch == -1: 64 | s.pno = (s.pno + sig.val) % len(fp.patches) 65 | else: 66 | s.pno = sig.patch 67 | fp.apply_patch(s.pno) 68 | print(f"Selected patch {s.pno + 1}/{len(fp.patches)}: {fp.patches[s.pno]}") 69 | elif s.monitor and not hasattr(sig, 'val') and sig.type in MSG_TYPES: 70 | t = MSG_TYPES.index(sig.type) 71 | if t < 3: 72 | octave = int(sig.par1 / 12) - 1 73 | note = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')[sig.par1 % 12] 74 | print(f"{MSG_NAMES[t]:15} : Channel {sig.chan:2} : {sig.par1} ({note}{octave})={sig.par2}") 75 | elif t < 4: 76 | print(f"{MSG_NAMES[t]:15} : Channel {sig.chan:2} : {sig.par1}={sig.par2}") 77 | elif t < 7: 78 | print(f"{MSG_NAMES[t]:15} : Channel {sig.chan:2} : {sig.par1}") 79 | 80 | cfgfile = sys.argv[1] if len(sys.argv) > 1 else 'config/fluidpatcherconf.yaml' 81 | try: 82 | fp = FluidPatcher(cfgfile) 83 | except Exception as e: 84 | print(f"Error loading config file {cfgfile}\n{str(e)}") 85 | sys.exit() 86 | fp.midi_callback = sig_handler 87 | load_bank(fp.currentbank) 88 | print(MENU) 89 | while True: 90 | if c := pollkeyb(): 91 | if c in 'np': 92 | if c == 'n': 93 | s.pno = (s.pno + 1) % len(fp.patches) 94 | elif c == 'p': 95 | s.pno = (s.pno - 1) % len(fp.patches) 96 | fp.apply_patch(s.pno) 97 | print(f"Selected patch {s.pno + 1}/{len(fp.patches)}: {fp.patches[s.pno]}") 98 | elif c == 'l': 99 | banks = sorted([b.relative_to(fp.bankdir) 100 | for b in fp.bankdir.rglob('*.yaml')]) 101 | if fp.currentbank in banks: 102 | bno = (banks.index(fp.currentbank) + 1) % len(banks) 103 | else: 104 | bno = 0 105 | load_bank(banks[bno]) 106 | elif c == 'm': 107 | s.monitor = False if s.monitor else True 108 | elif c == 'e': 109 | subprocess.run([EDITOR, fp.bankdir / fp.currentbank]) 110 | load_bank(fp.currentbank) 111 | elif c == 'q': 112 | sys.exit() 113 | else: 114 | print(MENU) 115 | time.sleep(POLL_TIME) 116 | -------------------------------------------------------------------------------- /scripts/config/banks/bank1.yaml: -------------------------------------------------------------------------------- 1 | # default bank w/ a range of useful patches 2 | # requiring only small included fonts plus fluid's GM font 3 | # obtaining FluidGM_R3.sf2: 4 | # -download from archive.org/details/fluidr3-gm-gs 5 | # -or link to copy installed w/fluidsynth: 6 | # `ln -s /usr/share/sounds/sf2/defaultGM.sf2 SquishBox/sf2/` 7 | # Tom's Audio Plugins required for effects: 8 | # `sudo apt-get install tap-plugins` 9 | # assign CCs 7, 13-16, 91, 93 to knobs/sliders 10 | # assign toggle CCs 27-28 to pads 11 | 12 | router_rules: 13 | - {type: cc, chan: 1=2-16, par1: 7} 14 | - {type: cc, chan: 1=2-6, par1: 1} 15 | - {type: cc, chan: 1=2-6, par1: 64} 16 | - {type: cc, chan: 1=2-6, par1: 91} 17 | - {type: pbend, chan: 1=2-6} 18 | 19 | patches: 20 | Piano: 21 | 1: defaultGM.sf2:000:001 22 | Rhodes: 23 | 1: defaultGM.sf2:000:004 24 | 10: defaultGM.sf2:128:032 25 | FM Piano: 26 | 1: defaultGM.sf2:000:005 27 | Vibes: 28 | 1: defaultGM.sf2:000:011 29 | Tonewheel: 30 | 1: defaultGM.sf2:000:017 31 | # CC 13-16 controls the leslie effect 32 | ladspafx: 33 | Rotary Speaker: 34 | lib: /usr/lib/ladspa/tap_rotspeak.so 35 | vals: 36 | Rotor Freq: 5 37 | Horn Freq: 8 38 | Rotor/Horn Mix: 0.5 39 | router_rules: 40 | - {type: cc, chan: 1, par1: 13, par2: 0-127=0-30, ladspafx: Rotary Speaker, port: Rotor Freq} 41 | - {type: cc, chan: 1, par1: 14, par2: 0-127=0-30, ladspafx: Rotary Speaker, port: Horn Freq} 42 | - {type: cc, chan: 1, par1: 15, par2: 0-127=0-100, ladspafx: Rotary Speaker, port: Mic Dist} 43 | - {type: cc, chan: 1, par1: 16, par2: 0-127=0-1, ladspafx: Rotary Speaker, port: Rotor/Horn Mix} 44 | Compy Guitar: 45 | 1: defaultGM.sf2:000:027 46 | Funk Guitar: 47 | 1: defaultGM.sf2:008:028 48 | Shred Guitar: 49 | 1: defaultGM.sf2:000:030 50 | # CC 13, 14 control tube drive, tape/tube mix 51 | ladspafx: 52 | Tube Warmth: 53 | lib: /usr/lib/ladspa/tap_tubewarmth.so 54 | audio: mono 55 | vals: {Drive: 2.5, Tape: 10} 56 | router_rules: 57 | - {type: cc, chan: 1, par1: 13, par2: 0-127=0.1-10, ladspafx: Tube Warmth, port: Drive} 58 | - {type: cc, chan: 1, par1: 14, par2: 0-127=-10-10, ladspafx: Tube Warmth, port: Tape} 59 | Bass+Oct: 60 | 1: defaultGM.sf2:000:033 61 | 3: defaultGM.sf2:000:033 62 | # CC 27 switches the octave doubling on/off 63 | router_rules: 64 | - {type: note, chan: 1=3, par1: 0-127*1-12} 65 | - {type: cc, chan: 10=3, par1: 27=7, par2: 0-127*127+0} 66 | Synth Bass: 67 | 1: defaultGM.sf2:000:038 68 | Strings: 69 | 1: defaultGM.sf2:000:048 70 | Synth Strings: 71 | 1: defaultGM.sf2:000:051 72 | Synth Voice: 73 | 1: defaultGM.sf2:000:054 74 | Synth Brass: 75 | 1: defaultGM.sf2:000:062 76 | Smooth Flute: 77 | 4: defaultGM.sf2:000:073 78 | router_rules: 79 | # CC 13, 28 control porta time, on/off 80 | - {type: note, chan: 1=4, par1: 0-127*1+12} 81 | - {type: cc, chan: 10=4, par1: 28=65, par2: 0-127*127+0} 82 | - {type: cc, chan: 10=4, par1: 28=68, par2: 0-127*127+0} 83 | - {type: cc, chan: 1=4, par1: 13=37, par2: 0-127*1+0} 84 | Mod Synth: 85 | 5: ModSynth_R1.sf2:000:000 86 | 6: ModSynth_R1.sf2:000:000 87 | router_rules: 88 | - {type: note, chan: 1-2*1+4} 89 | - {type: cc, chan: 1=5-6, par1: 13=74} # filter cutoff 90 | - {type: cc, chan: 1=5-6, par1: 14=78} # modenv attack 91 | - {type: cc, chan: 1=5-6, par1: 15=79} # modenv decay 92 | - {type: cc, chan: 1=5-6, par1: 16=82} # modenv -> filter 93 | Warm Pad: 94 | 1: defaultGM.sf2:000:089 95 | Sweep Pad: 96 | 1: defaultGM.sf2:000:095 97 | NewAge: 98 | 1: defaultGM.sf2:000:097 99 | SFX: 100 | 11: defaultGM.sf2:000:122 101 | 12: defaultGM.sf2:000:123 102 | 13: defaultGM.sf2:000:124 103 | 14: defaultGM.sf2:000:125 104 | 15: defaultGM.sf2:000:126 105 | 16: defaultGM.sf2:000:127 106 | router_rules: 107 | - {type: note, chan: 1=11, par1: C3-E3=C3-C5} 108 | - {type: note, chan: 1=12, par1: F3-A3=C3-C5} 109 | - {type: note, chan: 1=13, par1: A#3-D4=C3-C5} 110 | - {type: note, chan: 1=14, par1: D#4-G4=C3-C5} 111 | - {type: note, chan: 1=15, par1: G#4-C5=C3-C5} 112 | - {type: note, chan: 1=16, par1: C#5-F5=C3-C5} 113 | Standard Kit: 114 | 1: defaultGM.sf2:128:001 115 | router_rules: 116 | - {type: note, chan: 10=1} 117 | Power Kit: 118 | 1: defaultGM.sf2:128:016 119 | router_rules: 120 | - {type: note, chan: 10=1} 121 | 122 | init: 123 | messages: [cc:4:37:60, cc:4:65:127, cc:4:68:127, 124 | cc:5:71:0, cc:5:73:0, cc:5:74:0, cc:5:78:40, cc:5:79:40, cc:5:80:80, cc:5:81:127, cc:5:82:80, 125 | cc:5:71:0, cc:5:73:0, cc:5:74:0, cc:5:78:40, cc:5:79:40, cc:5:80:80, cc:5:81:127, cc:5:82:80, 126 | cc:6:100:0, cc:6:101:0, cc:6:6:12, cc:6:38:0] 127 | -------------------------------------------------------------------------------- /docs/basic_usage.md: -------------------------------------------------------------------------------- 1 | # Basic Usage 2 | 3 | This section explains how to use the scripts included in this repository, and in general how programs written using FluidPatcher should work. In most cases, one can connect a MIDI keyboard or controller, run a FluidPatcher program, and start playing notes to generate audio. Banks, soundfonts, and midi files can be copied from one program or device to another and provide the same sounds and performances. 4 | 5 | ## Config Files 6 | 7 | A config file allows the user provide settings specific to different programs or platforms, and is also used by programs to store settings and states. Config files are plain text in YAML format, which here means settings are just listed as `: ` (the space after `:` is required). The scripts in this repository look for `config/fluidpatcherconf.yaml` by default, but will use a different file if it is passed as a command-line argument. 8 | 9 | This is an example config file: 10 | 11 | ```yaml 12 | bankdir: config/banks 13 | soundfontdir: config/sf2 14 | mfilesdir: config/midi 15 | fluidsettings: 16 | midi.autoconnect: 1 17 | player.reset-synth: 0 18 | synth.gain: 0.6 19 | currentbank: bank1.yaml 20 | ``` 21 | 22 | All settings are optional, unrecognized settings will be ignored, and the order is flexible. Here are the common settings: 23 | 24 | * `bankdir` - directory prefix when loading/saving banks; can be relative to the program directory or absolute; defaults to `banks` 25 | * `soundfontdir` - directory prefix for soundfonts; defaults to `{bankdir}/../sf2` 26 | * `mfilesdir` - directory prefix for MIDI files; defaults to `{bankdir}/../midi` 27 | * `plugindir` - directory prefix for LADSPA plugins, see [Plugins](ladspa_plugins.md) for details 28 | * `fluidsettings` - indented list of settings to pass directly to FluidSynth. 29 | * `currentbank` - used by most programs to store the last bank opened, so it can be loaded next time the program starts 30 | 31 | FluidSynth maintains a [full list of fluidsynth settings](https://www.fluidsynth.org/api/fluidsettings.xml) with explanations and defaults by platform. Here are some notes on a few important ones: 32 | 33 | * `midi.autoconnect` - automatically connects MIDI keyboards/controllers. This does not work on all systems. In some cases (Windows), controllers may need to be connected before the program is started, or connected manually. 34 | * `player.reset-synth` - When playing a MIDI file and reaching the end of a song, all playing notes will be silenced and the synth reset, overriding settings in banks and patches. This is undesirable for FluidPatcher and should be set to 0. 35 | * `synth.gain` - scales the output volume of the synth. This can be in the range 0.0-10.0, but values above 1.0 will be clipped/distorted. 36 | * `synth.polyphony` - If too many voices are played at once (usually by sustaining lots of notes), the CPU may terminate audio while it catches up. This limits the number of active voices, canceling the oldest notes. 37 | * `audio.periods`, `audio.period-size` - These set the number and size of the buffers used for sending digital audio. Lowering these values decreases audio latency (the time between playing a note and hearing the audio), but too low and the sound card won't be able to keep up, producing stuttering/crackling audio. 38 | 39 | ## fluidpatcher_gui.pyw 40 | 41 | This is a desktop (GUI) program that can be used to edit and test bank files, or as a live software synthesizer. The main UI consists of a display showing the current bank and patch, and buttons for switching patches or loading the next available bank. The menus provide options for loading/saving bank files, and selecting patches. The _Tools_ menu provides useful functions: 42 | 43 | * _Edit Bank_ - Opens a separate text editor window in which the current bank can be edited directly. Clicking "Apply" will scan the text and update the bank, or pop up a message if there are errors. 44 | * _Choose Preset_ - Opens a soundfont, and allows the user to scroll through and play the soundfont's presets. Double-clicking or clicking _OK_ will paste the selected preset into the bank file. 45 | * _MIDI Monitor_ - Opens a window that will display received MIDI messages 46 | * _Fill Screen_ - Hides the menu bar and maximizes the main UI. Can be useful in live playing. 47 | * _Settings_ - Opens a dialog for viewing and editing the contents of the current config file. 48 | 49 | Some program settings, such as the initial height, width, and font size of the UI, can be adjusted by editing the script and changing the values of `WIDTH`, `HEIGHT`, `FONTSIZE`, `PAD`, and/or `FILLSCREEN` at the beginning of the file. 50 | 51 | ## fluidpatcher_cli.py 52 | 53 | This program works from the command line, even in a remote terminal. It lets you load banks, choose patches, and play the synthesizer. The keyboard is used to control the interface and choose options. Here is the list of commands: 54 | 55 | * `N` and `P` choose the next/previous patch 56 | * `L` loads the next bank, in alphabetical order 57 | * `M` toggles monitoring of MIDI messages 58 | * `E` opens the current bank in a text editor 59 | * `Q` exits the program 60 | 61 | Any other key will print out the list of keyboard commands. -------------------------------------------------------------------------------- /scripts/config/banks/bank0.yaml: -------------------------------------------------------------------------------- 1 | description: example bank file that demonstrates many of the features of fluidpatcher 2 | view the wiki at https://github.com/albedozero/fluidpatcher/wiki for detailed info 3 | 4 | note: > 5 | Nodes such as `comment`, `info`, etc. are just here to provide information. Note that YAML 6 | (usually) ignores text after #, so those comments will disappear if the bank file is saved. 7 | # e.g. this comment will disappear 8 | 9 | patches: 10 | Basic Piano: 11 | info: basic patch - assigns a voice (soundfont:bank:program) to a MIDI channel 12 | 1: defaultGM.sf2:000:000 13 | 14 | Two Hands: 15 | info: This patch has `router_rules` that clear the default rules and send each 16 | half of the keyboard to a different voice. 17 | 1: defaultGM.sf2:000:005 18 | note: extra zeros in bank and program numbers are optional 19 | 2: defaultGM.sf2:0:38 20 | router_rules: 21 | - clear 22 | - {type: note, chan: 1, par1: F3-G9} 23 | - {type: note, chan: 1=2, par1: C0-E3*1-12} 24 | - {type: cc} 25 | comment: These messages are sent each time the patch is selected 26 | messages: 27 | - cc:2:73:0 28 | - cc:2:74:0 29 | 30 | Playable: 31 | 3: defaultGM.sf2:000:084 32 | router_rules: 33 | - {type: cc, chan: 1=3} 34 | - {type: note, chan: 1=3, par1: Ab3-G9} 35 | - {type: note, chan: 1=3, par1: G#3-G9*1+5} 36 | - par1: C0-G3=D6-C2 37 | chan: 1=3 38 | type: note 39 | comment: Mappings and lists can be inline with {} and [] or block style, 40 | and parameters can be in any order. 41 | 42 | Cheap Synth: 43 | info: LADSPA effects can be used if they are available on your system 44 | 2: ModSynth_R1.sf2:000:000 45 | ladspafx: 46 | delayline1: &delayeffect 47 | comment: The & symbol defines a YAML anchor 48 | lib: delay.so 49 | audio: mono 50 | group: 2 51 | vals: {Delay: 0.3, Dry/Wet: 0.2} 52 | delayline2: *delayeffect 53 | delayline3: *delayeffect 54 | explanation: LADSPA effects are chained in the order they are listed. The anchor 55 | links copy the contents of delayline1 into delayline2 and delayline3, creating 56 | a sort of bucket delay 57 | router_rules: 58 | - {type: note, chan: 1=2, par2: 1-127=127} 59 | - {type: cc, chan: 1=2} 60 | - {type: pbend, chan: 1=2, par1: 8192-16383} 61 | - {type: pbend=cc, chan: 1=2, par2: 74, par1: 0-8192=127-0} 62 | - {type: cc, chan: 1, par1: 14, par2: 0-127=0-1, ladspafx: delayline1, port: Delay} 63 | - {type: cc, chan: 1, par1: 14, par2: 0-127=0-1, ladspafx: delayline2, port: Delay} 64 | - {type: cc, chan: 1, par1: 14, par2: 0-127=0-1, ladspafx: delayline3, port: Delay} 65 | comment: The first rule uses par2 routing to make all notes maximum volume like a retro synth, 66 | The `ladspafx` rules connects a cc to the Delay value for each of the delay effects, and the 67 | `pbend=cc` rule triggers a CC74 message from pitch bends. 68 | 69 | Elevator Jam: 70 | description: This patch demonstrates sequencers, arpeggiators, midifile players 71 | 3: defaultGM.sf2:000:016 72 | sequencers: 73 | fluteloop: 74 | swing: 0.7 75 | notes: [note:4:A5:70, note:4:G5:70, note:4:A5:70, note:4:C6:70] 76 | 4: defaultGM.sf2:000:096 77 | arpeggiators: 78 | ep_arp: {tdiv: 8, style: both, octaves: 2} 79 | 5: defaultGM.sf2:000:108 80 | midiplayers: 81 | groove: 82 | file: elevatorgroove.mid 83 | barlength: 1536 84 | loops: [15350, 18419] 85 | info: barlength and loops are given in MIDI ticks 86 | chan: 1-10*1+5 87 | mask: prog, kpress 88 | 6: defaultGM.sf2:000:100 89 | 7: defaultGM.sf2:008:038 90 | 15: defaultGM.sf2:128:000 91 | router_rules: 92 | - {type: note, chan: 1=3, par1: C4-C9} 93 | - {type: note, chan: 1=5, par1: C3-B3, arpeggiator: ep_arp} 94 | - {type: note, chan: 1, par1: F#2, par2: 1-127=-1, sequencer: fluteloop} 95 | - {type: note, chan: 1, par1: G#2, par2: 1-127=0, sequencer: fluteloop} 96 | - {type: note, chan: 1, par1: A#2, par2: 1-127=2, sequencer: fluteloop} 97 | - {type: note, chan: 1, par1: F2, par2: 1-127=1, midiplayer: groove} 98 | - {type: note, chan: 1, par1: G2, par2: 1-127=0, midiplayer: groove} 99 | - {type: note, chan: 1, par1: A2, par2: 1-127=-1, midiplayer: groove, tick: 10752} 100 | - {type: note, chan: 1, par1: B2, par2: 1-127=-1, midiplayer: groove, tick: 13824} 101 | - {type: cc, chan: 1, par1: 13, par2: 0-127=30-240, tempo: groove} 102 | - {type: cc, chan: 1, par1: 13, par2: 0-127=30-240, tempo: fluteloop} 103 | - {type: cc, chan: 1, par1: 13, par2: 0-127=30-240, tempo: ep_arp} 104 | 105 | about bank vs patch level: Settings outside of `patches` are applied first, 106 | every time a new patch is selected. 107 | 108 | fluidsettings: 109 | synth.reverb.width: 0.5 110 | 111 | router_rules: 112 | - {type: cc, chan: 1=2-16, par1: 7} 113 | - {type: pbend, chan: 1=3} 114 | - type: cc 115 | chan: 1 116 | par1: 14 117 | par2: 0-127=0.0-1.0 118 | fluidsetting: synth.reverb.room-size 119 | comment: See full list of fluidsettings at http://www.fluidsynth.org/api/fluidsettings.xml 120 | Only `synth.` settings work in bank files 121 | 122 | about init blocks: The init block is processed once, when the bank is first loaded. 123 | For example, this sets an initial value for synth.reverb.room-size that can be 124 | changed by the rule above, but the fluidsettings block above sets synth.reverb.width 125 | every time a patch is selected. Only `fluidsettings` and `messages` keywords are 126 | used in `init` blocks 127 | 128 | init: 129 | fluidsettings: 130 | synth.reverb.room-size: 0.8 131 | messages: [cc:3:11:50, cc:4:11:50, cc:5:11:50, cc:6:11:50, cc:7:11:60, 132 | cc:15:11:70, cc:1:91:70, cc:1:91:70, cc:2:91:80] 133 | comment: These messages use expression (CC#11) to soften the loud voices assigned 134 | to channels 3-7 and 15, and set the reverb level on channels 1 and 2 -------------------------------------------------------------------------------- /src/patchcord.c: -------------------------------------------------------------------------------- 1 | /* 2 | A super-simple LADSPA plugin that just copies audio from one port to another 3 | has a run_adding function so it can be used to mix audio from multiple plugins 4 | 5 | Should build on most linux systems with the LADSPA SDK installed by simply entering 6 | 7 | gcc -shared patchcord.c -o patchcord.so 8 | 9 | MIT License 10 | 11 | Copyright (c) 2022 Bill Peterson (white2rnado@geekfunklabs.com) 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | */ 31 | 32 | #include 33 | #include 34 | #include 35 | #include "ladspa.h" 36 | 37 | #define PATCHCORD_INPUT 0 38 | #define PATCHCORD_OUTPUT 1 39 | 40 | static LADSPA_Descriptor *patchcordDescriptor = NULL; 41 | 42 | typedef struct { 43 | LADSPA_Data *input; 44 | LADSPA_Data *output; 45 | LADSPA_Data run_adding_gain; 46 | } Patchcord; 47 | 48 | const LADSPA_Descriptor *ladspa_descriptor(unsigned long index) { 49 | switch (index) { 50 | case 0: 51 | return patchcordDescriptor; 52 | default: 53 | return NULL; 54 | } 55 | } 56 | 57 | static void cleanupPatchcord(LADSPA_Handle instance) { 58 | free(instance); 59 | } 60 | 61 | static void connectPortPatchcord( 62 | LADSPA_Handle instance, 63 | unsigned long port, 64 | LADSPA_Data *data) { 65 | Patchcord *plugin; 66 | 67 | plugin = (Patchcord *)instance; 68 | switch (port) { 69 | case PATCHCORD_INPUT: 70 | plugin->input = data; 71 | break; 72 | case PATCHCORD_OUTPUT: 73 | plugin->output = data; 74 | break; 75 | } 76 | } 77 | 78 | static LADSPA_Handle instantiatePatchcord( 79 | const LADSPA_Descriptor *descriptor, 80 | unsigned long s_rate) { 81 | Patchcord *plugin_data = (Patchcord *)calloc(1, sizeof(Patchcord)); 82 | plugin_data->run_adding_gain = 1.0f; 83 | 84 | return (LADSPA_Handle)plugin_data; 85 | } 86 | 87 | static void runPatchcord(LADSPA_Handle instance, unsigned long sample_count) { 88 | Patchcord *plugin_data = (Patchcord *)instance; 89 | 90 | /* Input (array of floats of length sample_count) */ 91 | const LADSPA_Data * const input = plugin_data->input; 92 | 93 | /* Output (array of floats of length sample_count) */ 94 | LADSPA_Data * const output = plugin_data->output; 95 | 96 | unsigned long pos; 97 | 98 | for (pos = 0; pos < sample_count; pos++) { 99 | output[pos] = input[pos]; 100 | } 101 | } 102 | 103 | static void setRunAddingGainPatchcord(LADSPA_Handle instance, LADSPA_Data gain) { 104 | ((Patchcord *)instance)->run_adding_gain = gain; 105 | } 106 | 107 | static void runAddingPatchcord(LADSPA_Handle instance, unsigned long sample_count) { 108 | Patchcord *plugin_data = (Patchcord *)instance; 109 | LADSPA_Data run_adding_gain = plugin_data->run_adding_gain; 110 | 111 | /* Input (array of floats of length sample_count) */ 112 | const LADSPA_Data * const input = plugin_data->input; 113 | 114 | /* Output (array of floats of length sample_count) */ 115 | LADSPA_Data * const output = plugin_data->output; 116 | 117 | unsigned long pos; 118 | 119 | for (pos = 0; pos < sample_count; pos++) { 120 | output[pos] += input[pos] * run_adding_gain; 121 | } 122 | } 123 | 124 | static void __attribute__((constructor)) patchcord_init() { 125 | char **port_names; 126 | LADSPA_PortDescriptor *port_descriptors; 127 | LADSPA_PortRangeHint *port_range_hints; 128 | 129 | patchcordDescriptor = 130 | (LADSPA_Descriptor *)malloc(sizeof(LADSPA_Descriptor)); 131 | 132 | if (patchcordDescriptor) { 133 | patchcordDescriptor->UniqueID = 650879; 134 | patchcordDescriptor->Label = "patchcord"; 135 | patchcordDescriptor->Properties = 136 | LADSPA_PROPERTY_HARD_RT_CAPABLE; 137 | patchcordDescriptor->Name = ("Patch cord"); 138 | patchcordDescriptor->Maker = 139 | "Bill Peterson "; 140 | patchcordDescriptor->Copyright = 141 | "GPL"; 142 | patchcordDescriptor->PortCount = 2; 143 | 144 | port_descriptors = (LADSPA_PortDescriptor *)calloc(2, 145 | sizeof(LADSPA_PortDescriptor)); 146 | patchcordDescriptor->PortDescriptors = 147 | (const LADSPA_PortDescriptor *)port_descriptors; 148 | 149 | port_range_hints = (LADSPA_PortRangeHint *)calloc(2, 150 | sizeof(LADSPA_PortRangeHint)); 151 | patchcordDescriptor->PortRangeHints = 152 | (const LADSPA_PortRangeHint *)port_range_hints; 153 | 154 | port_names = (char **)calloc(2, sizeof(char*)); 155 | patchcordDescriptor->PortNames = 156 | (const char **)port_names; 157 | 158 | /* Parameters for Input */ 159 | port_descriptors[PATCHCORD_INPUT] = 160 | LADSPA_PORT_INPUT | LADSPA_PORT_AUDIO; 161 | port_names[PATCHCORD_INPUT] = ("Input"); 162 | port_range_hints[PATCHCORD_INPUT].HintDescriptor = 0; 163 | 164 | /* Parameters for Output */ 165 | port_descriptors[PATCHCORD_OUTPUT] = 166 | LADSPA_PORT_OUTPUT | LADSPA_PORT_AUDIO; 167 | port_names[PATCHCORD_OUTPUT] = ("Output"); 168 | port_range_hints[PATCHCORD_OUTPUT].HintDescriptor = 0; 169 | 170 | patchcordDescriptor->activate = NULL; 171 | patchcordDescriptor->cleanup = cleanupPatchcord; 172 | patchcordDescriptor->connect_port = connectPortPatchcord; 173 | patchcordDescriptor->deactivate = NULL; 174 | patchcordDescriptor->instantiate = instantiatePatchcord; 175 | patchcordDescriptor->run = runPatchcord; 176 | patchcordDescriptor->run_adding = runAddingPatchcord; 177 | patchcordDescriptor->set_run_adding_gain = setRunAddingGainPatchcord; 178 | } 179 | } 180 | 181 | static void __attribute__((destructor)) patchcord_fini() { 182 | if (patchcordDescriptor) { 183 | free((LADSPA_PortDescriptor *)patchcordDescriptor->PortDescriptors); 184 | free((char **)patchcordDescriptor->PortNames); 185 | free((LADSPA_PortRangeHint *)patchcordDescriptor->PortRangeHints); 186 | free(patchcordDescriptor); 187 | } 188 | patchcordDescriptor = NULL; 189 | 190 | } 191 | -------------------------------------------------------------------------------- /fluidpatcher/bankfiles.py: -------------------------------------------------------------------------------- 1 | """YAML extensions for fluidpatcher 2 | """ 3 | 4 | import re 5 | import yaml 6 | 7 | sfp = re.compile('^(.+\.sf2):(\d+):(\d+)$', flags=re.I) 8 | nn = '[A-G]?[b#]?\d*[.]?\d+' # scientific note name or number 9 | msg = re.compile(f'^(note|cc|prog|pbend|cpress|kpress|noteoff|clock|start|continue|stop):(\d+)?:({nn})?:(\d+)?$') 10 | rspec = re.compile(f'^({nn})-({nn})\*(-?[\d\.]+)([+-]{nn})$') 11 | ftspec = re.compile(f'^({nn})?-?({nn})?=?(-?{nn})?-?(-?{nn})?$') 12 | scinote = re.compile('([+-]?)([A-G])([b#]?)(-?[0-9])') # scientific note name parts 13 | 14 | handlers = dict(Loader=yaml.SafeLoader, Dumper=yaml.SafeDumper) 15 | yaml.add_implicit_resolver('!sfpreset', sfp, **handlers) 16 | yaml.add_implicit_resolver('!midimsg', msg, **handlers) 17 | 18 | def add_bankobj_resolver(tag, path, kind): 19 | yaml.add_path_resolver(tag, path, kind, **handlers) 20 | yaml.add_path_resolver(tag, ['patches', (dict, None), *path], kind, **handlers) 21 | 22 | add_bankobj_resolver('!rrule', ['router_rules', (list, None)], dict) 23 | add_bankobj_resolver('!midiplayer', ['midiplayers', (dict, None)], dict) 24 | add_bankobj_resolver('!sequencer', ['sequencers', (dict, None)], dict) 25 | add_bankobj_resolver('!arpeggiator', ['arpeggiators', (dict, None)], dict) 26 | add_bankobj_resolver('!ladspafx', ['ladspafx', (dict, None)], dict) 27 | 28 | def scinote_to_val(n): 29 | """convert scientific note name to MIDI note number 30 | """ 31 | if not isinstance(n, str): 32 | return n 33 | sci = scinote.findall(n)[0] 34 | sign = -1 if sci[0] == '-' else 1 35 | note = 'C D EF G A B'.find(sci[1]) 36 | acc = ['b', '', '#'].index(sci[2]) - 1 37 | octave = int(sci[3]) 38 | return sign * ((octave + 1) * 12 + note + acc) 39 | 40 | def sift(s): 41 | """attempt to convert strings into floats or ints 42 | """ 43 | try: s = float(s) 44 | except (ValueError, TypeError): return s 45 | return int(s) if s.is_integer() else s 46 | 47 | def parseyaml(text='', data={}): 48 | """prune branches that contain None instances""" 49 | data = yaml.safe_load(text) if text else data 50 | if isinstance(data, (list, dict)): 51 | for item in data if isinstance(data, list) else data.values(): 52 | if item is None: return None 53 | elif isinstance(item, (list, dict)): 54 | if parseyaml(data=item) is None: return None 55 | return data 56 | 57 | def renderyaml(data): 58 | """sort_keys=False preserves dict order""" 59 | return yaml.safe_dump(data, sort_keys=False) 60 | 61 | 62 | class SFPreset(yaml.YAMLObject): 63 | 64 | yaml_tag = '!sfpreset' 65 | yaml_loader = yaml.SafeLoader 66 | yaml_dumper = yaml.SafeDumper 67 | 68 | def __init__(self, sfont, bank, prog): 69 | self.sfont = sfont 70 | self.bank = bank 71 | self.prog = prog 72 | 73 | def __str__(self): 74 | return f"{self.sfont}:{self.bank:03d}:{self.prog:03d}" 75 | 76 | @classmethod 77 | def from_yaml(cls, loader, node): 78 | sfont, bank, prog = sfp.search(loader.construct_scalar(node)).groups() 79 | bank = int(bank) 80 | prog = int(prog) 81 | return cls(sfont, bank, prog) 82 | 83 | @staticmethod 84 | def to_yaml(dumper, data): 85 | return dumper.represent_scalar('!sfpreset', str(data)) 86 | 87 | 88 | class MidiMessage(yaml.YAMLObject): 89 | 90 | yaml_tag = '!midimsg' 91 | yaml_loader = yaml.SafeLoader 92 | yaml_dumper = yaml.SafeDumper 93 | 94 | def __init__(self, type, chan, par1, par2, yaml=''): 95 | self.type = type 96 | self.chan = chan 97 | self.par1 = scinote_to_val(par1) 98 | self.par2 = par2 99 | self.yaml = yaml or f"{type}:{chan or ''}:{par1 or ''}:{par2 or ''}" 100 | 101 | def __str__(self): 102 | return self.yaml 103 | 104 | def __iter__(self): 105 | return iter([self.type, self.chan, self.par1, self.par2]) 106 | 107 | @classmethod 108 | def from_yaml(cls, loader, node): 109 | m = msg.search(loader.construct_scalar(node)) 110 | type, chan, par1, par2 = [sift(g) for g in m.groups()] 111 | return cls(type, chan, par1, par2, m[0]) 112 | 113 | @staticmethod 114 | def to_yaml(dumper, data): 115 | return dumper.represent_scalar('!midimsg', str(data)) 116 | 117 | 118 | class BankObject(yaml.YAMLObject): 119 | """Translation layer between YAML representation and bank data 120 | 121 | Attributes: 122 | opars: exact parameters as written in bank file, read-only 123 | pars: copy of opars with elements modified as needed 124 | """ 125 | 126 | yaml_loader = yaml.SafeLoader 127 | yaml_dumper = yaml.SafeDumper 128 | 129 | def __init__(self, **pars): 130 | self.opars = {**pars} 131 | self.pars = {**pars} 132 | 133 | def __str__(self): 134 | return str(self.opars) 135 | 136 | def __iter__(self): 137 | return iter(self.opars.items()) 138 | 139 | def __setitem__(self, key, val): 140 | self.pars[key] = val 141 | 142 | def __getitem__(self, key): 143 | return self.pars[key] 144 | 145 | def keys(self): 146 | return self.pars.keys() 147 | 148 | @classmethod 149 | def from_yaml(cls, loader, node): 150 | return cls(**loader.construct_mapping(node)) 151 | 152 | 153 | class RouterRule(BankObject): 154 | 155 | yaml_tag = '!rrule' 156 | 157 | def __init__(self, **pars): 158 | super().__init__(**pars) 159 | try: 160 | type = self.pars.pop('type').split('=')[:2] 161 | except KeyError as e: 162 | raise AttributeError("Router rule must have a type.") from e 163 | if 'type2' in pars: type.append(self.pars.pop('type2')) # old format 164 | self.type = [(t, type[-1]) for t in type[0].split('|')] 165 | self.chan = ChannelSpec(self.pars.pop('chan', '')) 166 | self.pars['par1'] = ParamSpec(pars.get('par1', '')) 167 | self.pars['par2'] = ParamSpec(pars.get('par2', '')) 168 | 169 | def add(self, addfunc): 170 | for type in self.type: 171 | for chan in self.chan or [None]: 172 | addfunc(type, chan, **self.pars) 173 | 174 | @staticmethod 175 | def to_yaml(dumper, data): 176 | return dumper.represent_mapping('!rrule', data, flow_style=True) 177 | 178 | 179 | class Arpeggiator(BankObject): 180 | 181 | yaml_tag = '!arpeggiator' 182 | 183 | def __init__(self, **pars): 184 | super().__init__(**pars) 185 | if 'groove' in pars: 186 | if isinstance(pars['groove'], int): 187 | self.pars['groove'] = [pars['groove'], 1] 188 | elif isinstance(pars['groove'], str): 189 | self.pars['groove'] = [int(a) for a in pars['groove'].split(',')] 190 | 191 | @staticmethod 192 | def to_yaml(dumper, data): 193 | return dumper.represent_mapping('!arpeggiator', data) 194 | 195 | 196 | class Sequencer(Arpeggiator): 197 | 198 | yaml_tag = '!sequencer' 199 | 200 | def __init__(self, **pars): 201 | super().__init__(**pars) 202 | if 'notes' in pars: 203 | if isinstance(pars['notes'], str): 204 | self.pars['notes'] = [MidiMessage.from_yaml(yaml.SafeLoader, n.strip()) 205 | for n in pars['notes'].split(',')] 206 | 207 | @staticmethod 208 | def to_yaml(dumper, data): 209 | return dumper.represent_mapping('!sequencer', data) 210 | 211 | 212 | class MidiPlayer(BankObject): 213 | 214 | yaml_tag = '!midiplayer' 215 | 216 | def __init__(self, **pars): 217 | super().__init__(**pars) 218 | if 'chan' in pars: 219 | self.pars['chan'] = ChannelSpec(pars['chan']).tups[0] 220 | if 'mask' in pars: 221 | if isinstance(pars['mask'], str): 222 | self.pars['mask'] = [t.strip() for t in pars['mask'].split(',')] 223 | if 'loops' in pars: 224 | if isinstance(pars['loops'], str): 225 | self.pars['loops'] = [int(t) for t in pars['loops'].split(',')] 226 | 227 | @staticmethod 228 | def to_yaml(dumper, data): 229 | return dumper.represent_mapping('!midiplayer', data) 230 | 231 | 232 | class LadspaEffect(BankObject): 233 | 234 | yaml_tag = '!ladspafx' 235 | 236 | def __init__(self, **pars): 237 | super().__init__(**pars) 238 | if 'group' in pars: 239 | if isinstance(pars['group'], int): 240 | self.pars['group'] = [pars['group']] 241 | elif isinstance(pars['group'], str): 242 | self.pars['group'] = [int(t) for t in pars['group'].split(',')] 243 | if 'audio' in pars: 244 | if ',' in pars['audio']: 245 | self.pars['audio'] = [t.strip() for t in pars['audio'].split(',')] 246 | 247 | @staticmethod 248 | def to_yaml(dumper, data): 249 | return dumper.represent_mapping('!ladspafx', data) 250 | 251 | 252 | class ParamSpec: 253 | 254 | def __init__(self, text): 255 | self.text = str(text) 256 | if not text: 257 | self.tups = [] 258 | elif spec := rspec.match(self.text): 259 | min, max, mul, add = [scinote_to_val(sift(x)) for x in spec.groups()] 260 | self.tups = min, max, mul, add 261 | elif spec := ftspec.match(self.text): 262 | min, max, tomin, tomax = [scinote_to_val(sift(x)) for x in spec.groups()] 263 | if min == None: min, max = 0, 127 264 | if max == None: max = min 265 | if tomin == None and tomax == None: tomin, tomax = min, max 266 | elif tomax == None: tomax = tomin 267 | mul = 1 if min == max else (tomax - tomin) / (max - min) 268 | add = tomin - min * mul 269 | self.tups = min, max, mul, add 270 | else: 271 | self.tups = [] 272 | 273 | def __iter__(self): 274 | return iter(self.tups) 275 | 276 | def __str__(self): 277 | return self.text 278 | 279 | def __bool__(self): 280 | return bool(self.tups) 281 | 282 | @classmethod 283 | def from_yaml(cls, loader, node): 284 | return cls(loader.construct_scalar(node)) 285 | 286 | @staticmethod 287 | def to_yaml(dumper, data): 288 | return dumper.represent_scalar('!chspec', str(data)) 289 | 290 | 291 | class ChannelSpec(ParamSpec): 292 | 293 | def __init__(self, text): 294 | self.text = str(text) 295 | if not text: 296 | self.tups = [] 297 | elif spec := rspec.match(self.text): 298 | min, max, mul, add = [sift(x) for x in spec.groups()] 299 | self.tups = [(min, max, mul, add)] 300 | elif spec := ftspec.match(self.text): 301 | min, max, tomin, tomax = [sift(x) for x in spec.groups()] 302 | if min == None: min, max = 1, 256 303 | if max == None: max = min 304 | if tomin == None: tomin = min 305 | if tomax == None: tomax = max if tomin == None else tomin 306 | mul = 1 if min == max else (tomax - tomin) / (max - min) 307 | add = tomin - min * mul 308 | self.tups = [(min, max, 0.0, chto) 309 | for chto in range(tomin, tomax + 1)] 310 | else: 311 | self.tups = [] 312 | -------------------------------------------------------------------------------- /docs/bank_files.md: -------------------------------------------------------------------------------- 1 | # Bank Files 2 | 3 | Bank files are [YAML](https://yaml.org/)-formatted text files that control many FluidSynth settings, from the soundfont presets that are selected for each MIDI channel to the way that MIDI messages from a controller interact with those presets. They can also activate and control tools such as MIDI file players, sequencers, arpeggiators, and LADSPA effect plugins. 4 | 5 | YAML is a plain text format that stores data, either as lists or as mappings (sets of `: ` pairs). Lists and mappings can be nested within each other, and nesting level is indicated by indenting at least two spaces per level. List elements are placed on separate lines and preceded by a dash, or can be written on a single line as a comma-separated list enclosed in square brackets. Mapping items are listed on separate lines, or on a single line as a comma-separated list enclosed in curly braces. 6 | 7 | Geek Funk Labs has produced a [series of lesson videos](https://youtube.com/playlist?list=PL4a8Oe3qfS_-CefZFNYssT1kHdzEOdAlD) that teach about creating bank files and the many features of FluidSynth, SoundFonts, and MIDI. 8 | 9 | ## Structure 10 | 11 | Bank files have three main sections: 12 | 13 | * A `patches` section that contains the individual patches 14 | * An `init` section that is read when the bank is loaded 15 | * The zero-indent or bank level - everything that is outside the other two sections 16 | 17 | When a patch is selected, bank settings are applied first, followed by the patch settings. The settings in `init` are read before bank and patch settings once, when the bank is loaded. Settings are applied in the order listed in each section. For example, when selecting the `Harpsichord` patch in the bank shown below, `synth.reverb.room-size` is first set to 0.6 by the bank-level `fluidsettings`, then set to 0.1 in the patch. 18 | 19 | ```yaml 20 | init: 21 | messages: [cc:1:91:100, cc:2:91:20] 22 | patches: 23 | Harpsichord: 24 | 1: defaultGM:sf2:000:006 25 | fluidsettings: 26 | synth.reverb.room-size: 0.1 27 | Piano and Bass: 28 | 2: defaultGM.sf2:000:000 29 | 3: defaultGM.sf2:000:034 30 | router_rules: 31 | - {type: note, chan: 1=2, par1: C1-B3} 32 | - {type: note, chan: 1=3, par1: C4-B6} 33 | fluidsettings: 34 | synth.reverb.room-size: 0.6 35 | ``` 36 | 37 | Each section can contain any of the keywords described below, although in the `init` section only `messages` and `fluidsettings` make sense - others will be ignored. The bank files included with the repository in `scripts/config/banks` provide additional examples. 38 | 39 | ## Keywords 40 | 41 | ### `` 42 | 43 | A number as a keyword indicates a MIDI channel on which a preset is to be selected. The preset is specified with the form `::`. MIDI channel numbers in bank files are numbered starting with channel 1. 44 | 45 | ### `messages` 46 | A list of MIDI messages to send. The format depends on message type: 47 | 48 | * Types `note`, `noteoff`, `kpress`, `cc` have two parameters - note number and velocity or controller number and value - and use format `:::`. The second parameter can be a number or note name (e.g. C#4). 49 | * One-parameter messages `pbend`, `cpress`, `prog` use format `:::` 50 | * System realtime messages `clock`, `start`, `continue`, `stop` have no parameters or channel, and use format `:::` 51 | 52 | ### `fluidsettings` 53 | 54 | A mapping of FluidSynth [settings](http://www.fluidsynth.org/api/fluidsettings.xml) and the values to set. Only settings that begin with `synth` will have any effect while the synth is running - any others should be set in the config file. 55 | 56 | ### `router_rules` 57 | 58 | A list of rules for routing incoming MIDI messages - from MIDI controllers, the `messages` keyword, or playing MIDI files - to synthesizer events. Rules must have a `type` parameter, and can also have `chan`, `par1`, and `par2` parameters. The values of the parameters define which messages should trigger the rule and how to modify the parameters in the event that is sent to the synth. Every rule that matches a message is triggered, so a message can trigger multiple events and rules can't override previous rules. When selecting a patch, default rules are created that pass on all messages unmodified to the synth. The rule `clear` will erase these and any previous rules. 59 | 60 | The `chan`, `par1`, and `par2` parameters can have the following formats: 61 | 62 | * a single value matches exactly that value and passes it unmodified 63 | * a range `-` matches any values in the range without changing them 64 | * `-=` matches values in the range and sets them to _value_ in the created event 65 | * `-=-` matches values in the _from_ range and scales them to values in the _to_ range for `par1` and `par2`; for `chan` a message on a channel in the _from_ range triggers events on every channel in the _to_ range 66 | * `=-` matches messages from the specified channel and creates events on every channel in the given range 67 | * `-*+` matches values in the range, multiplies them by _factor_ and adds _offset_. 68 | 69 | The rule type can be any of those listed above in `messages`. The created event can also be a different type from the triggering message by specifying the type as `=`. If the new type has a different number of parameters than the triggering message, the parameters of the event are determined as follows: 70 | 71 | * 2- to 1-parameter: `par2` becomes `par1` 72 | * 1- to 2-parameter: `par1` becomes `par2`, `par1` is taken from `par2` of the rule 73 | * sytem realtime to 1- or 2-parameter: `chan`, `par1`, and `par2` are set by the rule parameters 74 | 75 | Rules can have other parameters, in which case they do not send events to the synth and are used to trigger additional features. A rule with a `fluidsetting` parameter will change the corresponding FluidSynth setting. Rules with a `patch` parameter can be used to select patches - a patch number or name as the parameter value selects that patch, a number followed by `+` or `-` increments the patch number, and `select` sets the patch number according to the message. Patch numbers begin with 1. Other special rules are explained in relevant sections below. 76 | 77 | ### `midiplayers` 78 | 79 | A subsection that contains one or more named midiplayers that can play, loop, and seek within MIDI files. 80 | 81 | * `file`(required) - the MIDI file to play 82 | * `tempo` - tempo at which to play the file, in bpm. If not given, the tempo messages in the file will be obeyed 83 | * `loops` - a list of pairs of _start, end_ ticks. When the song reaches an _end_ tick, it will seek back to the previous _start_ tick in the list. If the _start_ tick is a negative number the player stops. 84 | * `barlength` - the number of ticks corresponding to a whole number of musical measures in the song 85 | * `chan` - same format as for a router rule parameter, can route MIDI messages in the file to different channels 86 | * `mask` - a list of MIDI message types to ignore in the file 87 | 88 | Router rules for controlling midiplayers have a `midiplayer` parameter with the player name as its value. The file pauses playing if the routed message value is zero, otherwise it plays/resumes. If the rule also has a `tick` parameter, the midiplayer will seek to that tick position in the song. If the value of `tick` has a `+` or `-` suffix the midiplayer will seek forward or backward from the current position. If the routed message value is negative and the midiplayer is currently playing, seeking will be postponed until the song reaches the end of a measure as specified by `barlength`. 89 | 90 | A rule with a `tempo` parameter and a midiplayer's name as its value will set the playing tempo of the midiplayer. A router rule with a `sync` parameter will set the tempo of the midiplayer by measuring the time between successive MIDI messages matching the rule, allowing a user to set the tempo by tapping a button or key. The value of the routed message sets the number of beats to sync to the time interval. A `sync` rule with type `clock` will synchronize the player with an external device or program that sends MIDI clock signals. Tempo changes to a midiplayer will cause it to stop paying attention to any tempo change messages in the file. This can be canceled by setting the tempo to zero. 91 | 92 | ### `sequencers` 93 | 94 | A subsection containing one or more named sequencers that can play a series of looped notes. A sequencer can have the following attributes: 95 | 96 | * `notes`(required) - a list of note messages the sequencer will play. There must be a soundfont preset assigned to the MIDI channel of the notes in order to hear them. 97 | * `tempo` - in beats per minute, defaults to 120 98 | * `tdiv` - the length of the notes in the pattern expressed as the number of notes in a measure of four beats. Defaults to 8 99 | * `swing` - the ratio by which to stretch the duration of on-beat notes and shorten off-beat notes, producing a "swing" feel. Values range from 0.5 (no swing) to 0.99. Default is 0.5 100 | * `groove` - an amount by which to multiply the volume of specific notes in a pattern, in order to create a rhythmic feel. Can be a single number, in which case the multiplier is applied to every other note starting with the first, or a list of values. Default is 1 101 | 102 | A router rule with a `sequencer` parameter and the sequencer's name as the value controls the playing of the sequencer. The value of the routed MIDI message controls how many times the sequence will loop. A value of 0 stops the sequencer, and negative values will cause it to loop indefinitely. A rule with a `swing` or `groove` parameter with the sequencer's name will adjust the corresponding sequencer values. Sequencers also respond to `tempo` and `sync` rules in the same way as midiplayers. 103 | 104 | ### `arpeggiators` 105 | 106 | This subsection contains one or more named arpeggiators that capture any notes and repeat them in a pattern as long as the notes are held. 107 | 108 | * `tempo`, `tdiv`, `swing`, `groove` - same as for sequencers 109 | * `octaves` - number of octaves over which to repeat the pattern. Defaults to 1 110 | * `style` - can be `up`, `down`, `both`, or `chord`. The first three options loop the held notes in ascending sequence, descending, or ascending followed by descending. The `chord` option plays all held notes at once repeatedly. If not given, the notes are looped in the order they were played. 111 | 112 | To make the arpeggiator work, create a `note` type router rule with an `arpeggiator` parameter that has the arpeggiator's name as its value. There must be a soundfont preset assigned on the MIDI channel to which the notes are routed in order to hear them. Like sequencers, arpeggiators can also be modified by `swing`, `groove`, `tempo`, and `sync` rules. 113 | 114 | ### `ladspafx` 115 | Contains one or more named units that activate and control external LADSPA effect plugins. See the [Plugins](ladspa_plugins.md) section for details. 116 | 117 | * `lib`(required) - the effect plugin file 118 | * `plugin` - the name of the plugin within the file, required if there's more than one 119 | * `group` - a list of audio group numbers on which to send the effects 120 | * `audio` - a list of the audio input and output ports in the plugin 121 | * `vals` - a mapping of control port names and initial values 122 | 123 | Router rules can be used to control plugin parameters by providing a `ladspafx` parameter with the effect unit name, and a `port` parameter with the control port name. 124 | -------------------------------------------------------------------------------- /fluidpatcher/__init__.py: -------------------------------------------------------------------------------- 1 | """A performance-oriented patch interface for FluidSynth 2 | 3 | A Python interface for the FluidSynth software synthesizer that 4 | allows combination of instrument settings, effects, sequences, 5 | midi file players, etc. into performance patches that can be 6 | quickly switched while playing. Patches are written in a rich, 7 | human-readable YAML-based bank file format. 8 | 9 | Includes: 10 | - pfluidsynth.py: ctypes bindings to libfluidsynth and wrapper classes 11 | for FluidSynth's features/functions 12 | - bankfiles.py: extensions to YAML and functions for parsing bank files 13 | 14 | Requires: 15 | - yaml 16 | - libfluidsynth 17 | """ 18 | 19 | __version__ = '0.9.1' 20 | 21 | from pathlib import Path 22 | from copy import deepcopy 23 | 24 | from .bankfiles import parseyaml, renderyaml, SFPreset, MidiMessage, RouterRule 25 | from .pfluidsynth import Synth 26 | 27 | 28 | class FluidPatcher: 29 | """An interface for running FluidSynth using patches 30 | 31 | Provides methods for: 32 | 33 | - loading/saving the config file and bank files 34 | - applying/creating/copying/deleting patches 35 | - directly controlling the Synth by modifying fluidsettings, 36 | manually adding router rules, and sending MIDI events 37 | - loading a single soundfont and browsing its presets 38 | 39 | Attributes: 40 | midi_callback: a function that takes a pfluidsynth.Midisignal instance 41 | as its argument. Will be called when MIDI events are received or 42 | custom router rules are triggered. This allows scripts to define 43 | and handle their own custom router rules and/or monitor incoming events. 44 | MidiSignal events have `type`, `chan`, `par1`, and `par2` events matching 45 | the triggering event. MidiSignals generated by rules have extra attributes 46 | corresponding to the rule parameters, plus a `val` attribute that is the 47 | result of parameter routing. Rules with a `patch` parameter will be modified 48 | by FluidPatcher so that the `patch` attribute corresponds to the patch index. 49 | If `patch` is -1, `val` is set to the patch increment. 50 | 51 | See the documentation for information on bank file format. 52 | """ 53 | 54 | def __init__(self, cfgfile, **fluidsettings): 55 | """Creates FluidPatcher and starts FluidSynth 56 | 57 | Starts fluidsynth using settings found in yaml-formatted `cfgfile`. 58 | Settings passed via `fluidsettings` will override those in config file. 59 | See https://www.fluidsynth.org/api/fluidsettings.xml for a 60 | full list and explanation of settings. See documentation 61 | for config file format. 62 | 63 | Args: 64 | cfgfile: Path object pointing to config file 65 | fluidsettings: dictionary of additional fluidsettings 66 | """ 67 | 68 | self.cfgfile = Path(cfgfile) 69 | self.cfg = parseyaml(self.cfgfile.read_text()) 70 | self.bank = {} 71 | self.soundfonts = set() 72 | self.fsynth = Synth(**{**self.cfg.get('fluidsettings', {}), **fluidsettings}) 73 | self.fsynth.midi_callback = self._midisignal_handler 74 | self.max_channels = self.fluidsetting_get('synth.midi-channels') 75 | self.patchcord = {'patchcordxxx': {'lib': self.plugindir / 'patchcord', 'audio': 'mono'}} 76 | self.midi_callback = None 77 | 78 | @property 79 | def currentbank(self): 80 | """a Path object pointing to the current bank file""" 81 | return Path(self.cfg['currentbank']) if 'currentbank' in self.cfg else '' 82 | 83 | @property 84 | def bankdir(self): 85 | """Path to bank files""" 86 | return Path(self.cfg.get('bankdir', 'banks')).resolve() 87 | 88 | @property 89 | def sfdir(self): 90 | """Path to soundfonts""" 91 | return Path(self.cfg.get('soundfontdir', self.bankdir / '../sf2')).resolve() 92 | 93 | @property 94 | def mfilesdir(self): 95 | """Path to MIDI files""" 96 | return Path(self.cfg.get('mfilesdir', self.bankdir / '../midi')).resolve() 97 | 98 | @property 99 | def plugindir(self): 100 | """Path to LADSPA effects""" 101 | return Path(self.cfg.get('plugindir', '')).resolve() 102 | 103 | @property 104 | def patches(self): 105 | """List of patch names in the current bank""" 106 | return list(self.bank.get('patches', {})) if self.bank else [] 107 | 108 | def update_config(self): 109 | """Write current configuration stored in `cfg` to file. 110 | """ 111 | self.cfgfile.write_text(renderyaml(self.cfg)) 112 | 113 | def load_bank(self, bankfile='', raw=''): 114 | """Load a bank from a file or from raw yaml text 115 | 116 | Parses a yaml stream from a string or file and stores as a 117 | nested collection of dict and list objects. The top-level 118 | dict must have at minimum a `patches` element or an error 119 | is raised. If loaded from a file successfully, that file 120 | is set as `currentbank` in the config - call update_config() 121 | to make it persistent. 122 | 123 | Upon loading, resets the synth, loads all necessary soundfonts, 124 | and applies settings in the `init` element. Returns the yaml stream 125 | as a string. If called with no arguments, resets the synth and 126 | restores the current bank from memory. 127 | 128 | Args: 129 | bankfile: bank file to load, absolute or relative to `bankdir` 130 | raw: string to parse directly 131 | 132 | Returns: yaml stream that was loaded 133 | """ 134 | if bankfile: 135 | try: 136 | raw = (self.bankdir / bankfile).read_text() 137 | bank = parseyaml(raw) 138 | except: 139 | if Path(bankfile).as_posix() == self.cfg['currentbank']: 140 | self.cfg.pop('currentbank', None) 141 | raise 142 | else: 143 | self.bank = bank 144 | self.cfg['currentbank'] = Path(bankfile).as_posix() 145 | elif raw: 146 | bank = parseyaml(raw) 147 | self.bank = bank 148 | self._reset_synth() 149 | self._refresh_bankfonts() 150 | for zone in self.bank, *self.bank.get('patches', {}).values(): 151 | for midi in zone.get('midiplayers', {}).values(): 152 | midi['file'] = self.mfilesdir / midi['file'] 153 | for fx in zone.get('ladspafx', {}).values(): 154 | fx['lib'] = self.plugindir / fx['lib'] 155 | for syx in self.bank.get('init', {}).get('sysex', []): 156 | self.fsynth.send_sysex(syx) 157 | for opt, val in self.bank.get('init', {}).get('fluidsettings', {}).items(): 158 | self.fluidsetting_set(opt, val) 159 | for msg in self.bank.get('init', {}).get('messages', []): 160 | self.send_event(msg) 161 | return raw 162 | 163 | def save_bank(self, bankfile, raw=''): 164 | """Save a bank file 165 | 166 | Saves the current bank in memory to `bankfile` after rendering it as 167 | a yaml stream. If `raw` is provided, it is parsed as the new bank and 168 | its exact contents are written to the file. 169 | 170 | Args: 171 | bankfile: file to save, absolute or relative to `bankdir` 172 | raw: exact text to save 173 | """ 174 | if raw: 175 | bank = parseyaml(raw) 176 | self.bank = bank 177 | else: 178 | raw = renderyaml(self.bank) 179 | (self.bankdir / bankfile).write_text(raw) 180 | self.cfg['currentbank'] = Path(bankfile).as_posix() 181 | 182 | def apply_patch(self, patch): 183 | """Select a patch and apply its settings 184 | 185 | Read the settings for the patch specified by index or name and combine 186 | them with bank-level settings. Select presets on specified channels and 187 | unsets others, clears router rules and applies new ones, activates 188 | players and effects and deactivates unused ones, send messages, and 189 | applies fluidsettings. Patch settings are applied after bank settings. 190 | If the specified patch isn't found, only bank settings are applied. 191 | 192 | Args: 193 | patch: patch index or name 194 | 195 | Returns: a list of warnings, if any 196 | """ 197 | warnings = [] 198 | patch = self._resolve_patch(patch) 199 | def mrg(kw): 200 | try: return self.bank.get(kw, {}) | patch.get(kw, {}) 201 | except TypeError: return self.bank.get(kw, []) + patch.get(kw, []) 202 | # presets 203 | for ch in range(1, self.max_channels + 1): 204 | if p := self.bank.get(ch) or patch.get(ch): 205 | if not self.fsynth.program_select(ch, self.sfdir / p.sfont, p.bank, p.prog): 206 | warnings.append(f"Unable to select preset {p} on channel {ch}") 207 | else: self.fsynth.program_unset(ch) 208 | # sysex 209 | for syx in mrg('sysex'): 210 | self.fsynth.send_sysex(syx) 211 | # fluidsettings 212 | for opt, val in mrg('fluidsettings').items(): 213 | self.fluidsetting_set(opt, val) 214 | # sequencers, arpeggiators, midiplayers 215 | self.fsynth.players_clear(save=[*mrg('sequencers'), *mrg('arpeggiators'), *mrg('midiplayers')]) 216 | for name, seq in mrg('sequencers').items(): 217 | self.fsynth.sequencer_add(name, **seq) 218 | for name, arp in mrg('arpeggiators').items(): 219 | self.fsynth.arpeggiator_add(name, **arp) 220 | for name, midi in mrg('midiplayers').items(): 221 | self.fsynth.midiplayer_add(name, **midi) 222 | # ladspa effects 223 | self.fsynth.fxchain_clear(save=mrg('ladspafx')) 224 | for name, fx in (mrg('ladspafx') | self.patchcord).items(): 225 | self.fsynth.fxchain_add(name, **fx) 226 | self.fsynth.fxchain_connect() 227 | # router rules -- invert b/c fluidsynth applies rules last-first 228 | self.fsynth.router_default() 229 | rules = [*mrg('router_rules')][::-1] 230 | if 'clear' in rules: 231 | self.fsynth.router_clear() 232 | rules = rules[:rules.index('clear')] 233 | for rule in rules: 234 | rule.add(self.fsynth.router_addrule) 235 | # midi messages 236 | for msg in mrg('messages'): 237 | self.send_event(msg) 238 | return warnings 239 | 240 | def add_patch(self, name, addlike=None): 241 | """Add a new patch 242 | 243 | Create a new empty patch, or one that copies all settings 244 | other than instruments from an existing patch 245 | 246 | Args: 247 | name: a name for the new patch 248 | addlike: number or name of an existing patch 249 | 250 | Returns: the index of the new patch 251 | """ 252 | if 'patches' not in self.bank: self.bank['patches'] = {} 253 | self.bank['patches'][name] = {} 254 | if addlike: 255 | addlike = self._resolve_patch(addlike) 256 | for x in addlike: 257 | if not isinstance(x, int): 258 | self.bank['patches'][name][x] = deepcopy(addlike[x]) 259 | return self.patches.index(name) 260 | 261 | def update_patch(self, patch): 262 | """Update the current patch 263 | 264 | Instruments and controller values can be changed by program change (PC) 265 | and continuous controller (CC) messages, but these will not persist 266 | in the patch unless this function is called. Settings can be saved to 267 | a new patch by first calling add_patch(), then update_patch() on the 268 | new patch. The bank file must be saved for updated patches to become 269 | permanent. 270 | 271 | Args: 272 | patch: index or name of the patch to update 273 | """ 274 | patch = self._resolve_patch(patch) 275 | messages = set(patch.get('messages', [])) 276 | for channel in range(1, self.max_channels + 1): 277 | for cc, default in enumerate(_CC_DEFAULTS): 278 | if default < 0: continue 279 | val = self.fsynth.get_cc(channel, cc) 280 | if val != default: 281 | messages.add(MidiMessage('cc', channel, cc, val)) 282 | info = self.fsynth.program_info(channel) 283 | if not info: 284 | patch.pop(channel, None) 285 | continue 286 | sfont, bank, prog = info 287 | sfrel = Path(sfont).relative_to(self.sfdir).as_posix() 288 | patch[channel] = SFPreset(sfrel, bank, prog) 289 | if messages: 290 | patch['messages'] = list(messages) 291 | 292 | def delete_patch(self, patch): 293 | """Delete a patch from the bank in memory 294 | 295 | Bank file must be saved for deletion to be permanent 296 | 297 | Args: 298 | patch: index or name of the patch to delete 299 | """ 300 | if isinstance(patch, int): 301 | name = self.patches[patch] 302 | else: 303 | name = patch 304 | del self.bank['patches'][name] 305 | self._refresh_bankfonts() 306 | 307 | def fluidsetting_get(self, opt): 308 | """Get the current value of a FluidSynth setting 309 | 310 | Args: 311 | opt: setting name 312 | 313 | Returns: the setting's current value as float, int, or str 314 | """ 315 | return self.fsynth.get_setting(opt) 316 | 317 | def fluidsetting_set(self, opt, val, patch=None): 318 | """Change a FluidSynth setting 319 | 320 | Modifies a FluidSynth setting. Settings without a "synth." prefix 321 | are ignored. If `patch` is provided, these settings are also added to 322 | the current bank in memory at bank level, and any conflicting 323 | settings are removed from the specified patch - which should ideally 324 | be the current patch so that the changes can be heard. The bank file 325 | must be saved for the changes to become permanent. 326 | 327 | Args: 328 | opt: setting name 329 | val: new value to set, type depends on setting 330 | patch: patch name or index 331 | """ 332 | if not opt.startswith('synth.'): return 333 | self.fsynth.setting(opt, val) 334 | if patch != None: 335 | if 'fluidsettings' not in self.bank: 336 | self.bank['fluidsettings'] = {} 337 | self.bank['fluidsettings'][opt] = val 338 | patch = self._resolve_patch(patch) 339 | if 'fluidsettings' in patch and opt in patch['fluidsettings']: 340 | del patch['fluidsettings'][opt] 341 | 342 | def add_router_rule(self, **pars): 343 | """Add a router rule to the Synth 344 | 345 | Directly add a router rule to the Synth. This rule will be added 346 | after the current bank- and patch-level rules. The rule is not 347 | saved to the bank, and will disappear if a patch is applied 348 | or the synth is reset. 349 | 350 | Returns: 351 | pars: router rule as a set of key=value pairs 352 | """ 353 | RouterRule(**pars).add(self.fsynth.router_addrule) 354 | 355 | def send_event(self, msg=None, type='note', chan=1, par1=0, par2=0): 356 | """Send a MIDI event to the Synth 357 | 358 | Sends a MidiMessage, or constructs one from a bank file-styled string 359 | (:::) or keywords and sends it 360 | to the Synth, which will apply all current router rules. 361 | 362 | Args: 363 | msg: MidiMessage instance or string 364 | type: event type as string 365 | chan: MIDI channel 366 | par1: first parameter, integer or note name 367 | par2: second parameter for valid types 368 | """ 369 | if isinstance(msg, str): 370 | msg = parseyaml(msg) 371 | elif msg == None: 372 | msg = MidiMessage(type, chan, par1, par2) 373 | self.fsynth.send_event(*msg) 374 | 375 | def solo_soundfont(self, soundfont): 376 | """Suspend the current bank and load a single soundfont 377 | 378 | Resets the Synth, loads a single soundfont, and creates router 379 | rules that route messages from all channels to channel 1. 380 | Scans through each bank and program in order and retrieves the 381 | preset name. After this, select_sfpreset() can be used to play 382 | any instrument in the soundfont. Call load_bank() with no 383 | arguments to restore the current bank. 384 | 385 | Args: 386 | soundfont: soundfont file to load, absolute or relative to `sfdir` 387 | 388 | Returns: a list of (bank, prog, name) tuples for each preset 389 | """ 390 | for sfont in self.soundfonts - {soundfont}: 391 | self.fsynth.unload_soundfont(self.sfdir / sfont) 392 | if {soundfont} - self.soundfonts: 393 | if not self.fsynth.load_soundfont(self.sfdir / soundfont): 394 | self.soundfonts = set() 395 | return [] 396 | self.soundfonts = {soundfont} 397 | self._reset_synth() 398 | for channel in range(1, self.max_channels + 1): 399 | self.fsynth.program_unset(channel) 400 | for type in 'note', 'cc', 'pbend', 'cpress', 'kpress': 401 | self.add_router_rule(type=type, chan=f"2-{self.max_channels}=1") 402 | return self.fsynth.get_sfpresets(self.sfdir / soundfont) 403 | 404 | def select_sfpreset(self, sfont, bank, prog, *_): 405 | """Select a preset on channel 1 406 | 407 | Call to select one of the presets in the soundfont loaded 408 | by solo_soundfount(). The variable-length garbage argument 409 | allows this function to be called by unpacking one of the 410 | tuples returned by solo_soundfont(). 411 | 412 | Args: 413 | sfont: the soundfont file loaded by solo_soundfont(), 414 | absolute or relative to `sfdir` 415 | bank: the bank to select 416 | prog: the program to select from bank 417 | 418 | Returns: a list of warnings, empty if none 419 | """ 420 | if sfont not in self.soundfonts: 421 | return [f"{str(sfont)} is not loaded"] 422 | if self.fsynth.program_select(1, self.sfdir / sfont, bank, prog): 423 | return [] 424 | else: return [f"Unable to select preset {str(sfont)}:{bank:03d}:{prog:03d}"] 425 | 426 | def _midisignal_handler(self, sig): 427 | if 'patch' in sig: 428 | if sig.patch in self.patches: 429 | sig.patch = self.patches.index(sig.patch) 430 | elif sig.patch == 'select': 431 | sig.patch = int(sig.val - 1) % len(self.patches) 432 | elif str(sig.patch)[-1] in '+-': 433 | sig.val = int(sig.patch[-1] + sig.patch[:-1]) 434 | sig.patch = -1 435 | elif isinstance(sig.patch, int): 436 | sig.patch -= 1 437 | else: 438 | sig.patch = -1 439 | sig.val = 0 440 | if self.midi_callback: self.midi_callback(sig) 441 | 442 | def _refresh_bankfonts(self): 443 | sfneeded = set() 444 | for zone in self.bank, *self.bank.get('patches', {}).values(): 445 | for sfont in [zone[ch].sfont for ch in zone if isinstance(ch, int)]: 446 | sfneeded.add(sfont) 447 | missing = set() 448 | for sfont in self.soundfonts - sfneeded: 449 | self.fsynth.unload_soundfont(self.sfdir / sfont) 450 | for sfont in sfneeded - self.soundfonts: 451 | if not self.fsynth.load_soundfont(self.sfdir / sfont): 452 | missing.add(sfont) 453 | self.soundfonts = sfneeded - missing 454 | 455 | def _resolve_patch(self, patch): 456 | if isinstance(patch, int): 457 | if 0 <= patch < len(self.patches): 458 | patch = self.patches[patch] 459 | else: patch = {} 460 | if isinstance(patch, str): 461 | patch = self.bank.get('patches', {}).get(patch, {}) 462 | return patch 463 | 464 | def _reset_synth(self): 465 | self.fsynth.players_clear() 466 | self.fsynth.fxchain_clear() 467 | self.fsynth.router_default() 468 | self.fsynth.reset() 469 | for opt, val in {**_SYNTH_DEFAULTS, **self.cfg.get('fluidsettings', {})}.items(): 470 | self.fluidsetting_set(opt, val) 471 | 472 | 473 | _CC_DEFAULTS = [0] * 120 474 | _CC_DEFAULTS[0] = -1 # bank select 475 | _CC_DEFAULTS[7] = 100 # volume 476 | _CC_DEFAULTS[8] = 64 # balance 477 | _CC_DEFAULTS[10] = 64 # pan 478 | _CC_DEFAULTS[11] = 127 # expression 479 | _CC_DEFAULTS[32] = -1 # bank select LSB 480 | _CC_DEFAULTS[43] = 127 # expression LSB 481 | _CC_DEFAULTS[70:80] = [64] * 10 # sound controls 482 | _CC_DEFAULTS[84] = 255 # portamento control 483 | _CC_DEFAULTS[96:102] = [-1] * 6 # RPN/NRPN controls 484 | 485 | _SYNTH_DEFAULTS = {'synth.chorus.active': 1, 'synth.reverb.active': 1, 486 | 'synth.chorus.depth': 8.0, 'synth.chorus.level': 2.0, 487 | 'synth.chorus.nr': 3, 'synth.chorus.speed': 0.3, 488 | 'synth.reverb.damp': 0.0, 'synth.reverb.level': 0.9, 489 | 'synth.reverb.room-size': 0.2, 'synth.reverb.width': 0.5, 490 | 'synth.gain': 0.2} 491 | -------------------------------------------------------------------------------- /scripts/fluidpatcher_gui.pyw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pythonw 2 | """ 3 | Description: a graphical implementation of fluidpatcher 4 | for composing/editing bank files or live playing 5 | """ 6 | 7 | from pathlib import Path 8 | import sys 9 | import tkinter as tk 10 | from tkinter.font import Font 11 | import tkinter.ttk as ttk 12 | import tkinter.messagebox as msgbox 13 | import tkinter.filedialog as filedlg 14 | import traceback 15 | import webbrowser 16 | 17 | from fluidpatcher import FluidPatcher, __version__ 18 | 19 | WIDTH = 500 20 | HEIGHT = 300 21 | XPOS = 50 22 | YPOS = 50 23 | FONTSIZE = 24 24 | PAD = 10 25 | FILLSCREEN = False 26 | 27 | MSG_TYPES = 'note', 'noteoff', 'kpress', 'cc', 'prog', 'pbend', 'cpress' 28 | MSG_NAMES = "Note On", "Note Off", "Key Pressure", "Control Change", "Program Change", "Pitch Bend", "Aftertouch" 29 | 30 | def gui_excepthook(etype, val, tb): 31 | s = traceback.format_exception(etype, val, tb) 32 | msgbox.showerror("Error", ''.join(s)) 33 | 34 | 35 | class PersistentDialog(tk.Toplevel): 36 | 37 | def __init__(self, master, hint=""): 38 | super().__init__() 39 | self.master = master 40 | self.protocol('WM_DELETE_WINDOW', self.hide) 41 | self.last_geometry = hint 42 | 43 | def show(self, *_): 44 | if self.state() == 'normal': 45 | self.last_geometry = self.geometry() 46 | self.withdraw() 47 | else: 48 | self.transient(self.master) 49 | self.geometry(self.last_geometry) 50 | self.deiconify() 51 | 52 | def hide(self, *_): 53 | self.last_geometry = self.geometry() 54 | self.withdraw() 55 | 56 | 57 | class PresetChooser(PersistentDialog): 58 | 59 | def __init__(self, master): 60 | super().__init__(master) 61 | self.master = master 62 | self.plist = ttk.Treeview(self, columns=('bank', 'prog', 'name'), show='headings', selectmode='browse') 63 | ysc = ttk.Scrollbar(self, orient='vertical', command=self.plist.yview) 64 | self.plist.configure(yscrollcommand=ysc.set) 65 | self.plist.heading(0, text='Bank') 66 | self.plist.heading(1, text='Program') 67 | self.plist.heading(2, text='Name') 68 | self.plist.column(0, width=75) 69 | self.plist.column(1, width=75) 70 | self.plist.column(2, width=200) 71 | ysc.pack(side='right', fill='y') 72 | self.plist.pack(fill='both', expand=True) 73 | self.plist.bind('<>', self.preset_select) 74 | self.plist.bind('', self.ok) 75 | self.bind('', self.ok) 76 | self.bind('', self.cancel) 77 | self.protocol('WM_DELETE_WINDOW', self.cancel) 78 | 79 | def preset_select(self, *_): 80 | if (self.plist.selection()) == (): 81 | self.preset_text = "" 82 | else: 83 | bank, prog, name = self.presets[int(self.plist.selection()[0])] 84 | warn = self.master.fp.select_sfpreset(self.title(), bank, prog) 85 | if warn: 86 | msgbox.showwarning("Preset Warning", '\n'.join(warn)) 87 | self.preset_text = f"{self.title()}:{bank:03d}:{prog:03d}" 88 | 89 | def getpreset(self, sfrel, presets): 90 | self.title(sfrel) 91 | self.presets = presets 92 | for i in self.plist.get_children(): 93 | self.plist.delete(i) 94 | for i, (bank, prog, name) in enumerate(presets): 95 | self.plist.insert('', 'end', iid=str(i), values=(f"{bank:03d}", f"{prog:03d}", name)) 96 | self.plist.selection_set("0") 97 | self.show() 98 | self.wait_visibility() 99 | self.grab_set() 100 | 101 | def ok(self, *_): 102 | self.grab_release() 103 | self.hide() 104 | self.master.bedit.text.insert('insert', self.preset_text) 105 | self.master.bedit.text.see('insert') 106 | self.master.bedit.text.focus_set() 107 | self.master.parse_bank() 108 | 109 | def cancel(self, *_): 110 | self.grab_release() 111 | self.hide() 112 | 113 | 114 | class SettingsDialog(PersistentDialog): 115 | 116 | def __init__(self, master): 117 | super().__init__(master) 118 | self.master = master 119 | self.title(cfgfile) 120 | self.cfg = tk.Text(self, wrap='none') 121 | xsc = ttk.Scrollbar(self, orient='horizontal', command=self.cfg.xview) 122 | ysc = ttk.Scrollbar(self, orient='vertical', command=self.cfg.yview) 123 | bf = ttk.Frame(self, relief='groove', padding=10) 124 | self.cfg.configure(xscrollcommand=xsc.set, yscrollcommand=ysc.set) 125 | self.cfg.grid(row=0, column=0, sticky='nsew') 126 | ysc.grid(row=0, column=1, sticky='ns') 127 | xsc.grid(row=1, column=0, sticky='ew') 128 | bf.grid(row=2, column=0, sticky='ew', columnspan=2) 129 | self.grid_columnconfigure(0, weight=1) 130 | self.grid_rowconfigure(0, weight=1) 131 | applybtn = ttk.Button(bf, text="Apply", command=self.apply) 132 | cancelbtn = ttk.Button(bf, text="Cancel", command=self.cancel) 133 | cancelbtn.pack(side='right') 134 | applybtn.pack(side='right') 135 | self.protocol('WM_DELETE_WINDOW', self.cancel) 136 | 137 | def viewsettings(self): 138 | self.cfg.delete('1.0', 'end') 139 | self.cfg.insert('1.0', cfgfile.read_text()) 140 | self.show() 141 | self.wait_visibility() 142 | self.grab_set() 143 | 144 | def apply(self): 145 | cfgfile.write_text(self.cfg.get('1.0', 'end')) 146 | try: 147 | self.master.fp = FluidPatcher(cfgfile) 148 | except Exception as e: 149 | msgbox.showerror("Configuration Error", f"Error in config file {cfgfile}:\n{str(e)}") 150 | else: 151 | self.grab_release() 152 | self.hide() 153 | self.master.fp.midi_callback = self.master.listener 154 | self.master.parse_bank() 155 | 156 | def cancel(self): 157 | self.grab_release() 158 | self.hide() 159 | 160 | 161 | class MainWindow(ttk.Frame): 162 | 163 | def __init__(self): 164 | try: 165 | self.fp = FluidPatcher(cfgfile) 166 | except Exception as e: 167 | msgbox.showerror("Configuration Error", f"Error in config file {cfgfile}:\n{str(e)}") 168 | sys.exit() # open settings dialog instead! 169 | 170 | super().__init__() 171 | self.master.title('FluidPatcher') 172 | self.master.geometry(f'{WIDTH}x{HEIGHT}+{XPOS}+{YPOS}') 173 | self.master.protocol('WM_DELETE_WINDOW', self.menu_exit) 174 | 175 | # create the bank editor window 176 | self.bedit = PersistentDialog(self, f'+{XPOS + WIDTH + 20}+{YPOS}') 177 | self.bedit.text = tk.Text(self.bedit, wrap='none', undo=True) 178 | xsc = ttk.Scrollbar(self.bedit, orient='horizontal', command=self.bedit.text.xview) 179 | ysc = ttk.Scrollbar(self.bedit, orient='vertical', command=self.bedit.text.yview) 180 | self.bedit.text.configure(xscrollcommand=xsc.set, yscrollcommand=ysc.set) 181 | self.bedit.text.grid(row=0, column=0, sticky='nsew') 182 | ysc.grid(row=0, column=1, sticky='ns') 183 | xsc.grid(row=1, column=0, sticky='ew') 184 | self.bedit.grid_columnconfigure(0, weight=1) 185 | self.bedit.grid_rowconfigure(0, weight=1) 186 | self.bedit.withdraw() 187 | self.bedit.text.bind('', self.parse_bank) 188 | 189 | # create the midi monitor window 190 | self.midimon = PersistentDialog(self, f'+{XPOS}+{YPOS + HEIGHT + 75}') 191 | self.midimon.title("MIDI Monitor") 192 | self.midimon.msglist = ttk.Treeview(self.midimon, columns=('type', 'chan', 'data'), show='headings', selectmode='none') 193 | ysc = ttk.Scrollbar(self.midimon, orient='vertical', command=self.midimon.msglist.yview) 194 | self.midimon.msglist.configure(yscrollcommand=ysc.set) 195 | self.midimon.msglist.heading(0, text='Type') 196 | self.midimon.msglist.heading(1, text='Channel') 197 | self.midimon.msglist.heading(2, text='Data') 198 | self.midimon.msglist.column(0, width=120) 199 | self.midimon.msglist.column(1, width=60) 200 | self.midimon.msglist.column(2, width=100) 201 | ysc.pack(side='right', fill='y') 202 | self.midimon.msglist.pack(fill='both', expand=True) 203 | self.midimon.withdraw() 204 | 205 | # create the preset chooser window 206 | self.presetdlg = PresetChooser(self) 207 | self.presetdlg.withdraw() 208 | 209 | # create the settings dialog 210 | self.settingsdlg = SettingsDialog(self) 211 | self.settingsdlg.withdraw() 212 | 213 | # create the main menu 214 | self.menu = tk.Menu(self) 215 | fm = tk.Menu(self.menu, tearoff=0) 216 | fm.add_command(label='New Bank', underline=0, command=self.menu_new, accelerator='Ctrl+N') 217 | fm.add_command(label='Load Bank', underline=1, command=self.menu_open, accelerator='Ctrl+O') 218 | fm.add_command(label='Save Bank', underline=0, command=self.menu_save, accelerator='Ctrl+S') 219 | fm.add_command(label='Save Bank As...', underline=10, command=self.menu_saveas, accelerator='Ctrl+Shift+S') 220 | fm.add_separator() 221 | fm.add_command(label='Exit', underline=1, command=self.menu_exit, accelerator='Ctrl+Q') 222 | self.bind_all('', self.menu_new) 223 | self.bind_all('', self.menu_open) 224 | self.bind_all('', self.menu_save) 225 | self.bind_all('', self.menu_saveas) 226 | self.bind_all('', self.menu_exit) 227 | 228 | self.patchmenu = tk.Menu(self.menu, tearoff=0) 229 | 230 | tm = tk.Menu(self.menu, tearoff=0) 231 | tm.add_command(label="Edit Bank", underline=5, command=self.bedit.show, accelerator='Ctrl+B') 232 | tm.add_command(label="Choose Preset", underline=7, command=self.menu_choosepreset, accelerator='Ctrl+P') 233 | tm.add_command(label="Midi Monitor", underline=0, command=self.menu_midimon, accelerator='Ctrl+M') 234 | tm.add_command(label="Fill Screen", underline=0, command=self.menu_fillscreen, accelerator='F11') 235 | tm.add_separator() 236 | tm.add_command(label="Settings", underline=0, command=self.settingsdlg.viewsettings) 237 | self.bind_all('', self.bedit.show) 238 | self.bind_all('', self.menu_choosepreset) 239 | self.bind_all('', self.menu_midimon) 240 | self.bind_all('', self.menu_fillscreen) 241 | for key in 'bnop': 242 | self.bind_class('Text', f'', lambda _: ()) 243 | 244 | hm = tk.Menu(self.menu, tearoff=0, name='help') 245 | hm.add_command(label='Quick Help', underline=6, 246 | command=lambda: webbrowser.open('https://geekfunklabs.github.io/fluidpatcher/basic_usage/#fluidpatcher_guipyw')) 247 | hm.add_command(label='Documentation', underline=0, 248 | command=lambda: webbrowser.open('https://geekfunklabs.github.io/fluidpatcher')) 249 | hm.add_command(label='About', underline=0, command=lambda: msgbox.showinfo("About", f""" 250 | FluidPatcher {__version__} 251 | github.com/albedozero/fluidpatcher 252 | 253 | by Bill Peterson 254 | geekfunklabs.com 255 | 256 | Python version {sys.version.split()[0]} 257 | """)) 258 | 259 | self.menu.add_cascade(label='File', underline=0, menu=fm) 260 | self.menu.add_cascade(label='Patches', underline=0, menu=self.patchmenu) 261 | self.menu.add_cascade(label='Tools', underline=0, menu=tm) 262 | self.menu.add_cascade(label='Help', underline=0, menu=hm) 263 | self.master.configure(menu=self.menu) 264 | 265 | # create the UI 266 | self.ui = tk.Canvas(self) 267 | w, h = WIDTH, HEIGHT 268 | fh = int(FONTSIZE * 1.6) 269 | font1 = Font(size=FONTSIZE) 270 | font2 = Font(size=int(0.8 * FONTSIZE)) 271 | h2 = fh * 3 + PAD * 4 272 | self.ui.create_rectangle(0, 0, 0, 0, fill='#0064ff', width=0, tags='lcd') 273 | self.ui.create_text(0, 0, fill="white", anchor='nw', font=font1, tags='row1') 274 | self.ui.create_text(0, 0, fill="white", anchor='nw', font=font1, tags='row2') 275 | self.ui.create_text(0, 0, fill="white", anchor='ne', font=font1, tags='row3') 276 | self.ui.create_rectangle(0, 0, 0, 0, width=4, tags='frame') 277 | for i, symbol, color in ((0, '-', '#ffff00'), (1, '+', '#00ff00'), (2, '>', '#ff6464')): 278 | self.ui.create_rectangle(0, 0, 0, 0, fill=color, width=0, tags=f"button{i}") 279 | self.ui.create_text(0, 0, text=symbol, fill="black", font=font2, tags=(f"button{i}", f"symbol{i}")) 280 | if h > (fh + PAD) * 5 and w > (fh + PAD) * 4: 281 | self.ui.create_text(0, 0, fill="black", anchor='nw', font=font2, tags=(f"button{i}", f"name{i}")) 282 | self.ui.create_text(0, 0, fill="black", anchor='se', font=font2, tags=(f"button{i}", f"accel{i}")) 283 | self.ui.bind('', self.resizeui) 284 | self.ui.pack(fill='both', expand=1) 285 | self.pack(fill='both', expand=1) 286 | self.pack_propagate(0) 287 | self.ui.tag_bind("button0", '', lambda _: self.select_patch((self.pno - 1) % len(self.fp.patches))) 288 | self.ui.tag_bind("button1", '', lambda _: self.select_patch((self.pno + 1) % len(self.fp.patches))) 289 | self.ui.tag_bind("button2", '', self.next_bank) 290 | self.bind_all('', lambda _: self.select_patch((self.pno - 1) % len(self.fp.patches))) 291 | self.bind_all('', lambda _: self.select_patch((self.pno + 1) % len(self.fp.patches))) 292 | self.bind_all('', self.next_bank) 293 | 294 | # initialize stuff 295 | self._lastfile = '' 296 | self.last_bankdir = self.fp.bankdir 297 | self.last_sfdir = self.fp.sfdir 298 | self.last_sfont = "" 299 | self.fp.midi_callback = self.listener 300 | self.load_bank(self.fp.currentbank) 301 | if FILLSCREEN: self.menu_fillscreen() 302 | 303 | @property 304 | def lastfile(self): 305 | return self._lastfile 306 | 307 | @lastfile.setter 308 | def lastfile(self, bfile): 309 | self._lastfile = bfile 310 | if bfile == '': 311 | self.set_text(row1="(Untitled)") 312 | self.bedit.title("(Untitled)") 313 | else: 314 | self.set_text(row1=self._lastfile) 315 | self.bedit.title(self._lastfile) 316 | 317 | def select_patch(self, i): 318 | if self.lastfile == '': 319 | self.set_text(row1="Untitled") 320 | else: 321 | self.set_text(row1=self.lastfile) 322 | if self.fp.patches == []: 323 | self.pno = -1 324 | self.set_text(row2="No Patches", row3="patch 0/0") 325 | else: 326 | self.pno = i 327 | self.set_text(row2=self.fp.patches[self.pno], 328 | row3=f"patch {self.pno + 1}/{len(self.fp.patches)}") 329 | warn = self.fp.apply_patch(self.pno) 330 | if warn: 331 | msgbox.showwarning("Patch Warning", '\n'.join(warn)) 332 | 333 | def resizeui(self, event): 334 | w, h = event.width, event.height 335 | fh = int(FONTSIZE * 1.6) 336 | h2 = fh * 3 + PAD * 4 337 | self.ui.coords('lcd', 0, 0, w, h2) 338 | self.ui.coords('row1', PAD, PAD) 339 | self.ui.coords('row2', PAD, fh * 1 + PAD * 2) 340 | self.ui.coords('row3', w - PAD, fh * 2 + PAD * 3) 341 | self.ui.coords('frame', 3, 3, w - 2, h2 - 2) 342 | for i, name, accel in ((0, 'Prev', '[F3]'), (1, 'Next', '[F4]'), (2, 'Bank', '[F6]')): 343 | self.ui.coords(f"button{i}", int(w / 3 * i), h2, int(w / 3 * (i + 1)), h) 344 | self.ui.coords(f"symbol{i}", int(w / 3 * (i + 0.5)), int((h + h2) / 2)) 345 | if h > (fh + PAD) * 6 and w > (fh + PAD) * 5: 346 | self.ui.coords(f"name{i}", int(w / 3 * i + PAD), h2 + PAD) 347 | self.ui.itemconfigure(f"name{i}", text=name) 348 | self.ui.coords(f"accel{i}", int(w / 3 * (i + 1) - PAD), h - PAD) 349 | self.ui.itemconfigure(f"accel{i}", text=accel) 350 | else: 351 | self.ui.itemconfigure(f"name{i}", text='') 352 | self.ui.itemconfigure(f"accel{i}", text='') 353 | 354 | def set_text(self, row1=None, row2=None, row3=None): 355 | if row1 != None: 356 | self.ui.itemconfigure('row1', text=row1) 357 | if row2 != None: 358 | self.ui.itemconfigure('row2', text=row2) 359 | if row3 != None: 360 | self.ui.itemconfigure('row3', text=row3) 361 | 362 | def listener(self, sig): 363 | if hasattr(sig, 'val'): 364 | if hasattr(sig, 'patch') and self.fp.patches: 365 | if sig.patch == -1: 366 | self.select_patch((self.pno + sig.val) % len(self.fp.patches)) 367 | else: 368 | self.select_patch(sig.patch) 369 | elif hasattr(sig, 'lcdwrite'): 370 | if hasattr(sig, 'format'): 371 | val = format(sig.val, sig.format) 372 | self.set_text(row3=f"{sig.lcdwrite} {val}") 373 | else: 374 | self.set_text(row3=sig.lcdwrite) 375 | elif sig.type in MSG_TYPES and self.midimon.state() == 'normal': 376 | t = MSG_TYPES.index(sig.type) 377 | if t < 3: 378 | octave = int(sig.par1 / 12) - 1 379 | note = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')[sig.par1 % 12] 380 | msg = MSG_NAMES[t], sig.chan, f"{sig.par1} ({note}{octave})={sig.par2}" 381 | elif t < 4: 382 | msg = MSG_NAMES[t], sig.chan, f"{sig.par1}={sig.par2}" 383 | elif t < 7: 384 | msg = MSG_NAMES[t], sig.chan, sig.par1 385 | self.midimon.msglist.insert('', 'end', values=msg) 386 | self.midimon.msglist.yview_moveto(1.0) 387 | 388 | def menu_new(self, *_): 389 | if self.bedit.text.edit_modified(): 390 | resp = msgbox.askyesnocancel("New", "Unsaved changes in bank - save?") 391 | if resp == True: self.menu_saveas() 392 | elif resp == None: return 393 | self.lastfile = '' 394 | self.select_patch(0) 395 | self.bedit.text.delete('1.0', 'end') 396 | self.fp.load_bank(raw="{}") 397 | self.bedit.text.edit_modified(False) 398 | 399 | def menu_open(self, *_): 400 | if self.bedit.text.edit_modified(): 401 | resp = msgbox.askyesnocancel("Load Bank", "Unsaved changes in bank - save?") 402 | if resp == True: self.menu_saveas() 403 | elif resp == None: return 404 | bank = filedlg.askopenfilename(initialdir=self.last_bankdir, initialfile=self.lastfile, 405 | defaultextension='.yaml', 406 | filetypes=[('Bank Files', '*.yaml')]) 407 | if bank == '': return 408 | self.last_bankdir = Path(bank).parent 409 | self.load_bank(bank) 410 | 411 | def menu_save(self, bank=''): 412 | if bank == '': bank = self.lastfile 413 | bank = Path(bank).resolve().relative_to(self.fp.bankdir) 414 | try: 415 | self.fp.save_bank(bank, self.bedit.text.get('1.0', 'end')) 416 | except Exception as e: 417 | msgbox.showerror("Save Bank", f"Error saving {bfile}:\n{str(e)}") 418 | return 419 | self.fp.update_config() 420 | self.lastfile = bank 421 | self.bedit.text.edit_modified(False) 422 | 423 | def menu_saveas(self, *_): 424 | if self.bedit.text['foreground'] == 'red': 425 | resp = msgbox.askokcancel("Save", "Errors in bank - save anyway?") 426 | if resp == False: return 427 | bank = filedlg.asksaveasfilename(initialdir=str(self.last_bankdir), defaultextension='.yaml', 428 | filetypes=[('Bank Files', '*.yaml')]) 429 | if bank == '': return 430 | self.last_bankdir = Path(bank).parent 431 | self.menu_save(bank) 432 | 433 | def menu_exit(self, *_): 434 | if self.bedit.text.edit_modified(): 435 | resp = msgbox.askyesnocancel("Exit", "Unsaved changes in bank - save?") 436 | if resp == True: self.menu_saveas() 437 | elif resp == None: return 438 | sys.exit() 439 | 440 | def menu_choosepreset(self, *_): 441 | sfont = filedlg.askopenfilename(initialdir=self.last_sfdir, initialfile=self.last_sfont, 442 | defaultextension='.sf2', 443 | filetypes=[('Soundfonts', '*.sf2')]) 444 | if sfont == '': return 445 | self.last_sfdir = Path(sfont).parent 446 | sfrel = Path(sfont).relative_to(self.fp.sfdir).as_posix() 447 | if not (presets := self.fp.solo_soundfont(sfrel)): 448 | msgbox.showerror("Choose Preset", f"Unable to load {sf}") 449 | return 450 | self.last_sfont = sfont 451 | self.presetdlg.getpreset(sfrel, presets) 452 | 453 | def menu_midimon(self, *_): 454 | for i in self.midimon.msglist.get_children(): 455 | self.midimon.msglist.delete(i) 456 | self.midimon.show() 457 | 458 | def menu_fillscreen(self, *_): 459 | if self.master.wm_attributes('-fullscreen'): 460 | self.master.configure(menu=self.menu) 461 | self.master.wm_attributes('-fullscreen', False) 462 | else: 463 | self.master.configure(menu="") 464 | self.master.wm_attributes('-fullscreen', True) 465 | 466 | def load_bank(self, bank=''): 467 | if bank == '': bank = self.fp.bankdir / self.fp.currentbank 468 | self.set_text(str(bank), "", "loading bank") 469 | try: 470 | rawbank = (self.fp.bankdir / bank).read_text() 471 | except Exception as e: 472 | msgbox.showerror("Load Bank", f"Error loading {bank}:\n{str(e)}") 473 | return 474 | try: 475 | self.fp.load_bank(bank) 476 | except Exception as e: 477 | msgbox.showerror("Bank Error", f"Error in bank {bank}:\n{str(e)}") 478 | self.fp.update_config() 479 | self.lastfile = bank 480 | self.bedit.title(self.lastfile) 481 | self.bedit.text.delete('1.0', 'end') 482 | self.bedit.text.insert('1.0', rawbank) 483 | self.select_patch(0) 484 | self.parse_bank() 485 | self.bedit.text.edit_modified(False) 486 | 487 | def next_bank(self, *_): 488 | banks = sorted([b.relative_to(self.fp.bankdir) 489 | for b in self.fp.bankdir.rglob('*.yaml')]) 490 | if self.fp.currentbank in banks: 491 | bno = (banks.index(self.fp.currentbank) + 1) % len(banks) 492 | else: 493 | bno = 0 494 | self.load_bank(banks[bno]) 495 | 496 | def parse_bank(self, *_): 497 | lastpatch = self.fp.patches[self.pno] if self.fp.patches else '' 498 | try: 499 | self.fp.load_bank(raw=self.bedit.text.get('1.0', 'end')) 500 | except Exception as e: 501 | self.bedit.text.configure(foreground='red') 502 | return False 503 | self.bedit.text.configure(foreground='black') 504 | self.patchmenu.delete(0, self.patchmenu.index('end')) 505 | for i, p in enumerate(self.fp.patches): 506 | self.patchmenu.add_command(label=p, command=lambda i=i: self.select_patch(i)) 507 | if lastpatch in self.fp.patches: 508 | self.select_patch(self.fp.patches.index(lastpatch)) 509 | elif self.pno < len(self.fp.patches): 510 | self.select_patch(self.pno) 511 | else: 512 | self.select_patch(0) 513 | return True 514 | 515 | 516 | sys.excepthook = gui_excepthook 517 | if len(sys.argv) > 1: 518 | cfgfile = Path(sys.argv[1]) 519 | else: 520 | cfgfile = Path('config/fluidpatcherconf.yaml') 521 | app = MainWindow() 522 | app.mainloop() 523 | -------------------------------------------------------------------------------- /fluidpatcher/pfluidsynth.py: -------------------------------------------------------------------------------- 1 | """ctypes bindings and interface classes for fluidsynth 2 | """ 3 | from ctypes.util import find_library 4 | from ctypes import * 5 | 6 | FLUID_OK = 0 7 | FLUID_FAILED = -1 8 | FLUID_NUM_TYPE = 0 9 | FLUID_INT_TYPE = 1 10 | FLUID_STR_TYPE = 2 11 | FLUID_SEQ_NOTEON = 1 12 | FLUID_SEQ_TIMER = 17 13 | FLUID_SEQ_UNREGISTERING = 21 14 | FLUID_PLAYER_TEMPO_INTERNAL = 0 15 | FLUID_PLAYER_TEMPO_EXTERNAL_MIDI = 2 16 | FLUID_PLAYER_PLAYING = 1 17 | FLUID_PLAYER_DONE = 3 18 | MIDI_TYPES = {'note': 0x90, 'cc': 0xb0, 'prog': 0xc0, 'pbend': 0xe0, 'cpress': 0xd0, 'kpress': 0xa0, 'noteoff': 0x80, 19 | 'clock': 0xf8, 'start': 0xfa, 'continue': 0xfb, 'stop': 0xfc} 20 | MIDI_VOICE_2PAR = 'note', 'cc', 'kpress', 'noteoff' 21 | MIDI_VOICE_1PAR = 'prog', 'pbend', 'cpress' 22 | MIDI_REALTIME = 'clock', 'start', 'continue', 'stop' 23 | SEEK_DONE = -1 24 | SEEK_WAIT = -2 25 | 26 | fslib = find_library('fluidsynth') or find_library('libfluidsynth-3') 27 | if fslib is None: 28 | raise ImportError("Couldn't find the FluidSynth library.") 29 | FS = CDLL(fslib) 30 | def specfunc(func, restype, *argtypes): 31 | func.restype = restype 32 | func.argtypes = argtypes 33 | return func 34 | 35 | # settings 36 | specfunc(FS.new_fluid_settings, c_void_p) 37 | specfunc(FS.fluid_settings_get_type, c_int, c_void_p, c_char_p) 38 | specfunc(FS.fluid_settings_getint, c_int, c_void_p, c_char_p, POINTER(c_int)) 39 | specfunc(FS.fluid_settings_getnum, c_int, c_void_p, c_char_p, POINTER(c_double)) 40 | specfunc(FS.fluid_settings_copystr, c_int, c_void_p, c_char_p, c_char_p, c_int) 41 | specfunc(FS.fluid_settings_setint, c_int, c_void_p, c_char_p, c_int) 42 | specfunc(FS.fluid_settings_setnum, c_int, c_void_p, c_char_p, c_double) 43 | specfunc(FS.fluid_settings_setstr, c_int, c_void_p, c_char_p, c_char_p) 44 | 45 | # synth 46 | fl_eventcallback = CFUNCTYPE(c_int, c_void_p, c_void_p) 47 | specfunc(FS.new_fluid_synth, c_void_p, c_void_p) 48 | specfunc(FS.new_fluid_audio_driver, c_void_p, c_void_p, c_void_p) 49 | specfunc(FS.new_fluid_midi_router, c_void_p, c_void_p, fl_eventcallback, c_void_p) 50 | specfunc(FS.new_fluid_midi_driver, c_void_p, c_void_p, fl_eventcallback, c_void_p) 51 | specfunc(FS.fluid_synth_handle_midi_event, c_int, c_void_p, c_void_p) 52 | specfunc(FS.fluid_synth_system_reset, c_int, c_void_p) 53 | specfunc(FS.fluid_synth_sfload, c_int, c_void_p, c_char_p, c_int) 54 | specfunc(FS.fluid_synth_sfunload, c_int, c_void_p, c_int, c_int) 55 | specfunc(FS.fluid_synth_get_sfont_by_id, c_void_p, c_void_p, c_int) 56 | specfunc(FS.fluid_synth_program_select, c_int, c_void_p, c_int, c_int, c_int, c_int) 57 | specfunc(FS.fluid_synth_unset_program, c_int, c_void_p, c_int) 58 | specfunc(FS.fluid_synth_get_program, c_int, c_void_p, c_int, POINTER(c_int), POINTER(c_int), POINTER(c_int)) 59 | specfunc(FS.fluid_synth_get_cc, c_int, c_void_p, c_int, c_int, POINTER(c_int)) 60 | def fl_synth_program_select(synth, chan, id, bank, prog): FS.fluid_synth_program_select(synth, chan - 1, id, bank, prog) 61 | def fl_synth_unset_program(synth, chan): FS.fluid_synth_unset_program(synth, chan - 1) 62 | def fl_synth_get_program(synth, chan, id, bank, prog): FS.fluid_synth_get_program(synth, chan - 1, id, bank, prog) 63 | def fl_synth_get_cc(synth, chan, ctrl, val): FS.fluid_synth_get_cc(synth, chan - 1, ctrl, val) 64 | 65 | # soundfonts 66 | specfunc(FS.fluid_sfont_iteration_start, None, c_void_p) 67 | specfunc(FS.fluid_sfont_iteration_next, c_void_p, c_void_p) 68 | specfunc(FS.fluid_preset_get_name, c_char_p, c_void_p) 69 | specfunc(FS.fluid_preset_get_banknum, c_int, c_void_p) 70 | specfunc(FS.fluid_preset_get_num, c_int, c_void_p) 71 | 72 | # midi router 73 | specfunc(FS.new_fluid_midi_router_rule, c_void_p) 74 | specfunc(FS.fluid_midi_router_add_rule, c_int, c_void_p, c_void_p, c_int) 75 | specfunc(FS.fluid_midi_router_clear_rules, c_int, c_void_p) 76 | specfunc(FS.fluid_midi_router_set_default_rules, c_int, c_void_p) 77 | specfunc(FS.fluid_midi_router_rule_set_chan, None, c_void_p, c_int, c_int, c_float, c_int) 78 | specfunc(FS.fluid_midi_router_rule_set_param1, None, c_void_p, c_int, c_int, c_float, c_int) 79 | specfunc(FS.fluid_midi_router_rule_set_param2, None, c_void_p, c_int, c_int, c_float, c_int) 80 | specfunc(FS.fluid_midi_router_handle_midi_event, c_int, c_void_p, c_void_p) 81 | def fl_midi_router_rule_set_chan(rule, min, max, mul, add): 82 | FS.fluid_midi_router_rule_set_chan(rule, int(min - 1), int(max - 1), mul, int(mul + add - 1)) 83 | def fl_midi_router_rule_set_param1(rule, min, max, mul, add): 84 | FS.fluid_midi_router_rule_set_param1(rule, int(min), int(max), mul, int(add)) 85 | def fl_midi_router_rule_set_param2(rule, min, max, mul, add): 86 | FS.fluid_midi_router_rule_set_param2(rule, int(min), int(max), mul, int(add)) 87 | 88 | # midi events 89 | specfunc(FS.new_fluid_midi_event, c_void_p) 90 | specfunc(FS.delete_fluid_event, None, c_void_p) 91 | specfunc(FS.fluid_midi_event_get_type, c_int, c_void_p) 92 | specfunc(FS.fluid_midi_event_get_channel, c_int, c_void_p) 93 | fl_midi_event_get_par1 = specfunc(FS.fluid_midi_event_get_key, c_int, c_void_p) 94 | fl_midi_event_get_par2 = specfunc(FS.fluid_midi_event_get_velocity, c_int, c_void_p) 95 | specfunc(FS.fluid_midi_event_set_type, c_int, c_void_p, c_int) 96 | specfunc(FS.fluid_midi_event_set_channel, c_int, c_void_p, c_int) 97 | fl_midi_event_set_par1 = specfunc(FS.fluid_midi_event_set_key, c_int, c_void_p, c_int) 98 | fl_midi_event_set_par2 = specfunc(FS.fluid_midi_event_set_velocity, c_int, c_void_p, c_int) 99 | specfunc(FS.fluid_midi_event_set_sysex, c_int, c_void_p, c_void_p, c_int, c_int) 100 | def fl_midi_event_get_channel(event): return FS.fluid_midi_event_get_channel(event) + 1 101 | def fl_midi_event_set_channel(event, chan): FS.fluid_midi_event_set_channel(event, chan - 1) 102 | 103 | # sequencer events 104 | specfunc(FS.new_fluid_event, c_void_p) 105 | specfunc(FS.delete_fluid_event, None, c_void_p) 106 | specfunc(FS.fluid_event_noteon, None, c_void_p, c_int, c_int, c_int) 107 | specfunc(FS.fluid_event_noteoff, None, c_void_p, c_int, c_int) 108 | specfunc(FS.fluid_event_set_source, None, c_void_p, c_void_p) 109 | specfunc(FS.fluid_event_set_dest, None, c_void_p, c_void_p) 110 | specfunc(FS.fluid_event_timer, None, c_void_p, c_void_p) 111 | specfunc(FS.fluid_event_get_type, c_int, c_void_p) 112 | def fl_event_noteon(event, chan, key, vel): FS.fluid_event_noteon(event, chan - 1, key, vel) 113 | def fl_event_noteoff(event, chan, key): FS.fluid_event_noteoff(event, chan - 1, key) 114 | 115 | # sequencer 116 | fl_seqcallback = CFUNCTYPE(None, c_uint, c_void_p, c_void_p, c_void_p) 117 | specfunc(FS.new_fluid_sequencer2, c_void_p, c_int) 118 | specfunc(FS.delete_fluid_sequencer, None, c_void_p) 119 | specfunc(FS.fluid_sequencer_register_fluidsynth, c_short, c_void_p, c_void_p) 120 | specfunc(FS.fluid_sequencer_register_client, c_short, c_void_p, c_char_p, fl_seqcallback, c_void_p) 121 | specfunc(FS.fluid_sequencer_unregister_client, None, c_void_p, c_short) 122 | specfunc(FS.fluid_sequencer_set_time_scale, None, c_void_p, c_double) 123 | specfunc(FS.fluid_sequencer_send_at, c_int, c_void_p, c_void_p, c_uint, c_int) 124 | specfunc(FS.fluid_sequencer_remove_events, None, c_void_p, c_short, c_short, c_int) 125 | specfunc(FS.fluid_sequencer_get_tick, c_uint, c_void_p) 126 | 127 | # player 128 | fl_tickcallback = CFUNCTYPE(None, c_void_p, c_uint) 129 | specfunc(FS.new_fluid_player, c_void_p, c_void_p) 130 | specfunc(FS.delete_fluid_player, None, c_void_p) 131 | specfunc(FS.fluid_player_add, c_int, c_void_p, c_char_p) 132 | specfunc(FS.fluid_player_set_playback_callback, c_int, c_void_p, fl_eventcallback, c_void_p) 133 | specfunc(FS.fluid_player_set_tick_callback, c_int, c_void_p, fl_tickcallback, c_void_p) 134 | specfunc(FS.fluid_player_set_tempo, c_int, c_void_p, c_int, c_double) 135 | specfunc(FS.fluid_player_play, c_int, c_void_p) 136 | specfunc(FS.fluid_player_stop, c_int, c_void_p) 137 | specfunc(FS.fluid_player_seek, c_int, c_void_p, c_int) 138 | specfunc(FS.fluid_player_get_status, c_int, c_void_p) 139 | specfunc(FS.fluid_player_get_current_tick, c_int, c_void_p) 140 | 141 | # ladspa effects 142 | try: 143 | specfunc(FS.fluid_ladspa_activate, c_void_p, c_void_p) 144 | specfunc(FS.fluid_ladspa_is_active, c_int, c_void_p) 145 | specfunc(FS.fluid_ladspa_reset, c_int, c_void_p) 146 | specfunc(FS.fluid_ladspa_add_effect, c_int, c_void_p, c_char_p, c_char_p, c_char_p) 147 | specfunc(FS.fluid_ladspa_add_buffer, c_int, c_void_p, c_char_p) 148 | specfunc(FS.fluid_ladspa_effect_can_mix, c_int, c_void_p, c_char_p) 149 | specfunc(FS.fluid_ladspa_effect_set_mix, c_int, c_void_p, c_char_p, c_int, c_float) 150 | specfunc(FS.fluid_ladspa_effect_set_control, c_int, c_void_p, c_char_p, c_char_p, c_float) 151 | specfunc(FS.fluid_ladspa_effect_link, c_int, c_void_p, c_char_p, c_char_p, c_char_p) 152 | specfunc(FS.fluid_synth_get_ladspa_fx, c_void_p, c_void_p) 153 | LADSPA_SUPPORT = True 154 | except AttributeError: 155 | LADSPA_SUPPORT = False 156 | 157 | 158 | class MidiEvent: 159 | 160 | def __init__(self, event): 161 | self.event = event 162 | 163 | @property 164 | def type(self): 165 | b = FS.fluid_midi_event_get_type(self.event) 166 | if b == 0x90 and self.par2 == 0: b = 0x80 167 | return {v: k for k, v in MIDI_TYPES.items()}.get(b, None) 168 | @type.setter 169 | def type(self, n): 170 | n = MIDI_TYPES.get(n, None) 171 | FS.fluid_midi_event_set_type(self.event, n) 172 | 173 | @property 174 | def chan(self): return fl_midi_event_get_channel(self.event) 175 | @chan.setter 176 | def chan(self, v): fl_midi_event_set_channel(self.event, v) 177 | 178 | @property 179 | def par1(self): return fl_midi_event_get_par1(self.event) 180 | @par1.setter 181 | def par1(self, v): fl_midi_event_set_par1(self.event, v) 182 | 183 | @property 184 | def par2(self): return fl_midi_event_get_par2(self.event) 185 | @par2.setter 186 | def par2(self, v): fl_midi_event_set_par2(self.event, v) 187 | 188 | def __repr__(self): 189 | return "type: %s, chan: %d, par1: %d, par2: %d" % (self.type, self.chan, self.par1, self.par2) 190 | 191 | 192 | class Route: 193 | 194 | def __init__(self, min, max, mul, add): 195 | self.min = min 196 | self.max = max 197 | self.mul = mul 198 | self.add = add 199 | 200 | 201 | class CustomRule: 202 | 203 | def __init__(self, type, chan, par1, par2, **apars): 204 | if isinstance(type, str): type = [type] 205 | self.hastype = type[0] 206 | self.newtype = type[-1] 207 | self.chan = Route(*chan) if chan else None 208 | self.par1 = Route(*par1) if par1 else None 209 | self.par2 = Route(*par2) if par2 else None 210 | for attr, val in apars.items(): 211 | setattr(self, attr, val) 212 | 213 | def __repr__(self): 214 | return str(self.__dict__) 215 | 216 | def __iter__(self): 217 | return iter(self.__dict__) 218 | 219 | def applies(self, mevent): 220 | if self.hastype != mevent.type: 221 | return False 222 | if self.hastype in MIDI_REALTIME: 223 | return True 224 | if self.chan != None: 225 | if self.chan.min > self.chan.max: 226 | if self.chan.min < mevent.chan < self.chan.max: 227 | return False 228 | else: 229 | if not (self.chan.min <= mevent.chan <= self.chan.max): 230 | return False 231 | if self.par1 != None: 232 | if self.par1.min > self.par1.max: 233 | if self.par1.min < mevent.par1 < self.par1.max: 234 | return False 235 | else: 236 | if not (self.par1.min <= mevent.par1 <= self.par1.max): 237 | return False 238 | if self.hastype in MIDI_VOICE_2PAR and self.par2 != None: 239 | if self.par2.min > self.par2.max: 240 | if self.par2.min < mevent.par2 < self.par2.max: 241 | return False 242 | else: 243 | if not (self.par2.min <= mevent.par2 <= self.par2.max): 244 | return False 245 | return True 246 | 247 | def apply(self, mevent): 248 | msig = MidiSignal(mevent, rule=self) 249 | if self.chan != None: msig.chan = int(mevent.chan * self.chan.mul + self.chan.add + 0.5) 250 | if self.par1 != None: msig.par1 = int(mevent.par1 * self.par1.mul + self.par1.add + 0.5) 251 | if self.par2 != None: msig.par2 = int(mevent.par2 * self.par2.mul + self.par2.add + 0.5) 252 | if self.hastype in MIDI_VOICE_2PAR: 253 | msig.val = mevent.par2 254 | if self.par2: msig.val = msig.val * self.par2.mul + self.par2.add 255 | elif self.hastype in MIDI_VOICE_1PAR: 256 | msig.val = mevent.par1 257 | if self.par1: msig.val = msig.val * self.par1.mul + self.par1.add 258 | elif self.hastype == 'clock': 259 | msig.val = 0.041666664 260 | elif self.hastype in ('start', 'continue'): 261 | msig.val = self.par1.min if self.par1 else -1 262 | elif self.hastype == 'stop': 263 | msig.val = self.par1.min if self.par1 else 0 264 | return msig 265 | 266 | 267 | class TransRule(CustomRule): 268 | 269 | def __init__(self, type, chan, par1, par2): 270 | super().__init__(type, chan, par1, par2) 271 | 272 | def apply(self, mevent): 273 | newevent = MidiEvent(FS.new_fluid_midi_event()) 274 | newevent.type = self.newtype 275 | newevent.chan = mevent.chan 276 | newevent.par1 = mevent.par1 277 | newevent.par2 = mevent.par2 278 | if self.hastype in MIDI_REALTIME: 279 | if self.chan != None: newevent.chan = self.chan.min 280 | if self.par1 != None: newevent.par1 = self.par1.min 281 | if self.par2 != None: newevent.par2 = self.par2.min 282 | else: 283 | if self.chan != None: newevent.chan = int(mevent.chan * self.chan.mul + self.chan.add + 0.5) 284 | if self.hastype in MIDI_VOICE_2PAR: 285 | if self.newtype in MIDI_VOICE_2PAR: 286 | if self.par1 != None: newevent.par1 = int(mevent.par1 * self.par1.mul + self.par1.add + 0.5) 287 | if self.par2 != None: newevent.par2 = int(mevent.par2 * self.par2.mul + self.par2.add + 0.5) 288 | elif self.newtype in MIDI_VOICE_1PAR: 289 | if self.par2 == None: newevent.par1 = mevent.par2 290 | else: newevent.par1 = int(mevent.par2 * self.par2.mul + self.par2.add + 0.5) 291 | elif self.hastype in MIDI_VOICE_1PAR: 292 | if self.newtype in MIDI_VOICE_1PAR: 293 | if self.par1 != None: newevent.par1 = int(mevent.par1 * self.par1.mul + self.par1.add + 0.5) 294 | elif self.newtype in MIDI_VOICE_2PAR: 295 | if self.par2 != None: newevent.par1 = self.par2.min 296 | if self.par1 == None: newevent.par2 = mevent.par1 297 | else: newevent.par2 = int(mevent.par1 * self.par1.mul + self.par1.add + 0.5) 298 | return newevent 299 | 300 | 301 | class MidiSignal: 302 | 303 | def __init__(self, mevent, rule=None): 304 | if rule: self.__dict__.update(rule.__dict__) 305 | self.type = mevent.type 306 | self.chan = mevent.chan 307 | self.par1 = mevent.par1 308 | self.par2 = mevent.par2 309 | 310 | def __repr__(self): 311 | return str(self.__dict__) 312 | 313 | def __iter__(self): 314 | return iter(self.__dict__) 315 | 316 | 317 | class SequencerNote: 318 | 319 | def __init__(self, chan, key, vel): 320 | self.chan = chan 321 | self.key = key 322 | self.vel = vel 323 | 324 | def __iter__(self): 325 | return iter([self]) 326 | 327 | def schedule(self, seq, timeon, timeoff, accent=1): 328 | evt = FS.new_fluid_event() 329 | FS.fluid_event_set_source(evt, -1) 330 | FS.fluid_event_set_dest(evt, seq.fsynth_id) 331 | fl_event_noteon(evt, self.chan, self.key, int(min(self.vel * accent, 127))) 332 | FS.fluid_sequencer_send_at(seq.fseq, evt, int(timeon), 1) 333 | FS.delete_fluid_event(evt) 334 | evt = FS.new_fluid_event() 335 | FS.fluid_event_set_source(evt, -1) 336 | FS.fluid_event_set_dest(evt, seq.fsynth_id) 337 | fl_event_noteoff(evt, int(self.chan), int(self.key)) 338 | FS.fluid_sequencer_send_at(seq.fseq, evt, int(timeoff), 1) 339 | FS.delete_fluid_event(evt) 340 | 341 | 342 | class Sequencer: 343 | 344 | def __init__(self, synth, notes, tdiv, swing, groove): 345 | self.fseq = synth.fseq 346 | self.fsynth_id = synth.fsynth_id 347 | self.callback = fl_seqcallback(self.scheduler) 348 | self.seq_id = FS.fluid_sequencer_register_client(self.fseq, b'seq', self.callback, None) 349 | self.notes = [SequencerNote(chan, key, vel) for _, chan, key, vel in notes] 350 | self.tdiv = tdiv 351 | self.swing = swing 352 | self.groove = groove 353 | self.ticksperbeat = 500 # default 120bpm at 1000 ticks/sec 354 | self.beat = 0 355 | 356 | def step(self, event, n): 357 | if n < 1: 358 | n = self.beat % len(self.notes) 359 | self.notes[n - 1] = SequencerNote(chan, key, vel) 360 | 361 | def scheduler(self, time=None, event=None, fseq=None, data=None): 362 | if event and FS.fluid_event_get_type(event) == FLUID_SEQ_UNREGISTERING: 363 | return 364 | if not self.notes: return 365 | dur = self.ticksperbeat * 4 / self.tdiv 366 | if self.tdiv >= 8 and self.tdiv % 3: 367 | if self.beat % 2: dur *= 2 * (1 - self.swing) 368 | else: dur *= 2 * self.swing 369 | pos = self.beat % len(self.notes) 370 | accent = self.groove[self.beat % len(self.groove)] 371 | for note in self.notes[pos]: 372 | note.schedule(self, self.nextnote, self.nextnote + dur, accent) 373 | if pos == len(self.notes) - 1: 374 | self.loop -= 1 375 | if self.loop != 0: 376 | self.timer(self.nextnote + 0.99 * dur) 377 | self.nextnote += dur 378 | self.beat += 1 379 | 380 | def play(self, loops=1): 381 | FS.fluid_sequencer_remove_events(self.fseq, -1, self.seq_id, FLUID_SEQ_TIMER) 382 | if loops != 0: 383 | self.loop = loops 384 | self.beat = 0 385 | self.nextnote = FS.fluid_sequencer_get_tick(self.fseq) 386 | self.scheduler() 387 | 388 | def timer(self, time): 389 | evt = FS.new_fluid_event() 390 | FS.fluid_event_set_source(evt, -1) 391 | FS.fluid_event_set_dest(evt, self.seq_id) 392 | FS.fluid_event_timer(evt, None) 393 | FS.fluid_sequencer_send_at(self.fseq, evt, int(time), 1) 394 | FS.delete_fluid_event(evt) 395 | 396 | def set_tempo(self, bpm): 397 | # default fluid_sequencer time scale is 1000 ticks per second 398 | self.ticksperbeat = 1000 * 60 / bpm 399 | 400 | def dismiss(self): 401 | self.notes = [] 402 | self.play(0) 403 | FS.fluid_sequencer_unregister_client(self.fseq, self.seq_id) 404 | 405 | 406 | class Arpeggiator(Sequencer): 407 | 408 | def __init__(self, synth, tdiv, swing, groove, style, octaves): 409 | super().__init__(synth, [], tdiv, swing, groove) 410 | self.style = style 411 | self.octaves = octaves 412 | self.keysdown = [] 413 | 414 | def note(self, chan, key, vel): 415 | if vel > 0: 416 | self.keysdown.append(SequencerNote(chan, key, vel)) 417 | nd = len(self.keysdown) 418 | else: 419 | for k in self.keysdown: 420 | if k.key == key: 421 | self.keysdown.remove(k) 422 | break 423 | nd = -len(self.keysdown) 424 | if self.style in ('up', 'down', 'both'): 425 | self.keysdown.sort(key=lambda n: n.key) 426 | self.notes = [] 427 | for i in range(self.octaves): 428 | for n in self.keysdown: 429 | self.notes.append(SequencerNote(n.chan, n.key + i * 12, n.vel)) 430 | if self.style == 'down': 431 | self.notes.reverse() 432 | elif self.style == 'both': 433 | self.notes += self.notes[-2:0:-1] 434 | elif self.style == 'chord': 435 | self.notes = [self.notes] 436 | if self.beat < 2: 437 | self.play(loops=-1) 438 | if nd == 1: 439 | self.play(loops=-1) 440 | elif nd == 0: 441 | self.play(loops=0) 442 | 443 | 444 | class MidiPlayer: 445 | 446 | def __init__(self, synth, file, loops, barlength, chan, mask): 447 | self.fplayer = FS.new_fluid_player(synth.fsynth) 448 | FS.fluid_player_add(self.fplayer, str(file).encode()) 449 | self.loops = list(zip(loops[::2], loops[1::2])) 450 | self.barlength = barlength 451 | self.seek = None 452 | self.seek_now = False 453 | self.lasttick = 0 454 | self.frouter_callback = fl_eventcallback(FS.fluid_midi_router_handle_midi_event) 455 | #self.frouter = FS.new_fluid_midi_router(synth.st, synth.custom_router_callback, synth.frouter) 456 | self.frouter = FS.new_fluid_midi_router(synth.st, self.frouter_callback, synth.frouter) 457 | FS.fluid_midi_router_clear_rules(self.frouter) 458 | for rtype in set(list(MIDI_TYPES)[:6]) - set(mask): 459 | rule = FS.new_fluid_midi_router_rule() 460 | if chan: fl_midi_router_rule_set_chan(rule, *chan) 461 | FS.fluid_midi_router_add_rule(self.frouter, rule, list(MIDI_TYPES).index(rtype)) 462 | self.playback_callback = fl_eventcallback(FS.fluid_midi_router_handle_midi_event) 463 | FS.fluid_player_set_playback_callback(self.fplayer, self.playback_callback, self.frouter) 464 | self.tickcallback = fl_tickcallback(self.looper) 465 | FS.fluid_player_set_tick_callback(self.fplayer, self.tickcallback, None) 466 | 467 | def transport(self, play, seek=None): 468 | if play == 0: 469 | FS.fluid_player_stop(self.fplayer) 470 | elif FS.fluid_player_get_status(self.fplayer) == FLUID_PLAYER_PLAYING: 471 | if seek != None: 472 | self.seek = seek 473 | self.seek_now = False if play < 0 else True 474 | else: 475 | if seek != None: 476 | self.seek = seek 477 | self.seek_now = True 478 | if play > 0: FS.fluid_player_play(self.fplayer) 479 | 480 | def looper(self, data, tick): 481 | if self.seek != None: 482 | if self.seek_now or tick % self.barlength < (tick - self.lasttick): 483 | if str(self.seek)[-1] in '+-': 484 | inc = int(self.seek[-1] + self.seek[:-1]) 485 | self.seek = FS.fluid_player_get_current_tick(self.fplayer) + inc 486 | if FS.fluid_player_seek(self.fplayer, self.seek) == FLUID_OK: 487 | self.lasttick = self.seek 488 | self.seek = None 489 | elif self.lasttick < tick: 490 | for start, end in self.loops: 491 | if self.lasttick < end <= tick: 492 | if start < 0: 493 | self.transport(0) 494 | start = 0 495 | if FS.fluid_player_seek(self.fplayer, start) == FLUID_OK: 496 | self.lasttick = start 497 | break 498 | else: 499 | self.lasttick = tick 500 | 501 | def set_tempo(self, bpm=None): 502 | if bpm: 503 | usec = int(60000000.0 / bpm) # usec per quarter note (MIDI standard) 504 | FS.fluid_player_set_tempo(self.fplayer, FLUID_PLAYER_TEMPO_EXTERNAL_MIDI, usec) 505 | else: 506 | FS.fluid_player_set_tempo(self.fplayer, FLUID_PLAYER_TEMPO_INTERNAL, 1.0) 507 | 508 | def dismiss(self): 509 | FS.fluid_player_stop(self.fplayer) 510 | FS.delete_fluid_player(self.fplayer) 511 | 512 | 513 | class LadspaEffect: 514 | 515 | def __init__(self, synth, name, lib, plugin, group, audio): 516 | self.synth = synth 517 | self.name = name 518 | self.lib = str(lib).encode() 519 | self.plugin = plugin.encode() if plugin else None 520 | self.groups = group 521 | if audio == 'stereo': 522 | audio = 'Input L', 'Input R', 'Output L', 'Output R' 523 | elif audio == 'mono': 524 | audio = 'Input', 'Output' 525 | self.aports = [port.encode() for port in audio] 526 | self.fxunits = [] 527 | self.portvals = {} 528 | 529 | def addfxunits(self): 530 | self.links = {} 531 | def addfxunit(): 532 | fxname = f"{self.name}{len(self.fxunits)}".encode() 533 | if FS.fluid_ladspa_add_effect(self.synth.ladspa, fxname, self.lib, self.plugin) != FLUID_OK: return False 534 | if FS.fluid_ladspa_effect_can_mix(self.synth.ladspa, fxname): 535 | FS.fluid_ladspa_effect_set_mix(self.synth.ladspa, fxname, 1, 1.0) 536 | self.fxunits.append(fxname) 537 | return True 538 | group = 0 539 | for hostports, outports in self.synth.port_mapping: 540 | group += 1 541 | if self.groups and group not in self.groups: continue 542 | if len(self.aports) == 4: # stereo effect 543 | if addfxunit(): 544 | self.links[hostports] = self.fxunits[-1:] * 2, self.aports[0:2], self.aports[2:4] 545 | if len(self.aports) == 2: # mono effect 546 | if addfxunit() and addfxunit(): 547 | self.links[hostports] = self.fxunits[-2:], self.aports[0:1] * 2, self.aports[1:2] * 2 548 | 549 | def link(self, hostports, inputs, outputs): 550 | for fxunit, fxin, fxout, inp, outp in zip(*self.links[hostports], inputs, outputs): 551 | FS.fluid_ladspa_effect_link(self.synth.ladspa, fxunit, fxin, inp.encode()) 552 | FS.fluid_ladspa_effect_link(self.synth.ladspa, fxunit, fxout, outp.encode()) 553 | 554 | def setcontrol(self, port, val): 555 | self.portvals[port] = val 556 | for fxunit in self.fxunits: 557 | FS.fluid_ladspa_effect_set_control(self.synth.ladspa, fxunit, port.encode(), c_float(val)) 558 | 559 | 560 | class Synth: 561 | 562 | def __init__(self, **settings): 563 | self.st = FS.new_fluid_settings() 564 | for opt, val in settings.items(): 565 | self.setting(opt, val) 566 | # create the synth and audio driver 567 | self.fsynth = FS.new_fluid_synth(self.st) 568 | FS.new_fluid_audio_driver(self.st, self.fsynth) 569 | # create a fluid router and point it at the synth 570 | self.frouter_callback = fl_eventcallback(FS.fluid_synth_handle_midi_event) 571 | self.frouter = FS.new_fluid_midi_router(self.st, self.frouter_callback, self.fsynth) 572 | # create the midi driver and point it at the custom router 573 | self.custom_router_callback = fl_eventcallback(lambda _, e: self.custom_midi_router(e)) 574 | FS.new_fluid_midi_driver(self.st, self.custom_router_callback, None) 575 | # create a sequencer and register it to the synth 576 | self.fseq = FS.new_fluid_sequencer2(0) 577 | self.fsynth_id = FS.fluid_sequencer_register_fluidsynth(self.fseq, self.fsynth) 578 | self.clocks = [0, 0] 579 | self.xrules = [] 580 | self.sfid = {} 581 | self.players = {} 582 | self.midi_callback = None 583 | if LADSPA_SUPPORT: 584 | nports = self.get_setting('synth.audio-groups') 585 | nchan = self.get_setting('synth.audio-channels') 586 | if nports == 1: 587 | hostports = outports = [('Main:L', 'Main:R')] 588 | else: 589 | hostports = [(f'Main:L{i}', f'Main:R{i}') for i in range(1, nports + 1)] 590 | outports = hostports[0:nchan] * nports 591 | self.port_mapping = list(zip(hostports, outports)) 592 | self.ladspa = FS.fluid_synth_get_ladspa_fx(self.fsynth) 593 | self.ladspafx = {} 594 | 595 | def reset(self): 596 | FS.fluid_synth_system_reset(self.fsynth) 597 | 598 | def custom_midi_router(self, event): 599 | mevent = MidiEvent(event) 600 | t = FS.fluid_sequencer_get_tick(self.fseq) 601 | dt = 0 602 | for rule in self.xrules: 603 | if not rule.applies(mevent): 604 | continue 605 | res = rule.apply(mevent) 606 | if isinstance(rule, TransRule): 607 | FS.fluid_synth_handle_midi_event(self.fsynth, res.event) 608 | continue 609 | if 'fluidsetting' in rule: 610 | self.setting(rule.fluidsetting, res.val) 611 | elif 'sequencer' in rule: 612 | if rule.sequencer in self.players: 613 | if 'step' in rule: 614 | self.players[rule.sequencer].step(res.event, res.step) # should be rule.event? new event? 615 | self.players[rule.sequencer].play(res.val) 616 | elif 'arpeggiator' in rule: 617 | if rule.arpeggiator in self.players: 618 | self.players[rule.arpeggiator].note(res.chan, res.par1, res.val) 619 | elif 'midiplayer' in rule: 620 | if rule.midiplayer in self.players: 621 | if 'tick' in rule: 622 | self.players[rule.midiplayer].transport(res.val, rule.tick) 623 | else: 624 | self.players[rule.midiplayer].transport(res.val) 625 | elif 'tempo' in rule: 626 | if rule.tempo in self.players: 627 | self.players[rule.tempo].set_tempo(res.val) 628 | elif 'swing' in rule: 629 | if rule.swing in self.players: 630 | self.players[rule.swing].swing = res.val if 0.5 <= res.val < 1 else 0.5 631 | elif 'groove' in rule: 632 | if rule.groove in self.players: 633 | self.players[rule.groove].groove = [res.val] 634 | elif 'sync' in rule: 635 | if rule.sync in self.players: 636 | dt, dt2 = t - self.clocks[0], self.clocks[0] - self.clocks[1] 637 | bpm = 1000 * 60 * res.val / dt 638 | if dt2/dt > 0.5: self.players[rule.sync].set_tempo(bpm) 639 | elif LADSPA_SUPPORT and 'ladspafx' in rule: 640 | if rule.ladspafx in self.ladspafx: 641 | self.ladspafx[rule.ladspafx].setcontrol(rule.port, res.val) 642 | else: 643 | # not handled here, pass it to the callback 644 | if self.midi_callback: self.midi_callback(res) 645 | if dt > 0: self.clocks = t, self.clocks[0] 646 | if self.midi_callback: 647 | # send the original event to the callback 648 | self.midi_callback(MidiSignal(mevent)) 649 | # pass the original event along to the fluid router 650 | return FS.fluid_midi_router_handle_midi_event(self.frouter, event) 651 | 652 | def setting(self, opt, val): 653 | stype = FS.fluid_settings_get_type(self.st, opt.encode()) 654 | if stype == FLUID_STR_TYPE: 655 | FS.fluid_settings_setstr(self.st, opt.encode(), str(val).encode()) 656 | elif stype == FLUID_INT_TYPE: 657 | FS.fluid_settings_setint(self.st, opt.encode(), int(val)) 658 | elif stype == FLUID_NUM_TYPE: 659 | FS.fluid_settings_setnum(self.st, opt.encode(), c_double(val)) 660 | 661 | def get_setting(self, opt): 662 | stype = FS.fluid_settings_get_type(self.st, opt.encode()) 663 | if stype == FLUID_STR_TYPE: 664 | strval = create_string_buffer(32) 665 | if FS.fluid_settings_copystr(self.st, opt.encode(), strval, 32) == FLUID_OK: 666 | return strval.value.decode() 667 | elif stype == FLUID_INT_TYPE: 668 | val = c_int() 669 | if FS.fluid_settings_getint(self.st, opt.encode(), byref(val)) == FLUID_OK: 670 | return val.value 671 | elif stype == FLUID_NUM_TYPE: 672 | num = c_double() 673 | if FS.fluid_settings_getnum(self.st, opt.encode(), byref(num)) == FLUID_OK: 674 | return round(num.value, 6) 675 | return None 676 | 677 | def load_soundfont(self, sfont): 678 | i = FS.fluid_synth_sfload(self.fsynth, str(sfont).encode(), False) 679 | if i == FLUID_FAILED: 680 | return False 681 | self.sfid[sfont] = i 682 | return True 683 | 684 | def unload_soundfont(self, sfont): 685 | if FS.fluid_synth_sfunload(self.fsynth, self.sfid[sfont], False) == FLUID_FAILED: 686 | return False 687 | del self.sfid[sfont] 688 | return True 689 | 690 | def program_select(self, chan, sfont, bank, prog): 691 | if sfont not in self.sfid: 692 | return False 693 | x = fl_synth_program_select(self.fsynth, chan, self.sfid[sfont], bank, prog) 694 | if x == FLUID_FAILED: 695 | return False 696 | return True 697 | 698 | def program_unset(self, chan): 699 | fl_synth_unset_program(self.fsynth, chan) 700 | 701 | def program_info(self, chan): 702 | i = c_int() 703 | bank = c_int() 704 | prog = c_int() 705 | fl_synth_get_program(self.fsynth, chan, byref(i), byref(bank), byref(prog)) 706 | if i.value not in self.sfid.values(): 707 | return None 708 | sfont = {v: k for k, v in self.sfid.items()}[i.value] 709 | return sfont, bank.value, prog.value 710 | 711 | def get_sfpresets(self, sfont): 712 | presets = [] 713 | sfont_obj = FS.fluid_synth_get_sfont_by_id(self.fsynth, self.sfid[sfont]) 714 | FS.fluid_sfont_iteration_start(sfont_obj) 715 | while True: 716 | p = FS.fluid_sfont_iteration_next(sfont_obj) 717 | if p == None: break 718 | bank = FS.fluid_preset_get_banknum(p) 719 | prog = FS.fluid_preset_get_num(p) 720 | name = FS.fluid_preset_get_name(p).decode() 721 | presets.append((bank, prog, name)) 722 | return presets 723 | 724 | def send_event(self, type, chan, par1, par2): 725 | newevent = MidiEvent(FS.new_fluid_midi_event()) 726 | newevent.type = type 727 | if chan: newevent.chan = chan 728 | if par1: newevent.par1 = par1 729 | if par2: newevent.par2 = par2 730 | self.custom_midi_router(newevent.event) 731 | 732 | def send_sysex(self, data): 733 | newevent = MidiEvent(FS.new_fluid_midi_event()) 734 | syxdata = (c_int * len(data))(*data) 735 | FS.fluid_midi_event_set_sysex(newevent.event, syxdata, sizeof(syxdata), True) 736 | FS.fluid_midi_router_handle_midi_event(self.frouter, newevent.event) 737 | 738 | def get_cc(self, chan, ctrl): 739 | val = c_int() 740 | fl_synth_get_cc(self.fsynth, chan, ctrl, byref(val)) 741 | return val.value 742 | 743 | def router_clear(self): 744 | FS.fluid_midi_router_clear_rules(self.frouter) 745 | self.xrules = [] 746 | 747 | def router_default(self): 748 | FS.fluid_midi_router_set_default_rules(self.frouter) 749 | self.xrules = [] 750 | 751 | def router_addrule(self, type, chan, par1, par2, **apars): 752 | if type[0] != type[-1]: 753 | self.xrules.insert(0, TransRule(type, chan, par1, par2)) 754 | elif apars: 755 | self.xrules.insert(0, CustomRule(type, chan, par1, par2, **apars)) 756 | if 'arpeggiator' in apars: 757 | self.xrules.insert(0, CustomRule('noteoff', chan, par1, (0, 127, 0, 0), **apars)) 758 | elif type[0] in list(MIDI_TYPES)[:6]: 759 | rule = FS.new_fluid_midi_router_rule() 760 | if chan: fl_midi_router_rule_set_chan(rule, *chan) 761 | if par1: fl_midi_router_rule_set_param1(rule, *par1) 762 | if par2: fl_midi_router_rule_set_param2(rule, *par2) 763 | FS.fluid_midi_router_add_rule(self.frouter, rule, list(MIDI_TYPES).index(type[0])) 764 | 765 | def players_clear(self, save=[]): 766 | for name in set(self.players) - set(save): 767 | self.players[name].dismiss() 768 | del self.players[name] 769 | 770 | def sequencer_add(self, name, notes, tdiv=8, swing=0.5, groove=[1], tempo=120, **_): 771 | if name not in self.players: 772 | self.players[name] = Sequencer(self, notes, tdiv, swing, groove) 773 | self.players[name].set_tempo(tempo) 774 | 775 | def arpeggiator_add(self, name, tdiv=8, swing=0.5, groove=[1], style='', octaves=1, tempo=120, **_): 776 | if name not in self.players: 777 | self.players[name] = Arpeggiator(self, tdiv, swing, groove, style, octaves) 778 | self.players[name].set_tempo(tempo) 779 | 780 | def midiplayer_add(self, name, file, loops=[], barlength=1, chan=None, mask=[], tempo=0, **_): 781 | if name not in self.players: 782 | self.players[name] = MidiPlayer(self, file, loops, barlength, chan, mask) 783 | if tempo > 0: 784 | self.players[name].set_tempo(tempo) 785 | 786 | def fxchain_clear(self, save=[]): 787 | clear = set(self.ladspafx) - set(save) 788 | if clear: 789 | FS.fluid_ladspa_reset(self.ladspa) 790 | for name in clear: 791 | del self.ladspafx[name] 792 | for ladpsafx in self.ladspafx.values(): 793 | ladpsafx.fxunits = [] 794 | 795 | def fxchain_add(self, name, lib, plugin=None, group=[], audio='mono', vals={}, **_): 796 | if name not in self.ladspafx: 797 | if FS.fluid_ladspa_is_active(self.ladspa): 798 | FS.fluid_ladspa_reset(self.ladspa) 799 | self.ladspafx[name] = LadspaEffect(self, name, lib, plugin, group, audio) 800 | self.ladspafx[name].portvals.update(vals) 801 | 802 | def fxchain_connect(self): 803 | if self.ladspafx == {} or FS.fluid_ladspa_is_active(self.ladspa): return 804 | for effect in self.ladspafx.values(): 805 | effect.addfxunits() 806 | for ctrl, val in effect.portvals.items(): 807 | effect.setcontrol(ctrl, val) 808 | b = -1 809 | for hostports, outports in self.port_mapping: 810 | effects = [e for e in self.ladspafx.values() if hostports in e.links] 811 | if not effects: continue 812 | lastports = hostports 813 | for effect in effects[0:-1]: 814 | b += 2 815 | buffers = (f"buffer{b}", f"buffer{b + 1}") 816 | FS.fluid_ladspa_add_buffer(self.ladspa, buffers[0].encode()) 817 | FS.fluid_ladspa_add_buffer(self.ladspa, buffers[1].encode()) 818 | effect.link(hostports, lastports, buffers) 819 | lastports = buffers 820 | effects[-1].link(hostports, lastports, outports) 821 | FS.fluid_ladspa_activate(self.ladspa) 822 | 823 | if not LADSPA_SUPPORT: 824 | def fxchain_clear(self): pass 825 | def fxchain_add(self, **_): pass 826 | def fxchain_connect(self): pass 827 | --------------------------------------------------------------------------------