From 50df3ae58418a684f7b13e0db31c02a864a5889d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 17:33:10 +0200 Subject: [PATCH 1/2] Carried: the reader's choice onto the rebuilt record on the worker --- .../application/reconstruction-edits.md | 12 +- docs/development/application/stems.md | 2 +- .../logic/reconstruction/listening.py | 66 ++---- .../logic/reconstruction/manager.py | 6 +- .../logic/reconstruction/ownership.py | 2 +- .../services/regeneration/service.py | 22 +- .../reconstruction/stems/hearing.py | 201 +++++++++++++++++- .../reconstruction/rewrites/test_queue.py | 44 +++- .../reconstruction/rewrites/test_queue.py | 25 +++ .../logic/reconstruction/test_listening.py | 136 +++++++++--- .../logic/reconstruction/test_manager.py | 31 ++- .../services/test_regeneration.py | 29 ++- .../reconstruction/test_stems_hearing.py | 191 ++++++++++++++++- 13 files changed, 669 insertions(+), 98 deletions(-) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 8b4a2927..6d7787e6 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -81,10 +81,14 @@ the ribbon answer at once. The waveform and the audio are read from the render c they sound, and the envelopes the instruments panel draws are the ones the worker exported for the recordings heard; where the cache keeps no sound for the hearing, or the envelopes drawn were exported for another choice of recordings, the panel asks for a listening step, which renders the hearing of the -document as it then stands and lands like a rebuild. An edit whose new frames offer a row of their own -moves the choice the listening makes on the new record, so its landing reads the envelopes on the spot. One -listening waiting answers every choice made before it is taken, and a listening asked for during a -rebuild reads the rebuilt document. A freshly opened document is shown the same way, so its channels +document as it then stands and lands like a rebuild. The reader's choice travels onto a new record by one +rule, written in core and read by the worker and the listening state alike: a recording keeps what was +chosen for it where it keeps holding frames, is heard wherever it reaches for the first time, and the row +an edit's new frames offer joins what is heard where it appears, as a channel coming into play joins the +channels on. The worker carries the hearing onto the document it rebuilt before rendering and exporting, +so the landing installs sound and envelopes for the choice the listening then makes. One listening +waiting answers every choice made before it is taken, and a listening asked for during a rebuild reads +the rebuilt document. A freshly opened document is shown the same way, so its channels render on the worker too. The showing the panel owes happens once the sound lands, and a hearing the cache already answers is shown at once. A WAV export is a reading of what plays like a showing: it writes the kept sound at once, and where the cache keeps none it is owed beside the showing and written diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index d5258c69..9b38d724 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -36,7 +36,7 @@ The reconstruction tab's Stems card turns the recorded assignment into a listene ### What the code guarantees -A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and a channel every owner of which is heard keeps its render as it is, which is exact because a resting frame renders as silence already; a document heard whole therefore reads the kept renders and computes nothing. The original mix covers the recordings of the stems heard on any channel, and it is carried across an edit that keeps every recording. It is silence while no stem is heard, and there is none while no recording loaded. A surface paints a lane as one series per recording, traced over the stretches that recording holds, so a lane costs as many items as it has owners however many times the document divides, and a box on the stems card repaints as a theme bound again per recording. The listening state holds the channels each stem is heard on and the channels kept on, and the panel logic re-answers the stems view model, the waveform and the audio data whenever either choice changes. The waveform and the audio are read from the render cache under the hearing they sound, and the envelopes the instruments panel draws are the ones the regeneration worker exported for the recordings heard; a hearing the cache keeps no sound for, or no envelopes for, is rendered on that worker as a step of the open document's line ([Editing the open reconstruction](reconstruction-edits.md)), and the showing waits for it, so a box computes no sample and reads no frame on the render thread. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. +A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and a channel every owner of which is heard keeps its render as it is, which is exact because a resting frame renders as silence already; a document heard whole therefore reads the kept renders and computes nothing. The original mix covers the recordings of the stems heard on any channel, and it is carried across an edit that keeps every recording. It is silence while no stem is heard, and there is none while no recording loaded. A surface paints a lane as one series per recording, traced over the stretches that recording holds, so a lane costs as many items as it has owners however many times the document divides, and a box on the stems card repaints as a theme bound again per recording. The listening state holds the channels each stem is heard on and the channels kept on, carries both onto a new record by the core's one rule (`carried_hearing`, which the regeneration worker applies to the document it rebuilds), and the panel logic re-answers the stems view model, the waveform and the audio data whenever either choice changes. The waveform and the audio are read from the render cache under the hearing they sound, and the envelopes the instruments panel draws are the ones the regeneration worker exported for the recordings heard; a hearing the cache keeps no sound for, or no envelopes for, is rendered on that worker as a step of the open document's line ([Editing the open reconstruction](reconstruction-edits.md)), and the showing waits for it, so a box computes no sample and reads no frame on the render thread. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. A confirmed removal is a step of the open document's edits, taken after the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)), and it lands as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. They differ in what the instruments panel does. A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel keeps drawing them. A removal redraws every channel from the document it leaves. diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py index 2355a4c9..b7184834 100644 --- a/src/sampletones_application/logic/reconstruction/listening.py +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -1,9 +1,14 @@ -from typing import Dict, FrozenSet, Optional, Set +from typing import Dict, FrozenSet, Optional -from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + Hearing, + carried_channels_on, + carried_heard, + offered_channels, + selection_of, +) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection @@ -18,10 +23,12 @@ class Listening: channels kept on are the boxes above the waveform: a channel switched off keeps every choice made for its recordings, so switching it back on restores them intact. - The choice is the reader's, so it travels across an edit rather than being remade: a channel - a recording keeps holding frames on keeps whatever was chosen for it, one the recording - reaches for the first time joins heard, and a channel coming into play joins the channels on - while one kept playing keeps the reader's choice for it. + The choice is the reader's, so it travels across an edit by the core's one rule + (``carried_hearing``): a channel a recording keeps holding frames on keeps whatever was chosen + for it, one the recording reaches for the first time joins heard, and a channel coming into + play joins the channels on while one kept playing keeps the reader's choice for it. The + regeneration worker carries the choice onto the document it rebuilds by the same rule, so the + sound and the envelopes a landing installs are read for the choice this state then makes. Soloing a recording silences every other one and remembers the choice it replaced, so soloing it again returns to that choice. A choice made by hand afterwards stands as the new state. @@ -46,19 +53,8 @@ def heard(self) -> Dict[int, FrozenSet[ChannelName]]: @property def selection(self) -> StemSelection: - """What the reader hears, read the way the filters ask for it. - - The card keeps the channels each recording is heard on; a filter asks each channel which - recordings it keeps, so the selection is that map turned around. - """ - channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()} - for stem_id, stem_channels in self._heard.items(): - for channel_name in stem_channels: - channels[channel_name].add(stem_id) - - return StemSelection( - channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()} - ) + """What the reader hears, read the way the filters ask for it: the heard map turned around.""" + return selection_of(self._heard) def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: """The recordings heard on one channel, which is the scope an edit writes in.""" @@ -80,7 +76,7 @@ def hearing(self) -> Hearing: return Hearing(selection=self.selection, channels_on=self._channels_on) def adopt(self, stems_data: StemsData, playing: FrozenSet[ChannelName]) -> None: - """Carries the reader's choice onto the record as it now stands. + """Carries the reader's choice onto the record as it now stands, by the core's rule. A recording appearing for the first time offers everything it holds and is heard on all of it, which is the same rule read against the nothing it offered before, so a freshly @@ -92,14 +88,10 @@ def adopt(self, stems_data: StemsData, playing: FrozenSet[ChannelName]) -> None: playing: The channels the document plays. """ offered = offered_channels(stems_data) - self._heard = { - stem_id: (self._heard.get(stem_id, frozenset()) & channels) - | (channels - self._offered.get(stem_id, frozenset())) - for stem_id, channels in offered.items() - } + self._heard = carried_heard(self._heard, self._offered, offered) self._offered = offered self._heard_before_solo = None - self._channels_on = (self._channels_on & playing) | (playing - self._playing) + self._channels_on = carried_channels_on(self._channels_on, self._playing, playing) self._playing = playing def set_channels_on(self, channels: FrozenSet[ChannelName]) -> None: @@ -151,23 +143,3 @@ def _is_alone(self, stem_id: int) -> bool: return False return all(not channels for other_id, channels in self._heard.items() if other_id != stem_id) - - -def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]: - """The channels each recording holds frames on, which is what its row offers a box for. - - A recording the picker never chose on a channel contributes nothing there whatever the - reader ticks, so the row draws a box exactly where the choice reaches something. A recording - holding no frame anywhere has left the document, so every recording offers a box somewhere - once any of them holds a frame. The frames the reader wrote gather under the authored stem, - which offers its boxes the same way and appears exactly where it holds a frame. - """ - offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries} - for channel_name, stem_ids in stems_data.assignments_by_channel.items(): - for stem_id in set(stem_ids): - if stem_id == AUTHORED_STEM_ID: - offered.setdefault(AUTHORED_STEM_ID, set()).add(channel_name) - elif stem_id in offered: - offered[stem_id].add(channel_name) - - return {stem_id: frozenset(channels) for stem_id, channels in offered.items()} diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 9abfce7a..f22281a1 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -124,8 +124,10 @@ def _adopt_reconstruction( answer for the part the reader is listening to as it now stands. ``voice_id`` names the project sample the document is, and ``None`` a standalone document. ``envelopes`` are the ones a worker exported for the document, which spare the reading here where they were - exported for the choice the listening makes on the new record; a document arriving with - none, or with envelopes for a choice an edit's new frames moved, is read on the spot. + exported for the choice the listening makes on the new record, as a rebuild's are: the + worker carries the choice onto the document it rebuilds by the rule the listening carries + it by. A document arriving with none, or with envelopes for another choice, is read on + the spot. """ self._current_reconstruction = reconstruction_data self._voice_id = voice_id diff --git a/src/sampletones_application/logic/reconstruction/ownership.py b/src/sampletones_application/logic/reconstruction/ownership.py index 43284b84..1d3e84fe 100644 --- a/src/sampletones_application/logic/reconstruction/ownership.py +++ b/src/sampletones_application/logic/reconstruction/ownership.py @@ -1,6 +1,5 @@ from typing import AbstractSet, Callable, Dict, Final, Mapping, Sequence, Tuple -from sampletones_application.logic.reconstruction.listening import offered_channels from sampletones_application.view_model.shared.ownership import ( OwnershipLaneViewModel, OwnershipRunViewModel, @@ -9,6 +8,7 @@ from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.hearing import offered_channels from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun, heard_frame DISTINGUISHABLE_OWNERS: Final[int] = 2 diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 1787fcd0..bad826b7 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -18,7 +18,11 @@ from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import sounding_streams -from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + Hearing, + heard_renders, + hearing_carried_onto, +) class RegenerationService(ServiceBase[RegenerationResult]): @@ -26,7 +30,11 @@ class RegenerationService(ServiceBase[RegenerationResult]): An edit reaches the frames the reader hears on the channel, so the hearing travels with it: a frame of a recording the reader left out stands as it is, which is what lets one - recording's part be shaped while the recordings beside it carry on. + recording's part be shaped while the recordings beside it carry on. The result sounds that + hearing carried onto the rebuilt document by the core's rule, the one the listening state + carries it by at the landing: the reader's own row joins what is heard on a channel the edit + first writes frames of its own on, and a channel coming into play joins the channels on, so + the landing installs sound and envelopes for the choice the listening then makes. A rebuild's result is a fresh reconstruction carrying the updated generator data, with the audio of every sounding channel, their mix, what the hearing sounds of it, and the envelopes @@ -61,7 +69,7 @@ def start( channel_name: The channel rebuilt. features: The envelopes the channel is rebuilt from. hearing: What the reader hears, which names the recordings the rebuild writes on the - channel and the reading the result sounds. + channel and, carried onto the rebuilt document, the reading the result sounds. kept: The renders the caller holds for ``reconstruction``'s channels, by channel. Every channel but ``channel_name`` keeps its stream through the rebuild, so a render handed for it carries over into the result, and the worker renders the rest. A @@ -95,9 +103,11 @@ def _run(self, job: RenderJob) -> None: case RebuildJob(): document = self._rebuilt(job) edited: Optional[ChannelName] = job.channel_name + hearing = hearing_carried_onto(job.hearing, job.reconstruction, document) case ListeningJob(): document = job.reconstruction edited = None + hearing = job.hearing if SingleThreadExecutor.is_shutting_down(): return @@ -110,9 +120,9 @@ def _run(self, job: RenderJob) -> None: reconstruction=document, channels=channels, mix=mixed, - hearing=job.hearing, - heard=heard_renders(document, job.hearing, channels, mixed), - envelopes=document.export_heard(job.hearing.selection), + hearing=hearing, + heard=heard_renders(document, hearing, channels, mixed), + envelopes=document.export_heard(hearing.selection), ) ) ) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py index c49f8448..ee6938f7 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py @@ -1,16 +1,19 @@ from dataclasses import dataclass -from typing import FrozenSet, List, Mapping, Optional +from typing import AbstractSet, Dict, FrozenSet, List, Mapping, Optional, Set import numpy as np from pydantic import BaseModel, ConfigDict from sampletones_core.audio.mixing import align, mix +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstruction.stems.filter import filter_approximations from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection +HeardChannels = Mapping[int, FrozenSet[ChannelName]] + class Hearing(BaseModel): """What a reader hears of a document: the recordings heard on each channel, and the channels kept on. @@ -48,6 +51,202 @@ def filter_for( return None if heard == owners else heard +def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording holds frames on, which is what its row offers a box for. + + A recording the picker never chose on a channel contributes nothing there whatever the + reader ticks, so the row draws a box exactly where the choice reaches something. A recording + holding no frame anywhere has left the document, so every recording offers a box somewhere + once any of them holds a frame. The frames the reader wrote gather under the authored stem, + which offers its boxes the same way and appears exactly where it holds a frame. + + Args: + stems_data: The record naming the owner of each frame. + + Returns: + Dict[int, FrozenSet[ChannelName]]: The channels each recording of the record offers, and + the authored row's where it holds a frame. + """ + offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries} + for channel_name, stem_ids in stems_data.assignments_by_channel.items(): + for stem_id in set(stem_ids): + if stem_id == AUTHORED_STEM_ID: + offered.setdefault(AUTHORED_STEM_ID, set()).add(channel_name) + elif stem_id in offered: + offered[stem_id].add(channel_name) + + return {stem_id: frozenset(channels) for stem_id, channels in offered.items()} + + +def selection_of(heard: HeardChannels) -> StemSelection: + """What a reader hears, read the way the filters ask for it. + + A listening keeps the channels each recording is heard on; a filter asks each channel which + recordings it keeps, so the selection is that map turned around, with every channel a key. + + Args: + heard: The channels each recording is heard on. + + Returns: + StemSelection: The recordings heard on each channel. + """ + channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()} + for stem_id, stem_channels in heard.items(): + for channel_name in stem_channels: + channels[channel_name].add(stem_id) + + return StemSelection(channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()}) + + +def heard_by_stem( + selection: StemSelection, + offered: HeardChannels, +) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording of ``offered`` is heard on under ``selection``, the selection turned back around. + + Args: + selection: The recordings heard on each channel. + offered: The channels each recording holds frames on. + + Returns: + Dict[int, FrozenSet[ChannelName]]: One entry per recording offered, holding the channels + it offers that hear it. + """ + return { + stem_id: frozenset(channel_name for channel_name in channels if stem_id in selection.stems_for(channel_name)) + for stem_id, channels in offered.items() + } + + +def carried_heard( + heard: HeardChannels, + offered_before: HeardChannels, + offered_after: HeardChannels, +) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording is heard on once a record stands in place of the one the choice was made on. + + A recording keeps whatever was chosen for it on a channel it keeps holding frames on, and is + heard on every channel it reaches for the first time. Read against the nothing it offered + before, that hears a recording appearing for the first time on all it holds, so a freshly + opened document is heard whole and the frames a reader first writes on a channel join what is + heard there. A recording the new record leaves out leaves the choice with it. + + Args: + heard: The channels each recording is heard on, as the reader left them. + offered_before: The channels each recording offered on the record the choice was made on. + offered_after: The channels each recording offers on the record now standing. + + Returns: + Dict[int, FrozenSet[ChannelName]]: One entry per recording offered now. + """ + return { + stem_id: (heard.get(stem_id, frozenset()) & channels) | (channels - offered_before.get(stem_id, frozenset())) + for stem_id, channels in offered_after.items() + } + + +def carried_channels_on( + channels_on: AbstractSet[ChannelName], + playing_before: AbstractSet[ChannelName], + playing_after: AbstractSet[ChannelName], +) -> FrozenSet[ChannelName]: + """The channels kept on once the document plays ``playing_after``. + + A channel coming into play joins the channels on, one kept playing keeps the reader's choice + for it, and one leaving play leaves them. + + Args: + channels_on: The playing channels the reader kept on. + playing_before: The channels the document played when the choice was made. + playing_after: The channels the document plays now. + + Returns: + FrozenSet[ChannelName]: The channels on, every one of them playing. + """ + return frozenset((channels_on & playing_after) | (playing_after - playing_before)) + + +def carried_hearing( + hearing: Hearing, + *, + offered_before: HeardChannels, + playing_before: AbstractSet[ChannelName], + offered_after: HeardChannels, + playing_after: AbstractSet[ChannelName], +) -> Hearing: + """``hearing`` carried onto a record offering ``offered_after`` and a document playing ``playing_after``. + + One rule carries the reader's choice across an edit wherever a new record is met: the + regeneration worker applies it to the document it rebuilt, and the listening state to the + document that lands, so the two agree on what is heard and the sound and envelopes a landing + installs are the ones the listening then reads. + + Args: + hearing: What the reader heard of the document the choice was made on. + offered_before: The channels each recording offered on that document. + playing_before: The channels that document played. + offered_after: The channels each recording offers on the document now standing. + playing_after: The channels that document plays. + + Returns: + Hearing: What the reader hears of the document now standing. + """ + heard = heard_by_stem(hearing.selection, offered_before) + return Hearing( + selection=selection_of(carried_heard(heard, offered_before, offered_after)), + channels_on=carried_channels_on(hearing.channels_on, playing_before, playing_after), + ) + + +def hearing_carried_onto( + hearing: Hearing, + before: Reconstruction, + after: Reconstruction, +) -> Hearing: + """``hearing``, made on ``before``, carried onto ``after``. + + A rebuild's worker hears the document it leaves this way, so the reading it renders and the + envelopes it exports are the ones the listening asks for once the document lands. + + Args: + hearing: What the reader heard of ``before``. + before: The document the choice was made on. + after: The document now standing, such as the one a rebuild leaves. + + Returns: + Hearing: What the reader hears of ``after``. + """ + return carried_hearing( + hearing, + offered_before=offered_channels(before.stems_data), + playing_before=frozenset(before.playing_channels), + offered_after=offered_channels(after.stems_data), + playing_after=frozenset(after.playing_channels), + ) + + +def whole_hearing(reconstruction: Reconstruction) -> Hearing: + """Every recording and the reader's own row heard wherever they hold frames, every playing channel on. + + It is the carry from a record offering nothing and a document playing nothing, which is how a + freshly opened document is heard, so a reading taken for it keys the render cache the way the + listening state keys the same document. + + Args: + reconstruction: The document heard. + + Returns: + Hearing: The document heard whole. + """ + return carried_hearing( + Hearing(selection=StemSelection(channels={}), channels_on=frozenset()), + offered_before={}, + playing_before=frozenset(), + offered_after=offered_channels(reconstruction.stems_data), + playing_after=frozenset(reconstruction.playing_channels), + ) + + @dataclass(frozen=True) class HeardRenders: """The audio a hearing of a document sounds. diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py index 93779408..462caf9a 100644 --- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -1,6 +1,6 @@ import time from pathlib import Path -from typing import Final, Iterator, Tuple +from typing import Any, Final, Iterator, List, Tuple from unittest.mock import MagicMock import pytest @@ -18,6 +18,7 @@ from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.view_model.reconstruction.work import Work +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -38,6 +39,7 @@ DRAG: Final[Tuple[Tuple[int, int], ...]] = ((1, 1), (2, 2), (3, 3), (4, 4), (6, 6)) ARPEGGIO: Final[Tuple[int, int]] = (4, 3) RETUNED_FREQUENCY: Final[int] = 50 +LONGER_VOLUME: Final[Tuple[int, ...]] = (5, 5, 5) SETTLE_SECONDS: Final[float] = 10.0 POLL_SECONDS: Final[float] = 0.005 @@ -139,3 +141,43 @@ def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoor work = [entry.args[0] for entry in settled._tab.show_work.call_args_list] assert work == [Work.REBUILDING, None] + + +class TestTheFirstFramesOfTheReadersOwn: + """An edit first writing frames of the reader's own on a channel, beside a recording left out, lands in one + job: the worker carries the choice onto the rebuilt record, so the sound kept and the envelopes installed are + the ones the listening asks for, and the panel has nothing left to ask a listening step for. + """ + + def test_the_landing_answers_the_listening_in_one_job( + self, + coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + manager = coordinator._reconstruction_manager + manager.listening.set_channels(STEM_B_ID, frozenset()) + listenings: List[Reconstruction] = [] + service = coordinator._rewrites._regeneration + + def counted(reconstruction: Reconstruction, **kwargs: Any) -> None: + listenings.append(reconstruction) + RegenerationService.start_listening(service, reconstruction, **kwargs) + + monkeypatch.setattr(service, "start_listening", counted) + + coordinator.request_rewrite( + ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: Envelope[int](items=LONGER_VOLUME)}, + initial_pitch=None, + ) + ) + _settle(coordinator) + + document = _open_document(coordinator) + hearing = manager.listening.hearing + assert AUTHORED_STEM_ID in hearing.selection.stems_for(SHARED_CHANNEL) + assert manager.renders.reading(document, hearing) is not None + assert manager.has_envelopes_for(hearing.selection) + assert listenings == [] diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py index 1e9a909c..8eb43d96 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -21,6 +21,7 @@ ) from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob from sampletones_application.view_model.reconstruction.work import Work +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -42,6 +43,7 @@ FIRST_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5)) SECOND_VOLUME: Final[Envelope[int]] = Envelope[int](items=(4, 4)) LATER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3, 3)) +LONGER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5, 5)) SOLE_VOLUME: Final[Envelope[int]] = Envelope[int](items=(2,)) ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(0, 3)) RETUNED_FREQUENCY: Final[int] = 50 @@ -680,6 +682,29 @@ def test_a_landed_rebuild_carries_the_envelopes_its_worker_exported( assert edit.envelopes.selection == reconstruction_manager.listening.selection assert edit.envelopes.channels == edit.reconstruction.export_heard(edit.envelopes.selection) + def test_an_edit_first_writing_frames_of_the_readers_own_lands_heard_as_the_listening_hears_it( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + """The worker carries the choice onto the rebuilt record, so the one job's sound and envelopes are the + ones the listening reads after the landing, a recording muted beside the new row included. + """ + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LONGER_VOLUME)) + + regeneration.finish() + + (edit,) = outcome.edits + assert isinstance(edit, ChannelEdit) + hearing = reconstruction_manager.listening.hearing + assert AUTHORED_STEM_ID in hearing.selection.stems_for(SHARED_CHANNEL) + assert edit.envelopes.selection == hearing.selection + assert reconstruction_manager.renders.reading(edit.reconstruction, hearing) is not None + assert regeneration.held == () + def test_the_hearing_is_read_at_the_steps_turn( self, rewrites: ReconstructionRewrites, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py index b7900f37..72dc0e46 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py @@ -1,17 +1,29 @@ +from dataclasses import dataclass from typing import Final, FrozenSet, List import pytest -from sampletones_application.logic.reconstruction.listening import Listening, offered_channels +from sampletones_application.logic.reconstruction.listening import Listening from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + carried_hearing, + offered_channels, + whole_hearing, +) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings -from tests.suite.stems import RECORDED_SCALE +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.stems import RECORDED_SCALE, SHARED_CHANNEL, TAKING_TURNS_PITCH, taking_turns + +__all__ = ["taking_turns"] STEM_A: Final[int] = 0 STEM_B: Final[int] = 1 @@ -43,31 +55,6 @@ def listening() -> Listening: return Listening() -class TestWhatARecordingOffers: - def test_a_recording_offers_the_channels_it_holds_frames_on(self) -> None: - stems_data = _stems_data( - ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), - ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, RESTING_STEM_ID]), - ) - - offered = offered_channels(stems_data) - - assert offered[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) - assert offered[STEM_B] == frozenset({ChannelName.PULSE1}) - - def test_a_recording_holding_nothing_offers_nothing(self) -> None: - stems_data = _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A])) - - assert offered_channels(stems_data)[STEM_B] == frozenset() - - def test_the_frames_the_reader_wrote_offer_a_row_of_their_own(self) -> None: - stems_data = _stems_data( - ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, AUTHORED_STEM_ID]), - ) - - assert offered_channels(stems_data)[AUTHORED_STEM_ID] == frozenset({ChannelName.PULSE1}) - - class TestWhatAFreshDocumentIsHeardAs: def test_every_recording_is_heard_everywhere_it_holds_frames(self, listening: Listening) -> None: stems_data = _stems_data( @@ -150,6 +137,101 @@ def test_a_released_choice_hears_the_next_document_whole(self, listening: Listen assert listening.heard == listening.offered +class TestTheChoiceIsCarriedByTheCoreRule(BaseTestSuite): + """Adopting a record carries the choice by the rule the regeneration worker carries it by, so the hearing + the listening makes on a landed document is the one the worker rendered and exported for. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + before: StemsData + playing_before: FrozenSet[ChannelName] + chosen: FrozenSet[ChannelName] + after: StemsData + playing_after: FrozenSet[ChannelName] + + test_cases = ( + TestCase( + label="a record kept as it was", + before=_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B])), + playing_before=PULSES, + chosen=frozenset({ChannelName.PULSE1}), + after=_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B])), + playing_after=PULSES, + ), + TestCase( + label="a recording newly reaching a channel", + before=_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B])), + playing_before=PULSES, + chosen=frozenset(), + after=_stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A]), + ), + playing_after=EVERY_CHANNEL, + ), + TestCase( + label="the reader's own row appearing while a recording is muted", + before=_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B])), + playing_before=PULSES, + chosen=frozenset(), + after=_stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B, AUTHORED_STEM_ID]) + ), + playing_after=frozenset({ChannelName.PULSE1}), + ), + TestCase( + label="a recording leaving the record", + before=_stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_B]), + ), + playing_before=EVERY_CHANNEL, + chosen=frozenset({ChannelName.PULSE1}), + after=_stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_A]) + ).without_entries(frozenset({STEM_B})), + playing_after=PULSES, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_adopting_a_record_hears_what_the_rule_carries(self, listening: Listening, test_case: TestCase) -> None: + listening.adopt(test_case.before, test_case.playing_before) + listening.set_channels(STEM_A, test_case.chosen) + listening.set_channels_on(frozenset({ChannelName.PULSE1})) + expected = carried_hearing( + listening.hearing, + offered_before=listening.offered, + playing_before=listening.playing, + offered_after=offered_channels(test_case.after), + playing_after=test_case.playing_after, + ) + + listening.adopt(test_case.after, test_case.playing_after) + + assert listening.hearing == expected + + def test_a_fresh_document_is_heard_as_the_whole_hearing( + self, + listening: Listening, + taking_turns: Reconstruction, + ) -> None: + """A preview of a document keys the cache the way the tab keys the same document opened afresh.""" + shared = taking_turns.instructions[SHARED_CHANNEL] + edited = taking_turns.with_channel_data( + SHARED_CHANNEL, + [PulseInstruction(on=True, pitch=TAKING_TURNS_PITCH, volume=8, duty_cycle=0)] * (len(shared) + 1), + TAKING_TURNS_PITCH, + (), + heard=taking_turns.recorded_stem_ids, + ) + + listening.adopt(edited.stems_data, frozenset(edited.playing_channels)) + + assert listening.hearing == whole_hearing(edited) + + class TestSoloingARecording: """A solo hears one recording alone and remembers the choice it replaced.""" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 865342fb..1085e875 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -16,6 +16,7 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.hearing import hearing_carried_onto from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -912,12 +913,38 @@ def test_an_edit_arriving_with_envelopes_spares_the_reading( assert reconstruction_manager.reconstruction is edited assert reconstruction_manager.has_envelopes_for(selection) - def test_an_edit_whose_new_frames_moved_the_choice_is_read_on_the_spot( + def test_an_edit_whose_new_frames_moved_the_choice_is_installed_as_the_worker_carried_it( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An edit writing frames of the reader's own offers a new row; the worker carried the choice onto it.""" + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + before = reconstruction_manager.listening.hearing + edited = reconstruction.with_channel_data( + ChannelName.PULSE1, + [PulseInstruction(on=True, pitch=60, volume=7, duty_cycle=0)] * 2, + 60, + (), + heard=reconstruction.recorded_stem_ids, + ) + carried = hearing_carried_onto(before, reconstruction, edited).selection + monkeypatch.setattr(manager_module, "heard_envelopes", MagicMock(side_effect=AssertionError("read here"))) + + reconstruction_manager.apply_edited(edited, _exported(edited, carried)) + + after = reconstruction_manager.listening.selection + assert after != before.selection + assert after == carried + assert reconstruction_manager.has_envelopes_for(after) + + def test_envelopes_exported_for_another_choice_are_read_on_the_spot( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - """An edit writing frames of the reader's own offers a new row, so the worker's export is stale.""" reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) before = reconstruction_manager.listening.selection diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 7c875479..bdb33198 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -15,14 +15,19 @@ ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix -from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + Hearing, + heard_renders, + hearing_carried_onto, +) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from tests.conftest import ReconstructionFactory from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, everything_heard, taking_turns @@ -439,10 +444,28 @@ def test_the_result_sounds_the_hearing_it_was_asked_with(self, taking_turns: Rec """The landing reads what the reader hears from the result, so the result carries that reading.""" outcome = _rebuilt(taking_turns, NO_RENDERS) - assert outcome.hearing == _heard_whole(taking_turns) + assert outcome.hearing == hearing_carried_onto(_heard_whole(taking_turns), taking_turns, outcome.reconstruction) assert outcome.heard.mix is outcome.mix assert outcome.heard.playing is outcome.mix + def test_an_edit_writing_frames_of_the_readers_own_hears_them(self, taking_turns: Reconstruction) -> None: + """The worker carries the choice onto the rebuilt record, so the sound and the envelopes it hands back are + the ones the listening asks for once the document lands: the reader's own row is heard where it appears. + """ + features = taking_turns.export()[SHARED_CHANNEL] + longer = features.with_envelope(FeatureKey.VOLUME, Envelope[int](items=features.volume.items + (7,))) + + outcome = _reported( + RegenerationService(), + RebuildJob(taking_turns, SHARED_CHANNEL, longer, _heard_whole(taking_turns), NO_RENDERS), + ) + + assert AUTHORED_STEM_ID in outcome.hearing.selection.stems_for(SHARED_CHANNEL) + assert outcome.hearing == hearing_carried_onto(_heard_whole(taking_turns), taking_turns, outcome.reconstruction) + assert outcome.envelopes == outcome.reconstruction.export_heard(outcome.hearing.selection) + assert outcome.envelopes[SHARED_CHANNEL].volume.items[len(features.volume.items)] == 7 + assert outcome.heard.mix is outcome.mix + class TestWhatAListeningSounds: """A listening renders what the reader hears of the document as it stands: the document comes back as diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py index 4bb8e9d3..2cfda45c 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py @@ -1,25 +1,53 @@ -from typing import Final, FrozenSet, List, Tuple +from typing import Dict, Final, FrozenSet, List, Tuple import numpy as np from sampletones_core.audio.mixing import align from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + Hearing, + carried_channels_on, + carried_heard, + carried_hearing, + heard_by_stem, + heard_renders, + hearing_carried_onto, + offered_channels, + selection_of, + whole_hearing, +) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings -from tests.suite.stems import RECORDED_SCALE, SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, taking_turns +from tests.suite.stems import ( + RECORDED_SCALE, + SHARED_CHANNEL, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + TAKING_TURNS_DUTY_CYCLE, + TAKING_TURNS_PITCH, + TAKING_TURNS_VOLUME, + taking_turns, +) __all__ = ["taking_turns"] EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +OTHER_CHANNEL: Final[ChannelName] = ChannelName.TRIANGLE +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +BOTH: Final[FrozenSet[int]] = frozenset({STEM_A, STEM_B}) +NOTHING_OFFERED: Final[Dict[int, FrozenSet[ChannelName]]] = {} def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: @@ -44,6 +72,163 @@ def _hearing(stem_ids: FrozenSet[int], channels_on: FrozenSet[ChannelName] = EVE return Hearing(selection=StemSelection.everywhere(stem_ids, ChannelName.items()), channels_on=channels_on) +def _held_on(*channels: ChannelName) -> Dict[int, FrozenSet[ChannelName]]: + """What a record offers where both recordings hold frames on every channel given.""" + return offered_channels(_stems_data(*((channel, [STEM_A, STEM_B]) for channel in channels))) + + +def _with_a_frame_of_the_readers_own(reconstruction: Reconstruction) -> Reconstruction: + """The document with one frame past the end of its shared channel written by the reader.""" + instruction = PulseInstruction( + on=True, + pitch=TAKING_TURNS_PITCH, + volume=TAKING_TURNS_VOLUME, + duty_cycle=TAKING_TURNS_DUTY_CYCLE, + ) + return reconstruction.with_channel_data( + SHARED_CHANNEL, + [instruction] * (len(reconstruction.instructions[SHARED_CHANNEL]) + 1), + TAKING_TURNS_PITCH, + (), + heard=reconstruction.recorded_stem_ids, + ) + + +class TestWhatARecordingOffers: + def test_a_recording_offers_the_channels_it_holds_frames_on(self) -> None: + stems_data = _stems_data((CHANNEL, [STEM_A, STEM_B]), (OTHER_CHANNEL, [STEM_A, RESTING_STEM_ID])) + + offered = offered_channels(stems_data) + + assert offered[STEM_A] == frozenset({CHANNEL, OTHER_CHANNEL}) + assert offered[STEM_B] == frozenset({CHANNEL}) + + def test_a_recording_holding_nothing_offers_nothing(self) -> None: + stems_data = _stems_data((CHANNEL, [STEM_A])) + + assert offered_channels(stems_data)[STEM_B] == frozenset() + + def test_the_frames_the_reader_wrote_offer_a_row_of_their_own(self) -> None: + stems_data = _stems_data((CHANNEL, [STEM_A, AUTHORED_STEM_ID])) + + assert offered_channels(stems_data)[AUTHORED_STEM_ID] == frozenset({CHANNEL}) + + +class TestHowAChoiceTravelsAcrossAnEdit: + """One rule carries the reader's choice onto a new record, read by the worker and by the listening state alike.""" + + def test_a_channel_a_recording_keeps_holding_keeps_the_readers_choice(self) -> None: + heard = {STEM_A: frozenset({CHANNEL}), STEM_B: frozenset({CHANNEL, OTHER_CHANNEL})} + + carried = carried_heard(heard, _held_on(CHANNEL, OTHER_CHANNEL), _held_on(CHANNEL, OTHER_CHANNEL)) + + assert carried[STEM_A] == heard[STEM_A] + assert carried[STEM_B] == heard[STEM_B] + + def test_a_channel_a_recording_newly_reaches_joins_what_is_heard(self) -> None: + heard = {STEM_A: frozenset({CHANNEL}), STEM_B: frozenset()} + + carried = carried_heard(heard, _held_on(CHANNEL), _held_on(CHANNEL, OTHER_CHANNEL)) + + assert carried[STEM_A] == frozenset({CHANNEL, OTHER_CHANNEL}) + assert carried[STEM_B] == frozenset({OTHER_CHANNEL}) + + def test_a_channel_a_recording_stops_holding_leaves_what_is_heard(self) -> None: + heard = _held_on(CHANNEL, OTHER_CHANNEL) + + carried = carried_heard(heard, _held_on(CHANNEL, OTHER_CHANNEL), _held_on(CHANNEL)) + + assert carried == _held_on(CHANNEL) + + def test_a_recording_leaving_the_record_leaves_the_choice(self) -> None: + heard = {STEM_A: frozenset({CHANNEL}), STEM_B: frozenset({CHANNEL})} + offered_after = {STEM_A: frozenset({CHANNEL})} + + carried = carried_heard(heard, _held_on(CHANNEL), offered_after) + + assert carried == offered_after + + def test_the_readers_own_row_joins_what_is_heard_where_it_first_appears(self) -> None: + heard = {STEM_A: frozenset({CHANNEL}), STEM_B: frozenset()} + offered_after = offered_channels(_stems_data((CHANNEL, [STEM_A, STEM_B, AUTHORED_STEM_ID]))) + + carried = carried_heard(heard, _held_on(CHANNEL), offered_after) + + assert carried[AUTHORED_STEM_ID] == frozenset({CHANNEL}) + assert carried[STEM_B] == frozenset() + + def test_a_record_offering_nothing_before_hears_everything_after(self) -> None: + offered_after = _held_on(CHANNEL, OTHER_CHANNEL) + + assert carried_heard({}, NOTHING_OFFERED, offered_after) == offered_after + + def test_a_channel_coming_into_play_joins_the_channels_on(self) -> None: + carried = carried_channels_on(frozenset(), frozenset({CHANNEL}), frozenset({CHANNEL, SOLE_CHANNEL})) + + assert carried == frozenset({SOLE_CHANNEL}) + + def test_a_channel_switched_off_stays_off_and_one_leaving_play_leaves(self) -> None: + both = frozenset({CHANNEL, SOLE_CHANNEL}) + + assert carried_channels_on(frozenset({CHANNEL}), both, both) == frozenset({CHANNEL}) + assert carried_channels_on(both, both, frozenset({CHANNEL})) == frozenset({CHANNEL}) + + def test_a_hearing_is_carried_on_both_of_its_choices(self) -> None: + hearing = Hearing( + selection=StemSelection.everywhere(frozenset({STEM_A}), [CHANNEL]), + channels_on=frozenset({CHANNEL}), + ) + + carried = carried_hearing( + hearing, + offered_before=_held_on(CHANNEL), + playing_before=frozenset({CHANNEL}), + offered_after=offered_channels(_stems_data((CHANNEL, [STEM_A, STEM_B, AUTHORED_STEM_ID]))), + playing_after=frozenset({CHANNEL, SOLE_CHANNEL}), + ) + + assert carried.selection.stems_for(CHANNEL) == frozenset({STEM_A, AUTHORED_STEM_ID}) + assert carried.channels_on == frozenset({CHANNEL, SOLE_CHANNEL}) + + def test_a_selection_turned_around_and_back_is_itself(self) -> None: + heard = {STEM_A: frozenset({CHANNEL}), STEM_B: frozenset({CHANNEL, OTHER_CHANNEL})} + + selection = selection_of(heard) + + assert selection.channels.keys() == set(ChannelName.items()) + assert selection.stems_for(CHANNEL) == BOTH + assert selection.stems_for(ChannelName.NOISE) == frozenset() + assert selection_of(heard_by_stem(selection, _held_on(CHANNEL, OTHER_CHANNEL))) == selection + + def test_an_edit_writing_frames_of_the_readers_own_hears_them(self, taking_turns: Reconstruction) -> None: + """The worker hears the document it rebuilt as the listening will, so its reading keys the cache alike.""" + edited = _with_a_frame_of_the_readers_own(taking_turns) + + carried = hearing_carried_onto(_hearing(taking_turns.recorded_stem_ids), taking_turns, edited) + + assert AUTHORED_STEM_ID in carried.selection.stems_for(SHARED_CHANNEL) + assert carried.filter_for(SHARED_CHANNEL, edited.stems_data) is None + assert carried.channels_on == frozenset(edited.playing_channels) + + +class TestWhatAWholeHearingHears: + """A document heard whole hears every owner on every channel and keeps every playing channel on.""" + + def test_every_channel_is_heard_whole(self, taking_turns: Reconstruction) -> None: + hearing = whole_hearing(taking_turns) + + assert all(hearing.filter_for(channel, taking_turns.stems_data) is None for channel in ChannelName.items()) + assert hearing.channels_on == frozenset(taking_turns.playing_channels) + + def test_the_readers_own_row_is_heard_too(self, taking_turns: Reconstruction) -> None: + edited = _with_a_frame_of_the_readers_own(taking_turns) + + hearing = whole_hearing(edited) + + assert AUTHORED_STEM_ID in hearing.selection.stems_for(SHARED_CHANNEL) + assert hearing.filter_for(SHARED_CHANNEL, edited.stems_data) is None + + class TestWhatAChannelIsFilteredBy: """A channel's filter names the owners heard, so two hearings reaching the channel alike read it alike.""" From 53b329c169b6673570dd7a8aefa4ca846d125d99 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 18:54:11 +0200 Subject: [PATCH 2/2] Rendered: the sequencer's voice preview on a regeneration worker --- automation/application/running.py | 6 +- automation/holds/regeneration.py | 3 +- docs/development/application/playback.md | 3 +- docs/development/application/render-thread.md | 5 +- docs/development/architecture.md | 2 +- docs/development/bugs-and-todos.md | 7 +- src/sampletones_application/application.py | 11 +- .../tabs/sequencer/coordinator.py | 6 +- .../reconstruction/rewrites/regeneration.py | 22 +- .../logic/sequencer/voices.py | 59 +++-- .../logic/shared/previews.py | 144 ++++++++++++ .../logic/shared/regeneration.py | 29 +++ .../logic/shared/__init__.py | 0 .../logic/shared/test_previews.py | 50 ++++ .../screens/sequencer/voices/test_previews.py | 35 +++ .../logic/sequencer/test_history_detail.py | 4 +- .../logic/sequencer/test_voices.py | 153 +++++++++--- .../logic/shared/test_previews.py | 220 ++++++++++++++++++ 18 files changed, 670 insertions(+), 89 deletions(-) create mode 100644 src/sampletones_application/logic/shared/previews.py create mode 100644 src/sampletones_application/logic/shared/regeneration.py create mode 100644 tests/integration/sampletones_application/logic/shared/__init__.py create mode 100644 tests/integration/sampletones_application/logic/shared/test_previews.py create mode 100644 tests/screens/sequencer/voices/test_previews.py create mode 100644 tests/unit/sampletones_application/logic/shared/test_previews.py diff --git a/automation/application/running.py b/automation/application/running.py index 9e7cd07d..2bc402f2 100644 --- a/automation/application/running.py +++ b/automation/application/running.py @@ -60,8 +60,10 @@ def language(self) -> LanguageManager: return self._application.language_manager def sound_settled(self) -> bool: - """Whether the open reconstruction's sound stands landed, with no rebuild or listening on its way.""" - return not self._application.reconstruction_rewrites.is_busy + """Whether every sound asked for stands landed: the open reconstruction's rebuilds and listenings, and a + voice's preview on its way to the sequencer. + """ + return not (self._application.reconstruction_rewrites.is_busy or self._application.document_previews.is_busy) @property def shortcuts(self) -> ShortcutManager: diff --git a/automation/holds/regeneration.py b/automation/holds/regeneration.py index eb619ada..cfbe9584 100644 --- a/automation/holds/regeneration.py +++ b/automation/holds/regeneration.py @@ -17,7 +17,8 @@ class RegenerationHold: The rebuild waits where the real one runs, so the application draws and answers while the edit is on its way, and once released the real rebuild computes the edit's result. A rendering of what the reader hears, which the same worker runs on opening a document and on a box ticked, - goes through at once, so the document stands drawn while the rebuilds wait. The hold lets + goes through at once, so the document stands drawn while the rebuilds wait; a voice's preview + in the sequencer is a listening on a worker of its own and passes through the same way. The hold lets every rebuild through from its release on, until the scenario holds again. A scenario standing for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service reports the way it reports a rebuild that raised. diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index dee2b783..04ff6ea7 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -22,7 +22,7 @@ Two terms recur. A **source** is what plays one kind of audio: a reconstruction, An instrument's audition is a preview of that kind. It reads the generator chosen on the instruments card, takes the note from the keyboard's two octaves above the octave the tracker types in, and renders the voice through the same two steps a tracker row takes: the step from the instrument's own pitch to the note, at full volume. The plot card draws the same rendering at the pitch the instrument stands at, so what is seen and what is heard name one generator. -**A preview sounds the frames it renders.** An instrument sounds the envelopes it has, and a sample previews the frames its reconstruction recorded. Both sound exactly what they carry, because a recording's drive matters only while the conversion runs. +**A preview sounds the frames it renders.** An instrument sounds the envelopes it has, and a sample previews the frames its reconstruction recorded. Both sound exactly what they carry, because a recording's drive matters only while the conversion runs. A sample's preview sounds the render the render cache keeps, or the one a worker of its own lands a moment later, so the render thread computes no sample for it; a newer click replaces a preview still on its way, and the voice heard is the last one asked for. **A voice sounded on its own runs to its release, or to the length it is offered.** A row holds a voice for as long as the pattern asks. An audition and the voice list's preview have no row behind them, so each has a span of its own. A volume dimension that ends at silence releases the voice, and the sound stops there. A voice that circles from a loop point never reaches a last item, so it sounds for the ticks `AUDITION_TICKS` offers it. One count serves both spans, so the plot card draws exactly the frames the keyboard sounds. @@ -153,6 +153,7 @@ The manager's own playback is held to the same rule. A stop waits a while for it | What the reader is told when a sound fails | `PlaybackFailurePresenter` (`coordinators/playback/failures.py`) | | The boundary that hands a transport command's failure to the presenter | `GuardedPlayer` (`coordinators/playback/guard.py`) | | The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) | +| A whole document's preview, read from the render cache or rendered on a worker of its own | `DocumentPreviews` (`logic/shared/previews.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | | The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.py`) | | What a row's voice and pitch start, and what they move | `apply_row` and `note_step` (`sampletones_core/performance/rows.py`) | diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index d30fb50e..a2c571df 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -25,7 +25,10 @@ and hands them back inside its result; the landing keeps the audio in the render envelopes on the render thread. A reading the cache cannot answer, such as a recording left out for the first time or a freshly opened document, is asked of the same worker and lands the same way. What a worker produces for the interface therefore reaches what the interface reads through the -queue alone, and the render thread computes no sample while the worker renders. +queue alone, and the render thread computes no sample while the worker renders. A document previewed +elsewhere, such as a voice clicked in the sequencer, reads the same cache and asks a worker of its own +for what the cache lacks (`DocumentPreviews` in `logic/shared/previews.py`), and sounds at the landing; +the cache's writers are the landings, every one of them on the render thread. A logic object hearing a worker's report, such as a library generation or the audio device's position, posts its own handler to the queue the same way. The logic layer reaches the queue, and `utils/gui` diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 65c10e2c..8e566d41 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -207,7 +207,7 @@ Runs long operations (file conversion, waveform regeneration, export, playback s - `_emit` posts the result to `CallbackQueue`, which puts every handler on the render thread (principle 6). - Result types are a tagged union, so a subscriber matches exhaustively. A long operation reports through `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError` and `ServiceCanceled`, and a service whose reports carry more, such as the playhead or the request they answer, declares a union of its own in its `result.py`. - A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately. The generic contracts every service reports through are in `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws. See [`progress.md`](progress.md). -- A service imports nothing from `logic/`, `view_model/`, `ui/`, `coordinators/` or `config/`, which the boundary check holds. What a service is handed must be safe on its worker: a value, a kernel it drives alone for the span of a job (`RowSynthesizerProtocol`), or a store built for concurrent use, each named as a Protocol in the service's own subpackage and proven by a case. What a job produces for the interface travels in its result, so the landing installs it on the render thread; `RenderedDocument` carries the audio its worker rendered. +- A service imports nothing from `logic/`, `view_model/`, `ui/`, `coordinators/` or `config/`, which the boundary check holds. What a service is handed must be safe on its worker: a value, a kernel it drives alone for the span of a job (`RowSynthesizerProtocol`), or a store built for concurrent use, each named as a Protocol in the service's own subpackage and proven by a case. What a job produces for the interface travels in its result, so the landing installs it on the render thread; `RenderedDocument` carries the audio its worker rendered. A service instance serves one client's line: a result reaches every subscriber of the instance that made it and names no sender, so a second line, such as the sequencer's previews beside the tab's rewrites, takes an instance of its own. --- diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ac28e14a..90e27636 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -89,10 +89,9 @@ dimension the import starts carrying. entry down to the frames the edit changed ([undo](application/undo.md)). * What the Reconstructions tab still computes on the render thread. The regeneration worker renders, mixes and exports what the reader hears, and the landing installs it ([Editing the open - reconstruction](application/reconstruction-edits.md)); two readings stay on the render thread. The - sequencer's preview of a sample mixes the sample cold on its first read, since it reads the render - cache directly. The original mix of the recordings heard is mixed again on the render thread whenever a - recording is left out, which is a few milliseconds per recording on a two-minute document. + reconstruction](application/reconstruction-edits.md)); one reading stays on the render thread. The + original mix of the recordings heard is mixed again on the render thread whenever a recording is + left out, which is a few milliseconds per recording on a two-minute document. * Improve performance of the browser's favorite scan of the entire tree per click * The `fft` and `logfft` analysis floors. Their window spans two cycles of the pulse's lowest note, so the triangle's lowest octave reaches them by its harmonics alone. Covering it doubles their window and diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 8ddcb2ca..94e2aa0e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -75,6 +75,7 @@ ) from sampletones_application.logic.reconstruction.rewrites.steps import RateChange from sampletones_application.logic.render import SongRenderLogic +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.parameters import ( InstructionsTabParameters, @@ -291,6 +292,9 @@ def __init__( self.reconstruction_manager, self.regeneration_service, ) + self.preview_service: RegenerationService = RegenerationService(priority=_priority) + self.document_previews: DocumentPreviews = DocumentPreviews(self.renders, self.preview_service) + self.document_previews.on_failed = self._report_preview_failure self.export_service: ExportService = ExportService(priority=_priority) self.export_service.subscribe(self._on_export_activity) self.render_service: SongRenderService = SongRenderService(priority=_priority) @@ -593,7 +597,7 @@ def __init__( browser_manager=self.browser_manager, project_controller=self.project_controller, history=self.history, - renders=self.renders, + previews=self.document_previews, original_audio_locator=self._original_audio_locator, instrument_exports=self._instrument_exports, tab_active=self._is_sequencer_tab_current, @@ -1109,6 +1113,11 @@ def _is_operation_active(self) -> bool: or self.export_service.is_running() ) + def _report_preview_failure(self, exception: Exception) -> None: + """Tells the reader a voice's preview failed to render, the way a rebuild's failure is told.""" + logger.error_with_traceback(exception, "A preview's render failed") + self.dialogs.show_error(exception) + def _on_export_activity(self, result: ExportResult) -> None: """Follows an export claiming the application and handing it back. diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 58921e89..bb975579 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -54,7 +54,7 @@ ) from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.file_playback import FilePlayback -from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters from sampletones_application.services.song_player.service import SongPlayerService @@ -119,7 +119,7 @@ def __init__( browser_manager: BrowserManager, project_controller: ProjectController, history: HistoryManager, - renders: RenderCache, + previews: DocumentPreviews, original_audio_locator: OriginalAudioLocator, instrument_exports: InstrumentExportCoordinator, *, @@ -195,7 +195,7 @@ def __init__( session_manager, audio_device_manager, scheduling=layout.scheduling, - renders=renders, + previews=previews, ) self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic() self._song_player_logic: SongPlayerLogic = SongPlayerLogic( diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py index 4dca9ddf..ea851c2e 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -1,22 +1,16 @@ -from typing import Callable, Mapping, Protocol +from typing import Mapping, Protocol import numpy as np -from sampletones_application.services.regeneration.result import RegenerationResult +from sampletones_application.logic.shared.regeneration import ListeningServiceProtocol from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing -class RegenerationServiceProtocol(Protocol): - """The slice of the regeneration service the rewrites drive. - - Typing the collaborator structurally keeps the logic layer bound to the service's result - contract alone; the composition root supplies the real service. - """ - - def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ... +class RegenerationServiceProtocol(ListeningServiceProtocol, Protocol): + """The slice of the regeneration service the rewrites drive: a listening's calls, and the rebuild.""" def start( self, @@ -27,11 +21,3 @@ def start( hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], ) -> None: ... - - def start_listening( - self, - reconstruction: Reconstruction, - *, - hearing: Hearing, - kept: Mapping[ChannelName, np.ndarray], - ) -> None: ... diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 6ba283ed..c1e1c259 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Final, Optional, Tuple @@ -10,7 +11,7 @@ ) from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.shared.playback_priority import PlaybackPriority -from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.sequencer.kind import voice_kind from sampletones_application.view_model.sequencer.voices import ( @@ -58,7 +59,10 @@ class SequencerVoicesLogic(CallbackMixin): Previewing mirrors the reconstruction browser: a single click schedules a debounced autoplay that fires only when the session's autoplay flag is on, and a double-click (edit) cancels the - pending preview before it plays. + pending preview before it plays. A sample sounds through the document previews, which play + the render the cache keeps at once and otherwise sound the sample once a worker lands it, so + the debounce is this logic's own span and the preview on its way is the previews' own: a + double-click outliving the debounce cancels there too. """ def __init__( @@ -68,13 +72,13 @@ def __init__( audio_device_manager: AudioDeviceManager, *, scheduling: SchedulingBehavior, - renders: RenderCache, + previews: DocumentPreviews, ) -> None: self._controller = project_controller self._session_manager = session_manager self._audio_device_manager = audio_device_manager self._scheduling = scheduling - self._renders = renders + self._previews = previews self._pending_autoplay_voice: Optional[str] = None self.on_voices_changed: Optional[Callable[[SequencerVoicesViewModel], None]] = None @@ -271,7 +275,9 @@ def request_autoplay(self, voice_id: str) -> None: ) def cancel_autoplay(self) -> None: + """Forgets the preview pending, whether it waits for the debounce or for a worker's render.""" self._pending_autoplay_voice = None + self._previews.cancel() def _execute_autoplay(self) -> None: if self._pending_autoplay_voice is None: @@ -282,8 +288,8 @@ def _execute_autoplay(self) -> None: if self._session_manager.autoplay: self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW) - def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: - """The audio a preview sounds: a sample's approximation, or an instrument on the pulse. + def _audition_audio(self, instrument: Instrument) -> Optional[np.ndarray]: + """The audio an instrument's preview sounds, on the pulse. The pulse channel offers every dimension an instrument writes, so sounding the preview there sounds the whole instrument rather than the part another channel would read. The @@ -291,24 +297,18 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: measured against. Args: - voice_id: The voice to preview. + instrument: The voice to preview. Returns: Optional[np.ndarray]: The waveform to play, or ``None`` where the voice sounds nothing. """ - match self._controller.project.voices.get(voice_id): - case Sample() as sample: - return self._renders.mix(sample.reconstruction) - case Instrument() as instrument: - return audition_audio( - instrument, - PREVIEW_CHANNEL, - self._preview_config(), - pitch=instrument.reference(PREVIEW_CHANNEL), - ticks=audition_ticks(instrument, cap=AUDITION_TICKS), - ) - case _: - return None + return audition_audio( + instrument, + PREVIEW_CHANNEL, + self._preview_config(), + pitch=instrument.reference(PREVIEW_CHANNEL), + ticks=audition_ticks(instrument, cap=AUDITION_TICKS), + ) def _preview_config(self) -> Config: settings = self._controller.project.settings @@ -323,7 +323,24 @@ def _play_voice( *, priority: PlaybackPriority, ) -> None: - audio = self._preview_audio(voice_id) + """Sounds one voice: a sample through the document previews, an instrument rendered on the spot. + + A preview asked for replaces one still on its way, so the voice heard is the last one + asked for. + """ + self._previews.cancel() + match self._controller.project.voices.get(voice_id): + case Sample() as sample: + self._previews.play(sample.reconstruction, partial(self._sound, priority=priority)) + case Instrument() as instrument: + self._sound(self._audition_audio(instrument), priority=priority) + + def _sound( + self, + audio: Optional[np.ndarray], + *, + priority: PlaybackPriority, + ) -> None: if audio is None: return diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py new file mode 100644 index 00000000..4dfd7e16 --- /dev/null +++ b/src/sampletones_application/logic/shared/previews.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import Callable, Optional + +import numpy as np + +from sampletones_application.logic.shared.regeneration import ListeningServiceProtocol +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.result import ( + RegenerationResult, + RenderedDocument, +) +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.hearing import whole_hearing +from sampletones_shared.utils.callbacks import CallbackMixin + +Play = Callable[[np.ndarray], None] + + +@dataclass(frozen=True) +class WantedPreview: + """A document a reader asked to hear whole, and how it sounds once its audio is in hand. + + Attributes: + reconstruction: The document to sound. + play: What sounds the audio, which owns the device, the priority and the refusal a device + answers with. + """ + + reconstruction: Reconstruction + play: Play + + +class DocumentPreviews(CallbackMixin): + """Sounds whole documents from the render cache, and asks a worker of its own for what the cache lacks. + + A preview of a document is a reading like any other on the Reconstructions tab: sound the + cache keeps plays at once, and sound it lacks is rendered on a worker and played when it + lands, so the render thread computes no sample for a voice clicked in the sequencer. The + cache is the one the tab reads, so a document the tab has landed previews from it and a + document previewed here opens on the tab rendered. + + The latest preview asked for is the one wanted: a newer request replaces one still on its + way, whose landing then fills the cache and sounds nothing, and ``cancel`` forgets the wanted + preview the same way. One job runs at a time and the next wanted document is pursued once it + lands, reading the cache first, since the tab may have landed it meanwhile. The result a + landing sounds is the result's own audio, so a landing never asks for the document it just + delivered. A render that fails reports through ``on_failed`` and forgets the preview that + asked for it, so the next wanted document is pursued and the failed one is asked for again + only by a new gesture. + + The service is this reader's own instance: a result reaches every subscriber of the instance + that made it and names no sender, so a line of previews and the tab's line of rewrites each + take one. Every result lands on the render thread through the callback queue, which keeps the + cache's writers there. + """ + + def __init__( + self, + renders: RenderCache, + listening: ListeningServiceProtocol, + ) -> None: + self._renders = renders + self._listening = listening + self._wanted: Optional[WantedPreview] = None + self._running: Optional[Reconstruction] = None + + self.on_failed: Optional[Callable[[Exception], None]] = None + + listening.subscribe(self._on_result) + + @property + def is_busy(self) -> bool: + """Whether a preview's sound is still on its way, running on the worker or waiting for its turn.""" + return self._running is not None or self._wanted is not None + + def play( + self, + reconstruction: Reconstruction, + play: Play, + ) -> None: + """Sounds ``reconstruction`` whole through ``play``, at once or once its render lands. + + Args: + reconstruction: The document to sound. + play: What sounds the audio once it is in hand. + """ + self._wanted = WantedPreview(reconstruction, play) + self._pursue() + + def cancel(self) -> None: + """Forgets the preview wanted; a render on its way still lands and fills the cache.""" + self._wanted = None + + def _pursue(self) -> None: + """Sounds the wanted preview from the cache, or asks the worker for it while no job runs.""" + wanted = self._wanted + if wanted is None or self._running is not None: + return + + hearing = whole_hearing(wanted.reconstruction) + heard = self._renders.reading(wanted.reconstruction, hearing) + if heard is not None: + self._wanted = None + wanted.play(heard.playing) + return + + self._running = wanted.reconstruction + self._listening.start_listening( + wanted.reconstruction, + hearing=hearing, + kept=self._renders.held(wanted.reconstruction), + ) + + def _on_result(self, result: RegenerationResult) -> None: + """Takes a job's result on the render thread: keeps what it rendered, sounds it where still wanted. + + Raises: + RuntimeError: If a result arrives while no preview is on its way, which is a second + reader sharing the service instance. + """ + running = self._running + if running is None: + raise RuntimeError("A preview's render arrived while no preview was on its way") + + self._running = None + match result: + case ServiceSuccess(value=outcome): + self._land(outcome) + case ServiceError(exception=exception): + if self._wanted is not None and self._wanted.reconstruction is running: + self._wanted = None + + self.call(self.on_failed, exception) + + self._pursue() + + def _land(self, outcome: RenderedDocument) -> None: + self._renders.adopt(outcome.reconstruction, outcome.channels, outcome.mix) + self._renders.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + wanted = self._wanted + if wanted is not None and wanted.reconstruction is outcome.reconstruction: + self._wanted = None + wanted.play(outcome.heard.playing) diff --git a/src/sampletones_application/logic/shared/regeneration.py b/src/sampletones_application/logic/shared/regeneration.py new file mode 100644 index 00000000..8592a29c --- /dev/null +++ b/src/sampletones_application/logic/shared/regeneration.py @@ -0,0 +1,29 @@ +from typing import Callable, Mapping, Protocol + +import numpy as np + +from sampletones_application.services.regeneration.result import RegenerationResult +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing + + +class ListeningServiceProtocol(Protocol): + """The slice of the regeneration service a reader of a document's sound drives. + + A reader asks for what a hearing of a document sounds and takes the result where it lands, so + it names the two calls that make a listening and nothing of the rebuilds. Typing the + collaborator structurally keeps the logic layer bound to the service's result contract alone; + the composition root supplies the real service, one instance per reader, since a result + reaches every subscriber of the instance that made it. + """ + + def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ... + + def start_listening( + self, + reconstruction: Reconstruction, + *, + hearing: Hearing, + kept: Mapping[ChannelName, np.ndarray], + ) -> None: ... diff --git a/tests/integration/sampletones_application/logic/shared/__init__.py b/tests/integration/sampletones_application/logic/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/sampletones_application/logic/shared/test_previews.py b/tests/integration/sampletones_application/logic/shared/test_previews.py new file mode 100644 index 00000000..749977ad --- /dev/null +++ b/tests/integration/sampletones_application/logic/shared/test_previews.py @@ -0,0 +1,50 @@ +import time +from typing import Final, List + +import numpy as np +import pytest + +from sampletones_application.logic.shared.previews import DocumentPreviews +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix +from tests.conftest import RENDER_BUDGET +from tests.suite.application import draw_frame, live_queue +from tests.suite.sequencer import sample_reconstruction + +__all__ = ["live_queue"] + +PREVIEWED_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] +SETTLE_SECONDS: Final[float] = 10.0 +POLL_SECONDS: Final[float] = 0.005 + + +@pytest.fixture +def previews(live_queue: None) -> DocumentPreviews: + """Previews over a cache of their own, rendered on the real worker and landed through the live queue.""" + return DocumentPreviews(RenderCache(budget_bytes=RENDER_BUDGET), RegenerationService()) + + +def _settle(previews: DocumentPreviews) -> None: + """Draws frames until the preview has landed, the way the render loop drains the results.""" + deadline = time.monotonic() + SETTLE_SECONDS + while previews.is_busy: + assert time.monotonic() < deadline, "the preview never landed" + draw_frame() + time.sleep(POLL_SECONDS) + + +class TestAPreviewOnTheRealWorker: + def test_the_sound_lands_and_a_second_ask_answers_from_the_cache(self, previews: DocumentPreviews) -> None: + document = sample_reconstruction(PREVIEWED_CHANNELS) + played: List[np.ndarray] = [] + + previews.play(document, played.append) + _settle(previews) + + assert len(played) == 1 + np.testing.assert_array_equal(played[0], rendered_mix(document)) + previews.play(document, played.append) + assert len(played) == 2 + assert played[1] is played[0] diff --git a/tests/screens/sequencer/voices/test_previews.py b/tests/screens/sequencer/voices/test_previews.py new file mode 100644 index 00000000..739f275b --- /dev/null +++ b/tests/screens/sequencer/voices/test_previews.py @@ -0,0 +1,35 @@ +from functools import partial +from typing import Final + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.worlds.songs import LINE + +PLAY_VOICE: Final[str] = "global.context.label.play" + + +def voice_row(screen: Screen, name: str) -> Item: + """Brings the Sequencer tab to the front and returns the list row of the voice called ``name``.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}") + + +class TestPlayingASampleFromItsRow: + """Play on a sample's row sounds the sample once its render lands, and the window answers meanwhile. + + A sample never opened on the Reconstructions tab has no render kept, so the preview is rendered + on a worker of its own; the sound is heard once it lands, and the frames in between are drawn. + """ + + def test_play_sounds_the_sample_once_it_lands(self, screen: Screen) -> None: + voices = screen.sequencer.voices + menu = screen.context_menu + heard = screen.sound_heard() + + voices.right_click(voice_row(screen, LINE)) + screen.expect(menu.is_shown, bool, description="the row's menu") + menu.choose(screen.words(PLAY_VOICE)) + + screen.expect(screen.sound_settled, bool, description="the preview landed") + screen.expect(screen.sound_heard, heard.__lt__, description="the sample heard") diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 8f71c81e..4d762c94 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -11,6 +11,7 @@ ) from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.view_model.sequencer.region import ( OrderCell, @@ -29,6 +30,7 @@ from sampletones_core.project.voices.creation import new_instrument from sampletones_core.utils.display import display_id from tests.conftest import RENDER_BUDGET +from tests.suite.regeneration import HeldRegeneration from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -45,7 +47,7 @@ def _formatter(controller: ProjectController) -> SequencerHistoryDetail: MagicMock(), MagicMock(), scheduling=MagicMock(), - renders=RenderCache(budget_bytes=RENDER_BUDGET), + previews=DocumentPreviews(RenderCache(budget_bytes=RENDER_BUDGET), HeldRegeneration()), ) return SequencerHistoryDetail(tracker_logic, voices_logic) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index b330986e..add84101 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, List, Tuple from unittest.mock import MagicMock @@ -10,6 +11,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel @@ -31,23 +33,62 @@ from sampletones_core.formats.famitracker.voice import InstrumentOmission from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_mix +from sampletones_core.reconstructions.reconstruction.stems.hearing import whole_hearing from sampletones_shared.exceptions import LoadInstrumentError, NoOutputDeviceError from tests.conftest import RENDER_BUDGET +from tests.suite.application import synchronous_queue +from tests.suite.regeneration import HeldRegeneration +from tests.suite.renders import warmed from tests.suite.sequencer import sample_reconstruction +__all__ = ["synchronous_queue"] -def _logic() -> Tuple[ProjectController, SequencerVoicesLogic]: + +@dataclass(frozen=True) +class VoicesHarness: + """The voices logic over a project of its own, with the device mocked and the worker held.""" + + controller: ProjectController + logic: SequencerVoicesLogic + session_manager: MagicMock + audio_device_manager: MagicMock + renders: RenderCache + regeneration: HeldRegeneration + + def add_sample(self, reconstruction: Reconstruction, *, kept: bool) -> Sample: + """Adds a sample to the pool, its sound kept in the cache where ``kept`` says so.""" + sample = self.controller.add_sample(reconstruction, name="lead") + if kept: + warmed(self.renders, sample.reconstruction, whole_hearing(sample.reconstruction)) + + return sample + + def played(self) -> List[np.ndarray]: + return [call.args[0] for call in self.audio_device_manager.play.call_args_list] + + +def _harness() -> VoicesHarness: controller = ProjectController(ProjectManager()) + renders = RenderCache(budget_bytes=RENDER_BUDGET) + regeneration = HeldRegeneration() + session_manager = MagicMock() + audio_device_manager = MagicMock() logic = SequencerVoicesLogic( controller, - MagicMock(), - MagicMock(), + session_manager, + audio_device_manager, scheduling=MagicMock(), - renders=RenderCache(budget_bytes=RENDER_BUDGET), + previews=DocumentPreviews(renders, regeneration), ) - return controller, logic + return VoicesHarness(controller, logic, session_manager, audio_device_manager, renders, regeneration) + + +def _logic() -> Tuple[ProjectController, SequencerVoicesLogic]: + harness = _harness() + return harness.controller, harness.logic def _logic_with_mocks() -> Tuple[ @@ -56,17 +97,8 @@ def _logic_with_mocks() -> Tuple[ MagicMock, MagicMock, ]: - controller = ProjectController(ProjectManager()) - session_manager = MagicMock() - audio_device_manager = MagicMock() - logic = SequencerVoicesLogic( - controller, - session_manager, - audio_device_manager, - scheduling=MagicMock(), - renders=RenderCache(budget_bytes=RENDER_BUDGET), - ) - return controller, logic, session_manager, audio_device_manager + harness = _harness() + return harness.controller, harness.logic, harness.session_manager, harness.audio_device_manager def _place_instrument( @@ -343,20 +375,37 @@ def test_a_voice_the_pool_has_dropped_makes_nothing(self) -> None: class TestPlaySample: - def test_plays_reconstruction_regardless_of_autoplay( + """A sample plays from the render cache at once, or once the worker lands it, whatever the autoplay setting.""" + + def test_a_kept_sample_plays_at_once_regardless_of_autoplay( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: - controller, logic, session_manager, audio_device_manager = _logic_with_mocks() - session_manager.autoplay = False - sample = controller.add_sample(reconstruction_factory(), name="lead") + harness = _harness() + harness.session_manager.autoplay = False + sample = harness.add_sample(reconstruction_factory(), kept=True) - logic.play_voice(sample.id) + harness.logic.play_voice(sample.id) - audio_device_manager.play.assert_called_once() - call = audio_device_manager.play.call_args + harness.audio_device_manager.play.assert_called_once() + call = harness.audio_device_manager.play.call_args assert np.array_equal(call.args[0], rendered_mix(sample.reconstruction)) assert call.kwargs["priority"] == PlaybackPriority.NORMAL assert call.kwargs["update"] is False + assert harness.regeneration.held == () + + def test_an_unkept_sample_plays_once_the_worker_lands_it( + self, reconstruction_factory: Callable[[], Reconstruction] + ) -> None: + harness = _harness() + sample = harness.add_sample(reconstruction_factory(), kept=False) + + harness.logic.play_voice(sample.id) + + harness.audio_device_manager.play.assert_not_called() + harness.regeneration.finish() + call = harness.audio_device_manager.play.call_args + assert np.array_equal(call.args[0], rendered_mix(sample.reconstruction)) + assert call.kwargs["priority"] == PlaybackPriority.NORMAL def test_unknown_sample_is_ignored(self) -> None: _, logic, _, audio_device_manager = _logic_with_mocks() @@ -368,14 +417,14 @@ def test_unknown_sample_is_ignored(self) -> None: def test_a_device_refusing_the_preview_reports_the_refusal( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: - controller, logic, _, audio_device_manager = _logic_with_mocks() - sample = controller.add_sample(reconstruction_factory(), name="lead") + harness = _harness() + sample = harness.add_sample(reconstruction_factory(), kept=True) refusal = NoOutputDeviceError("no device") - audio_device_manager.play.side_effect = refusal + harness.audio_device_manager.play.side_effect = refusal reported: List[Exception] = [] - logic.on_autoplay_error = reported.append + harness.logic.on_autoplay_error = reported.append - logic.play_voice(sample.id) + harness.logic.play_voice(sample.id) assert reported == [refusal] @@ -384,15 +433,49 @@ class TestAutoplay: def test_executes_pending_preview_when_autoplay_enabled( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: - controller, logic, session_manager, audio_device_manager = _logic_with_mocks() - session_manager.autoplay = True - sample = controller.add_sample(reconstruction_factory(), name="lead") - logic._pending_autoplay_voice = sample.id + harness = _harness() + harness.session_manager.autoplay = True + sample = harness.add_sample(reconstruction_factory(), kept=True) + harness.logic._pending_autoplay_voice = sample.id - logic._execute_autoplay() + harness.logic._execute_autoplay() + + harness.audio_device_manager.play.assert_called_once() + assert harness.audio_device_manager.play.call_args.kwargs["priority"] == PlaybackPriority.PREVIEW + + def test_a_preview_landing_after_the_debounce_sounds_the_latest_voice( + self, reconstruction_factory: Callable[[], Reconstruction] + ) -> None: + """Two voices clicked faster than the worker renders sound the second alone, once it lands.""" + harness = _harness() + harness.session_manager.autoplay = True + first = harness.add_sample(reconstruction_factory(), kept=False) + second = harness.add_sample(reconstruction_factory(), kept=False) + harness.logic._pending_autoplay_voice = first.id + harness.logic._execute_autoplay() + harness.logic._pending_autoplay_voice = second.id + harness.logic._execute_autoplay() + + harness.regeneration.finish() + harness.regeneration.finish() + + played = harness.played() + assert len(played) == 1 + assert np.array_equal(played[0], rendered_mix(second.reconstruction)) + + def test_an_edit_request_forgets_a_preview_on_its_way( + self, reconstruction_factory: Callable[[], Reconstruction] + ) -> None: + harness = _harness() + harness.session_manager.autoplay = True + sample = harness.add_sample(reconstruction_factory(), kept=False) + harness.logic._pending_autoplay_voice = sample.id + harness.logic._execute_autoplay() + + harness.logic.request_edit(sample.id) + harness.regeneration.finish() - audio_device_manager.play.assert_called_once() - assert audio_device_manager.play.call_args.kwargs["priority"] == PlaybackPriority.PREVIEW + harness.audio_device_manager.play.assert_not_called() def test_skips_pending_preview_when_autoplay_disabled( self, diff --git a/tests/unit/sampletones_application/logic/shared/test_previews.py b/tests/unit/sampletones_application/logic/shared/test_previews.py new file mode 100644 index 00000000..8a0c246a --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_previews.py @@ -0,0 +1,220 @@ +from typing import Final, Iterator, List + +import numpy as np +import pytest + +from sampletones_application.logic.shared.previews import DocumentPreviews +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.jobs import ListeningJob +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix +from sampletones_core.reconstructions.reconstruction.stems.hearing import whole_hearing +from tests.suite.regeneration import HeldRegeneration +from tests.suite.renders import warmed +from tests.suite.sequencer import sample_reconstruction + +Played = List[np.ndarray] + +PREVIEWED_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] + + +@pytest.fixture +def regeneration() -> HeldRegeneration: + return HeldRegeneration() + + +@pytest.fixture +def previews(renders: RenderCache, regeneration: HeldRegeneration) -> DocumentPreviews: + return DocumentPreviews(renders, regeneration) + + +@pytest.fixture +def played() -> Played: + return [] + + +def _document() -> Reconstruction: + return sample_reconstruction(PREVIEWED_CHANNELS) + + +def _sounded(played: Played, document: Reconstruction) -> bool: + """Whether exactly the whole document was played, once.""" + return len(played) == 1 and bool(np.array_equal(played[0], rendered_mix(document))) + + +class TestAPreviewOfAKeptDocument: + def test_it_plays_at_once( + self, + previews: DocumentPreviews, + renders: RenderCache, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + document = _document() + warmed(renders, document, whole_hearing(document)) + + previews.play(document, played.append) + + assert _sounded(played, document) + assert regeneration.held == () + assert not previews.is_busy + + +class TestAPreviewOfAnUnkeptDocument: + """The sound is asked of the worker once, played when it lands, and kept for the next ask.""" + + def test_it_asks_once_and_plays_at_the_landing( + self, + previews: DocumentPreviews, + renders: RenderCache, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + document = _document() + + previews.play(document, played.append) + + assert played == [] + assert previews.is_busy + (job,) = regeneration.held + assert isinstance(job, ListeningJob) + assert job.reconstruction is document + assert job.hearing == whole_hearing(document) + assert job.kept == renders.held(document) + regeneration.finish() + assert _sounded(played, document) + assert regeneration.held == () + assert renders.reading(document, whole_hearing(document)) is not None + assert not previews.is_busy + + def test_a_newer_request_replaces_the_one_waiting( + self, + previews: DocumentPreviews, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + first = _document() + second = _document() + previews.play(first, played.append) + + previews.play(second, played.append) + regeneration.finish() + + assert played == [] + (job,) = regeneration.held + assert job.reconstruction is second + regeneration.finish() + assert _sounded(played, second) + + def test_a_canceled_preview_lands_silently_and_fills_the_cache( + self, + previews: DocumentPreviews, + renders: RenderCache, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + document = _document() + previews.play(document, played.append) + + previews.cancel() + assert previews.is_busy + regeneration.finish() + + assert played == [] + assert renders.reading(document, whole_hearing(document)) is not None + assert not previews.is_busy + + def test_a_document_no_longer_wanted_is_kept_while_the_wanted_one_plays_from_the_cache( + self, + previews: DocumentPreviews, + renders: RenderCache, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + first = _document() + second = _document() + previews.play(first, played.append) + warmed(renders, second, whole_hearing(second)) + previews.play(second, played.append) + assert played == [] + + regeneration.finish() + + assert _sounded(played, second) + assert regeneration.held == () + assert renders.reading(first, whole_hearing(first)) is not None + + def test_a_failed_render_is_reported_and_the_next_wanted_document_starts( + self, + previews: DocumentPreviews, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + first = _document() + second = _document() + failures: List[Exception] = [] + previews.on_failed = failures.append + failure = RuntimeError("the worker fell over") + previews.play(first, played.append) + previews.play(second, played.append) + + regeneration.fail(failure) + + assert failures == [failure] + assert played == [] + (job,) = regeneration.held + assert job.reconstruction is second + + def test_a_failed_render_of_the_wanted_document_is_asked_again_by_a_new_gesture_alone( + self, + previews: DocumentPreviews, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + document = _document() + previews.on_failed = lambda exception: None + previews.play(document, played.append) + + regeneration.fail(RuntimeError("the worker fell over")) + + assert regeneration.held == () + assert not previews.is_busy + previews.play(document, played.append) + assert len(regeneration.held) == 1 + + def test_a_result_arriving_with_nothing_on_its_way_raises(self, previews: DocumentPreviews) -> None: + """A second reader sharing the service instance would land its results here, which is refused.""" + document = _document() + stray = HeldRegeneration() + stray.subscribe(previews._on_result) + stray.start_listening(document, hearing=whole_hearing(document), kept={}) + + with pytest.raises(RuntimeError, match="no preview was on its way"): + stray.finish() + + +class TestAShutdownMidPreview: + """A shutdown asked for while a preview renders ends the job with nothing played and nothing kept.""" + + @pytest.fixture(autouse=True) + def armed_again(self) -> Iterator[None]: + yield + SingleThreadExecutor.reset_shutdown() + + def test_nothing_plays_and_nothing_is_kept( + self, + previews: DocumentPreviews, + renders: RenderCache, + regeneration: HeldRegeneration, + played: Played, + ) -> None: + document = _document() + previews.play(document, played.append) + + SingleThreadExecutor.request_shutdown() + regeneration.finish() + + assert played == [] + assert renders.reading(document, whole_hearing(document)) is None