diff --git a/sardine_core/handlers/midi.py b/sardine_core/handlers/midi.py index 1f8e9233..a6516d3c 100644 --- a/sardine_core/handlers/midi.py +++ b/sardine_core/handlers/midi.py @@ -1,5 +1,11 @@ -from .sender import Number, NumericElement, Sender, ParsableElement -from typing import Optional, Union +from .sender import ( + Number, + NumericElement, + Sender, + ParsableElement, + _resolve_if_callable, +) +from typing import Optional, Callable from ..utils import alias_param from ..logger import print import asyncio @@ -173,7 +179,7 @@ def _pitch_wheel(self, pitch: int, channel: int) -> None: self._midi.send(mido.Message("pitchweel", pitch=pitch, channel=channel)) async def send_off( - self, note: int, channel: int, velocity: int, delay: Union[int, float] + self, note: int, channel: int, velocity: int, delay: int | float ): await self.env.sleep_beats(delay) self._midi.send( @@ -243,12 +249,12 @@ async def send_midi_note( @alias_param(name="rate", alias="r") def send_control( self, - control: Optional[NumericElement] = 0, - channel: NumericElement = 0, - value: NumericElement = 60, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, + control: Optional[NumericElement] | Callable[[], NumericElement] = 0, + channel: NumericElement | Callable[[], NumericElement] = 0, + value: NumericElement | Callable[[], NumericElement] = 60, + iterator: Number | Callable[[], NumericElement] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, **rest_of_pattern: ParsableElement, ) -> None: """ @@ -264,10 +270,24 @@ def send_control( ): return - pattern = {"control": control, "channel": channel, "value": value} + pattern = { + "control": _resolve_if_callable(control), + "channel": _resolve_if_callable(channel), + "value": _resolve_if_callable(value), + } + + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + pattern = {**self._defaults, **pattern} deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if None in [message["control"], message["value"]]: continue for k, v in message.items(): @@ -282,10 +302,10 @@ def send_control( def send_program( self, channel: Optional[NumericElement], - program: NumericElement = 60, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, + program: NumericElement | Callable[[], NumericElement] = 60, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, **rest_of_pattern: ParsableElement, ) -> None: if channel is None: @@ -296,10 +316,23 @@ def send_program( ): return - pattern = {"channel": channel, "program": program} + pattern = { + "channel": _resolve_if_callable(channel), + "program": _resolve_if_callable(program), + } + + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + pattern = {**self._defaults, **pattern} deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["channel"] is None: continue for k, v in message.items(): @@ -313,12 +346,12 @@ def send_program( @alias_param(name="rate", alias="r") def send_sysex( self, - data: list[int], - value: NumericElement = 60, - optional_modulo: NumericElement = 127, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, + data: list[int] | Callable[[], list[int]], + value: NumericElement | Callable[[], NumericElement] = 60, + optional_modulo: NumericElement | Callable[[], NumericElement] = 127, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, **rest_of_pattern: ParsableElement, ) -> None: if data is None: @@ -329,9 +362,17 @@ def send_sysex( ): return - pattern = {"value": value} + pattern = {"value": _resolve_if_callable(value)} + + # NOTE: No need to resolve any more callables for such a simple message... + deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["value"] is None: continue for k, v in message.items(): @@ -350,15 +391,15 @@ def send_sysex( @alias_param(name="rate", alias="r") def send_ziffers( self, - ziff: str, - velocity: NumericElement = 100, - channel: NumericElement = 0, - duration: NumericElement = 1, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - scale: str = "IONIAN", - key: str = "C4", + ziff: str | Callable[[], str], + velocity: NumericElement | Callable[[], NumericElement] = 100, + channel: NumericElement | Callable[[], NumericElement] = 0, + duration: NumericElement | Callable[[], NumericElement] = 1, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + scale: str | Callable[[], str] = "IONIAN", + key: str | Callable[[], str] = "C4", **rest_of_pattern: ParsableElement, ) -> int | float: """ @@ -388,15 +429,25 @@ def send_ziffers( note = f"{{{' '.join([str(x) for x in note])}}}" pattern = { - "note": note, - "velocity": velocity, - "channel": channel, - "duration": duration, + "note": _resolve_if_callable(note), + "velocity": _resolve_if_callable(velocity), + "channel": _resolve_if_callable(channel), + "duration": _resolve_if_callable(duration), } + + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + pattern = {**self._defaults, **pattern} deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["note"] is None: continue for k in ("note", "velocity", "channel"): @@ -415,14 +466,14 @@ def send_ziffers( def send_instrument( self, note: Optional[NumericElement] = 60, - velocity: NumericElement = 100, - channel: NumericElement = 0, - duration: NumericElement = 1, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - map: dict = {}, - program_change: Optional[Number] = None, + velocity: NumericElement | Callable[[], NumericElement] = 100, + channel: NumericElement | Callable[[], NumericElement] = 0, + duration: NumericElement | Callable[[], NumericElement] = 1, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + map: dict | Callable[[], dict] = {}, + program_change: Optional[Number] | Callable[[], Number] = None, **rest_of_pattern: ParsableElement, ) -> None: """ @@ -446,15 +497,27 @@ def send_instrument( def note_pattern(): pattern = { - "note": note, - "velocity": velocity, - "channel": channel, - "duration": duration, - "program_change": (program_change if program_change else None), + "note": _resolve_if_callable(note), + "velocity": _resolve_if_callable(velocity), + "channel": _resolve_if_callable(channel), + "duration": _resolve_if_callable(duration), + "program_change": ( + _resolve_if_callable(program_change) if program_change else None + ), } + + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + pattern = {**self._defaults, **pattern} deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["program_change"] is not None: self._send_control( program=message["program_change"], channel=message["channel"] @@ -468,7 +531,12 @@ def note_pattern(): def send_controls(pattern: dict) -> None: deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if None in [message["control"], message["value"]]: continue for k, v in message.items(): @@ -486,11 +554,11 @@ def send_controls(pattern: dict) -> None: @alias_param(name="rate", alias="r") def send_controller( self, - channel: NumericElement = 0, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - map: dict = {}, + channel: NumericElement | Callable[[], NumericElement] = 0, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + map: dict | Callable[[], dict] = {}, **rest_of_pattern: ParsableElement, ) -> None: """ @@ -506,12 +574,17 @@ def send_controller( for key, value in map.items(): if key in rest_of_pattern.keys(): control = value - control["value"] = rest_of_pattern[key] + control["value"] = _resolve_if_callable(rest_of_pattern[key]) control_messages.append(control) def send_controls(pattern: dict) -> None: deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if None in [message["control"], message["value"]]: continue for k, v in message.items(): @@ -531,14 +604,14 @@ def send_controls(pattern: dict) -> None: @alias_param(name="program_change", alias="pgch") def send( self, - note: Optional[NumericElement] = 60, - velocity: NumericElement = 100, - channel: NumericElement = 0, - duration: NumericElement = 1, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - program_change: Optional[Number] = None, + note: Optional[NumericElement] | Callable[[], Optional[NumericElement]] = 60, + velocity: NumericElement | Callable[[], NumericElement] = 100, + channel: NumericElement | Callable[[], NumericElement] = 0, + duration: NumericElement | Callable[[], NumericElement] = 1, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], Number] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + program_change: Optional[Number] | Callable[[], Number] = None, **rest_of_pattern: ParsableElement, ) -> None: """ @@ -556,15 +629,25 @@ def send( return pattern = { - "note": note, - "velocity": velocity, - "channel": channel, - "duration": duration, - "program_change": program_change, + "note": _resolve_if_callable(note), + "velocity": _resolve_if_callable(velocity), + "channel": _resolve_if_callable(channel), + "duration": _resolve_if_callable(duration), + "program_change": _resolve_if_callable(program_change), } + + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + pattern = {**self._defaults, **pattern} deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["program_change"] is not None: self.send_program( program=message["program_change"], channel=message["channel"] diff --git a/sardine_core/handlers/osc.py b/sardine_core/handlers/osc.py index d1c65d5e..6ec333cd 100644 --- a/sardine_core/handlers/osc.py +++ b/sardine_core/handlers/osc.py @@ -1,6 +1,6 @@ import time from itertools import chain -from typing import Optional +from typing import Optional, Callable from osc4py3 import oscbuildparse from osc4py3.as_eventloop import * @@ -8,7 +8,7 @@ from ..utils import alias_param from .osc_loop import OSCLoop -from .sender import Number, NumericElement, Sender, StringElement +from .sender import Number, NumericElement, Sender, StringElement, _resolve_if_callable __all__ = ("OSCHandler",) @@ -96,11 +96,11 @@ def _make_bundle(self, messages: list) -> oscbuildparse.OSCBundle: @alias_param(name="sorted", alias="s") def send( self, - address: Optional[StringElement], - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - sort: bool = True, + address: Optional[StringElement] | Callable[[], StringElement], + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + sort: bool | Callable[[], bool] = True, **pattern: NumericElement, ) -> None: if address is None: @@ -111,9 +111,19 @@ def send( ): return - pattern["address"] = address + # Evaluate all potential callables + for key, value in rest_of_pattern.items(): + pattern[key] = _resolve_if_callable(value) + + pattern["address"] = _resolve_if_callable(address) + deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["address"] is None: continue address = message.pop("address") diff --git a/sardine_core/handlers/player.py b/sardine_core/handlers/player.py index 39670b02..5a46079e 100644 --- a/sardine_core/handlers/player.py +++ b/sardine_core/handlers/player.py @@ -1,6 +1,6 @@ from typing import Any, Callable, Optional, ParamSpec, TypeVar, Self from ..handlers.sender import Number, NumericElement, Sender -from ..utils import alias_param, get_quant_deadline, lerp +from ..utils import Quant, alias_param, get_deadline_from_quant, lerp, Span from ..scheduler import AsyncRunner from dataclasses import dataclass from ..base import BaseHandler @@ -35,15 +35,13 @@ class PatternInformation: args: tuple[Any] kwargs: dict[str, Any] period: NumericElement - sync: Optional[ - Any - ] # NOTE: Actually Optional[Player] but I don't know how to type it + sync: Optional[AsyncRunner] iterator: Optional[Number] - iterator_span: NumericElement - iterator_limit: NumericElement + iterator_step: NumericElement + iterator_limit: Span divisor: NumericElement rate: NumericElement - quant: Number + quant: Quant timespan: Optional[float] until: Optional[int] @@ -61,9 +59,6 @@ def __init__(self, name: str): super().__init__() self._name = name self.runner = AsyncRunner(name=name) - self.iterator: Number = 0 - self._iteration_span: Number = 1 - self._iteration_limit: Optional[Number] = None self._period: int | float = 1.0 @property @@ -86,7 +81,7 @@ def fit_period_to_timespan(self, period: NumericElement, timespan: float): @staticmethod @alias_param(name="period", alias="p") - @alias_param(name="iterator_span", alias="i") + @alias_param(name="iterator_step", alias="i") @alias_param(name="iterator_limit", alias="l") @alias_param(name="divisor", alias="d") @alias_param(name="rate", alias="r") @@ -98,13 +93,13 @@ def _play_factory( timespan: Optional[float] = None, until: Optional[int] = None, period: NumericElement = 1, - sync: Optional[Self] = None, + sync: Optional[AsyncRunner] = None, iterator: Optional[Number] = None, - iterator_span: Optional[Number] = 1, - iterator_limit: Optional[Number] = None, + iterator_step: Optional[Number] = 1, + iterator_limit: Span = "inf", divisor: NumericElement = 1, rate: NumericElement = 1, - quant: Number = 0, + quant: Quant = "bar", **kwargs: P.kwargs, ) -> PatternInformation: """Entry point of a pattern into the Player""" @@ -117,7 +112,7 @@ def _play_factory( period, sync, iterator, - iterator_span, + iterator_step, iterator_limit, divisor, rate, @@ -170,32 +165,25 @@ def func( p: NumericElement = 1, # pylint: disable=invalid-name,unused-argument ) -> None: """Central swimming function defined by the player""" - self._iterator_span = pattern.iterator_span + self._iterator_step = pattern.iterator_step self._iterator_limit = pattern.iterator_limit - if pattern.iterator is not None: - self.iterator = pattern.iterator - pattern.iterator = None + self.runner._iter_limit = pattern.iterator_limit + self.runner._iter_step = pattern.iterator_step + + if pattern.sync is None: + iterator = self.runner.iter + else: + iterator = pattern.sync.runner.iter dur = pattern.send_method( *pattern.args, **pattern.kwargs, - iterator=self.iterator, + iterator=iterator, divisor=pattern.divisor, rate=pattern.rate, ) - # Reset the iterator when it reaches a certain ceiling - if self._iterator_limit: - if self.iterator >= self._iterator_limit: - self.iterator = 0 - - # Moving the iterator up - self.iterator += self._iterator_span - - # If synced, we use the iterator from the other player - self.iterator = pattern.sync.iterator if pattern.sync else self.iterator - period = self.get_new_period(pattern) if not dur: self.again(pattern=pattern, p=period) @@ -224,15 +212,14 @@ def push(self, pattern: Optional[PatternInformation]): # the new pattern can be synchronized self.runner.interval_shift = 0.0 + func = for_(pattern.until)(self.func) if pattern.until else self.func + deadline = get_deadline_from_quant(self.env.clock, pattern.quant) period = self.get_new_period(pattern) - deadline = get_quant_deadline(self.env.clock, pattern.quant) - self.runner.push_deferred( - deadline, - for_(pattern.until)(self.func) if pattern.until else self.func, - pattern=pattern, - p=period, - ) + if deadline is None: + self.runner.push(func, pattern=pattern, p=period) + else: + self.runner.push_deferred(deadline, func, pattern=pattern, p=period) self.env.scheduler.start_runner(self.runner) self.runner.reload() diff --git a/sardine_core/handlers/sender.py b/sardine_core/handlers/sender.py index be797331..0e228bfe 100644 --- a/sardine_core/handlers/sender.py +++ b/sardine_core/handlers/sender.py @@ -1,7 +1,7 @@ import asyncio from math import floor from random import random -from typing import Callable, Generator, ParamSpec, TypeVar, Union, Optional +from typing import Callable, Generator, ParamSpec, TypeVar, Union, Optional, Any from ..base import BaseHandler from ..utils import maybe_coro from ..sequences import euclid @@ -33,6 +33,11 @@ def _maybe_index(val: RecursiveElement, i: int) -> RecursiveElement: return val[i % length] +def _resolve_if_callable(val: Callable[[], Any] | Any) -> Any: + """Evaluate a callable if it is one, otherwise return the value.""" + return val() if callable(val) else val + + def _maybe_length(val: RecursiveElement) -> int: if isinstance(val, list): return len(val) diff --git a/sardine_core/handlers/superdirt.py b/sardine_core/handlers/superdirt.py index 361835a1..3ad97f35 100644 --- a/sardine_core/handlers/superdirt.py +++ b/sardine_core/handlers/superdirt.py @@ -1,13 +1,20 @@ import time from itertools import chain -from typing import Optional, List +from typing import Optional, List, Union, Callable, Any from osc4py3 import oscbuildparse from osc4py3.as_eventloop import osc_send, osc_udp_client from ..utils import alias_param from .osc_loop import OSCLoop -from .sender import Number, NumericElement, ParsableElement, Sender, StringElement +from .sender import ( + Number, + NumericElement, + ParsableElement, + Sender, + StringElement, + _resolve_if_callable, +) __all__ = ("SuperDirtHandler",) @@ -99,11 +106,21 @@ def _send(self, address, message): self.__send(address=address, message=message) def _dirt_play(self, message: list): + # TODO: custom logic here? self._send_timed_message(address="/dirt/play", message=message) def _dirt_panic(self): self._dirt_play(message=["sound", "superpanic"]) + def _handle_sample_number(self, message: dict): + if ":" in message["sound"]: + orig_sp, orig_nb = message["sound"].split(":") + message["sound"] = orig_sp + ":" + str(int(orig_nb) + int(message["n"])) + else: + message["sound"] = message["sound"] + ":" + str(message["n"]) + del message["n"] + return message + def _parse_aliases(self, pattern: dict): """Parse aliases for certain keys in the pattern (lpf -> cutoff)""" @@ -119,7 +136,6 @@ def rename_keys(initial_dictionary: dict, aliases: dict) -> dict: "bpq": "resonance", "res": "resonance", "midi": "midinote", - "n": "midinote", "oct": "octave", "accel": "accelerate", "leg": "legato", @@ -140,11 +156,14 @@ def rename_keys(initial_dictionary: dict, aliases: dict) -> dict: @alias_param(name="rate", alias="r") def send( self, - sound: Optional[StringElement | List[StringElement]], - orbit: NumericElement = 0, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, + sound: Union[ + Optional[StringElement | List[StringElement]], + Callable[[], Optional[StringElement | List[StringElement]]], + ], + orbit: Union[NumericElement, Callable[[], NumericElement]] = 0, + iterator: Union[Number, Callable[[], Number]] = 0, + divisor: Union[NumericElement, Callable[[], NumericElement]] = 1, + rate: Union[NumericElement, Callable[[], NumericElement]] = 1, **pattern: ParsableElement, ): if sound is None: @@ -155,21 +174,32 @@ def send( ): return + # Evaluate all potential callables + for key, value in pattern.items(): + pattern[key] = _resolve_if_callable(value) + # Replace some shortcut parameters by their real name pattern = self._parse_aliases(pattern) pattern = {**self._defaults, **pattern} - pattern["sound"] = sound - pattern["orbit"] = orbit + pattern["sound"] = _resolve_if_callable(sound) + pattern["orbit"] = _resolve_if_callable(orbit) pattern["cps"] = round(self.env.clock.phase, 1) pattern["cycle"] = ( self.env.clock.bar * self.env.clock.beats_per_bar ) + self.env.clock.beat deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["sound"] is None: continue + if "n" in message and message["sound"] is not None: + message = self._handle_sample_number(message) serialized = list(chain(*sorted(message.items()))) self.call_timed(deadline, self._dirt_play, serialized) @@ -178,15 +208,18 @@ def send( @alias_param(name="rate", alias="r") def send_ziffers( self, - sound: Optional[StringElement | List[StringElement]], - ziff: str, - orbit: NumericElement = 0, - iterator: Number = 0, - divisor: NumericElement = 1, - rate: NumericElement = 1, - key: str = "C4", - scale: str = "IONIAN", - degrees: bool = False, + sound: ( + Optional[StringElement | List[StringElement]] + | Callable[[], Optional[StringElement | List[StringElement]]] + ), + ziff: str | Callable[[], str], + orbit: NumericElement | Callable[[], NumericElement] = 0, + iterator: Number | Callable[[], Number] = 0, + divisor: NumericElement | Callable[[], NumericElement] = 1, + rate: NumericElement | Callable[[], NumericElement] = 1, + key: str | Callable[[], str] = "C4", + scale: str | Callable[[], str] = "IONIAN", + degrees: bool | Callable[[], bool] = False, **pattern: ParsableElement, ) -> int | float: # Replace some shortcut parameters by their real name @@ -198,12 +231,19 @@ def send_ziffers( ): return + # Evaluate all potential callables + for key, value in pattern.items(): + pattern[key] = _resolve_if_callable(value) + if not self._ziffers_parser: raise Exception("The ziffers package is not imported!") else: - ziffer = self._ziffers_parser(ziff, scale=scale, key=key, degrees=degrees)[ - iterator - ] + ziffer = self._ziffers_parser( + _resolve_if_callable(ziff), + scale=_resolve_if_callable(scale), + key=_resolve_if_callable(key), + degrees=_resolve_if_callable(degrees), + )[_resolve_if_callable(iterator)] try: freq = ziffer.freq except AttributeError: # if there is no note, it must be a silence @@ -225,16 +265,20 @@ def send_ziffers( return if sound != "rest": - pattern["freq"] = freq - pattern["sound"] = sound - pattern["orbit"] = orbit + pattern["freq"] = _resolve_if_callable(freq) + pattern["sound"] = _resolve_if_callable(sound) + pattern["orbit"] = _resolve_if_callable(orbit) pattern["cps"] = round(self.env.clock.phase, 4) pattern["cycle"] = ( self.env.clock.bar * self.env.clock.beats_per_bar ) + self.env.clock.beat - deadline = self.env.clock.shifted_time - for message in self.pattern_reduce(pattern, iterator, divisor, rate): + for message in self.pattern_reduce( + pattern, + _resolve_if_callable(iterator), + _resolve_if_callable(divisor), + _resolve_if_callable(rate), + ): if message["sound"] is None: continue serialized = list(chain(*sorted(message.items()))) diff --git a/sardine_core/run.py b/sardine_core/run.py index e484bf25..dde4cadb 100644 --- a/sardine_core/run.py +++ b/sardine_core/run.py @@ -4,7 +4,7 @@ from itertools import product from pathlib import Path from string import ascii_lowercase, ascii_uppercase -from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union, Literal, overload +from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union, overload from . import * from .io.UserConfig import read_user_configuration, read_extension_configuration @@ -12,7 +12,7 @@ from .sequences import ListParser, ziffers_factory from .sequences.tidal_parser import * from .superdirt import SuperDirtProcess -from .utils import greeter_printer, get_quant_deadline, join, sardine_intro +from .utils import Quant, greeter_printer, get_deadline_from_quant, join, sardine_intro from ziffers import z ParamSpec = ParamSpec("PS") @@ -160,7 +160,7 @@ def swim( /, # NOTE: AsyncRunner doesn't support generic args/kwargs *args: ParamSpec.args, - quant: Optional[Union[float, int]] = 0, + quant: Quant = "bar", until: Optional[int] = None, **kwargs: ParamSpec.kwargs, ) -> AsyncRunner: ... @@ -169,18 +169,19 @@ def swim( @overload def swim( *args, - quant: Optional[Union[float, int]] = 0, + quant: Quant = "bar", until: Optional[int] = None, **kwargs, ) -> Callable[[Union[Callable, AsyncRunner]], AsyncRunner]: ... +# FIXME: quant docstring is outdated # pylint: disable=keyword-arg-before-vararg # signature is valid def swim( func: Optional[Union[Callable, AsyncRunner]] = None, /, *args, - quant: Optional[Union[float, int]] = 0, + quant: Quant = "bar", until: Optional[int] = None, background_job: bool = False, **kwargs, @@ -194,7 +195,7 @@ def swim( The function to be scheduled. If this is an AsyncRunner, the current state is simply updated with new arguments. *args: Positional arguments to be passed to `func.` - quant (Optional[Union[float, int]]): + quant (Quant): If set to a numeric value, the new function will be deferred until the next bar + `quant` beats arrives. If None, the function is immediately pushed and will @@ -236,11 +237,12 @@ def decorator(func: Union[Callable, AsyncRunner], /) -> AsyncRunner: again(runner) bowl.scheduler.start_runner(runner) return runner - elif quant is not None: - deadline = get_quant_deadline(bowl.clock, quant) - runner.push_deferred(deadline, func, *args, **kwargs) - else: + + deadline = get_deadline_from_quant(bowl.clock, quant) + if deadline is None: runner.push(func, *args, **kwargs) + else: + runner.push_deferred(deadline, func, *args, **kwargs) # Intentionally avoid interval correction so # the user doesn't accidentally nudge the runner @@ -366,10 +368,21 @@ def silence(*runners: AsyncRunner) -> None: def solo(*args): - """Soloing a single player out of all running players""" - for pat in bowl.scheduler.runners: - if pat.name not in args: - silence(pat) + """Soloing a single player out of all running players, excluding background job.""" + foreground_runner_names = { + runner.name for runner in bowl.scheduler.runners if not runner.background_job + } + args_names = {runner.name for runner in args} + names_to_silence = foreground_runner_names - args_names + for runner in bowl.scheduler.runners: + if runner.name in names_to_silence: + silence(runner) + + +def runners(): + """Return all currently active AsyncRunners""" + condition = lambda x: x.name if x.name != "tidal_loop" else "internal" + return list(map(condition, bowl.scheduler.runners)) def panic(*runners: AsyncRunner) -> None: @@ -384,7 +397,13 @@ def panic(*runners: AsyncRunner) -> None: D("superpanic") -def Pat(pattern: str, i: int = 0, div: int = 1, rate: int = 1) -> Any: +def Pat( + pattern: str, + i: int = 0, + div: int = 1, + rate: int = 1, + as_text: bool = False + ) -> Any: """ General purpose pattern interface. This function can be used to summon the global parser stored in the fish_bowl. It is generally used to pattern outside of the @@ -392,6 +411,9 @@ def Pat(pattern: str, i: int = 0, div: int = 1, rate: int = 1) -> Any: if you want to take the best of the patterning system without having to deal with all the built-in I/O. + The as_text argument allows the study of patterns in textual format. If as_text is + true, the pattern will print from index 0 up to i. + Args: pattern (str): A pattern to be parsed i (int, optional): Index for iterators. Defaults to 0. @@ -400,7 +422,14 @@ def Pat(pattern: str, i: int = 0, div: int = 1, rate: int = 1) -> Any: int: The ith element from the resulting pattern """ result = bowl.parser.parse(pattern) - return Sender.pattern_element(result, i, div, rate) + if print: + pattern = [] + for iterator in range(i): + pattern.append(Sender.pattern_element(result, iterator, div, rate)) + print(pattern) + return pattern + else: + return Sender.pattern_element(result, i, div, rate) class Delay: diff --git a/sardine_core/scheduler/async_runner.py b/sardine_core/scheduler/async_runner.py index 712c6510..e5f86d45 100644 --- a/sardine_core/scheduler/async_runner.py +++ b/sardine_core/scheduler/async_runner.py @@ -12,7 +12,7 @@ from ..base import BaseClock from ..clock import Time -from ..utils import MISSING, maybe_coro +from ..utils import MISSING, maybe_coro, Span from .constants import MaybeCoroFunc from .errors import * @@ -131,6 +131,12 @@ class AsyncRunner: _iter: int """Number of times this asyncrunner has been executed""" + _iter_step: int + """The iteration step to use for the next iteration""" + + _iter_limit: Span + """The maximum number of iterations allowed before going back to 0""" + _default_period: int | float """Default recursion period""" @@ -200,6 +206,7 @@ class AsyncRunner: _task: Optional[asyncio.Task] _reload_event: asyncio.Event _has_reverted: bool + _jump_start: bool _deferred_state_index: int @@ -217,7 +224,9 @@ def __init__(self, name: str): self.interval_shift = 0.0 self.snap = None self._iter = 0 - self._default_period = 0.25 + self._iter_step = 1 + self._iter_limit = "inf" + self._default_period = 1 self.background_job = False self._swimming = False @@ -225,6 +234,7 @@ def __init__(self, name: str): self._task = None self._reload_event = asyncio.Event() self._has_reverted = False + self._jump_start = False self._deferred_state_index = 0 @@ -576,9 +586,7 @@ async def _runner(self): raise exc if not self.background_job: - print( - f"[yellow][[red]{self.name}[/red] is swimming at {current_bar}/{current_beat}/{current_phase:.2f}][/yellow]" - ) + print(f"[yellow][[red]{self.name}[/red] is swimming][/yellow]") try: while self._is_ready_for_iteration(): @@ -592,9 +600,7 @@ async def _runner(self): self._revert_state() self.swim() finally: - print( - f"[yellow][Stopped [red]{self.name}[/red] at {current_bar}/{current_beat}/{current_phase:.2f}][/yellow]" - ) + print(f"[yellow][Stopped [red]{self.name}[/red]][/yellow]") def _prepare(self): self._last_expected_time = -math.inf @@ -659,14 +665,18 @@ async def _run_once(self): ) return self._skip_iteration() elif state is None: - # Nothing to do until the next deferred state arrives + # Nothing to do until the next deferred state arrives. + # + # However, we will need to jump-start the next iteration + # so it runs exactly on the deadline instead of unnecessarily + # sleeping a full period. deadline = self.deferred_states[0].deadline interrupted = await self._sleep_until(deadline) - return self._skip_iteration() + return self._jump_start_iteration() # NOTE: deadline will always be defined at this point if not self.background_job: - interrupted = await self._sleep_until(deadline) + interrupted = await self._sleep_unless_jump_started(deadline) if interrupted: return self._skip_iteration() @@ -678,7 +688,14 @@ async def _run_once(self): ) finally: self._last_expected_time = self._expected_time - self._iter += 1 + self._update_iter() + + def _update_iter(self): + """Updates the iteration number""" + self._iter += self._iter_step + if self._iter_limit != "inf": + if self._iter >= self._iter_limit: + self._iter = 0 async def _call_func(self, func, args, kwargs): """Calls the given function and optionally applies time shift @@ -762,6 +779,13 @@ async def _sleep_until(self, deadline: Union[float, int]) -> bool: ) ) + async def _sleep_unless_jump_started(self, deadline: Union[float, int]) -> bool: + if self._jump_start: + self._jump_start = False + return False + + return await self._sleep_until(deadline) + def _revert_state(self): if self.states: self.states.pop() @@ -769,3 +793,7 @@ def _revert_state(self): def _skip_iteration(self) -> None: self.swim() + + def _jump_start_iteration(self) -> None: + self._jump_start = True + self._skip_iteration() diff --git a/sardine_core/superdirt/process.py b/sardine_core/superdirt/process.py index e304009d..19b64a80 100644 --- a/sardine_core/superdirt/process.py +++ b/sardine_core/superdirt/process.py @@ -95,9 +95,11 @@ def _analyze_and_warn(self, decoded_line: str): sample_name = decoded_line.split("'") print(f"[[red]/!\\\\[/red] - Sample {sample_name[1]} not found]") if "late 0." in decoded_line: - print(f"[[red]/!\\\\[/red] - Late messages. Increase SC latency]") + print( + f"[yellow][[red]/!\\\\[/red] - Late messages. Increase SC latency][/yellow]" + ) if "listening on port 57120" in decoded_line: - print(f"[[green]/!\\\\[/green] - Audio server ready!]") + print(f"[yellow][[green]/!\\\\[/green] - Audio server ready!][/yellow]") if self._synth_directory is not None: self.load_custom_synthdefs() if "ERROR: failed to open UDP socket: address in use" in decoded_line: diff --git a/sardine_core/utils/__init__.py b/sardine_core/utils/__init__.py index 82167e1f..155d88d9 100644 --- a/sardine_core/utils/__init__.py +++ b/sardine_core/utils/__init__.py @@ -1,6 +1,6 @@ import functools import inspect -from typing import TYPE_CHECKING, Callable, ParamSpec, TypeVar, Union +from typing import TYPE_CHECKING, Callable, Literal, Optional, ParamSpec, TypeVar, Union from .Messages import * @@ -11,6 +11,8 @@ T = TypeVar("T") Number = Union[float, int] +Quant = Optional[Union[Number, Literal["now", "beat", "bar"]]] +Span = Optional[Union[Number, Literal["inf"]]] MISSING = object() @@ -38,10 +40,23 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: return deco -def get_quant_deadline(clock: "BaseClock", offset_beats: Union[float, int]): +def get_deadline_from_quant(clock: "BaseClock", quant: Quant) -> Optional[float]: + if quant == "now" or quant is None: + return None + elif quant == "beat": + time = clock.shifted_time + return time + clock.get_beat_time(1, time=time) + elif quant == "bar": + return get_deadline_from_quant(clock, 0) + elif not isinstance(quant, (float, int)): + raise ValueError( + f"Invalid quant argument {quant!r}; must be 'now', 'beat', " + f"'bar', None, or a numeric offset measured in beats" + ) + time = clock.shifted_time next_bar = clock.get_bar_time(1, time=time) - offset = clock.get_beat_time(offset_beats, sync=False) + offset = clock.get_beat_time(quant, sync=False) return time + next_bar + offset