Merge pull request #1 from Bubobubobubobubo/lark

Lark implementation
This commit is contained in:
Raphaël Forment
2023-02-12 14:59:28 +01:00
committed by GitHub
22 changed files with 3438 additions and 132 deletions

170
.gitignore vendored Normal file
View File

@ -0,0 +1,170 @@
# Byte-compiled / optimized / DLL files
**/__pycache__/
*.py[cod]
*$py.class
# Pytest
.pytest_cache
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# VSCode
.vscode
# Debugging file
debug.py

View File

@ -1,5 +1,3 @@
MIT License
Copyright (c) [2022] [Raphaël Forment]
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

67
main.py
View File

@ -1,39 +1,42 @@
from ziffers import *
from rich import print
# FIXME: TO BE REMOVED / CHANGED TO TEST?
if __name__ == "__main__":
expressions = {
'Pitches': "-2 -1 0 1 2",
'Chords': "0 024 2 246",
'Note lengths': "w 0 h 1 q 2 e 3 s 4",
'Subdivision': "[1 2 [3 4]]",
'Decimal durations': "0.25 0 1 [0.333]2 3",
'Octaves': "^ 0 ^ 1 _ 2 _ 3",
'Escaped octave': "<2> 1 <1>1<-2>3",
'Roman chords': "i ii iii+4 iv+5 v+8 vi+10 vii+20",
'Named chords': "i^7 i^min i^dim i^maj7",
'Modal interchange (a-g)': "iiia ig ivf^7",
'Escape/eval': "{10 11} {1.2 2.43} {3+1*2}",
'Randoms': "% ? % ? % ?",
'Random between': "(-3,6)",
'Random selections': "[q 1 2, q 3 e 4 6]",
'Repeat': "[: 1 (2,6) 3 :4]",
'Repeat cycles': "[: <q e> (1,4) <(2 3) (3 (1,7))> :]",
'Lists': "h 1 q(0 1 2 3) 2",
'List cycles': "(: <q e> (1,4) <(2 3) (3 (1,7))> :)",
'Loop cycles (for zloop or z0-z9)': "<0 <1 <2 <3 <4 5>>>>>",
'Basic operations': "(1 2 (3 4)+2)*2 ((1 2 3)+(0 9 13))-2 ((3 4 {10})*(2 9 3))%7",
'Product operations': "(0 1 2 3)+(1 4 2 3) (0 1 2)-(0 2 1)+2",
'Euclid cycles': "(q1)<6,7>(q4 (e3 e4) q2) or (q1)<6,7<(q4 q3 q2)",
'Transformations': "(0 1 2)<r> (0 1 2)<i>(-2 1)",
'List assignation': "A=(0 (1,6) 3) B=(3 ? 2) B A B B A",
'Random repeat': "(: 1 (2,6) 3 :4)",
'Conditionals': "1 {%<0.5?3} 3 4 (: 1 2 {%<0.2?3:2} :3)",
'Functions': "(0 1 2 3){x%3==0?x-2:x+2}",
'Polynomials': "(-10..10){(x**3)*(x+1)%12}",
"Pitches": "-2 -1 0 1 2",
"Chords": "0 024 2 246",
"Note lengths": "w 0 h 1 q 2 e 3 s 4",
"Subdivision": "[1 2 [3 4]]",
"Decimal durations": "0.25 0 1 <0.333>2 3",
"Octaves": "^ 0 ^ 1 _ 2 _ 3",
"Escaped octave": "<2> 1 <1>1<-2>3",
"Roman chords": "i ii iii+4 iv+5 v+8 vi+10 vii+20",
"Named chords": "i^7 i^min i^dim i^maj7",
"Modal interchange (a-g)": "iiia ig ivf^7",
"Escape/eval": "{10 11} {1.2 2.43} {3+1*2}",
"Randoms": "% ? % ? % ?",
"Random between": "(-3,6)",
"Random selections": "[q 1 2, q 3 e 4 6]",
"Repeat": "[: 1 (2,6) 3 :4]",
"Repeat cycles": "[: <q e> (1,4) <(2 3) (3 (1,7))> :]",
"Lists": "h 1 q(0 1 2 3) 2",
"List cycles": "(: <q e> (1,4) <(2 3) (3 (1,7))> :)",
"Loop cycles (for zloop or z0-z9)": "<0 <1 <2 <3 <4 5>>>>>",
"Basic operations": "(1 2 (3 4)+2)*2 ((1 2 3)+(0 9 13))-2 ((3 4 {10})*(2 9 3))%7",
"Product operations": "(0 1 2 3)+(1 4 2 3) (0 1 2)-(0 2 1)+2",
"Euclid cycles": "(q1)<6,7>(q4 (e3 e4) q2) (q1)<6,7>(q4 q3 q2)",
"Transformations": "(0 1 2)<r> (0 1 2)<i>(-2 1)",
"List assignation": "A=(0 (1,6) 3) B=(3 ? 2) B A B B A",
"Random repeat": "(: 1 (2,6) 3 :4)",
"Conditionals": "1 {%<0.5?3} 3 4 (: 1 2 {%<0.2?3:2} :3)",
"Functions": "(0 1 2 3){x%3==0?x-2:x+2}",
"Polynomials": "(-10..10){(x**3)*(x+1)%12}",
}
for expression in expressions:
try:
parse_expression(expression)
for ex in expressions:
try:
print("Parsed: " + parse_expression(expressions[ex]).text)
except Exception as e:
print(f"[red]Failed on {expression}[/red]: {str(e)[0:40]}...")
print(f"[red]Failed on {ex}[/red]")
# print(f"[red]Failed on {ex}[/red]: {str(e)[0:40]}...")

View File

