Compare commits

79 Commits
lark ... main

Author SHA1 Message Date
7cfc92db3d Changes to triplet characters 2023-12-10 14:45:58 +02:00
9bd4ec0ff0 More examples and some minor fixes 2023-06-29 00:03:45 +03:00
7d6ba407bd Added music21 and csound examples 2023-06-28 00:55:56 +03:00
fe65c87ed2 Packaging 2023-05-06 19:38:19 +03:00
c8a45a3f8b Degree based notation
Added parameter for degree based notation. Using degrees=True integers are interpreted as degrees and 0=Rest.
2023-04-29 17:06:07 +03:00
8f5d8adf48 Added pick and select for lists
Pick: (1 2 3 4)?4 or (1 2 3 4)?(3 2)
Select: (1 2 3 4)~2 or (1 2 3 4)~(2 3)
2023-04-26 21:14:21 +03:00
cc3497fd29 Changing py-modules to packages 2023-03-18 22:55:05 +02:00
d3edfb5b42 Added build-system 2023-03-18 22:27:18 +02:00
c9b6a01ef3 Config changes 2023-03-18 19:32:11 +02:00
830cc94744 Updated docs 2023-03-18 02:54:39 +02:00
c88f02419d Updating documentation 2023-03-18 02:51:08 +02:00
f0b9de0118 Adding checks if music21 is installed
Music21 is optional dependency
2023-03-18 00:01:48 +02:00
5d122a90e0 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}
2023-03-16 22:29:24 +02:00
882a9a7b4b Adding support for ratio operations 2023-03-15 23:42:34 +02:00
48426fdb0e Adding scala_parser to zparse
Can parse scales as options, for example:
zparse("0..8", scale="17/16 19/16")
2023-03-15 23:08:12 +02:00
8929940328 Fixes for edji ratio 2023-03-15 22:59:06 +02:00
95b69d1d41 Added support for m-EDO and EDJI notation 2023-03-15 22:53:31 +02:00
c30ff41f1f Add options to SampleList 2023-03-11 23:34:01 +02:00
c8c590d7ed Refactor multiple samples as SampleList 2023-03-10 20:11:38 +02:00
bd2a1587d7 Added experimental scala scale parser 2023-03-10 18:41:50 +02:00
545ae1f92a Added beat to events 2023-03-06 21:22:37 +02:00
7e015a635d Added total_duration and total_beats 2023-03-06 20:40:30 +02:00
745632ce59 Updating cyclic zip 2023-03-05 21:58:32 +02:00
323b41b36e Added samples and prefixes to variables 2023-03-05 18:45:14 +02:00
ea0e9ae0cd Fixed some bugs and added new tests
New test_multi_03 can be used to test multiple variables at once. Based on new collect function that can be used to collect n variables from parsed Ziffers.
2023-03-05 13:31:52 +02:00
004578e56e Added len to Ziffers object 2023-03-04 22:37:40 +02:00
7bf6669cd1 Fixed some issues 2023-03-04 22:05:34 +02:00
13f68f7ee7 Moving things around 2023-03-04 12:36:09 +02:00
5fd03fac6b Added Function to evaluated items 2023-03-03 20:11:55 +02:00
115da4c96c Added cycles to operations 2023-03-02 20:56:49 +02:00
bc779b0c81 Added arpeggios and cyclic zip operation 2023-03-02 20:45:32 +02:00
6167c4be33 Fix for chromatic chord names 2023-03-02 01:34:48 +02:00
f60e21c341 Adding duration for roman chords 2023-03-02 01:18:54 +02:00
3d543601e6 Fix for some chord issues 2023-03-02 01:07:05 +02:00
7d35ce0118 Adding dim back 2023-03-02 00:45:27 +02:00
f996e57acf Refactored roman numeral chords 2023-03-02 00:40:46 +02:00
9e37bd366f Fixed Subdivision in normal repeats 2023-02-26 22:32:50 +02:00
da020cc3a2 Fixed some bugs 2023-02-26 21:41:30 +02:00
78295da323 Added measures and fixed some bugs 2023-02-26 20:42:20 +02:00
443d4e6639 Adding chord inversion 2023-02-26 13:16:19 +02:00
ef27557c76 Small fix for old code 2023-02-26 00:28:55 +02:00
1168449dfa Added some helper methods 2023-02-26 00:20:06 +02:00
f0e1aca247 Major refactoring
Added more items to shorter repeat syntax: 124:3 A=(1 2) A:4

Prefixes now work for more items: q..r qs(1 2 3)

Duration support for list operations: (q1 e3)+(1 4 3 5)
2023-02-25 23:47:28 +02:00
7a2f0b5a0a Added repeat item to subdivisions 2023-02-25 02:20:48 +02:00
864b110931 Adding rest and subdivision to new repeat syntax 2023-02-24 16:51:10 +02:00
0d285a63eb Added cycle to new repeat syntax 2023-02-24 16:39:21 +02:00
f4f819291a Added support for cycles in new repeat syntax 2023-02-24 16:32:41 +02:00
595fc346ae Added new repeat syntax
Shortened syntax for repeats:
1:5 (1,5):4 (1 2 3):4
2023-02-24 16:16:24 +02:00
bcc86f4cfe Added two options for variables
Pre-evaluated: A=(1,4)
Just a clone: A~(1,4)
2023-02-23 21:42:12 +02:00
3e32c9ecf2 Added variable assingment 2023-02-23 19:11:06 +02:00
6dd8333007 Fixed bug with subdivisions and added tests 2023-02-23 18:22:43 +02:00
63dab6fbdf Adding cycles for operations 2023-02-22 23:31:50 +02:00
805d7af216 Changed default options to immutable MappingProxyType
Options as immutable dicts are less prone to bugs
2023-02-22 22:14:10 +02:00
4dd0f12aca Fix for repeats 2023-02-22 19:41:17 +02:00
5e78ea9c4c Added reversed ranges 2023-02-22 19:23:24 +02:00
77cf10c95c Added lru_cache and range support for operations 2023-02-22 15:47:07 +02:00
acbfacee59 Simple support for re-evaluating cycles with get_item 2023-02-21 20:56:06 +02:00
973d3eab2b Added range evaluation and more operators 2023-02-21 19:48:37 +02:00
e9c0731d7e Refactored evaluation for subdivisions 2023-02-21 03:16:00 +02:00
a9e2936a29 Some refactoring 2023-02-20 18:22:33 +02:00
65257217c5 Fix for subdiv looping 2023-02-20 00:29:56 +02:00
ffbff359fa Some fixes 2023-02-19 23:55:56 +02:00
9a4c970b95 Added euclidean evaluation 2023-02-19 22:56:11 +02:00
cca08b250f Evaluation of ListOperation for repeats 2023-02-19 21:34:18 +02:00
bae01efa58 Fixing some issues with repeats 2023-02-19 19:02:42 +02:00
c7a905f5a0 Added support for mote item in cyclic 2023-02-19 12:45:50 +02:00
f417f0282e Added evaluation for subdivision 2023-02-18 12:45:32 +02:00
5e9bf94d42 Added rest and update in RepeatedSequence 2023-02-17 17:28:23 +02:00
a7da9795a5 Added string replacement function for future use 2023-02-16 22:58:54 +02:00
0816ac65db Improvements in packaging.
The ziffers.lark file is not included in the installed package. This was
preventing users from importing the ziffers package from elsewhere.
2023-02-16 07:39:51 +01:00
1ff7e3b6d2 Added rest and chords to music21 converter 2023-02-16 00:41:31 +02:00
f6c6497319 Added rest and infinite indexing
Preliminary support for rest: q 1 r 3 er 4 etc. Currently supports only single character prefixes like: qr er.
2023-02-15 23:14:53 +02:00
10f66d0027 Some refactoring 2023-02-15 19:44:40 +02:00
726dc42902 Added repeats
Added evaluation of [: :] and (:  :) and cycles: (: (1,5) <q e <e s>> (2,3) :6) ... etc.
2023-02-14 22:41:41 +02:00
90d7b0bdff Added notes function to get list of notes 2023-02-13 17:55:04 +02:00
e240c45693 Added basic music21 converter 2023-02-13 17:28:42 +02:00
7cc89d3333 Added frequency and default key&scale 2023-02-13 01:21:55 +02:00
4f019bfda4 Refactored list operation to use product
Evaluated pitches are now stored to evaluated_valued variable
2023-02-12 22:05:48 +02:00
70b56dbc52 Merge pull request #1 from Bubobubobubobubo/lark
Lark implementation
2023-02-12 14:59:28 +01:00
41 changed files with 5369 additions and 2558 deletions

6
.gitignore vendored
View File

@ -165,6 +165,8 @@ cython_debug/
# VSCode
.vscode
# Debugging file
debug.py
# Debugging files
debug/
debug*.py
test.py

