pycodedj 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycodedj-0.4.0 → pycodedj-0.5.0}/CHANGELOG.md +15 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/PKG-INFO +17 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/README.ja.md +16 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/README.md +16 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.html +26 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.ja.md +31 -2
- {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.md +31 -2
- {pycodedj-0.4.0 → pycodedj-0.5.0}/pyproject.toml +1 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/sc/synths.scd +100 -41
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/__init__.py +1 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/engine.py +2 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/osc_bridge.py +5 -2
- pycodedj-0.5.0/src/pycodedj/pattern.py +123 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_engine.py +33 -1
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_osc_bridge.py +45 -5
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_pattern.py +79 -1
- pycodedj-0.4.0/src/pycodedj/pattern.py +0 -47
- {pycodedj-0.4.0 → pycodedj-0.5.0}/.github/workflows/workflow.yml +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/.gitignore +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/LICENSE +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/CNAME +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/index.html +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/club_set.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/demo.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/hello_sc.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/sound_showcase.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/__main__.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/_loop.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/analyzer.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/block_parser.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/mapper.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/watcher.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/__init__.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_analyzer.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_block_parser.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_mapper.py +0 -0
- {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_watcher.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-05-08
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- `pattern()` にコード構文 `[0 3]` を追加。1 ステップで複数の度数を同時発音できる
|
|
8
|
+
- `pattern()` にタイ構文 `~` を追加。直前の音(または コード)を次のステップまで延長できる
|
|
9
|
+
|
|
10
|
+
### Improvements
|
|
11
|
+
|
|
12
|
+
- OSC パターン payload を v2 フォーマット(`"v2"` マーカー付き flat encoding)に統一
|
|
13
|
+
- SC 側 `~setupPattern` を v2 専用 decode に変更。`\sustain` を `Pseq` で制御し、chord/tie の発音長を正確に管理
|
|
14
|
+
- `encode_steps()` を `pattern.py` に追加。Python 内部の `list[PatternStep]` を OSC flat encoding に変換する
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
3
18
|
## [0.4.0] - 2026-05-08
|
|
4
19
|
|
|
5
20
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycodedj
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
License: MIT License with Commons Clause
|
|
5
5
|
|
|
6
6
|
"Commons Clause" License Condition v1.0
|
|
@@ -213,8 +213,24 @@ def kick():
|
|
|
213
213
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
214
214
|
def bass():
|
|
215
215
|
pattern("0 . 3 . 5 .")
|
|
216
|
+
|
|
217
|
+
# Chords and ties
|
|
218
|
+
@loop("chord", synth="note", root="A1", scale="minor", dur=0.25)
|
|
219
|
+
def chord():
|
|
220
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
221
|
+
# [0 3] = two-note chord, ~ = sustain the previous note one more step
|
|
216
222
|
```
|
|
217
223
|
|
|
224
|
+
Token reference:
|
|
225
|
+
|
|
226
|
+
| Token | Meaning |
|
|
227
|
+
| :--- | :--- |
|
|
228
|
+
| `x` | Trigger (plays root note) |
|
|
229
|
+
| `.` | Rest (silence) |
|
|
230
|
+
| `0`, `1`, `2` … | Scale degree (pitch) |
|
|
231
|
+
| `[0 3]` | Chord (multiple degrees simultaneously) |
|
|
232
|
+
| `~` | Tie (extends the previous note/chord by one step) |
|
|
233
|
+
|
|
218
234
|
`@loop` arguments for pattern mode:
|
|
219
235
|
|
|
220
236
|
| Argument | Description |
|
|
@@ -151,8 +151,24 @@ def kick():
|
|
|
151
151
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
152
152
|
def bass():
|
|
153
153
|
pattern("0 . 3 . 5 .")
|
|
154
|
+
|
|
155
|
+
# コードとタイ
|
|
156
|
+
@loop("chord", synth="note", root="A1", scale="minor", dur=0.25)
|
|
157
|
+
def chord():
|
|
158
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
159
|
+
# [0 3] = 2音コード、~ = 直前の音を伸ばす
|
|
154
160
|
```
|
|
155
161
|
|
|
162
|
+
`pattern()` のトークン一覧:
|
|
163
|
+
|
|
164
|
+
| トークン | 意味 |
|
|
165
|
+
| :--- | :--- |
|
|
166
|
+
| `x` | トリガー(ルート音で鳴らす) |
|
|
167
|
+
| `.` | 休符 |
|
|
168
|
+
| `0`, `1`, `2` … | スケール度数 |
|
|
169
|
+
| `[0 3]` | コード(複数の度数を同時発音) |
|
|
170
|
+
| `~` | タイ(直前の音を 1 ステップ延長) |
|
|
171
|
+
|
|
156
172
|
`@loop` に渡す引数:
|
|
157
173
|
|
|
158
174
|
| 引数 | 説明 |
|
|
@@ -151,8 +151,24 @@ def kick():
|
|
|
151
151
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
152
152
|
def bass():
|
|
153
153
|
pattern("0 . 3 . 5 .")
|
|
154
|
+
|
|
155
|
+
# Chords and ties
|
|
156
|
+
@loop("chord", synth="note", root="A1", scale="minor", dur=0.25)
|
|
157
|
+
def chord():
|
|
158
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
159
|
+
# [0 3] = two-note chord, ~ = sustain the previous note one more step
|
|
154
160
|
```
|
|
155
161
|
|
|
162
|
+
Token reference:
|
|
163
|
+
|
|
164
|
+
| Token | Meaning |
|
|
165
|
+
| :--- | :--- |
|
|
166
|
+
| `x` | Trigger (plays root note) |
|
|
167
|
+
| `.` | Rest (silence) |
|
|
168
|
+
| `0`, `1`, `2` … | Scale degree (pitch) |
|
|
169
|
+
| `[0 3]` | Chord (multiple degrees simultaneously) |
|
|
170
|
+
| `~` | Tie (extends the previous note/chord by one step) |
|
|
171
|
+
|
|
156
172
|
`@loop` arguments for pattern mode:
|
|
157
173
|
|
|
158
174
|
| Argument | Description |
|
|
@@ -801,6 +801,8 @@ def kick_drum():
|
|
|
801
801
|
<tr><td><code>x</code></td><td>ここで音を鳴らす(トリガー)</td></tr>
|
|
802
802
|
<tr><td><code>.</code></td><td>休符(無音)</td></tr>
|
|
803
803
|
<tr><td><code>0</code>, <code>1</code>, <code>2</code> …</td><td>音を鳴らす + 音程(スケール度数)</td></tr>
|
|
804
|
+
<tr><td><code>[0 3]</code></td><td>コード(複数の度数を同時発音)</td></tr>
|
|
805
|
+
<tr><td><code>~</code></td><td>タイ(直前の音を 1 ステップ延長)</td></tr>
|
|
804
806
|
</tbody>
|
|
805
807
|
</table>
|
|
806
808
|
|
|
@@ -872,6 +874,28 @@ def melody():
|
|
|
872
874
|
</tbody>
|
|
873
875
|
</table>
|
|
874
876
|
|
|
877
|
+
<h3>コード — 複数の音を同時に鳴らす</h3>
|
|
878
|
+
|
|
879
|
+
<p>角括弧で複数の度数を囲むと、1 ステップで複数の音を同時に鳴らせます。</p>
|
|
880
|
+
|
|
881
|
+
<pre data-lang="python"><code id="code-s7-chord">@loop("chord", synth="note", root="C3", scale="minor", dur=0.5)
|
|
882
|
+
def chord():
|
|
883
|
+
pattern("[0 2 4] . [0 3] .")
|
|
884
|
+
# [0 2 4]→C3+Eb3+G3(短三和音), 休, [0 3]→C3+Eb3, 休</code></pre>
|
|
885
|
+
|
|
886
|
+
<p>コード内に書けるのは <strong>0 以上の整数のみ</strong>です。<code>.</code>・<code>x</code>・<code>~</code> は書けません。</p>
|
|
887
|
+
|
|
888
|
+
<h3>タイ — 音を伸ばす</h3>
|
|
889
|
+
|
|
890
|
+
<p><code>~</code> を使うと、直前の音(またはコード)を次のステップまで伸ばせます。</p>
|
|
891
|
+
|
|
892
|
+
<pre data-lang="python"><code id="code-s7-tie">@loop("bass", synth="note", root="A1", scale="minor", dur=0.25)
|
|
893
|
+
def bass():
|
|
894
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
895
|
+
# 0→A1, 休, [0 3]→和音(2ステップ持続), 5→F2, 休, 3→D2, 休</code></pre>
|
|
896
|
+
|
|
897
|
+
<p><code>~</code> は単音・コードの直後にのみ書けます。先頭・<code>.</code> や <code>x</code> の直後は書けません。</p>
|
|
898
|
+
|
|
875
899
|
<h3>実例: キック・ベース・メロディー</h3>
|
|
876
900
|
|
|
877
901
|
<pre data-lang="python"><code id="code-s7-4">from pycodedj import loop, pattern
|
|
@@ -882,7 +906,8 @@ def kick():
|
|
|
882
906
|
|
|
883
907
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
884
908
|
def bass():
|
|
885
|
-
pattern("0 . 0
|
|
909
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
910
|
+
# 0→A1, 休, [0 3]→和音(2ステップ持続), 5→F2, 休, 3→D2, 休
|
|
886
911
|
|
|
887
912
|
@loop("melody", synth="acid_lead", root="A3", scale="minor", dur=0.5)
|
|
888
913
|
def melody():
|
|
@@ -496,6 +496,8 @@ def kick_drum():
|
|
|
496
496
|
| `x` | ここで音を鳴らす(トリガー) |
|
|
497
497
|
| `.` | 休符(無音) |
|
|
498
498
|
| `0`, `1`, `2` … | 音を鳴らす + 音程(スケール度数) |
|
|
499
|
+
| `[0 3]` | コード(複数の度数を同時に鳴らす) |
|
|
500
|
+
| `~` | タイ(直前の音を次のステップまで伸ばす) |
|
|
499
501
|
|
|
500
502
|
スペースで区切ると 1 ステップになります。`"x . x ."` なら 4 ステップのパターンです。
|
|
501
503
|
|
|
@@ -584,6 +586,32 @@ def bass():
|
|
|
584
586
|
# 0→C3, 休, x→C3, 休, 3→Eb3, 休, x→C3, 休
|
|
585
587
|
```
|
|
586
588
|
|
|
589
|
+
### コード — 複数の音を同時に鳴らす
|
|
590
|
+
|
|
591
|
+
`[0 3]` のように角括弧で複数の度数を囲むと、1 ステップで複数の音を同時に鳴らせます。
|
|
592
|
+
|
|
593
|
+
```python
|
|
594
|
+
@loop("chord", synth="note", root="C3", scale="minor", dur=0.5)
|
|
595
|
+
def chord():
|
|
596
|
+
pattern("[0 2 4] . [0 3] .")
|
|
597
|
+
# [0 2 4]→C3+Eb3+G3(短三和音), 休, [0 3]→C3+Eb3, 休
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
コード内に書けるのは **0 以上の整数のみ** です。`.`・`x`・`~` は書けません。
|
|
601
|
+
|
|
602
|
+
### タイ — 音を伸ばす
|
|
603
|
+
|
|
604
|
+
`~` を使うと、直前の音(または コード)を次のステップまで伸ばせます。
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
@loop("bass", synth="note", root="A1", scale="minor", dur=0.25)
|
|
608
|
+
def bass():
|
|
609
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
610
|
+
# 0→A1, 休, [0 3]→和音(2ステップ分 持続), 5→F2, 休, 3→D2, 休
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
`~` は単音・コードの直後にのみ書けます。先頭・`.` や `x` の直後は書けません。
|
|
614
|
+
|
|
587
615
|
### ルートノートの書き方
|
|
588
616
|
|
|
589
617
|
`root=` にはノート名 + オクターブ番号を渡します。
|
|
@@ -596,7 +624,7 @@ def bass():
|
|
|
596
624
|
| `"F#3"` | F♯3(`s` を使って `"Fs3"` とも書ける) |
|
|
597
625
|
| `"C1"` | 低い C |
|
|
598
626
|
|
|
599
|
-
### 実例:
|
|
627
|
+
### 実例: キックとベースとコード
|
|
600
628
|
|
|
601
629
|
```python
|
|
602
630
|
from pycodedj import loop, pattern
|
|
@@ -607,7 +635,8 @@ def kick():
|
|
|
607
635
|
|
|
608
636
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
609
637
|
def bass():
|
|
610
|
-
pattern("0 . 0
|
|
638
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
639
|
+
# 0→A1, 休, [0 3]→2音コード(2ステップ持続), 5→F2, 休, 3→D2, 休
|
|
611
640
|
|
|
612
641
|
@loop("melody", synth="acid_lead", root="A3", scale="minor", dur=0.5)
|
|
613
642
|
def melody():
|
|
@@ -477,6 +477,8 @@ The string passed to `pattern()` describes a sequence of steps:
|
|
|
477
477
|
| `x` | Trigger: play the sound here |
|
|
478
478
|
| `.` | Rest: silence |
|
|
479
479
|
| `0`, `1`, `2` … | Play the sound at this scale degree |
|
|
480
|
+
| `[0 3]` | Chord: play multiple degrees simultaneously |
|
|
481
|
+
| `~` | Tie: extend the previous note or chord by one step |
|
|
480
482
|
|
|
481
483
|
Tokens are separated by spaces. `"x . x ."` is a 4-step pattern.
|
|
482
484
|
|
|
@@ -563,6 +565,32 @@ def bass():
|
|
|
563
565
|
# 0→C3, rest, x→C3, rest, 3→Eb3, rest, x→C3, rest
|
|
564
566
|
```
|
|
565
567
|
|
|
568
|
+
### Chords — multiple notes at once
|
|
569
|
+
|
|
570
|
+
Wrap multiple degrees in square brackets to play them simultaneously in one step:
|
|
571
|
+
|
|
572
|
+
```python
|
|
573
|
+
@loop("chord", synth="note", root="C3", scale="minor", dur=0.5)
|
|
574
|
+
def chord():
|
|
575
|
+
pattern("[0 2 4] . [0 3] .")
|
|
576
|
+
# [0 2 4]→C3+Eb3+G3 (minor triad), rest, [0 3]→C3+Eb3, rest
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Only non-negative integers are allowed inside brackets. `.`, `x`, and `~` are not valid inside a chord.
|
|
580
|
+
|
|
581
|
+
### Ties — sustaining a note
|
|
582
|
+
|
|
583
|
+
Use `~` to extend the previous note or chord by one additional step:
|
|
584
|
+
|
|
585
|
+
```python
|
|
586
|
+
@loop("bass", synth="note", root="A1", scale="minor", dur=0.25)
|
|
587
|
+
def bass():
|
|
588
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
589
|
+
# 0→A1, rest, [0 3] chord held for 2 steps, 5→F2, rest, 3→D2, rest
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`~` can only follow a degree or chord. Placing it at the start, or after `.` or `x`, raises a `ValueError`.
|
|
593
|
+
|
|
566
594
|
### Root note notation
|
|
567
595
|
|
|
568
596
|
| Notation | Note |
|
|
@@ -573,7 +601,7 @@ def bass():
|
|
|
573
601
|
| `"F#3"` | F♯3 (also writable as `"Fs3"`) |
|
|
574
602
|
| `"C1"` | Low C |
|
|
575
603
|
|
|
576
|
-
### Full example: kick, bass, and
|
|
604
|
+
### Full example: kick, bass, and chord
|
|
577
605
|
|
|
578
606
|
```python
|
|
579
607
|
from pycodedj import loop, pattern
|
|
@@ -584,7 +612,8 @@ def kick():
|
|
|
584
612
|
|
|
585
613
|
@loop("bass", synth="bass_acid", root="A1", scale="minor", dur=0.25)
|
|
586
614
|
def bass():
|
|
587
|
-
pattern("0 . 0
|
|
615
|
+
pattern("0 . [0 3] ~ 5 . 3 .")
|
|
616
|
+
# 0→A1, rest, [0 3] chord held for 2 steps, 5→F2, rest, 3→D2, rest
|
|
588
617
|
|
|
589
618
|
@loop("melody", synth="acid_lead", root="A3", scale="minor", dur=0.5)
|
|
590
619
|
def melody():
|
|
@@ -697,47 +697,105 @@ s.waitForBoot({
|
|
|
697
697
|
};
|
|
698
698
|
};
|
|
699
699
|
|
|
700
|
-
~setupPattern = { |name, rootMidi, scaleName, dur, synthName,
|
|
701
|
-
var scale, pitches, instrSym;
|
|
700
|
+
~setupPattern = { |name, rootMidi, scaleName, dur, synthName, version, encoded|
|
|
701
|
+
var scale, pitches, sustains, instrSym, i, hasDegree, decodeError, tieTargetIndex, degreeToMidi;
|
|
702
702
|
scale = Scale.at(scaleName.asSymbol) ?? { Scale.chromatic };
|
|
703
|
-
pitches =
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
// Degree tokens require a freq-aware synth; trigger-only patterns can use the
|
|
715
|
-
// caller-specified synth= or fall back to the loop's default.
|
|
716
|
-
instrSym = if (steps.detect({ |s| s >= 0 }).notNil) {
|
|
717
|
-
\pycodedj_note
|
|
703
|
+
pitches = List.new;
|
|
704
|
+
sustains = List.new;
|
|
705
|
+
i = 0;
|
|
706
|
+
hasDegree = false;
|
|
707
|
+
decodeError = false;
|
|
708
|
+
tieTargetIndex = nil;
|
|
709
|
+
degreeToMidi = { |degree|
|
|
710
|
+
(scale.degrees[degree % scale.size] + rootMidi + (degree.div(scale.size) * 12)).asInteger
|
|
711
|
+
};
|
|
712
|
+
if (version.asString != "v2") {
|
|
713
|
+
("PyCodeDJ pattern error: unsupported pattern payload version: " ++ version.asString).postln;
|
|
718
714
|
} {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
715
|
+
while ({ (i < encoded.size) and: { decodeError.not } }, {
|
|
716
|
+
var count = encoded[i].asInteger;
|
|
717
|
+
if (count == 0) {
|
|
718
|
+
pitches.add(Rest());
|
|
719
|
+
sustains.add(dur);
|
|
720
|
+
tieTargetIndex = nil;
|
|
721
|
+
i = i + 1;
|
|
722
|
+
} {
|
|
723
|
+
if (count == -3) {
|
|
724
|
+
if (tieTargetIndex.isNil) {
|
|
725
|
+
"PyCodeDJ pattern error: tie must follow a note or chord.".postln;
|
|
726
|
+
decodeError = true;
|
|
727
|
+
} {
|
|
728
|
+
sustains[tieTargetIndex] = sustains[tieTargetIndex] + dur;
|
|
729
|
+
pitches.add(Rest());
|
|
730
|
+
sustains.add(dur);
|
|
731
|
+
i = i + 1;
|
|
732
|
+
};
|
|
733
|
+
} {
|
|
734
|
+
if ((i + count) >= encoded.size) {
|
|
735
|
+
"PyCodeDJ pattern error: truncated v2 pattern payload.".postln;
|
|
736
|
+
decodeError = true;
|
|
737
|
+
} {
|
|
738
|
+
if (count == 1) {
|
|
739
|
+
var degree = encoded[i + 1].asInteger;
|
|
740
|
+
if (degree == -1) {
|
|
741
|
+
pitches.add(rootMidi.asInteger);
|
|
742
|
+
tieTargetIndex = nil;
|
|
743
|
+
} {
|
|
744
|
+
pitches.add(degreeToMidi.value(degree));
|
|
745
|
+
hasDegree = true;
|
|
746
|
+
tieTargetIndex = sustains.size;
|
|
747
|
+
};
|
|
748
|
+
sustains.add(dur);
|
|
749
|
+
i = i + 2;
|
|
750
|
+
} {
|
|
751
|
+
if (count > 1) {
|
|
752
|
+
pitches.add(Array.fill(count, { |j|
|
|
753
|
+
degreeToMidi.value(encoded[i + 1 + j].asInteger)
|
|
754
|
+
}));
|
|
755
|
+
sustains.add(dur);
|
|
756
|
+
hasDegree = true;
|
|
757
|
+
tieTargetIndex = sustains.size - 1;
|
|
758
|
+
i = i + 1 + count;
|
|
759
|
+
} {
|
|
760
|
+
("PyCodeDJ pattern error: invalid v2 pattern count: " ++ count).postln;
|
|
761
|
+
decodeError = true;
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
};
|
|
765
|
+
};
|
|
766
|
+
};
|
|
767
|
+
});
|
|
768
|
+
if (decodeError.not) {
|
|
769
|
+
// Degree and chord tokens require a freq-aware synth; trigger-only patterns can use the
|
|
770
|
+
// caller-specified synth= or fall back to the loop's default.
|
|
771
|
+
instrSym = if (hasDegree) {
|
|
772
|
+
\pycodedj_note
|
|
773
|
+
} {
|
|
774
|
+
if (synthName.size > 0) {
|
|
775
|
+
~synthForLoop.value(synthName)
|
|
776
|
+
} {
|
|
777
|
+
~synthForLoop.value(name)
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
Pdef(name.asSymbol,
|
|
781
|
+
Pbind(
|
|
782
|
+
\instrument, instrSym,
|
|
783
|
+
\midinote, Pseq(pitches.asArray, inf),
|
|
784
|
+
\dur, dur,
|
|
785
|
+
\sustain, Pseq(sustains.asArray, inf),
|
|
786
|
+
\amp, Pfunc({ (~loopParams[name] ?? { (amp: 0.3) })[\amp] }),
|
|
787
|
+
\cutoff, Pfunc({ (~loopParams[name] ?? { (cutoff: 800) })[\cutoff] }),
|
|
788
|
+
\lfoRate, Pfunc({ (~loopParams[name] ?? { (lfoRate: 0.5) })[\lfoRate] }),
|
|
789
|
+
\reverbMix, Pfunc({ (~loopParams[name] ?? { (reverbMix: 0.2) })[\reverbMix] }),
|
|
790
|
+
\eqLow, Pfunc({ (~loopParams[name] ?? { (low: 1) })[\low] }),
|
|
791
|
+
\eqMid, Pfunc({ (~loopParams[name] ?? { (mid: 1) })[\mid] }),
|
|
792
|
+
\eqHigh, Pfunc({ (~loopParams[name] ?? { (high: 1) })[\high] }),
|
|
793
|
+
)
|
|
794
|
+
).play(TempoClock.default);
|
|
795
|
+
~patterns[name] = true;
|
|
796
|
+
("PyCodeDJ pattern started: " ++ name).postln;
|
|
797
|
+
};
|
|
724
798
|
};
|
|
725
|
-
Pdef(name.asSymbol,
|
|
726
|
-
Pbind(
|
|
727
|
-
\instrument, instrSym,
|
|
728
|
-
\midinote, Pseq(pitches, inf),
|
|
729
|
-
\dur, dur,
|
|
730
|
-
\amp, Pfunc({ (~loopParams[name] ?? { (amp: 0.3) })[\amp] }),
|
|
731
|
-
\cutoff, Pfunc({ (~loopParams[name] ?? { (cutoff: 800) })[\cutoff] }),
|
|
732
|
-
\lfoRate, Pfunc({ (~loopParams[name] ?? { (lfoRate: 0.5) })[\lfoRate] }),
|
|
733
|
-
\reverbMix, Pfunc({ (~loopParams[name] ?? { (reverbMix: 0.2) })[\reverbMix] }),
|
|
734
|
-
\eqLow, Pfunc({ (~loopParams[name] ?? { (low: 1) })[\low] }),
|
|
735
|
-
\eqMid, Pfunc({ (~loopParams[name] ?? { (mid: 1) })[\mid] }),
|
|
736
|
-
\eqHigh, Pfunc({ (~loopParams[name] ?? { (high: 1) })[\high] }),
|
|
737
|
-
)
|
|
738
|
-
).play(TempoClock.default);
|
|
739
|
-
~patterns[name] = true;
|
|
740
|
-
("PyCodeDJ pattern started: " ++ name).postln;
|
|
741
799
|
};
|
|
742
800
|
|
|
743
801
|
// Single handler for all /pycodedj/loop/<name>/<param> addresses.
|
|
@@ -813,15 +871,16 @@ s.waitForBoot({
|
|
|
813
871
|
};
|
|
814
872
|
},
|
|
815
873
|
"pattern", {
|
|
816
|
-
// msg: [addr, rootMidi, scaleName, dur, synthName,
|
|
817
|
-
if (msg.size >=
|
|
874
|
+
// msg: [addr, rootMidi, scaleName, dur, synthName, "v2", encoded...]
|
|
875
|
+
if (msg.size >= 6) {
|
|
818
876
|
~setupPattern.value(
|
|
819
877
|
name,
|
|
820
878
|
msg[1].asInteger,
|
|
821
879
|
msg[2].asString,
|
|
822
880
|
msg[3].asFloat,
|
|
823
881
|
msg[4].asString,
|
|
824
|
-
msg[5
|
|
882
|
+
msg[5].asString,
|
|
883
|
+
msg[6..]
|
|
825
884
|
);
|
|
826
885
|
};
|
|
827
886
|
},
|
|
@@ -8,6 +8,7 @@ from .analyzer import analyze
|
|
|
8
8
|
from .block_parser import LoopBlock
|
|
9
9
|
from .mapper import MusicParams, map_features
|
|
10
10
|
from .osc_bridge import OscBridge, OscError
|
|
11
|
+
from .pattern import PatternStep
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass
|
|
@@ -76,7 +77,7 @@ class Engine:
|
|
|
76
77
|
|
|
77
78
|
def _make_pattern_args(
|
|
78
79
|
self, block: LoopBlock
|
|
79
|
-
) -> tuple[int, str, float, list[
|
|
80
|
+
) -> tuple[int, str, float, list[PatternStep], str]:
|
|
80
81
|
from .pattern import parse_pattern, root_to_midi
|
|
81
82
|
steps = parse_pattern(block.pattern_str or "")
|
|
82
83
|
midi = root_to_midi(block.root or "C4")
|
|
@@ -5,6 +5,8 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
|
|
6
6
|
from pythonosc import udp_client
|
|
7
7
|
|
|
8
|
+
from .pattern import PatternStep, encode_steps
|
|
9
|
+
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
from .mapper import MusicParams
|
|
10
12
|
|
|
@@ -48,7 +50,7 @@ class OscBridge:
|
|
|
48
50
|
root_midi: int,
|
|
49
51
|
scale: str,
|
|
50
52
|
dur: float,
|
|
51
|
-
steps: list[
|
|
53
|
+
steps: list[PatternStep],
|
|
52
54
|
synth: str = "",
|
|
53
55
|
) -> None:
|
|
54
56
|
self.audio.send(
|
|
@@ -57,7 +59,8 @@ class OscBridge:
|
|
|
57
59
|
scale,
|
|
58
60
|
dur,
|
|
59
61
|
synth,
|
|
60
|
-
|
|
62
|
+
"v2",
|
|
63
|
+
*encode_steps(steps),
|
|
61
64
|
)
|
|
62
65
|
|
|
63
66
|
def send_synth(self, name: str, synth: str) -> None:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Pattern sentinel values:
|
|
4
|
+
# REST means a silent step, TIE extends the previous degree/chord, TRIGGER fires
|
|
5
|
+
# the loop/root synth without a scale degree.
|
|
6
|
+
REST = -2
|
|
7
|
+
TIE = -3
|
|
8
|
+
TRIGGER = -1
|
|
9
|
+
PatternStep = int | list[int]
|
|
10
|
+
|
|
11
|
+
_NOTE_MAP = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
12
|
+
_ACCIDENTAL = {"#": 1, "s": 1, "b": -1}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _tokenize_pattern(pattern_str: str) -> list[str]:
|
|
16
|
+
tokens: list[str] = []
|
|
17
|
+
i = 0
|
|
18
|
+
while i < len(pattern_str):
|
|
19
|
+
char = pattern_str[i]
|
|
20
|
+
if char.isspace():
|
|
21
|
+
i += 1
|
|
22
|
+
continue
|
|
23
|
+
if char == "[":
|
|
24
|
+
end = pattern_str.find("]", i + 1)
|
|
25
|
+
if end == -1:
|
|
26
|
+
raise ValueError("unterminated chord")
|
|
27
|
+
tokens.append(pattern_str[i : end + 1])
|
|
28
|
+
i = end + 1
|
|
29
|
+
continue
|
|
30
|
+
if char == "]":
|
|
31
|
+
raise ValueError("unexpected chord close")
|
|
32
|
+
start = i
|
|
33
|
+
while i < len(pattern_str) and not pattern_str[i].isspace():
|
|
34
|
+
if pattern_str[i] in "[]":
|
|
35
|
+
break
|
|
36
|
+
i += 1
|
|
37
|
+
tokens.append(pattern_str[start:i])
|
|
38
|
+
return tokens
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_degree(token: str) -> int:
|
|
42
|
+
if token.lstrip("-").isdigit():
|
|
43
|
+
degree = int(token)
|
|
44
|
+
if degree < 0:
|
|
45
|
+
raise ValueError(f"negative degree not allowed: {token!r}")
|
|
46
|
+
return degree
|
|
47
|
+
raise ValueError(f"unknown pattern token: {token!r}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_chord(token: str) -> list[int]:
|
|
51
|
+
body = token[1:-1].strip()
|
|
52
|
+
if not body:
|
|
53
|
+
raise ValueError("empty chord not allowed")
|
|
54
|
+
chord: list[int] = []
|
|
55
|
+
for part in body.split():
|
|
56
|
+
if part in {".", "x", "~"}:
|
|
57
|
+
raise ValueError(f"invalid chord token: {part!r}")
|
|
58
|
+
if not part.lstrip("-").isdigit():
|
|
59
|
+
raise ValueError(f"invalid chord token: {part!r}")
|
|
60
|
+
degree = int(part)
|
|
61
|
+
if degree < 0:
|
|
62
|
+
raise ValueError(f"negative degree not allowed in chord: {part!r}")
|
|
63
|
+
chord.append(degree)
|
|
64
|
+
return chord
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_pattern(pattern_str: str) -> list[PatternStep]:
|
|
68
|
+
"""
|
|
69
|
+
"x . x ." -> [-1, -2, -1, -2]
|
|
70
|
+
"0 . 3 ." -> [0, -2, 3, -2]
|
|
71
|
+
"0 . [0 3] ~" -> [0, -2, [0, 3], -3]
|
|
72
|
+
"""
|
|
73
|
+
steps: list[PatternStep] = []
|
|
74
|
+
for token in _tokenize_pattern(pattern_str):
|
|
75
|
+
if token == ".":
|
|
76
|
+
steps.append(REST)
|
|
77
|
+
elif token == "x":
|
|
78
|
+
steps.append(TRIGGER)
|
|
79
|
+
elif token == "~":
|
|
80
|
+
if not steps or not (
|
|
81
|
+
isinstance(steps[-1], list) or steps[-1] not in {REST, TRIGGER, TIE}
|
|
82
|
+
):
|
|
83
|
+
raise ValueError("tie must follow a degree or chord")
|
|
84
|
+
steps.append(TIE)
|
|
85
|
+
elif token.startswith("[") and token.endswith("]"):
|
|
86
|
+
steps.append(_parse_chord(token))
|
|
87
|
+
else:
|
|
88
|
+
steps.append(_parse_degree(token))
|
|
89
|
+
return steps
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def encode_steps(steps: list[PatternStep]) -> list[int | float | str]:
|
|
93
|
+
encoded: list[int | float | str] = []
|
|
94
|
+
for step in steps:
|
|
95
|
+
if isinstance(step, list):
|
|
96
|
+
encoded.append(len(step))
|
|
97
|
+
encoded.extend(step)
|
|
98
|
+
elif step == REST:
|
|
99
|
+
encoded.append(0)
|
|
100
|
+
elif step == TIE:
|
|
101
|
+
encoded.append(TIE)
|
|
102
|
+
else:
|
|
103
|
+
encoded.extend((1, step))
|
|
104
|
+
return encoded
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def root_to_midi(root: str) -> int:
|
|
108
|
+
"""
|
|
109
|
+
"C3" -> 48, "A1" -> 33, "Bb2" -> 46
|
|
110
|
+
Convention: C-1=0, C0=12, C4=60 (General MIDI)
|
|
111
|
+
"""
|
|
112
|
+
if not root or root[0] not in _NOTE_MAP:
|
|
113
|
+
raise ValueError(f"invalid root: {root!r}")
|
|
114
|
+
note = _NOTE_MAP[root[0]]
|
|
115
|
+
pos = 1
|
|
116
|
+
if pos < len(root) and root[pos] in _ACCIDENTAL:
|
|
117
|
+
note += _ACCIDENTAL[root[pos]]
|
|
118
|
+
pos += 1
|
|
119
|
+
octave_str = root[pos:]
|
|
120
|
+
if not octave_str or not (octave_str.lstrip("-").isdigit()):
|
|
121
|
+
raise ValueError(f"invalid root: {root!r}")
|
|
122
|
+
octave = int(octave_str)
|
|
123
|
+
return (octave + 1) * 12 + note
|
|
@@ -386,7 +386,39 @@ def test_eval_block_pattern_values_correct() -> None:
|
|
|
386
386
|
assert values[1] == "minor"
|
|
387
387
|
assert values[2] == 0.25
|
|
388
388
|
assert values[3] == "kick_pulse"
|
|
389
|
-
assert values[4:] == [
|
|
389
|
+
assert values[4:] == ["v2", 1, -1, 0, 1, -1, 0]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_eval_block_pattern_chord_and_tie_values_correct() -> None:
|
|
393
|
+
engine = _make_engine()
|
|
394
|
+
from typing import cast
|
|
395
|
+
mock_send = cast(MagicMock, engine.bridge.audio._client).send_message
|
|
396
|
+
mock_send.reset_mock()
|
|
397
|
+
|
|
398
|
+
engine.eval_block(_pattern_block(pattern_str="0 . [0 3] ~ 5 . 3 .", root="C4", scale="minor", dur=0.25))
|
|
399
|
+
|
|
400
|
+
call_map = {c.args[0]: c.args[1] for c in mock_send.call_args_list}
|
|
401
|
+
values = call_map["/pycodedj/loop/kick/pattern"]
|
|
402
|
+
assert values == [
|
|
403
|
+
60,
|
|
404
|
+
"minor",
|
|
405
|
+
0.25,
|
|
406
|
+
"kick_pulse",
|
|
407
|
+
"v2",
|
|
408
|
+
1,
|
|
409
|
+
0,
|
|
410
|
+
0,
|
|
411
|
+
2,
|
|
412
|
+
0,
|
|
413
|
+
3,
|
|
414
|
+
-3,
|
|
415
|
+
1,
|
|
416
|
+
5,
|
|
417
|
+
0,
|
|
418
|
+
1,
|
|
419
|
+
3,
|
|
420
|
+
0,
|
|
421
|
+
]
|
|
390
422
|
|
|
391
423
|
|
|
392
424
|
def test_eval_block_pattern_uses_defaults_for_missing_root_scale_dur() -> None:
|
|
@@ -5,6 +5,7 @@ import pytest
|
|
|
5
5
|
|
|
6
6
|
from pycodedj.mapper import MusicParams
|
|
7
7
|
from pycodedj.osc_bridge import OscBridge, OscEndpoint, OscError
|
|
8
|
+
from pycodedj.pattern import REST, TIE, TRIGGER
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def _make_mock_endpoint(port: int) -> OscEndpoint:
|
|
@@ -133,11 +134,23 @@ def test_send_pattern_address(mock_endpoint: OscEndpoint) -> None:
|
|
|
133
134
|
|
|
134
135
|
def test_send_pattern_values(mock_endpoint: OscEndpoint) -> None:
|
|
135
136
|
bridge = OscBridge(audio=mock_endpoint)
|
|
136
|
-
bridge.send_pattern("kick", 48, "chromatic", 0.25, [
|
|
137
|
+
bridge.send_pattern("kick", 48, "chromatic", 0.25, [TRIGGER, REST, TRIGGER, REST])
|
|
137
138
|
|
|
138
139
|
call_map = {c.args[0]: c.args[1] for c in _send_mock(mock_endpoint).call_args_list}
|
|
139
|
-
# order: root_midi, scale, dur, synth(""), steps...
|
|
140
|
-
assert call_map["/pycodedj/loop/kick/pattern"] == [
|
|
140
|
+
# order: root_midi, scale, dur, synth(""), version, encoded steps...
|
|
141
|
+
assert call_map["/pycodedj/loop/kick/pattern"] == [
|
|
142
|
+
48,
|
|
143
|
+
"chromatic",
|
|
144
|
+
0.25,
|
|
145
|
+
"",
|
|
146
|
+
"v2",
|
|
147
|
+
1,
|
|
148
|
+
TRIGGER,
|
|
149
|
+
0,
|
|
150
|
+
1,
|
|
151
|
+
TRIGGER,
|
|
152
|
+
0,
|
|
153
|
+
]
|
|
141
154
|
|
|
142
155
|
|
|
143
156
|
def test_send_pattern_with_synth(mock_endpoint: OscEndpoint) -> None:
|
|
@@ -145,7 +158,7 @@ def test_send_pattern_with_synth(mock_endpoint: OscEndpoint) -> None:
|
|
|
145
158
|
bridge.send_pattern("bass", 33, "minor", 0.5, [0, -2], synth="bass_acid")
|
|
146
159
|
|
|
147
160
|
call_map = {c.args[0]: c.args[1] for c in _send_mock(mock_endpoint).call_args_list}
|
|
148
|
-
assert call_map["/pycodedj/loop/bass/pattern"] == [33, "minor", 0.5, "bass_acid", 0,
|
|
161
|
+
assert call_map["/pycodedj/loop/bass/pattern"] == [33, "minor", 0.5, "bass_acid", "v2", 1, 0, 0]
|
|
149
162
|
|
|
150
163
|
|
|
151
164
|
def test_send_pattern_empty_steps(mock_endpoint: OscEndpoint) -> None:
|
|
@@ -153,7 +166,34 @@ def test_send_pattern_empty_steps(mock_endpoint: OscEndpoint) -> None:
|
|
|
153
166
|
bridge.send_pattern("bass", 33, "minor", 0.5, [])
|
|
154
167
|
|
|
155
168
|
call_map = {c.args[0]: c.args[1] for c in _send_mock(mock_endpoint).call_args_list}
|
|
156
|
-
assert call_map["/pycodedj/loop/bass/pattern"] == [33, "minor", 0.5, ""]
|
|
169
|
+
assert call_map["/pycodedj/loop/bass/pattern"] == [33, "minor", 0.5, "", "v2"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_send_pattern_chord_and_tie_payload(mock_endpoint: OscEndpoint) -> None:
|
|
173
|
+
bridge = OscBridge(audio=mock_endpoint)
|
|
174
|
+
bridge.send_pattern("lead", 60, "minor", 0.25, [0, REST, [0, 3], TIE, 5, REST, 3, REST])
|
|
175
|
+
|
|
176
|
+
call_map = {c.args[0]: c.args[1] for c in _send_mock(mock_endpoint).call_args_list}
|
|
177
|
+
assert call_map["/pycodedj/loop/lead/pattern"] == [
|
|
178
|
+
60,
|
|
179
|
+
"minor",
|
|
180
|
+
0.25,
|
|
181
|
+
"",
|
|
182
|
+
"v2",
|
|
183
|
+
1,
|
|
184
|
+
0,
|
|
185
|
+
0,
|
|
186
|
+
2,
|
|
187
|
+
0,
|
|
188
|
+
3,
|
|
189
|
+
TIE,
|
|
190
|
+
1,
|
|
191
|
+
5,
|
|
192
|
+
0,
|
|
193
|
+
1,
|
|
194
|
+
3,
|
|
195
|
+
0,
|
|
196
|
+
]
|
|
157
197
|
|
|
158
198
|
|
|
159
199
|
def test_send_pattern_stop(mock_endpoint: OscEndpoint) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from pycodedj.pattern import REST, TRIGGER, parse_pattern, root_to_midi
|
|
3
|
+
from pycodedj.pattern import REST, TIE, TRIGGER, encode_steps, parse_pattern, root_to_midi
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
# --- parse_pattern ---
|
|
@@ -34,6 +34,22 @@ def test_degree_pattern() -> None:
|
|
|
34
34
|
assert parse_pattern("0 . 3 .") == [0, REST, 3, REST]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def test_chord_pattern() -> None:
|
|
38
|
+
assert parse_pattern("[0 3]") == [[0, 3]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_chord_mixed_pattern() -> None:
|
|
42
|
+
assert parse_pattern("0 . [0 3] ~ 5 . 3 .") == [0, REST, [0, 3], TIE, 5, REST, 3, REST]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_tie_after_degree() -> None:
|
|
46
|
+
assert parse_pattern("0 ~") == [0, TIE]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tie_after_chord() -> None:
|
|
50
|
+
assert parse_pattern("[0 3] ~") == [[0, 3], TIE]
|
|
51
|
+
|
|
52
|
+
|
|
37
53
|
def test_longer_pattern() -> None:
|
|
38
54
|
result = parse_pattern("x x . x . . x .")
|
|
39
55
|
assert result == [TRIGGER, TRIGGER, REST, TRIGGER, REST, REST, TRIGGER, REST]
|
|
@@ -51,11 +67,46 @@ def test_trigger_constant_value() -> None:
|
|
|
51
67
|
assert TRIGGER == -1
|
|
52
68
|
|
|
53
69
|
|
|
70
|
+
def test_tie_constant_value() -> None:
|
|
71
|
+
assert TIE == -3
|
|
72
|
+
|
|
73
|
+
|
|
54
74
|
def test_unknown_token_raises() -> None:
|
|
55
75
|
with pytest.raises(ValueError, match="unknown pattern token"):
|
|
56
76
|
parse_pattern("x y .")
|
|
57
77
|
|
|
58
78
|
|
|
79
|
+
def test_leading_tie_raises() -> None:
|
|
80
|
+
with pytest.raises(ValueError, match="tie must follow"):
|
|
81
|
+
parse_pattern("~")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_rest_then_tie_raises() -> None:
|
|
85
|
+
with pytest.raises(ValueError, match="tie must follow"):
|
|
86
|
+
parse_pattern(". ~")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_trigger_then_tie_raises() -> None:
|
|
90
|
+
with pytest.raises(ValueError, match="tie must follow"):
|
|
91
|
+
parse_pattern("x ~")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_empty_chord_raises() -> None:
|
|
95
|
+
with pytest.raises(ValueError, match="empty chord"):
|
|
96
|
+
parse_pattern("[]")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize("token", ["x", ".", "~"])
|
|
100
|
+
def test_chord_special_token_raises(token: str) -> None:
|
|
101
|
+
with pytest.raises(ValueError, match="invalid chord token"):
|
|
102
|
+
parse_pattern(f"[0 {token}]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_chord_negative_degree_raises() -> None:
|
|
106
|
+
with pytest.raises(ValueError, match="negative degree not allowed in chord"):
|
|
107
|
+
parse_pattern("[0 -3]")
|
|
108
|
+
|
|
109
|
+
|
|
59
110
|
def test_empty_string_returns_empty() -> None:
|
|
60
111
|
assert parse_pattern("") == []
|
|
61
112
|
|
|
@@ -64,6 +115,33 @@ def test_whitespace_only_returns_empty() -> None:
|
|
|
64
115
|
assert parse_pattern(" ") == []
|
|
65
116
|
|
|
66
117
|
|
|
118
|
+
# --- encode_steps ---
|
|
119
|
+
|
|
120
|
+
def test_encode_steps_rest() -> None:
|
|
121
|
+
assert encode_steps([REST]) == [0]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_encode_steps_tie() -> None:
|
|
125
|
+
assert encode_steps([TIE]) == [TIE]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_encode_steps_trigger() -> None:
|
|
129
|
+
assert encode_steps([TRIGGER]) == [1, TRIGGER]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_encode_steps_note() -> None:
|
|
133
|
+
assert encode_steps([3]) == [1, 3]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_encode_steps_chord() -> None:
|
|
137
|
+
assert encode_steps([[0, 3]]) == [2, 0, 3]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_encode_steps_acceptance_pattern() -> None:
|
|
141
|
+
steps = parse_pattern("0 . [0 3] ~ 5 . 3 .")
|
|
142
|
+
assert encode_steps(steps) == [1, 0, 0, 2, 0, 3, TIE, 1, 5, 0, 1, 3, 0]
|
|
143
|
+
|
|
144
|
+
|
|
67
145
|
# --- root_to_midi ---
|
|
68
146
|
|
|
69
147
|
def test_c4() -> None:
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
REST = -2
|
|
4
|
-
TRIGGER = -1
|
|
5
|
-
|
|
6
|
-
_NOTE_MAP = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
7
|
-
_ACCIDENTAL = {"#": 1, "s": 1, "b": -1}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def parse_pattern(pattern_str: str) -> list[int]:
|
|
11
|
-
"""
|
|
12
|
-
"x . x ." -> [-1, -2, -1, -2]
|
|
13
|
-
"0 . 3 ." -> [0, -2, 3, -2]
|
|
14
|
-
"""
|
|
15
|
-
steps: list[int] = []
|
|
16
|
-
for token in pattern_str.split():
|
|
17
|
-
if token == ".":
|
|
18
|
-
steps.append(REST)
|
|
19
|
-
elif token == "x":
|
|
20
|
-
steps.append(TRIGGER)
|
|
21
|
-
elif token.lstrip("-").isdigit():
|
|
22
|
-
val = int(token)
|
|
23
|
-
if val < 0:
|
|
24
|
-
raise ValueError(f"negative degree not allowed: {token!r}")
|
|
25
|
-
steps.append(val)
|
|
26
|
-
else:
|
|
27
|
-
raise ValueError(f"unknown pattern token: {token!r}")
|
|
28
|
-
return steps
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def root_to_midi(root: str) -> int:
|
|
32
|
-
"""
|
|
33
|
-
"C3" -> 48, "A1" -> 33, "Bb2" -> 46
|
|
34
|
-
Convention: C-1=0, C0=12, C4=60 (General MIDI)
|
|
35
|
-
"""
|
|
36
|
-
if not root or root[0] not in _NOTE_MAP:
|
|
37
|
-
raise ValueError(f"invalid root: {root!r}")
|
|
38
|
-
note = _NOTE_MAP[root[0]]
|
|
39
|
-
pos = 1
|
|
40
|
-
if pos < len(root) and root[pos] in _ACCIDENTAL:
|
|
41
|
-
note += _ACCIDENTAL[root[pos]]
|
|
42
|
-
pos += 1
|
|
43
|
-
octave_str = root[pos:]
|
|
44
|
-
if not octave_str or not (octave_str.lstrip("-").isdigit()):
|
|
45
|
-
raise ValueError(f"invalid root: {root!r}")
|
|
46
|
-
octave = int(octave_str)
|
|
47
|
-
return (octave + 1) * 12 + note
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|