Added parsing for monzos and support for escaped pitch_classes

Syntax for monzos supported in scala scales: [-1 1> etc.

Support for escaped pitches: {q12 e23 26}
This commit is contained in:
2023-03-16 22:29:24 +02:00
parent 882a9a7b4b
commit 5d122a90e0
11 changed files with 284 additions and 102 deletions

View File

@ -7,6 +7,7 @@ from ziffers import zparse
[
("1 2 3", [[1, 2, 3], [0.25,0.25,0.25]]),
("q2 eq3 e.4", [[2, 3, 4], [0.25,0.375,0.1875]]),
("{q9 e10 23}", [[9,10,23],[0.25,0.125,0.25]])
],
)
def test_multi_var(pattern: str, expected: list):

View File

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

View File

@ -1,7 +1,6 @@
""" Ziffers item classes """
from dataclasses import dataclass, field, asdict
from math import floor
import operator
import random
from ..scale import (
note_from_pc,
@ -149,6 +148,26 @@ class Event(Item):
class Rest(Event):
"""Class for rests"""
def get_note(self):
"""Getter for note"""
return None
def get_freq(self):
"""Getter for freq"""
return None
def get_octave(self):
"""Getter for octave"""
return None
def get_pitch_class(self):
"""Getter for pitche"""
return None
def get_pitch_bend(self):
"""Getter for pitche"""
return None
@dataclass
class Measure(Item):
@ -283,9 +302,16 @@ class RandomPitch(Event):
Returns:
int: Returns random pitch
"""
return random.randint(
0, get_scale_length(options.get("scale", "Major")) if options else 9
)
if options:
scale = options["scale"]
if isinstance(scale, str):
scale_length = get_scale_length(options.get("scale", "Major"))
else:
scale_length = len(scale)
else:
scale_length = 9
return random.randint(0, scale_length)
@dataclass(kw_only=True)
@ -604,14 +630,6 @@ class Operator(Item):
value: ...
@dataclass(kw_only=True)
class Operation(Item):
"""Class for lisp-like operations: (+ 1 2 3) etc."""
values: list
operator: operator
@dataclass(kw_only=True)
class Atom(Item):
"""Class for evaluable atoms"""

View File

@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from itertools import islice, cycle
from ..defaults import DEFAULT_OPTIONS
from .items import Item, Pitch, Chord, Event
from .items import Item, Pitch, Chord, Event, Rest
from .sequences import Sequence, Subdivision
@ -32,7 +32,7 @@ class Ziffers(Sequence):
def __len__(self):
return len(self.evaluated_values)
def __iter__(self):
return self
@ -107,15 +107,15 @@ class Ziffers(Sequence):
return [
val.get_pitch_class()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord))
if isinstance(val, (Pitch, Chord, Rest))
]
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))
if isinstance(val, (Pitch, Chord, Rest))
]
def notes(self) -> list[int]:
@ -123,7 +123,7 @@ class Ziffers(Sequence):
return [
val.get_note()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord))
if isinstance(val, (Pitch, Chord, Rest))
]
def durations(self) -> list[float]:
@ -133,10 +133,12 @@ class Ziffers(Sequence):
for val in self.evaluated_values
if isinstance(val, Event)
]
def total_duration(self) -> float:
"""Return total duration"""
return sum([val.duration for val in self.evaluated_values if isinstance(val, Event)])
return sum(
[val.duration for val in self.evaluated_values if isinstance(val, Event)]
)
def total_beats(self) -> float:
"""Return total beats"""
@ -161,7 +163,7 @@ class Ziffers(Sequence):
return [
val.get_octave()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord))
if isinstance(val, (Pitch, Chord, Rest))
]
def freqs(self) -> list[int]:
@ -169,9 +171,9 @@ class Ziffers(Sequence):
return [
val.get_freq()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord))
if isinstance(val, (Pitch, Chord, Rest))
]
def collect(self, num: int = None, keys: str | list = None) -> list:
"""Collect n items from parsed Ziffers"""
if num is None:

View File

@ -4,8 +4,9 @@ from itertools import product
from math import floor
from types import LambdaType
from copy import deepcopy
import operator
from ..defaults import DEFAULT_OPTIONS
from ..common import cyclic_zip, euclidian_rhythm
from ..common import cyclic_zip, euclidian_rhythm, flatten
from ..scale import note_from_pc, midi_to_freq
from .items import (
Meta,
@ -31,7 +32,7 @@ from .items import (
Modification,
Whitespace,
Sample,
SampleList
SampleList,
)
@ -46,6 +47,8 @@ def resolve_item(item: Meta, options: dict):
elif isinstance(item, Subdivision):
item.evaluate_values(options)
yield item
elif isinstance(item, Eval):
yield from item.evaluate_values(options)
else:
yield from item.evaluate_tree(options)
elif isinstance(item, VariableAssignment):
@ -66,14 +69,14 @@ def resolve_item(item: Meta, options: dict):
run=opt_item,
text=item.text,
kwargs=(options | item.local_options),
local_options=item.local_options
local_options=item.local_options,
)
elif isinstance(opt_item, str):
yield Sample(
name=opt_item,
text=item.text,
kwargs=(options | item.local_options),
local_options=item.local_options
local_options=item.local_options,
)
variable = deepcopy(opt_item)
yield from resolve_item(variable, options)
@ -90,7 +93,7 @@ def resolve_item(item: Meta, options: dict):
run=opt_item,
text=var.text,
kwargs=(options | var.local_options),
local_options=var.local_options
local_options=var.local_options,
)
)
elif isinstance(opt_item, str):
@ -99,7 +102,7 @@ def resolve_item(item: Meta, options: dict):
name=opt_item,
text=var.text,
kwargs=(options | var.local_options),
local_options=var.local_options
local_options=var.local_options,
)
)
elif isinstance(opt_item, Sequence):
@ -343,7 +346,7 @@ class Subdivision(Sequence):
if isinstance(item, Event):
if duration is not None:
item.duration = new_d
item.beat = new_d*4
item.beat = new_d * 4
yield item
@ -373,6 +376,8 @@ class ListOperation(Sequence):
flattened_list.extend(list(item.evaluate_durations()))
elif isinstance(item, RepeatedListSequence):
flattened_list.extend(list(item.resolve_repeat(options)))
elif isinstance(item, Eval):
flattened_list.extend(item.evaluate_values(options))
else:
flattened_list.append(_filter_operation(item, options))
elif isinstance(item, Cyclic):
@ -543,13 +548,43 @@ class ListOperation(Sequence):
class Eval(Sequence):
"""Class for evaluation notation"""
result: ... = None
wrap_start: str = field(default="{", repr=False)
wrap_end: str = field(default="}", repr=False)
def __post_init__(self):
super().__post_init__()
self.result = eval(self.text)
# def __post_init__(self):
# self.text = "".join([val.text for val in flatten(self.values)])
# super().__post_init__()
def evaluate_values(self, options):
operations = [val for val in self.values if isinstance(val, (Operation, Rest))]
eval_values = []
for val in operations:
if isinstance(val,Operation):
eval_values.append(Pitch(pitch_class=val.evaluate(), kwargs=options | val.local_options))
else:
eval_values.append(val)
self.evaluated_values = eval_values
return self.evaluated_values
@dataclass(kw_only=True)
class LispOperation(Sequence):
"""Class for lisp-like operations: (+ 1 2 3) etc."""
values: list
operator: operator
@dataclass(kw_only=True)
class Operation(Sequence):
"""Class for sequential operations"""
values: list
def evaluate(self):
return eval(self.text)
@dataclass(kw_only=True)
@ -588,6 +623,8 @@ class RepeatedSequence(Sequence):
elif isinstance(item, Subdivision):
item.evaluate_values(options)
yield item
elif isinstance(item, Eval):
yield from item.evaluate_values(options)
else:
yield from item
elif isinstance(item, Cyclic):

View File

@ -2,6 +2,7 @@
import re
from copy import deepcopy
def flatten(arr: list) -> list:
"""Flattens array"""
return (
@ -23,14 +24,16 @@ def rotate(arr, k):
arr = arr[-k:] + arr[:-k]
return arr
def repeat_text(pos,neg,times):
def repeat_text(pos, neg, times):
"""Helper to repeat text"""
if times>0:
return pos*times
if times<0:
return neg*abs(times)
if times > 0:
return pos * times
if times < 0:
return neg * abs(times)
return ""
def sum_dict(arr: list[dict]) -> dict:
"""Sums a list of dicts: [{a:3,b:3},{b:1}] -> {a:3,b:4}"""
result = arr[0]
@ -110,4 +113,4 @@ def cyclic_zip(first: list, second: list) -> list:
f_length = len(first)
for i in range(s_length):
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]

35
ziffers/generators.py Normal file
View File

@ -0,0 +1,35 @@
"""Collection of generators"""
# Sieve of Eratosthenes
# Based on code by David Eppstein, UC Irvine, 28 Feb 2002
# http://code.activestate.com/recipes/117119/
def gen_primes():
"""Generate an infinite sequence of prime numbers."""
# Maps composites to primes witnessing their compositeness.
# This is memory efficient, as the sieve is not "run forward"
# indefinitely, but only as long as required by the current
# number being tested.
sieve = {}
# The running integer that's checked for primeness
current = 2
while True:
if current not in sieve:
# current is a new prime.
# Yield it and mark its first multiple that isn't
# already marked in previous iterations
yield current
sieve[current * current] = [current]
else:
# current is composite. sieve[current] is the list of primes that
# divide it. Since we've reached current, we no longer
# need it in the map, but we'll mark the next
# multiples of its witnesses to prepare for larger
# numbers
for composite in sieve[current]:
sieve.setdefault(composite + current, []).append(composite)
del sieve[current]
current += 1

View File

@ -1,8 +1,7 @@
""" Lark transformer for mapping Lark tokens to Ziffers objects """
import random
from math import log, pow
from lark import Transformer, Token
from .scale import cents_to_semitones, ratio_to_cents
from .scale import cents_to_semitones, ratio_to_cents, monzo_to_cents
from .classes.root import Ziffers
from .classes.sequences import (
Sequence,
@ -13,6 +12,8 @@ from .classes.sequences import (
Euclid,
Subdivision,
Eval,
Operation,
LispOperation,
)
from .classes.items import (
Whitespace,
@ -29,7 +30,6 @@ from .classes.items import (
RandomInteger,
Range,
Operator,
Operation,
Atom,
Integer,
VariableAssignment,
@ -138,6 +138,11 @@ class ZiffersTransformer(Transformer):
text_value = items[0].value.replace("T", "10").replace("E", "11")
return {"pitch_class": int(text_value), "text": items[0].value}
def escaped_pitch(self, items):
"""Return escaped pitch"""
val = items[0].value[1:-1]
return {"pitch_class": int(val), "text": val}
def prefix(self, items):
"""Return prefix"""
return items[0]
@ -305,8 +310,7 @@ class ZiffersTransformer(Transformer):
def eval(self, items):
"""Parse eval"""
val = items[0]
return Eval(values=val)
return Eval(values=items)
def sub_operations(self, items):
"""Returns list of operations"""
@ -314,12 +318,16 @@ class ZiffersTransformer(Transformer):
def operation(self, items):
"""Return partial eval operations"""
return flatten(items)
if isinstance(items[0], dict):
local_opts = items[0]
del local_opts["text"]
return Operation(values=flatten(items[1:]), local_options=items[0])
return Operation(values=flatten(items))
def atom(self, token):
"""Return partial eval item"""
val = token[0].value
return Atom(value=val, text=val)
return Atom(value=val, text=str(val))
# Variable assignment
@ -340,6 +348,7 @@ class ZiffersTransformer(Transformer):
return items[0].value
def variable(self, items):
"""Return variable"""
if len(items) > 1:
prefixes = sum_dict(items[0:-1])
text_prefix = prefixes.pop("text")
@ -405,9 +414,10 @@ class ZiffersTransformer(Transformer):
)
return seq
def NUMBER(self, token):
"""Parse integer"""
val = token.value
def integer(self, items):
"""Parses integer from single ints"""
concatted = sum_dict(items)
val = concatted["text"]
return Integer(text=val, value=int(val))
def number(self, item):
@ -422,7 +432,7 @@ class ZiffersTransformer(Transformer):
"""Parse lisp like list operation"""
op = items[0]
values = items[1:]
return Operation(
return LispOperation(
operator=op,
values=values,
text="(+" + "".join([v.text for v in values]) + ")",
@ -433,6 +443,11 @@ class ZiffersTransformer(Transformer):
val = token[0].value
return Operator(text=val, value=OPERATORS[val])
def list_operator(self, token):
"""Parse list operator"""
val = token[0].value
return Operator(text=val, value=OPERATORS[val])
def list_items(self, items):
"""Parse sequence"""
return Sequence(values=items)
@ -483,44 +498,67 @@ class ZiffersTransformer(Transformer):
# pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name, eval-used
class ScalaTransformer(Transformer):
"""Transformer for scala scales"""
def lines(self, items):
cents = [ratio_to_cents(item) if isinstance(item,int) else item for item in items]
"""Transforms cents to semitones"""
cents = [
ratio_to_cents(item) if isinstance(item, int) else item for item in items
]
return cents_to_semitones(cents)
def operation(self, items):
"""Get operation"""
# Safe eval. Items are pre-parsed.
val = eval("".join(str(item) for item in items))
return val
def operator(self, items):
"""Get operator"""
return items[0].value
def sub_operations(self, items):
"""Get sub-operation"""
return "(" + items[0] + ")"
def ratio(self, items):
ratio = items[0]/items[1]
def frac_ratio(self, items):
"""Get ration as fraction"""
ratio = items[0] / items[1]
return ratio_to_cents(ratio)
def decimal_ratio(self, items):
"""Get ratio as decimal"""
ratio = float(str(items[0]) + "." + str(items[1]))
return ratio_to_cents(ratio)
def monzo(self, items):
"""Get monzo ratio"""
return monzo_to_cents(items)
def edo_ratio(self, items):
ratio = pow(2,items[0]/items[1])
"""Get EDO ratio"""
ratio = pow(2, items[0] / items[1])
return ratio_to_cents(ratio)
def edji_ratio(self, items):
if len(items)>3:
power = items[2]/items[3]
"""Get EDJI ratio"""
if len(items) > 3:
power = items[2] / items[3]
else:
power = items[2]
ratio = pow(power,items[0]/items[1])
ratio = pow(power, items[0] / items[1])
return ratio_to_cents(ratio)
def int(self, items):
"""Get integer"""
return int(items[0].value)
def float(self, items):
"""Get float"""
return float(items[0].value)
def random_int(self, items):
"""Get random integer"""
def _rand_between(start, end):
return random.randint(min(start, end), max(start, end))
@ -529,8 +567,9 @@ class ScalaTransformer(Transformer):
end = items[1]
rand_val = _rand_between(start, end)
return rand_val
def random_decimal(self, items):
"""Get random decimal"""
def _rand_between(start, end):
return random.uniform(min(start, end), max(start, end))

View File

@ -2,7 +2,9 @@
#!/usr/bin/env python3
# pylint: disable=locally-disabled, no-name-in-module
import re
from math import log2, floor, log
from math import log2
from itertools import islice
from .generators import gen_primes
from .common import repeat_text
from .defaults import (
SCALES,
@ -70,7 +72,7 @@ def note_name_to_midi(name: str) -> int:
return 12 + octave * 12 + interval + modifier
def get_scale(name: str) -> list[int]:
def get_scale(scale: str) -> list[int]:
"""Get a scale from the global scale list
Args:
@ -79,7 +81,10 @@ def get_scale(name: str) -> list[int]:
Returns:
list: List of intervals in the scale
"""
scale = SCALES.get(name.lower().capitalize(), SCALES["Ionian"])
if isinstance(scale, (list, tuple)):
return scale
scale = SCALES.get(scale.lower().capitalize(), SCALES["Ionian"])
return scale
@ -102,7 +107,11 @@ def get_scale_notes(name: str, root: int = 60, num_octaves: int = 1) -> list[int
def get_chord_from_scale(
degree: int, root: int = 60, scale: str = "Major", num_notes: int = 3, skip: int = 2
degree: int,
root: int = 60,
scale: str | tuple = "Major",
num_notes: int = 3,
skip: int = 2,
) -> list[int]:
"""Generate chord from the scale by skipping notes
@ -116,12 +125,17 @@ def get_chord_from_scale(
Returns:
list[int]: List of midi notes
"""
num_of_octaves = ((num_notes * skip + degree) // get_scale_length(scale)) + 1
if isinstance(scale, str):
scale_length = get_scale_length(scale)
else:
scale_length = len(scale)
num_of_octaves = ((num_notes * skip + degree) // scale_length) + 1
scale_notes = get_scale_notes(scale, root, num_of_octaves)
return scale_notes[degree - 1 :: skip][:num_notes]
def get_scale_length(name: str) -> int:
def get_scale_length(scale: str) -> int:
"""Get length of the scale
Args:
@ -130,8 +144,10 @@ def get_scale_length(name: str) -> int:
Returns:
int: Length of the scale
"""
scale = SCALES.get(name.lower().capitalize(), SCALES["Ionian"])
return len(scale)
if isinstance(scale, (list, tuple)):
return len(scale)
return len(SCALES.get(scale.lower().capitalize(), SCALES["Ionian"]))
# pylint: disable=locally-disabled, too-many-arguments
@ -271,9 +287,9 @@ def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict:
Returns:
tuple: Returns dict containing pitch-class values
"""
pitch_class = note % 12
pitch_class = int(note % 12) # Cast to int "fixes" microtonal scales
octave = midi_to_octave(note) - 5
if scale.upper() == "CHROMATIC":
if isinstance(scale, str) and scale.upper() == "CHROMATIC":
return {"text": str(pitch_class), "pitch_class": pitch_class, "octave": octave}
sharps = ["0", "#0", "1", "#1", "2", "3", "#3", "4", "#4", "5", "#5", "6"]
@ -319,7 +335,11 @@ def chord_from_degree(
"""
root = note_name_to_midi(root) if isinstance(root, str) else root
if name is None and scale.lower().capitalize() == "Chromatic":
if (
name is None
and isinstance(scale, str)
and scale.lower().capitalize() == "Chromatic"
):
name = "major"
if name:
@ -387,6 +407,32 @@ def cents_to_semitones(cents: list) -> tuple[float]:
semitone_scale.append(semitone_interval)
return tuple(semitone_scale)
def ratio_to_cents(ratio: float) -> float:
"""Transform ratio to cents"""
return 1200.0 * log(float(ratio), 2)
return 1200.0 * log2(float(ratio))
def monzo_to_cents(monzo) -> float:
"""
Convert a monzo to cents using the prime factorization method.
Args:
monzo (list): A list of integers representing the exponents of the prime factorization
Returns:
float: The value in cents
"""
# Calculate the prime factors of the indices in the monzo
max_index = len(monzo)
primes = list(islice(gen_primes(), max_index + 1))
# Product of the prime factors raised to the corresponding exponents
ratio = 1
for i in range(max_index):
ratio *= primes[i] ** monzo[i]
# Frequency ratio to cents
cents = 1200 * log2(ratio)
return cents

View File

@ -1,20 +1,24 @@
?root: lines
lines: (number | operation | ratio | edo_ratio | edji_ratio)+
lines: (number | ratio | monzo | operation)+
operation: (number | ratio) (operator ((number | ratio) | sub_operations | operation))+
ratio: (int | random_int) "/" (int | random_int)
edo_ratio: (int | random_int) "\\" (int | random_int)
edji_ratio: (int | random_int) "\\" (int | random_int) "<" (int | random_int) "/"? (int | random_int)? ">"
!operator: "+" | "-" | "*" | "%" | "&" | "|" | "<<" | ">>"
sub_operations: "(" operation ")"
// Signed number without EXP
?number: float | int | random_int | random_float
random_int: "(" int "," int ")"
random_float: "(" float "," float ")"
float: /(-?[0-9]+\.[0-9]*)|(\.[0-9]+)/
int: /[0-9]+/
int: /-?[0-9]+/
?ratio: frac_ratio | edo_ratio | edji_ratio | decimal_ratio
frac_ratio: (int | random_int) "/" (int | random_int)
edo_ratio: (int | random_int) "\\" (int | random_int)
edji_ratio: (int | random_int) "\\" (int | random_int) "<" (int | random_int) "/"? (int | random_int)? ">"
decimal_ratio: int "," int
monzo: "[" int+ ">"
operation: (number | ratio | monzo) (operator ((number | ratio | monzo) | sub_operations | operation))+
!operator: "+" | "-" | "*" | "%" | "&" | "|" | "<<" | ">>"
sub_operations: "(" operation ")"
%import common.WS
%ignore WS

View File

@ -3,7 +3,7 @@
sequence: (pitch_class | repeat_item | assignment | variable | variablelist | rest | dur_change | oct_mod | oct_change | WS | measure | chord | named_roman | cycle | random_integer | random_pitch | random_percent | range | list | repeated_list | lisp_operation | list_op | subdivision | eval | euclid | repeat)*
// Pitch classes
pitch_class: prefix* pitch
pitch_class: prefix* (pitch | escaped_pitch)
prefix: (octave | duration_chars | escaped_decimal | escaped_octave | modifier)
pitch: /-?[0-9TE]/
escaped_decimal: "<" decimal ">"
@ -39,10 +39,9 @@
invert: /%-?[0-9][0-9]*/
// Valid as integer
number: NUMBER | random_integer | cycle
// CYCLIC NUMBERS NOT IN USE. NUMBERS MUST BE VALIDATED FROM FULL CYCLES!
// cyclic_number: "<" number (WS number)* ">"
number: integer | random_integer | cycle
integer: pitch+
escaped_pitch: /{-?[0-9]+}/
// Repeats
repeat: "[:" sequence ":" [number] "]"
@ -53,10 +52,12 @@
repeated_list: prefix* "(:" sequence ":" [number] ")"
// Right recursive list operation
list_op: list (operator right_op)+
list_op: list ((operator | list_operator) right_op)+
right_op: list | number
//operator: "+" | "-" | "*" | "%" | "&" | "|" | "<<" | ">>" | "@" | "#"
operator: /([\+\-\*\/%\|\&]|<<|>>|@|#|<>)/
// Operators that work only on lists: | << >>
list_operator: /(\||<<|>>|<>|#|@)(?=[(\d])/
// Common operators that works with numbers 3+5 3-5 etc.
operator: /([\+\-\*\/%\&])/
// Euclidean cycles
// TODO: Support randomization etc.
@ -88,13 +89,9 @@
random_percent: /(%)(?!\d)/
// Rules for evaluating clauses inside {}
// TODO: Support for parenthesis?
eval: "{" operation+ "}"
operation: atom (operator (sub_operations | operation))*
eval: "{" ((operation | rest) WS?)+ "}"
operation: prefix? atom (operator (sub_operations | operation))*
sub_operations: "(" operation ")"
atom: (NUMBER | DECIMAL)
atom: number
%import common.NUMBER
//%import common.SIGNED_NUMBER
%import common.DECIMAL
%import common.WS