@ -4,7 +4,7 @@ version = "0.1.0"
description = "Port of the Ziffers numerical notation for Python"
authors = [
{name = "Raphaël Forment", email="raphael.forment@gmail.com"},
{email = "raphael.forment@gmail.com"}
{name = "Miika Alonen", email = "amiika@gmail.com"}
]
license = {file = "LICENSE.txt"}
readme = "README.md"
@ -17,8 +17,9 @@ classifiers = [
]
dependencies = [
"parsimonious>=0.10.0",
"rich>=12.6.0"
"lark>=1.1.5",
"rich>=12.6.0",
"pytest>=7.2.1",
]
[project.urls]
@ -30,4 +31,3 @@ repository = "https://github.com/Bubobubobubobubo/ziffers-python"
line-length = 88
target_version = ['py310']
include = '\.pyi?$'
exclude = '''

17
repl.py
View File

@ -1,16 +1,19 @@
from ziffers import *
from rich import print
""" Simple REPL for testing Ziffers notation """
# pylint: disable=locally-disabled, redefined-builtin, broad-except
EXIT_CONDITION = ('exit', 'quit', '')
from rich import print
from ziffers import parse_expression
import readline
EXIT_CONDITION = ("exit", "quit", "")
if __name__ == "__main__":
print(f"[purple]== ZIFFERS REPL ==[/purple]")
print("[purple]== ZIFFERS REPL ==[/purple]")
while True:
expr = input('> ')
expr = input("> ")
if expr not in EXIT_CONDITION:
try:
result = parse_expression(expr)
print(result)
print(parse_expression(expr))
except Exception as e:
print(f"[red]Failed:[/red] {e}")
else:

4
test.py Normal file
View File

@ -0,0 +1,4 @@
from ziffers import *
# a = z1("q e 1 ^ ^ 2 _ 3 4 <3> 3")
a = z1("1 2")
print(a.take(3))

0
tests/__init__.py Normal file
View File

54
tests/test_parser.py Normal file
View File

@ -0,0 +1,54 @@
""" Test cases for the parser """
import pytest
from ziffers import parse_expression
def test_can_parse():
expressions = [
"[1 [2 3]]",
"(1 (1,3) 1..3)",
"_^ q _qe^3 qww_4 _123 <1 2>",
"q _2 _ 3 ^ 343",
"2 qe2 e4",
"q 2 <3 343>",
"q (2 <3 343 (3 4)>)",
]
results = []
for expression in expressions:
try:
print(f"Parsing expression: {expression}")
result = parse_expression(expression)
results.append(True)
except Exception as e:
print(e)
results.append(False)
# Return true if all the results are true (succesfully parsed)
print(results)
assert all(results)
@pytest.mark.parametrize(
"pattern",
[
"1 2 3",
"q3 e4 s5",
],
)
def test_parsing_text(pattern: str):
assert parse_expression(pattern).text == pattern
@pytest.mark.parametrize(
"pattern,expected",
[
("1 2 3", [1, 2, 3]),
("q2 eq3", [2, 3]),
],
)
def test_pitch_classes(pattern: str, expected: list):
assert parse_expression(pattern).pitch_classes() == expected
# TODO: Add tests for octaves
# ("__6 _0 _1 _2 _3 _4 _5 _6 0 1 2 3 4 5 6 ^0 ^1 ^2 ^3 ^4 ^5 ^6 ^^0", [-2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2]),
# ("_ 1 _2 <3>3 ^^4", [-1,-2,3,-1]),

53
tests/test_scale.py Normal file
View File

@ -0,0 +1,53 @@
""" Tests for the scale module """
import pytest
from ziffers import scale
@pytest.mark.parametrize(
"name,expected",
[
("C4", 60),
("A1", 33),
("Bb3", 58),
("C#1", 25),
("foo", 60),
],
)
def test_notenames(name: str, expected: int):
assert scale.note_name_to_midi(name) == expected
@pytest.mark.parametrize(
"pitch_classes,expected",
[
(
list(range(-9, 10)),
[
45,
47,
48,
50,
52,
53,
55,
57,
59,
60,
62,
64,
65,
67,
69,
71,
72,
74,
76,
],
),
],
)
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
] == expected

View File

@ -1 +1,6 @@
from .parser import *
from .mapper import *
from .classes import *
from .common import *
from .defaults import *
from .scale import *

676
ziffers/classes.py Normal file
View File

@ -0,0 +1,676 @@
""" Ziffers classes for the parsed notation """
from dataclasses import dataclass, field, replace
import itertools
import operator
import random
from .defaults import DEFAULT_OPTIONS
from .scale import note_from_pc, midi_to_pitch_class
@dataclass(kw_only=True)
class Meta:
"""Abstract class for all Ziffers items"""
kwargs: dict = field(default=None, repr=False)
def __post_init__(self):
if self.kwargs:
for key, val in self.kwargs.items():
setattr(self, key, val)
def update(self, new_values):
"""Update attributes from dict"""
for key, value in new_values.items():
if hasattr(self, key):
setattr(self, key, value)
def update_new(self, new_values):
"""Updates new attributes from dict"""
for key, value in new_values.items():
if hasattr(self, key):
if getattr(self, key) is None:
setattr(self, key, value)
@dataclass(kw_only=True)
class Item(Meta):
"""Class for all Ziffers text based items"""
text: str = field(default=None)
def get_item(self):
"""Return the item"""
return self
@dataclass(kw_only=True)
class Whitespace:
"""Class for whitespace"""
text: str
item_type: str = field(default=None, repr=False, init=False)
def get_item(self):
"""Returns None. Used in filtering"""
return None
@dataclass(kw_only=True)
class DurationChange(Item):
"""Class for changing duration"""
value: float
key: str = field(default="duration", repr=False, init=False)
item_type: str = field(default="change", repr=False, init=False)
@dataclass
class OctaveChange(Item):
"""Class for changing octave"""
value: int
key: str = field(default="octave", repr=False, init=False)
item_type: str = field(default="change", repr=False, init=False)
@dataclass(kw_only=True)
class OctaveAdd(Item):
"""Class for modifying octave"""
value: int
key: str = field(default="octave", repr=False, init=False)
item_type: str = field(default="add", repr=False, init=False)
@dataclass(kw_only=True)
class Event(Item):
"""Abstract class for events with duration"""
duration: float = field(default=None)
@dataclass(kw_only=True)
class Pitch(Event):
"""Class for pitch in time"""
pitch_class: int
octave: int = field(default=None)
modifier: int = field(default=0)
note: int = field(default=None)
key: str = field(default=None)
scale: str | list = field(default=None)
def __post_init__(self):
super().__post_init__()
if self.text is None:
self.text = str(self.pitch_class)
self.update_note()
def update_note(self):
"""Update note if Key, Scale and Pitch-class is present"""
if (
(self.key is not None)
and (self.scale is not None)
and (self.pitch_class is not None)
and (self.note is None)
):
note = 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.set_note(note)
def set_note(self, note: int) -> int:
"""Sets a note for the pitch and returns the note.
Args:
note (int): Midi note
Returns:
int: Returns the saved note
"""
self.note = note
return note
# pylint: disable=locally-disabled, unused-argument
def get_value(self) -> int:
"""Returns the pitch class
Returns:
int: Integer value for the pitch
"""
return self.pitch_class
@dataclass(kw_only=True)
class RandomPitch(Event):
"""Class for random pitch"""
pitch_class: int = field(default=None)
# pylint: disable=locally-disabled, unused-argument
def get_value(self) -> int:
"""Return random value
Returns:
int: Returns random pitch
"""
return self.pitch_class
@dataclass(kw_only=True)
class RandomPercent(Item):
"""Class for random percent"""
percent: float = field(default=None)
@dataclass(kw_only=True)
class Chord(Event):
"""Class for chords"""
pitch_classes: list[Pitch] = field(default=None)
notes: list[int] = field(default=None)
def set_notes(self, notes: list[int]):
"""Set notes to the class"""
self.notes = notes
@dataclass(kw_only=True)
class RomanNumeral(Event):
"""Class for roman numbers"""
value: str = field(default=None)
chord_type: str = field(default=None)
notes: list[int] = field(default_factory=[])
pitch_classes: list = None
def set_notes(self, chord_notes: list[int]):
"""Set notes to roman numeral
Args:
chord_notes (list[int]): List of notes in midi to be added
"""
self.notes = chord_notes
def set_pitch_classes(self, pitches: list[tuple]):
"""Set pitch classes to roman numeral
Args:
pitches (list[tuple]): Pitch classes to be added
"""
if self.pitch_classes is None:
self.pitch_classes = []
for pitch in pitches:
self.pitch_classes.append(Pitch(**pitch))
@dataclass(kw_only=True)
class Function(Event):
"""Class for functions"""
run: str = field(default=None)
@dataclass(kw_only=True)
class Sequence(Meta):
"""Class for sequences of items"""
values: list
text: str = field(default=None)
wrap_start: str = field(default=None, repr=False)
wrap_end: str = field(default=None, repr=False)
local_index: int = field(default=0, init=False)
evaluation: bool = field(default=False, init=False)
def __post_init__(self):
super().__post_init__()
self.text = self.__collect_text()
def __getitem__(self, index):
return self.values[index]
def update_values(self, new_values):
"""Update value attributes from dict"""
for key, value in new_values.items():
for obj in self.values:
if key != "text" and hasattr(obj, key):
setattr(obj, key, value)
def __collect_text(self) -> str:
"""Collect text value from values"""
text = "".join([val.text for val in self.values])
if self.wrap_start is not None:
text = self.wrap_start + text
if self.wrap_end is not None:
text = text + self.wrap_end
return text
def evaluate_tree(self, options=None):
"""Evaluates and flattens the Ziffers object tree"""
for item in self.values:
if isinstance(item, Sequence):
if item.evaluation:
yield from item.evaluate(options)
else:
yield from item.evaluate_tree(options)
else:
# Get value / generated value from the item
current = item.get_item()
# Ignore items that returns None
if current is not None:
if isinstance(current, (DurationChange, OctaveChange, OctaveAdd)):
options = self.__update_options(current, options)
else:
if set(("key", "scale")) <= options.keys():
if isinstance(current, Cyclic):
current = current.get_value()
if isinstance(current, (Pitch, RandomPitch, RandomInteger)):
current = self.__update_pitch(current, options)
elif isinstance(current, Chord):
current = self.__update_chord(current, options)
elif isinstance(current, RomanNumeral):
current = self.__create_chord_from_roman(
current, options
)
current.update_new(options)
yield current
def filter(self, keep: tuple):
"""Filter out items from sequence.
Args:
keep (tuple): Tuple describing classes to keep
Returns:
Sequence: Copy of the sequence with filtered values.
"""
return replace(
self, values=[item for item in self.values if isinstance(item, keep)]
)
def __update_options(self, current: Item, options: dict) -> dict:
"""Update options based on current item
Args:
current (Item): Current item like Duration change, Octave change etc.
options (dict): Current options
Returns:
dict: Updated options
"""
if current.item_type == "change": # Change options
options[current.key] = current.value
elif current.item_type == "add":
if current.key in options: # Add to existing value
options[current.key] += current.value
else: # Create value if not existing
options[current.key] = current.value
return options
def __update_pitch(self, current: Item, options: dict) -> dict:
"""Update pich based on optons
Args:
current (Item): _description_
options (dict): _description_
Returns:
dict: _description_
"""
if hasattr(current, "modifier"):
c_modifier = 0
elif "modifier" in options:
c_modifier = options["modifier"]
else:
c_modifier = 0
if hasattr(current, "octave"):
c_octave = 0
elif "octave" in options:
c_octave = options["octave"]
else:
c_octave = 0
note = note_from_pc(
root=options["key"],
pitch_class=current.get_value(),
intervals=options["scale"],
modifier=c_modifier,
octave=c_octave,
)
new_pitch = Pitch(
pitch_class=current.get_value(),
text=str(current.get_value()),
note=note,
octave=c_octave,
modifier=c_modifier,
kwargs=options,
)
return new_pitch
def __update_chord(self, current: Chord, options: dict) -> Chord:
"""Update chord based on options
Args:
current (Chord): Current chord object
options (dict): Options
re (bool, optional): Re-evaluation flag. Defaults to False.
Returns:
Chord: Returns updated chord
"""
pcs = current.pitch_classes
notes = [
pc.set_note(note_from_pc(options["key"], pc.pitch_class, options["scale"]))
for pc in pcs
]
current.set_notes(notes)
return current
def __create_chord_from_roman(self, current: RomanNumeral, options: dict) -> Chord:
"""Create chord fom roman numeral
Args:
current (RomanNumeral): Current roman numeral
options (dict): Options
re (bool, optional): Re-evaluation flag. Defaults to False.
Returns:
Chord: New chord created from Roman numeral
"""
key = options["key"]
scale = options["scale"]
pitches = [midi_to_pitch_class(note, key, scale) for note in current.notes]
chord_notes = [
note_from_pc(
root=key,
pitch_class=pitch,
intervals=scale,
modifier=current.modifier if hasattr(current, "modifier") else 0,
)
for pitch in pitches
]
chord = Chord(text="".join(pitches), pitch_classes=pitches, notes=chord_notes)
return chord
@dataclass(kw_only=True)
class Ziffers(Sequence):
"""Main class for holding options and the current state"""
options: dict = field(default_factory=DEFAULT_OPTIONS)
loop_i: int = 0
iterator = None
current: Item = field(default=None)
def __iter__(self):
return self
def __next__(self):
self.current = next(self.iterator)
self.loop_i += 1
return self.current
# pylint: disable=locally-disabled, dangerous-default-value
def init_opts(self, options=None):
"""Evaluate the Ziffers tree using the options"""
self.options.update(DEFAULT_OPTIONS)
if options:
self.options.update(options)
else:
self.options = DEFAULT_OPTIONS
self.iterator = iter(self.evaluate_tree(self.options))
def re_eval(self, options=None):
"""Re-evaluate the iterator"""
self.options.update(DEFAULT_OPTIONS)
if options:
self.options.update(options)
self.iterator = iter(self.evaluate_tree(self.options))
def get_list(self):
"""Return list"""
return list(self)
def take(self, num: int) -> list[Pitch]:
"""Take number of pitch classes from the parsed sequence. Cycles from the beginning.
Args:
num (int): Number of pitch classes to take from the sequence
Returns:
list: List of pitch class items
"""
return list(itertools.islice(itertools.cycle(self), num))
def loop(self) -> iter:
"""Return cyclic loop"""
return itertools.cycle(iter(self))
def set_defaults(self, options: dict):
"""Sets options for the parser
Args:
options (dict): Options as a dict
"""
self.options = DEFAULT_OPTIONS | options
# TODO: Refactor these
def pitch_classes(self) -> list[int]:
"""Return list of pitch classes as ints"""
return [val.pitch_class for val in self.values if isinstance(val, Pitch)]
def durations(self) -> list[float]:
"""Return list of pitch durations as floats"""
return [val.dur for val in self.values if isinstance(val, Pitch)]
def pairs(self) -> list[tuple]:
"""Return list of pitches and durations"""
return [
(val.pitch_class, val.dur) for val in self.values if isinstance(val, Pitch)
]
def octaves(self) -> list[int]:
"""Return list of octaves"""
return [val.octave for val in self.values if isinstance(val, Pitch)]
@dataclass(kw_only=True)
class ListSequence(Sequence):
"""Class for Ziffers list sequences"""
wrap_start: str = field(default="(", repr=False)
wrap_end: str = field(default=")", repr=False)
@dataclass(kw_only=True)
class Integer(Item):
"""Class for integers"""
value: int
# pylint: disable=locally-disabled, unused-argument
def get_value(self):
"""Return value of the integer"""
return self.value
@dataclass(kw_only=True)
class RandomInteger(Item):
"""Class for random integer"""
min: int
max: int
def __post_init__(self):
super().__post_init__()
if self.min > self.max:
new_max = self.min
self.min = self.max
self.max = new_max
# pylint: disable=locally-disabled, unused-argument
def get_value(self):
"""Evaluate the random value for the generator"""
return random.randint(self.min, self.max)
@dataclass(kw_only=True)
class RepeatedListSequence(Sequence):
"""Class for Ziffers list sequences"""
repeats: RandomInteger | Integer = field(default_factory=Integer(value=1, text="1"))
wrap_start: str = field(default="(:", repr=False)
wrap_end: str = field(default=":)", repr=False)
@dataclass(kw_only=True)
class Subdivision(Item):
"""Class for subdivisions"""
values: list[Event]
@dataclass(kw_only=True)
class Cyclic(Item):
"""Class for cyclic sequences"""
values: list
cycle: int = 0
wrap_start: str = field(default="<", repr=False)
wrap_end: str = field(default=">", repr=False)
def __post_init__(self):
super().__post_init__()
self.text = self.__collect_text()
self.values = [val for val in self.values if not isinstance(val, Whitespace)]
def __collect_text(self) -> str:
"""Collect text value from values"""
text = "".join([val.text for val in self.values])
if self.wrap_start is not None:
text = self.wrap_start + text
if self.wrap_end is not None:
text = text + self.wrap_end
return text
def get_value(self):
"""Get the value for the current cycle"""
value = self.values[self.cycle % len(self.values)]
self.cycle += 1
return value
@dataclass(kw_only=True)
class Range(Item):
"""Class for range"""
start: int = field(default=None)
end: int = field(default=None)
@dataclass(kw_only=True)
class Operator(Item):
"""Class for math operators"""
value: ...
@dataclass(kw_only=True)
class ListOperation(Sequence):
"""Class for list operations"""
def __post_init__(self):
super().__post_init__()
self.evaluation = True
def filter_operation(self, values):
"""Filtering for the operation elements"""
keep = (Sequence, Event, RandomInteger, Integer, Cyclic)
for item in values:
if isinstance(item, Sequence):
yield item.filter(keep)
elif isinstance(item, keep):
yield item
def evaluate(self, options: dict):
"""Evaluates the operation"""
operators = self.values[1::2] # Fetch every second operator element
values = self.values[::2] # Fetch every second list element
values = list(self.filter_operation(values)) # Filter out crap
result = values[0] # Start results with the first array
for i, operand in enumerate(operators):
operation = operand.value
right_value = values[i + 1]
if isinstance(right_value, Sequence):
result = [
Pitch(
pitch_class=operation(x.get_value(), y.get_value()),
kwargs=options,
)
for x in result
for y in right_value
]
else:
result = [
Pitch(
pitch_class=operation(x.get_value(), right_value.get_value()),
kwargs=options,
)
for x in result
]
return Sequence(values=result)
@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 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)
@dataclass(kw_only=True)
class Atom(Item):
"""Class for evaluable atoms"""
value: ...
@dataclass(kw_only=True)
class Euclid(Item):
"""Class for euclidean cycles"""
pulses: int
length: int
onset: list
offset: list = field(default=None)
rotate: int = field(default=None)
@dataclass(kw_only=True)
class RepeatedSequence(Sequence):
"""Class for repeats"""
repeats: RandomInteger | Integer = field(default_factory=Integer(value=1, text="1"))
wrap_start: str = field(default="[:", repr=False)
wrap_end: str = field(default=":]", repr=False)

19
ziffers/common.py Normal file
View File

@ -0,0 +1,19 @@
""" Common methods used in parsing """
def flatten(arr: list) -> list:
"""Flattens array"""
return (
flatten(arr[0]) + (flatten(arr[1:]) if len(arr) > 1 else [])
if isinstance(arr, list) else [arr]
)
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]
for element in arr[1:]:
for key in element.keys():
if key in result:
result[key] = result[key] + element[key]
else:
result[key] = element[key]
return result

1674
ziffers/defaults.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +0,0 @@
ebnf = r"""
ziffers = (bar / octup / octdown / subdiv / escape / rhythm / float / chord / pc / ws?)+
escape = (lt (chord / pc) gt)
subdiv = (lbra ziffers rbra)
chord = pc{2,}
pc = (neg_pc / pc_basic)
neg_pc = (~r"-" pc)
pc_basic = ~r"[0-9TE]"
rhythm = ~r"[mklpdcwyhnqaefsxtgujoz]"
float = ~r"\d+\.\d+"
lpar = "("
rpar = ")"
lbra = "["
rbra = "]"
lcbra = "{"
rcbra = "}"
lt = "<"
gt = ">"
octup = "^"
octdown = "_"
bar = "|"
plus = "+"
minus = "-"
times = "*"
div = "/"
emptyline = ws+
comma = ","
ws = ~"\s*"
"""

351
ziffers/mapper.py Normal file
View File

@ -0,0 +1,351 @@
""" Lark transformer for mapping Lark tokens to Ziffers objects """
from typing import Optional
from lark import Transformer
from .classes import (
Ziffers,
Whitespace,
DurationChange,
OctaveChange,
OctaveAdd,
Pitch,
RandomPitch,
RandomPercent,
Chord,
RomanNumeral,
Sequence,
ListSequence,
RepeatedListSequence,
Subdivision,
Cyclic,
RandomInteger,
Range,
Operator,
ListOperation,
Operation,
Eval,
Atom,
Integer,
Euclid,
RepeatedSequence,
)
from .common import flatten, sum_dict
from .defaults import DEFAULT_DURS, OPERATORS
from .scale import parse_roman, chord_from_roman_numeral
# pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name
class ZiffersTransformer(Transformer):
"""Rules for transforming Ziffers expressions into tree."""
def __init__(self, options: Optional[dict] = None):
super().__init__()
self.options = options
def start(self, items) -> Ziffers:
"""Root for the rules"""
# seq = Sequence(values=items[0])
return Ziffers(values=items[0], options={})
def sequence(self, items):
"""Flatten sequence"""
return flatten(items)
def random_integer(self, item) -> RandomInteger:
"""Parses random integer syntax"""
val = item[0][1:-1].split(",")
return RandomInteger(min=int(val[0]), max=int(val[1]), text=item[0].value)
def range(self, item) -> Range:
"""Parses range syntax"""
val = item[0].split("..")
return Range(start=val[0], end=val[1], text=item[0])
def cycle(self, items) -> Cyclic:
"""Parses cycle"""
values = items[0]
return Cyclic(values=values)
def pitch_class(self, item):
"""Parses pitch class"""
# If there are prefixes
if len(item) > 1:
# Collect&sum prefixes from any order: _qee^s4 etc.
result = sum_dict(item)
return Pitch(**result)
val = item[0]
return Pitch(**val)
def pitch(self, items):
"""Return pitch class info"""
return {"pitch_class": int(items[0].value), "text": items[0].value}
def prefix(self, items):
"""Return prefix"""
return items[0]
def oct_change(self, items):
"""Parses octave change"""
octave = items[0]
return [OctaveChange(value=octave["octave"], text=octave["text"]), items[1]]
def oct_mod(self, items):
"""Parses octave modification"""
octave = items[0]
return [OctaveAdd(value=octave["octave"], text=octave["text"]), items[1]]
def escaped_octave(self, items):
"""Return octave info"""
value = items[0][1:-1]
return {"octave": int(value), "text": items[0].value}
def octave(self, items):
"""Return octaves ^ and _"""
value = sum(1 if char == "^" else -1 for char in items[0].value)
return {"octave": value, "text": items[0].value}
def modifier(self, items):
"""Return modifiers # and b"""
value = 1 if items[0].value == "#" else -1
return {"modifier": value}
def chord(self, items):
"""Parses chord"""
return Chord(pitch_classes=items, text="".join([val.text for val in items]))
def named_roman(self, items) -> RomanNumeral:
"""Parse chord from roman numeral"""
numeral = items[0].value
if len(items) > 1:
name = items[1]
chord_notes = chord_from_roman_numeral(numeral, name)
parsed_number = parse_roman(numeral)
return RomanNumeral(
text=numeral, value=parsed_number, chord_type=name, notes=chord_notes
)
return RomanNumeral(
value=parse_roman(numeral),
text=numeral,
notes=chord_from_roman_numeral(numeral),
)
def chord_name(self, item):
"""Return name for chord"""
return item[0].value
def roman_number(self, item):
"""Return roman numeral"""
return item.value
def dur_change(self, items):
"""Parses duration change"""
durs = items[0]
return DurationChange(value=durs["duration"], text=durs["text"])
def char_change(self, items):
"""Return partial duration char info"""
chars = ""
durs = 0.0
for dchar, dots in items:
val = DEFAULT_DURS[dchar]
if dots > 0:
val = val * (2.0 - (1.0 / (2 * dots)))
chars = chars + (dchar + "." * dots)
durs = durs + val
return {"text": chars, "duration": durs}
def dchar_not_prefix(self, items):
"""Return partial duration char info"""
dur = items[0].split(".", 1)
dots = 0
if len(dur) > 1:
dots = len(dur[1]) + 1
return [dur[0], dots]
def escaped_decimal(self, items):
"""Return partial decimal info"""
val = items[0]
val["text"] = "<" + val["text"] + ">"
return val
def random_pitch(self, items):
"""Parses random pitch"""
return RandomPitch(text="?")
def random_percent(self, items):
"""Parses random percent"""
return RandomPercent(text="%")
def duration_chars(self, items):
"""Return partial duration info"""
durations = [val[1] for val in items]
characters = "".join([val[0] for val in items])
return {"duration": sum(durations), "text": characters}
def dotted_dur(self, items):
"""Return partial duration info"""
key = items[0]
val = DEFAULT_DURS[key]
dots = len(items) - 1
if dots > 0:
val = val * (2.0 - (1.0 / (2 * dots)))
return [key + "." * dots, val]
def decimal(self, items):
"""Return partial duration info"""
val = items[0]
return {"duration": float(val), "text": val.value}
def dot(self, items):
"""Return partial duration info"""
return "."
def dchar(self, items):
"""Return partial duration info"""
chardur = items[0].value
return chardur
def WS(self, items):
"""Parse whitespace"""
return Whitespace(text=items[0])
def subdivision(self, items):
"""Parse subdivision"""
values = flatten(items[0])
return Subdivision(
values=values, text="[" + "".join([val.text for val in values]) + "]"
)
def subitems(self, items):
"""Return subdivision items"""
return items
# Eval rules
def eval(self, items):
"""Parse eval"""
val = items[0]
return Eval(values=val)
def sub_operations(self, items):
"""Returns list of operations"""
return Eval(values=items[0], wrap_start="(", wrap_end=")")
def operation(self, items):
"""Return partial eval operations"""
return flatten(items)
def atom(self, token):
"""Return partial eval item"""
val = token[0].value
return Atom(value=val, text=val)
# List rules
def list(self, items):
"""Parse list sequence notation, ex: (1 2 3)"""
if len(items) > 1:
prefixes = sum_dict(items[0:-1])
values = items[-1]
seq = ListSequence(values=values, wrap_start=prefixes["text"] + "(")
seq.update_values(prefixes)
return seq
else:
seq = ListSequence(values=items[0])
return seq
def repeated_list(self, items):
"""Parse repeated list notation ex: (: 1 2 3 :)"""
if len(items) > 2:
prefixes = sum_dict(items[0:-2]) # If there are prefixes
if items[-1] is not None:
seq = RepeatedListSequence(
values=items[-2],
repeats=items[-1],
wrap_end=":" + items[-1].text + ")",
)
else:
seq = RepeatedListSequence(
values=items[-2], repeats=Integer(text="1", value=1)
)
seq.update_values(prefixes)
return seq
else:
if items[-1] is not None:
seq = RepeatedListSequence(
values=items[-2],
repeats=items[-1],
wrap_end=":" + items[-1].text + ")",
)
else:
seq = RepeatedListSequence(
values=items[-2], repeats=Integer(text="1", value=1)
)
return seq
def NUMBER(self, token):
"""Parse integer"""
val = token.value
return Integer(text=val, value=int(val))
def number(self, item):
"""Return partial number (Integer or RandomInteger)"""
return item[0]
def cyclic_number(self, item):
"""Parse cyclic notation"""
return Cyclic(values=item)
def lisp_operation(self, items):
"""Parse lisp like list operation"""
op = items[0]
values = items[1:]
return Operation(
operator=op,
values=values,
text="(+" + "".join([v.text for v in values]) + ")",
)
def operator(self, token):
"""Parse operator"""
val = token[0].value
return Operator(text=val, value=OPERATORS[val])
def list_items(self, items):
"""Parse sequence"""
return Sequence(values=items)
def list_op(self, items):
"""Parse list operation"""
return ListOperation(values=items)
def right_op(self,items):
"""Get right value for the operation"""
return items[0]
def euclid(self, items):
"""Parse euclid notation"""
params = items[1][1:-1].split(",")
init = {"onset": items[0], "pulses": params[0], "length": params[1]}
text = items[0].text + items[1]
if len(params) > 2:
init["rotate"] = params[2]
if len(items) > 2:
init["offset"] = items[2]
text = text + items[2].text
init["text"] = text
return Euclid(**init)
def euclid_operator(self, token):
"""Return euclid operators"""
return token.value
def repeat(self, items):
"""Parse repeated sequence, ex: [: 1 2 3 :]"""
if items[-1] is not None:
return RepeatedSequence(
values=items[0], repeats=items[-1], wrap_end=":" + items[-1].text + "]"
)
else:
return RepeatedSequence(values=items[0], repeats=Integer(value=1, text="1"))

View File

@ -1,57 +1,52 @@
from parsimonious.grammar import Grammar
from parsimonious.nodes import NodeVisitor
from rich import print
from .ebnf import ebnf
""" Module for the parser """
from pathlib import Path
from lark import Lark
from .classes import Ziffers
from .mapper import ZiffersTransformer
__all__ = ('ZiffersVisitor', 'parse_expression',)
GRAMMAR = Grammar(ebnf)
grammar_path = Path(__file__).parent
grammar = grammar_path / "ziffers.lark"
class ZiffersVisitor(NodeVisitor):
ziffers_parser = Lark.open(
grammar,
rel_to=__file__,
start="root",
parser="lalr",
transformer=ZiffersTransformer(),
)
def parse_expression(expr: str) -> Ziffers:
"""Parse an expression using the Ziffers parser
Args:
expr (str): Ziffers expression as a string
Returns:
Ziffers: Reutrns Ziffers iterable
"""
Visitor for the Ziffers syntax.
return ziffers_parser.parse(expr)
def zparse(expr: str, **opts) -> Ziffers:
"""Parses ziffers expression with options
Args:
expr (str): Ziffers expression as a string
opts (dict, optional): Options for parsing the Ziffers expression. Defaults to None.
Returns:
Ziffers: Returns Ziffers iterable parsed with the given options
"""
parsed = parse_expression(expr)
parsed.init_opts(opts)
return parsed
def visit_ziffers(self, node, children):
"""
Top-level visiter. Will traverse and build something out of a complete and valid
Ziffers expression.
"""
print(f"Node: {node}, Children: {children}")
result = {'ziffers': []}
for i in children:
if i[0] in (None, [], {}) and isinstance(i[0], dict):
continue
try:
result['ziffers'].append(i[0])
except Exception as e:
print(f"[red]Error in ziffers:[/red] {e}")
pass
return result
# pylint: disable=invalid-name
def visit_pc(self, node, children):
return {node.expr_name: node.text}
def visit_escape(self, node, children):
return {node.expr_name: node.text}
# def visit_subdiv(self, node, visited_children):
#  key, values = visited_children
#  ret)rn {key, dict(values)}
def generic_visit(self, node, children):
"""
This method seem to be the generic method to include in any NodeVisitor.
Probably better to keep it as is for the moment. This is appending a whole
lot of garbage to the final expression because I don't really know how to
write it properly...
"""
return children or node
def parse_expression(expression: str) -> dict:
tree = GRAMMAR.parse(expression)
visitor = ZiffersVisitor()
return visitor.visit(tree)
def z(expr: str, **opts) -> Ziffers:
"""Shortened method name for zparse"""
return zparse(expr, **opts)

260
ziffers/scale.py Normal file
View File

@ -0,0 +1,260 @@
""" Methods for calculating notes from scales and list of all intervals in scales"""
#!/usr/bin/env python3
# pylint: disable=locally-disabled, no-name-in-module
import re
from math import floor
from .defaults import (
DEFAULT_OCTAVE,
SCALES,
MODIFIERS,
NOTES_TO_INTERVALS,
INTERVALS_TO_NOTES,
ROMANS,
CIRCLE_OF_FIFTHS,
CHORDS,
)
def midi_to_note_name(midi: int) -> str:
"""Creates note name from midi number
Args:
midi (int): Mii number
Returns:
str: Note name
"""
return INTERVALS_TO_NOTES[midi % 12]
def note_name_to_interval(name: str) -> int:
"""Parse note name to interval
Args:
name (str): Note name as: [a-gA-G][#bs]
Returns:
int: Interval of the note name [-1 - 11]
"""
items = re.match(r"^([a-gA-G])([#bs])?$", name)
if items is None:
return 0
values = items.groups()
modifier = MODIFIERS[values[1]] if values[1] else 0
interval = NOTES_TO_INTERVALS[values[0].capitalize()]
return interval + modifier
def note_name_to_midi(name: str) -> int:
"""Parse note name to midi
Args:
name (str): Note name in scientific notation: [a-gA-G][#bs][1-9]
Returns:
int: Midi note
"""
items = re.match(r"^([a-gA-G])([#bs])?([1-9])?$", name)
if items is None:
return 60
values = items.groups()
octave = int(values[2]) if values[2] else 4
modifier = MODIFIERS[values[1]] if values[1] else 0
interval = NOTES_TO_INTERVALS[values[0].capitalize()]
return 12 + octave * 12 + interval + modifier
def get_scale(name: str) -> list[int]:
"""Get a scale from the global scale list
Args:
name (str): Name of the scale as named in https://allthescales.org/
Returns:
list: List of intervals in the scale
"""
scale = SCALES.get(name.lower().capitalize(), SCALES["Chromatic"])
return list(map(int, str(scale)))
# pylint: disable=locally-disabled, too-many-arguments
def note_from_pc(
root: int | str,
pitch_class: int,
intervals: str | list[int | float],
cents: bool = False,
octave: int = 0,
modifier: int = 0,
) -> int:
"""Resolve a pitch class into a note from a scale
Args:
root (int | str): Root of the scale in MIDI or scientific pitch notation
pitch_class (int): Pitch class to be resolved
intervals (str | list[int | float]): Name or Intervals for the scale
cents (bool, optional): Flag for interpreting intervals as cents. Defaults to False.
octave (int, optional): Default octave. Defaults to 0.
modifier (int, optional): Modifier for the pitch class (#=1, b=-1). Defaults to 0.
Returns:
int: Resolved MIDI note
"""
# 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)
pitch_class = (
scale_length - (abs(pitch_class) % scale_length)
if pitch_class < 0
else pitch_class % scale_length
)
if pitch_class == scale_length:
pitch_class = 0
# Computing the result
note = root + sum(intervals[0:pitch_class])
return note + (octave * sum(intervals)) + modifier
def parse_roman(numeral: str) -> int:
"""Parse roman numeral from string
Args:
numeral (str): Roman numeral as string
Returns:
int: Integer parsed from roman numeral
"""
values = [ROMANS[val] for val in numeral]
result = 0
i = 0
while i < len(values):
if i < len(values) - 1 and values[i + 1] > values[i]:
result += values[i + 1] - values[i]
i += 2
else:
result += values[i]
i += 1
return result
def accidentals_from_note_name(name: str) -> int:
"""Generates number of accidentals from name of the note.
Args:
name (str): Name of the note
Returns:
int: Integer representing number of flats or sharps: -7 flat to 7 sharp.
"""
idx = CIRCLE_OF_FIFTHS.index(name.upper())
return idx - 6
def accidentals_from_midi_note(note: int) -> int:
"""Generates number of accidentals from name of the note.
Args:
note (int): Note as midi number
Returns:
int: Integer representing number of flats or sharps: -7 flat to 7 sharp.
"""
name = midi_to_note_name(note)
return accidentals_from_note_name(name)
def midi_to_tpc(note: int, key: str | int):
"""Return Tonal Pitch Class value for the note
Args:
note (int): MIDI note
key (str | int): Key as a string (A-G) or a MIDI note.
Returns:
_type_: Tonal Pitch Class value for the note
"""
if isinstance(key, str):
acc = accidentals_from_note_name(key)
else:
acc = accidentals_from_midi_note(key)
return (note * 7 + 26 - (11 + acc)) % 12 + (11 + acc)
def midi_to_octave(note: int) -> int:
"""Return octave for the midi note
Args:
note (int): Note in midi
Returns:
int: Returns default octave in Ziffers where C4 is in octave 0
"""
return 0 if note <= 0 else floor(note / 12)
def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict:
"""Return pitch class and octave from given midi note, key and scale
Args:
note (int): Note as MIDI number
key (str | int): Used key
scale (str): Used scale
Returns:
tuple: Returns dict containing pitch-class values
"""
pitch_class = note % 12
octave = midi_to_octave(note) - 5
if scale.upper() == "CHROMATIC":
return (str(pitch_class), pitch_class, octave)
sharps = ["0", "#0", "1", "#1", "2", "3", "#3", "4", "#4", "5", "#5", "6"]
flats = ["0", "b1", "1", "b2", "2", "3", "b4", "4", "b5", "5", "b6", "6"]
tpc = midi_to_tpc(note, key)
if tpc >= 6 and tpc <= 12 and len(flats[pitch_class]) == 2:
npc = flats[pitch_class]
elif tpc >= 20 and tpc <= 26 and len(sharps[pitch_class]) == 2:
npc = sharps[pitch_class]
else:
npc = sharps[pitch_class]
if len(npc) > 1:
return {
"text": npc,
"pitch_class": int(npc[1]),
"octave": octave,
"modifier": 1 if (npc[0] == "#") else -1,
}
return {"text": npc, "pitch_class": int(npc), "octave": octave}
def chord_from_roman_numeral(
roman: str, name: str = "major", num_octaves: int = 1
) -> list[int]:
"""Generates chord from given roman numeral and chord name
Args:
roman (str): Roman numeral
name (str, optional): Chord name. Defaults to "major".
num_octaves (int, optional): Number of octaves for the chord. Defaults to 1.
Returns:
list[int]: _description_
"""
root = parse_roman(roman) - 1
tonic = (DEFAULT_OCTAVE * 12) + root + 12
intervals = CHORDS.get(name, CHORDS["major"])
notes = []
for cur_oct in range(num_octaves):
for iterval in intervals:
notes.append(tonic + iterval + (cur_oct * 12))
return notes

81
ziffers/ziffers.lark Normal file
View File

@ -0,0 +1,81 @@
// Root for the rules
?root: sequence -> start
sequence: (pitch_class | dur_change | oct_mod | oct_change | WS | 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
prefix: (octave | duration_chars | escaped_decimal | escaped_octave | modifier)
pitch: /-?[0-9TE]/
escaped_decimal: "<" decimal ">"
escaped_octave: /<-?[0-9]>/
octave: /[_^]+/
modifier: /[#b]/
// Chords
chord: pitch_class pitch_class+
named_roman: roman_number (("^" chord_name))? // TODO: Add | ("+" number)
chord_name: /[a-zA-Z0-9]+/
?roman_number: /iv|v|v?i{1,3}/
// Valid as integer
number: NUMBER | random_integer | cyclic_number
cyclic_number: "<" number (WS number)* ">"
// Repeats
repeat: "[:" sequence ":" [number] "]"
// List
list: prefix* "(" sequence ")"
repeated_list: prefix* "(:" sequence ":" [number] ")"
// Right recursive list operation
list_op: list (operator right_op)+
right_op: list | number
operator: /([\+\-\*\/%]|<<|>>)/
// Euclidean cycles
euclid: list euclid_operator list?
?euclid_operator: /<[0-9]+,[0-9]+(,[0-9])?>/
// TODO: Support randomization etc.
//euclid_operator: (">" | "<") number "," number ["," number] (">" | "<")
// Lisp like list operation
lisp_operation: "(" operator WS sequence ")"
// Durations
duration_chars: dotted_dur+
dotted_dur: dchar dot*
decimal: /-?[0-9]+\.[0-9]+/
dchar: /[mklpdcwyhnqaefsxtgujzo]/
dot: "."
// Subdivision
subdivision: "[" subitems "]"
subitems: (pitch_class | WS | chord | cycle | subdivision)*
// Control characters modifying future events
oct_mod: octave WS
oct_change: escaped_octave WS
dur_change: (decimal | char_change)
char_change: dchar_not_prefix+
dchar_not_prefix: /([mklpdcwyhnqaefsxtgujzo](\.)*)(?!\d)/
// Generative rules
random_integer: /\(-?[0-9]+,-?[0-9]+\)/
range: /-?[0-9]\.\.-?[0-9]/
cycle: "<" sequence ">"
random_pitch: /(\?)(?!\d)/
random_percent: /(%)(?!\d)/
// Rules for evaluating clauses inside {}
// TODO: Support for parenthesis?
eval: "{" operation+ "}"
operation: atom (operator (sub_operations | operation))*
sub_operations: "(" operation ")"
atom: (NUMBER | DECIMAL)
%import common.NUMBER
//%import common.SIGNED_NUMBER
%import common.DECIMAL
%import common.WS