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
tuney/__init__.py
ADDED
|
File without changes
|
tuney/__main__.py
ADDED
tuney/audio/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
from typing import Generic, TypeVar, get_args
|
|
3
|
+
|
|
4
|
+
import sounddevice as sd
|
|
5
|
+
|
|
6
|
+
_T = TypeVar('_T', bound=sd._StreamBase)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dc.dataclass
|
|
10
|
+
class DeviceConfig:
|
|
11
|
+
samplerate: int | None = None
|
|
12
|
+
blocksize: int | None = None
|
|
13
|
+
device: int | str | None = None
|
|
14
|
+
channels: int | None = None
|
|
15
|
+
dtype: type | None = None
|
|
16
|
+
latency: int | None = None
|
|
17
|
+
extra_settings: str | None = None
|
|
18
|
+
clip_off: bool | None = None
|
|
19
|
+
dither_off: bool | None = None
|
|
20
|
+
never_drop_input: bool | None = None
|
|
21
|
+
prime_output_buffers_using_stream_callback: bool | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeviceMaker(Generic[_T]):
|
|
25
|
+
@classmethod
|
|
26
|
+
def type(cls) -> type[_T]:
|
|
27
|
+
bases = getattr(cls, '__orig_bases__', None)
|
|
28
|
+
assert bases is not None
|
|
29
|
+
return get_args(bases[0])[0]
|
|
30
|
+
|
|
31
|
+
def __call__(self, config: DeviceConfig) -> _T:
|
|
32
|
+
return self.type()(**dc.asdict(config))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutputStreamMaker(DeviceMaker[sd.OutputStream]):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == '__main__':
|
|
40
|
+
print(OutputStreamMaker.type())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
|
|
5
|
+
from .device_config import DeviceConfig
|
|
6
|
+
from .player import Player
|
|
7
|
+
from .sample_data import Data, SampleData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilePlayer(Player):
|
|
11
|
+
def __init__(self, filename: str, device: str | int = 0) -> None:
|
|
12
|
+
self.filename = filename
|
|
13
|
+
self.device = device
|
|
14
|
+
|
|
15
|
+
@cached_property
|
|
16
|
+
def _data(self) -> SampleData:
|
|
17
|
+
return SampleData.make(self.filename).cut_to(1.5)
|
|
18
|
+
|
|
19
|
+
@cached_property
|
|
20
|
+
def config(self) -> DeviceConfig:
|
|
21
|
+
return self._data.config(self.device)
|
|
22
|
+
|
|
23
|
+
def _fill(self, out: Data) -> bool:
|
|
24
|
+
chunk = self._data.data[self.frame_count : self.frame_count + self.frame_size]
|
|
25
|
+
out[: len(chunk)] = chunk
|
|
26
|
+
success = len(chunk) == self.frame_size
|
|
27
|
+
if not success:
|
|
28
|
+
out[len(chunk) : self.frame_size] = 0
|
|
29
|
+
return success
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == '__main__':
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
for a in sys.argv[1:]:
|
|
36
|
+
print('open', a)
|
|
37
|
+
FilePlayer(a).run()
|
tuney/audio/midi.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
|
|
3
|
+
import mido
|
|
4
|
+
|
|
5
|
+
ZERO_IS_NOTE_OFF = True
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dc.dataclass(frozen=True)
|
|
9
|
+
class NoteMaker:
|
|
10
|
+
channel: int = 0
|
|
11
|
+
velocity: int = 0x40
|
|
12
|
+
note_offset: int = 0
|
|
13
|
+
|
|
14
|
+
def message(self, note_number: int, is_press: bool) -> mido.Message:
|
|
15
|
+
return mido.Message(
|
|
16
|
+
channel=self.channel,
|
|
17
|
+
note=(note_number + self.note_offset) % 128,
|
|
18
|
+
type='note_on' if is_press or ZERO_IS_NOTE_OFF else 'note_off',
|
|
19
|
+
velocity=max(0, min(127, is_press * self.velocity)),
|
|
20
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, TypeAlias, override
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from . import Data, Number
|
|
10
|
+
|
|
11
|
+
Function: TypeAlias = Callable[..., Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Oscillator:
|
|
15
|
+
period: Number = 2 * np.pi
|
|
16
|
+
# TODO: add intensity to compensate for different energies
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def function(self, x: Data, out: Data) -> Data: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Sine(Oscillator):
|
|
23
|
+
@override
|
|
24
|
+
def function(self, x: Data, out: Data) -> Data:
|
|
25
|
+
return np.sin(x, out=out)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Triangle(Oscillator):
|
|
29
|
+
width: Number = 0.5
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def function(self, x: Data, out: Data) -> Data:
|
|
33
|
+
from .scipy import sawtooth
|
|
34
|
+
|
|
35
|
+
out[:] = sawtooth(x, self.width)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OldSawtooth(Oscillator):
|
|
40
|
+
@override
|
|
41
|
+
def function(self, x: Data, out: Data) -> Data:
|
|
42
|
+
return np.add(np.mod(x, 2.0, out=out), -1.0, out=out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Sawtooth(Triangle):
|
|
46
|
+
width: Number = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
sawtooth, sine, triangle = Sawtooth(), Sine(), Triangle()
|
tuney/audio/player.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from threading import Event
|
|
7
|
+
from typing import override
|
|
8
|
+
|
|
9
|
+
from sounddevice import CallbackStop, OutputStream
|
|
10
|
+
|
|
11
|
+
from . import Data
|
|
12
|
+
from .device_config import DeviceConfig
|
|
13
|
+
from .runnable import Runnable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dc.dataclass
|
|
17
|
+
class Player(Runnable, ABC):
|
|
18
|
+
config: DeviceConfig = dc.field(default_factory=DeviceConfig)
|
|
19
|
+
|
|
20
|
+
chunk_count: int = 0
|
|
21
|
+
frame_size: int = 0
|
|
22
|
+
|
|
23
|
+
_event: Event = dc.field(default_factory=Event)
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def _fill(self, out: Data) -> bool:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def fill(self, out: Data, frame_size: int) -> bool:
|
|
30
|
+
if self.frame_size and frame_size != self.frame_size:
|
|
31
|
+
# Hope this never happens
|
|
32
|
+
print('framesize change', self.frame_size, frame_size)
|
|
33
|
+
self.frame_size = frame_size
|
|
34
|
+
success = self._fill(out)
|
|
35
|
+
self.chunk_count += 1
|
|
36
|
+
return success
|
|
37
|
+
|
|
38
|
+
def callback(self, out: Data, frame_size: int, time: float, status: str) -> None:
|
|
39
|
+
if status:
|
|
40
|
+
print('Playback', status) # TODO:
|
|
41
|
+
|
|
42
|
+
if not self.fill(out, frame_size) or not self.is_running:
|
|
43
|
+
self.stop()
|
|
44
|
+
raise CallbackStop
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def _run(self):
|
|
48
|
+
with self.stream:
|
|
49
|
+
self._event.wait()
|
|
50
|
+
|
|
51
|
+
@cached_property
|
|
52
|
+
def stream(self) -> OutputStream:
|
|
53
|
+
callbacks = {'callback': self.callback, 'finished_callback': self._event.set}
|
|
54
|
+
return OutputStream(**dc.asdict(self.config), **callbacks)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def frame_count(self) -> int:
|
|
58
|
+
return self.frame_size * self.chunk_count
|
tuney/audio/runnable.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Runnable(ABC):
|
|
5
|
+
def run(self) -> None:
|
|
6
|
+
self._running = True
|
|
7
|
+
try:
|
|
8
|
+
self._run()
|
|
9
|
+
finally:
|
|
10
|
+
self.stop()
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def is_running(self) -> bool:
|
|
14
|
+
return self._running
|
|
15
|
+
|
|
16
|
+
def stop(self) -> None:
|
|
17
|
+
self._running = False
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def _run(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
_running: bool = False
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
6
|
+
import soundfile
|
|
7
|
+
|
|
8
|
+
from . import Data
|
|
9
|
+
from .device_config import DeviceConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dc.dataclass
|
|
13
|
+
class SampleData:
|
|
14
|
+
data: Data
|
|
15
|
+
samplerate: int
|
|
16
|
+
|
|
17
|
+
def config(self, device: int | str | None) -> DeviceConfig:
|
|
18
|
+
return DeviceConfig(
|
|
19
|
+
channels=self.channels, device=device, samplerate=self.samplerate
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def make(filename: str) -> SampleData:
|
|
24
|
+
return SampleData(*soundfile.read(filename, always_2d=True))
|
|
25
|
+
|
|
26
|
+
def cut_to(self, time: float) -> SampleData:
|
|
27
|
+
count = round(time * self.samplerate)
|
|
28
|
+
to_cut = len(self.data) - count
|
|
29
|
+
if to_cut <= 0:
|
|
30
|
+
return self
|
|
31
|
+
half = to_cut // 2
|
|
32
|
+
return SampleData(self.data[half : count + half], self.samplerate)
|
|
33
|
+
|
|
34
|
+
@cached_property
|
|
35
|
+
def channels(self) -> int:
|
|
36
|
+
return self.data.shape[1]
|