Added experimental scala scale parser

This commit is contained in:
2023-03-10 18:41:50 +02:00
parent 545ae1f92a
commit bd2a1587d7
12 changed files with 1718 additions and 1579 deletions

View File

@ -47,5 +47,5 @@ def test_notenames(name: str, expected: int):
) )
def test_note_to_midi(pitch_classes: str, expected: int): def test_note_to_midi(pitch_classes: str, expected: int):
assert [ assert [
scale.note_from_pc(root=60, pitch_class=val, intervals="Ionian") for val in pitch_classes scale.note_from_pc(root=60, pitch_class=val, intervals="Ionian")[0] for val in pitch_classes
] == expected ] == expected

View File

@ -5,3 +5,4 @@ from .defaults import *
from .scale import * from .scale import *
from .converters import * from .converters import *
from .spec import * from .spec import *
from .classes import *

View File

@ -1,3 +1,3 @@
from .items import * from .items import *
from .sequences import *
from .root import * from .root import *
from .sequences import *

View File

@ -1,5 +1,6 @@
""" Ziffers item classes """ """ Ziffers item classes """
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from math import floor
import operator import operator
import random import random
from ..scale import ( from ..scale import (
@ -171,6 +172,7 @@ class Pitch(Event):
"""Class for pitch in time""" """Class for pitch in time"""
pitch_class: int pitch_class: int
pitch_bend: float = field(default=None)
octave: int = field(default=None) octave: int = field(default=None)
modifier: int = field(default=None) modifier: int = field(default=None)
note: int = field(default=None) note: int = field(default=None)
@ -206,6 +208,10 @@ class Pitch(Event):
def get_pitch_class(self): def get_pitch_class(self):
"""Getter for pitche""" """Getter for pitche"""
return self.pitch_class return self.pitch_class
def get_pitch_bend(self):
"""Getter for pitche"""
return self.pitch_bend
def update_note(self, force: bool = False): def update_note(self, force: bool = False):
"""Update note if Key, Scale and Pitch-class are present""" """Update note if Key, Scale and Pitch-class are present"""
@ -215,15 +221,16 @@ class Pitch(Event):
and (self.pitch_class is not None) and (self.pitch_class is not None)
and (self.note is None or force) and (self.note is None or force)
): ):
note = note_from_pc( note, pitch_bend = note_from_pc(
root=self.key, root=self.key,
pitch_class=self.pitch_class, pitch_class=self.pitch_class,
intervals=self.scale, intervals=self.scale,
modifier=self.modifier if self.modifier is not None else 0, modifier=self.modifier if self.modifier is not None else 0,
octave=self.octave if self.octave is not None else 0, octave=self.octave if self.octave is not None else 0,
) )
self.pitch_bend = pitch_bend
self.freq = midi_to_freq(note) self.freq = midi_to_freq(note)
self.note = note self.note = floor(note)
if self.duration is not None: if self.duration is not None:
self.beat = self.duration * 4 self.beat = self.duration * 4
@ -296,6 +303,7 @@ class Chord(Event):
notes: list[int] = field(default=None) notes: list[int] = field(default=None)
inversions: int = field(default=None) inversions: int = field(default=None)
pitches: list[int] = field(default=None, init=False) pitches: list[int] = field(default=None, init=False)
pitch_bends: list[int] = field(default=None, init=False)
freqs: list[float] = field(default=None, init=False) freqs: list[float] = field(default=None, init=False)
octaves: list[int] = field(default=None, init=False) octaves: list[int] = field(default=None, init=False)
durations: list[float] = field(default=None, init=False) durations: list[float] = field(default=None, init=False)
@ -333,6 +341,10 @@ class Chord(Event):
def get_pitch_class(self): def get_pitch_class(self):
"""Getter for pitches""" """Getter for pitches"""
return self.pitches return self.pitches
def get_pitch_bend(self):
"""Getter for pitche"""
return self.pitch_bends
def get_duration(self): def get_duration(self):
"""Getter for durations""" """Getter for durations"""
@ -353,7 +365,7 @@ class Chord(Event):
def update_notes(self, options=None): def update_notes(self, options=None):
"""Update notes""" """Update notes"""
pitches, notes, freqs, octaves, durations, beats = ([] for _ in range(6)) pitches, pitch_bends, notes, freqs, octaves, durations, beats = ([] for _ in range(7))
# Update notes # Update notes
for pitch in self.pitch_classes: for pitch in self.pitch_classes:
@ -367,6 +379,7 @@ class Chord(Event):
# Create helper lists # Create helper lists
for pitch in self.pitch_classes: for pitch in self.pitch_classes:
pitches.append(pitch.pitch_class) pitches.append(pitch.pitch_class)
pitch_bends.append(pitch.pitch_bend)
notes.append(pitch.note) notes.append(pitch.note)
freqs.append(pitch.freq) freqs.append(pitch.freq)
octaves.append(pitch.octave) octaves.append(pitch.octave)
@ -374,6 +387,7 @@ class Chord(Event):
beats.append(pitch.beat) beats.append(pitch.beat)
self.pitches = pitches self.pitches = pitches
self.pitch_bends = pitch_bends
self.notes = notes self.notes = notes
self.freqs = freqs self.freqs = freqs
self.octaves = octaves self.octaves = octaves

