Source code for jird.config

"""Config for jird."""
import os
from dataclasses import dataclass, field, fields
from enum import Enum
from importlib import resources
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, TypeVar, Union

import jird.data
from jird.constants import Synth, TuningMethod

with resources.path(jird.data, "default.fxp") as path:
    DEFAULT_SURGE_PATCH = path

with resources.path(jird.data, "default.xiz") as path:
    DEFAULT_ZYN_PATCH = path

_DEFAULT_SOUNDFONT = os.getenv("JIRD_SOUNDFONT")
DEFAULT_SOUNDFONT = Path(_DEFAULT_SOUNDFONT) if _DEFAULT_SOUNDFONT is not None else None


U = TypeVar("U", str, float, int)


def _as_type(t: Type[U], x: Optional[Union[str, float, int]]) -> Optional[U]:
    return t(x) if x is not None else None


def _as_path(x: Optional[str]) -> Optional[Path]:
    return Path(x).expanduser() if x is not None else None


[docs]@dataclass class PartConfig: """ Config for each jird part. Attributes ---------- volume : float Volume for this part. panning : int Pan (position left/right in stereo image) for this part. instrument : Path, optional Path to instrument patch to use for this part. For ZynAddSubFX this is an xiz file. For Surge XT this is an fxp file. Not used by fluidsynth. program : int, optional Midi program to use for this part. Used with fluidsynth to specify which instrument in the soundfont to use. """ volume: float = 0.0 panning: Optional[Union[float, int]] = None instrument: Optional[Path] = None program: Optional[int] = None def __post_init__(self) -> None: if self.program is not None: assert self.program > 0 if self.instrument is not None: assert self.instrument.exists(), f"Could not find {self.instrument}"
T = TypeVar("T", bound="Config")
[docs]@dataclass class Config: # pylint: disable=R0902 """ Overall config controlling jird. Attributes ---------- t : float Basic time in seconds. Real note durations are the jird duration multiplied by this basic time. For example the note 1:1 will have duration `t` when played back. f : float Basic frequency in Hz. Real note frequencies are the jird note frequency multiplied by this basic frequency. For example the note 1:1 will have frequency `f` when played back. tuning_method : TuningMethod Method to use for tuning midi notes to the desired frequency. Choices are `PITCH_BEND`, which sends a pitch bend before each midi note to adjust its frequency, and `SCALA`, which uses Scala scl and kbm files to map each midi note onto its frequency. synth : Synth Synth to use for playback. Choices are FLUIDSYNTH, ZYNADDSUBFX, and SURGE_XT. volume : float Overall volume for playback. Interpretation varies by synth; typical values are given in the example configs for each synth. pitch_bend_range : int Midi pitch bend range, in semitones, to assume when calculating bends. Defaults to two. edo : int, optional If specified, music is tempered using `edo` equal divisions of the octave. sample_rate : int Sample rate to use when rendering audio. Defaults to 44100 soundfont : Path, optional Path of soundfont to use when using fluidsynth. verbose : bool Whether to show logs and subprocess output. Defaults to False. parts : list of PartConfig Part-specific config for each jird part. See PartConfig for details. """ t: float = 0.5 f: float = 440.0 tuning_method: TuningMethod = TuningMethod.SCALA synth: Synth = Synth.FLUIDSYNTH volume: float = 2.0 pitch_bend_range: int = 2 edo: Optional[int] = None sample_rate: int = 44100 soundfont: Optional[Path] = DEFAULT_SOUNDFONT verbose: bool = False parts: List[PartConfig] = field(default_factory=list) def __post_init__(self) -> None: assert self.t > 0 assert self.f > 0 if self.edo is not None: assert self.edo > 0 if self.synth == Synth.FLUIDSYNTH: self._setup_for_fluidsynth() if self.synth == Synth.SURGE_XT: self._setup_for_surge() if self.synth == Synth.ZYNADDSUBFX: self._setup_for_zyn() def _setup_for_fluidsynth(self) -> None: assert 0 <= self.volume <= 10 assert 8000 <= self.sample_rate <= 96000 def _setup_for_surge(self) -> None: for p in self.parts: assert -48 <= p.volume <= 0 if p.panning is None: p.panning = 0.0 assert -1 <= p.panning <= 1 if p.instrument is None: p.instrument = DEFAULT_SURGE_PATCH def _setup_for_zyn(self) -> None: for p in self.parts: if p.panning is None: p.panning = 64 else: p.panning = int(p.panning) assert 0 <= p.panning <= 128 if p.instrument is None: p.instrument = DEFAULT_ZYN_PATCH @classmethod def from_dict(cls: Type[T], config_dict: Dict[str, Any]) -> T: """Build Config from dictionary.""" field_names = {x.name for x in fields(cls)} attrs = {k: v for k, v in config_dict.items() if k in field_names} if "soundfont" in attrs: attrs["soundfont"] = _as_path(config_dict["soundfont"]) if "tuning_method" in attrs: attrs["tuning_method"] = _to_enum( config_dict["tuning_method"], TuningMethod ) if "synth" in attrs: attrs["synth"] = _to_enum(config_dict["synth"], Synth) if "parts" in config_dict: attrs["parts"] = [] for part in config_dict["parts"]: attrs["parts"].append( PartConfig( volume=part.get("volume", 0.0), panning=_as_type(float, part.get("panning")), instrument=_as_path(part.get("instrument")), program=_as_type(int, part.get("program")), ) ) elif "programs" in config_dict: attrs["parts"] = [ PartConfig(program=int(x)) for x in config_dict["programs"].split(",") ] else: config_instrument = config_dict.get("instrument") attrs["parts"] = [PartConfig(instrument=_as_path(config_instrument))] # Infer synth from patch file extension if config_instrument is not None: if config_instrument.endswith(".fxp"): attrs["synth"] = Synth.SURGE_XT if config_instrument.endswith(".xiz"): attrs["synth"] = Synth.ZYNADDSUBFX return cls(**attrs)
V = TypeVar("V", bound=Enum) def _to_enum(x: str, enum: Type[V]) -> V: try: return enum[x.upper()] except KeyError: print( f"\n{enum.__name__} '{x}' not recognized." + f"\n\nSupported {enum.__name__}s: {[x.name for x in enum]}\n" ) raise