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.
Files changed (42) hide show
  1. flux_tensor_midi-0.1.0/PKG-INFO +147 -0
  2. flux_tensor_midi-0.1.0/README.md +124 -0
  3. flux_tensor_midi-0.1.0/flux_tensor_midi/__init__.py +21 -0
  4. flux_tensor_midi-0.1.0/flux_tensor_midi/core/__init__.py +27 -0
  5. flux_tensor_midi-0.1.0/flux_tensor_midi/core/clock.py +144 -0
  6. flux_tensor_midi-0.1.0/flux_tensor_midi/core/flux.py +192 -0
  7. flux_tensor_midi-0.1.0/flux_tensor_midi/core/room.py +201 -0
  8. flux_tensor_midi-0.1.0/flux_tensor_midi/core/snap.py +188 -0
  9. flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/__init__.py +8 -0
  10. flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/band.py +172 -0
  11. flux_tensor_midi-0.1.0/flux_tensor_midi/ensemble/score.py +159 -0
  12. flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/__init__.py +35 -0
  13. flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/chord.py +183 -0
  14. flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/jaccard.py +71 -0
  15. flux_tensor_midi-0.1.0/flux_tensor_midi/harmony/spectrum.py +138 -0
  16. flux_tensor_midi-0.1.0/flux_tensor_midi/midi/__init__.py +24 -0
  17. flux_tensor_midi-0.1.0/flux_tensor_midi/midi/channel.py +67 -0
  18. flux_tensor_midi-0.1.0/flux_tensor_midi/midi/clock.py +116 -0
  19. flux_tensor_midi-0.1.0/flux_tensor_midi/midi/events.py +159 -0
  20. flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/__init__.py +9 -0
  21. flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/frown.py +65 -0
  22. flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/nod.py +65 -0
  23. flux_tensor_midi-0.1.0/flux_tensor_midi/sidechannel/smile.py +65 -0
  24. flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/PKG-INFO +147 -0
  25. flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/SOURCES.txt +40 -0
  26. flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/dependency_links.txt +1 -0
  27. flux_tensor_midi-0.1.0/flux_tensor_midi.egg-info/top_level.txt +1 -0
  28. flux_tensor_midi-0.1.0/pyproject.toml +40 -0
  29. flux_tensor_midi-0.1.0/setup.cfg +4 -0
  30. flux_tensor_midi-0.1.0/tests/test_band.py +119 -0
  31. flux_tensor_midi-0.1.0/tests/test_chord.py +106 -0
  32. flux_tensor_midi-0.1.0/tests/test_clock.py +99 -0
  33. flux_tensor_midi-0.1.0/tests/test_flux.py +140 -0
  34. flux_tensor_midi-0.1.0/tests/test_jaccard.py +52 -0
  35. flux_tensor_midi-0.1.0/tests/test_midi_channel.py +46 -0
  36. flux_tensor_midi-0.1.0/tests/test_midi_clock.py +116 -0
  37. flux_tensor_midi-0.1.0/tests/test_midi_events.py +116 -0
  38. flux_tensor_midi-0.1.0/tests/test_room.py +132 -0
  39. flux_tensor_midi-0.1.0/tests/test_score.py +112 -0
  40. flux_tensor_midi-0.1.0/tests/test_sidechannel.py +101 -0
  41. flux_tensor_midi-0.1.0/tests/test_snap.py +136 -0
  42. 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)