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.
Files changed (37) hide show
  1. {pycodedj-0.4.0 → pycodedj-0.5.0}/CHANGELOG.md +15 -0
  2. {pycodedj-0.4.0 → pycodedj-0.5.0}/PKG-INFO +17 -1
  3. {pycodedj-0.4.0 → pycodedj-0.5.0}/README.ja.md +16 -0
  4. {pycodedj-0.4.0 → pycodedj-0.5.0}/README.md +16 -0
  5. {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.html +26 -1
  6. {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.ja.md +31 -2
  7. {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/manual.md +31 -2
  8. {pycodedj-0.4.0 → pycodedj-0.5.0}/pyproject.toml +1 -1
  9. {pycodedj-0.4.0 → pycodedj-0.5.0}/sc/synths.scd +100 -41
  10. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/__init__.py +1 -1
  11. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/engine.py +2 -1
  12. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/osc_bridge.py +5 -2
  13. pycodedj-0.5.0/src/pycodedj/pattern.py +123 -0
  14. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_engine.py +33 -1
  15. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_osc_bridge.py +45 -5
  16. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_pattern.py +79 -1
  17. pycodedj-0.4.0/src/pycodedj/pattern.py +0 -47
  18. {pycodedj-0.4.0 → pycodedj-0.5.0}/.github/workflows/workflow.yml +0 -0
  19. {pycodedj-0.4.0 → pycodedj-0.5.0}/.gitignore +0 -0
  20. {pycodedj-0.4.0 → pycodedj-0.5.0}/LICENSE +0 -0
  21. {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/CNAME +0 -0
  22. {pycodedj-0.4.0 → pycodedj-0.5.0}/docs/index.html +0 -0
  23. {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/club_set.py +0 -0
  24. {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/demo.py +0 -0
  25. {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/hello_sc.py +0 -0
  26. {pycodedj-0.4.0 → pycodedj-0.5.0}/examples/sound_showcase.py +0 -0
  27. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/__main__.py +0 -0
  28. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/_loop.py +0 -0
  29. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/analyzer.py +0 -0
  30. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/block_parser.py +0 -0
  31. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/mapper.py +0 -0
  32. {pycodedj-0.4.0 → pycodedj-0.5.0}/src/pycodedj/watcher.py +0 -0
  33. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/__init__.py +0 -0
  34. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_analyzer.py +0 -0
  35. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_block_parser.py +0 -0
  36. {pycodedj-0.4.0 → pycodedj-0.5.0}/tests/test_mapper.py +0 -0
  37. {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.4.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 . 3 . 5 .")
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 . 3 . 5 .")
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 melody
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 . 3 . 5 .")
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():
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pycodedj"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  requires-python = ">=3.10"
5
5
  readme = "README.md"
6
6
  license = {file = "LICENSE"}
@@ -697,47 +697,105 @@ s.waitForBoot({
697
697
  };
698
698
  };
699
699
 
700
- ~setupPattern = { |name, rootMidi, scaleName, dur, synthName, steps|
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 = steps.collect({ |step|
704
- if (step == -2) {
705
- Rest()
706
- } {
707
- if (step == -1) {
708
- rootMidi.asInteger
709
- } {
710
- (scale.degrees[step % scale.size] + rootMidi + (step.div(scale.size) * 12)).asInteger
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
- if (synthName.size > 0) {
720
- ~synthForLoop.value(synthName)
721
- } {
722
- ~synthForLoop.value(name)
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, step...]
817
- if (msg.size >= 5) {
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
  },
@@ -2,5 +2,5 @@
2
2
 
3
3
  from ._loop import loop, pattern
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.0"
6
6
  __all__ = ["loop", "pattern"]
@@ -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[int], str]:
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[int],
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
- *steps,
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:] == [-1, -2, -1, -2]
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, [-1, -2, -1, -2])
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"] == [48, "chromatic", 0.25, "", -1, -2, -1, -2]
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, -2]
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