"""
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)