flux-tensor-midi 0.1.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.
- flux_tensor_midi-0.1.0/PKG-INFO +147 -0
- flux_tensor_midi-0.1.0/README.md +124 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/__init__.py +21 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/core/__init__.py +27 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/core/clock.py +144 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/core/flux.py +192 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/core/room.py +201 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/core/snap.py +188 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/__init__.py +8 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/band.py +172 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/score.py +159 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/__init__.py +35 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/chord.py +183 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/jaccard.py +71 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/spectrum.py +138 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/midi/__init__.py +24 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/midi/channel.py +67 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/midi/clock.py +116 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/midi/events.py +159 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/__init__.py +9 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/frown.py +65 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/nod.py +65 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/smile.py +65 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/PKG-INFO +147 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/SOURCES.txt +40 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/dependency_links.txt +1 -0
- flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/top_level.txt +1 -0
- flux_tensor_midi-0.1.0/pyproject.toml +40 -0
- flux_tensor_midi-0.1.0/setup.cfg +4 -0
- flux_tensor_midi-0.1.0/tests/test_band.py +119 -0
- flux_tensor_midi-0.1.0/tests/test_chord.py +106 -0
- flux_tensor_midi-0.1.0/tests/test_clock.py +99 -0
- flux_tensor_midi-0.1.0/tests/test_flux.py +140 -0
- flux_tensor_midi-0.1.0/tests/test_jaccard.py +52 -0
- flux_tensor_midi-0.1.0/tests/test_midi_channel.py +46 -0
- flux_tensor_midi-0.1.0/tests/test_midi_clock.py +116 -0
- flux_tensor_midi-0.1.0/tests/test_midi_events.py +116 -0
- flux_tensor_midi-0.1.0/tests/test_room.py +132 -0
- flux_tensor_midi-0.1.0/tests/test_score.py +112 -0
- flux_tensor_midi-0.1.0/tests/test_sidechannel.py +101 -0
- flux_tensor_midi-0.1.0/tests/test_snap.py +136 -0
- flux_tensor_midi-0.1.0/tests/test_spectrum.py +87 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flux-tensor-midi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PLATO rooms as musicians — flux tensor MIDI ensemble coordination
|
|
5
|
+
Author-email: Forgemaster <forgemaster@superinstance.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SuperInstance/flux-tensor-midi
|
|
8
|
+
Project-URL: Documentation, https://github.com/SuperInstance/flux-tensor-midi#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/SuperInstance/flux-tensor-midi
|
|
10
|
+
Project-URL: Issues, https://github.com/SuperInstance/flux-tensor-midi/issues
|
|
11
|
+
Keywords: midi,flux,tensor,plato,ensemble,music
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Artistic Software
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# FLUX-Tensor-MIDI ⚒️
|
|
25
|
+
|
|
26
|
+
**PLATO rooms as musicians.** Each room has a T-0 clock, produces timestamped events (tiles=notes), listens to other rooms, snaps to rhythm via Eisenstein lattice, and sends side-channels (nods/smiles/frowns) for ensemble coordination.
|
|
27
|
+
|
|
28
|
+
Zero external dependencies. Pure Python 3.10+.
|
|
29
|
+
|
|
30
|
+
## Musical Analogy Table
|
|
31
|
+
|
|
32
|
+
| PLATO Concept | Musical Equivalent | MIDI Mapping |
|
|
33
|
+
|----------------------|-----------------------|---------------------------|
|
|
34
|
+
| Room | Musician | MIDI Channel |
|
|
35
|
+
| T-0 Clock | Metronome / Conductor | MIDI Clock (0xF8) |
|
|
36
|
+
| Tile (event) | Note | Note On/Off |
|
|
37
|
+
| FluxVector (9-ch) | Chord / Harmonic Spectrum | 9 MIDI notes (C4–D5) |
|
|
38
|
+
| Salience | Note Velocity | Velocity (0–127) |
|
|
39
|
+
| Tolerance | Pitch Bend / Jitter | Pitch Bend range |
|
|
40
|
+
| Eisenstein Snap | Rhythmic Quantization | Grid snapping |
|
|
41
|
+
| Nod | Nod (agreement) | CC #1 (Mod Wheel) |
|
|
42
|
+
| Smile | Smile (approval) | CC #2 (Breath) |
|
|
43
|
+
| Frown | Frown (disagreement) | CC #3 (Expression) |
|
|
44
|
+
| Listening (room→room)| Musician's ear | MIDI Channel routing |
|
|
45
|
+
| Conductor | Band leader / click | Master MIDI Clock |
|
|
46
|
+
| Ensemble | Band / Orchestra | Multi-channel MIDI output |
|
|
47
|
+
| Jaccard Harmony | Chord similarity | Chromatic interval match |
|
|
48
|
+
| Rhythmic Role | Groove part | Program change (patch) |
|
|
49
|
+
|
|
50
|
+
## Rhythm Ratios (Eisenstein Lattice)
|
|
51
|
+
|
|
52
|
+
| Role | Ratio | Period (at 120 BPM) | Musical Feel |
|
|
53
|
+
|-------------|-------|---------------------|--------------------|
|
|
54
|
+
| Root | 1:1 | 500 ms | Downbeat / quarter |
|
|
55
|
+
| Halftime | 2:1 | 1000 ms | Half speed |
|
|
56
|
+
| Triplet | 3:2 | 750 ms | Swung feel |
|
|
57
|
+
| Waltz | 3:1 | 1500 ms | Waltz time |
|
|
58
|
+
| Compound | 4:3 | ~667 ms | Compound meter |
|
|
59
|
+
| Doubletime | 1:2 | 250 ms | Double speed |
|
|
60
|
+
| Offset | 1:1 | 500 ms + 120° phase | Syncopated |
|
|
61
|
+
| Quintuple | 5:4 | 625 ms | Quintuple meter |
|
|
62
|
+
| Septuple | 7:4 | 875 ms | Septuple meter |
|
|
63
|
+
|
|
64
|
+
Covering radius: **1/√3 ≈ 0.577** — optimal hexagonal tiling.
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install flux-tensor-midi
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from flux_tensor_midi import FluxVector, TZeroClock, RoomMusician, EisensteinSnap
|
|
74
|
+
from flux_tensor_midi.core.snap import RhythmicRole
|
|
75
|
+
from flux_tensor_midi.ensemble.band import Band
|
|
76
|
+
|
|
77
|
+
# Create musicians with different rhythmic roles
|
|
78
|
+
piano = RoomMusician("Piano", role=RhythmicRole.ROOT)
|
|
79
|
+
bass = RoomMusician("Bass", role=RhythmicRole.HALFTIME)
|
|
80
|
+
drums = RoomMusician("Drums", role=RhythmicRole.TRIPLET)
|
|
81
|
+
|
|
82
|
+
# Form a band with a conductor
|
|
83
|
+
band = Band("Trio", conductor=piano, bpm=120.0)
|
|
84
|
+
band.add_musician(bass)
|
|
85
|
+
band.add_musician(drums)
|
|
86
|
+
band.everyone_listens_to_everyone()
|
|
87
|
+
|
|
88
|
+
# Set some initial state
|
|
89
|
+
piano.update_state(FluxVector([0.8, 0.0, 0.6, 0.0, 0.7, 0.0, 0.0, 0.0, 0.0]))
|
|
90
|
+
bass.update_state(FluxVector([0.0, 0.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])) # simple root
|
|
91
|
+
|
|
92
|
+
# Tick the band
|
|
93
|
+
results = band.tick_all()
|
|
94
|
+
print(f"Piano event at {results['Piano'][0]:.1f}ms")
|
|
95
|
+
|
|
96
|
+
# Check harmony
|
|
97
|
+
hs = band.harmony()
|
|
98
|
+
print(f"Chord quality: {hs.quality()}, consonance: {hs.consonance():.3f}")
|
|
99
|
+
|
|
100
|
+
# Side-channel comms
|
|
101
|
+
piano.send_nod(bass)
|
|
102
|
+
print(f"Bass received nods from: {bass.receive_sidechannels()['nods']}")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Package Structure
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
flux_tensor_midi/
|
|
109
|
+
├── __init__.py
|
|
110
|
+
├── core/
|
|
111
|
+
│ ├── __init__.py
|
|
112
|
+
│ ├── flux.py — FluxVector (9-ch tensor with salience+tolerance)
|
|
113
|
+
│ ├── clock.py — TZeroClock (EWMA-adaptive metronome)
|
|
114
|
+
│ ├── room.py — RoomMusician (PLATO room as musician)
|
|
115
|
+
│ └── snap.py — EisensteinSnap (rhythmic quantization lattice)
|
|
116
|
+
├── midi/
|
|
117
|
+
│ ├── __init__.py
|
|
118
|
+
│ ├── events.py — MidiEvent, NoteName, flux→MIDI conversion
|
|
119
|
+
│ ├── clock.py — MidiClock (24 PPQN, software clock)
|
|
120
|
+
│ └── channel.py — MidiChannel mapping by rhythmic role
|
|
121
|
+
├── sidechannel/
|
|
122
|
+
│ ├── __init__.py
|
|
123
|
+
│ ├── nod.py — Nod gesture (agreement/acknowledgment)
|
|
124
|
+
│ ├── smile.py — Smile gesture (positive affect)
|
|
125
|
+
│ └── frown.py — Frown gesture (disagreement/concern)
|
|
126
|
+
├── harmony/
|
|
127
|
+
│ ├── __init__.py
|
|
128
|
+
│ ├── jaccard.py — Jaccard similarity for FluxVector sets
|
|
129
|
+
│ ├── spectrum.py — Spectral analysis (centroid, flux, autocorr)
|
|
130
|
+
│ └── chord.py — HarmonyState (consonance, quality, voice leading)
|
|
131
|
+
└── ensemble/
|
|
132
|
+
├── __init__.py
|
|
133
|
+
├── band.py — Band (ensemble with conductor)
|
|
134
|
+
└── score.py — Score (recorded performance, export)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Running Tests
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
cd python
|
|
141
|
+
pip install -e ".[dev]"
|
|
142
|
+
pytest -v
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# FLUX-Tensor-MIDI ⚒️
|
|
2
|
+
|
|
3
|
+
**PLATO rooms as musicians.** Each room has a T-0 clock, produces timestamped events (tiles=notes), listens to other rooms, snaps to rhythm via Eisenstein lattice, and sends side-channels (nods/smiles/frowns) for ensemble coordination.
|
|
4
|
+
|
|
5
|
+
Zero external dependencies. Pure Python 3.10+.
|
|
6
|
+
|
|
7
|
+
## Musical Analogy Table
|
|
8
|
+
|
|
9
|
+
| PLATO Concept | Musical Equivalent | MIDI Mapping |
|
|
10
|
+
|----------------------|-----------------------|---------------------------|
|
|
11
|
+
| Room | Musician | MIDI Channel |
|
|
12
|
+
| T-0 Clock | Metronome / Conductor | MIDI Clock (0xF8) |
|
|
13
|
+
| Tile (event) | Note | Note On/Off |
|
|
14
|
+
| FluxVector (9-ch) | Chord / Harmonic Spectrum | 9 MIDI notes (C4–D5) |
|
|
15
|
+
| Salience | Note Velocity | Velocity (0–127) |
|
|
16
|
+
| Tolerance | Pitch Bend / Jitter | Pitch Bend range |
|
|
17
|
+
| Eisenstein Snap | Rhythmic Quantization | Grid snapping |
|
|
18
|
+
| Nod | Nod (agreement) | CC #1 (Mod Wheel) |
|
|
19
|
+
| Smile | Smile (approval) | CC #2 (Breath) |
|
|
20
|
+
| Frown | Frown (disagreement) | CC #3 (Expression) |
|
|
21
|
+
| Listening (room→room)| Musician's ear | MIDI Channel routing |
|
|
22
|
+
| Conductor | Band leader / click | Master MIDI Clock |
|
|
23
|
+
| Ensemble | Band / Orchestra | Multi-channel MIDI output |
|
|
24
|
+
| Jaccard Harmony | Chord similarity | Chromatic interval match |
|
|
25
|
+
| Rhythmic Role | Groove part | Program change (patch) |
|
|
26
|
+
|
|
27
|
+
## Rhythm Ratios (Eisenstein Lattice)
|
|
28
|
+
|
|
29
|
+
| Role | Ratio | Period (at 120 BPM) | Musical Feel |
|
|
30
|
+
|-------------|-------|---------------------|--------------------|
|
|
31
|
+
| Root | 1:1 | 500 ms | Downbeat / quarter |
|
|
32
|
+
| Halftime | 2:1 | 1000 ms | Half speed |
|
|
33
|
+
| Triplet | 3:2 | 750 ms | Swung feel |
|
|
34
|
+
| Waltz | 3:1 | 1500 ms | Waltz time |
|
|
35
|
+
| Compound | 4:3 | ~667 ms | Compound meter |
|
|
36
|
+
| Doubletime | 1:2 | 250 ms | Double speed |
|
|
37
|
+
| Offset | 1:1 | 500 ms + 120° phase | Syncopated |
|
|
38
|
+
| Quintuple | 5:4 | 625 ms | Quintuple meter |
|
|
39
|
+
| Septuple | 7:4 | 875 ms | Septuple meter |
|
|
40
|
+
|
|
41
|
+
Covering radius: **1/√3 ≈ 0.577** — optimal hexagonal tiling.
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install flux-tensor-midi
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from flux_tensor_midi import FluxVector, TZeroClock, RoomMusician, EisensteinSnap
|
|
51
|
+
from flux_tensor_midi.core.snap import RhythmicRole
|
|
52
|
+
from flux_tensor_midi.ensemble.band import Band
|
|
53
|
+
|
|
54
|
+
# Create musicians with different rhythmic roles
|
|
55
|
+
piano = RoomMusician("Piano", role=RhythmicRole.ROOT)
|
|
56
|
+
bass = RoomMusician("Bass", role=RhythmicRole.HALFTIME)
|
|
57
|
+
drums = RoomMusician("Drums", role=RhythmicRole.TRIPLET)
|
|
58
|
+
|
|
59
|
+
# Form a band with a conductor
|
|
60
|
+
band = Band("Trio", conductor=piano, bpm=120.0)
|
|
61
|
+
band.add_musician(bass)
|
|
62
|
+
band.add_musician(drums)
|
|
63
|
+
band.everyone_listens_to_everyone()
|
|
64
|
+
|
|
65
|
+
# Set some initial state
|
|
66
|
+
piano.update_state(FluxVector([0.8, 0.0, 0.6, 0.0, 0.7, 0.0, 0.0, 0.0, 0.0]))
|
|
67
|
+
bass.update_state(FluxVector([0.0, 0.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])) # simple root
|
|
68
|
+
|
|
69
|
+
# Tick the band
|
|
70
|
+
results = band.tick_all()
|
|
71
|
+
print(f"Piano event at {results['Piano'][0]:.1f}ms")
|
|
72
|
+
|
|
73
|
+
# Check harmony
|
|
74
|
+
hs = band.harmony()
|
|
75
|
+
print(f"Chord quality: {hs.quality()}, consonance: {hs.consonance():.3f}")
|
|
76
|
+
|
|
77
|
+
# Side-channel comms
|
|
78
|
+
piano.send_nod(bass)
|
|
79
|
+
print(f"Bass received nods from: {bass.receive_sidechannels()['nods']}")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Package Structure
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
flux_tensor_midi/
|
|
86
|
+
├── __init__.py
|
|
87
|
+
├── core/
|
|
88
|
+
│ ├── __init__.py
|
|
89
|
+
│ ├── flux.py — FluxVector (9-ch tensor with salience+tolerance)
|
|
90
|
+
│ ├── clock.py — TZeroClock (EWMA-adaptive metronome)
|
|
91
|
+
│ ├── room.py — RoomMusician (PLATO room as musician)
|
|
92
|
+
│ └── snap.py — EisensteinSnap (rhythmic quantization lattice)
|
|
93
|
+
├── midi/
|
|
94
|
+
│ ├── __init__.py
|
|
95
|
+
│ ├── events.py — MidiEvent, NoteName, flux→MIDI conversion
|
|
96
|
+
│ ├── clock.py — MidiClock (24 PPQN, software clock)
|
|
97
|
+
│ └── channel.py — MidiChannel mapping by rhythmic role
|
|
98
|
+
├── sidechannel/
|
|
99
|
+
│ ├── __init__.py
|
|
100
|
+
│ ├── nod.py — Nod gesture (agreement/acknowledgment)
|
|
101
|
+
│ ├── smile.py — Smile gesture (positive affect)
|
|
102
|
+
│ └── frown.py — Frown gesture (disagreement/concern)
|
|
103
|
+
├── harmony/
|
|
104
|
+
│ ├── __init__.py
|
|
105
|
+
│ ├── jaccard.py — Jaccard similarity for FluxVector sets
|
|
106
|
+
│ ├── spectrum.py — Spectral analysis (centroid, flux, autocorr)
|
|
107
|
+
│ └── chord.py — HarmonyState (consonance, quality, voice leading)
|
|
108
|
+
└── ensemble/
|
|
109
|
+
├── __init__.py
|
|
110
|
+
├── band.py — Band (ensemble with conductor)
|
|
111
|
+
└── score.py — Score (recorded performance, export)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Running Tests
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cd python
|
|
118
|
+
pip install -e ".[dev]"
|
|
119
|
+
pytest -v
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FLUX-Tensor-MIDI: PLATO rooms as musicians.
|
|
3
|
+
|
|
4
|
+
Each room has a T-0 clock, produces timestamped events (tiles=notes),
|
|
5
|
+
listens to others, snaps to rhythm via Eisenstein lattice, and sends
|
|
6
|
+
side-channels (nods/smiles/frowns) for ensemble coordination.
|
|
7
|
+
|
|
8
|
+
Zero external dependencies. Pure Python 3.10+.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from flux_tensor_midi.core.flux import FluxVector
|
|
12
|
+
from flux_tensor_midi.core.clock import TZeroClock
|
|
13
|
+
from flux_tensor_midi.core.room import RoomMusician
|
|
14
|
+
from flux_tensor_midi.core.snap import EisensteinSnap
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FluxVector",
|
|
18
|
+
"TZeroClock",
|
|
19
|
+
"RoomMusician",
|
|
20
|
+
"EisensteinSnap",
|
|
21
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core modules for FLUX-Tensor-MIDI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from flux_tensor_midi.core.flux import FluxVector
|
|
6
|
+
from flux_tensor_midi.core.clock import TZeroClock
|
|
7
|
+
from flux_tensor_midi.core.room import RoomMusician
|
|
8
|
+
from flux_tensor_midi.core.snap import (
|
|
9
|
+
EisensteinSnap,
|
|
10
|
+
EisensteinRatio,
|
|
11
|
+
RhythmicRole,
|
|
12
|
+
UNISON,
|
|
13
|
+
HALFTIME,
|
|
14
|
+
TRIPLET,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FluxVector",
|
|
19
|
+
"TZeroClock",
|
|
20
|
+
"RoomMusician",
|
|
21
|
+
"EisensteinSnap",
|
|
22
|
+
"EisensteinRatio",
|
|
23
|
+
"RhythmicRole",
|
|
24
|
+
"UNISON",
|
|
25
|
+
"HALFTIME",
|
|
26
|
+
"TRIPLET",
|
|
27
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TZeroClock: An EWMA-adaptive clock for PLATO room T-0 time.
|
|
3
|
+
|
|
4
|
+
Each RoomMusician has a TZeroClock that produces drift-corrected
|
|
5
|
+
timestamps. Uses Exponentially Weighted Moving Average to adapt
|
|
6
|
+
to observed clock skew from the conductor (or any reference beat).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TZeroClock:
|
|
16
|
+
"""An adaptive clock using EWMA for drift correction.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
alpha : float, default=0.125
|
|
21
|
+
EWMA smoothing factor. Higher = faster adapt, lower = smoother.
|
|
22
|
+
reference_clock : Callable[[], float], optional
|
|
23
|
+
Source of wall time in seconds. Defaults to time.monotonic.
|
|
24
|
+
initial_ticks : int, default=0
|
|
25
|
+
Starting tick count.
|
|
26
|
+
bpm : float, default=120.0
|
|
27
|
+
Beats per minute for tick duration.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
alpha: float = 0.125,
|
|
33
|
+
reference_clock: Callable[[], float] | None = None,
|
|
34
|
+
initial_ticks: int = 0,
|
|
35
|
+
bpm: float = 120.0,
|
|
36
|
+
):
|
|
37
|
+
if not 0 < alpha < 1:
|
|
38
|
+
raise ValueError(f"alpha must be in (0, 1), got {alpha}")
|
|
39
|
+
if bpm <= 0:
|
|
40
|
+
raise ValueError(f"bpm must be positive, got {bpm}")
|
|
41
|
+
|
|
42
|
+
self._alpha = alpha
|
|
43
|
+
self._clock = reference_clock or time.monotonic
|
|
44
|
+
self._ticks = initial_ticks
|
|
45
|
+
self._bpm = bpm
|
|
46
|
+
|
|
47
|
+
# EWMA state
|
|
48
|
+
self._drift: float = 0.0 # ms drift estimate
|
|
49
|
+
self._last_wall: float = self._clock() # last wall clock reading
|
|
50
|
+
|
|
51
|
+
# Timing
|
|
52
|
+
self._tick_duration_s: float = 60.0 / bpm # seconds per tick
|
|
53
|
+
self._start_wall: float = self._last_wall
|
|
54
|
+
self._start_tick: float = 0.0 # virtual tick time at start
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def alpha(self) -> float:
|
|
58
|
+
return self._alpha
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def bpm(self) -> float:
|
|
62
|
+
return self._bpm
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def tick_duration_ms(self) -> float:
|
|
66
|
+
return self._tick_duration_s * 1000.0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ticks(self) -> int:
|
|
70
|
+
return self._ticks
|
|
71
|
+
|
|
72
|
+
# ---- core operations ----
|
|
73
|
+
|
|
74
|
+
def tick(self) -> float:
|
|
75
|
+
"""Advance one tick and return the corrected timestamp (ms)."""
|
|
76
|
+
now = self._clock()
|
|
77
|
+
expected_s = (self._ticks + 1) * self._tick_duration_s
|
|
78
|
+
actual_s = now - self._start_wall
|
|
79
|
+
|
|
80
|
+
# Observed drift in ms
|
|
81
|
+
observed_drift_ms = (actual_s - expected_s) * 1000.0
|
|
82
|
+
|
|
83
|
+
# EWMA update
|
|
84
|
+
self._drift = self._alpha * observed_drift_ms + (1 - self._alpha) * self._drift
|
|
85
|
+
|
|
86
|
+
# Corrected timestamp (ms): expected time - estimated drift
|
|
87
|
+
corrected_ms = expected_s * 1000.0 - self._drift
|
|
88
|
+
|
|
89
|
+
self._ticks += 1
|
|
90
|
+
self._last_wall = now
|
|
91
|
+
return corrected_ms
|
|
92
|
+
|
|
93
|
+
def time_ms(self) -> float:
|
|
94
|
+
"""Current corrected time in ms (without advancing ticks)."""
|
|
95
|
+
now = self._clock()
|
|
96
|
+
elapsed_s = now - self._start_wall
|
|
97
|
+
return elapsed_s * 1000.0 - self._drift
|
|
98
|
+
|
|
99
|
+
def drift_ms(self) -> float:
|
|
100
|
+
"""Current estimated drift in ms (negative = ahead of wall)."""
|
|
101
|
+
return self._drift
|
|
102
|
+
|
|
103
|
+
def reset(self, bpm: float | None = None) -> None:
|
|
104
|
+
"""Reset the clock. Optionally set a new BPM."""
|
|
105
|
+
if bpm is not None:
|
|
106
|
+
if bpm <= 0:
|
|
107
|
+
raise ValueError(f"bpm must be positive, got {bpm}")
|
|
108
|
+
self._bpm = bpm
|
|
109
|
+
self._tick_duration_s = 60.0 / bpm
|
|
110
|
+
self._drift = 0.0
|
|
111
|
+
self._ticks = 0
|
|
112
|
+
self._last_wall = self._clock()
|
|
113
|
+
self._start_wall = self._last_wall
|
|
114
|
+
self._start_tick = 0.0
|
|
115
|
+
|
|
116
|
+
def set_bpm(self, bpm: float) -> None:
|
|
117
|
+
"""Change BPM mid-stream (preserves drift estimate)."""
|
|
118
|
+
if bpm <= 0:
|
|
119
|
+
raise ValueError(f"bpm must be positive, got {bpm}")
|
|
120
|
+
self._bpm = bpm
|
|
121
|
+
self._tick_duration_s = 60.0 / bpm
|
|
122
|
+
|
|
123
|
+
def synchronize_to(self, other: TZeroClock) -> None:
|
|
124
|
+
"""Synchronize drift estimate to another clock."""
|
|
125
|
+
self._drift = other._drift
|
|
126
|
+
|
|
127
|
+
# ---- alignment ----
|
|
128
|
+
|
|
129
|
+
def align(self, reference_timestamp: float) -> float:
|
|
130
|
+
"""Align clock to a reference timestamp, return correction applied."""
|
|
131
|
+
current = self.time_ms()
|
|
132
|
+
correction = reference_timestamp - current
|
|
133
|
+
# Apply correction by adjusting start wall equivalently
|
|
134
|
+
# (negative correction = we were ahead, move start_wall forward)
|
|
135
|
+
self._start_wall -= correction / 1000.0
|
|
136
|
+
return correction
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_beat(cls, beat_number: int, bpm: float = 120.0, alpha: float = 0.125) -> TZeroClock:
|
|
140
|
+
"""Create a clock pre-advanced to a specific beat."""
|
|
141
|
+
c = cls(alpha=alpha, bpm=bpm)
|
|
142
|
+
for _ in range(beat_number):
|
|
143
|
+
c.tick()
|
|
144
|
+
return c
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FluxVector: A 9-channel tensor representing PLATO room state.
|
|
3
|
+
|
|
4
|
+
Each channel has a salience (importance) and tolerance (jitter allowed).
|
|
5
|
+
Inspired by the musical metaphor: 9 channels = notes in a harmonic spectrum,
|
|
6
|
+
salience = velocity, tolerance = pitch bend range.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import math
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FluxVector:
|
|
15
|
+
"""A 9-channel vector with per-channel salience and tolerance.
|
|
16
|
+
|
|
17
|
+
PLATO rooms produce timestamped events. Each event is encoded as a
|
|
18
|
+
FluxVector whose channels represent aspects of the room's state.
|
|
19
|
+
Salience is the strength (0–1), tolerance is the allowed jitter (ms).
|
|
20
|
+
|
|
21
|
+
Properties
|
|
22
|
+
----------
|
|
23
|
+
channels : int
|
|
24
|
+
Fixed at 9.
|
|
25
|
+
salience : tuple[float, ...]
|
|
26
|
+
Length-9 tuple of salience values (0 ≤ s ≤ 1).
|
|
27
|
+
tolerance : tuple[float, ...]
|
|
28
|
+
Length-9 tuple of tolerance values in milliseconds.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_CHANNELS = 9
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
values: Sequence[float],
|
|
36
|
+
salience: Sequence[float] | None = None,
|
|
37
|
+
tolerance: Sequence[float] | None = None,
|
|
38
|
+
):
|
|
39
|
+
if len(values) != self._CHANNELS:
|
|
40
|
+
raise ValueError(f"FluxVector requires {self._CHANNELS} values, got {len(values)}")
|
|
41
|
+
if salience is not None and len(salience) != self._CHANNELS:
|
|
42
|
+
raise ValueError(f"salience must have {self._CHANNELS} elements, got {len(salience)}")
|
|
43
|
+
if tolerance is not None and len(tolerance) != self._CHANNELS:
|
|
44
|
+
raise ValueError(f"tolerance must have {self._CHANNELS} elements, got {len(tolerance)}")
|
|
45
|
+
|
|
46
|
+
self._values = tuple(float(v) for v in values)
|
|
47
|
+
self._salience = tuple(
|
|
48
|
+
float(s) if s is not None else 1.0 for s in (salience or [1.0] * self._CHANNELS)
|
|
49
|
+
)
|
|
50
|
+
self._tolerance = tuple(
|
|
51
|
+
float(t) if t is not None else 0.0 for t in (tolerance or [0.0] * self._CHANNELS)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Clamp salience
|
|
55
|
+
self._salience = tuple(max(0.0, min(1.0, s)) for s in self._salience)
|
|
56
|
+
|
|
57
|
+
# ---- accessors ----
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def values(self) -> tuple[float, ...]:
|
|
61
|
+
return self._values
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def salience(self) -> tuple[float, ...]:
|
|
65
|
+
return self._salience
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def tolerance(self) -> tuple[float, ...]:
|
|
69
|
+
return self._tolerance
|
|
70
|
+
|
|
71
|
+
def __getitem__(self, idx: int) -> float:
|
|
72
|
+
return self._values[idx]
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return self._CHANNELS
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return (
|
|
79
|
+
f"FluxVector({list(self._values)}, "
|
|
80
|
+
f"salience={list(self._salience)}, "
|
|
81
|
+
f"tolerance={list(self._tolerance)})"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __eq__(self, other: object) -> bool:
|
|
85
|
+
if not isinstance(other, FluxVector):
|
|
86
|
+
return NotImplemented
|
|
87
|
+
return (
|
|
88
|
+
self._values == other._values
|
|
89
|
+
and self._salience == other._salience
|
|
90
|
+
and self._tolerance == other._tolerance
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __hash__(self) -> int:
|
|
94
|
+
return hash((self._values, self._salience, self._tolerance))
|
|
95
|
+
|
|
96
|
+
# ---- operators ----
|
|
97
|
+
|
|
98
|
+
def __add__(self, other: FluxVector) -> FluxVector:
|
|
99
|
+
"""Element-wise addition, weighting by minimum salience."""
|
|
100
|
+
if not isinstance(other, FluxVector):
|
|
101
|
+
return NotImplemented
|
|
102
|
+
w = tuple(min(a, b) for a, b in zip(self._salience, other._salience))
|
|
103
|
+
v = tuple(self._values[i] + other._values[i] for i in range(self._CHANNELS))
|
|
104
|
+
s = tuple(max(a, b) for a, b in zip(self._salience, other._salience))
|
|
105
|
+
t = tuple(max(a, b) for a, b in zip(self._tolerance, other._tolerance))
|
|
106
|
+
return FluxVector(v, salience=s, tolerance=t)
|
|
107
|
+
|
|
108
|
+
def __sub__(self, other: FluxVector) -> FluxVector:
|
|
109
|
+
"""Element-wise subtraction, weighting by minimum salience."""
|
|
110
|
+
if not isinstance(other, FluxVector):
|
|
111
|
+
return NotImplemented
|
|
112
|
+
v = tuple(self._values[i] - other._values[i] for i in range(self._CHANNELS))
|
|
113
|
+
s = tuple(max(a, b) for a, b in zip(self._salience, other._salience))
|
|
114
|
+
t = tuple(max(a, b) for a, b in zip(self._tolerance, other._tolerance))
|
|
115
|
+
return FluxVector(v, salience=s, tolerance=t)
|
|
116
|
+
|
|
117
|
+
def __mul__(self, scalar: float) -> FluxVector:
|
|
118
|
+
"""Scale all values by a scalar."""
|
|
119
|
+
v = tuple(x * scalar for x in self._values)
|
|
120
|
+
return FluxVector(v, salience=self._salience, tolerance=self._tolerance)
|
|
121
|
+
|
|
122
|
+
def __rmul__(self, scalar: float) -> FluxVector:
|
|
123
|
+
return self.__mul__(scalar)
|
|
124
|
+
|
|
125
|
+
# ---- norms / distance ----
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def magnitude(self) -> float:
|
|
129
|
+
"""Euclidean norm."""
|
|
130
|
+
return math.sqrt(sum(x * x for x in self._values))
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def salience_weighted_magnitude(self) -> float:
|
|
134
|
+
"""Euclidean norm weighted by salience."""
|
|
135
|
+
return math.sqrt(
|
|
136
|
+
sum(self._salience[i] * self._values[i] * self._values[i] for i in range(self._CHANNELS))
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def distance_to(self, other: FluxVector, weighted: bool = False) -> float:
|
|
140
|
+
"""Euclidean distance to another FluxVector.
|
|
141
|
+
|
|
142
|
+
If weighted, use salience-weighted difference.
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(other, FluxVector):
|
|
145
|
+
raise TypeError("distance_to requires a FluxVector")
|
|
146
|
+
if weighted:
|
|
147
|
+
diff = tuple(
|
|
148
|
+
self._salience[i] * (self._values[i] - other._values[i])
|
|
149
|
+
for i in range(self._CHANNELS)
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
diff = tuple(self._values[i] - other._values[i] for i in range(self._CHANNELS))
|
|
153
|
+
return math.sqrt(sum(d * d for d in diff))
|
|
154
|
+
|
|
155
|
+
def dot(self, other: FluxVector) -> float:
|
|
156
|
+
"""Dot product, unweighted."""
|
|
157
|
+
if not isinstance(other, FluxVector):
|
|
158
|
+
raise TypeError("dot requires a FluxVector")
|
|
159
|
+
return sum(self._values[i] * other._values[i] for i in range(self._CHANNELS))
|
|
160
|
+
|
|
161
|
+
def cosine_similarity(self, other: FluxVector) -> float:
|
|
162
|
+
"""Cosine similarity (-1 to 1)."""
|
|
163
|
+
mag_self = self.magnitude
|
|
164
|
+
mag_other = other.magnitude
|
|
165
|
+
if mag_self == 0.0 or mag_other == 0.0:
|
|
166
|
+
return 0.0
|
|
167
|
+
return self.dot(other) / (mag_self * mag_other)
|
|
168
|
+
|
|
169
|
+
# ---- tolerance helpers ----
|
|
170
|
+
|
|
171
|
+
def within_tolerance(self, other: FluxVector) -> bool:
|
|
172
|
+
"""True if every channel is within its tolerance of the other."""
|
|
173
|
+
for i in range(self._CHANNELS):
|
|
174
|
+
if abs(self._values[i] - other._values[i]) > self._tolerance[i]:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def jitter(self, channel: int) -> float:
|
|
179
|
+
"""Return the allowed jitter (tolerance) for a specific channel."""
|
|
180
|
+
return self._tolerance[channel]
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def zero(cls, salience: Sequence[float] | None = None) -> FluxVector:
|
|
184
|
+
"""Create a zero FluxVector with optional salience."""
|
|
185
|
+
return cls([0.0] * cls._CHANNELS, salience=salience)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def unit(cls, channel: int, salience: Sequence[float] | None = None) -> FluxVector:
|
|
189
|
+
"""Create a unit vector along one channel."""
|
|
190
|
+
v = [0.0] * cls._CHANNELS
|
|
191
|
+
v[channel] = 1.0
|
|
192
|
+
return cls(v, salience=salience)
|