# pylint: disable=too-many-instance-attributes,invalid-name,missing-class-docstring,fixme
# Number and names of fields are determined by Zyn xml format
"""
Code for calling the `ZynAddSubFX synth <https://github.com/zynaddsubfx/zynaddsubfx>`_.
Zyn does not have a Python API but it can:
#. Save its whole state to a master xml file
#. Load instruments from xml files
This module implements reading and writing Zyn xml files. It allows jird
to automatically retune and configure Zyn by loading Zyn instruments
and building a Zyn master xml.
It turns out that while the master xml file does contain blocks
corresponding to the xml in the loaded instrument xml, Zyn does quite a
bit of processing of the instrument xml before it is added to the master.
Most of this is implemented here (some of the version-specific adjustments
are not yet implemented).
"""
import gzip
import logging
import math
import struct
import subprocess
import xml.etree.ElementTree as ET # noqa: N817
from dataclasses import dataclass, field, fields, is_dataclass, replace
from fractions import Fraction
from itertools import chain
from pathlib import Path
from time import sleep
from typing import Dict, List, Optional, Tuple, Type, TypeVar, Union, get_origin
from jird.config import Config, PartConfig
from jird.constants import TuningMethod
from jird.process import run, run_async
from jird.scala import (
ScalaData,
ScalaKeyboardMap,
ScalaScale,
)
logger = logging.getLogger(__name__)
LOWER_CASE_FIELDS = {"par_no"}
SerializableType = TypeVar("SerializableType", bound="Serializable")
@dataclass
class Serializable:
"""
Base class for Zyn config blocks.
Any block can be written-to/loaded-from xml following Zyn's conventions.
"""
def to_xml(
self,
filename: Union[str, Path],
tag: Optional[str] = None,
attrib: Optional[Dict[str, str]] = None,
) -> None:
"""
Write block to Zyn-format xml.
Parameters
----------
filename : str or Path
Output filename.
tag : str, optional
Tag to use for root node of xml. Uses the upper-cased class name if no
tag provided.
attrib : dict {str : str}, optional
Attributes for the root note of the xml.
"""
logger.info("Writing %s", filename)
root_tag = type(self).__name__.upper() if tag is None else tag
attrib = {} if attrib is None else attrib
root_node = ET.Element(root_tag, attrib)
_add_to_xml(self, root_node)
tree = ET.ElementTree(root_node)
tree.write(filename, encoding="UTF-8", xml_declaration=True)
@classmethod
def from_xml(
cls: Type[SerializableType],
filename: Union[str, Path],
tag: Optional[str] = None,
) -> SerializableType:
"""
Load config block from Zyn-format xml.
Parameters
----------
filename : str or Path
Filename of input xml. Can be gzipped.
tag : str, optional
Tag of block to pull from xml. Whole xml is loaded if no tag is provided.
Returns
-------
SerializableType
Instance of the particular config block being loaded.
"""
logger.info("Loading %s", filename)
try:
with gzip.open(filename, "rt", encoding="utf8") as f:
string = f.read()
except gzip.BadGzipFile:
with open(filename, "r", encoding="utf8") as f:
string = f.read()
node = ET.fromstring(string.strip())
version = None
if node.tag == "ZynAddSubFX-data":
version = (
int(node.attrib["version-major"]),
int(node.attrib["version-minor"]),
int(node.attrib.get("version-revision", 0)),
)
if tag is not None:
tag_node = node.find(tag)
assert tag_node is not None
node = tag_node
return _from_xml_node(cls, node, version=version)
@dataclass
class BaseParameters(Serializable):
max_midi_parts: int = 16
max_kit_items_per_instrument: int = 16
max_system_effects: int = 4
max_insertion_effects: int = 8
max_instrument_effects: int = 3
max_addsynth_voices: int = 8
@dataclass
class Degree(Serializable):
id: int # noqa: A003
numerator: Optional[int] = None
denominator: Optional[int] = None
cents: Optional[float] = None
exact_value: Optional[str] = None
@dataclass
class Octave(Serializable):
octave_size: int
degree: Tuple[Degree, ...]
@dataclass
class KeyMap(Serializable):
id: int # noqa: A003
degree: int
@dataclass
class KeyboardMapping(Serializable):
map_size: int
mapping_enabled: int
keymap: Tuple[KeyMap, ...]
@dataclass
class Scale(Serializable):
octave: Octave
keyboard_mapping: KeyboardMapping
first_key: int
last_key: int
middle_note: int
scale_shift: int = 64
@dataclass
class Microtonal(Serializable):
scale: Optional[Scale] = None
name: str = ""
comment: str = ""
invert_up_down: bool = False
invert_up_down_center: int = 60
enabled: bool = False
global_fine_detune: int = 64
a_note: int = 69
a_freq: float = 440.0
def build_microtonal_from_scala(
scale: ScalaScale,
keyboard_map: ScalaKeyboardMap,
) -> Microtonal:
"""Build Zyn microtonal config from Scala data."""
# Zyn xmz expects the float frequency ratio in the cents field rather than cents value.
octave = Octave(
octave_size=scale.number_of_notes,
degree=tuple(
Degree(id=i, numerator=x.numerator, denominator=x.denominator)
if isinstance(x, Fraction)
else Degree(id=i, cents=x, exact_value=float_to_hex(x))
for i, x in enumerate(scale.frequencies)
),
)
keyboard_mapping = KeyboardMapping(
map_size=keyboard_map.size,
mapping_enabled=1,
keymap=tuple(
KeyMap(id=i, degree=x) for i, x in enumerate(keyboard_map.mapping)
),
)
zyn_scale = Scale(
octave=octave,
keyboard_mapping=keyboard_mapping,
first_key=keyboard_map.first_midi_note,
last_key=keyboard_map.last_midi_note,
middle_note=keyboard_map.middle_note,
)
return Microtonal(
scale=zyn_scale,
enabled=True,
a_note=keyboard_map.reference_note,
a_freq=keyboard_map.reference_frequency,
)
@dataclass
class Info(Serializable):
type: int # noqa: A003
name: str
author: str
comments: str
@dataclass
class ParNo(Serializable):
id: int # noqa: A003
par: int
@dataclass
class EffectParameters(Serializable):
par_no: Tuple[ParNo, ...]
@dataclass
class Effect(Serializable):
type: Optional[int] = None # noqa: A003
preset: Optional[int] = None
numerator: Optional[int] = None
denominator: Optional[int] = None
effect_parameters: Optional[EffectParameters] = None
def __post_init__(self) -> None:
if self.type != 0:
self.numerator = 0
self.denominator = 4
@dataclass
class InstrumentEffect(Serializable):
id: int # noqa: A003
effect: Effect
route: int
bypass: bool
@dataclass
class Envelope(Serializable):
free_mode: bool
env_points: int
env_sustain: int
env_stretch: int
forced_release: bool
linear_envelope: bool
A_dt: float
D_dt: float
R_dt: float
A_val: int
D_val: int
S_val: int
R_val: int
# TODO add other defaults from zynaddsubfx/src/Params/EnvelopeParams.cpp
# TODO possibly implement version fix
repeating_envelope: bool = False
def __post_init__(self) -> None:
for x in ["A_dt", "D_dt", "R_dt"]:
param = getattr(self, x)
if isinstance(param, int):
setattr(self, x, (2 ** (param / 127 * 12) - 1) / 100)
@dataclass
class LFO(Serializable):
freq: float
intensity: int
start_phase: int
lfo_type: int
randomness_amplitude: int
randomness_frequency: int
delay: float
stretch: int = 64
continous: bool = False
cutoff: Optional[int] = 127
fadein: Optional[float] = 0.0
fadeout: Optional[float] = 10.0
numerator: Optional[int] = 0
denominator: Optional[int] = 4
def __post_init__(self) -> None:
if isinstance(self.delay, int):
self.delay = 4 * self.delay / 127
@dataclass
class AmplitudeParameters(Serializable):
volume: float
panning: int
velocity_sensing: int
punch_strength: Optional[int] = None
punch_time: Optional[int] = None
punch_stretch: Optional[int] = None
punch_velocity_sensing: Optional[int] = None
harmonic_randomness_grouping: Optional[int] = None
amplitude_envelope: Optional[Envelope] = None
amplitude_lfo: Optional[LFO] = None
volume_minus: Optional[bool] = None
fadein_adjustment: Optional[int] = None
amp_envelope_enabled: Optional[bool] = None
amp_lfo_enabled: Optional[bool] = None
stereo: Optional[bool] = None
@dataclass
class FrequencyParameters(Serializable):
detune: int
coarse_detune: int
detune_type: int
freq_envelope_enabled: Optional[bool] = None
frequency_envelope: Optional[Envelope] = None
freq_lfo_enabled: Optional[bool] = None
frequency_lfo: Optional[LFO] = None
band_width_envelope_enabled: Optional[bool] = None
bandwidth_envelope: Optional[Envelope] = None
bandwidth: Optional[int] = None
bandwidth_scale: Optional[int] = None
fixed_freq: Optional[bool] = None
fixed_freq_et: Optional[int] = None
bend_adjust: Optional[int] = None
offset_hz: Optional[int] = None
overtone_spread_type: Optional[int] = None
overtone_spread_par1: Optional[int] = None
overtone_spread_par2: Optional[int] = None
overtone_spread_par3: Optional[int] = None
@dataclass
class Filter(Serializable):
category: int
type: int # noqa: A003
stages: int
gain: float
freq: Optional[int] = None
q: Optional[int] = None
freq_track: Optional[int] = None
basefreq: Optional[float] = None
baseq: Optional[float] = None
freq_tracking: Optional[float] = None
def __post_init__(self) -> None:
if self.basefreq is None:
assert self.freq is not None
self.basefreq = 2 ** ((self.freq / 64 - 1) * 5 + 9.96578428)
self.freq = None
# baseq = expf(powf((float) Pq / 127.0f, 2) * logf(1000.0f)) - 0.9f;
assert self.q is not None
self.baseq = math.exp((self.q / 127) ** 2 * math.log(1000)) - 0.9
self.q = None
self.gain = (self.gain / 64 - 1) * 30
assert self.freq_track is not None
self.freq_tracking = 100 * (self.freq_track - 64) / 64
self.freq_track = None
@dataclass
class FilterParameters(Serializable):
enabled: bool = False
velocity_sensing_amplitude: Optional[int] = None
velocity_sensing: Optional[int] = None
filter: Optional[Filter] = None # noqa: A003
filter_envelope: Optional[Envelope] = None
filter_lfo: Optional[LFO] = None
@dataclass
class Resonance(Serializable):
enabled: bool
@dataclass
class Harmonic(Serializable):
id: int # noqa: A003
mag: int
phase: Optional[int] = None
relbw: Optional[int] = None
@dataclass
class Harmonics(Serializable):
harmonic: Tuple[Harmonic, ...]
@dataclass
class Oscil(Serializable):
harmonics: Harmonics
harmonic_mag_type: int
base_function: int
base_function_par: int
base_function_modulation: int
base_function_modulation_par1: int
base_function_modulation_par2: int
base_function_modulation_par3: int
modulation: int
modulation_par1: int
modulation_par2: int
modulation_par3: int
wave_shaping: int
wave_shaping_function: int
filter_type: int
filter_par1: int
filter_par2: int
filter_before_wave_shaping: int
spectrum_adjust_type: int
spectrum_adjust_par: int
rand: int
amp_rand_type: int
amp_rand_power: int
harmonic_shift: int
harmonic_shift_first: bool
adaptive_harmonics: int
adaptive_harmonics_base_frequency: int
adaptive_harmonics_power: int
adaptive_harmonics_par: Optional[int] = 50
@dataclass
class Voice(Serializable):
id: int # noqa: A003
enabled: bool
oscil: Optional[Oscil] = None
amplitude_parameters: Optional[AmplitudeParameters] = None
frequency_parameters: Optional[FrequencyParameters] = None
type: Optional[int] = None # noqa: A003
unison_frequency_spread: Optional[int] = 60
unison_invert_phase: Optional[int] = 0
unison_phase_randomness: Optional[int] = 127
unison_size: Optional[int] = 1
unison_stereo_spread: Optional[int] = 64
unison_vibratto: Optional[int] = 64
unison_vibratto_speed: Optional[int] = 64
delay: Optional[int] = None
resonance: Optional[bool] = None
ext_oscil: Optional[int] = None
ext_fm_oscil: Optional[int] = None
oscil_phase: Optional[int] = None
oscil_fm_phase: Optional[int] = None
filter_bypass: Optional[bool] = None
filter_enabled: Optional[bool] = None
filter_fcctl_bypass: Optional[bool] = False
fm_enabled: Optional[int] = None
def __post_init__(self) -> None:
if not self.enabled:
for f in fields(self):
if f.name not in {"id", "enabled"}:
setattr(self, f.name, None)
return
# TODO possibly implement version fix for 3.0.5
# see zynaddsubfx/src/Params/ADnoteParameters.cpp line 976
if self.amplitude_parameters and isinstance(
self.amplitude_parameters.volume, int
):
self.amplitude_parameters.volume = -60 * (
1 - self.amplitude_parameters.volume / 127
)
if self.frequency_parameters:
if self.frequency_parameters.bend_adjust is None:
self.frequency_parameters.bend_adjust = 88
if self.frequency_parameters.offset_hz is None:
self.frequency_parameters.offset_hz = 64
DISABLED_VOICE = Voice(id=0, enabled=False)
@dataclass
class AddSynthParameters(Serializable):
stereo: bool
amplitude_parameters: AmplitudeParameters
frequency_parameters: FrequencyParameters
filter_parameters: FilterParameters
resonance: Resonance
voice: Tuple[Voice, ...]
def __post_init__(self) -> None:
# TODO possibly implement version fix for 3.0.5
# see zynaddsubfx/src/Params/ADnoteParameters.cpp line 976
if self.amplitude_parameters:
if isinstance(self.amplitude_parameters.volume, int):
self.amplitude_parameters.volume = 12.0412 - 60 * (
1 - self.amplitude_parameters.volume / 96
)
if self.amplitude_parameters.fadein_adjustment is None:
self.amplitude_parameters.fadein_adjustment = 20
if self.frequency_parameters and self.frequency_parameters.bandwidth is None:
self.frequency_parameters.bandwidth = 64
@dataclass
class HarmonicPosition(Serializable):
parameter1: int
parameter2: int
parameter3: int
type: int # noqa: A003
@dataclass
class HarmonicProfile(Serializable):
amplitude_multiplier_mode: int = 0
amplitude_multiplier_par1: int = 80
amplitude_multiplier_par2: int = 64
amplitude_multiplier_type: int = 0
base_par1: int = 80
base_type: int = 0
frequency_multiplier: int = 0
modulator_frequency: int = 30
modulator_par1: int = 0
one_half: int = 0
width: int = 127
autoscale: bool = True
@dataclass
class SampleQuality(Serializable):
basenote: int
octaves: int
samples_per_octave: int
samplesize: int
@dataclass
class PadSynthParameters(Serializable):
amplitude_parameters: AmplitudeParameters
filter_parameters: FilterParameters
frequency_parameters: FrequencyParameters
oscil: Oscil
resonance: Resonance
harmonic_position: HarmonicPosition
harmonic_profile: HarmonicProfile
sample_quality: SampleQuality
bandwidth: int
bandwidth_scale: int
mode: int
stereo: bool
def __post_init__(self) -> None:
if self.frequency_parameters:
if self.frequency_parameters.bend_adjust is None:
self.frequency_parameters.bend_adjust = 88
if self.frequency_parameters.offset_hz is None:
self.frequency_parameters.offset_hz = 64
@dataclass
class SubSynthParameters(Serializable):
amplitude_parameters: AmplitudeParameters
filter_parameters: FilterParameters
frequency_parameters: FrequencyParameters
harmonics: Harmonics
harmonic_mag_type: int
num_stages: int
start: int
def __post_init__(self) -> None:
if self.amplitude_parameters:
if isinstance(self.amplitude_parameters.volume, int):
self.amplitude_parameters.volume = -60 * (
1 - self.amplitude_parameters.volume / 96
)
if self.amplitude_parameters.stereo is None:
self.amplitude_parameters.stereo = True
@dataclass
class InstrumentKitItem(Serializable):
id: int # noqa: A003
enabled: bool
name: Optional[str] = None
muted: Optional[bool] = None
min_key: Optional[int] = None
max_key: Optional[int] = None
send_to_instrument_effect: Optional[int] = None
add_enabled: Optional[bool] = None
add_synth_parameters: Optional[AddSynthParameters] = None
sub_enabled: Optional[bool] = None
sub_synth_parameters: Optional[SubSynthParameters] = None
pad_enabled: Optional[bool] = None
pad_synth_parameters: Optional[PadSynthParameters] = None
@dataclass
class InstrumentKit(Serializable):
kit_mode: int
drum_mode: bool
instrument_kit_item: Tuple[InstrumentKitItem, ...]
@dataclass
class InstrumentEffects(Serializable):
instrument_effect: Tuple[InstrumentEffect, ...]
@dataclass
class Instrument(Serializable):
info: Info
instrument_kit: InstrumentKit
instrument_effects: InstrumentEffects
def _from_xml_node(
cls: Type[SerializableType],
node: ET.Element,
*,
id: Optional[int] = None, # noqa: A002 pylint: disable=W0622
version: Optional[Tuple[int, int, int]] = None,
) -> SerializableType:
components = {}
for f in fields(cls):
children = node.findall(
f.name.upper() if f.name not in LOWER_CASE_FIELDS else f.name
)
if is_dataclass(f.type) and children:
components[f.name] = _from_xml_node(f.type, children[0], version=version)
elif (f.type == Optional[f.type]) and children:
# Handle Optional[T]
# Check uses Optional[T] == Optional[Optional[T]]
components[f.name] = _from_xml_node(
f.type.__args__[0], children[0], version=version
)
elif get_origin(f.type) == tuple:
components[f.name] = tuple(
_from_xml_node(
f.type.__args__[0], x, id=int(x.attrib["id"]), version=version
)
for x in children
)
else:
for x in node:
if "name" in x.attrib and x.attrib["name"] == f.name:
components[f.name] = get_value(x)
if version is not None and version < (3, 0, 4) and cls == LFO:
components["freq"] = (2 ** (10 * components["freq"]) - 1) / 12
if id is not None:
components["id"] = id
return cls(**components)
def get_value(node: ET.Element) -> Union[Optional[str], float, int]:
"""Get value from leaf node in Zyn xml."""
if node.tag == "string":
return node.text
value = node.attrib["value"]
if node.tag == "par_bool":
return value == "yes"
if node.tag == "par_real":
# TODO use exact value field in xml
return float(value)
if node.tag == "par":
return int(value)
raise ValueError(node)
@dataclass
class Controller(Serializable):
pitchwheel_bendrange: int = 200
pitchwheel_bendrange_down: int = 0
pitchwheel_split: bool = False
expression_receive: bool = True
panning_depth: int = 64
filter_cutoff_depth: int = 64
filter_q_depth: int = 64
bandwidth_depth: int = 64
mod_wheel_depth: int = 80
mod_wheel_exponential: bool = False
fm_amp_receive: bool = True
volume_receive: bool = True
sustain_receive: bool = True
portamento_receive: bool = True
portamento_time: int = 64
portamento_pitchthresh: int = 3
portamento_pitchthreshtype: int = 1
portamento_portamento: int = 0
portamento_auto: bool = True
portamento_updowntimestretch: int = 64
portamento_proportional: int = 0
portamento_proprate: int = 80
portamento_propdepth: int = 90
resonance_center_depth: int = 64
resonance_bandwidth_depth: int = 64
PartType = TypeVar("PartType", bound="Part")
@dataclass
class Part(Serializable):
id: int # noqa: A003
enabled: bool
instrument: Optional[Instrument] = None
volume: Optional[float] = 0.0
panning: Optional[int] = 64
min_key: Optional[int] = 0
max_key: Optional[int] = 127
key_shift: Optional[int] = 64
rcv_chn: Optional[int] = 0
velocity_sensing: Optional[int] = 64
velocity_offset: Optional[int] = 64
note_on: Optional[bool] = True
poly_mode: Optional[bool] = True
legato_mode: Optional[int] = 0
key_limit: Optional[int] = 15
voice_limit: Optional[int] = 0
controller: Optional[Controller] = field(default_factory=Controller)
@classmethod
def from_config(cls: Type[PartType], part: PartConfig) -> PartType:
"""Build Zyn part from jird config."""
assert part.instrument is not None
assert isinstance(part.panning, int)
return cls(
id=0,
enabled=True,
instrument=Instrument.from_xml(part.instrument, tag="INSTRUMENT"),
volume=part.volume,
panning=part.panning,
)
DISABLED_PART = Part(id=0, enabled=False)
@dataclass
class Volume(Serializable):
id: int # noqa: A003
vol: int = 0
@dataclass
class SendTo(Serializable):
id: int # noqa: A003
send_vol: int = 0
@dataclass
class SystemEffect(Serializable):
id: int # noqa: A003
effect: Effect = field(default_factory=Effect)
volume: Tuple[Volume, ...] = tuple(Volume(id=i) for i in range(16))
sendto: Tuple[SendTo, ...] = tuple(SendTo(id=i) for i in range(3))
@dataclass
class InsertionEffect(Serializable):
id: int # noqa: A003
effect: Effect = field(default_factory=Effect)
part: int = -1
@dataclass
class Master(Serializable):
part: Tuple[Part, ...]
volume: float = -20 / 3
key_shift: int = 64
nrpn_receive: bool = True
microtonal: Microtonal = field(default_factory=Microtonal)
automation: None = None
system_effect: Tuple[SystemEffect, ...] = tuple(
SystemEffect(id=i) for i in range(4)
)
insertion_effect: Tuple[InsertionEffect, ...] = tuple(
InsertionEffect(id=i) for i in range(8)
)
@dataclass
class Information(Serializable):
pass
[docs]@dataclass
class ZynConfig(Serializable):
"""Top level Zyn config."""
master: Master
information: Information = field(default_factory=Information)
base_parameters: BaseParameters = field(default_factory=BaseParameters)
def to_xml(
self,
filename: Union[str, Path],
tag: Optional[str] = None, # noqa: ARG002
attrib: Optional[Dict[str, str]] = None, # noqa: ARG002
) -> None:
"""Write Zyn master config to xml."""
super().to_xml(
filename,
tag="ZynAddSubFX-data",
attrib={
"version-major": "3",
"version-minor": "0",
"version-revision": "7",
"ZynAddSubFX-author": "Nasca Octavian Paul",
},
)
def float_to_hex(f: float) -> str:
"""Convert float to hex showing its binary representation."""
return f'0x{struct.unpack("<I", struct.pack("<f", f))[0]:08X}'
def _add_to_xml(d: Serializable, parent: ET.Element) -> None: # noqa: max-complexity=11
for f in fields(d):
tag = f.name.upper() if f.name not in LOWER_CASE_FIELDS else f.name
x = getattr(d, f.name)
if is_dataclass(x):
child = ET.SubElement(parent, tag)
_add_to_xml(x, child)
elif isinstance(x, tuple):
for y in x:
child = ET.SubElement(parent, tag, id=str(y.id))
_add_to_xml(y, child)
elif isinstance(x, bool):
ET.SubElement(parent, "par_bool", name=f.name, value="yes" if x else "no")
elif isinstance(x, int):
if f.name != "id":
ET.SubElement(parent, "par", name=f.name, value=str(x))
elif isinstance(x, float):
ET.SubElement(
parent,
"par_real",
name=f.name,
value=str(x).rstrip("0").rstrip("."),
exact_value=float_to_hex(x),
)
elif isinstance(x, str):
child = ET.SubElement(parent, "string", name=f.name)
child.text = x
elif x is None:
continue
else:
raise ValueError(x)
[docs]def play_with_zyn(
config: Config,
part_channels: List[List[int]],
scala_data: Optional[ScalaData],
filepath: Path,
) -> None:
"""
Play music with Zyn.
Generate a Zyn master config from Zyn instruments and any scala tuning
data. Run Zyn with this config. Send midi to Zyn with aplaymidi. Kill
Zyn when done.
Parameters
----------
config : Config
Config controlling playback.
part_channels : list of list of int
Midi channels used for each part.
scala_data : ScalaData, optional
Scala tuning files and frequency map.
filepath : Path
Path to midi file.
"""
if config.tuning_method == TuningMethod.SCALA:
assert scala_data is not None
microtonal = build_microtonal_from_scala(
scala_data.scale, scala_data.keyboard_map
)
elif config.tuning_method == TuningMethod.PITCH_BEND:
microtonal = Microtonal()
else:
raise ValueError(config.tuning_method)
assert config.parts is not None
zyn_parts = [Part.from_config(x) for x in config.parts]
if len(zyn_parts) < len(part_channels):
zyn_parts += (len(part_channels) - len(zyn_parts)) * [zyn_parts[-1]]
assert len(zyn_parts) >= len(part_channels)
zyn_parts_for_channels = tuple(
chain.from_iterable(
[replace(zyn_part, rcv_chn=x) for x in channels]
for zyn_part, channels in zip(zyn_parts, part_channels)
)
)
zyn_parts_for_channels = tuple(
replace(x, id=i) for i, x in enumerate(zyn_parts_for_channels)
)
max_parts = 16
unused = max_parts - len(zyn_parts_for_channels)
if unused > 0:
zyn_parts_for_channels += unused * (DISABLED_PART,)
assert len(zyn_parts_for_channels) == max_parts
zyn_config = ZynConfig(
master=Master(
part=zyn_parts_for_channels,
microtonal=microtonal,
)
)
zyn_config_path = filepath.with_suffix(".xmz")
zyn_config.to_xml(zyn_config_path)
# Start Zyn
p = run_async(
[
"zynaddsubfx",
"-U",
"-I",
"alsa",
"-O",
"alsa",
"-l",
str(zyn_config_path),
"-r",
str(config.sample_rate),
],
verbose=config.verbose,
)
# Wait for Zyn to be ready to receive midi
n = 0
while n < 50 and str(p.pid) not in subprocess.check_output(
["aconnect", "-o"],
encoding="utf8",
):
sleep(0.1)
n += 1
# Send midi with aplaymidi
run(["aplaymidi", "-p", "ZynAddSubFX", filepath], verbose=config.verbose)
# Kill Zyn once music has finished
logger.info("Killing %d", p.pid)
p.kill()