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/realtime.py b/supriya/contexts/realtime.py index 4ecacb071..3656b4638 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..5a9f54ee4 --- /dev/null +++ b/supriya/mixers/components.py @@ -0,0 +1,297 @@ +import asyncio +from typing import ( + TYPE_CHECKING, + Awaitable, + Dict, + Generator, + Generic, + Iterator, + List, + Literal, + Optional, + Set, + Tuple, + TypeAlias, + TypeVar, + cast, +) + +from ..contexts import AsyncServer, Buffer, BusGroup, Context, Group, Node +from ..contexts.responses import QueryTreeGroup +from ..enums import BootStatus, CalculationRate +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" + MAIN = "main" + OUTPUT = "output" + 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 + + def _allocate_deep(self, *, context: AsyncServer) -> None: + fifo: List[Tuple[Component, int]] = [] + for component in self._walk(): + if not component._allocate(context=context): + fifo.append((component, 0)) + 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 _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) -> Generator["Component", None, None]: + yield self + for child in self.children: + yield from child._walk() + + @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, + ) -> Optional[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, + ) -> Optional[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.get(name) + + def _get_control_bus( + self, + context: Optional[AsyncServer], + name: str, + can_allocate: bool = False, + channel_count: int = 1, + ) -> Optional[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..9ae22e3ab --- /dev/null +++ b/supriya/mixers/devices.py @@ -0,0 +1,54 @@ +from typing import List + +from ..contexts import AsyncServer +from ..enums import AddAction +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(): + 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 + + @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..0c2df8bcf --- /dev/null +++ b/supriya/mixers/mixers.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING, List, Optional, Tuple + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import DEFAULT, Default +from .components import AllocatableComponent, Component, ComponentNames +from .devices import DeviceContainer +from .routing import Connection +from .synthdefs import CHANNEL_STRIP_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 _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 + ) + target_node = context.default_group + with context.at(): + self._nodes[ComponentNames.GROUP] = group = target_node.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.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] = group.add_synth( + add_action=AddAction.ADD_TO_TAIL, + bus=main_audio_bus, + gain=gain_control_bus.map_symbol() if gain_control_bus else None, + synthdef=CHANNEL_STRIP_2, + ) + return True + + 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..ee8515f8b --- /dev/null +++ b/supriya/mixers/routing.py @@ -0,0 +1,244 @@ +import dataclasses +import enum +from typing import Generic, Optional, Tuple, TypeAlias, TypeVar, Union + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import Default +from .components import A, AllocatableComponent, ComponentNames +from .synthdefs import 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( + 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 _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..73fa18a14 --- /dev/null +++ b/supriya/mixers/sessions.py @@ -0,0 +1,212 @@ +import asyncio +import logging +from typing import TYPE_CHECKING, Dict, List, Optional + +from ..clocks import AsyncClock +from ..contexts import AsyncServer, ServerLifecycleEvent +from ..enums import BootStatus +from ..osc import find_free_port +from .components import Component +from .synthdefs import ( + CHANNEL_STRIP_2, + DEVICE_DC_TESTER_2, + FB_PATCH_CABLE_2_2, + PATCH_CABLE_2_2, +) + +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]] = { + (context := self._new_context()): [mixer := Mixer(parent=self)], + } + self._lock = asyncio.Lock() + self._mixers: Dict[Mixer, AsyncServer] = {mixer: context} + self._quit_future: Optional[asyncio.Future] = None + self._status = BootStatus.OFFLINE + + 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 _delete_mixer(self, mixer) -> None: + if mixer in (mixers := self._contexts.get(self._mixers.pop(mixer), [])): + mixers.remove(mixer) + + def _new_context(self) -> AsyncServer: + # TODO: We need a registration system (lazy) + async def add_synthdefs(event: ServerLifecycleEvent) -> None: + for synthdef in [ + CHANNEL_STRIP_2, + FB_PATCH_CABLE_2_2, + PATCH_CABLE_2_2, + DEVICE_DC_TESTER_2, + ]: + with context.at(): + context.add_synthdefs(synthdef) + await context.sync() + + context = AsyncServer() + context.on(ServerLifecycleEvent.BOOTED, add_synthdefs) + return context + + async def add_context(self) -> AsyncServer: + async with self._lock: + context = self._new_context() + self._contexts[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 = await self.add_context() + 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: + 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: + 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: + if mixer not in self._mixers: + raise ValueError(mixer) + elif context not in self._contexts: + raise ValueError(context) + if context is mixer._context: + return + async with self._lock: + self._contexts[self._mixers[mixer]].remove(mixer) + async with mixer._lock: + mixer._deallocate_deep() + if self._status == BootStatus.ONLINE: + 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..8323c5e91 --- /dev/null +++ b/supriya/mixers/synthdefs.py @@ -0,0 +1,101 @@ +from ..ugens import ( + DC, + In, + InFeedback, + Linen, + Out, + Parameter, + ReplaceOut, + SynthDef, + SynthDefBuilder, +) + +LAG_TIME = 0.005 + + +# TODO: When to allocate? +# TODO: Separate up/down mix sd? +# TODO: Separate levels sd? + + +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}") + + +CHANNEL_STRIP_2 = build_channel_strip(2) + + +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( + 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_"], + ) + free_gate = Linen.kr( + attack_time=LAG_TIME, + gate=builder["gate"], + release_time=LAG_TIME, + ) + source *= builder["gain"].db_to_amplitude() + source *= free_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) + + +FB_PATCH_CABLE_2_2 = build_patch_cable(2, 2, feedback=True) +PATCH_CABLE_2_2 = build_patch_cable(2, 2) + + +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}") + + +DEVICE_DC_TESTER_2 = build_device_dc_tester(2) diff --git a/supriya/mixers/tracks.py b/supriya/mixers/tracks.py new file mode 100644 index 000000000..0fbedb13b --- /dev/null +++ b/supriya/mixers/tracks.py @@ -0,0 +1,406 @@ +from typing import List, Optional, Tuple, Union, cast + +from ..contexts import AsyncServer, BusGroup +from ..enums import AddAction +from ..typing import DEFAULT, Default +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, 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(): + 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( + 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( + 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( + 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 + ) + gain_control_bus = self._get_control_bus( + context, name=ComponentNames.GAIN, can_allocate=True + ) + target_node = self.parent._nodes[ComponentNames.TRACKS] + with context.at(): + self._nodes[ComponentNames.GROUP] = group = target_node.add_group( + add_action=AddAction.ADD_TO_TAIL + ) + self._nodes[ComponentNames.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] = group.add_synth( + add_action=AddAction.ADD_TO_TAIL, + bus=main_audio_bus, + gain=gain_control_bus.map_symbol() if gain_control_bus else None, + synthdef=CHANNEL_STRIP_2, + ) + return True + + def _register_feedback( + self, context: Optional[AsyncServer], dependent: "Component" + ) -> Optional[BusGroup]: + super()._register_feedback(context, dependent) + # check if feedback should be setup + 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 activate(self) -> None: + async with self._lock: + pass + + 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(): + send._allocate_deep(context=context) + return send + + async def deactivate(self) -> None: + async with self._lock: + pass + + 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 probe(self) -> None: + # TODO: What is the right interface for this? + 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 solo(self) -> None: + async with self._lock: + pass + + async def ungroup(self) -> None: + async with self._lock: + pass + + async def unprobe(self) -> None: + # TODO: What is the right interface for this? + async with self._lock: + pass + + async def unsolo(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..17f5ee7d5 --- /dev/null +++ b/tests/mixers/conftest.py @@ -0,0 +1,59 @@ +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 assert_diff(session: Session, expected_diff: str, initial_tree: str) -> None: + await session.sync() + actual_tree = str(await session.dump_tree()).replace( + repr(session.contexts[0]), "{context}" + ) + print(f"initial tree:\n{normalize(initial_tree)}") + print(f"actual tree:\n{normalize(actual_tree)}") + actual_diff = "".join( + difflib.unified_diff( + normalize(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..83c4e7f1d --- /dev/null +++ b/tests/mixers/test_Mixer.py @@ -0,0 +1,158 @@ +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 + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [ + OscBundle( + contents=( + OscMessage( + "/g_new", 1010, 1, 1001, 1011, 0, 1010, 1012, 1, 1010 + ), + OscMessage( + "/s_new", + "supriya:channel-strip:2", + 1013, + 1, + 1010, + "bus", + 20.0, + "gain", + "c2", + ), + ), + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1014, + 1, + 1010, + "in_", + 20.0, + "out", + 16.0, + ), + ], + """ + --- initial + +++ mutation + @@ -8,6 +8,12 @@ + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + + 1010 group (session.mixers[0].tracks[1]:group) + + 1011 group (session.mixers[0].tracks[1]:tracks) + + 1012 group (session.mixers[0].tracks[1]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel_strip) + """, + ), + ], +) +@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() + # 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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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, + initial_tree=""" + {context} + """, + ) + 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..107cf3ceb --- /dev/null +++ b/tests/mixers/test_Session.py @@ -0,0 +1,318 @@ +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 + + +@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) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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) + 1004 group (session.mixers[1].tracks[0]:group) + 1005 group (session.mixers[1].tracks[0]:tracks) + 1006 group (session.mixers[1].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + 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) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1010 group (session.mixers[1]:group) + 1011 group (session.mixers[1]:tracks) + 1014 group (session.mixers[1].tracks[0]:group) + 1015 group (session.mixers[1].tracks[0]:tracks) + 1016 group (session.mixers[1].tracks[0]:devices) + 1017 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + 1018 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1012 group (session.mixers[1]:devices) + 1013 supriya:channel-strip:2 (session.mixers[1]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + 1019 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + 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, expectation", + [ + (False, pytest.raises(RuntimeError)), + (True, does_not_raise()), + ], +) +@pytest.mark.asyncio +async def test_Session_set_mixer_context( + expectation, online: bool, session: Session +) -> None: + # Pre-conditions + if online: + await session.boot() + mixer = await session.add_mixer() + await session.add_context() + # Operation + await session.set_mixer_context(mixer=mixer, context=session.contexts[1]) + # Post-conditions + assert len(session.contexts) == 2 + assert len(session.mixers) == 2 + 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) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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) + 1004 group (session.mixers[1].tracks[0]:group) + 1005 group (session.mixers[1].tracks[0]:tracks) + 1006 group (session.mixers[1].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """ + ) + # Operation + await session.set_mixer_context( + mixer=session.mixers[0], context=session.contexts[1] + ) + # Post-conditions + with expectation: + assert await session.dump_tree() == normalize( + f""" + {session.contexts[0]!r} + {session.contexts[1]!r} + NODE TREE 1000 group (session.mixers[1]:group) + 1001 group (session.mixers[1]:tracks) + 1004 group (session.mixers[1].tracks[0]:group) + 1005 group (session.mixers[1].tracks[0]:tracks) + 1006 group (session.mixers[1].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1010 group (session.mixers[0]:group) + 1011 group (session.mixers[0]:tracks) + 1014 group (session.mixers[0].tracks[0]:group) + 1015 group (session.mixers[0].tracks[0]:tracks) + 1016 group (session.mixers[0].tracks[0]:devices) + 1017 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + 1018 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1012 group (session.mixers[0]:devices) + 1013 supriya:channel-strip:2 (session.mixers[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + 1019 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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..4686a2a2f --- /dev/null +++ b/tests/mixers/test_Track.py @@ -0,0 +1,1503 @@ +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.tracks import Track, TrackContainer +from supriya.typing import DEFAULT, Default + +from .conftest import assert_diff, capture, does_not_raise + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [], + """ + """, + ), + ], +) +@pytest.mark.asyncio +async def test_Track_activate( + 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.activate() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + raise Exception + + +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ( + [ + OscBundle( + contents=( + OscMessage("/g_new", 1010, 1, 1006), + OscMessage( + "/s_new", "supriya:device-dc-tester:2", 1011, 1, 1010 + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -4,6 +4,9 @@ + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + + 1010 group (session.mixers[0].tracks[0].devices[0]:group) + + 1011 supriya:device-dc-tester:2 (session.mixers[0].tracks[0].devices[0]:synth) + + dc: 1.0, out: 0.0 + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + """, + ), + ], +) +@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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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", + 1015, + 3, + 1007, + "in_", + 18.0, + "out", + 16.0, + ) + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,8 @@ + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + + 1015 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1010 group (session.mixers[0].tracks[1]:group) + """, + ), + ( + True, + "self", + "other", + [ + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1015, + 3, + 1007, + "in_", + 18.0, + "out", + 20.0, + ) + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,8 @@ + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + + 1015 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1010 group (session.mixers[0].tracks[1]:group) + """, + ), + ( + False, + "self", + "other", + [ + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1015, + 2, + 1007, + "in_", + 18.0, + "out", + 20.0, + ) + ], + """ + --- initial + +++ mutation + @@ -4,6 +4,8 @@ + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + + 1015 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + """, + ), + ( + False, + "self", + "self", + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1015, + 0, + 1004, + "in_", + 22.0, + "out", + 18.0, + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1016, + 2, + 1007, + "in_", + 18.0, + "out", + 22.0, + ), + ], + """ + --- initial + +++ mutation + @@ -2,8 +2,12 @@ + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + + 1015 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].feedback:synth) + + gain: 0.0, gate: 1.0, in_: 22.0, out: 18.0 + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + + 1016 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + """, + ), + ( + True, + "other", + "self", + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1015, + 0, + 1004, + "in_", + 22.0, + "out", + 18.0, + ), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1016, + 3, + 1013, + "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) + 1004 group (session.mixers[0].tracks[0]:group) + + 1015 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].feedback:synth) + + gain: 0.0, gate: 1.0, in_: 22.0, out: 18.0 + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + @@ -13,6 +15,8 @@ + 1012 group (session.mixers[0].tracks[1]:devices) + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + + 1016 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 22.0 + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1002 group (session.mixers[0]:devices) + """, + ), + ], +) +@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) + # 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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1010 group (session.mixers[0].tracks[1]:group) + 1011 group (session.mixers[0].tracks[1]:tracks) + 1012 group (session.mixers[0].tracks[1]:devices) + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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,13 @@ + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + + 1010 group (session.mixers[0].tracks[0].tracks[0]:group) + + 1011 group (session.mixers[0].tracks[0].tracks[0]:tracks) + + 1012 group (session.mixers[0].tracks[0].tracks[0]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + """, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == [ + OscBundle( + contents=( + OscMessage("/g_new", 1010, 1, 1005, 1011, 0, 1010, 1012, 1, 1010), + OscMessage( + "/s_new", + "supriya:channel-strip:2", + 1013, + 1, + 1010, + "bus", + 20.0, + "gain", + "c2", + ), + ), + ), + OscMessage( + "/s_new", "supriya:patch-cable:2x2", 1014, 1, 1010, "in_", 20.0, "out", 18.0 + ), + ] + + +@pytest.mark.xfail +@pytest.mark.parametrize("online", [False, True]) +@pytest.mark.parametrize( + "expected_commands, expected_diff", + [ + ([], ""), + ], +) +@pytest.mark.asyncio +async def test_Track_deactivate( + 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.deactivate() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + raise Exception + + +@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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: 0.0, gate: 1.0 + 1007 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1008 group (session.mixers[0].tracks[1]:group) + 1009 group (session.mixers[0].tracks[1]:tracks) + 1012 group (session.mixers[0].tracks[1].tracks[0]:group) + 1013 group (session.mixers[0].tracks[1].tracks[0]:tracks) + 1016 group (session.mixers[0].tracks[1].tracks[0].tracks[0]:group) + 1017 group (session.mixers[0].tracks[1].tracks[0].tracks[0]:tracks) + 1018 supriya:channel-strip:2 (session.mixers[0].tracks[1].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 24.0, gain: 0.0, gate: 1.0 + 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 24.0, out: 22.0 + 1014 supriya:channel-strip:2 (session.mixers[0].tracks[1].tracks[0]:channel_strip) + active: 1.0, bus: 22.0, gain: 0.0, gate: 1.0 + 1015 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1010 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 20.0, gain: 0.0, gate: 1.0 + 1011 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1020 group (session.mixers[0].tracks[2]:group) + 1021 group (session.mixers[0].tracks[2]:tracks) + 1022 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + active: 1.0, bus: 26.0, gain: 0.0, gate: 1.0 + 1023 supriya:patch-cable:2x2 + gain: 0.0, gate: 0.0, in_: 26.0, out: 16.0 + 1024 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + gain: 0.0, gate: 1.0, in_: 26.0, out: 22.0 + 1002 supriya:channel-strip:2 (session.mixers[0]:channel_strip) + active: 1.0, bus: 16.0, gain: 0.0, gate: 1.0 + 1003 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + 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( + "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,22 +1,22 @@ + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1004 group (session.mixers[0].tracks[0]:group) + - 1005 group (session.mixers[0].tracks[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:devices) + - 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + + 1010 group (session.mixers[0].tracks[0]:group) + + 1011 group (session.mixers[0].tracks[0]:tracks) + + 1012 group (session.mixers[0].tracks[0]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + + 1004 group (session.mixers[0].tracks[1]:group) + + 1005 group (session.mixers[0].tracks[1]:tracks) + + 1006 group (session.mixers[0].tracks[1]:devices) + + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + - 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + + 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].sends[0]:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1010 group (session.mixers[0].tracks[1]:group) + - 1011 group (session.mixers[0].tracks[1]:tracks) + - 1012 group (session.mixers[0].tracks[1]:devices) + - 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + - active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1015 group (session.mixers[0].tracks[2]:group) + 1016 group (session.mixers[0].tracks[2]:tracks) + 1017 group (session.mixers[0].tracks[2]:devices) + """, + [OscMessage("/n_after", 1004, 1010)], + ), + ( + "mixer", + 2, + does_not_raise, + (0, 2), + """ + --- initial + +++ mutation + @@ -1,29 +1,33 @@ + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1004 group (session.mixers[0].tracks[0]:group) + - 1005 group (session.mixers[0].tracks[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:devices) + - 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + + 1010 group (session.mixers[0].tracks[0]:group) + + 1011 group (session.mixers[0].tracks[0]:tracks) + + 1012 group (session.mixers[0].tracks[0]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + + 1015 group (session.mixers[0].tracks[1]:group) + + 1031 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[1].feedback:synth) + + gain: 0.0, gate: 1.0, in_: 28.0, out: 22.0 + + 1016 group (session.mixers[0].tracks[1]:tracks) + + 1017 group (session.mixers[0].tracks[1]:devices) + + 1018 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + + 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + + 1004 group (session.mixers[0].tracks[2]:group) + + 1005 group (session.mixers[0].tracks[2]:tracks) + + 1006 group (session.mixers[0].tracks[2]:devices) + + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + - 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + + 1032 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 28.0 + + 1030 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 18.0, out: 22.0 + + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1010 group (session.mixers[0].tracks[1]:group) + - 1011 group (session.mixers[0].tracks[1]:tracks) + - 1012 group (session.mixers[0].tracks[1]:devices) + - 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + - active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1015 group (session.mixers[0].tracks[2]:group) + - 1016 group (session.mixers[0].tracks[2]:tracks) + - 1017 group (session.mixers[0].tracks[2]:devices) + - 1018 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + - active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + - 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + - gain: 0.0, gate: 1.0, in_: 22.0, out: 16.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 + """, + [ + OscMessage("/n_after", 1004, 1015), + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1031, + 0, + 1015, + "in_", + 28.0, + "out", + 22.0, + ), + OscBundle( + contents=( + OscMessage("/n_set", 1030, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1032, + 3, + 1007, + "in_", + 18.0, + "out", + 28.0, + ), + ), + ), + ], + ), + ( + "other", + 0, + does_not_raise, + (0, 0, 2), + """ + --- initial + +++ mutation + @@ -1,28 +1,30 @@ + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1004 group (session.mixers[0].tracks[0]:group) + - 1005 group (session.mixers[0].tracks[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:devices) + - 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + - active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + - 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1010 group (session.mixers[0].tracks[1]:group) + - 1011 group (session.mixers[0].tracks[1]:tracks) + - 1012 group (session.mixers[0].tracks[1]:devices) + - 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + + 1010 group (session.mixers[0].tracks[0]:group) + + 1011 group (session.mixers[0].tracks[0]:tracks) + + 1004 group (session.mixers[0].tracks[0].tracks[0]:group) + + 1005 group (session.mixers[0].tracks[0].tracks[0]:tracks) + + 1006 group (session.mixers[0].tracks[0].tracks[0]:devices) + + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + + 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1008 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 18.0, out: 16.0 + + 1031 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 20.0 + + 1012 group (session.mixers[0].tracks[0]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1015 group (session.mixers[0].tracks[2]:group) + - 1016 group (session.mixers[0].tracks[2]:tracks) + - 1017 group (session.mixers[0].tracks[2]:devices) + - 1018 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + + 1015 group (session.mixers[0].tracks[1]:group) + + 1016 group (session.mixers[0].tracks[1]:tracks) + + 1017 group (session.mixers[0].tracks[1]:devices) + + 1018 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + - 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + + 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel_strip) + """, + [ + OscMessage("/g_head", 1011, 1004), + OscBundle( + contents=( + OscMessage("/n_set", 1008, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1031, + 1, + 1004, + "in_", + 18.0, + "out", + 20.0, + ), + ), + ), + ], + ), + ( + "other_other", + 0, + does_not_raise, + (0, 1, 2), + """ + --- initial + +++ mutation + @@ -1,28 +1,30 @@ + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + - 1004 group (session.mixers[0].tracks[0]:group) + - 1005 group (session.mixers[0].tracks[0]:tracks) + - 1006 group (session.mixers[0].tracks[0]:devices) + - 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + - active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + - 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + - gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + - 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + - 1010 group (session.mixers[0].tracks[1]:group) + - 1011 group (session.mixers[0].tracks[1]:tracks) + - 1012 group (session.mixers[0].tracks[1]:devices) + - 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + + 1010 group (session.mixers[0].tracks[0]:group) + + 1011 group (session.mixers[0].tracks[0]:tracks) + + 1012 group (session.mixers[0].tracks[0]:devices) + + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + - 1015 group (session.mixers[0].tracks[2]:group) + - 1016 group (session.mixers[0].tracks[2]:tracks) + - 1017 group (session.mixers[0].tracks[2]:devices) + - 1018 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + + 1015 group (session.mixers[0].tracks[1]:group) + + 1016 group (session.mixers[0].tracks[1]:tracks) + + 1004 group (session.mixers[0].tracks[1].tracks[0]:group) + + 1005 group (session.mixers[0].tracks[1].tracks[0]:tracks) + + 1006 group (session.mixers[0].tracks[1].tracks[0]:devices) + + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[1].tracks[0]:channel_strip) + + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + + 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].sends[0]:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1008 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 18.0, out: 16.0 + + 1031 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + + 1017 group (session.mixers[0].tracks[1]:devices) + + 1018 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + - 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + + 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 16.0 + 1002 group (session.mixers[0]:devices) + 1003 supriya:channel-strip:2 (session.mixers[0]:channel_strip) + """, + [ + OscMessage("/g_head", 1016, 1004), + OscBundle( + contents=( + OscMessage("/n_set", 1008, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1031, + 1, + 1004, + "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) + # 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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1030 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].sends[0]:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 22.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1010 group (session.mixers[0].tracks[1]:group) + 1011 group (session.mixers[0].tracks[1]:tracks) + 1012 group (session.mixers[0].tracks[1]:devices) + 1013 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + 1014 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1015 group (session.mixers[0].tracks[2]:group) + 1016 group (session.mixers[0].tracks[2]:tracks) + 1017 group (session.mixers[0].tracks[2]:devices) + 1018 supriya:channel-strip:2 (session.mixers[0].tracks[2]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + 1019 supriya:patch-cable:2x2 (session.mixers[0].tracks[2].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1020 group (session.mixers[1]:group) + 1021 group (session.mixers[1]:tracks) + 1024 group (session.mixers[1].tracks[0]:group) + 1025 group (session.mixers[1].tracks[0]:tracks) + 1026 group (session.mixers[1].tracks[0]:devices) + 1027 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 26.0, gain: c5, gate: 1.0 + 1028 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 26.0, out: 24.0 + 1022 group (session.mixers[1]:devices) + 1023 supriya:channel-strip:2 (session.mixers[1]:channel_strip) + active: 1.0, bus: 24.0, gain: c4, gate: 1.0 + 1029 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + 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_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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + 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", 1017, "gate", 0.0)], + """ + --- initial + +++ mutation + @@ -15,8 +15,8 @@ + 1010 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1011 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1017 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1017 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + """, + ), + ( + "default", + does_not_raise, + [], + "", + ), + ( + "self", + pytest.raises(RuntimeError), + [], + "", + ), + ( + "parent", + does_not_raise, + [], + "", + ), + ( + "child", + does_not_raise, + [ + OscMessage( + "/s_new", + "supriya:fb-patch-cable:2x2", + 1035, + 0, + 1012, + "in_", + 30.0, + "out", + 22.0, + ), + OscBundle( + contents=( + OscMessage("/n_set", 1017, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1036, + 1, + 1008, + "in_", + 20.0, + "out", + 30.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -6,6 +6,8 @@ + 1008 group (session.mixers[0].tracks[0].tracks[0]:group) + 1009 group (session.mixers[0].tracks[0].tracks[0]:tracks) + 1012 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:group) + + 1035 supriya:fb-patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].tracks[0].feedback:synth) + + gain: 0.0, gate: 1.0, in_: 30.0, out: 22.0 + 1013 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:tracks) + 1014 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:devices) + 1015 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:channel_strip) + @@ -15,8 +17,10 @@ + 1010 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1011 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1017 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1017 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1036 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 30.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + """, + ), + ( + "mixer", + does_not_raise, + [ + OscBundle( + contents=( + OscMessage("/n_set", 1017, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1035, + 1, + 1008, + "in_", + 20.0, + "out", + 16.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -15,8 +15,10 @@ + 1010 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1011 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1017 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1017 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1035 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 16.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + """, + ), + ( + "sibling", + does_not_raise, + [ + OscBundle( + contents=( + OscMessage("/n_set", 1017, "gate", 0.0), + OscMessage( + "/s_new", + "supriya:patch-cable:2x2", + 1035, + 1, + 1008, + "in_", + 20.0, + "out", + 24.0, + ), + ), + ), + ], + """ + --- initial + +++ mutation + @@ -15,8 +15,10 @@ + 1010 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1011 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + - 1017 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + - gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + + 1017 supriya:patch-cable:2x2 + + gain: 0.0, gate: 0.0, in_: 20.0, out: 18.0 + + 1035 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + + gain: 0.0, gate: 1.0, in_: 20.0, out: 24.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + """, + ), + ( + "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() + if online or True: + await session.boot() + 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, + } + # 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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1008 group (session.mixers[0].tracks[0].tracks[0]:group) + 1009 group (session.mixers[0].tracks[0].tracks[0]:tracks) + 1012 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:group) + 1013 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:tracks) + 1014 group (session.mixers[0].tracks[0].tracks[0].tracks[0]:devices) + 1015 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 22.0, gain: c3, gate: 1.0 + 1016 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 22.0, out: 20.0 + 1010 group (session.mixers[0].tracks[0].tracks[0]:devices) + 1011 supriya:channel-strip:2 (session.mixers[0].tracks[0].tracks[0]:channel_strip) + active: 1.0, bus: 20.0, gain: c2, gate: 1.0 + 1017 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 20.0, out: 18.0 + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1018 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.0 + 1019 group (session.mixers[0].tracks[1]:group) + 1020 group (session.mixers[0].tracks[1]:tracks) + 1021 group (session.mixers[0].tracks[1]:devices) + 1022 supriya:channel-strip:2 (session.mixers[0].tracks[1]:channel_strip) + active: 1.0, bus: 24.0, gain: c4, gate: 1.0 + 1023 supriya:patch-cable:2x2 (session.mixers[0].tracks[1].output:synth) + gain: 0.0, gate: 1.0, in_: 24.0, out: 16.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 + 1024 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + NODE TREE 1025 group (session.mixers[1]:group) + 1026 group (session.mixers[1]:tracks) + 1029 group (session.mixers[1].tracks[0]:group) + 1030 group (session.mixers[1].tracks[0]:tracks) + 1031 group (session.mixers[1].tracks[0]:devices) + 1032 supriya:channel-strip:2 (session.mixers[1].tracks[0]:channel_strip) + active: 1.0, bus: 28.0, gain: c6, gate: 1.0 + 1033 supriya:patch-cable:2x2 (session.mixers[1].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 28.0, out: 26.0 + 1027 group (session.mixers[1]:devices) + 1028 supriya:channel-strip:2 (session.mixers[1]:channel_strip) + active: 1.0, bus: 26.0, gain: c5, gate: 1.0 + 1034 supriya:patch-cable:2x2 (session.mixers[1].output:synth) + 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_solo( + 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.solo() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + 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, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + 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_unsolo( + 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.unsolo() + # Post-conditions + if not online: + raise Exception + await assert_diff( + session, + expected_diff, + initial_tree=""" + {context} + NODE TREE 1000 group (session.mixers[0]:group) + 1001 group (session.mixers[0]:tracks) + 1004 group (session.mixers[0].tracks[0]:group) + 1005 group (session.mixers[0].tracks[0]:tracks) + 1006 group (session.mixers[0].tracks[0]:devices) + 1007 supriya:channel-strip:2 (session.mixers[0].tracks[0]:channel_strip) + active: 1.0, bus: 18.0, gain: c1, gate: 1.0 + 1008 supriya:patch-cable:2x2 (session.mixers[0].tracks[0].output:synth) + gain: 0.0, gate: 1.0, in_: 18.0, out: 16.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 + 1009 supriya:patch-cable:2x2 (session.mixers[0].output:synth) + gain: 0.0, gate: 1.0, in_: 16.0, out: 0.0 + """, + ) + assert commands == expected_commands + raise Exception