diff --git a/pyproject.toml b/pyproject.toml index 13a684dd3..649bedeeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ addopts = [ "-rf", "-vv", ] +asyncio_default_fixture_loop_scope = "function" doctest_optionflags = [ "ELLIPSIS" ] diff --git a/supriya/contexts/__init__.py b/supriya/contexts/__init__.py index 2ece96390..8a0232a84 100644 --- a/supriya/contexts/__init__.py +++ b/supriya/contexts/__init__.py @@ -14,7 +14,7 @@ Synth, ) from .nonrealtime import Score -from .realtime import AsyncServer, BaseServer, Server +from .realtime import AsyncServer, BaseServer, Server, ServerLifecycleEvent __all__ = [ "AsyncServer", @@ -29,5 +29,6 @@ "Node", "Score", "Server", + "ServerLifecycleEvent", "Synth", ] diff --git a/supriya/contexts/entities.py b/supriya/contexts/entities.py index 26d479add..b0ade53ef 100644 --- a/supriya/contexts/entities.py +++ b/supriya/contexts/entities.py @@ -568,6 +568,21 @@ def free(self): """ self.context.free_bus_group(self) + def get( + self, sync: bool = True + ) -> Union[Awaitable[Optional[Sequence[float]]], Optional[Sequence[float]]]: + """ + Get the control bus group's values. + + Emit ``/c_getn`` requests. + + :param sync: If true, communicate the request immediately. Otherwise bundle it + with the current request context. + """ + return cast(Union["AsyncServer", "Server"], self.context).get_bus_range( + bus=self[0], count=len(self), sync=sync + ) + def map_symbol(self) -> str: """ Get the bus group's map symbol. @@ -578,6 +593,22 @@ def map_symbol(self) -> str: return f"c{self.id_}" raise InvalidCalculationRate + def set(self, values: Union[float, Sequence[float]]) -> None: + """ + Set a range of control buses. + + Emit ``/c_setn`` or ``/c_fill`` requests. + + :param values: The values to write. If a float is passed, use that as a fill. + """ + if isinstance(values, float): + if len(self) == 1: + self.context.set_bus(bus=self[0], value=values) + else: + self.context.fill_bus_range(bus=self[0], count=len(self), value=values) + else: + self.context.set_bus_range(bus=self[0], values=values) + @dataclasses.dataclass(frozen=True) class Node(ContextObject): diff --git a/supriya/contexts/realtime.py b/supriya/contexts/realtime.py index 923e62f36..5e6080c77 100644 --- a/supriya/contexts/realtime.py +++ b/supriya/contexts/realtime.py @@ -11,7 +11,9 @@ from collections.abc import Sequence as SequenceABC from typing import ( TYPE_CHECKING, + Any, Callable, + Coroutine, Dict, Iterable, List, @@ -357,7 +359,7 @@ def _validate_moment_timestamp(self, seconds: Optional[float]) -> None: def on( self, event: Union[ServerLifecycleEvent, Iterable[ServerLifecycleEvent]], - callback: Callable[[ServerLifecycleEvent], None], + callback: Callable[[ServerLifecycleEvent], Optional[Coroutine[Any, Any, None]]], ) -> None: if isinstance(event, ServerLifecycleEvent): events_ = [event] @@ -554,6 +556,9 @@ def _lifecycle(self, owned=True) -> None: def _on_lifecycle_event(self, event: ServerLifecycleEvent) -> None: for callback in self._lifecycle_event_callbacks.get(event, []): + logger.info( + self._log_prefix() + f"lifecycle event: {event.name} {callback}" + ) callback(event) def _setup_notifications(self) -> None: @@ -1105,6 +1110,9 @@ async def _lifecycle(self, owned=True) -> None: async def _on_lifecycle_event(self, event: ServerLifecycleEvent) -> None: for callback in self._lifecycle_event_callbacks.get(event, []): + logger.info( + self._log_prefix() + f"lifecycle event: {event.name} {callback}" + ) if asyncio.iscoroutine(result := callback(event)): await result diff --git a/supriya/contexts/responses.py b/supriya/contexts/responses.py index 62a434fb0..bf4e3df81 100644 --- a/supriya/contexts/responses.py +++ b/supriya/contexts/responses.py @@ -466,8 +466,10 @@ def from_string(cls, string) -> "QueryTreeGroup": node_pattern = re.compile(r"^\s*(\d+) (\S+)$") control_pattern = re.compile(r"\w+: \S+") lines = string.splitlines() - if not lines[0].startswith("NODE TREE"): - raise ValueError + while not lines[0].startswith("NODE TREE"): + lines.pop(0) + if not lines: + raise ValueError(string) stack: List[QueryTreeGroup] = [ QueryTreeGroup(node_id=int(lines.pop(0).rpartition(" ")[-1])) ] diff --git a/supriya/mixers/__init__.py b/supriya/mixers/__init__.py new file mode 100644 index 000000000..8def09c49 --- /dev/null +++ b/supriya/mixers/__init__.py @@ -0,0 +1,3 @@ +from .sessions import Session + +__all__ = ["Session"] diff --git a/supriya/mixers/components.py b/supriya/mixers/components.py new file mode 100644 index 000000000..f18e5322f --- /dev/null +++ b/supriya/mixers/components.py @@ -0,0 +1,324 @@ +import asyncio +from typing import ( + TYPE_CHECKING, + Awaitable, + Dict, + Generator, + Generic, + Iterator, + List, + Literal, + Optional, + Set, + Tuple, + Type, + TypeVar, + cast, +) + +try: + from typing import TypeAlias +except ImportError: + from typing_extensions import TypeAlias # noqa + +from ..contexts import AsyncServer, Buffer, BusGroup, Context, Group, Node +from ..contexts.responses import QueryTreeGroup +from ..enums import BootStatus, CalculationRate +from ..ugens import SynthDef +from ..utils import iterate_nwise + +C = TypeVar("C", bound="Component") + +A = TypeVar("A", bound="AllocatableComponent") + +# TODO: Integrate this with channel logic +ChannelCount: TypeAlias = Literal[1, 2, 4, 8] + +if TYPE_CHECKING: + from .mixers import Mixer + from .sessions import Session + + +class ComponentNames: + ACTIVE = "active" + CHANNEL_STRIP = "channel-strip" + DEVICES = "devices" + FEEDBACK = "feedback" + GAIN = "gain" + GROUP = "group" + INPUT = "input" + INPUT_LEVELS = "input-levels" + MAIN = "main" + OUTPUT = "output" + OUTPUT_LEVELS = "output-levels" + SYNTH = "synth" + TRACKS = "tracks" + + +class Component(Generic[C]): + + def __init__( + self, + *, + parent: Optional[C] = None, + ) -> None: + self._lock = asyncio.Lock() + self._parent: Optional[C] = parent + self._dependents: Set[Component] = set() + self._feedback_dependents: Set[Component] = set() + + def __repr__(self) -> str: + return f"<{type(self).__name__}>" + + def _activate(self) -> None: + self._is_active = True + + async def _allocate_deep(self, *, context: AsyncServer) -> None: + if self.session is None: + raise RuntimeError + fifo: List[Tuple[Component, int]] = [] + current_synthdefs = self.session._synthdefs[context] + desired_synthdefs: Set[SynthDef] = set() + for component in self._walk(): + fifo.append((component, 0)) + desired_synthdefs.update(component._get_synthdefs()) + if required_synthdefs := sorted( + desired_synthdefs - current_synthdefs, key=lambda x: x.effective_name + ): + for synthdef in required_synthdefs: + context.add_synthdefs(synthdef) + await context.sync() + current_synthdefs.update(required_synthdefs) + while fifo: + component, attempts = fifo.pop(0) + if attempts > 2: + raise RuntimeError(component, attempts) + if not component._allocate(context=context): + fifo.append((component, attempts + 1)) + + def _allocate(self, *, context: AsyncServer) -> bool: + return True + + def _deallocate(self) -> None: + pass + + def _deallocate_deep(self) -> None: + for component in self._walk(): + component._deallocate() + + def _deactivate(self) -> None: + self._is_active = False + + def _delete(self) -> None: + self._deallocate_deep() + self._parent = None + + def _get_synthdefs(self) -> List[SynthDef]: + return [] + + def _iterate_parentage(self) -> Iterator["Component"]: + component = self + while component.parent is not None: + yield component + component = component.parent + yield component + + def _reconcile(self, context: Optional[AsyncServer] = None) -> bool: + return True + + def _register_dependency(self, dependent: "Component") -> None: + self._dependents.add(dependent) + + def _register_feedback( + self, context: Optional[AsyncServer], dependent: "Component" + ) -> Optional[BusGroup]: + self._dependents.add(dependent) + self._feedback_dependents.add(dependent) + return None + + def _unregister_dependency(self, dependent: "Component") -> bool: + self._dependents.discard(dependent) + return self._unregister_feedback(dependent) + + def _unregister_feedback(self, dependent: "Component") -> bool: + had_feedback = bool(self._feedback_dependents) + self._feedback_dependents.discard(dependent) + return had_feedback and not self._feedback_dependents + + def _walk( + self, component_class: Optional[Type["Component"]] = None + ) -> Generator["Component", None, None]: + component_class_ = component_class or Component + if isinstance(self, component_class_): + yield self + for child in self.children: + if isinstance(child, component_class_): + yield from child._walk(component_class_) + + @property + def address(self) -> str: + raise NotImplementedError + + @property + def children(self) -> List["Component"]: + return [] + + @property + def context(self) -> Optional[AsyncServer]: + if (mixer := self.mixer) is not None: + return mixer.context + return None + + @property + def graph_order(self) -> Tuple[int, ...]: + # TODO: Cache this + graph_order = [] + for parent, child in iterate_nwise(reversed(list(self._iterate_parentage()))): + graph_order.append(parent.children.index(child)) + return tuple(graph_order) + + @property + def mixer(self) -> Optional["Mixer"]: + # TODO: Cache this + from .mixers import Mixer + + for component in self._iterate_parentage(): + if isinstance(component, Mixer): + return component + return None + + @property + def parent(self) -> Optional[C]: + return self._parent + + @property + def parentage(self) -> List["Component"]: + # TODO: Cache this + return list(self._iterate_parentage()) + + @property + def session(self) -> Optional["Session"]: + # TODO: Cache this + from .sessions import Session + + for component in self._iterate_parentage(): + if isinstance(component, Session): + return component + return None + + +class AllocatableComponent(Component[C]): + + def __init__( + self, + *, + parent: Optional[C] = None, + ) -> None: + super().__init__(parent=parent) + self._audio_buses: Dict[str, BusGroup] = {} + self._buffers: Dict[str, Buffer] = {} + self._context: Optional[Context] = None + self._control_buses: Dict[str, BusGroup] = {} + self._is_active: bool = True + self._nodes: Dict[str, Node] = {} + + def _activate(self) -> None: + super()._activate() + if group := self._nodes.get(ComponentNames.GROUP): + group.unpause() + group.set(active=1) + + def _can_allocate(self) -> Optional[AsyncServer]: + if ( + context := self.context + ) is not None and context.boot_status == BootStatus.ONLINE: + return context + return None + + def _deactivate(self) -> None: + super()._deactivate() + if group := self._nodes.get(ComponentNames.GROUP): + group.set(active=0) + + def _deallocate(self) -> None: + super()._deallocate() + for key in tuple(self._audio_buses): + self._audio_buses.pop(key).free() + for key in tuple(self._control_buses): + self._control_buses.pop(key).free() + if group := self._nodes.get(ComponentNames.GROUP): + if not self._is_active: + group.free() + else: + group.set(gate=0) + self._nodes.clear() + for key in tuple(self._buffers): + self._buffers.pop(key).free() + + def _get_audio_bus( + self, + context: Optional[AsyncServer], + name: str, + can_allocate: bool = False, + channel_count: int = 2, + ) -> BusGroup: + return self._get_buses( + calculation_rate=CalculationRate.AUDIO, + can_allocate=can_allocate, + channel_count=channel_count, + context=context, + name=name, + ) + + def _get_buses( + self, + context: Optional[AsyncServer], + name: str, + *, + calculation_rate: CalculationRate, + can_allocate: bool = False, + channel_count: int = 1, + ) -> BusGroup: + if calculation_rate == CalculationRate.CONTROL: + buses = self._control_buses + elif calculation_rate == CalculationRate.AUDIO: + buses = self._audio_buses + else: + raise ValueError(calculation_rate) + if (name not in buses) and can_allocate and context: + buses[name] = context.add_bus_group( + calculation_rate=calculation_rate, + count=channel_count, + ) + return buses[name] + + def _get_control_bus( + self, + context: Optional[AsyncServer], + name: str, + can_allocate: bool = False, + channel_count: int = 1, + ) -> BusGroup: + return self._get_buses( + calculation_rate=CalculationRate.CONTROL, + can_allocate=can_allocate, + channel_count=channel_count, + context=context, + name=name, + ) + + async def dump_tree(self) -> QueryTreeGroup: + if self.session and self.session.status != BootStatus.ONLINE: + raise RuntimeError + annotations: Dict[int, str] = {} + tree = await cast( + Awaitable[QueryTreeGroup], + cast(Group, self._nodes[ComponentNames.GROUP]).dump_tree(), + ) + for component in self._walk(): + if not isinstance(component, AllocatableComponent): + continue + address = component.address + for name, node in component._nodes.items(): + annotations[node.id_] = f"{address}:{name}" + return tree.annotate(annotations) diff --git a/supriya/mixers/devices.py b/supriya/mixers/devices.py new file mode 100644 index 000000000..9e225187b --- /dev/null +++ b/supriya/mixers/devices.py @@ -0,0 +1,62 @@ +from typing import List + +from ..contexts import AsyncServer +from ..enums import AddAction +from ..ugens import SynthDef +from .components import AllocatableComponent, C, ComponentNames +from .synthdefs import DEVICE_DC_TESTER_2 + + +class DeviceContainer(AllocatableComponent[C]): + + def __init__(self) -> None: + self._devices: List[Device] = [] + + def _delete_device(self, device: "Device") -> None: + self._devices.remove(device) + + async def add_device(self) -> "Device": + async with self._lock: + self._devices.append(device := Device(parent=self)) + if context := self._can_allocate(): + await device._allocate_deep(context=context) + return device + + @property + def devices(self) -> List["Device"]: + return self._devices[:] + + +class Device(AllocatableComponent): + + def _allocate(self, *, context: AsyncServer) -> bool: + if not super()._allocate(context=context): + return False + elif self.parent is None: + raise RuntimeError + main_audio_bus = self.parent._get_audio_bus(context, name=ComponentNames.MAIN) + target_node = self.parent._nodes[ComponentNames.DEVICES] + with context.at(): + self._nodes[ComponentNames.GROUP] = group = target_node.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.SYNTH] = group.add_synth( + add_action=AddAction.ADD_TO_TAIL, + bus=main_audio_bus, + synthdef=DEVICE_DC_TESTER_2, + ) + return True + + def _get_synthdefs(self) -> List[SynthDef]: + return [DEVICE_DC_TESTER_2] + + async def set_active(self, active: bool = True) -> None: + async with self._lock: + pass + + @property + def address(self) -> str: + if self.parent is None: + return "devices[?]" + index = self.parent.devices.index(self) + return f"{self.parent.address}.devices[{index}]" diff --git a/supriya/mixers/mixers.py b/supriya/mixers/mixers.py new file mode 100644 index 000000000..68e23243e --- /dev/null +++ b/supriya/mixers/mixers.py @@ -0,0 +1,155 @@ +from typing import TYPE_CHECKING, List, Optional, Tuple + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import DEFAULT, Default +from ..ugens import SynthDef +from .components import AllocatableComponent, Component, ComponentNames +from .devices import DeviceContainer +from .routing import Connection +from .synthdefs import CHANNEL_STRIP_2, METERS_2, PATCH_CABLE_2_2 +from .tracks import Track, TrackContainer + +if TYPE_CHECKING: + from .sessions import Session + + +class MixerOutput(Connection["Mixer", "Mixer", Default]): + def __init__( + self, + *, + parent: "Mixer", + ) -> None: + super().__init__( + name="output", + parent=parent, + source=parent, + target=DEFAULT, + ) + + def _allocate_synth( + self, + *, + context: AsyncServer, + parent: AllocatableComponent, + new_state: "Connection.State", + ) -> None: + self._nodes[ComponentNames.SYNTH] = parent._nodes[ + ComponentNames.GROUP + ].add_synth( + add_action=AddAction.ADD_TO_TAIL, + in_=new_state.source_bus, + out=new_state.target_bus, + synthdef=PATCH_CABLE_2_2, + ) + + def _resolve_default_target( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + if not context: + return None, None + return None, context.audio_output_bus_group + + +class Mixer(TrackContainer["Session"], DeviceContainer): + + # TODO: add_device() -> Device + # TODO: group_devices(index: int, count: int) -> Rack + # TODO: set_channel_count(self, channel_count: ChannelCount) -> None + # TODO: set_output(output: int) -> None + + def __init__(self, *, parent: Optional["Session"]) -> None: + AllocatableComponent.__init__(self, parent=parent) + DeviceContainer.__init__(self) + TrackContainer.__init__(self) + self._tracks.append(Track(parent=self)) + self._output = MixerOutput(parent=self) + + def _allocate(self, context: AsyncServer) -> bool: + if not super()._allocate(context=context): + return False + # self._audio_buses["main"] = context.add_bus_group( + # calculation_rate=CalculationRate.AUDIO, + # count=2, + # ) + main_audio_bus = self._get_audio_bus( + context, name=ComponentNames.MAIN, can_allocate=True + ) + gain_control_bus = self._get_control_bus( + context, name=ComponentNames.GAIN, can_allocate=True + ) + input_levels_control_bus = self._get_control_bus( + context, + name=ComponentNames.INPUT_LEVELS, + can_allocate=True, + channel_count=2, + ) + output_levels_control_bus = self._get_control_bus( + context, + name=ComponentNames.OUTPUT_LEVELS, + can_allocate=True, + channel_count=2, + ) + target_node = context.default_group + with context.at(): + gain_control_bus.set(0.0) + input_levels_control_bus.set(0.0) + output_levels_control_bus.set(0.0) + self._nodes[ComponentNames.GROUP] = group = target_node.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.TRACKS] = tracks = group.add_group( + add_action=AddAction.ADD_TO_HEAD + ) + self._nodes[ComponentNames.DEVICES] = group.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.CHANNEL_STRIP] = channel_strip = group.add_synth( + add_action=AddAction.ADD_TO_TAIL, + bus=main_audio_bus, + gain=gain_control_bus.map_symbol(), + synthdef=CHANNEL_STRIP_2, + ) + self._nodes[ComponentNames.INPUT_LEVELS] = tracks.add_synth( + add_action=AddAction.ADD_AFTER, + synthdef=METERS_2, + in_=self._audio_buses[ComponentNames.MAIN], + out=input_levels_control_bus, + ) + self._nodes[ComponentNames.OUTPUT_LEVELS] = channel_strip.add_synth( + add_action=AddAction.ADD_AFTER, + synthdef=METERS_2, + in_=self._audio_buses[ComponentNames.MAIN], + out=output_levels_control_bus, + ) + return True + + def _get_synthdefs(self) -> List[SynthDef]: + return [ + CHANNEL_STRIP_2, + METERS_2, + ] + + async def delete(self) -> None: + # TODO: What are delete semantics actually? + async with self._lock: + if self.session is not None: + self.session._delete_mixer(self) + self._delete() + + @property + def address(self) -> str: + if self.session is None: + return "mixers[?]" + index = self.session.mixers.index(self) + return f"session.mixers[{index}]" + + @property + def children(self) -> List[Component]: + return [*self._tracks, self._output] + + @property + def context(self) -> Optional[AsyncServer]: + if self.parent is None: + return None + return self.parent._mixers[self] diff --git a/supriya/mixers/parameters.py b/supriya/mixers/parameters.py new file mode 100644 index 000000000..c4dce22b2 --- /dev/null +++ b/supriya/mixers/parameters.py @@ -0,0 +1,5 @@ +from .components import Component + + +class Parameter(Component): + pass diff --git a/supriya/mixers/routing.py b/supriya/mixers/routing.py new file mode 100644 index 000000000..0aef96aa4 --- /dev/null +++ b/supriya/mixers/routing.py @@ -0,0 +1,254 @@ +import dataclasses +import enum +from typing import Generic, List, Optional, Tuple, TypeVar, Union + +try: + from typing import TypeAlias +except ImportError: + from typing_extensions import TypeAlias # noqa + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import Default +from ..ugens import SynthDef +from .components import A, AllocatableComponent, ComponentNames +from .synthdefs import FB_PATCH_CABLE_2_2, PATCH_CABLE_2_2 + +Connectable: TypeAlias = Union[AllocatableComponent, BusGroup, Default] + + +class DefaultBehavior(enum.Enum): + PARENT = enum.auto() + GRANDPARENT = enum.auto() + + +S = TypeVar("S", bound=Connectable) + +T = TypeVar("T", bound=Connectable) + + +class Connection(AllocatableComponent[A], Generic[A, S, T]): + + @dataclasses.dataclass + class State: + feedsback: Optional[bool] = None + postfader: bool = True + source_bus: Optional[BusGroup] = None + source_component: Optional[AllocatableComponent] = None + target_bus: Optional[BusGroup] = None + target_component: Optional[AllocatableComponent] = None + + def __init__( + self, + *, + name: str, + source: Optional[S], + target: Optional[T], + parent: Optional[A] = None, + postfader: bool = True, + ) -> None: + super().__init__(parent=parent) + self._cached_state = self.State() + self._name = name + self._postfader = postfader + self._source = source + self._target = target + + def _allocate(self, *, context: AsyncServer) -> bool: + if not super()._allocate(context=context): + return False + return self._reconcile(context) + + def _allocate_synth( + self, + *, + context: AsyncServer, + parent: AllocatableComponent, + new_state: "Connection.State", + ) -> None: + self._nodes[ComponentNames.SYNTH] = parent._nodes[ + ComponentNames.GROUP + ].add_synth( + active=parent._control_buses[ComponentNames.ACTIVE].map_symbol(), + add_action=AddAction.ADD_TO_TAIL, + in_=new_state.source_bus, + out=new_state.target_bus, + synthdef=PATCH_CABLE_2_2, + ) + + def _delete(self) -> None: + super()._delete() + for component in [ + self._cached_state.source_component, + self._cached_state.target_component, + ]: + if component: + component._unregister_dependency(self) + + def _get_synthdefs(self) -> List[SynthDef]: + return [FB_PATCH_CABLE_2_2, PATCH_CABLE_2_2] + + def _reconcile(self, context: Optional[AsyncServer] = None) -> bool: + new_state = self._resolve_state(context) + self._reconcile_dependencies(context, new_state) + self._reconcile_synth(context, new_state) + self._cached_state = new_state + return self._reconcile_deferment(new_state) + + def _reconcile_deferment(self, new_state: "Connection.State") -> bool: + if ( + new_state.source_component + and new_state.target_component + and not (new_state.source_bus and new_state.target_bus) + ): + return False + return True + + def _reconcile_dependencies( + self, context: Optional[AsyncServer], new_state: "Connection.State" + ) -> None: + for new_component, old_component in [ + (new_state.source_component, self._cached_state.source_component), + (new_state.target_component, self._cached_state.target_component), + ]: + if new_component is not old_component: + if old_component: + old_component._unregister_dependency(self) + if new_component: + new_component._register_dependency(self) + if new_state.target_component and not new_state.feedsback: + new_state.target_component._unregister_feedback(self) + + def _reconcile_synth( + self, context: Optional[AsyncServer], new_state: "Connection.State" + ) -> None: + if self.parent is None: + return + if context is None: + return + if new_state == self._cached_state and self._nodes.get(ComponentNames.SYNTH): + return + with context.at(): + # Free the existing synth (if it exists) + if synth := self._nodes.pop(ComponentNames.SYNTH, None): + synth.free() + # Add a new synth (if source and target buses exist) + if new_state.source_bus and new_state.target_bus: + self._allocate_synth( + context=context, parent=self.parent, new_state=new_state + ) + + def _resolve_default_source( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + # return self.parent + raise NotImplementedError + + def _resolve_default_target( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + # return self.parent and self.parent.parent + raise NotImplementedError + + def _resolve_feedback( + self, + context: Optional[AsyncServer], + source_component: Optional[AllocatableComponent], + target_component: Optional[AllocatableComponent], + ) -> Tuple[Optional[bool], Optional[BusGroup]]: + feedsback, target_bus = None, None + try: + source_order = source_component.graph_order if source_component else None + target_order = target_component.graph_order if target_component else None + feedsback = self.feedsback(source_order, target_order) + except Exception: + pass + if feedsback and target_component: + target_bus = target_component._register_feedback(context, self) + return feedsback, target_bus + + def _resolve_source( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + # resolve source + source_component, source_bus = None, None + if isinstance(self._source, BusGroup): + source_bus = self._source + elif isinstance(self._source, AllocatableComponent): + source_component = self._source + elif isinstance(self._source, Default): + source_component, source_bus = self._resolve_default_source(context) + if source_component: + source_bus = source_component._audio_buses.get(ComponentNames.MAIN) + return source_component, source_bus + + def _resolve_state( + self, context: Optional[AsyncServer] = None + ) -> "Connection.State": + source_component, source_bus = self._resolve_source(context) + target_component, target_bus = self._resolve_target(context) + feedsback, feedback_bus = self._resolve_feedback( + context, source_component, target_component + ) + return self.State( + feedsback=feedsback, + postfader=self._postfader, + source_component=source_component, + source_bus=source_bus, + target_component=target_component, + target_bus=feedback_bus or target_bus, + ) + + def _resolve_target( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + target_component, target_bus = None, None + if isinstance(self._target, BusGroup): + target_bus = self._target + elif isinstance(self._target, AllocatableComponent): + target_component = self._target + elif isinstance(self._target, Default): + target_component, target_bus = self._resolve_default_target(context) + if target_component: + target_bus = target_component._audio_buses.get(ComponentNames.MAIN) + return target_component, target_bus + + def _set_postfader(self, postfader: bool) -> None: + self._postfader = postfader + self._reconcile(self._can_allocate()) + + def _set_source(self, source: Optional[S]) -> None: + if isinstance(source, AllocatableComponent) and self.mixer is not source.mixer: + raise RuntimeError + self._source = source + self._reconcile(self._can_allocate()) + + def _set_target(self, target: Optional[T]) -> None: + if isinstance(target, AllocatableComponent) and self.mixer is not target.mixer: + raise RuntimeError + self._target = target + self._reconcile(self._can_allocate()) + + @classmethod + def feedsback( + cls, + source_order: Optional[Tuple[int, ...]], + target_order: Optional[Tuple[int, ...]], + ) -> Optional[bool]: + if source_order is None or target_order is None: + return None + length = min(len(target_order), len(source_order)) + # If source_order is shallower than target_order, source_order might contain target_order + if len(source_order) < len(target_order): + return target_order[:length] <= source_order + # If target_order is shallower than source_order, target_order might contain source_order + elif len(target_order) < len(source_order): + return target_order < source_order[:length] + # If orders are same depth, check difference strictly + return target_order <= source_order + + @property + def address(self) -> str: + if self.parent is None: + return self._name + return f"{self.parent.address}.{self._name}" diff --git a/supriya/mixers/sessions.py b/supriya/mixers/sessions.py new file mode 100644 index 000000000..b53df5a3f --- /dev/null +++ b/supriya/mixers/sessions.py @@ -0,0 +1,199 @@ +import asyncio +import logging +from typing import TYPE_CHECKING, Dict, List, Optional, Set + +from ..clocks import AsyncClock +from ..contexts import AsyncServer +from ..enums import BootStatus +from ..osc import find_free_port +from ..ugens import SynthDef +from .components import Component + +if TYPE_CHECKING: + from .mixers import Mixer + + +logger = logging.getLogger(__name__) + + +class Session(Component): + """ + Top-level object. + + Contains one transport. + + Contains one or more contexts. + + Contains one or more mixers. + + Each mixer references one context. + + This supports running scsynth and supernova simultaneously via two mixers. + """ + + def __init__(self) -> None: + from .mixers import Mixer + + super().__init__() + self._boot_future: Optional[asyncio.Future] = None + self._clock = AsyncClock() + self._contexts: Dict[AsyncServer, List[Mixer]] = {} + self._lock = asyncio.Lock() + self._mixers: Dict[Mixer, AsyncServer] = {} + self._quit_future: Optional[asyncio.Future] = None + self._status = BootStatus.OFFLINE + self._synthdefs: Dict[AsyncServer, Set[SynthDef]] = {} + # add initial context and mixer + self._contexts[(context := self._add_context())] = [mixer := Mixer(parent=self)] + self._mixers[mixer] = context + + def __repr__(self) -> str: + return f"<{type(self).__name__}>" + + def __str__(self) -> str: + parts: List[str] = [f"<{type(self).__name__} status={self.status.name}>"] + for context, mixers in self._contexts.items(): + parts.append( + f" <{type(context).__name__} address={context.options.ip_address}:{context.options.port}>" + ) + for mixer in mixers: + parts.extend(" " + line for line in str(mixer).splitlines()) + return "\n".join(parts) + + def _add_context(self) -> AsyncServer: + context = AsyncServer() + self._contexts[context] = [] + self._synthdefs[context] = set() + return context + + def _delete_mixer(self, mixer) -> None: + if mixer in (mixers := self._contexts.get(self._mixers.pop(mixer), [])): + mixers.remove(mixer) + + async def add_context(self) -> AsyncServer: + async with self._lock: + context = self._add_context() + if self._status == BootStatus.ONLINE: + await context.boot(port=find_free_port()) + return context + + async def add_mixer(self, context: Optional[AsyncServer] = None) -> "Mixer": + from .mixers import Mixer + + async with self._lock: + if not self._contexts: + context = self._add_context() + if self._status == BootStatus.ONLINE: + await context.boot(port=find_free_port()) + if context is None: + context = list(self._contexts)[0] + self._contexts.setdefault(context, []).append(mixer := Mixer(parent=self)) + self._mixers[mixer] = context + if self._status == BootStatus.ONLINE: + await mixer._allocate_deep(context=context) + return mixer + + async def boot(self) -> None: + async with self._lock: + # guard against concurrent boot / quits + if self._status == BootStatus.OFFLINE: + self._quit_future = None + self._boot_future = asyncio.get_running_loop().create_future() + self._status = BootStatus.BOOTING + await asyncio.gather( + *[context.boot(port=find_free_port()) for context in self._contexts] + ) + self._status = BootStatus.ONLINE + self._boot_future.set_result(True) + for context, mixers in self._contexts.items(): + for mixer in mixers: + await mixer._allocate_deep(context=context) + elif self._boot_future is not None: # BOOTING / ONLINE + await self._boot_future + else: # NONREALTIME + raise Exception(self._status) + + async def delete_context(self, context: AsyncServer) -> None: + async with self._lock: + for mixer in self._contexts.pop(context): + await mixer.delete() + await context.quit() + + async def dump_components(self) -> str: + return "" + + async def dump_tree(self) -> str: + # what if components and query tree stuff was intermixed? + # we fetch the node tree once per mixer + # and then the node tree needs to get partitioned by subtrees + if self.status != BootStatus.ONLINE: + raise RuntimeError + parts: List[str] = [] + for context, mixers in self._contexts.items(): + parts.append(repr(context)) + for mixer in mixers: + for line in str(await mixer.dump_tree()).splitlines(): + parts.append(f" {line}") + return "\n".join(parts) + + async def quit(self) -> None: + async with self._lock: + # guard against concurrent boot / quits + if self._status == BootStatus.ONLINE: + self._boot_future = None + self._quit_future = asyncio.get_running_loop().create_future() + self._status = BootStatus.QUITTING + for context, mixers in self._contexts.items(): + for mixer in mixers: + with context.at(): + mixer._deallocate() + await asyncio.gather(*[context.quit() for context in self._contexts]) + self._status = BootStatus.OFFLINE + self._quit_future.set_result(True) + elif self._quit_future is not None: # QUITTING / OFFLINE + await self._quit_future + elif self._status == BootStatus.OFFLINE: # Never booted + return + else: # NONREALTIME + raise Exception(self._status) + + async def set_mixer_context(self, mixer: "Mixer", context: AsyncServer) -> None: + async with self._lock: + if mixer not in self._mixers: + raise ValueError(mixer) + elif context not in self._contexts: + raise ValueError(context) + if mixer in self._contexts[context]: + return + self._contexts[self._mixers[mixer]].remove(mixer) + async with mixer._lock: + mixer._deallocate_deep() + if self._status == BootStatus.ONLINE: + await mixer._allocate_deep(context=context) + self._contexts[context].append(mixer) + self._mixers[mixer] = context + + async def sync(self) -> None: + if self._status != BootStatus.ONLINE: + raise RuntimeError + await asyncio.gather(*[context.sync() for context in self.contexts]) + + @property + def address(self) -> str: + return "session" + + @property + def children(self) -> List[Component]: + return list(self._mixers) + + @property + def contexts(self) -> List[AsyncServer]: + return list(self._contexts) + + @property + def mixers(self) -> List["Mixer"]: + return list(self._mixers) + + @property + def status(self) -> BootStatus: + return self._status diff --git a/supriya/mixers/synthdefs.py b/supriya/mixers/synthdefs.py new file mode 100644 index 000000000..b2d446ab1 --- /dev/null +++ b/supriya/mixers/synthdefs.py @@ -0,0 +1,102 @@ +from ..ugens import ( + DC, + Amplitude, + In, + InFeedback, + Linen, + Out, + Parameter, + ReplaceOut, + SynthDef, + SynthDefBuilder, +) + +LAG_TIME = 0.005 + + +def build_channel_strip(channel_count: int = 2) -> SynthDef: + with SynthDefBuilder( + active=1, + bus=0, + gain=Parameter(value=0, lag=LAG_TIME), + gate=1, + ) as builder: + source = In.ar( + channel_count=channel_count, + bus=builder["bus"], + ) + active_gate = Linen.kr( + attack_time=LAG_TIME, + gate=builder["active"], + release_time=LAG_TIME, + ) + free_gate = Linen.kr( + attack_time=LAG_TIME, + gate=builder["gate"], + release_time=LAG_TIME, + ) + source *= builder["gain"].db_to_amplitude() + source *= active_gate + source *= free_gate + ReplaceOut.ar(bus=builder["bus"], source=source) + return builder.build(f"supriya:channel-strip:{channel_count}") + + +def build_device_dc_tester(channel_count: int = 2) -> SynthDef: + with SynthDefBuilder(dc=1, out=0) as builder: + Out.ar(bus=builder["out"], source=[DC.ar(source=builder["dc"])] * channel_count) + return builder.build(f"supriya:device-dc-tester:{channel_count}") + + +def build_meters(channel_count: int = 2) -> SynthDef: + with SynthDefBuilder(in_=0, out=0) as builder: + Out.kr( + bus=builder["out"], + source=Amplitude.kr( + source=In.ar(bus=builder["in_"], channel_count=channel_count) + ), + ) + return builder.build(f"supriya:meters:{channel_count}") + + +def build_patch_cable( + input_channel_count: int = 2, output_channel_count: int = 2, feedback: bool = False +) -> SynthDef: + # TODO: Implement up/down channel mixing + with SynthDefBuilder(active=1, in_=0, out=0, gain=0, gate=1) as builder: + if feedback: + source = InFeedback.ar( + channel_count=input_channel_count, + bus=builder["in_"], + ) + else: + source = In.ar( + channel_count=input_channel_count, + bus=builder["in_"], + ) + active_gate = Linen.kr( + attack_time=LAG_TIME, + gate=builder["active"], + release_time=LAG_TIME, + ) + free_gate = Linen.kr( + attack_time=LAG_TIME, + gate=builder["gate"], + release_time=LAG_TIME, + ) + source *= builder["gain"].db_to_amplitude() + source *= free_gate * active_gate + Out.ar(bus=builder["out"], source=source) + + name = ( + f"supriya:{'fb-' if feedback else ''}" + f"patch-cable:{input_channel_count}x{output_channel_count}" + ) + return builder.build(name) + + +CHANNEL_STRIP_2 = build_channel_strip(2) +DEVICE_DC_TESTER_2 = build_device_dc_tester(2) +FB_PATCH_CABLE_2_2 = build_patch_cable(2, 2, feedback=True) +METERS_2 = build_meters(2) +PATCH_CABLE_2_2 = build_patch_cable(2, 2) diff --git a/supriya/mixers/tracks.py b/supriya/mixers/tracks.py new file mode 100644 index 000000000..13140b68e --- /dev/null +++ b/supriya/mixers/tracks.py @@ -0,0 +1,432 @@ +from typing import List, Optional, Tuple, Union, cast + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import DEFAULT, Default +from ..ugens import SynthDef +from .components import AllocatableComponent, C, Component, ComponentNames +from .devices import DeviceContainer +from .routing import Connection +from .synthdefs import CHANNEL_STRIP_2, FB_PATCH_CABLE_2_2, METERS_2, PATCH_CABLE_2_2 + + +class TrackContainer(AllocatableComponent[C]): + + def __init__(self) -> None: + self._tracks: List[Track] = [] + + def _delete_track(self, track: "Track") -> None: + self._tracks.remove(track) + + async def add_track(self) -> "Track": + async with self._lock: + self._tracks.append(track := Track(parent=self)) + if context := self._can_allocate(): + await track._allocate_deep(context=context) + return track + + @property + def tracks(self) -> List["Track"]: + return self._tracks[:] + + +class TrackFeedback(Connection["Track", BusGroup, "Track"]): + def __init__( + self, + *, + parent: "Track", + source: Optional[BusGroup] = None, + ) -> None: + super().__init__( + name="feedback", + parent=parent, + source=source, + target=parent, + ) + + def _allocate_synth( + self, + *, + context: AsyncServer, + parent: AllocatableComponent, + new_state: Connection.State, + ) -> None: + self._nodes[ComponentNames.SYNTH] = parent._nodes[ + ComponentNames.GROUP + ].add_synth( + active=parent._control_buses[ComponentNames.ACTIVE].map_symbol(), + add_action=AddAction.ADD_TO_HEAD, + in_=new_state.source_bus, + out=new_state.target_bus, + synthdef=FB_PATCH_CABLE_2_2, + ) + + +class TrackInput(Connection["Track", Union[BusGroup, TrackContainer], "Track"]): + + def __init__( + self, + *, + parent: "Track", + source: Optional[Union[BusGroup, "Track"]] = None, + ) -> None: + super().__init__( + name="input", + parent=parent, + source=source, + target=parent, + ) + + def _allocate_synth( + self, + *, + context: AsyncServer, + parent: AllocatableComponent, + new_state: Connection.State, + ) -> None: + self._nodes[ComponentNames.SYNTH] = parent._nodes[ + ComponentNames.TRACKS + ].add_synth( + active=parent._control_buses[ComponentNames.ACTIVE].map_symbol(), + add_action=AddAction.ADD_BEFORE, + in_=new_state.source_bus, + out=new_state.target_bus, + synthdef=PATCH_CABLE_2_2, + ) + + async def set_source(self, source: Optional[Union[BusGroup, "Track"]]) -> None: + async with self._lock: + if source is self.parent: + raise RuntimeError + self._set_source(source) + + +class TrackOutput( + Connection["Track", "Track", Union[BusGroup, Default, TrackContainer]] +): + + def __init__( + self, + *, + parent: "Track", + target: Optional[Union[BusGroup, Default, TrackContainer]] = DEFAULT, + ) -> None: + super().__init__( + name="output", + parent=parent, + source=parent, + target=target, + ) + + def _resolve_default_target( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + return (self.parent and self.parent.parent), None + + async def set_target( + self, target: Optional[Union[BusGroup, Default, TrackContainer]] + ) -> None: + async with self._lock: + if target is self.parent: + raise RuntimeError + self._set_target(target) + + +class TrackSend(Connection["Track", "Track", TrackContainer]): + def __init__( + self, + *, + parent: "Track", + target: TrackContainer, + postfader: bool = True, + ) -> None: + super().__init__( + name="send", + parent=parent, + postfader=postfader, + source=parent, + target=target, + ) + + def _allocate_synth( + self, + *, + context: AsyncServer, + parent: AllocatableComponent, + new_state: Connection.State, + ) -> None: + self._nodes[ComponentNames.SYNTH] = parent._nodes[ + ComponentNames.CHANNEL_STRIP + ].add_synth( + active=parent._control_buses[ComponentNames.ACTIVE].map_symbol(), + add_action=( + AddAction.ADD_AFTER if new_state.postfader else AddAction.ADD_BEFORE + ), + in_=new_state.source_bus, + out=new_state.target_bus, + synthdef=PATCH_CABLE_2_2, + ) + + def _resolve_default_source( + self, context: Optional[AsyncServer] + ) -> Tuple[Optional[AllocatableComponent], Optional[BusGroup]]: + return self.parent, None + + async def delete(self) -> None: + async with self._lock: + if self._parent is not None and self in self._parent._sends: + self._parent._sends.remove(self) + self._delete() + + async def set_postfader(self, postfader: bool) -> None: + async with self._lock: + self._set_postfader(postfader) + + async def set_target(self, target: TrackContainer) -> None: + async with self._lock: + if target is self.parent: + raise RuntimeError + self._set_target(target) + + @property + def address(self) -> str: + if self.parent is None: + return "sends[?]" + index = self.parent.sends.index(self) + return f"{self.parent.address}.sends[{index}]" + + @property + def postfader(self) -> bool: + return self._postfader + + @property + def target(self) -> Union[AllocatableComponent, BusGroup]: + # TODO: Can this be parameterized via generics? + return cast(Union[AllocatableComponent, BusGroup], self._target) + + +class Track(TrackContainer[TrackContainer], DeviceContainer): + + # TODO: add_device() -> Device + # TODO: add_send(destination: Track) -> Send + # TODO: group_devices(index: int, count: int) -> Rack + # TODO: group_tracks(index: int, count: int) -> Track + # TODO: set_channel_count(self, channel_count: Optional[ChannelCount] = None) -> None + # TODO: set_input(None | Default | Track | BusGroup) + + def __init__( + self, + *, + parent: Optional[TrackContainer] = None, + ) -> None: + AllocatableComponent.__init__(self, parent=parent) + DeviceContainer.__init__(self) + TrackContainer.__init__(self) + self._feedback = TrackFeedback(parent=self) + self._input = TrackInput(parent=self) + self._output = TrackOutput(parent=self) + # TODO: Are sends the purview of track containers in general? + self._sends: List[TrackSend] = [] + + def _allocate(self, *, context: AsyncServer) -> bool: + if not super()._allocate(context=context): + return False + elif self.parent is None: + raise RuntimeError + main_audio_bus = self._get_audio_bus( + context, name=ComponentNames.MAIN, can_allocate=True + ) + active_control_bus = self._get_control_bus( + context, name=ComponentNames.ACTIVE, can_allocate=True + ) + gain_control_bus = self._get_control_bus( + context, name=ComponentNames.GAIN, can_allocate=True + ) + input_levels_control_bus = self._get_control_bus( + context, + name=ComponentNames.INPUT_LEVELS, + can_allocate=True, + channel_count=2, + ) + output_levels_control_bus = self._get_control_bus( + context, + name=ComponentNames.OUTPUT_LEVELS, + can_allocate=True, + channel_count=2, + ) + target_node = self.parent._nodes[ComponentNames.TRACKS] + with context.at(): + active_control_bus.set(1.0) + gain_control_bus.set(0.0) + input_levels_control_bus.set(0.0) + output_levels_control_bus.set(0.0) + self._nodes[ComponentNames.GROUP] = group = target_node.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.TRACKS] = tracks = group.add_group( + add_action=AddAction.ADD_TO_HEAD + ) + self._nodes[ComponentNames.DEVICES] = group.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.CHANNEL_STRIP] = channel_strip = group.add_synth( + add_action=AddAction.ADD_TO_TAIL, + bus=main_audio_bus, + active=active_control_bus.map_symbol(), + gain=gain_control_bus.map_symbol(), + synthdef=CHANNEL_STRIP_2, + ) + self._nodes[ComponentNames.INPUT_LEVELS] = tracks.add_synth( + add_action=AddAction.ADD_AFTER, + synthdef=METERS_2, + in_=self._audio_buses[ComponentNames.MAIN], + out=input_levels_control_bus, + ) + self._nodes[ComponentNames.OUTPUT_LEVELS] = channel_strip.add_synth( + add_action=AddAction.ADD_AFTER, + synthdef=METERS_2, + in_=self._audio_buses[ComponentNames.MAIN], + out=output_levels_control_bus, + ) + return True + + def _get_synthdefs(self) -> List[SynthDef]: + return [ + CHANNEL_STRIP_2, + METERS_2, + ] + + def _register_feedback( + self, context: Optional[AsyncServer], dependent: "Component" + ) -> Optional[BusGroup]: + super()._register_feedback(context, dependent) + # check if feedback should be setup + if not context: + return None + if self._feedback_dependents: + self._feedback._set_source( + self._get_audio_bus( + context, name=ComponentNames.FEEDBACK, can_allocate=True + ) + ) + return self._get_audio_bus(context, name=ComponentNames.FEEDBACK) + + def _unregister_feedback(self, dependent: "Component") -> bool: + if should_tear_down := super()._unregister_feedback(dependent): + # check if feedback should be torn down + if bus_group := self._audio_buses.get(ComponentNames.FEEDBACK): + bus_group.free() + self._feedback._set_source(None) + return should_tear_down + + async def add_send( + self, target: TrackContainer, postfader: bool = True + ) -> TrackSend: + async with self._lock: + self._sends.append( + send := TrackSend(parent=self, postfader=postfader, target=target) + ) + if context := self._can_allocate(): + await send._allocate_deep(context=context) + return send + + async def delete(self) -> None: + # TODO: What are delete semantics actually? + async with self._lock: + if self._parent is not None: + self._parent._delete_track(self) + self._delete() + + async def move(self, parent: TrackContainer, index: int) -> None: + async with self._lock: + # Validate if moving is possible + if self.mixer is not parent.mixer: + raise RuntimeError + elif self in parent.parentage: + raise RuntimeError + elif index < 0 or index > len(parent.tracks): + raise RuntimeError + # Reconfigure parentage and bail if this is a no-op + old_parent, old_index = self._parent, 0 + if old_parent is not None: + old_index = old_parent._tracks.index(self) + if old_parent is parent and old_index == index: + return # Bail + if old_parent is not None: + old_parent._tracks.remove(self) + self._parent = parent + parent._tracks.insert(index, self) + # Apply changes against the context + if (context := self._can_allocate()) is not None: + if index == 0: + node_id = self._parent._nodes[ComponentNames.TRACKS] + add_action = AddAction.ADD_TO_HEAD + else: + node_id = self._parent._tracks[index - 1]._nodes[ + ComponentNames.GROUP + ] + add_action = AddAction.ADD_AFTER + with context.at(): + self._nodes[ComponentNames.GROUP].move( + target_node=node_id, add_action=add_action + ) + for component in self._dependents: + component._reconcile(context) + + async def set_active(self, active: bool = True) -> None: + async with self._lock: + pass + + async def set_input(self, input_: Optional[Union[BusGroup, "Track"]]) -> None: + await self._input.set_source(input_) + + async def set_output( + self, output: Optional[Union[Default, TrackContainer]] + ) -> None: + await self._output.set_target(output) + + async def set_soloed(self, soloed: bool = True, exclusive: bool = True) -> None: + async with self._lock: + pass + + async def ungroup(self) -> None: + async with self._lock: + pass + + @property + def address(self) -> str: + if self.parent is None: + return "tracks[?]" + index = self.parent.tracks.index(self) + return f"{self.parent.address}.tracks[{index}]" + + @property + def children(self) -> List[Component]: + prefader_sends = [] + postfader_sends = [] + for send in self._sends: + if send.postfader: + postfader_sends.append(send) + else: + prefader_sends.append(send) + return [ + self._feedback, + self._input, + *self._tracks, + *self._devices, + *prefader_sends, + self._output, + *postfader_sends, + ] + + @property + def input_(self) -> Optional[Union[BusGroup, TrackContainer]]: + return self._input._source + + @property + def output(self) -> Optional[Union[BusGroup, Default, TrackContainer]]: + return self._output._target + + @property + def sends(self) -> List[TrackSend]: + return self._sends[:] diff --git a/supriya/typing.py b/supriya/typing.py index c36c22045..2edec2d02 100644 --- a/supriya/typing.py +++ b/supriya/typing.py @@ -41,10 +41,16 @@ class Default: pass +DEFAULT = Default() + + class Missing: pass +MISSING = Missing() + + @runtime_checkable class SupportsOsc(Protocol): def to_osc(self) -> Union["OscBundle", "OscMessage"]: diff --git a/tests/mixers/__init__.py b/tests/mixers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/mixers/conftest.py b/tests/mixers/conftest.py new file mode 100644 index 000000000..a60c35dcf --- /dev/null +++ b/tests/mixers/conftest.py @@ -0,0 +1,66 @@ +import contextlib +import difflib +from typing import List, Optional, Union + +import pytest +from uqbar.strings import normalize + +from supriya import AsyncServer, OscBundle, OscMessage +from supriya.mixers import Session +from supriya.mixers.mixers import Mixer +from supriya.mixers.tracks import Track + + +@contextlib.contextmanager +def capture(context: Optional[AsyncServer]): + entries: List[Union[OscBundle, OscMessage]] = [] + if context is None: + yield entries + else: + with context.osc_protocol.capture() as transcript: + yield entries + entries.extend(transcript.filtered(received=False, status=False)) + + +async def debug_tree(session: Session, label: str = "initial tree") -> str: + tree = str(await session.dump_tree()) + for i, context in enumerate(session.contexts): + tree = tree.replace(repr(context), f"") + print(f"{label}:\n{tree}") + return tree + + +async def assert_diff( + session: Session, expected_diff: str, expected_initial_tree: str +) -> None: + await session.sync() + print(f"expected initial tree:\n{normalize(expected_initial_tree)}") + actual_tree = await debug_tree(session, "actual tree") + actual_diff = "".join( + difflib.unified_diff( + normalize(expected_initial_tree).splitlines(True), + actual_tree.splitlines(True), + tofile="mutation", + fromfile="initial", + ) + ) + print(f"actual diff:\n{normalize(actual_diff)}") + assert normalize(expected_diff) == normalize(actual_diff) + + +does_not_raise = contextlib.nullcontext() + + +@pytest.fixture +def mixer(session: Session) -> Mixer: + return session.mixers[0] + + +@pytest.fixture +def session() -> Session: + return Session() + + +@pytest.fixture +def track(mixer: Mixer) -> Track: + return mixer.tracks[0] diff --git a/tests/mixers/test_Connection.py b/tests/mixers/test_Connection.py new file mode 100644 index 000000000..070878fda --- /dev/null +++ b/tests/mixers/test_Connection.py @@ -0,0 +1,56 @@ +from typing import Dict + +import pytest + +from supriya.mixers.components import Component +from supriya.mixers.mixers import Mixer +from supriya.mixers.routing import Connection + + +@pytest.mark.parametrize( + "source_name, target_name, feedsback", + [ + ("mixer", "mixer", True), + ("mixer", "track_one", True), + ("mixer", "track_two", True), + ("mixer", "track_one_child", True), + ("mixer", "track_two_child", True), + ("track_one", "mixer", False), + ("track_one", "track_one", True), + ("track_one", "track_two", False), + ("track_one", "track_one_child", True), + ("track_one", "track_two_child", False), + ("track_two", "mixer", False), + ("track_two", "track_one", True), + ("track_two", "track_two", True), + ("track_two", "track_one_child", True), + ("track_two", "track_two_child", True), + ("track_one_child", "mixer", False), + ("track_one_child", "track_one", False), + ("track_one_child", "track_two", False), + ("track_one_child", "track_one_child", True), + ("track_one_child", "track_two_child", False), + ("track_two_child", "mixer", False), + ("track_two_child", "track_one", True), + ("track_two_child", "track_two", False), + ("track_two_child", "track_one_child", True), + ("track_two_child", "track_two_child", True), + ], +) +@pytest.mark.asyncio +async def test_Connection_feedsback( + source_name: str, target_name: str, feedsback: bool, mixer: Mixer +) -> None: + components: Dict[str, Component] = { + "mixer": mixer, + "track_one": (track_one := mixer.tracks[0]), + "track_two": (track_two := await mixer.add_track()), + "track_one_child": await track_one.add_track(), + "track_two_child": await track_two.add_track(), + } + source = components[source_name] + target = components[target_name] + assert Connection.feedsback(source.graph_order, target.graph_order) == feedsback, ( + source_name, + target_name, + ) diff --git a/tests/mixers/test_Mixer.py b/tests/mixers/test_Mixer.py new file mode 100644 index 000000000..44c13299e --- /dev/null +++ b/tests/mixers/test_Mixer.py @@ -0,0 +1,199 @@ +from typing import List, Union + +import pytest + +from supriya import OscBundle, OscMessage +from supriya.mixers import Session +from supriya.mixers.mixers import Mixer + +from .conftest import assert_diff, capture, debug_tree + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [ + OscBundle( + contents=( + OscMessage("/c_set", 11, 1.0, 12, 0.0), + OscMessage("/c_fill", 13, 2, 0.0, 15, 2, 0.0), + OscMessage( + "/g_new", 1014, 1, 1001, 1015, 0, 1014, 1016, 1, 1014 + ), + OscMessage( + "/s_new", + "supriya:channel-strip:2", + 1017, + 1, + 1014, + "active", + "c11", + "bus", + 20.0, + "gain", + "c12", + ), + OscMessage( + "/s_new", + "supriya:meters:2", + 1018, + 3, + 1015, + "in_", + 20.0, + "out", + 13.0, + ), + OscMessage( + "/s_new", + "supriya:meters:2", + 1019, + 3, + 1017, + "in_", + 20.0, + "out", + 15.0, + ), + ), + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1020, + 1, + 1014, + "active", + "c11", + "in_", + 20.0, + "out", + 16.0, + ), + ], + """ + --- initial + +++ mutation + @@ -12,6 +12,17 @@ + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + + 1014 group (session.mixers[0].tracks[1]:group) + + 1015 group (session.mixers[0].tracks[1]:tracks) + + 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + + in_: 20.0, out: 13.0 + + 1016 group (session.mixers[0].tracks[1]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + + active: c11, bus: 20.0, gain: c12, gate: 1.0 + + 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + + in_: 20.0, out: 15.0 + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + """, + ), + ], +) +@pytest.mark.asyncio +async def test_Mixer_add_track( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, +) -> None: + # Pre-conditions + print("Pre-conditions") + if online: + await session.boot() + await debug_tree(session) + # Operation + print("Operation") + with capture(mixer.context) as commands: + track = await mixer.add_track() + # Post-conditions + print("Post-conditions") + assert track in mixer.tracks + assert track.parent is mixer + assert mixer.tracks[-1] is track + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [ + OscMessage("/n_set", 1000, "gate", 0.0), + OscMessage("/n_set", 1004, "gate", 0.0), + ], + "", + ), + ], +) +@pytest.mark.asyncio +async def test_Mixer_delete( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, +) -> None: + # Pre-conditions + print("Pre-conditions") + if online: + await session.boot() + # Operation + print("Operation") + with capture(mixer.context) as commands: + await mixer.delete() + # Post-conditions + print("Post-conditions") + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + """, + ) + assert commands == expected_commands + raise Exception diff --git a/tests/mixers/test_Session.py b/tests/mixers/test_Session.py new file mode 100644 index 000000000..91639f10e --- /dev/null +++ b/tests/mixers/test_Session.py @@ -0,0 +1,475 @@ +from contextlib import nullcontext as does_not_raise + +import pytest +from uqbar.strings import normalize + +from supriya.enums import BootStatus +from supriya.mixers import Session + +from .conftest import assert_diff, debug_tree + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.asyncio +async def test_Session_boot(online: bool, session: Session) -> None: + # Pre-conditions + assert session.status == BootStatus.OFFLINE + if online: + await session.boot() + assert session.status == BootStatus.ONLINE + # Operation + await session.boot() # idempotent + # Post-conditions + assert session.status == BootStatus.ONLINE + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """ + ) + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.asyncio +async def test_Session_quit(online: bool, session: Session) -> None: + # Pre-conditions + assert session.status == BootStatus.OFFLINE + if online: + await session.boot() + assert session.status == BootStatus.ONLINE + # Operation + await session.quit() + # Post-conditions + assert session.status == BootStatus.OFFLINE + with pytest.raises(RuntimeError): + assert await session.dump_tree() + + +@pytest.mark.parametrize( + "online, expectation", + [ + (False, pytest.raises(RuntimeError)), + (True, does_not_raise()), + ], +) +@pytest.mark.asyncio +async def test_Session_add_context(expectation, online: bool, session: Session) -> None: + # Pre-conditions + if online: + await session.boot() + assert len(session.contexts) == 1 + # Operation + context = await session.add_context() + # Post-conditions + assert len(session.contexts) == 2 + assert context in session.contexts + assert context.boot_status == session.status + with expectation: + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + {session.contexts[1]!r} + """ + ) + + +@pytest.mark.parametrize( + "online, expectation, reuse_context", + [ + (False, pytest.raises(RuntimeError), False), + (True, does_not_raise(), False), + (True, does_not_raise(), True), + ], +) +@pytest.mark.asyncio +async def test_Session_add_mixer( + expectation, online: bool, reuse_context, session: Session +) -> None: + # Pre-conditions + if online: + await session.boot() + await session.add_context() + assert len(session.mixers) == 1 + # Operation + await session.add_mixer(context=None if reuse_context else session.contexts[1]) + # Post-conditions + assert len(session.mixers) == 2 + with expectation: + if not reuse_context: + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + {session.contexts[1]!r} + NODE TREE 1000 group (session.mixers[1]:group) + 1001 group (session.mixers[1]:tracks) + 1006 group (session.mixers[1].tracks[0]:group) + 1007 group (session.mixers[1].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[1].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[1]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[1]:devices) + 1003 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[1]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """ + ) + else: + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1014 group (session.mixers[1]:group) + 1015 group (session.mixers[1]:tracks) + 1020 group (session.mixers[1].tracks[0]:group) + 1021 group (session.mixers[1].tracks[0]:tracks) + 1024 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + in_: 22.0, out: 18.0 + 1022 group (session.mixers[1].tracks[0]:devices) + 1023 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + active: c16, bus: 22.0, gain: c17, gate: 1.0 + 1025 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + in_: 22.0, out: 20.0 + 1026 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + active: c16, gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1018 supriya:meters:2 (session.mixers[1]:input-levels) + in_: 20.0, out: 12.0 + 1016 group (session.mixers[1]:devices) + 1017 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + active: 1.0, bus: 20.0, gain: c11, gate: 1.0 + 1019 supriya:meters:2 (session.mixers[1]:output-levels) + in_: 20.0, out: 14.0 + 1027 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 20.0, out: 0.0 + {session.contexts[1]!r} + """ + ) + + +@pytest.mark.parametrize( + "online, expectation", + [ + (False, pytest.raises(RuntimeError)), + (True, does_not_raise()), + ], +) +@pytest.mark.asyncio +async def test_Session_delete_context( + expectation, online: bool, session: Session +) -> None: + # Pre-conditions + if online: + await session.boot() + await session.add_context() + # Operation + await session.delete_context(session.contexts[0]) + # Post-conditions + assert len(session.contexts) == 1 + assert len(session.mixers) == 0 + with expectation: + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + """ + ) + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "mixer_index, context_index, expected_diff", + [ + (0, 0, ""), + ( + 0, + 1, + """ + --- initial + +++ mutation + @@ -1,26 +1,4 @@ + + - NODE TREE 1000 group (session.mixers[0]:group) + - 1001 group (session.mixers[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:group) + - 1007 group (session.mixers[0].tracks[0]:tracks) + - 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + - in_: 18.0, out: 7.0 + - 1008 group (session.mixers[0].tracks[0]:devices) + - 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + - active: c5, bus: 18.0, gain: c6, gate: 1.0 + - 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + - in_: 18.0, out: 9.0 + - 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1004 supriya:meters:2 (session.mixers[0]:input-levels) + - in_: 16.0, out: 1.0 + - 1002 group (session.mixers[0]:devices) + - 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + - active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + - 1005 supriya:meters:2 (session.mixers[0]:output-levels) + - in_: 16.0, out: 3.0 + - 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + - active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1014 group (session.mixers[1]:group) + 1015 group (session.mixers[1]:tracks) + 1020 group (session.mixers[1].tracks[0]:group) + @@ -43,4 +21,26 @@ + in_: 20.0, out: 14.0 + 1027 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 20.0, out: 0.0 + -+ + + NODE TREE 1000 group (session.mixers[0]:group) + + 1001 group (session.mixers[0]:tracks) + + 1006 group (session.mixers[0].tracks[0]:group) + + 1007 group (session.mixers[0].tracks[0]:tracks) + + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + + in_: 18.0, out: 7.0 + + 1008 group (session.mixers[0].tracks[0]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + + in_: 18.0, out: 9.0 + + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + + in_: 16.0, out: 1.0 + + 1002 group (session.mixers[0]:devices) + + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + + in_: 16.0, out: 3.0 + + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ), + (1, 0, ""), + ( + 1, + 1, + """ + --- initial + +++ mutation + @@ -21,26 +21,26 @@ + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + - NODE TREE 1014 group (session.mixers[1]:group) + - 1015 group (session.mixers[1]:tracks) + - 1020 group (session.mixers[1].tracks[0]:group) + - 1021 group (session.mixers[1].tracks[0]:tracks) + - 1024 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + - in_: 22.0, out: 18.0 + - 1022 group (session.mixers[1].tracks[0]:devices) + - 1023 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + - active: c16, bus: 22.0, gain: c17, gate: 1.0 + - 1025 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + - in_: 22.0, out: 20.0 + - 1026 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + - active: c16, gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + - 1018 supriya:meters:2 (session.mixers[1]:input-levels) + - in_: 20.0, out: 12.0 + - 1016 group (session.mixers[1]:devices) + - 1017 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + - active: 1.0, bus: 20.0, gain: c11, gate: 1.0 + - 1019 supriya:meters:2 (session.mixers[1]:output-levels) + - in_: 20.0, out: 14.0 + - 1027 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + - active: 1.0, gain: 0.0, gate: 1.0, in_: 20.0, out: 0.0 + -+ + + NODE TREE 1000 group (session.mixers[1]:group) + + 1001 group (session.mixers[1]:tracks) + + 1006 group (session.mixers[1].tracks[0]:group) + + 1007 group (session.mixers[1].tracks[0]:tracks) + + 1010 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + + in_: 18.0, out: 7.0 + + 1008 group (session.mixers[1].tracks[0]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1011 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + + in_: 18.0, out: 9.0 + + 1012 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + + 1004 supriya:meters:2 (session.mixers[1]:input-levels) + + in_: 16.0, out: 1.0 + + 1002 group (session.mixers[1]:devices) + + 1003 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + + 1005 supriya:meters:2 (session.mixers[1]:output-levels) + + in_: 16.0, out: 3.0 + + 1013 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ), + ], +) +@pytest.mark.asyncio +async def test_Session_set_mixer_context( + context_index: int, + expected_diff: str, + mixer_index: int, + online: bool, + session: Session, +) -> None: + # Pre-conditions + if online: + await session.boot() + await session.add_mixer() + await session.add_context() + assert len(session.contexts) == 2 + assert len(session.mixers) == 2 + if online: + await debug_tree(session) + # Operation + await session.set_mixer_context( + mixer=session.mixers[mixer_index], context=session.contexts[context_index] + ) + # Post-conditions + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1014 group (session.mixers[1]:group) + 1015 group (session.mixers[1]:tracks) + 1020 group (session.mixers[1].tracks[0]:group) + 1021 group (session.mixers[1].tracks[0]:tracks) + 1024 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + in_: 22.0, out: 18.0 + 1022 group (session.mixers[1].tracks[0]:devices) + 1023 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + active: c16, bus: 22.0, gain: c17, gate: 1.0 + 1025 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + in_: 22.0, out: 20.0 + 1026 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + active: c16, gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1018 supriya:meters:2 (session.mixers[1]:input-levels) + in_: 20.0, out: 12.0 + 1016 group (session.mixers[1]:devices) + 1017 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + active: 1.0, bus: 20.0, gain: c11, gate: 1.0 + 1019 supriya:meters:2 (session.mixers[1]:output-levels) + in_: 20.0, out: 14.0 + 1027 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 20.0, out: 0.0 + + """, + ) diff --git a/tests/mixers/test_Track.py b/tests/mixers/test_Track.py new file mode 100644 index 000000000..a306a6074 --- /dev/null +++ b/tests/mixers/test_Track.py @@ -0,0 +1,1525 @@ +from typing import Dict, List, Optional, Union + +import pytest + +from supriya import OscBundle, OscMessage +from supriya.mixers import Session +from supriya.mixers.mixers import Mixer +from supriya.mixers.synthdefs import DEVICE_DC_TESTER_2 +from supriya.mixers.tracks import Track, TrackContainer +from supriya.typing import DEFAULT, Default + +from .conftest import assert_diff, capture, debug_tree, does_not_raise + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [ + OscMessage("/d_recv", DEVICE_DC_TESTER_2.compile()), + OscMessage("/sync", 2), + OscBundle( + contents=( + OscMessage("/g_new", 1014, 1, 1008), + OscMessage( + "/s_new", "supriya:device-dc-tester:2", 1015, 1, 1014 + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,9 @@ + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + + 1014 group (session.mixers[0].tracks[0].devices[0]:group) + + 1015 supriya:device-dc-tester:2 (session.mixers[0].tracks[0].devices[0]:synth) + + dc: 1.0, out: 0.0 + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + """, + ), + ], +) +@pytest.mark.asyncio +async def test_Track_add_device( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + device = await track.add_device() + # Post-conditions + assert device in track.devices + assert device.parent is track + assert track.devices[0] is device + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "postfader, source, target, expected_commands, expected_diff", + [ + ( + True, + "self", + "mixer", + [ + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1021, + 3, + 1009, + "active", + "c5", + "in_", + 18.0, + "out", + 16.0, + ) + ], + """ + --- initial + +++ mutation + @@ -8,6 +8,8 @@ + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1021 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + """, + ), + ( + True, + "self", + "other", + [ + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1021, + 3, + 1009, + "active", + "c5", + "in_", + 18.0, + "out", + 20.0, + ) + ], + """ + --- initial + +++ mutation + @@ -8,6 +8,8 @@ + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1021 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + """, + ), + ( + False, + "self", + "other", + [ + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1021, + 2, + 1009, + "active", + "c5", + "in_", + 18.0, + "out", + 20.0, + ) + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,8 @@ + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + + 1021 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + """, + ), + ( + False, + "self", + "self", + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1021, + 0, + 1006, + "active", + "c5", + "in_", + 22.0, + "out", + 18.0, + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1022, + 2, + 1009, + "active", + "c5", + "in_", + 18.0, + "out", + 22.0, + ), + ], + """ + --- initial + +++ mutation + @@ -2,10 +2,14 @@ + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + + 1021 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].feedback:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 22.0, out: 18.0 + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + + 1022 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + """, + ), + ( + True, + "other", + "self", + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1021, + 0, + 1006, + "active", + "c5", + "in_", + 22.0, + "out", + 18.0, + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1022, + 3, + 1017, + "active", + "c11", + "in_", + 20.0, + "out", + 22.0, + ), + ], + """ + --- initial + +++ mutation + @@ -2,6 +2,8 @@ + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + + 1021 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].feedback:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 22.0, out: 18.0 + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + @@ -19,6 +21,8 @@ + 1016 group (session.mixers[0].tracks[1]:devices) + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + + 1022 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].sends[0]:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 22.0 + 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 20.0, out: 15.0 + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + """, + ), + ], +) +@pytest.mark.asyncio +async def test_Track_add_send( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + postfader: bool, + session: Session, + source: str, + target: str, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + targets: Dict[str, TrackContainer] = { + "mixer": mixer, + "other": await mixer.add_track(), + "self": track, + } + source_ = targets[source] + target_ = targets[target] + assert isinstance(source_, Track) + if online: + await debug_tree(session) + # Operation + with capture(mixer.context) as commands: + send = await source_.add_send(postfader=postfader, target=target_) + # Post-conditions + assert send in source_.sends + assert send.parent is source_ + assert send.postfader == postfader + assert send.target is target_ + assert source_.sends[0] is send + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1014 group (session.mixers[0].tracks[1]:group) + 1015 group (session.mixers[0].tracks[1]:tracks) + 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 20.0, out: 13.0 + 1016 group (session.mixers[0].tracks[1]:devices) + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 20.0, out: 15.0 + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.asyncio +async def test_Track_add_track( + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + child_track = await track.add_track() + # Post-conditions + assert child_track in track.tracks + assert child_track.parent is track + assert track.tracks[0] is child_track + if not online: + return + await assert_diff( + session, + expected_diff=""" + --- initial + +++ mutation + @@ -3,6 +3,17 @@ + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + + 1014 group (session.mixers[0].tracks[0].tracks[0]:group) + + 1015 group (session.mixers[0].tracks[0].tracks[0]:tracks) + + 1018 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:input-levels) + + in_: 20.0, out: 13.0 + + 1016 group (session.mixers[0].tracks[0].tracks[0]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel-strip) + + active: c11, bus: 20.0, gain: c12, gate: 1.0 + + 1019 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + + in_: 20.0, out: 15.0 + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + """, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == [ + OscBundle( + contents=( + OscMessage("/c_set", 11, 1.0, 12, 0.0), + OscMessage("/c_fill", 13, 2, 0.0, 15, 2, 0.0), + OscMessage("/g_new", 1014, 1, 1007, 1015, 0, 1014, 1016, 1, 1014), + OscMessage( + "/s_new", + "supriya:channel-strip:2", + 1017, + 1, + 1014, + "active", + "c11", + "bus", + 20.0, + "gain", + "c12", + ), + OscMessage( + "/s_new", + "supriya:meters:2", + 1018, + 3, + 1015, + "in_", + 20.0, + "out", + 13.0, + ), + OscMessage( + "/s_new", + "supriya:meters:2", + 1019, + 3, + 1017, + "in_", + 20.0, + "out", + 15.0, + ), + ), + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1020, + 1, + 1014, + "active", + "c11", + "in_", + 20.0, + "out", + 18.0, + ), + ] + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "target, expected_commands, expected_diff", + [ + ("parent", [], ""), + ("self", [], ""), + ("child", [], ""), + ("sibling", [], ""), + ], +) +@pytest.mark.asyncio +async def test_Track_delete( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + online: bool, + mixer: Mixer, + session: Session, + target: str, +) -> None: + # Pre-conditions + if online: + await session.boot() + targets: Dict[str, Track] = { + "parent": (parent := await mixer.add_track()), + "self": (track := await parent.add_track()), + "child": await track.add_track(), + "sibling": (sibling := await mixer.add_track()), + } + await sibling.set_output(track) + target_ = targets[target] + parent_ = target_.parent + # Operation + with capture(mixer.context) as commands: + await target_.delete() + # Post-conditions + assert parent_ and target_ not in parent_.children + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + """, + ) + assert commands == expected_commands + raise Exception + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "parent, index, maybe_raises, expected_graph_order, expected_diff, expected_commands", + [ + ("self", 0, pytest.raises(RuntimeError), (0, 0), "", []), + ("mixer", 0, does_not_raise, (0, 0), "", []), + ( + "mixer", + 1, + does_not_raise, + (0, 1), + """ + --- initial + +++ mutation + @@ -1,30 +1,30 @@ + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:group) + - 1007 group (session.mixers[0].tracks[0]:tracks) + - 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + + 1014 group (session.mixers[0].tracks[0]:group) + + 1015 group (session.mixers[0].tracks[0]:tracks) + + 1018 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + + in_: 20.0, out: 13.0 + + 1016 group (session.mixers[0].tracks[0]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + + active: c11, bus: 20.0, gain: c12, gate: 1.0 + + 1019 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + + in_: 20.0, out: 15.0 + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + + 1006 group (session.mixers[0].tracks[1]:group) + + 1007 group (session.mixers[0].tracks[1]:tracks) + + 1010 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 18.0, out: 7.0 + - 1008 group (session.mixers[0].tracks[0]:devices) + - 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + + 1008 group (session.mixers[0].tracks[1]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + - 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].sends[0]:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + + 1011 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 18.0, out: 9.0 + - 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1014 group (session.mixers[0].tracks[1]:group) + - 1015 group (session.mixers[0].tracks[1]:tracks) + - 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + - in_: 20.0, out: 13.0 + - 1016 group (session.mixers[0].tracks[1]:devices) + - 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + - active: c11, bus: 20.0, gain: c12, gate: 1.0 + - 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + - in_: 20.0, out: 15.0 + - 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1021 group (session.mixers[0].tracks[2]:group) + 1022 group (session.mixers[0].tracks[2]:tracks) + 1025 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + """, + [OscMessage("/n_after", 1006, 1014)], + ), + ( + "mixer", + 2, + does_not_raise, + (0, 2), + """ + --- initial + +++ mutation + @@ -1,41 +1,45 @@ + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:group) + - 1007 group (session.mixers[0].tracks[0]:tracks) + - 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + + 1014 group (session.mixers[0].tracks[0]:group) + + 1015 group (session.mixers[0].tracks[0]:tracks) + + 1018 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + + in_: 20.0, out: 13.0 + + 1016 group (session.mixers[0].tracks[0]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + + active: c11, bus: 20.0, gain: c12, gate: 1.0 + + 1019 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + + in_: 20.0, out: 15.0 + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + + 1021 group (session.mixers[0].tracks[1]:group) + + 1043 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[1].feedback:synth) + + active: c17, gain: 0.0, gate: 1.0, in_: 28.0, out: 22.0 + + 1022 group (session.mixers[0].tracks[1]:tracks) + + 1025 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + + in_: 22.0, out: 19.0 + + 1023 group (session.mixers[0].tracks[1]:devices) + + 1024 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + + active: c17, bus: 22.0, gain: c18, gate: 1.0 + + 1026 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + + in_: 22.0, out: 21.0 + + 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + + 1006 group (session.mixers[0].tracks[2]:group) + + 1007 group (session.mixers[0].tracks[2]:tracks) + + 1010 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + in_: 18.0, out: 7.0 + - 1008 group (session.mixers[0].tracks[0]:devices) + - 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + + 1008 group (session.mixers[0].tracks[2]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + - 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + + 1044 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 28.0 + + 1042 supriya:patch-cable:2x2 + + active: c5, gain: 0.0, gate: 0.0, in_: 18.0, out: 22.0 + + 1011 supriya:meters:2 (session.mixers[0].tracks[2]:output-levels) + in_: 18.0, out: 9.0 + - 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1014 group (session.mixers[0].tracks[1]:group) + - 1015 group (session.mixers[0].tracks[1]:tracks) + - 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + - in_: 20.0, out: 13.0 + - 1016 group (session.mixers[0].tracks[1]:devices) + - 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + - active: c11, bus: 20.0, gain: c12, gate: 1.0 + - 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + - in_: 20.0, out: 15.0 + - 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1021 group (session.mixers[0].tracks[2]:group) + - 1022 group (session.mixers[0].tracks[2]:tracks) + - 1025 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + - in_: 22.0, out: 19.0 + - 1023 group (session.mixers[0].tracks[2]:devices) + - 1024 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel-strip) + - active: c17, bus: 22.0, gain: c18, gate: 1.0 + - 1026 supriya:meters:2 (session.mixers[0].tracks[2]:output-levels) + - in_: 22.0, out: 21.0 + - 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + - active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + """, + [ + OscMessage("/n_after", 1006, 1021), + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1043, + 0, + 1021, + "active", + "c17", + "in_", + 28.0, + "out", + 22.0, + ), + OscBundle( + contents=( + OscMessage("/n_set", 1042, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1044, + 3, + 1009, + "active", + "c5", + "in_", + 18.0, + "out", + 28.0, + ), + ), + ), + ], + ), + ( + "other", + 0, + does_not_raise, + (0, 0, 2), + """ + --- initial + +++ mutation + @@ -1,40 +1,42 @@ + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:group) + - 1007 group (session.mixers[0].tracks[0]:tracks) + - 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + - in_: 18.0, out: 7.0 + - 1008 group (session.mixers[0].tracks[0]:devices) + - 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + - active: c5, bus: 18.0, gain: c6, gate: 1.0 + - 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + - in_: 18.0, out: 9.0 + - 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1014 group (session.mixers[0].tracks[1]:group) + - 1015 group (session.mixers[0].tracks[1]:tracks) + - 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + + 1014 group (session.mixers[0].tracks[0]:group) + + 1015 group (session.mixers[0].tracks[0]:tracks) + + 1006 group (session.mixers[0].tracks[0].tracks[0]:group) + + 1007 group (session.mixers[0].tracks[0].tracks[0]:tracks) + + 1010 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:input-levels) + + in_: 18.0, out: 7.0 + + 1008 group (session.mixers[0].tracks[0].tracks[0]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel-strip) + + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1011 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + + in_: 18.0, out: 9.0 + + 1012 supriya:patch-cable:2x2 + + active: c5, gain: 0.0, gate: 0.0, in_: 18.0, out: 16.0 + + 1043 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + + 1018 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 20.0, out: 13.0 + - 1016 group (session.mixers[0].tracks[1]:devices) + - 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + + 1016 group (session.mixers[0].tracks[0]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + - 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + + 1019 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1021 group (session.mixers[0].tracks[2]:group) + - 1022 group (session.mixers[0].tracks[2]:tracks) + - 1025 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + + 1021 group (session.mixers[0].tracks[1]:group) + + 1022 group (session.mixers[0].tracks[1]:tracks) + + 1025 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 22.0, out: 19.0 + - 1023 group (session.mixers[0].tracks[2]:devices) + - 1024 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel-strip) + + 1023 group (session.mixers[0].tracks[1]:devices) + + 1024 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c17, bus: 22.0, gain: c18, gate: 1.0 + - 1026 supriya:meters:2 (session.mixers[0].tracks[2]:output-levels) + + 1026 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 22.0, out: 21.0 + - 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + + 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + """, + [ + OscMessage("/g_head", 1015, 1006), + OscBundle( + contents=( + OscMessage("/n_set", 1012, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1043, + 1, + 1006, + "active", + "c5", + "in_", + 18.0, + "out", + 20.0, + ), + ), + ), + ], + ), + ( + "other_other", + 0, + does_not_raise, + (0, 1, 2), + """ + --- initial + +++ mutation + @@ -1,40 +1,42 @@ + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:group) + - 1007 group (session.mixers[0].tracks[0]:tracks) + - 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + - in_: 18.0, out: 7.0 + - 1008 group (session.mixers[0].tracks[0]:devices) + - 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + - active: c5, bus: 18.0, gain: c6, gate: 1.0 + - 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + - in_: 18.0, out: 9.0 + - 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + - active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1014 group (session.mixers[0].tracks[1]:group) + - 1015 group (session.mixers[0].tracks[1]:tracks) + - 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + + 1014 group (session.mixers[0].tracks[0]:group) + + 1015 group (session.mixers[0].tracks[0]:tracks) + + 1018 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 20.0, out: 13.0 + - 1016 group (session.mixers[0].tracks[1]:devices) + - 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + + 1016 group (session.mixers[0].tracks[0]:devices) + + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + - 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + + 1019 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1021 group (session.mixers[0].tracks[2]:group) + - 1022 group (session.mixers[0].tracks[2]:tracks) + - 1025 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + + 1021 group (session.mixers[0].tracks[1]:group) + + 1022 group (session.mixers[0].tracks[1]:tracks) + + 1006 group (session.mixers[0].tracks[1].tracks[0]:group) + + 1007 group (session.mixers[0].tracks[1].tracks[0]:tracks) + + 1010 supriya:meters:2 (session.mixers[0].tracks[1].tracks[0]:input-levels) + + in_: 18.0, out: 7.0 + + 1008 group (session.mixers[0].tracks[1].tracks[0]:devices) + + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[1].tracks[0]:channel-strip) + + active: c5, bus: 18.0, gain: c6, gate: 1.0 + + 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].sends[0]:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1011 supriya:meters:2 (session.mixers[0].tracks[1].tracks[0]:output-levels) + + in_: 18.0, out: 9.0 + + 1012 supriya:patch-cable:2x2 + + active: c5, gain: 0.0, gate: 0.0, in_: 18.0, out: 16.0 + + 1043 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].output:synth) + + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1025 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 22.0, out: 19.0 + - 1023 group (session.mixers[0].tracks[2]:devices) + - 1024 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel-strip) + + 1023 group (session.mixers[0].tracks[1]:devices) + + 1024 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c17, bus: 22.0, gain: c18, gate: 1.0 + - 1026 supriya:meters:2 (session.mixers[0].tracks[2]:output-levels) + + 1026 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 22.0, out: 21.0 + - 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + + 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + """, + [ + OscMessage("/g_head", 1022, 1006), + OscBundle( + contents=( + OscMessage("/n_set", 1012, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1043, + 1, + 1006, + "active", + "c5", + "in_", + 18.0, + "out", + 22.0, + ), + ), + ), + ], + ), + ("other_mixer", 0, pytest.raises(RuntimeError), (0, 0), "", []), + ], +) +@pytest.mark.asyncio +async def test_Track_move( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_graph_order: List[int], + expected_diff: str, + index: int, + mixer: Mixer, + online: bool, + parent: str, + maybe_raises, + session: Session, + track: Track, +) -> None: + # Pre-conditions + print("Pre-conditions") + if online: + await session.boot() + targets: Dict[str, TrackContainer] = { + "self": track, + "mixer": mixer, + "other": await mixer.add_track(), + "other_other": (other_other := await mixer.add_track()), + "other_mixer": await session.add_mixer(), + } + await track.add_send(target=other_other) + if online: + await debug_tree(session) + # Operation + print("Operation") + with maybe_raises, capture(mixer.context) as commands: + await track.move(index=index, parent=targets[parent]) + # Post-conditions + print("Post-conditions") + assert track.graph_order == expected_graph_order + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1042 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1012 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1014 group (session.mixers[0].tracks[1]:group) + 1015 group (session.mixers[0].tracks[1]:tracks) + 1018 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 20.0, out: 13.0 + 1016 group (session.mixers[0].tracks[1]:devices) + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1019 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 20.0, out: 15.0 + 1020 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1021 group (session.mixers[0].tracks[2]:group) + 1022 group (session.mixers[0].tracks[2]:tracks) + 1025 supriya:meters:2 (session.mixers[0].tracks[2]:input-levels) + in_: 22.0, out: 19.0 + 1023 group (session.mixers[0].tracks[2]:devices) + 1024 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel-strip) + active: c17, bus: 22.0, gain: c18, gate: 1.0 + 1026 supriya:meters:2 (session.mixers[0].tracks[2]:output-levels) + in_: 22.0, out: 21.0 + 1027 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1013 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1028 group (session.mixers[1]:group) + 1029 group (session.mixers[1]:tracks) + 1034 group (session.mixers[1].tracks[0]:group) + 1035 group (session.mixers[1].tracks[0]:tracks) + 1038 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + in_: 26.0, out: 30.0 + 1036 group (session.mixers[1].tracks[0]:devices) + 1037 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + active: c28, bus: 26.0, gain: c29, gate: 1.0 + 1039 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + in_: 26.0, out: 32.0 + 1040 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + active: c28, gain: 0.0, gate: 1.0, in_: 26.0, out: 24.0 + 1032 supriya:meters:2 (session.mixers[1]:input-levels) + in_: 24.0, out: 24.0 + 1030 group (session.mixers[1]:devices) + 1031 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + active: 1.0, bus: 24.0, gain: c23, gate: 1.0 + 1033 supriya:meters:2 (session.mixers[1]:output-levels) + in_: 24.0, out: 26.0 + 1041 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 24.0, out: 0.0 + """, + ) + assert commands == expected_commands + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize("expected_commands, expected_diff", [([], "")]) +@pytest.mark.asyncio +async def test_Track_set_active( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + await track.set_active() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + """, + ) + assert commands == expected_commands + raise Exception + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize("expected_commands, expected_diff", [([], "")]) +@pytest.mark.asyncio +async def test_Track_set_input( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + await track.set_input(None) + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + """, + ) + assert commands == expected_commands + raise Exception + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "output, maybe_raises, expected_commands, expected_diff", + [ + ( + "none", + does_not_raise, + [OscMessage("/n_set", 1025, "gate", 0.0)], + """ + --- initial + +++ mutation + @@ -23,8 +23,8 @@ + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1017 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1025 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1025 supriya:patch-cable:2x2 + + active: c11, gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + """, + ), + ( + "default", + does_not_raise, + [], + "", + ), + ( + "self", + pytest.raises(RuntimeError), + [], + "", + ), + ( + "parent", + does_not_raise, + [], + "", + ), + ( + "child", + does_not_raise, + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1049, + 0, + 1018, + "active", + "c17", + "in_", + 30.0, + "out", + 22.0, + ), + OscBundle( + contents=( + OscMessage("/n_set", 1025, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1050, + 1, + 1012, + "active", + "c11", + "in_", + 20.0, + "out", + 30.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,8 @@ + 1012 group (session.mixers[0].tracks[0].tracks[0]:group) + 1013 group (session.mixers[0].tracks[0].tracks[0]:tracks) + 1018 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:group) + + 1049 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].tracks[0].feedback:synth) + + active: c17, gain: 0.0, gate: 1.0, in_: 30.0, out: 22.0 + 1019 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:tracks) + 1022 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:input-levels) + in_: 22.0, out: 19.0 + @@ -23,8 +25,10 @@ + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1017 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1025 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1025 supriya:patch-cable:2x2 + + active: c11, gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1050 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 30.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + """, + ), + ( + "mixer", + does_not_raise, + [ + OscBundle( + contents=( + OscMessage("/n_set", 1025, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1049, + 1, + 1012, + "active", + "c11", + "in_", + 20.0, + "out", + 16.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -23,8 +23,10 @@ + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1017 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1025 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1025 supriya:patch-cable:2x2 + + active: c11, gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1049 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + """, + ), + ( + "sibling", + does_not_raise, + [ + OscBundle( + contents=( + OscMessage("/n_set", 1025, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1049, + 1, + 1012, + "active", + "c11", + "in_", + 20.0, + "out", + 24.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -23,8 +23,10 @@ + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1017 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + - 1025 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1025 supriya:patch-cable:2x2 + + active: c11, gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1049 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 24.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + """, + ), + ( + "other_mixer", + pytest.raises(RuntimeError), + [], + "", + ), + ], +) +@pytest.mark.asyncio +async def test_Track_set_output( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + maybe_raises, + mixer: Mixer, + online: bool, + output: str, + session: Session, + track: Track, +) -> None: + # Pre-conditions + subtrack = await track.add_track() + subsubtrack = await subtrack.add_track() + sibling = await mixer.add_track() + targets: Dict[str, Optional[Union[Default, TrackContainer]]] = { + "child": subsubtrack, + "default": DEFAULT, + "mixer": mixer, + "none": None, + "other_mixer": await session.add_mixer(), + "parent": track, + "self": subtrack, + "sibling": sibling, + } + if online: + await session.boot() + await debug_tree(session) + # Operation + with maybe_raises, capture(mixer.context) as commands: + await subtrack.set_output(targets[output]) + # Post-conditions + if not online: + return + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:group) + 1007 group (session.mixers[0].tracks[0]:tracks) + 1012 group (session.mixers[0].tracks[0].tracks[0]:group) + 1013 group (session.mixers[0].tracks[0].tracks[0]:tracks) + 1018 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:group) + 1019 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:tracks) + 1022 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:input-levels) + in_: 22.0, out: 19.0 + 1020 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:devices) + 1021 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:channel-strip) + active: c17, bus: 22.0, gain: c18, gate: 1.0 + 1023 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:output-levels) + in_: 22.0, out: 21.0 + 1024 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].tracks[0].output:synth) + active: c17, gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1016 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:input-levels) + in_: 20.0, out: 13.0 + 1014 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1015 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel-strip) + active: c11, bus: 20.0, gain: c12, gate: 1.0 + 1017 supriya:meters:2 (session.mixers[0].tracks[0].tracks[0]:output-levels) + in_: 20.0, out: 15.0 + 1025 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + active: c11, gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + 1010 supriya:meters:2 (session.mixers[0].tracks[0]:input-levels) + in_: 18.0, out: 7.0 + 1008 group (session.mixers[0].tracks[0]:devices) + 1009 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel-strip) + active: c5, bus: 18.0, gain: c6, gate: 1.0 + 1011 supriya:meters:2 (session.mixers[0].tracks[0]:output-levels) + in_: 18.0, out: 9.0 + 1026 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + active: c5, gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1027 group (session.mixers[0].tracks[1]:group) + 1028 group (session.mixers[0].tracks[1]:tracks) + 1031 supriya:meters:2 (session.mixers[0].tracks[1]:input-levels) + in_: 24.0, out: 25.0 + 1029 group (session.mixers[0].tracks[1]:devices) + 1030 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel-strip) + active: c23, bus: 24.0, gain: c24, gate: 1.0 + 1032 supriya:meters:2 (session.mixers[0].tracks[1]:output-levels) + in_: 24.0, out: 27.0 + 1033 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + active: c23, gain: 0.0, gate: 1.0, in_: 24.0, out: 16.0 + 1004 supriya:meters:2 (session.mixers[0]:input-levels) + in_: 16.0, out: 1.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel-strip) + active: 1.0, bus: 16.0, gain: c0, gate: 1.0 + 1005 supriya:meters:2 (session.mixers[0]:output-levels) + in_: 16.0, out: 3.0 + 1034 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1035 group (session.mixers[1]:group) + 1036 group (session.mixers[1]:tracks) + 1041 group (session.mixers[1].tracks[0]:group) + 1042 group (session.mixers[1].tracks[0]:tracks) + 1045 supriya:meters:2 (session.mixers[1].tracks[0]:input-levels) + in_: 28.0, out: 36.0 + 1043 group (session.mixers[1].tracks[0]:devices) + 1044 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel-strip) + active: c34, bus: 28.0, gain: c35, gate: 1.0 + 1046 supriya:meters:2 (session.mixers[1].tracks[0]:output-levels) + in_: 28.0, out: 38.0 + 1047 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + active: c34, gain: 0.0, gate: 1.0, in_: 28.0, out: 26.0 + 1039 supriya:meters:2 (session.mixers[1]:input-levels) + in_: 26.0, out: 30.0 + 1037 group (session.mixers[1]:devices) + 1038 supriya:channel-strip:2 (session.mixers[1]:channel-strip) + active: 1.0, bus: 26.0, gain: c29, gate: 1.0 + 1040 supriya:meters:2 (session.mixers[1]:output-levels) + in_: 26.0, out: 32.0 + 1048 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + active: 1.0, gain: 0.0, gate: 1.0, in_: 26.0, out: 0.0 + """, + ) + assert commands == expected_commands + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ([], ""), + ], +) +@pytest.mark.asyncio +async def test_Track_set_soloed( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + await track.set_soloed() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + """, + ) + assert commands == expected_commands + raise Exception + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ([], ""), + ], +) +@pytest.mark.asyncio +async def test_Track_ungroup( + expected_commands: List[Union[OscBundle, OscMessage]], + expected_diff: str, + mixer: Mixer, + online: bool, + session: Session, + track: Track, +) -> None: + # Pre-conditions + if online: + await session.boot() + # Operation + with capture(mixer.context) as commands: + await track.ungroup() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + expected_initial_tree=""" + """, + ) + assert commands == expected_commands + raise Exception