tuney 0.2.0__py3-none-any.whl
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.
- tuney/__init__.py +0 -0
- tuney/__main__.py +10 -0
- tuney/audio/__init__.py +8 -0
- tuney/audio/device_config.py +40 -0
- tuney/audio/file_player.py +37 -0
- tuney/audio/midi.py +20 -0
- tuney/audio/oscillator.py +49 -0
- tuney/audio/player.py +58 -0
- tuney/audio/runnable.py +22 -0
- tuney/audio/sample_data.py +36 -0
- tuney/audio/scipy.py +701 -0
- tuney/audio/synth_player.py +151 -0
- tuney/keyboard/__init__.py +13 -0
- tuney/keyboard/key_types.py +17 -0
- tuney/keyboard/listener.py +93 -0
- tuney/keyboard/queue.py +84 -0
- tuney/mapper/__init__.py +0 -0
- tuney/mapper/linear_mapper.py +29 -0
- tuney/scale/__init__.py +0 -0
- tuney/scale/nearest_note.py +23 -0
- tuney/scale/scale.py +32 -0
- tuney/scale/twelve_tet.py +50 -0
- tuney/time/__init__.py +4 -0
- tuney/time/event.py +61 -0
- tuney/time/text_timings.py +114 -0
- tuney/ui/__init__.py +0 -0
- tuney/ui/controller.py +45 -0
- tuney/ui/keyboard_controller.py +25 -0
- tuney/ui/note_grid.py +74 -0
- tuney/ui/note_grid.tcss +18 -0
- tuney/ui/text_controller.py +48 -0
- tuney-0.2.0.dist-info/METADATA +23 -0
- tuney-0.2.0.dist-info/RECORD +36 -0
- tuney-0.2.0.dist-info/WHEEL +4 -0
- tuney-0.2.0.dist-info/entry_points.txt +2 -0
- tuney-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from threading import Thread
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..scale.scale import NoteNumber, Scale
|
|
12
|
+
from ..scale.twelve_tet import TWELVE_TET
|
|
13
|
+
from . import Data, Number
|
|
14
|
+
from . import oscillator as osc
|
|
15
|
+
from .device_config import DeviceConfig
|
|
16
|
+
from .player import Player
|
|
17
|
+
|
|
18
|
+
INTENSITY = 0.1
|
|
19
|
+
FADE = 0 # 0x40000
|
|
20
|
+
OSC = osc.sawtooth
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# TODO: relieve the tension between human units (frequency, time) and sample units.
|
|
24
|
+
@dc.dataclass(frozen=True)
|
|
25
|
+
class Sound:
|
|
26
|
+
period: Number = 0x100
|
|
27
|
+
intensity: Number = INTENSITY
|
|
28
|
+
fade_in_samples: Number = 0x1000
|
|
29
|
+
fade_out_samples: Number = 0x1000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dc.dataclass
|
|
33
|
+
class OscillatorPlayer(Player):
|
|
34
|
+
sound: Sound = dc.field(default_factory=Sound)
|
|
35
|
+
oscillator: osc.Oscillator = OSC
|
|
36
|
+
|
|
37
|
+
_stopping: bool = False
|
|
38
|
+
|
|
39
|
+
#: Records the the frame we started to fade out.
|
|
40
|
+
_fade_frame: Number | None = None
|
|
41
|
+
|
|
42
|
+
def stop(self) -> None:
|
|
43
|
+
if self.sound.fade_out_samples > 0:
|
|
44
|
+
self._stopping = True
|
|
45
|
+
else:
|
|
46
|
+
super().stop()
|
|
47
|
+
|
|
48
|
+
def _fill(self, out: Data) -> bool:
|
|
49
|
+
start = self.frame_count % self.sound.period
|
|
50
|
+
end = start + len(out)
|
|
51
|
+
ratio = self.oscillator.period / self.sound.period
|
|
52
|
+
wave = np.linspace(start * ratio, end * ratio, len(out))
|
|
53
|
+
wave = self.oscillator.function(wave, out=wave)
|
|
54
|
+
|
|
55
|
+
intensity = self.sound.intensity
|
|
56
|
+
with suppress(ValueError):
|
|
57
|
+
# Scale up from [-1, 1] for int types only
|
|
58
|
+
intensity *= np.iinfo(out.dtype).max
|
|
59
|
+
wave *= intensity
|
|
60
|
+
|
|
61
|
+
fade_in = self.sound.fade_in_samples
|
|
62
|
+
if self.frame_count < fade_in and not self._stopping:
|
|
63
|
+
_fade(wave, self.frame_count / fade_in, len(out) / fade_in)
|
|
64
|
+
|
|
65
|
+
elif self._stopping:
|
|
66
|
+
if self._fade_frame is None:
|
|
67
|
+
# Account for the case when we fade out before we've faded in
|
|
68
|
+
offset = max(0.0, fade_in - self.frame_count)
|
|
69
|
+
self._fade_frame = self.frame_count - offset
|
|
70
|
+
|
|
71
|
+
fade_out = self.sound.fade_out_samples
|
|
72
|
+
elapsed = self.frame_count - self._fade_frame
|
|
73
|
+
if (start := 1 - elapsed / fade_out) <= 0:
|
|
74
|
+
super().stop()
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
_fade(wave, start, -len(out) / fade_out)
|
|
78
|
+
|
|
79
|
+
wave = wave.reshape((len(wave), 1))
|
|
80
|
+
out[:] = np.asarray(wave, dtype=out.dtype)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dc.dataclass(frozen=True)
|
|
85
|
+
class OscillatorController:
|
|
86
|
+
config: DeviceConfig = dc.field(default_factory=DeviceConfig)
|
|
87
|
+
oscillator: osc.Oscillator = OSC
|
|
88
|
+
players: dict[int, OscillatorPlayer] = dc.field(default_factory=dict)
|
|
89
|
+
scale: Scale = TWELVE_TET
|
|
90
|
+
start_note_name: str = 'C3'
|
|
91
|
+
|
|
92
|
+
def note(self, note_number: NoteNumber, is_press: bool) -> bool:
|
|
93
|
+
return self.start(note_number) if is_press else self.stop(note_number)
|
|
94
|
+
|
|
95
|
+
def start(self, note_number: NoteNumber) -> bool:
|
|
96
|
+
if note_number in self.players:
|
|
97
|
+
return False
|
|
98
|
+
frequency = self.scale.tuning(note_number + self.start_note_number)
|
|
99
|
+
period = (self.config.samplerate or 48_000) / frequency
|
|
100
|
+
op = OscillatorPlayer(
|
|
101
|
+
config=self.config, oscillator=self.oscillator, sound=Sound(period)
|
|
102
|
+
)
|
|
103
|
+
Thread(target=op.run).start()
|
|
104
|
+
self.players[note_number] = op
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def stop(self, note_number: NoteNumber) -> bool:
|
|
108
|
+
if (op := self.players.pop(note_number, None)) is not None:
|
|
109
|
+
op.stop()
|
|
110
|
+
return bool(op)
|
|
111
|
+
|
|
112
|
+
def stop_all(self) -> None:
|
|
113
|
+
for player in self.players.values():
|
|
114
|
+
player.stop()
|
|
115
|
+
self.players.clear()
|
|
116
|
+
|
|
117
|
+
@cached_property
|
|
118
|
+
def start_note_number(self) -> int:
|
|
119
|
+
return self.scale.name_to_number(self.start_note_name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _clamp(x: Number) -> Number:
|
|
123
|
+
return max(0.0, min(1.0, x))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fade(wave: Data, start: Number, length: Number) -> None:
|
|
127
|
+
wave *= np.linspace(_clamp(start), _clamp(start + length), len(wave))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_many_notes():
|
|
131
|
+
oc = OscillatorController()
|
|
132
|
+
DT = 0.2
|
|
133
|
+
|
|
134
|
+
stack = []
|
|
135
|
+
o1 = 'C4', 'E4', 'D5', 'Eb3', 'G3', 'C3', 'E3', 'D4', 'Eb2', 'G2'
|
|
136
|
+
o2 = 'C2', 'E2', 'D3', 'Eb1', 'G1', 'C1', 'E1', 'D2', 'Eb0', 'G0'
|
|
137
|
+
|
|
138
|
+
for name in (o1 + o2)[0]:
|
|
139
|
+
stack.append(note := TWELVE_TET.name_to_number(name))
|
|
140
|
+
if not oc.start(note):
|
|
141
|
+
print('oops', name)
|
|
142
|
+
time.sleep(DT)
|
|
143
|
+
|
|
144
|
+
while stack:
|
|
145
|
+
if not oc.stop(note := stack.pop()):
|
|
146
|
+
print('oops off', note)
|
|
147
|
+
time.sleep(DT / 2)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == '__main__':
|
|
151
|
+
run_many_notes()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
__all__ = ['KeyAction', 'KeyboardListener', 'KeyboardQueue']
|
|
4
|
+
|
|
5
|
+
from .key_types import KeyAction
|
|
6
|
+
from .listener import KeyboardListener
|
|
7
|
+
from .queue import KeyboardQueue
|
|
8
|
+
|
|
9
|
+
if __name__ == '__main__':
|
|
10
|
+
if True:
|
|
11
|
+
KeyboardQueue(print).start()
|
|
12
|
+
else:
|
|
13
|
+
KeyboardListener(print).start()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
|
|
7
|
+
from pynput import keyboard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dc.dataclass(frozen=True)
|
|
11
|
+
class KeyAction:
|
|
12
|
+
char: str = ''
|
|
13
|
+
is_press: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Key: TypeAlias = keyboard.Key | keyboard.KeyCode
|
|
17
|
+
Callback: TypeAlias = Callable[[KeyAction], None]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
from functools import cached_property, wraps
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pynput import keyboard
|
|
8
|
+
|
|
9
|
+
from .key_types import Callback, Key, KeyAction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dc.dataclass
|
|
13
|
+
class Modifiers:
|
|
14
|
+
alt: int = 0
|
|
15
|
+
cmd: int = 0
|
|
16
|
+
ctrl: int = 0
|
|
17
|
+
shift: int = 0
|
|
18
|
+
|
|
19
|
+
def apply(self, key: keyboard.Key, is_press: bool) -> None:
|
|
20
|
+
name = key.name.partition('_')[0]
|
|
21
|
+
if (value := vars(self).get(name)) is not None:
|
|
22
|
+
value = max(0, min(2, value + (1 if is_press else -1)))
|
|
23
|
+
setattr(self, name, value)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_printable(self) -> bool:
|
|
27
|
+
return not (self.alt or self.cmd or self.ctrl)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dc.dataclass
|
|
31
|
+
class KeyboardListener:
|
|
32
|
+
callback: Callback
|
|
33
|
+
stop_key: keyboard.Key | None = None
|
|
34
|
+
|
|
35
|
+
_running: bool = False
|
|
36
|
+
|
|
37
|
+
@cached_property
|
|
38
|
+
def listener(self) -> keyboard.Listener:
|
|
39
|
+
return _make_listener(self)
|
|
40
|
+
|
|
41
|
+
@cached_property
|
|
42
|
+
def modifiers(self) -> Modifiers:
|
|
43
|
+
return Modifiers()
|
|
44
|
+
|
|
45
|
+
def on_press(self, key: Key | None) -> bool | None:
|
|
46
|
+
if not self._running or key == self.stop_key:
|
|
47
|
+
return False
|
|
48
|
+
self._on(key, True)
|
|
49
|
+
|
|
50
|
+
def on_release(self, key: Key | None) -> None:
|
|
51
|
+
self._on(key, False)
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
self._running = True
|
|
55
|
+
self.listener.__enter__()
|
|
56
|
+
|
|
57
|
+
def join(self) -> None:
|
|
58
|
+
self.listener.join()
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
self._running = False
|
|
62
|
+
self.listener.stop()
|
|
63
|
+
|
|
64
|
+
def _on(self, key: Key | None, is_press: bool) -> None:
|
|
65
|
+
if isinstance(key, keyboard.Key):
|
|
66
|
+
self.modifiers.apply(key, is_press)
|
|
67
|
+
if self.modifiers.is_printable and (char := getattr(key, 'char', '')):
|
|
68
|
+
self.callback(KeyAction(char, is_press))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _make_listener(kl: KeyboardListener) -> keyboard.Listener:
|
|
72
|
+
listener = keyboard.Listener(
|
|
73
|
+
on_press=kl.on_press,
|
|
74
|
+
on_release=kl.on_release,
|
|
75
|
+
)
|
|
76
|
+
log = getattr(listener, '_log', None)
|
|
77
|
+
if not (log and hasattr(listener, 'IS_TRUSTED')):
|
|
78
|
+
return listener
|
|
79
|
+
|
|
80
|
+
# Work around a bogus warning in pynput and Darwin
|
|
81
|
+
BOGUS_WARNING = (
|
|
82
|
+
'This process is not trusted! Input event monitoring will not be possible'
|
|
83
|
+
' until it is added to accessibility clients.'
|
|
84
|
+
)
|
|
85
|
+
warning_ = log.warning
|
|
86
|
+
|
|
87
|
+
@wraps(warning_)
|
|
88
|
+
def warning(a: str, *args: Any, **kwargs: Any) -> None:
|
|
89
|
+
if not a.strip() or a.replace(BOGUS_WARNING, '').strip() or args or kwargs:
|
|
90
|
+
warning_(a, *args, **kwargs)
|
|
91
|
+
|
|
92
|
+
log.warning = warning
|
|
93
|
+
return listener
|
tuney/keyboard/queue.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from queue import Empty, Queue
|
|
10
|
+
|
|
11
|
+
from .key_types import Callback, KeyAction
|
|
12
|
+
from .listener import KeyboardListener
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dc.dataclass
|
|
16
|
+
class KeyboardQueue:
|
|
17
|
+
callback: Callback
|
|
18
|
+
timeout: float = 0.01
|
|
19
|
+
running: bool = False
|
|
20
|
+
|
|
21
|
+
def start(self) -> None:
|
|
22
|
+
self.running = True
|
|
23
|
+
threading.Thread(target=self._target).start()
|
|
24
|
+
self._listener.start()
|
|
25
|
+
|
|
26
|
+
def stop(self) -> None:
|
|
27
|
+
self.running = False
|
|
28
|
+
self._listener.stop()
|
|
29
|
+
|
|
30
|
+
def join(self) -> None:
|
|
31
|
+
# TODO: shouldn't I stop first?
|
|
32
|
+
try:
|
|
33
|
+
self._listener.join()
|
|
34
|
+
finally:
|
|
35
|
+
self.stop()
|
|
36
|
+
|
|
37
|
+
@cached_property
|
|
38
|
+
def _listener(self) -> KeyboardListener:
|
|
39
|
+
return KeyboardListener(self._queue.put)
|
|
40
|
+
|
|
41
|
+
def _target(self) -> None:
|
|
42
|
+
try:
|
|
43
|
+
while self.running:
|
|
44
|
+
try:
|
|
45
|
+
key_action = self._queue.get(timeout=self.timeout)
|
|
46
|
+
except Empty:
|
|
47
|
+
continue
|
|
48
|
+
if not key_action:
|
|
49
|
+
break
|
|
50
|
+
self.callback(key_action)
|
|
51
|
+
self.callback(KeyAction())
|
|
52
|
+
except Exception:
|
|
53
|
+
print('THREAD TERMINATED', file=sys.stderr)
|
|
54
|
+
traceback.print_exc()
|
|
55
|
+
|
|
56
|
+
@cached_property
|
|
57
|
+
def _queue(self) -> Queue[KeyAction]:
|
|
58
|
+
return Queue()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def time_keyboard() -> None:
|
|
62
|
+
def key_callback(k):
|
|
63
|
+
if k.is_press:
|
|
64
|
+
nonlocal now
|
|
65
|
+
old, now = now, time.time()
|
|
66
|
+
print(now - old)
|
|
67
|
+
|
|
68
|
+
now = time.time()
|
|
69
|
+
kq = KeyboardQueue(key_callback)
|
|
70
|
+
kq.start()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def report() -> None:
|
|
74
|
+
def key_callback(k):
|
|
75
|
+
if k.is_press:
|
|
76
|
+
print(k)
|
|
77
|
+
|
|
78
|
+
kq = KeyboardQueue(key_callback)
|
|
79
|
+
kq.start()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == '__main__':
|
|
83
|
+
report()
|
|
84
|
+
# time_keyboard()
|
tuney/mapper/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from string import ascii_letters, ascii_lowercase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dc.dataclass(frozen=True)
|
|
7
|
+
class LinearMapper:
|
|
8
|
+
alphabet: str | None = None
|
|
9
|
+
length: int = 0
|
|
10
|
+
case_sensitive: bool = True
|
|
11
|
+
invert: bool = False
|
|
12
|
+
offset: int = 0
|
|
13
|
+
|
|
14
|
+
def __call__(self, k: str) -> int | None:
|
|
15
|
+
return self.char_to_number.get(k if self.case_sensitive else k.lower())
|
|
16
|
+
|
|
17
|
+
@cached_property
|
|
18
|
+
def char_to_number(self) -> dict[str, int]:
|
|
19
|
+
if not (alphabet := self.alphabet):
|
|
20
|
+
alphabet = ascii_letters if self.case_sensitive else ascii_lowercase
|
|
21
|
+
|
|
22
|
+
def char_to_number(index: int, c: str) -> int:
|
|
23
|
+
if self.invert:
|
|
24
|
+
index = len(alphabet) - index - 1
|
|
25
|
+
if self.length:
|
|
26
|
+
index %= self.length
|
|
27
|
+
return index + self.offset
|
|
28
|
+
|
|
29
|
+
return {a: char_to_number(i, a) for i, a in enumerate(alphabet)}
|
tuney/scale/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .scale import Tuning
|
|
2
|
+
|
|
3
|
+
EPSILON = 1e-6
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def nearest_note(
|
|
7
|
+
tuning: Tuning, frequency: float, epsilon: float = EPSILON
|
|
8
|
+
) -> int | tuple[int, int]:
|
|
9
|
+
below = above = 0
|
|
10
|
+
while tuning(below) > frequency:
|
|
11
|
+
below = (2 * below) or -0x40
|
|
12
|
+
while tuning(above) < frequency:
|
|
13
|
+
above = (2 * above) or 0x40
|
|
14
|
+
while (above - below) > 1:
|
|
15
|
+
mid = (below + above) // 2
|
|
16
|
+
f = tuning(mid)
|
|
17
|
+
if abs(f - frequency) < epsilon:
|
|
18
|
+
return mid
|
|
19
|
+
elif f < frequency:
|
|
20
|
+
below = mid
|
|
21
|
+
else:
|
|
22
|
+
above = mid
|
|
23
|
+
return below, above
|
tuney/scale/scale.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
type Frequency = float # Must be non-negative
|
|
7
|
+
type NoteNumber = int # May be negative
|
|
8
|
+
type NumberToFrequency = Callable[[NoteNumber], Frequency]
|
|
9
|
+
type Tuning = NumberToFrequency
|
|
10
|
+
|
|
11
|
+
type NameToNumber = Callable[[str], NoteNumber]
|
|
12
|
+
type NumberToName = Callable[[NoteNumber, ...], str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scale(NamedTuple):
|
|
16
|
+
tuning: Tuning
|
|
17
|
+
name_to_number: NameToNumber
|
|
18
|
+
number_to_name: NumberToName
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class XNote(NamedTuple): # The future of Note
|
|
22
|
+
scale: Scale
|
|
23
|
+
number: NoteNumber
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def make(scale: Scale, n: NoteNumber | str) -> XNote:
|
|
28
|
+
if isinstance(n, str):
|
|
29
|
+
name, number = n, scale.name_to_number(n)
|
|
30
|
+
else:
|
|
31
|
+
name, number = scale.number_to_name(n), n # ty: ignore[missing-argument]
|
|
32
|
+
return XNote(scale, number, name)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .scale import NoteNumber, Scale
|
|
6
|
+
|
|
7
|
+
# Standard: 60 = C3, C-1 == 0 Yamaha: 60 = C4, C0 == 0
|
|
8
|
+
A440 = 69
|
|
9
|
+
|
|
10
|
+
MIDI_ZERO_OCTAVE = -1
|
|
11
|
+
ACCIDENTAL_DICT = {'#': '♯', 'b': '♭', '♭': '♭', '♯': '♯'}
|
|
12
|
+
ACCIDENTALS = '#b♭♯'
|
|
13
|
+
FLAT, SHARP = '♭', '♯'
|
|
14
|
+
CANONICALS = FLAT + SHARP
|
|
15
|
+
NAME_TO_NUMBER = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11}
|
|
16
|
+
NAMES = ''.join(NAME_TO_NUMBER)
|
|
17
|
+
NUMBER_TO_NAME = {
|
|
18
|
+
FLAT: ('C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B'),
|
|
19
|
+
SHARP: ('C', 'C♯', 'D', 'D♯', 'E', 'F', 'F♯', 'G', 'G♯', 'A', 'A♯', 'B'),
|
|
20
|
+
}
|
|
21
|
+
NOTE_RE = re.compile(rf'([{NAMES}])([{ACCIDENTALS}]*)(-?\d*)')
|
|
22
|
+
|
|
23
|
+
assert ACCIDENTALS == ''.join(sorted(ACCIDENTAL_DICT))
|
|
24
|
+
assert CANONICALS == ACCIDENTALS[2:]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tuning(note_number: NoteNumber) -> float:
|
|
28
|
+
return 440.0 * 2 ** ((note_number - A440) / 12)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def name_to_number(note_name: str) -> int:
|
|
32
|
+
if not (m := NOTE_RE.match(note_name)):
|
|
33
|
+
raise ValueError(f'Cannot understand note {note_name}')
|
|
34
|
+
|
|
35
|
+
name, accidentals, octave = m.groups()
|
|
36
|
+
semitones = sum(2 * (ACCIDENTAL_DICT[a] == SHARP) - 1 for a in accidentals)
|
|
37
|
+
octaves = int(octave) - MIDI_ZERO_OCTAVE
|
|
38
|
+
return 12 * octaves + NAME_TO_NUMBER[name] + semitones
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def number_to_name(number: NoteNumber, use_sharp: bool = True) -> str:
|
|
42
|
+
accidental = SHARP if use_sharp else FLAT
|
|
43
|
+
assert accidental in NUMBER_TO_NAME, accidental
|
|
44
|
+
octave, number1 = divmod(number, 12)
|
|
45
|
+
octave += MIDI_ZERO_OCTAVE
|
|
46
|
+
name = NUMBER_TO_NAME[accidental][number1]
|
|
47
|
+
return f'{name}{octave}'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TWELVE_TET = Scale(tuning, name_to_number, number_to_name)
|
tuney/time/__init__.py
ADDED
tuney/time/event.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
import heapq
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import Seconds
|
|
10
|
+
|
|
11
|
+
MAX_WAIT: Seconds = 0.01
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dc.dataclass
|
|
15
|
+
class Event[Data]:
|
|
16
|
+
timestamp: Seconds
|
|
17
|
+
data: Data
|
|
18
|
+
|
|
19
|
+
def __lt__(self, other: Event) -> bool:
|
|
20
|
+
return self.timestamp < other.timestamp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dc.dataclass
|
|
24
|
+
class Runner[Data]:
|
|
25
|
+
events: list[Event[Data]]
|
|
26
|
+
callback: Callable[..., Any]
|
|
27
|
+
|
|
28
|
+
_running: bool = False
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
heapq.heapify(self.events)
|
|
32
|
+
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
self._running = True
|
|
35
|
+
while self._running:
|
|
36
|
+
while self.events and self.events[0].timestamp <= time.time():
|
|
37
|
+
self.callback(d := heapq.heappop(self.events).data)
|
|
38
|
+
print('run', d)
|
|
39
|
+
|
|
40
|
+
if self.events:
|
|
41
|
+
time.sleep(min(MAX_WAIT, self.events[0].timestamp - time.time()))
|
|
42
|
+
else:
|
|
43
|
+
self.stop()
|
|
44
|
+
|
|
45
|
+
def stop(self) -> None:
|
|
46
|
+
self._running = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def demo() -> None:
|
|
50
|
+
def event() -> Event[float]:
|
|
51
|
+
import random
|
|
52
|
+
|
|
53
|
+
t = timestamp + random.uniform(0, 1)
|
|
54
|
+
return Event(t, t - timestamp)
|
|
55
|
+
|
|
56
|
+
timestamp = time.time()
|
|
57
|
+
Runner([event() for _ in range(10)], print).run()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == '__main__':
|
|
61
|
+
demo()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import Collection, Iterable, Iterator
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
from . import Milliseconds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CharBeginEnd(NamedTuple):
|
|
13
|
+
char: str
|
|
14
|
+
begin: Milliseconds
|
|
15
|
+
end: Milliseconds
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dc.dataclass(frozen=True)
|
|
19
|
+
class TextTimings:
|
|
20
|
+
space: Milliseconds = 100
|
|
21
|
+
period: Milliseconds = 300
|
|
22
|
+
comma: Milliseconds = 200
|
|
23
|
+
colon: Milliseconds = 400
|
|
24
|
+
semicolon: Milliseconds = 400
|
|
25
|
+
blank_line: Milliseconds = 1000
|
|
26
|
+
|
|
27
|
+
overlap: Milliseconds = 20
|
|
28
|
+
random_seed: int | None = None
|
|
29
|
+
alpha_only: bool = True
|
|
30
|
+
strip_accents: bool = True
|
|
31
|
+
|
|
32
|
+
other: dict[str, Milliseconds] = dc.field(default_factory=dict)
|
|
33
|
+
timings: Collection[Milliseconds] | None = None
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def timings_(self) -> Collection[Milliseconds]:
|
|
37
|
+
return self.timings or _TIMINGS
|
|
38
|
+
|
|
39
|
+
@cached_property
|
|
40
|
+
def average_time(self) -> float:
|
|
41
|
+
return sum(self.timings_) / len(self.timings_)
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def random(self):
|
|
45
|
+
return random.Random(self.random_seed)
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def char_to_time(self) -> dict[str, Milliseconds]:
|
|
49
|
+
return {v: getattr(self, k) for k, v in _CHARS.items()} | self.other
|
|
50
|
+
|
|
51
|
+
def __call__(self, text: str) -> Iterator[CharBeginEnd]:
|
|
52
|
+
time = 0
|
|
53
|
+
chars = _strip_accents(text) if self.strip_accents else text
|
|
54
|
+
for char in _filter_chars(chars):
|
|
55
|
+
dt = self.char_to_time.get(char)
|
|
56
|
+
if char.isalpha() or not (dt is None and self.alpha_only):
|
|
57
|
+
dt = (dt or 0.0) + self.random.choice(self.timings_)
|
|
58
|
+
yield CharBeginEnd(char, time, time + dt)
|
|
59
|
+
time += max(0, dt - self.overlap)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _filter_chars(it: Iterable[str]) -> Iterator[str]:
|
|
63
|
+
# Filter out the first `\n', so each \n means an actual new line,
|
|
64
|
+
# and any spaces after the first one.
|
|
65
|
+
previous = ''
|
|
66
|
+
for c in it:
|
|
67
|
+
if not c.isspace():
|
|
68
|
+
yield c
|
|
69
|
+
elif c not in ' \n':
|
|
70
|
+
continue
|
|
71
|
+
elif c == '\n' == previous:
|
|
72
|
+
yield c
|
|
73
|
+
elif c == ' ' and previous not in ' \n':
|
|
74
|
+
yield c
|
|
75
|
+
previous = c
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _strip_accents(s: str) -> Iterator[str]:
|
|
79
|
+
# https://stackoverflow.com/questions/517923/
|
|
80
|
+
from unicodedata import category, normalize
|
|
81
|
+
|
|
82
|
+
yield from (c for c in normalize('NFD', s) if category(c) != 'Mn')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_CHARS = {
|
|
86
|
+
'space': ' ',
|
|
87
|
+
'period': '.',
|
|
88
|
+
'comma': ',',
|
|
89
|
+
'colon': ':',
|
|
90
|
+
'semicolon': ';',
|
|
91
|
+
'blank_line': '\n',
|
|
92
|
+
}
|
|
93
|
+
_TIMINGS = (
|
|
94
|
+
(56.04, 57.92, 60.35, 61.94, 62.54, 63.29, 63.32, 64.27, 66.26, 66.92)
|
|
95
|
+
+ (67.98, 68.02, 68.44, 69.33, 69.61, 70.61, 72.72, 72.75, 72.76, 73.42)
|
|
96
|
+
+ (75.07, 77.92, 78.52, 80.12, 83.55, 84.34, 85.13, 85.36, 85.47, 85.77)
|
|
97
|
+
+ (85.97, 86.32, 86.7, 88.45, 89.24, 89.26, 89.44, 89.54, 90.82, 91.18)
|
|
98
|
+
+ (91.22, 92.69, 92.94, 93.32, 94.24, 94.59, 94.75, 95.83, 96.64, 97.79)
|
|
99
|
+
+ (98.76, 99.11, 100.01, 100.38, 101.67, 103.73, 104.81, 104.87, 105.41, 106.56)
|
|
100
|
+
+ (107.5, 107.68, 107.97, 110.95, 111.07, 113.24, 115.18, 116.24, 116.24, 117.64)
|
|
101
|
+
+ (120.67, 122.09, 124.4, 125.88, 127.58, 128.57, 129.16, 130.85, 131.73, 133.19)
|
|
102
|
+
+ (133.81, 134.46, 136.04, 138.25, 140.16, 140.21, 140.22, 140.32, 141.9, 142.83)
|
|
103
|
+
+ (143.1, 148.71, 149.47, 149.88, 150.53, 153.21, 153.44, 153.66, 153.95, 156.17)
|
|
104
|
+
+ (157.75, 157.76, 158.17, 159.87, 160.48, 160.87, 160.97, 163.56, 164.12, 167.26)
|
|
105
|
+
+ (167.36, 168.21, 168.55, 169.04, 169.85, 169.86, 170.61, 171.43, 171.46, 172.9)
|
|
106
|
+
+ (173.78, 174.22, 174.76, 175.52, 176.18, 176.38, 176.49, 176.83, 178.75, 179.03)
|
|
107
|
+
+ (179.41, 180.81, 181.69, 182.37, 184.2, 184.48, 185.46, 185.9, 185.96, 187.05)
|
|
108
|
+
+ (188.67, 189.03, 190.0, 190.71, 192.68, 192.74, 192.88, 193.29, 194.49, 197.11)
|
|
109
|
+
+ (197.81, 199.08, 200.55, 200.66, 200.7, 201.65, 202.79, 203.32, 205.1, 205.7)
|
|
110
|
+
+ (206.18, 209.24, 210.69, 213.53, 214.09, 214.13, 222.19, 222.56, 222.58, 223.53)
|
|
111
|
+
+ (224.91, 227.26, 230.08, 234.93, 236.72, 246.01, 259.32, 260.27, 261.92, 266.67)
|
|
112
|
+
+ (269.42, 279.88, 287.71, 295.99, 299.94, 317.22, 329.73, 330.68, 357.17, 367.83)
|
|
113
|
+
+ (419.5, 422.63, 475.46, 521.2, 526.85, 594.64, 738.79)
|
|
114
|
+
)
|
tuney/ui/__init__.py
ADDED
|
File without changes
|