Source code for jird.scala

"""
Generate scala scl and kbm files.

Scala files are widely used to load microtonal
tunings into synths. For more information see `here
<https://www.huygens-fokker.org/scala/scl_format.html>`_.
"""
import logging
import math
from dataclasses import dataclass
from fractions import Fraction
from pathlib import Path
from typing import Dict, List, Union

from jird.constants import DIVISION, REST_FREQUENCY
from jird.core import MidiNote, Music, Note, Number, all_frequencies, evaluate

logger = logging.getLogger(__name__)


[docs]@dataclass class ScalaScale: """ Scala scale data. Contains frequencies of the notes in a scale. For more information see `here <https://www.huygens-fokker.org/scala/scl_format.html>`_. Attributes ---------- description : str Text describing the scale. number_of_notes : int Number of notes in the scale frequencies : list of Fraction or float Frequencies in the scale. Fractions are interpreted as frequency ratios, floats as cent values. """ description: str number_of_notes: int frequencies: List[Number] def __init__( self, frequencies: List[Number], description: str = "Generated by jird" ) -> None: """ Build scala scale. Parameters ---------- frequencies : list of Number Frequencies of each note in the scale. description : str Text description to include with the scale. """ self.description = description self.number_of_notes = len(frequencies) self.frequencies = frequencies def write(self, filename: Union[str, Path]) -> None: """Write scala scale to file.""" write_scl_file(self.frequencies, filename)
[docs]@dataclass class ScalaKeyboardMap: # pylint: disable=R0902 """ Scala keyboard map data. Contains mapping from midi note number to scale degree. For more information see `here <https://www.huygens-fokker.org/scala/scl_format.html>`_. Attribute descriptions below copied from example.kbm in scala repo. Attributes ---------- size : int Size of map (greater than or equal to the number of notes in the scale to be mapped). The pattern repeats every so many keys. first_midi_note : int First MIDI note number to retune. last_midi_note : int Last MIDI note number to retune. middle_note : int Middle note where the first entry of the mapping is mapped to. reference_note : int Reference note for which frequency is given. reference_frequency : float Frequency to tune the above note to (floating point e.g. 440.0). formal_octave : int Scale degree to consider as formal octave (determines difference in pitch between adjacent mapping patterns) and 0 means last scale degree. mapping : list of int The numbers represent scale degrees mapped to keys. The first entry is for the given middle note, the next for subsequent higher keys. For an unmapped key, put in an "x". At the end, unmapped keys may be left out. """ size: int first_midi_note: int last_midi_note: int middle_note: int reference_note: int reference_frequency: float formal_octave: int mapping: List[int] def write(self, filename: Union[str, Path]) -> None: """Write scala keyboard map to file.""" logger.info("Writing %s", filename) lines = [ self.size, self.first_midi_note, self.last_midi_note, self.middle_note, self.reference_note, self.reference_frequency, self.formal_octave, "! Mapping", *self.mapping, ] filepath = Path(filename) filepath.write_text("\n".join(str(x) for x in lines) + "\n", encoding="utf8") def __post_init__(self) -> None: assert self.size == len( self.mapping ), "Keyboard map size must match length of mapping"
def build_scale(music: Music) -> List[Number]: """ Build scale of frequencies in music. Frequencies are transposed by octaves so that the scale lies within one octave. Parameters ---------- music : Music Music to be analyzed. Returns ------- List of [Fraction or float] Scale making up `music`. Can be floats if music is tempered. """ frequencies = all_frequencies(music) scale_frequencies = set() octave = 2 for freq in frequencies: reduced_freq = freq while reduced_freq >= octave: reduced_freq /= octave while reduced_freq < 1: reduced_freq *= octave assert 1 <= reduced_freq < octave scale_frequencies.add(reduced_freq) return sorted(scale_frequencies) def write_scl_file(frequencies: List[Number], filename: Union[str, Path]) -> None: """ Write scala scl file. The scl format is widely used to load microtonal tunings. For more information see `here <https://www.huygens-fokker.org/scala/scl_format.html>`_. Parameters ---------- frequencies : list of Fraction of float Frequency ratios to write out. Can be float if coming from tempered music. filename : str or Path Output filename. """ output_path = Path(filename) logger.info("Writing %s with %d frequencies", output_path, len(frequencies)) scl_values = [ x if isinstance(x, Fraction) else round(1200 * math.log2(x), 5) for x in frequencies ] lines = [f" {output_path.stem}", f" {len(scl_values)}", "!"] + [ f" {x}" for x in scl_values ] output_path.write_text("\n".join(lines) + "\n", encoding="utf8") @dataclass class ScalaData: """Scala tuning data and associated jird frequency map.""" scale: ScalaScale keyboard_map: ScalaKeyboardMap frequency_map: Dict[Number, int]
[docs]def write_scala_files_for_midi( music: Music, f0: float, base_filename: Union[str, Path] ) -> ScalaData: """ Write scl and kbm files for retuning midi. The scl file contains each frequency in music sorted low to high. The lowest frequency is taken as 1/1 in the scala file. The kbm file contains a mapping from each frequency in the music to a unique midi note, along with the frequency in Hz of the lowest note in music to use as the frequency of the scala 1/1. Parameters ---------- music : Music Music to build tuning for. f0 : float Base frequency in Hz. base_filename : str or Path Filename to build output scl and kbm filenames from by changing the file extension. Returns ------- ScalaData Scala data to be used with the midi file for `music`. """ ratios = all_frequencies(music) n_ratios = len(ratios) max_midi_notes = 128 assert n_ratios <= max_midi_notes base_path = Path(base_filename) # scl file scl_ratios = [x / ratios[0] for x in ratios[1:]] if len(ratios) > 1 else ratios scale = ScalaScale(scl_ratios) scale.write(base_path.with_suffix(".scl")) keyboard_map = ScalaKeyboardMap( size=n_ratios, first_midi_note=0, last_midi_note=n_ratios - 1, middle_note=0, reference_note=0, reference_frequency=float(ratios[0] * f0) if ratios else 0.0, formal_octave=0, mapping=list(range(n_ratios)), ) keyboard_map.write(base_path.with_suffix(".kbm")) frequency_map = {k: i for i, k in enumerate(all_frequencies(music))} return ScalaData( scale=scale, keyboard_map=keyboard_map, frequency_map=frequency_map )
[docs]def write_scale(music: Music, filename: Union[str, Path]) -> None: """ Write scala scl file for scale in `music`. The scale is all the notes in `music` transposed into the same octave. Parameters ---------- music : Music Music to build scale from. filename : str or Path Output file name. """ ratios = build_scale(music) if not ratios: return # Scale ratios r satisfy 1 <= r < 2 # Scala files do not include 1 but do include 2 if ratios[0] == Fraction(1, 1): ratios.pop(0) ratios.append(Fraction(2, 1)) write_scl_file(ratios, filename)
def scala_midi_note(note: Note, frequency_map: Dict[Number, int]) -> MidiNote: """ Midi representation of note for use with scala scl and kbm files. Each frequency is mapped to a single midi note number for later retuning with scala scl and kbm files. Parameters ---------- note : Note Note to represent. frequency_map : dict of {Fraction or float : int} Mapping from frequencies to midi note numbers. Returns ------- MidiNote Midi representation of `note` for use with scala files. """ duration = evaluate(note.duration) # DIVISION is midi ticks per quarter note. ticks = duration * DIVISION assert int(ticks) == ticks ticks = int(ticks) frequency = evaluate(note.frequency) if frequency == REST_FREQUENCY: return MidiNote(pitch=0, bend=None, ticks=ticks, velocity=0) pitch = frequency_map[frequency] velocity = min(round(evaluate(note.volume) * 64), 127) return MidiNote(pitch=pitch, bend=None, ticks=ticks, velocity=velocity)