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 ADDED
File without changes
tuney/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ from .ui.keyboard_controller import KeyboardController
2
+
3
+
4
+ def main() -> None:
5
+ with KeyboardController():
6
+ pass
7
+
8
+
9
+ if __name__ == '__main__':
10
+ main()
@@ -0,0 +1,8 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, TypeAlias
3
+
4
+ import numpy as np
5
+
6
+ Data: TypeAlias = np.ndarray
7
+ Function: TypeAlias = Callable[..., Any]
8
+ Number: TypeAlias = int | float | np.floating | np.integer
@@ -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
@@ -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]