View File

@ -109,6 +109,14 @@ class Ziffers(Sequence):
for val in self.evaluated_values for val in self.evaluated_values
if isinstance(val, (Pitch, Chord)) if isinstance(val, (Pitch, Chord))
] ]
def pitch_bends(self) -> list[int]:
"""Return list of pitch bend values"""
return [
val.get_pitch_bend()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord))
]
def notes(self) -> list[int]: def notes(self) -> list[int]:
"""Return list of midi notes""" """Return list of midi notes"""

View File

@ -1,6 +1,7 @@
""" Sequence classes for Ziffers """ """ Sequence classes for Ziffers """
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from itertools import product from itertools import product
from math import floor
from types import LambdaType from types import LambdaType
from copy import deepcopy from copy import deepcopy
from ..defaults import DEFAULT_OPTIONS from ..defaults import DEFAULT_OPTIONS
@ -190,7 +191,7 @@ def create_pitch(current: Item, options: dict) -> Pitch:
current_value = current.get_value(merged_options) current_value = current.get_value(merged_options)
note = note_from_pc( note, pitch_bend = note_from_pc(
root=merged_options["key"], root=merged_options["key"],
pitch_class=current_value, pitch_class=current_value,
intervals=merged_options["scale"], intervals=merged_options["scale"],
@ -200,8 +201,9 @@ def create_pitch(current: Item, options: dict) -> Pitch:
new_pitch = Pitch( new_pitch = Pitch(
pitch_class=current_value, pitch_class=current_value,
text=str(current_value), text=str(current_value),
note=note,
freq=midi_to_freq(note), freq=midi_to_freq(note),
note=floor(note),
pitch_bend=pitch_bend,
octave=c_octave, octave=c_octave,
modifier=c_modifier, modifier=c_modifier,
kwargs=merged_options, kwargs=merged_options,

View File

@ -110,4 +110,4 @@ def cyclic_zip(first: list, second: list) -> list:
f_length = len(first) f_length = len(first)
for i in range(s_length): for i in range(s_length):
result.append([first[i % f_length], second[i]]) result.append([first[i % f_length], second[i]])
return [deepcopy(item) for sublist in result for item in sublist] return [deepcopy(item) for sublist in result for item in sublist]

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
""" Lark transformer for mapping Lark tokens to Ziffers objects """ """ Lark transformer for mapping Lark tokens to Ziffers objects """
import random
from math import log
from lark import Transformer, Token from lark import Transformer, Token
from .scale import cents_to_semitones
from .classes.root import Ziffers from .classes.root import Ziffers
from .classes.sequences import ( from .classes.sequences import (
Sequence, Sequence,
@ -337,15 +340,17 @@ class ZiffersTransformer(Transformer):
return items[0].value return items[0].value
def variable(self, items): def variable(self, items):
if len(items)>1: if len(items) > 1:
prefixes = sum_dict(items[0:-1]) prefixes = sum_dict(items[0:-1])
text_prefix = prefixes.pop("text") text_prefix = prefixes.pop("text")
return Variable(name=items[-1], text=text_prefix+items[-1], local_options=prefixes) return Variable(
name=items[-1], text=text_prefix + items[-1], local_options=prefixes
)
return Variable(name=items[0], text=items[0]) return Variable(name=items[0], text=items[0])
def variable_char(self, items): def variable_char(self, items):
"""Return parsed variable name""" """Return parsed variable name"""
return items[0].value #Variable(name=items[0].value, text=items[0].value) return items[0].value # Variable(name=items[0].value, text=items[0].value)
def variablelist(self, items): def variablelist(self, items):
"""Return list of variables""" """Return list of variables"""
@ -474,3 +479,41 @@ class ZiffersTransformer(Transformer):
wrap_start="", wrap_start="",
wrap_end=":" + items[1].text, wrap_end=":" + items[1].text,
) )
# pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name
class ScalaTransformer(Transformer):
def lines(self, items):
return cents_to_semitones(items)
def operation(self, items):
val = eval("".join(str(item) for item in items))
return 1200.0 * log(float(val), 2)
def operator(self, items):
return items[0].value
def sub_operations(self, items):
return "(" + items[0] + ")"
def number(self, items):
val = items[0]
return float(val.value)
def random(self, items):
def _parse_type(val):
if "." in val:
return float(val)
else:
return int(val)
def _rand_between(start, end):
if isinstance(start, float) or isinstance(end, float):
return random.uniform(min(start, end), max(start, end))
elif isinstance(start, int) and isinstance(end, int):
return random.randint(min(start, end), max(start, end))
start = _parse_type(items[0].value)
end = _parse_type(items[1].value)
rand_val = _rand_between(start, end)
return 1200.0 * log(float(rand_val), 2)

View File

@ -3,21 +3,43 @@ from pathlib import Path
from functools import lru_cache from functools import lru_cache
from lark import Lark from lark import Lark
from .classes.root import Ziffers from .classes.root import Ziffers
from .mapper import ZiffersTransformer from .mapper import ZiffersTransformer, ScalaTransformer
grammar_path = Path(__file__).parent grammar_path = Path(__file__).parent
grammar_folder = Path.joinpath(grammar_path, "spec") grammar_folder = Path.joinpath(grammar_path, "spec")
grammar_file = Path.joinpath(grammar_folder, "ziffers.lark") ziffers_grammar = Path.joinpath(grammar_folder, "ziffers.lark")
scala_grammar = Path.joinpath(grammar_folder, "scala.lark")
ziffers_parser = Lark.open( ziffers_parser = Lark.open(
str(grammar_file), str(ziffers_grammar),
rel_to=__file__, rel_to=__file__,
start="root", start="root",
parser="lalr", parser="lalr",
transformer=ZiffersTransformer(), transformer=ZiffersTransformer(),
) )
scala_parser = Lark.open(
str(scala_grammar),
rel_to=__file__,
start="root",
parser="lalr",
transformer=ScalaTransformer(),
)
def parse_scala(expr: str):
"""Parse an expression using the Ziffers parser
Args:
expr (str): Ziffers expression as a string
Returns:
Ziffers: Reutrns Ziffers iterable
"""
# Ignore everything before last comment "!"
values = expr.split("!")[-1]
return scala_parser.parse(values)
def parse_expression(expr: str) -> Ziffers: def parse_expression(expr: str) -> Ziffers:
"""Parse an expression using the Ziffers parser """Parse an expression using the Ziffers parser

View File

@ -2,7 +2,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# pylint: disable=locally-disabled, no-name-in-module # pylint: disable=locally-disabled, no-name-in-module
import re import re
from math import floor from math import log2, floor
from .common import repeat_text from .common import repeat_text
from .defaults import ( from .defaults import (
SCALES, SCALES,
@ -138,8 +138,7 @@ def get_scale_length(name: str) -> int:
def note_from_pc( def note_from_pc(
root: int | str, root: int | str,
pitch_class: int, pitch_class: int,
intervals: str | list[int | float], intervals: str | tuple[int | float],
cents: bool = False,
octave: int = 0, octave: int = 0,
modifier: int = 0, modifier: int = 0,
) -> int: ) -> int:
@ -160,12 +159,11 @@ def note_from_pc(
# Initialization # Initialization
root = note_name_to_midi(root) if isinstance(root, str) else root root = note_name_to_midi(root) if isinstance(root, str) else root
intervals = get_scale(intervals) if isinstance(intervals, str) else intervals intervals = get_scale(intervals) if isinstance(intervals, str) else intervals
intervals = list(map(lambda x: x / 100), intervals) if cents else intervals
scale_length = len(intervals) scale_length = len(intervals)
# Resolve pitch classes to the scale and calculate octave # Resolve pitch classes to the scale and calculate octave
if pitch_class >= scale_length or pitch_class < 0: if pitch_class >= scale_length or pitch_class < 0:
octave += floor(pitch_class / scale_length) octave += pitch_class // scale_length
pitch_class = ( pitch_class = (
scale_length - (abs(pitch_class) % scale_length) scale_length - (abs(pitch_class) % scale_length)
if pitch_class < 0 if pitch_class < 0
@ -177,7 +175,9 @@ def note_from_pc(
# Computing the result # Computing the result
note = root + sum(intervals[0:pitch_class]) note = root + sum(intervals[0:pitch_class])
return note + (octave * sum(intervals)) + modifier note = note + (octave * sum(intervals)) + modifier
return resolve_pitch_bend(note)
def parse_roman(numeral: str) -> int: def parse_roman(numeral: str) -> int:
@ -254,7 +254,7 @@ def midi_to_octave(note: int) -> int:
Returns: Returns:
int: Returns default octave in Ziffers where C4 is in octave 0 int: Returns default octave in Ziffers where C4 is in octave 0
""" """
return 0 if note <= 0 else floor(note / 12) return 0 if note <= 0 else note // 12
def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict: def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict:
@ -345,3 +345,34 @@ def named_chord_from_degree(
for interval in intervals: for interval in intervals:
notes.append(scale_degree + interval + (cur_oct * 12)) notes.append(scale_degree + interval + (cur_oct * 12))
return notes return notes
def resolve_pitch_bend(note_value: float, semitones: int=1) -> int:
"""Resolves pitch bend value from float midi note
Args:
note_value (float): Note value as float, eg. 60.41123
semitones (int, optional): Number of semitones to scale the pitch bend. Defaults to 1.
Returns:
int: Returns pitch bend value ranging from 0 to 16383. 8192 means no bend.
"""
# TODO: None or 8192
midi_bend_value = None
if isinstance(note_value, float) and note_value % 1 != 0.0:
start_value = note_value if note_value > round(note_value) else round(note_value)
end_value = round(note_value) if note_value > round(note_value) else note_value
bend_diff = midi_to_freq(start_value) / midi_to_freq(end_value)
bend_target = 1200 * log2(bend_diff)
# https://www.cs.cmu.edu/~rbd/doc/cmt/part7.html
midi_bend_value = 8192 + int(8191 * (bend_target/(100*semitones)))
return (note_value, midi_bend_value)
def cents_to_semitones(cents):
if cents[0] != 0.0:
cents = [0.0]+cents
semitone_scale = []
for i, cent in enumerate(cents[:-1]):
semitone_interval = (cents[i+1] - cent) / 100
semitone_scale.append(semitone_interval)
return tuple(semitone_scale)

18
ziffers/spec/scala.lark Normal file
View File

@ -0,0 +1,18 @@
?root: lines
lines: (number | operation)+
random: "(" SIGNED_NUMBER "," SIGNED_NUMBER ")"
operation: number (operator (number | sub_operations | operation))+
!operator: "+" | "-" | "*" | "%" | "&" | "|" | "<<" | ">>" | "/"
sub_operations: "(" operation ")"
number: SIGNED_NUMBER | random
%import common.SIGNED_NUMBER
%import common.FLOAT
%import common.INT
%import common.WORD
%import common.CNAME
%import common.WS
%import common.NEWLINE
%ignore WS