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

View File

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

View File

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

View File

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

View File

@ -110,6 +110,14 @@ class Ziffers(Sequence):
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]:
"""Return list of midi notes"""
return [

View File

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

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 """
import random
from math import log
from lark import Transformer, Token
from .scale import cents_to_semitones
from .classes.root import Ziffers
from .classes.sequences import (
Sequence,
@ -340,7 +343,9 @@ class ZiffersTransformer(Transformer):
if len(items) > 1:
prefixes = sum_dict(items[0:-1])
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])
def variable_char(self, items):
@ -474,3 +479,41 @@ class ZiffersTransformer(Transformer):
wrap_start="",
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 lark import Lark
from .classes.root import Ziffers
from .mapper import ZiffersTransformer
from .mapper import ZiffersTransformer, ScalaTransformer
grammar_path = Path(__file__).parent
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(
str(grammar_file),
str(ziffers_grammar),
rel_to=__file__,
start="root",
parser="lalr",
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:
"""Parse an expression using the Ziffers parser

View File

@ -2,7 +2,7 @@
#!/usr/bin/env python3
# pylint: disable=locally-disabled, no-name-in-module
import re
from math import floor
from math import log2, floor
from .common import repeat_text
from .defaults import (
SCALES,
@ -138,8 +138,7 @@ def get_scale_length(name: str) -> int:
def note_from_pc(
root: int | str,
pitch_class: int,
intervals: str | list[int | float],
cents: bool = False,
intervals: str | tuple[int | float],
octave: int = 0,
modifier: int = 0,
) -> int:
@ -160,12 +159,11 @@ def note_from_pc(
# Initialization
root = note_name_to_midi(root) if isinstance(root, str) else root
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)
# Resolve pitch classes to the scale and calculate octave
if pitch_class >= scale_length or pitch_class < 0:
octave += floor(pitch_class / scale_length)
octave += pitch_class // scale_length
pitch_class = (
scale_length - (abs(pitch_class) % scale_length)
if pitch_class < 0
@ -177,7 +175,9 @@ def note_from_pc(
# Computing the result
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:
@ -254,7 +254,7 @@ def midi_to_octave(note: int) -> int:
Returns:
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:
@ -345,3 +345,34 @@ def named_chord_from_degree(
for interval in intervals:
notes.append(scale_degree + interval + (cur_oct * 12))
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