4
MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
include ziffers/*
include ziffers/classes/*
include ziffers/spec/*
global-include *.lark

View File

@ -1,4 +1,59 @@
# Ziffers for Python 3.10+
This repo is hosting an attempt at porting the [Ziffers](https://github.com/amiika/ziffers) numbered musical notation to Python 3.10+. This library is using [parsimonious](https://github.com/erikrose/parsimonious) for PEG parsing.
This repo is hosting experimental parser for the [Ziffers](https://github.com/amiika/ziffers) numbered musical notation to Python 3.10+. This library is using lark lalr-1 parser and ebnf PEG for parsing the notation.
## Supported environments
Ziffers python supports following live coding and computer-aided composition environments:
* [Sardine](https://github.com/Bubobubobubobubo/sardine)
* [Music21](https://github.com/cuthbertLab/music21)
* [Csound](http://www.csounds.com/manual/html/ScoreTop.html)
* [Sonicsynth](https://github.com/Frikallo/SonicSynth)
# Status:
**Supported:**
```
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
Named chords: i i i i
Randoms: % ? % ? % ?
Random between: (-3,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: <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)
```
**New features:**
```
Shorthand for random repeat: (2 5):3 [2 5 1]:4 (1,6):6
```
**Partial support:**
```
Escape/eval: {10 11} {3+1*2} // {1.2 2.43} NOT SUPPORTED YET.
Roman chords: i ii iii i^maj i^7
```
**TBD:**
```
Random selections: [q 1 2, q 3 e 4 6]
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}
Modal interchange (a-g): iiia ig ivf^7
```

101
example.musicxml Normal file
View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<movement-title>Music21 Fragment</movement-title>
<identification>
<creator type="composer">Music21</creator>
<encoding>
<encoding-date>2023-06-27</encoding-date>
<software>music21 v.9.1.0</software>
</encoding>
</identification>
<defaults>
<scaling>
<millimeters>7</millimeters>
<tenths>40</tenths>
</scaling>
</defaults>
<part-list>
<score-part id="P0c4c819b93d5335473e23baff277bd9f">
<part-name />
</score-part>
</part-list>
<!--=========================== Part 1 ===========================-->
<part id="P0c4c819b93d5335473e23baff277bd9f">
<!--========================= Measure 1 ==========================-->
<measure implicit="no" number="1">
<attributes>
<divisions>10080</divisions>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<note>
<pitch>
<step>D</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>E</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<rest />
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
</note>
<note>
<chord />
<pitch>
<step>E</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>G</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
</note>
<note print-object="no" print-spacing="yes">
<rest />
<duration>5040</duration>
<type>eighth</type>
</note>
<barline location="right">
<bar-style>light-heavy</bar-style>
</barline>
</measure>
</part>
</score-partwise>

View File

@ -0,0 +1,42 @@
"""Example of using ziffers with Csound score."""
try:
import ctcsound
except (ImportError,TypeError):
csound_imported = false
from ziffers import *
# Csound numeric score is very versatile so it is hard to do generic tranformer
# See http://www.csounds.com/manual/html/ScoreTop.html and http://www.csounds.com/manual/html/ScoreStatements.html
# There is a simple converter implemented that uses format:
# i {instrument} {start time} {duration} {amplitude} {frequency}
# See ziffers_to_csound_score in ziffers/converters.py
if(csound_imported):
# Parse ziffers notation, scale in SCALA format
parsed = zparse("w 0 024 q 0 1 2 346 r e (5 3 9 2 -605)+(0 -3 6) q 0h24e67s9^s1^e3^5^7", key="D4", scale="100. 200. 250. 400. 560.")
score = ziffers_to_csound_score(parsed, 180, 1500, "FooBar") # 180 bpm, 1500 amplitude, FooBar instrument
print("Generated score:")
print(score)
# Define FooBar Csound instrument
orc = """
instr FooBar
out(linen(oscili(p4,p5),0.1,p3,0.1))
endin
"""
# Run score with Csound
c = ctcsound.Csound()
c.setOption("-odac")
c.compileOrc(orc)
c.readScore(score)
c.start()
c.perform()
c.stop()
else:
print("Csound not found! First download from https://csound.com/ and add to PATH or PYENV (Windows path: C:\Program Files\Csound6_x64\bin). Then install ctcsound with 'pip install ctcsound'.")

View File

@ -0,0 +1,82 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Playing note: 60\n",
"Playing note: 67\n",
"Playing note: 64\n",
"Playing note: 69\n",
"Playing note: 65\n",
"Playing note: 76\n",
"Playing note: 67\n",
"Playing note: 64\n",
"Playing note: 69\n",
"Playing note: 65\n",
"Playing note: 62\n",
"Playing note: 71\n",
"Playing note: 74\n",
"Playing note: 65\n",
"Playing note: 67\n",
"Playing note: 69\n",
"Playing note: 64\n",
"Playing note: 62\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"Stream callback called with status: <sounddevice.CallbackFlags: output underflow>.\n",
"Stream callback called with status: <sounddevice.CallbackFlags: output underflow>.\n"
]
}
],
"source": [
"from jupylet.audio.bundle import *\n",
"from clockblocks import *\n",
"from ziffers import *\n",
"\n",
"melody = zparse(\"q 0 4 2 5 e 3 9 4 2 s 5 3 1 6 8 3 4 5 2 1\")\n",
"bpm = 180\n",
"\n",
"clock = Clock(initial_tempo=bpm)\n",
"\n",
"for n in melody.evaluated_values:\n",
" if(isinstance(n, Pitch)):\n",
" print(\"Playing note: \" + str(n.get_note()))\n",
" tb303.play(n.get_note(), n.get_beat())\n",
" clock.wait(n.get_beat())\n",
"\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.0b4"
},
"orig_nbformat": 4
},
"nbformat": 4,
"nbformat_minor": 2
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
from music21 import *
from ziffers import *
# Create melody string
melody = "q 0 2 4 r e 1 4 3 2 s 0 1 2 6 e 2 8 2 1 h 0"
bass_line = "(q 0 2 4 6 e 1 4 3 2 s 0 1 2 6 e 2 8 2 1 h 0)-(7)"
# Parse ziffers notation to melody from string
parsed = zparse(melody)
parsed_bass = zparse(bass_line)
# Convert to music21 objects
part1 = to_music21(parsed, time="4/4")
part2 = to_music21(parsed_bass, time="4/4")
# Add instruments
part1.insert(instrument.Piano())
part2.insert(instrument.Soprano())
# Create score
song = stream.Score()
song.insert(0,part1)
song.insert(0,part2)
# Write to midi file under examples/music21/midi folder
song.write('midi', fp='examples/music21/output/ziffers_example.mid')

View File

@ -0,0 +1,9 @@
from music21 import *
from ziffers import *
# Parse ziiffers object to music21 object
parsed = zparse("q 1 024 5 235 h 02345678{12} 0", key='C', scale='Zyditonic')
s2 = to_music21(parsed,time="4/4")
# Save to MusicXML file
s2.write('musicxml', fp='examples/music21/output/ziffers_example.xml')

View File

@ -0,0 +1,9 @@
from music21 import *
from ziffers import *
# Parse ziiffers object to music21 object
parsed = zparse('1 2 qr e 124')
s2 = to_music21(parsed,time="4/4")
# Save object as png file
s2.write("musicxml.png", fp="examples/music21/output/example.png")

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<encoding-date>2023-06-27</encoding-date>
<software>music21 v.9.1.0</software>
</encoding>
</identification>
<defaults>
<scaling>
<millimeters>7</millimeters>
<tenths>40</tenths>
</scaling>
</defaults>
<part-list>
<score-part id="P2a5e2bdd0bc8bab63f324063ff62ac90">
<part-name />
</score-part>
</part-list>
<!--=========================== Part 1 ===========================-->
<part id="P2a5e2bdd0bc8bab63f324063ff62ac90">
<!--========================= Measure 1 ==========================-->
<measure implicit="no" number="1">
<attributes>
<divisions>10080</divisions>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<note>
<pitch>
<step>D</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>E</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<rest />
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
</note>
<note>
<chord />
<pitch>
<step>E</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>G</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>5040</duration>
<type>eighth</type>
</note>
<note print-object="no" print-spacing="yes">
<rest />
<duration>5040</duration>
<type>eighth</type>
</note>
<barline location="right">
<bar-style>light-heavy</bar-style>
</barline>
</measure>
</part>
</score-partwise>

Binary file not shown.

View File

@ -0,0 +1,228 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<movement-title>Music21 Fragment</movement-title>
<identification>
<creator type="composer">Music21</creator>
<encoding>
<encoding-date>2023-06-27</encoding-date>
<software>music21 v.9.1.0</software>
</encoding>
</identification>
<defaults>
<scaling>
<millimeters>7</millimeters>
<tenths>40</tenths>
</scaling>
</defaults>
<part-list>
<score-part id="P381d8734c7dfe8b1fbfca186c059f010">
<part-name />
</score-part>
</part-list>
<!--=========================== Part 1 ===========================-->
<part id="P381d8734c7dfe8b1fbfca186c059f010">
<!--========================= Measure 1 ==========================-->
<measure implicit="no" number="1">
<attributes>
<divisions>10080</divisions>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<note>
<pitch>
<step>C</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
<accidental>sharp</accidental>
</note>
<note>
<pitch>
<step>C</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>F</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<chord />
<pitch>
<step>A</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>C</step>
<alter>0</alter>
<octave>5</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>F</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<chord />
<pitch>
<step>G</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
</note>
<note>
<chord />
<pitch>
<step>C</step>
<alter>0</alter>
<octave>5</octave>
</pitch>
<duration>10080</duration>
<type>quarter</type>
<accidental>natural</accidental>
</note>
</measure>
<!--========================= Measure 2 ==========================-->
<measure implicit="no" number="2">
<note>
<pitch>
<step>C</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>F</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>G</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>A</step>
<alter>0</alter>
<octave>4</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>C</step>
<alter>0</alter>
<octave>5</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>D</step>
<alter>-1</alter>
<octave>5</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>flat</accidental>
</note>
<note>
<chord />
<pitch>
<step>F</step>
<alter>0</alter>
<octave>5</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>G</step>
<alter>0</alter>
<octave>5</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note>
<chord />
<pitch>
<step>F</step>
<alter>0</alter>
<octave>6</octave>
</pitch>
<duration>20160</duration>
<type>half</type>
<accidental>natural</accidental>
</note>
<note print-object="no" print-spacing="yes">
<rest />
<duration>20160</duration>
<type>half</type>
</note>
<barline location="right">
<bar-style>light-heavy</bar-style>
</barline>
</measure>
</part>
</score-partwise>

View File

@ -0,0 +1,9 @@
from music21 import *
from ziffers import *
# Parse Ziffers string to music21 object
s = to_music21('(i v vi vii^dim)@(q0 e 2 1 q 012)', key="d3", scale="Minor", time="4/4", bpm=190)
# See https://web.mit.edu/music21/doc/installing/installAdditional.html
# Attempt to open / show the midi in MuseScore
s.show()

View File

@ -0,0 +1,17 @@
'''Simple example of using SonicSynth to play Ziffers melody.'''
from sonicsynth import *
from ziffers import *
import numpy as np
melody = zparse("(q 0 4 2 5 e 3 9 4 2 s 5 3 1 6 8 3 4 5 2 1 q 0)+(0 3 -2 4 2)", key="E3", scale="Aerathitonic")
def build_waveform(melody, bpm):
for item in melody.evaluated_values:
if isinstance(item, Pitch):
time_in_seconds = item.duration * 4 * 60 / bpm
yield generate_square_wave(frequency=item.freq, amplitude=0.25, duration=time_in_seconds)
waveform = np.concatenate(list(build_waveform(melody,130)))
player = Playback(44100)
player.play(waveform)

View File

@ -0,0 +1,23 @@
'''Testing arpeggio effect with sonicsynth and ziffers'''
from sonicsynth import *
from ziffers import *
import numpy as np
melody = zparse("(q 0 024 2 246 e 4 2 6 2 q 5 0)+(0 3 1 2)", key="D4", scale="Minor")
def build_waveform(melody, bpm):
for item in melody.evaluated_values:
if isinstance(item, Pitch):
time_in_seconds = item.duration * 4 * 60 / bpm
yield generate_sine_wave(frequency=item.freq, amplitude=0.5, duration=time_in_seconds)
elif isinstance(item, Chord):
time_in_seconds = item.durations[0] * 4 * 60 / bpm
for pitch in item.pitch_classes:
# Create "NES arpeggio effect"
for i in range(1,len(item.durations)):
yield generate_sine_wave(frequency=pitch.freq, amplitude=0.5, duration=time_in_seconds/(len(item.durations)*3))
waveform = np.concatenate(list(build_waveform(melody,180)))
player = Playback(44100)
player.play(waveform)

View File

@ -23,7 +23,7 @@ if __name__ == "__main__":
"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>>>>>",
"Loop cycles": "<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)",
@ -36,7 +36,6 @@ if __name__ == "__main__":
}
for ex in expressions:
try:
print("Parsed: " + parse_expression(expressions[ex]).text)
print(f"{ex}: " + parse_expression(expressions[ex]).text)
except Exception as e:
print(f"[red]Failed on {ex}[/red]")
# print(f"[red]Failed on {ex}[/red]: {str(e)[0:40]}...")
print(f"[red]Failed on {ex}[/red]: "+expressions[ex])

View File

@ -1,6 +1,6 @@
[project]
name = "python-ziffers"
version = "0.1.0"
name = "ziffers"
version = "0.0.1"
description = "Port of the Ziffers numerical notation for Python"
authors = [
{name = "Raphaël Forment", email="raphael.forment@gmail.com"},
@ -13,7 +13,10 @@ requires-python = ">=3.10"
keywords = ["mininotation", "algorithmic music", "parser"]
classifiers = [
"Topic :: Software Development"
"Topic :: Artistic Software",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
@ -22,12 +25,34 @@ dependencies = [
"pytest>=7.2.1",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project.urls]
homepage = "https://github.com/Bubobubobubobubo/ziffers-python"
documentation = "https://github.com/Bubobubobubobubo/ziffers-python"
repository = "https://github.com/Bubobubobubobubo/ziffers-python"
[tool.setuptools]
packages = ['ziffers','ziffers.classes','ziffers.spec']
[tool.black]
line-length = 88
target_version = ['py310']
target_version = ['py311']
include = '\.pyi?$'
exclude = '''
(
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
)
'''

View File

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

14
tests/test_multi_03.py Normal file
View File

@ -0,0 +1,14 @@
""" Test cases for the parser """
import pytest
from ziffers import zparse
@pytest.mark.parametrize(
"pattern,expected",
[
("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):
assert zparse(pattern).collect(6, keys=["pitch_class", "duration"]) == [item*2 for item in expected]

View File

@ -1,54 +0,0 @@
""" 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]),

View File

@ -2,8 +2,6 @@
import pytest
from ziffers import scale
@pytest.mark.parametrize(
"name,expected",
[
@ -49,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

210
tests/test_singular_02.py Normal file
View File

@ -0,0 +1,210 @@
""" Test cases for the parser """
import pytest
from ziffers import zparse, get_items
# pylint: disable=missing-function-docstring, line-too-long, invalid-name
def test_can_parse():
expressions = [
"0 1 2 3 4 5 6 7 8 9 T E",
"023 i iv iv^min",
"[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)>)",
"? 1 2",
"(? 2 ? 4)+(1,4)",
"(1 2 <2 3>)+(0 1 2)"
]
results = []
for expression in expressions:
try:
print(f"Parsing expression: {expression}")
result = zparse(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 zparse(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 get_items(zparse(pattern),len(expected)*2,"pitch_class") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("__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]),
("^ 1 ^1 3 _2 ^^1 _2 <-1> 2 ^4", [1, 2, 1, 0, 3, 0, -1, 0]),
]
)
def test_pitch_octaves(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"octave") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("w [1 [2 3]]", [0.5, 0.25, 0.25]),
("1.0 [1 [2 3]] 4 [3 [4 5]]", [0.5, 0.25, 0.25, 1.0, 0.5, 0.25, 0.25]),
("0.5 (0 0.25 3)+1", [0.5, 0.25]),
("[0 2 <2 8>:2 4] 0", [0.05, 0.05, 0.05, 0.05, 0.05, 0.25])
]
)
def test_subdivisions(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"duration") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("[: 1 [: 2 :] 3 :]", [62, 64, 64, 65, 62, 64, 64, 65]),
("(: 1 (: 2 :) 3 :)", [62, 64, 64, 65, 62, 64, 64, 65]),
("(1 2:2 3):2", [62, 64, 64, 65, 62, 64, 64, 65]),
("1:4",[62,62,62,62])
]
)
def test_repeats(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"note") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("[: 3 [2 4] :]", [0.25, 0.125, 0.125, 0.25, 0.125, 0.125]),
("[: 1 [2 [5 6]] 3 [2 4] :]", [0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125, 0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125]),
("(: 3 [2 4] :)", [0.25, 0.125, 0.125, 0.25, 0.125, 0.125]),
("(: 1 [2 [5 6]] 3 [2 4] :)", [0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125, 0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125]),
("(3 [2 4]):2", [0.25, 0.125, 0.125, 0.25, 0.125, 0.125]),
("(1 [2 [5 6]] 3 [2 4]):2", [0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125, 0.25, 0.125, 0.0625, 0.0625, 0.25, 0.125, 0.125]),
]
)
def test_repeat_durations(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"duration") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("0 [0 2] 0", [0.25, 0.125, 0.125, 0.25, 0.25, 0.125, 0.125, 0.25, 0.25, 0.125, 0.125, 0.25]),
("w 0 [0 [1 [2 3]] 0] 9", [1.0, 0.3333333333333333, 0.16666666666666666, 0.08333333333333333, 0.08333333333333333, 0.3333333333333333, 1.0, 1.0, 0.3333333333333333, 0.16666666666666666, 0.08333333333333333, 0.08333333333333333]),
("1.0 0 [[2 3] [3 5]] 4", [1.0, 0.25, 0.25, 0.25, 0.25, 1.0, 1.0, 0.25, 0.25, 0.25, 0.25, 1.0]),
("2.0 0 [1[2[3[4 5]6]7]8] 9", [2.0, 0.6666666666666666, 0.2222222222222222, 0.07407407407407407, 0.037037037037037035, 0.037037037037037035, 0.07407407407407407, 0.2222222222222222, 0.6666666666666666, 2.0, 2.0, 0.6666666666666666])
]
)
def test_looping_durations(pattern: str, expected: list):
parsed = zparse(pattern)
durations = []
for i in range(12):
durations.append(parsed[i].duration)
assert durations == expected
@pytest.mark.parametrize(
"pattern,expected",
[
("e 1 | 3 | h 3 | e3 | 4", [0.125,0.25,0.5,0.125,0.25])
]
)
def test_measure_durations(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"duration") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("^ 1 | _ 3 | ^3 | 3 | _4", [1,-1,1,0,-1])
]
)
def test_measure_octaves(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"octave") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("e r qr r q r", [0.125, 0.25, 0.125, 0.25])
]
)
def test_rest(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"duration") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("-3..9 1..9", [55, 57, 59, 60, 62, 64, 65, 67, 69, 71, 72, 74, 76, 62, 64, 65, 67, 69, 71, 72, 74, 76])
]
)
def test_ranges(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"note") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("0 1 2 3", [None, 60, 62, 64])
]
)
def test_degree_notation(pattern: str, expected: list):
assert get_items(zparse(pattern, degrees=True),len(expected)*2,"note") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("i ii iii iv v vi vii", [[60, 64, 67], [62, 65, 69], [64, 67, 71], [65, 69, 72], [67, 71, 74], [69, 72, 76], [71, 74, 77]])
]
)
def test_romans(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"note") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("[: i vi v :]", [[0, 2, 4], [5, 0, 2], [4, 6, 1], [0, 2, 4], [5, 0, 2], [4, 6, 1]]),
("i ii iii iv v vi vii", [[0, 2, 4], [1, 3, 5], [2, 4, 6], [3, 5, 0], [4, 6, 1], [5, 0, 2], [6, 1, 3]]),
("i^7 i^min iv^6", [[0, 2, 4, 6], [0, 2, 4], [3, 5, 0, 1]])
]
)
def test_romans_pcs(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"pitches") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("(0 1 2 3 r)+(1)", [1,2,3,4,None]),
("(0 1 2 3 r)-(0 1 4)", [0, -1, -2, -3, None, 1, 0, -1, -2, None, 4, 3, 2, 1, None])
]
)
def test_operations(pattern: str, expected: list):
assert get_items(zparse(pattern),len(expected)*2,"pitch_class") == expected*2
@pytest.mark.parametrize(
"pattern,expected",
[
("(i)+<0 1 2>", [[60, 64, 67], [62, 65, 69], [64, 67, 71], [60, 64, 67]]),
("(i)+<0 <1 2>>",[[60, 64, 67], [62, 65, 69], [60, 64, 67], [64, 67, 71]])
]
)
def test_cycles(pattern: str, expected: list):
zparse.cache_clear() # Clear cache for cycles
assert get_items(zparse(pattern),4,"note") == expected

View File

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

View File

@ -1,676 +0,0 @@
""" 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)

View File

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

645
ziffers/classes/items.py Normal file
View File

@ -0,0 +1,645 @@
""" Ziffers item classes """
from dataclasses import dataclass, field, asdict
from math import floor
import random
from ..scale import (
note_from_pc,
midi_to_pitch_class,
midi_to_freq,
get_scale_length,
chord_from_degree,
)
from ..common import repeat_text
@dataclass(kw_only=True)
class Meta:
"""Abstract class for all Ziffers items"""
kwargs: dict = field(default=None, repr=False)
local_options: dict = field(default_factory=dict)
def __post_init__(self):
if self.kwargs:
self.update_options(self.kwargs)
def replace_options(self, new_values):
"""Replaces attribute values from dict"""
for key, value in new_values.items():
if hasattr(self, key):
setattr(self, key, value)
def update_options(self, options):
"""Updates attribute values only if value is None"""
merged_options = self.local_options | options
for key, value in merged_options.items():
if hasattr(self, key):
if key == "octave":
local_value = self.local_options.get("octave", False)
oct_change = self.local_options.get("octave_change", False)
if oct_change:
setattr(self, key, oct_change)
elif local_value:
setattr(self, key, value + local_value)
elif getattr(self, key) is None:
setattr(self, key, value)
elif getattr(self, key) is None:
local_value = self.local_options.get(key, False)
if local_value:
value = local_value
setattr(self, key, value)
if key == "duration":
setattr(self, "beat", value * 4)
def dict(self):
"""Returns safe dict from the dataclass"""
return {k: str(v) for k, v in asdict(self).items()}
@dataclass(kw_only=True)
class Item(Meta):
"""Class for all Ziffers text based items"""
text: str = field(default=None)
measure: int = field(default=0, init=False)
def get_updated_item(self, options: dict):
"""Get updated item with replaced options
Args:
options (dict): Options as a dict
Returns:
Item: Returns updated item
"""
self.replace_options(options)
return self
def get_options(self) -> dict:
"""Return local options from item
Returns:
dict: Options as a dict
"""
keys = ["octave", "modifier", "key", "scale", "duration"]
return {key: getattr(self, key) for key in keys if hasattr(self, key)}
@dataclass(kw_only=True)
class Whitespace:
"""Class for whitespace"""
text: str
@dataclass(kw_only=True)
class Modification(Item):
"""Superclass for pitch modifications"""
key: str
value: ...
def as_options(self):
"""Return modification as a dict"""
return {self.key: self.value}
@dataclass(kw_only=True)
class DurationChange(Modification):
"""Class for changing duration"""
value: float
key: str = field(default="duration", repr=False, init=False)
@dataclass
class OctaveChange(Modification):
"""Class for changing octave"""
value: int
key: str = field(default="octave", repr=False, init=False)
@dataclass(kw_only=True)
class OctaveAdd(Modification):
"""Class for modifying octave"""
value: int
key: str = field(default="octave", repr=False, init=False)
@dataclass(kw_only=True)
class Event(Item):
"""Abstract class for events with duration"""
duration: float = field(default=None)
beat: float = field(default=None)
def get_duration(self):
"""Getter for duration"""
return self.duration
def get_beat(self):
"""Getter for beat"""
return self.beat
@dataclass
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):
"""Class for measures/bars. Used to reset default options."""
text: str = field(default="|", init=False)
def reset_options(self, options: dict):
"""Reset options when measure changes"""
next_measure = options.get("measure", 0) + 1
start_options = options["start_options"].copy()
options.clear()
options.update(start_options)
options["measure"] = next_measure
options["start_options"] = start_options.copy()
self.measure = next_measure
@dataclass(kw_only=True)
class Pitch(Event):
"""Class for pitch in time"""
pitch_class: int
pitch_bend: int = field(default=None)
octave: int = field(default=None)
modifier: int = field(default=None)
note: int = field(default=None)
key: str = field(default=None)
scale: str | list = field(default=None)
freq: float = field(default=None)
degrees: bool = field(default=None)
def __post_init__(self):
super().__post_init__()
if self.text is None:
self.text = str(self.pitch_class)
self.update_note()
# self._update_text()
def _update_text(self):
if self.octave is not None:
self.text = repeat_text("^", "_", self.octave) + self.text
if self.modifier is not None:
self.text = repeat_text("#", "b", self.modifier) + self.text
def get_note(self):
"""Getter for note"""
return self.note
def get_freq(self):
"""Getter for freq"""
return self.freq
def get_octave(self):
"""Getter for octave"""
return self.octave
def get_beat(self):
"""Getter for beat"""
return self.beat
def get_pitch_class(self):
"""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 (
(self.key is not None)
and (self.scale is not None)
and (self.pitch_class is not None)
and (self.note is None or force)
):
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,
degrees=self.degrees
)
self.pitch_bend = pitch_bend
self.freq = midi_to_freq(note)
self.note = floor(note)
if self.duration is not None:
self.beat = self.duration * 4
def check_note(self, options: dict):
"""Check for note modification"""
if "key" in options and self.key is not options["key"]:
self.key = options["key"]
edit = True
if "scale" in options and self.scale is not options["scale"]:
self.scale = options["scale"]
edit = True
if edit:
self.update_note(True)
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
def set_freq(self, freq: float):
"""Set frequency for the pitch object"""
self.freq = freq
# pylint: disable=locally-disabled, unused-argument
def get_value(self, options) -> 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)
def get_value(self, options: dict) -> int:
"""Return random value
Returns:
int: Returns random pitch
"""
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)
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)
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)
beats: list[float] = field(default=None, init=False)
def __post_init__(self):
if self.inversions is not None:
self.invert(self.inversions)
@property
def note(self):
"""Synonym for notes"""
return self.notes
def set_notes(self, notes: list[int]):
"""Set notes to the class"""
self.notes = notes
def get_note(self):
"""Getter for notes"""
return self.notes
def get_freq(self):
"""Getter for freqs"""
return self.freqs
def get_octave(self):
"""Getter for octaves"""
return self.octaves
def get_beat(self):
"""Getter for beats"""
return self.beats
def get_pitch_class(self):
"""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
def invert(self, value: int):
"""Chord inversion"""
new_pitches = (
list(reversed(self.pitch_classes)) if value < 0 else self.pitch_classes
)
for _ in range(abs(value)):
new_pitch = new_pitches[_ % len(new_pitches)]
if not new_pitch.local_options.get("octave"):
new_pitch.local_options["octave"] = 0
new_pitch.local_options["octave"] += -1 if value <= 0 else 1
self.pitch_classes = new_pitches
def update_notes(self, options=None):
"""Update notes"""
pitches, pitch_bends, notes, freqs, octaves, durations, beats = ([] for _ in range(7))
# Update notes
for pitch in self.pitch_classes:
if options is not None:
pitch.update_options(options)
pitch.update_note(True)
# Sort by generated notes
self.pitch_classes = sorted(self.pitch_classes, key=lambda x: x.note)
# 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)
durations.append(pitch.duration)
beats.append(pitch.beat)
self.pitches = pitches
self.pitch_bends = pitch_bends
self.notes = notes
self.freqs = freqs
self.octaves = octaves
self.durations = durations
self.duration = durations[0]
self.beats = beats
self.text = "".join([val.text for val in self.pitch_classes])
@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=None, init=False)
pitch_classes: list = field(default=None, init=False)
inversions: int = field(default=None)
evaluated_chord: Chord = 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))
def evaluate_chord(self, options: dict) -> Chord:
"""Create chord fom roman numeral"""
key = options["key"]
scale = options["scale"]
pitch_text = ""
pitch_classes = []
self.notes = chord_from_degree(
self.value, self.chord_type, options["scale"], options["key"]
)
for note in self.notes:
pitch_dict = midi_to_pitch_class(note, key, scale)
pitch_classes.append(
Pitch(
pitch_class=pitch_dict["pitch_class"],
note=note,
freq=midi_to_freq(note),
kwargs=(options | pitch_dict),
)
)
pitch_text += pitch_dict["text"]
chord = Chord(
text=pitch_text,
pitch_classes=pitch_classes,
duration=options["duration"],
notes=self.notes,
kwargs=options,
inversions=self.inversions,
)
chord.update_notes(options)
self.evaluated_chord = chord
return chord
@dataclass(kw_only=True)
class Function(Event):
"""Class for functions"""
run: ... = field(default=None)
@dataclass(kw_only=True)
class FunctionList(Event):
"""Class for functions"""
values: list
@dataclass(kw_only=True)
class VariableAssignment(Item):
"""Class for defining variables"""
variable: str
value: Item
pre_eval: bool
@dataclass(kw_only=True)
class Variable(Event):
"""Class for using variables"""
name: str
@dataclass(kw_only=True)
class Sample(Event):
"""Class for samples"""
name: str
@dataclass(kw_only=True)
class SampleList(Event):
"""Class for using multiple samples"""
values: list
@dataclass(kw_only=True)
class VariableList(Item):
"""Class for using variables"""
values: list
@dataclass(kw_only=True)
class Integer(Item):
"""Class for integers"""
value: int
# pylint: disable=locally-disabled, unused-argument
def get_value(self, options):
"""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, options: dict = None):
"""Evaluate the random value for the generator"""
return random.randint(self.min, self.max)
@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, options=None):
"""Get the value for the current cycle"""
value = self.values[self.cycle % len(self.values)]
self.cycle += 1
if options:
value.update_options(options)
return value
@dataclass(kw_only=True)
class Range(Item):
"""Class for range"""
start: int = field(default=None)
end: int = field(default=None)
def evaluate(self, options):
"""Evaluates range and generates a generator of Pitches"""
merged_options = options | self.local_options
if options["octave"]:
merged_options["octave"] += options["octave"]
if self.start < self.end:
for i in range(self.start, self.end + 1):
yield Pitch(pitch_class=i, kwargs=merged_options)
elif self.start > self.end:
for i in reversed(range(self.end, self.start + 1)):
yield Pitch(pitch_class=i, kwargs=merged_options)
else:
yield Pitch(pitch_class=self.start, kwargs=merged_options)
@dataclass(kw_only=True)
class Operator(Item):
"""Class for math operators"""
value: ...
@dataclass(kw_only=True)
class Atom(Item):
"""Class for evaluable atoms"""
value: ...

205
ziffers/classes/root.py Normal file
View File

@ -0,0 +1,205 @@
"""Root class for Ziffers object"""
from dataclasses import dataclass, field
from itertools import islice, cycle
from ..defaults import DEFAULT_OPTIONS
from .items import Item, Pitch, Chord, Event, Rest
from .sequences import Sequence, Subdivision
@dataclass(kw_only=True)
class Ziffers(Sequence):
"""Main class for holding options and the current state"""
options: dict = field(default_factory=DEFAULT_OPTIONS.copy())
start_options: dict = None
loop_i: int = field(default=0, init=False)
cycle_i: int = field(default=0, init=False)
iterator = None
current: Item = field(default=None)
cycle_length: int = field(default=0, init=False)
def __getitem__(self, index):
self.loop_i = index % self.cycle_length
new_cycle = index // self.cycle_length
# Re-evaluate if the prior loop has ended
if new_cycle > self.cycle_i or new_cycle < self.cycle_i:
self.re_eval()
self.cycle_i = new_cycle
self.cycle_length = len(self.evaluated_values)
self.loop_i = index % self.cycle_length
return self.evaluated_values[self.loop_i]
def __len__(self):
return len(self.evaluated_values)
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.copy())
if options:
self.options.update(options)
else:
self.options = DEFAULT_OPTIONS.copy()
self.start_options = self.options.copy()
self.options["start_options"] = self.start_options
self.init_tree(self.options)
def re_eval(self):
"""Re-evaluate the iterator"""
self.options = self.start_options.copy()
self.options["start_options"] = self.start_options
self.init_tree(self.options)
def init_tree(self, options):
"""Initialize evaluated values and perform post-evaluation"""
self.evaluated_values = list(self.evaluate_tree(options))
self.evaluated_values = list(self.post_evaluation())
self.iterator = iter(self.evaluated_values)
self.cycle_length = len(self.evaluated_values)
def post_evaluation(self):
"""Post-evaluation performs evaluation that can only be done after initial evaluation"""
for item in self.evaluated_values:
if isinstance(item, Subdivision):
yield from item.evaluate_durations()
else:
yield item
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(islice(cycle(self), num))
def loop(self) -> iter:
"""Return cyclic loop"""
return 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.copy() | options
def pitch_classes(self) -> list[int]:
"""Return list of pitch classes as ints"""
return [
val.get_pitch_class()
for val in self.evaluated_values
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, Rest))
]
def notes(self) -> list[int]:
"""Return list of midi notes"""
return [
val.get_note()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord, Rest))
]
def durations(self) -> list[float]:
"""Return list of pitch durations as floats"""
return [
val.get_duration()
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)]
)
def total_beats(self) -> float:
"""Return total beats"""
return sum(self.beats())
def beats(self) -> list[float]:
"""Return list of pitch durations as floats"""
return [
val.get_beat() for val in self.evaluated_values if isinstance(val, Event)
]
def pairs(self) -> list[tuple]:
"""Return list of pitches and durations"""
return [
[val.get_pitch_class(), val.get_duration()]
for val in self.evaluated_values
if isinstance(val, Pitch) or isinstance(val, Chord) or isinstance(val, Rest)
]
def freq_pairs(self) -> list[tuple]:
"""Return list of pitches in freq and durations"""
return [
[val.get_freq(), val.get_duration()]
for val in self.evaluated_values
if isinstance(val, Pitch) or isinstance(val, Chord) or isinstance(val, Rest)
]
def octaves(self) -> list[int]:
"""Return list of octaves"""
return [
val.get_octave()
for val in self.evaluated_values
if isinstance(val, (Pitch, Chord, Rest))
]
def freqs(self) -> list[int]:
"""Return list of octaves"""
return [
val.get_freq()
for val in self.evaluated_values
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:
num = len(self.evaluated_values)
if keys is None or isinstance(keys, str):
keys = [keys]
all_items = []
values = []
for key in keys:
for i in range(num):
if key is not None:
values.append(getattr(self[i], key, None))
else:
values.append(self[i])
all_items.append(values)
values = []
if len(all_items) > 1:
return all_items
if len(all_items) == 1:
return all_items[0]
return None

View File

@ -0,0 +1,739 @@
""" Sequence classes for Ziffers """
from dataclasses import dataclass, field, replace
from itertools import product
from math import floor
import random
from types import LambdaType
from copy import deepcopy
import operator
from ..defaults import DEFAULT_OPTIONS
from ..common import cyclic_zip, euclidian_rhythm, flatten
from ..scale import note_from_pc, midi_to_freq
from .items import (
Meta,
Item,
Event,
DurationChange,
OctaveChange,
OctaveAdd,
Pitch,
Rest,
RandomPitch,
Chord,
RomanNumeral,
Cyclic,
RandomInteger,
Range,
Integer,
VariableAssignment,
Variable,
VariableList,
Measure,
Function,
Modification,
Whitespace,
Sample,
SampleList,
)
# TODO: Could be refactored to each class?
def resolve_item(item: Meta, options: dict):
"""Resolve cyclic value"""
if isinstance(item, Sequence):
if isinstance(item, ListOperation):
yield from item.evaluate(options)
elif isinstance(item, (RepeatedSequence, RepeatedListSequence)):
yield from item.resolve_repeat(options)
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):
if item.pre_eval:
pre_options = options.copy()
pre_options["pre_eval"] = True
options[item.variable.name] = Sequence(
values=list(resolve_item(item.value, pre_options))
)
else:
options[item.variable.name] = item.value
elif isinstance(item, Variable):
if options[item.name]:
if item.name in options:
opt_item = options[item.name]
if isinstance(opt_item, LambdaType):
yield Function(
run=opt_item,
text=item.text,
kwargs=(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,
)
variable = deepcopy(opt_item)
yield from resolve_item(variable, options)
elif isinstance(item, VariableList):
seqlist = []
sample_list = True
for var in item.values:
if var.name in options:
opt_item = options[var.name]
if isinstance(opt_item, LambdaType):
sample_list = False
seqlist.append(
Function(
run=opt_item,
text=var.text,
kwargs=(options | var.local_options),
local_options=var.local_options,
)
)
elif isinstance(opt_item, str):
seqlist.append(
Sample(
name=opt_item,
text=var.text,
kwargs=(options | var.local_options),
local_options=var.local_options,
)
)
elif isinstance(opt_item, Sequence):
sample_list = False
seqlist.append(opt_item)
if len(seqlist) > 0:
if sample_list:
yield SampleList(values=seqlist, kwargs=options)
else:
yield PolyphonicSequence(values=seqlist)
elif isinstance(item, Range):
yield from item.evaluate(options)
elif isinstance(item, Cyclic):
yield from resolve_item(item.get_value(), options)
elif isinstance(item, Euclid):
yield from euclidean_items(item, options)
elif isinstance(item, Modification):
update_modifications(item, options)
elif isinstance(item, Measure):
item.reset_options(options)
elif options["degrees"] is True and isinstance(item, Pitch) and item.pitch_class == 0:
yield Rest(text="r", kwargs=options)
elif isinstance(item, Meta): # Filters whitespace
yield update_item(item, options)
def resolve_integer_value(item, options):
"""Helper for resolving integer value of different types"""
while isinstance(item, Cyclic):
item = item.get_value(options)
if isinstance(item, Pitch):
return item.get_value(options)
if isinstance(item, Integer):
return item.get_value(options)
return item
def update_item(item, options):
"""Update or create new pitch"""
if set(("key", "scale")) <= options.keys():
if isinstance(item, Pitch):
item.update_options(options)
item.update_note()
if options.get("pre_eval", False):
item.duration = options["duration"]
if isinstance(item, Rest):
item.update_options(options)
elif isinstance(item, (RandomPitch, RandomInteger)):
item = create_pitch(item, options)
elif isinstance(item, Chord):
item.update_options(options)
item.update_notes(options)
elif isinstance(item, RomanNumeral):
item = item.evaluate_chord(options)
return item
def euclidean_items(euclid: Item, options: dict):
"""Loops values from generated euclidean sequence"""
euclid.evaluate(options)
for item in euclid.evaluated_values:
yield from resolve_item(item, options)
def update_modifications(current: Item, options: dict) -> dict:
"""Update options based on current item"""
if isinstance(current, (OctaveChange, DurationChange)):
options[current.key] = current.value
elif isinstance(current, OctaveAdd):
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
def create_pitch(current: Item, options: dict) -> Pitch:
"""Create pitch based on values and options"""
merged_options = options | current.local_options
if "modifier" in merged_options:
c_modifier = merged_options["modifier"]
else:
c_modifier = 0
if hasattr(current, "modifier") and current.modifier is not None:
c_modifier += current.modifier
if "octave" in merged_options:
c_octave = merged_options["octave"]
if "octave" in options:
c_octave = options["octave"] + c_octave
else:
c_octave = 0
if hasattr(current, "octave") and current.octave is not None:
c_octave += current.octave
current_value = current.get_value(merged_options)
note, pitch_bend = note_from_pc(
root=merged_options["key"],
pitch_class=current_value,
intervals=merged_options["scale"],
modifier=c_modifier,
octave=c_octave,
degrees=merged_options["degrees"]
)
new_pitch = Pitch(
pitch_class=current_value,
text=str(current_value),
freq=midi_to_freq(note),
note=floor(note),
pitch_bend=pitch_bend,
octave=c_octave,
modifier=c_modifier,
kwargs=merged_options,
)
return new_pitch
@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)
evaluated_values: list = field(default=None)
def __post_init__(self):
super().__post_init__()
self.text = self.__collect_text()
self.update_local_options()
def __getitem__(self, index):
return self.values[index]
def __len__(self):
return len(self.values)
def update_local_options(self):
"""Update value attributes from dict"""
if self.local_options:
for obj in self.values:
if isinstance(obj, Event):
if obj.local_options:
obj.local_options = (
obj.local_options | self.local_options.copy()
)
else:
obj.local_options = self.local_options.copy()
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: dict = None, eval_tree: bool = False):
"""Evaluate the tree and return array of resolved pitches
Args:
options (dict, optional): Options for the pitches. Defaults to None.
eval_tree (bool, optional): Flag for using the evaluated subtree. Defaults to False.
"""
# Start of the main function: Evaluate and flatten the Ziffers object tree
values = self.evaluated_values if eval_tree else self.values
for item in values:
yield from resolve_item(item, options)
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)]
)
@dataclass(kw_only=True)
class PolyphonicSequence:
"""Class for polyphonic sequence"""
values: list
@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 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)
def resolve_repeat(self, options: dict):
"""Repeats items and generates new random values"""
repeats = self.repeats.get_value(options)
if not isinstance(repeats, int):
repeats = resolve_integer_value(repeats, options)
for _ in range(repeats):
for item in self.evaluate_tree(options):
yield from resolve_item(item, options)
@dataclass(kw_only=True)
class Subdivision(Sequence):
"""Class for subdivisions"""
full_duration: float = field(default=None, init=False)
def evaluate_values(self, options):
"""Evaluate values and store to evaluated_values"""
self.full_duration = options["duration"]
self.evaluated_values = list(self.evaluate_tree(options))
def evaluate_durations(self, duration=None):
"""Calculate new durations by dividing with the number of items in the sequence"""
if duration is None:
duration = self.full_duration
new_d = duration / len(self.evaluated_values)
for item in self.evaluated_values:
if isinstance(item, Subdivision):
yield from item.evaluate_durations(new_d)
if isinstance(item, Event):
if duration is not None:
item.duration = new_d
item.beat = new_d * 4
yield item
@dataclass(kw_only=True)
class ListOperation(Sequence):
"""Class for list operations"""
evaluated_values: list = None
def evaluate(self, options=DEFAULT_OPTIONS.copy()):
"""Evaluates the operation"""
def _filter_whitespace(input_list):
for item in input_list:
if isinstance(item, Meta):
yield item
def _filter_operation(input_list, options):
"""Filter and evaluate values"""
flattened_list = []
for item in input_list:
if isinstance(item, (list, Sequence)):
if isinstance(item, ListOperation):
flattened_list.extend(item.evaluated_values)
elif isinstance(item, Subdivision):
item.evaluate_values(options)
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):
value = item.get_value(options)
if isinstance(value, Sequence):
flattened_list.extend(_filter_operation(value, options))
elif isinstance(value, (Event, RandomInteger, Integer)):
flattened_list.append(value)
elif isinstance(item, Modification):
options = options | item.as_options()
elif isinstance(item, RomanNumeral):
item = item.evaluate_chord(options)
flattened_list.append(item)
elif isinstance(item, Range):
flattened_list.extend(list(item.evaluate(options)))
elif isinstance(item, (Event, RandomInteger, Integer)):
item.update_options(options)
item = update_item(item, options)
flattened_list.append(item)
if isinstance(input_list, Sequence):
return replace(input_list, values=flattened_list)
return flattened_list
def _pick_from_list(left, right, options):
"""Pick random numbers from a list"""
if isinstance(left, Sequence):
left = _filter_operation(left, options)
if isinstance(right, Sequence):
right = _filter_operation(right, options)
if not isinstance(right, (list, Sequence)):
right = Sequence(values=[right])
result = []
for num in right.values:
for _ in range(num.get_value(options)):
result.append(random.choice(left.values))
return flatten(result)
def _select_from_list(left, right, options):
"""Select number of items from shuffled list"""
if isinstance(left, Sequence):
left = _filter_operation(left, options)
if isinstance(right, Sequence):
right = _filter_operation(right, options)
if not isinstance(right, (list, Sequence)):
right = Sequence(values=[right])
result = []
left = left.values
for num in right.values:
random.shuffle(left)
num = num.get_value(options)
new_list = [left[i % len(left)] for i in range(num)]
result += new_list
return flatten(result)
def _vertical_arpeggio(left, right, options):
"""Vertical arpeggio operation, eg. (135)@(q 1 2 021)"""
left = _filter_operation(left, options)
right = _filter_operation(right, options)
if not isinstance(left, list):
left = list(left.evaluate_tree(options))
if not isinstance(right, list):
right = list(right.evaluate_tree(options))
arp_items = []
for item in left:
for index in right:
pcs = item.pitch_classes
if isinstance(index, Pitch):
new_pitch = deepcopy(pcs[index.get_value(options) % len(pcs)])
new_pitch.duration = index.duration
arp_items.append(new_pitch)
else: # Should be a chord
new_pitches = []
for pitch in index.pitch_classes:
new_pitch = deepcopy(
pcs[pitch.get_value(options) % len(pcs)]
)
new_pitch.duration = pitch.duration
new_pitches.append(new_pitch)
new_chord = Chord(pitch_classes=new_pitches, kwargs=options)
new_chord.update_notes()
new_chord.text = "".join(
[val.text for val in new_chord.pitch_classes]
)
arp_items.append(new_chord)
return Sequence(values=arp_items)
def _horizontal_arpeggio(left, right, options):
"""Horizontal arpeggio operation, eg. (1 2 3 4)#(0 3 2 1)"""
left = _filter_operation(left, options)
right = _filter_operation(right, options)
left = list(left.evaluate_tree(options))
right = list(right.evaluate_tree(options))
arp_items = []
for index in right:
new_item = deepcopy(left[index.get_value(options) % len(left)])
arp_items.append(new_item)
return Sequence(values=arp_items)
def _cyclic_zip(left, right, options):
"""Cyclic zip operaiton, eg. (q e)<>(1 2 3)"""
left = list(_filter_whitespace(left))
right = list(_filter_whitespace(right))
result = Sequence(values=cyclic_zip(left, right))
return _filter_operation(result, options)
def _python_operations(left, right, options):
"""Python math operations"""
def __chord_operation(chord, pitch_y, yass, options):
"""Operation for single chords"""
new_pitches = []
pitch_y = pitch_y.get_value(options)
for pitch_x in chord.pitch_classes:
pitch_x = pitch_x.pitch_class
pitch_y = resolve_integer_value(pitch_y, options)
new_pitch = Pitch(
pitch_class=operation(
pitch_y if yass else pitch_x, pitch_x if yass else pitch_y
),
kwargs=options,
)
new_pitches.append(new_pitch)
new_chord = Chord(pitch_classes=new_pitches, kwargs=options)
new_chord.update_notes()
return new_chord
# _python_operation starts. Filter & evaluate items.
left = _filter_operation(left, options)
if isinstance(right, Sequence):
right = _filter_operation(right, options)
elif isinstance(right, Cyclic):
right = right.get_value(options)
# Create product of items.
pairs = product(
(right.values if isinstance(right, Sequence) else [right]), left
)
results = []
for first, second in pairs:
if isinstance(first, Chord) and isinstance(second, Chord):
new_pitches = []
for pitch_x in first.pitch_classes:
for pitch_y in second.pitch_classes:
new_pitch = Pitch(
pitch_class=operation(
pitch_x.pitch_class, pitch_y.pitch_class
),
kwargs=options,
)
new_pitches.append(new_pitch)
new_chord = Chord(pitch_classes=new_pitches, kwargs=options)
new_chord.update_notes()
outcome = new_chord
elif isinstance(first, Chord):
outcome = __chord_operation(first, second, False, options)
elif isinstance(second, Chord):
outcome = __chord_operation(second, first, True, options)
elif isinstance(first, Rest) or isinstance(second, Rest):
outcome = Rest(duration=first.get_duration())
else:
outcome = Pitch(
pitch_class=operation(
first.get_value(options), second.get_value(options)
),
kwargs=second.get_options(),
)
results.append(outcome)
return results
# Start of the evaluate() function
operators = self.values[1::2] # Fetch every second operator element
values = self.values[::2] # Fetch every second list element
# values = _filter_operation(values, options) # Filter out
if len(values) == 1:
return values[0] # If right hand doesnt contain anything sensible
left = values[0] # Start results with the first array
for i, operand in enumerate(operators):
operation = operand.value
right = values[i + 1]
if isinstance(operation, str):
if operation == "vertical":
left = _vertical_arpeggio(left, right, options)
elif operation == "horizontal":
left = _horizontal_arpeggio(left, right, options)
elif operation == "zip":
left = _cyclic_zip(left, right, options)
elif operation == "pick":
left = _pick_from_list(left, right, options)
elif operation == "select":
left = _select_from_list(left, right, options)
else:
left = _python_operations(left, right, options)
return left
@dataclass(kw_only=True)
class Eval(Sequence):
"""Class for evaluation notation"""
wrap_start: str = field(default="{", repr=False)
wrap_end: str = field(default="}", repr=False)
# 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)
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)
evaluated_values: list = None
def resolve_repeat(self, options):
"""Resolves all items"""
self.evaluate_values(options)
repeats = self.repeats.get_value(options)
if not isinstance(repeats, int):
repeats = resolve_integer_value(repeats, options)
for _ in range(repeats):
for item in self.evaluated_values:
yield from resolve_item(item, options)
def evaluate_values(self, options):
"""Evaluate values and store to evaluated_values"""
self.evaluated_values = list(self.evaluate(options))
def evaluate(self, options: dict):
"""Evaluate repeated sequence partially. Leaves Cycles intact."""
self.local_options = options.copy()
for item in self.values:
if isinstance(item, Sequence):
if isinstance(item, ListOperation):
yield from item.evaluate_tree(self.local_options, True)
elif isinstance(item, RepeatedSequence):
yield item
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):
yield item # Return the cycle instead of values
elif isinstance(item, Modification):
self.local_options = self.local_options | item.as_options()
elif isinstance(item, Rest):
yield item.get_updated_item(self.local_options)
elif isinstance(item, Range):
yield from item.evaluate(self.local_options)
elif isinstance(item, (Pitch, Chord, RomanNumeral)):
yield item
elif isinstance(item, (Event, RandomInteger)):
yield Pitch(
pitch_class=item.get_value(self.local_options),
kwargs=self.local_options,
)
@dataclass(kw_only=True)
class Euclid(Item):
"""Class for euclidean cycles"""
pulses: int
length: int
onset: ListSequence
offset: ListSequence = field(default=None)
rotate: int = field(default=0)
evaluated_values: list = field(default=None)
def evaluate(self, options):
"""Evaluate values using euclidean spread"""
onset_values = [
val for val in self.onset.values if not isinstance(val, Whitespace)
]
onset_length = len(onset_values)
booleans = euclidian_rhythm(self.pulses, self.length, self.rotate)
self.evaluated_values = []
if self.offset is not None:
offset_values = [
val for val in self.offset.values if not isinstance(val, Whitespace)
]
offset_length = len(offset_values)
on_i = 0
off_i = 0
for i in range(self.length):
if booleans[i]:
value = onset_values[on_i % onset_length]
on_i += 1
else:
if self.offset is None:
value = Rest(duration=options["duration"])
else:
value = offset_values[off_i % offset_length]
off_i += 1
self.evaluated_values.append(value)

View File

@ -1,12 +1,39 @@
""" Common methods used in parsing """
import re
from copy import deepcopy
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]
if isinstance(arr, list)
else [arr]
)
def rotate(arr, k):
"""Rotates array"""
# Calculate the effective rotation amount (mod the array length)
k = k % len(arr)
# Rotate the array to the right
if k > 0:
arr = arr[-k:] + arr[:-k]
# Rotate the array to the left
elif k < 0:
arr = arr[-k:] + arr[:-k]
return arr
def repeat_text(pos, neg, times):
"""Helper to repeat text"""
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]
@ -17,3 +44,73 @@ def sum_dict(arr: list[dict]) -> dict:
else:
result[key] = element[key]
return result
def string_rewrite(axiom: str, rules: dict):
"""String rewrite / Lindemeyer system for rule based text manipulation
Args:
axiom (str): Input string
rules (dict): String manipulation rules in dict:
Example:
rules = {
"1": "2",
"[2-9]": "45",
"4": lambda: str(randint(1, 7)),
"([1-9])(5)": lambda a, b: str(int(a)*int(b))
}
for i range(10):
print(string_rewrite("1", rules))
"""
def _apply_rules(match):
for key, value in rules.items():
if re.match(key, match.group(0)):
if callable(value):
yield value(
*match.groups()
) if value.__code__.co_argcount > 0 else value()
yield value
pattern = re.compile("|".join(rules.keys()))
return pattern.sub(lambda m: next(_apply_rules(m)), axiom)
def euclidian_rhythm(pulses: int, length: int, rot: int = 0):
"""Calculate Euclidean rhythms. Original algorithm by Thomas Morrill."""
def _starts_descent(arr, index):
length = len(arr)
next_index = (index + 1) % length
return arr[index] > arr[next_index]
def rotation(arr, idx):
return arr[-idx:] + arr[:-idx]
if pulses >= length:
return [True]
res_list = [pulses * t % length for t in range(-1, length - 1)]
bool_list = [_starts_descent(res_list, index) for index in range(length)]
return rotation(bool_list, rot)
def cyclic_zip(first: list, second: list) -> list:
"""Cyclic zip method
Args:
first (list): First list is cycled
second (list): Second list
Returns:
list: Cyclicly zipped list
"""
result = []
s_length = len(second)
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]

131
ziffers/converters.py Normal file
View File

@ -0,0 +1,131 @@
"""Collection of converters"""
from ziffers import zparse, Ziffers, Pitch, Rest, Chord, accidentals_from_note_name, MODES, MODE_ACCIDENTALS
try:
from music21 import converter, note, stream, meter, chord, environment, tempo, key
music21_imported: bool = True
except ImportError:
music21_imported: bool = False
try:
import ctcsound
csound_imported: bool = True
except (ImportError, TypeError) as Error:
csound_imported: bool = False
def ziffers_to_csound_score(ziffers: Ziffers, bpm: int=80, amp: float=1500, instr: (int|str)=1) -> str:
""" Transform Ziffers object to Csound score in format:
i {instrument} {start time} {duration} {amplitude} {frequency} """
if not csound_imported:
raise ImportError("Install Csound")
score = ""
instr = f'"{instr}"' if isinstance(instr, str) else instr
start_time = 0
for item in ziffers.evaluated_values:
if isinstance(item, Chord):
for freq, dur in zip(item.get_freq(), item.get_duration()):
score += f"i {instr} {start_time} {dur * 4 * 60 / bpm} {amp} {freq:.2f}\n"
start_time += max(item.get_duration()) * 4 * 60 / bpm
elif isinstance(item, Rest):
score += f"i {instr} {start_time} {item.get_duration() * 4 * 60 / bpm} {amp} 0\n"
elif isinstance(item, Pitch):
score += f"i {instr} {start_time} {item.get_duration() * 4 * 60 / bpm} {amp} {item.get_freq():.2f}\n"
start_time += item.get_duration() * 4 * 60 / bpm
return score
def to_music21(expression: str | Ziffers, **options):
"""Helper for passing options to the parser"""
if not music21_imported:
raise ImportError("Install Music21 library")
# Register the ZiffersMusic21 converter
converter.registerSubConverter(ZiffersMusic21)
if isinstance(expression, Ziffers):
if options:
options["preparsed"] = expression
else:
options = {"preparsed": expression}
options = {"ziffers": options}
return converter.parse("PREPARSED", format="ziffers", keywords=options)
if options:
options = {"ziffers": options}
return converter.parse(expression, format="ziffers", keywords=options)
test = converter.parse(expression, format="ziffers")
return test
def set_musescore_path(path: str):
"""Helper for setting the Musescore path"""
settings = environment.UserSettings()
# Default windows path:
# 'C:\\Program Files\\MuseScore 3\\bin\\MuseScore3.exe'
settings["musicxmlPath"] = path
settings["musescoreDirectPNGPath"] = path
if music21_imported:
# pylint: disable=locally-disabled, invalid-name, unused-argument, attribute-defined-outside-init
class ZiffersMusic21(converter.subConverters.SubConverter):
"""Ziffers converter to Music21"""
registerFormats = ("ziffers",)
registerInputExtensions = ("zf",)
def parseData(self, dataString, number=None):
"""Parses Ziffers string to Music21 object"""
# Look for options in keywords object
keywords = self.keywords["keywords"]
if "ziffers" in keywords:
options = keywords["ziffers"]
if "preparsed" in options:
parsed = options["preparsed"]
else:
parsed = zparse(dataString, **options)
else:
parsed = zparse(dataString)
note_stream = stream.Part()
if "time" in options:
m_item = meter.TimeSignature(options["time"]) # Common time
else:
m_item = meter.TimeSignature("c") # Common time
note_stream.insert(0, m_item)
if "key" in options:
accidentals = accidentals_from_note_name(options["key"])
else:
accidentals = 0
if "scale" in options:
scale_upper = options["scale"].upper()
scale_lower = options["scale"].lower()
if scale_upper in MODES:
accidentals += MODE_ACCIDENTALS[scale_upper]
note_stream.append(key.KeySignature(accidentals,mode=scale_lower))
else:
note_stream.append(key.KeySignature(accidentals))
if "bpm" in options:
note_stream.append(tempo.MetronomeMark(number=options["bpm"]))
for item in parsed:
if isinstance(item, Pitch):
m_item = note.Note(item.note)
m_item.duration.quarterLength = item.duration * 4
elif isinstance(item, Rest):
m_item = note.Rest(item.duration * 4)
elif isinstance(item, Chord):
m_item = chord.Chord(item.notes)
m_item.duration.quarterLength = item.duration * 4
note_stream.append(m_item)
# TODO: Is this ok?
self.stream = note_stream.makeMeasures()

File diff suppressed because it is too large Load Diff

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,85 +1,147 @@
""" Lark transformer for mapping Lark tokens to Ziffers objects """
from typing import Optional
from lark import Transformer
from .classes import (
Ziffers,
import random
from lark import Transformer, Token
from .scale import cents_to_semitones, ratio_to_cents, monzo_to_cents
from .classes.root import Ziffers
from .classes.sequences import (
Sequence,
ListSequence,
RepeatedListSequence,
ListOperation,
RepeatedSequence,
Euclid,
Subdivision,
Eval,
Operation,
LispOperation,
)
from .classes.items import (
Whitespace,
DurationChange,
OctaveChange,
OctaveAdd,
Pitch,
Rest,
RandomPitch,
RandomPercent,
Chord,
RomanNumeral,
Sequence,
ListSequence,
RepeatedListSequence,
Subdivision,
Cyclic,
RandomInteger,
Range,
Operator,
ListOperation,
Operation,
Eval,
Atom,
Integer,
Euclid,
RepeatedSequence,
VariableAssignment,
Variable,
VariableList,
Measure,
)
from .common import flatten, sum_dict
from .defaults import DEFAULT_DURS, OPERATORS
from .scale import parse_roman, chord_from_roman_numeral
from .scale import parse_roman
# 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 rest(self, items):
"""Return duration event"""
if len(items) > 0:
prefixes = sum_dict(items)
text_prefix = prefixes.pop("text")
prefixes["prefix"] = text_prefix
return Rest(text=text_prefix + "r", local_options=prefixes)
return Rest(text="r")
def range(self, item) -> Range:
def measure(self, items):
"""Return new measure"""
return Measure()
def random_integer(self, items) -> RandomInteger:
"""Parses random integer syntax"""
if len(items) > 1:
prefixes = sum_dict(items[0:-1]) # If there are prefixes
text_prefix = prefixes.pop("text")
prefixes["prefix"] = text_prefix
val = items[-1][1:-1].split(",")
return RandomInteger(
min=int(val[0]),
max=int(val[1]),
text=text_prefix + items[-1],
local_options=prefixes,
)
else:
val = items[0][1:-1].split(",")
return RandomInteger(min=int(val[0]), max=int(val[1]), text=items[0])
def random_integer_re(self, items):
"""Return random integer notation from regex"""
return items[0].value
def range(self, items) -> Range:
"""Parses range syntax"""
val = item[0].split("..")
return Range(start=val[0], end=val[1], text=item[0])
if len(items) > 1:
prefixes = sum_dict(items[0:-1]) # If there are prefixes
text_prefix = prefixes.pop("text")
prefixes["prefix"] = text_prefix
val = items[-1].split("..")
return Range(
start=int(val[0]),
end=int(val[1]),
text=text_prefix + items[-1],
local_options=prefixes,
)
# Else
val = items[0].split("..")
return Range(start=int(val[0]), end=int(val[1]), text=items[0])
def range_re(self, items):
"""Return range value from regex"""
return items[0].value
def cycle(self, items) -> Cyclic:
"""Parses cycle"""
values = items[0]
return Cyclic(values=values)
def pitch_class(self, item):
def pitch_class(self, items):
"""Parses pitch class"""
# If there are prefixes
if len(item) > 1:
if len(items) > 1:
# Collect&sum prefixes from any order: _qee^s4 etc.
result = sum_dict(item)
return Pitch(**result)
prefixes = sum_dict(items[0:-1]) # If there are prefixes
text_prefix = prefixes.pop("text")
prefixes["prefix"] = text_prefix
pitch = Pitch(
pitch_class=items[-1]["pitch_class"],
text=text_prefix + items[-1]["text"],
local_options=prefixes,
)
return pitch
val = item[0]
val = items[0]
return Pitch(**val)
def pitch(self, items):
"""Return pitch class info"""
return {"pitch_class": int(items[0].value), "text": items[0].value}
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"""
@ -88,7 +150,10 @@ class ZiffersTransformer(Transformer):
def oct_change(self, items):
"""Parses octave change"""
octave = items[0]
return [OctaveChange(value=octave["octave"], text=octave["text"]), items[1]]
return [
OctaveChange(value=octave["octave_change"], text=octave["text"]),
items[1],
]
def oct_mod(self, items):
"""Parses octave modification"""
@ -98,7 +163,7 @@ class ZiffersTransformer(Transformer):
def escaped_octave(self, items):
"""Return octave info"""
value = items[0][1:-1]
return {"octave": int(value), "text": items[0].value}
return {"octave_change": int(value), "text": items[0].value}
def octave(self, items):
"""Return octaves ^ and _"""
@ -108,26 +173,48 @@ class ZiffersTransformer(Transformer):
def modifier(self, items):
"""Return modifiers # and b"""
value = 1 if items[0].value == "#" else -1
return {"modifier": value}
return {"modifier": value, "text": items[0].value}
def chord(self, items):
"""Parses chord"""
if isinstance(items[-1], Token):
return Chord(
pitch_classes=items[0:-1],
text="".join([val.text for val in items[0:-1]]),
inversions=int(items[-1].value[1:]),
)
return Chord(pitch_classes=items, text="".join([val.text for val in items]))
def invert(self, items):
"""Return chord inversion"""
return items[0]
def named_roman(self, items) -> RomanNumeral:
"""Parse chord from roman numeral"""
numeral = items[0].value
if len(items) > 1:
# TODO: Refactor this and the rule
if len(items) == 1:
return RomanNumeral(value=parse_roman(numeral), text=numeral)
if len(items) > 2:
name = items[1]
chord_notes = chord_from_roman_numeral(numeral, name)
parsed_number = parse_roman(numeral)
inversions = int(items[-1].value[1:])
return RomanNumeral(
text=numeral, value=parsed_number, chord_type=name, notes=chord_notes
text=numeral,
value=parse_roman(numeral),
chord_type=name,
inversions=inversions,
)
elif len(items) == 2:
if isinstance(items[-1], Token):
inversions = int(items[-1].value[1:])
return RomanNumeral(
value=parse_roman(numeral),
text=numeral,
notes=chord_from_roman_numeral(numeral),
inversions=inversions,
)
else:
return RomanNumeral(
value=parse_roman(numeral), text=numeral, chord_type=items[1]
)
def chord_name(self, item):
@ -136,7 +223,7 @@ class ZiffersTransformer(Transformer):
def roman_number(self, item):
"""Return roman numeral"""
return item.value
return item[0]
def dur_change(self, items):
"""Parses duration change"""
@ -213,9 +300,7 @@ class ZiffersTransformer(Transformer):
def subdivision(self, items):
"""Parse subdivision"""
values = flatten(items[0])
return Subdivision(
values=values, text="[" + "".join([val.text for val in values]) + "]"
)
return Subdivision(values=values, wrap_start="[", wrap_end="]")
def subitems(self, items):
"""Return subdivision items"""
@ -225,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"""
@ -234,12 +318,52 @@ 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
def assignment(self, items):
"""Creates variable assignment"""
var = items[0]
op = items[1]
content = items[2]
return VariableAssignment(
variable=var,
value=content,
text=var.text + "=" + content.text,
pre_eval=True if op == "=" else False,
)
def ass_op(self, items):
"""Return parsed type for assignment: = or ~"""
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")
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):
"""Return parsed variable name"""
return items[0].value # Variable(name=items[0].value, text=items[0].value)
def variablelist(self, items):
"""Return list of variables"""
return VariableList(values=items, text="".join([item.text for item in items]))
# List rules
@ -247,9 +371,14 @@ class ZiffersTransformer(Transformer):
"""Parse list sequence notation, ex: (1 2 3)"""
if len(items) > 1:
prefixes = sum_dict(items[0:-1])
text_prefix = prefixes.pop("text")
prefixes["prefix"] = text_prefix
values = items[-1]
seq = ListSequence(values=values, wrap_start=prefixes["text"] + "(")
seq.update_values(prefixes)
seq = ListSequence(
values=values,
wrap_start=prefixes["prefix"] + "(",
local_options=prefixes,
)
return seq
else:
seq = ListSequence(values=items[0])
@ -264,12 +393,13 @@ class ZiffersTransformer(Transformer):
values=items[-2],
repeats=items[-1],
wrap_end=":" + items[-1].text + ")",
local_options=prefixes,
)
else:
seq = RepeatedListSequence(
values=items[-2], repeats=Integer(text="1", value=1)
values=items[-2],
repeats=Integer(text="2", value=2, local_options=prefixes),
)
seq.update_values(prefixes)
return seq
else:
if items[-1] is not None:
@ -280,13 +410,14 @@ class ZiffersTransformer(Transformer):
)
else:
seq = RepeatedListSequence(
values=items[-2], repeats=Integer(text="1", value=1)
values=items[-2], repeats=Integer(text="2", value=2)
)
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):
@ -300,8 +431,8 @@ class ZiffersTransformer(Transformer):
def lisp_operation(self, items):
"""Parse lisp like list operation"""
op = items[0]
values = items[1:]
return Operation(
values = items[2]
return LispOperation(
operator=op,
values=values,
text="(+" + "".join([v.text for v in values]) + ")",
@ -312,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)
@ -327,10 +463,10 @@ class ZiffersTransformer(Transformer):
def euclid(self, items):
"""Parse euclid notation"""
params = items[1][1:-1].split(",")
init = {"onset": items[0], "pulses": params[0], "length": params[1]}
init = {"onset": items[0], "pulses": int(params[0]), "length": int(params[1])}
text = items[0].text + items[1]
if len(params) > 2:
init["rotate"] = params[2]
init["rotate"] = int(params[2])
if len(items) > 2:
init["offset"] = items[2]
text = text + items[2].text
@ -348,4 +484,97 @@ class ZiffersTransformer(Transformer):
values=items[0], repeats=items[-1], wrap_end=":" + items[-1].text + "]"
)
else:
return RepeatedSequence(values=items[0], repeats=Integer(value=1, text="1"))
return RepeatedSequence(values=items[0], repeats=Integer(value=2, text="2"))
def repeat_item(self, items):
"""Parse repeat item syntax to sequence, ex: 1:4 (1 2 3):5"""
return RepeatedListSequence(
values=[items[0]],
repeats=items[1],
wrap_start="",
wrap_end=":" + items[1].text,
)
# 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):
"""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 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):
"""Get EDO ratio"""
ratio = pow(2, items[0] / items[1])
return ratio_to_cents(ratio)
def edji_ratio(self, items):
"""Get EDJI ratio"""
if len(items) > 3:
power = items[2] / items[3]
else:
power = items[2]
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))
start = items[0]
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))
start = items[0]
end = items[1]
rand_val = _rand_between(start, end)
return rand_val

View File

@ -1,21 +1,45 @@
""" Module for the parser """
from pathlib import Path
from functools import lru_cache
from lark import Lark
from .classes import Ziffers
from .mapper import ZiffersTransformer
from .classes.root import Ziffers
from .mapper import ZiffersTransformer, ScalaTransformer
grammar_path = Path(__file__).parent
grammar = grammar_path / "ziffers.lark"
grammar_folder = Path.joinpath(grammar_path, "spec")
ziffers_grammar = Path.joinpath(grammar_folder, "ziffers.lark")
scala_grammar = Path.joinpath(grammar_folder, "scala.lark")
ziffers_parser = Lark.open(
grammar,
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
@ -28,7 +52,7 @@ def parse_expression(expr: str) -> Ziffers:
"""
return ziffers_parser.parse(expr)
@lru_cache
def zparse(expr: str, **opts) -> Ziffers:
"""Parses ziffers expression with options
@ -39,14 +63,32 @@ def zparse(expr: str, **opts) -> Ziffers:
Returns:
Ziffers: Returns Ziffers iterable parsed with the given options
"""
if "scale" in opts:
scale = opts["scale"]
if isinstance(scale,str) and not scale.isalpha():
parsed_scale = parse_scala(scale)
opts["scale"] = parsed_scale
parsed = parse_expression(expr)
parsed.init_opts(opts)
return parsed
# pylint: disable=invalid-name
def z(expr: str, **opts) -> Ziffers:
"""Shortened method name for zparse"""
return zparse(expr, **opts)
def yield_items(gen: Ziffers, num: int, key: str = None) -> list:
"""Yield n items from parsed Ziffers"""
for i in range(num):
if key is not None:
yield getattr(gen[i],key,None)
else:
yield gen[i]
def get_items(gen: Ziffers, num: int, key: str = None) -> list:
"""Get n-item from parsed Ziffers. Functional alternative to Ziffers-object collect method."""
return list(yield_items(gen,num,key))

View File

@ -2,9 +2,11 @@
#!/usr/bin/env python3
# pylint: disable=locally-disabled, no-name-in-module
import re
from math import floor
from math import log2
from itertools import islice
from .generators import gen_primes
from .common import repeat_text
from .defaults import (
DEFAULT_OCTAVE,
SCALES,
MODIFIERS,
NOTES_TO_INTERVALS,
@ -45,6 +47,12 @@ def note_name_to_interval(name: str) -> int:
return interval + modifier
def midi_to_freq(note: int) -> float:
"""Transform midi to frequency"""
freq = 440 # Frequency of A
return (freq / 32) * (2 ** ((note - 9) / 12))
def note_name_to_midi(name: str) -> int:
"""Parse note name to midi
@ -64,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:
@ -73,18 +81,83 @@ def get_scale(name: str) -> list[int]:
Returns:
list: List of intervals in the scale
"""
scale = SCALES.get(name.lower().capitalize(), SCALES["Chromatic"])
return list(map(int, str(scale)))
if isinstance(scale, (list, tuple)):
return scale
scale = SCALES.get(scale.lower().capitalize(), SCALES["Ionian"])
return scale
def get_scale_notes(name: str, root: int = 60, num_octaves: int = 1) -> list[int]:
"""Return notes for the scale
Args:
name (str): Name of the scale
root (int, optional): Root note. Defaults to 60.
num_octaves (int, optional): Number of octaves. Defaults to 1.
Returns:
list[int]: List of notes
"""
scale = get_scale(name)
scale_notes = [root]
for _ in range(num_octaves):
scale_notes = scale_notes + [root := root + semitone for semitone in scale]
return scale_notes
def get_chord_from_scale(
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
Args:
degree (int): Degree of scale to start on
root (int, optional): Root for the scale. Defaults to 60.
scale (str, optional): Name of the scale. Defaults to "Major".
num_notes (int, optional): Number of notes. Defaults to 3.
skip (int, optional): Takes every n from the scale. Defaults to 2.
Returns:
list[int]: List of midi notes
"""
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(scale: str) -> int:
"""Get length of the scale
Args:
name (str): Name of the scale
Returns:
int: Length of the 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
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,
degrees: bool = False
) -> int:
"""Resolve a pitch class into a note from a scale
@ -101,14 +174,14 @@ def note_from_pc(
"""
# Initialization
pitch_class = pitch_class-1 if degrees and pitch_class>0 else pitch_class
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
@ -120,7 +193,12 @@ 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
if isinstance(note, float):
return resolve_pitch_bend(note)
return (note, None)
def parse_roman(numeral: str) -> int:
@ -154,7 +232,10 @@ def accidentals_from_note_name(name: str) -> int:
Returns:
int: Integer representing number of flats or sharps: -7 flat to 7 sharp.
"""
idx = CIRCLE_OF_FIFTHS.index(name.upper())
if name not in CIRCLE_OF_FIFTHS:
name = midi_to_note_name(note_name_to_midi(name))
idx = CIRCLE_OF_FIFTHS.index(name)
return idx - 6
@ -182,7 +263,7 @@ def midi_to_tpc(note: int, key: str | int):
_type_: Tonal Pitch Class value for the note
"""
if isinstance(key, str):
acc = accidentals_from_note_name(key)
acc = accidentals_from_note_name(key[0])
else:
acc = accidentals_from_midi_note(key)
return (note * 7 + 26 - (11 + acc)) % 12 + (11 + acc)
@ -197,7 +278,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:
@ -211,10 +292,10 @@ 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":
return (str(pitch_class), pitch_class, octave)
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"]
flats = ["0", "b1", "1", "b2", "2", "3", "b4", "4", "b5", "5", "b6", "6"]
@ -227,18 +308,57 @@ def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict:
npc = sharps[pitch_class]
if len(npc) > 1:
modifier = 1 if (npc[0] == "#") else -1
return {
"text": npc,
"text": repeat_text("^", "_", octave) + npc,
"pitch_class": int(npc[1]),
"octave": octave,
"modifier": 1 if (npc[0] == "#") else -1,
"modifier": modifier,
}
return {"text": npc, "pitch_class": int(npc), "octave": octave}
return {
"text": repeat_text("^", "_", octave) + npc,
"pitch_class": int(npc),
"octave": octave,
}
def chord_from_roman_numeral(
roman: str, name: str = "major", num_octaves: int = 1
def chord_from_degree(
degree: int, name: str, scale: str, root: str | int, num_octaves: int = 1
) -> list[int]:
"""Generate chord from scale
Args:
degree (int): Chord degree
name (str): Chord type
scale (str): Scale name
root (str | int): Root for the chord
num_octaves (int, optional): Number of octaves. Defaults to 1.
Returns:
list[int]: Created chord as list of midi notes
"""
root = note_name_to_midi(root) if isinstance(root, str) else root
if (
name is None
and isinstance(scale, str)
and scale.lower().capitalize() == "Chromatic"
):
name = "major"
if name:
return named_chord_from_degree(degree, name, root, scale, num_octaves)
else:
return get_chord_from_scale(degree, root, scale)
def named_chord_from_degree(
degree: int,
name: str = "major",
root: int = 60,
scale: str = "Major",
num_octaves: int = 1,
) -> list[int]:
"""Generates chord from given roman numeral and chord name
@ -250,11 +370,74 @@ def chord_from_roman_numeral(
Returns:
list[int]: _description_
"""
root = parse_roman(roman) - 1
tonic = (DEFAULT_OCTAVE * 12) + root + 12
intervals = CHORDS.get(name, CHORDS["major"])
scale_degree = get_scale_notes(scale, root)[degree - 1]
notes = []
for cur_oct in range(num_octaves):
for iterval in intervals:
notes.append(tonic + iterval + (cur_oct * 12))
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.
"""
midi_bend_value = 8192
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: list) -> tuple[float]:
"""Tranform cents to semitones"""
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)
def ratio_to_cents(ratio: float) -> float:
"""Transform ratio to cents"""
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

0
ziffers/spec/__init__.py Normal file
View File

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

@ -0,0 +1,24 @@
?root: lines
lines: (number | ratio | monzo | operation)+
?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]+/
?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

99
ziffers/spec/ziffers.lark Normal file
View File

@ -0,0 +1,99 @@
// Root for the rules
?root: sequence -> start
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 | escaped_pitch)
prefix: (octave | duration_chars | escaped_decimal | escaped_octave | modifier)
pitch: /-?[0-9TE]/
escaped_decimal: "<" decimal ">"
escaped_octave: /<-?[0-9]>/
octave: /[_^]+/
modifier: /[#b]/
measure: "|"
// Variable assignment
assignment: variable ass_op (list | pitch_class | random_integer | random_pitch | cycle | list_op | repeat_item)
ass_op: /[=~]/
variable: prefix* variable_char
variable_char: /[A-Z]/
variablelist: variable variable+
// Durations
duration_chars: dotted_dur+
dotted_dur: dchar dot*
decimal: /-?[0-9]+\.[0-9]+/
dchar: /[mklpdcwyhnqaefsxtgujzo]/
dot: "."
rest: prefix* "r"
// Chords
chord: pitch_class pitch_class+ invert?
named_roman: roman_number (("^" chord_name))? invert? // TODO: Add | ("+" number)
chord_name: /[a-zA-Z0-9]+/
!roman_number: "i" | "ii" | "iii" | "iv" | "v" | "vi" | "vii"
invert: /%-?[0-9][0-9]*/
// Valid as integer
number: integer | random_integer | cycle
integer: pitch+
escaped_pitch: /{-?[0-9]+}/
// Repeats
repeat: "[:" sequence ":" [number] "]"
repeat_item: (pitch_class | list | list_op | random_integer | cycle | rest | subdivision | chord | named_roman | variable | range) ":" number
// List
list: prefix* "(" sequence ")"
repeated_list: prefix* "(:" sequence ":" [number] ")"
// Right recursive list operation
list_op: list ((operator | list_operator) right_op)+
right_op: list | number
// Operators that work only on lists: | << >>
list_operator: /(\||<<|>>|<>|#|@)(?=[(\d])/
// /(\||<<|>>|<>|#|@)(?=[(\d])/
// Common operators that works with numbers 3+5 3-5 etc.
operator: /(\+|-|\*|\/|%|&|\?|~)/
// Euclidean cycles
// TODO: Support randomization etc.
//euclid_operator: (">" | "<") number "," number ["," number] (">" | "<")
euclid: list euclid_operator list?
?euclid_operator: /<[0-9]+,[0-9]+(,[0-9])?>/
// Lisp like list operation
lisp_operator: /([\+\-\*\/%\&])/
lisp_operation: "(" lisp_operator WS sequence ")"
// Subdivision
subdivision: "[" subitems "]"
subitems: (pitch_class | random_integer | random_pitch | rest | oct_mod | oct_change | WS | chord | named_roman | cycle | subdivision | list | list_op | range | repeat_item)*
// 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](\.)*)(?=[ >)])/
// Generative rules
random_integer: prefix* random_integer_re
random_integer_re: /\(-?[0-9]+,-?[0-9]+\)/
range: prefix* range_re
range_re: /-?[0-9]+\.\.-?[0-9]+/
cycle: "<" sequence ">"
random_pitch: /(\?)(?!\d)/
random_percent: /(%)(?!\d)/
// Rules for evaluating clauses inside {}
eval: "{" ((operation | rest) WS?)+ "}"
operation: prefix? atom (operator (sub_operations | operation))*
sub_operations: "(" operation ")"
atom: number
%import common.WS

View File

@ -1,81 +0,0 @@
// 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