From d9e98f2879d96b678439761d27806957ac664ff7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 15:56:18 +0200 Subject: [PATCH 01/37] Updated: CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876af936f..a24dd1307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Optimized reconstruction size. * Optimized algorithm memory usage. * Improved FamiTracker/Bitphase compatibility. +* Supported CUDA for local installation if available. * Bumped the reconstruction data- version to `2.2`, with backward compatibility for `2.1`. * Bumped the project data version to `1.1`, with backward compatibility. * Bumped the library data version to `2.1`. From ecc56909f6a910ac1c645aa3a57433101145c8b2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 16:27:13 +0200 Subject: [PATCH 02/37] Fixed: stem mute state carried onto a newly opened reconstruction --- .../logic/reconstruction/listening.py | 2 +- .../logic/reconstruction/manager.py | 26 ++++- .../logic/reconstruction/test_manager.py | 94 +++++++++++++++++-- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py index 4ed21375d..d87a32786 100644 --- a/src/sampletones_application/logic/reconstruction/listening.py +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -104,7 +104,7 @@ def solo(self, stem_id: int) -> None: } def release(self) -> None: - """Lets go of the choice, which is what closing the reconstruction it describes does.""" + """Lets go of the choice, which is what closing the reconstruction it describes, or opening another, does.""" self._offered = {} self._heard = {} self._heard_before_solo = None diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 7d9c986f9..eea58a025 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -61,9 +61,11 @@ def renders(self) -> RenderCache: def load_reconstruction(self, path: Path) -> None: logger.info(f"Loading reconstruction: {logger.format_path(path)}") - self._adopt_reconstruction(ReconstructionData.load(path), voice_id=None) - self._session.mark_loaded(path.name) - self.call(self.on_reconstruction_loaded) + self._open_reconstruction( + ReconstructionData.load(path), + name=path.name, + voice_id=None, + ) logger.info(f"Reconstruction {logger.format_path(path)} loaded successfully") def load_reconstruction_object( @@ -80,10 +82,26 @@ def load_reconstruction_object( from. The document remembers the sample's voice id, which is how an edit finds the sample to write back into and how a project change finds the voice the tab shows. """ - self._adopt_reconstruction( + self._open_reconstruction( ReconstructionData.from_reconstruction(reconstruction, name=name), + name=name, voice_id=voice_id, ) + + def _open_reconstruction( + self, + reconstruction_data: ReconstructionData, + *, + name: str, + voice_id: Optional[str], + ) -> None: + """Puts a document in place of the open one, heard whole, under ``name``. + + Every document numbers its recordings from the same ids, so the reader's listening choice + belongs to the document it was made on, and an opened document starts a choice of its own. + """ + self._listening.release() + self._adopt_reconstruction(reconstruction_data, voice_id=voice_id) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 4f5884e70..9033f888f 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -51,6 +51,18 @@ def _two_frames() -> List[PulseInstruction]: return [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] * 2 +@pytest.fixture(name="two_recordings") +def two_recordings_fixture(tmp_path: Path) -> Reconstruction: + """A document of two recordings sharing the first pulse channel, a frame each.""" + return Reconstruction.create( + instructions={ChannelName.PULSE1: _two_frames()}, + config=Config(), + coefficient=1.0, + audio_filepath=(tmp_path / "kick.wav", tmp_path / "snare.wav"), + stems_data=_two_entry_stems_data(), + ) + + class TestLoadReconstructionPropagatesErrors: @staticmethod def _manager() -> ReconstructionManager: @@ -530,18 +542,12 @@ def _edited(reconstruction: Reconstruction) -> Reconstruction: def test_the_recording_leaves_what_the_reader_hears( self, reconstruction_manager: ReconstructionManager, + two_recordings: Reconstruction, tmp_path: Path, ) -> None: - reconstruction = Reconstruction.create( - instructions={ChannelName.PULSE1: _two_frames()}, - config=Config(), - coefficient=1.0, - audio_filepath=(tmp_path / "kick.wav", tmp_path / "snare.wav"), - stems_data=_two_entry_stems_data(), - ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.load_reconstruction_object(two_recordings, name="Sample", voice_id=SAMPLE_VOICE_ID) - reconstruction_manager.apply_edited(self._edited(reconstruction)) + reconstruction_manager.apply_edited(self._edited(two_recordings)) assert reconstruction_manager.listening.offered == {0: frozenset({ChannelName.PULSE1})} assert reconstruction_manager.source_paths == (tmp_path / "kick.wav",) @@ -550,6 +556,76 @@ def test_the_recording_leaves_what_the_reader_hears( assert features[ChannelName.PULSE1].volume.items[-1] == 0 +class TestAnOpenedDocumentIsHeardWhole: + """The reader's choice of recordings belongs to the document it was made on. + + Every document numbers its recordings from the same ids, so a document opened in place of + another starts heard whole, while an edit and a save of the open one keep what was chosen. + """ + + MUTED_STEM_ID: Final[int] = 1 + + @pytest.fixture(name="muted") + def muted_fixture( + self, + reconstruction_manager: ReconstructionManager, + two_recordings: Reconstruction, + ) -> ReconstructionManager: + reconstruction_manager.load_reconstruction_object(two_recordings, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.listening.set_channels(self.MUTED_STEM_ID, frozenset()) + return reconstruction_manager + + def test_a_file_opened_in_its_place( + self, + muted: ReconstructionManager, + two_recordings: Reconstruction, + tmp_path: Path, + ) -> None: + path = tmp_path / "other.stn" + two_recordings.save(path) + + muted.load_reconstruction(path) + + assert muted.listening.heard == muted.listening.offered + + def test_a_sample_opened_in_its_place( + self, + muted: ReconstructionManager, + two_recordings: Reconstruction, + ) -> None: + muted.load_reconstruction_object(two_recordings.model_copy(deep=True), name="Other", voice_id="other-id") + + assert muted.listening.heard == muted.listening.offered + + def test_a_file_failing_to_open_keeps_the_choice( + self, + muted: ReconstructionManager, + tmp_path: Path, + ) -> None: + with pytest.raises(FileNotFoundError): + muted.load_reconstruction(tmp_path / "missing.stn") + + assert muted.listening.heard[self.MUTED_STEM_ID] == frozenset() + + def test_an_edit_keeps_the_choice( + self, + muted: ReconstructionManager, + two_recordings: Reconstruction, + ) -> None: + muted.apply_edited(two_recordings.model_copy(deep=True)) + + assert muted.listening.heard[self.MUTED_STEM_ID] == frozenset() + + def test_save_as_keeps_the_choice( + self, + muted: ReconstructionManager, + tmp_path: Path, + ) -> None: + muted.save_reconstruction_as(tmp_path / "detached.stn") + + assert muted.listening.heard[self.MUTED_STEM_ID] == frozenset() + + class TestTheVoiceTheDocumentIs: """The open document remembers the project voice it is, which is how the tab follows it.""" From 5ff00965478da3f03b07dd6baff14b1aeb04f00a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 16:35:19 +0200 Subject: [PATCH 03/37] Extended: the bend tooltip --- src/sampletones_config/lang/en.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 76f660893..cff2df205 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -294,7 +294,7 @@ global.stems.label.remove: "x" global.stems.label.edits: "Edits" global.stems.label.channel_on: "on" global.stems.label.channel_bend: "bend" -global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording." +global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording. Otherwise, snap to the nearest note." global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level." global.stems.message.inert_tooltip: "Tick a channel to use this recording." global.stems.message.missing_tooltip: "This recording is missing from disk." From 9f043c12f7842d257b3704a3d0840237e0d6fe24 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 17:41:30 +0200 Subject: [PATCH 04/37] Redrawn: the tracker caret under its character and with the row's scroll --- automation/views/sequencer.py | 16 +- automation/views/tracker.py | 33 ++-- .../application/sequencer-blocks.md | 2 + .../layout/general/caret.py | 11 +- .../ui/elements/table/caret.py | 144 ++++++++++++---- .../ui/panels/sequencer/order/panel.py | 3 +- .../ui/panels/sequencer/tracker/move.py | 34 ++++ .../ui/panels/sequencer/tracker/panel.py | 155 +++++++++++++----- .../layout/general/caret.yaml | 7 +- src/sampletones_config/palettes/dark.yaml | 4 +- src/sampletones_config/palettes/light.yaml | 4 +- src/sampletones_config/palettes/studio.yaml | 4 +- .../theme/tables/order.yaml | 8 + .../sequencer/tracker/caret/__init__.py | 0 .../sequencer/tracker/caret/constants.py | 18 ++ .../screens/sequencer/tracker/caret/steps.py | 104 ++++++++++++ .../sequencer/tracker/caret/test_alignment.py | 114 +++++++++++++ .../tracker/caret/test_follows_the_row.py | 76 +++++++++ .../ui/elements/table/test_caret.py | 19 ++- .../ui/panels/sequencer/conftest.py | 16 +- 20 files changed, 657 insertions(+), 115 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/move.py create mode 100644 tests/screens/sequencer/tracker/caret/__init__.py create mode 100644 tests/screens/sequencer/tracker/caret/constants.py create mode 100644 tests/screens/sequencer/tracker/caret/steps.py create mode 100644 tests/screens/sequencer/tracker/caret/test_alignment.py create mode 100644 tests/screens/sequencer/tracker/caret/test_follows_the_row.py diff --git a/automation/views/sequencer.py b/automation/views/sequencer.py index 8161526e8..5ca7de6d6 100644 --- a/automation/views/sequencer.py +++ b/automation/views/sequencer.py @@ -3,8 +3,9 @@ import dearpygui.dearpygui as dpg from automation.dearpygui.bridge import Bridge -from automation.dearpygui.geometry import Point +from automation.dearpygui.geometry import Point, Rect from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme_text_color from automation.dearpygui.items.reading import read_item from automation.dearpygui.items.regions import read_region_view, read_table from automation.dearpygui.items.texts import read_label @@ -207,6 +208,19 @@ def positions(self) -> int: lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS ) + def cell_box(self, channel: Optional[ChannelName], position: int) -> Optional[Rect]: + """Where the entry of ``channel`` at frame ``position`` stands on the screen.""" + return self._bridge.ask(lambda: read_item(_order_entry(channel, position)).rect) + + def text_color(self, channel: Optional[ChannelName], position: int) -> Optional[Tuple[float, ...]]: + """The text color the theme bound to the entry of ``channel`` at frame ``position`` sets.""" + + def read() -> Optional[Tuple[float, ...]]: + theme = dpg.get_item_info(_order_entry(channel, position))["theme"] + return read_theme_text_color(theme) if theme is not None else None + + return self._bridge.ask(read) + def click(self, channel: Optional[ChannelName], position: int) -> None: """Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first.""" entry = self._bridge.ask(lambda: _order_entry(channel, position)) diff --git a/automation/views/tracker.py b/automation/views/tracker.py index 9c7ca9cc0..e64888d93 100644 --- a/automation/views/tracker.py +++ b/automation/views/tracker.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg from automation.dearpygui.bridge import Bridge -from automation.dearpygui.geometry import Point +from automation.dearpygui.geometry import Point, Rect from automation.dearpygui.hand import Hand from automation.dearpygui.items.colors import read_theme_text_color from automation.dearpygui.items.reading import read_item @@ -61,7 +61,7 @@ def _table_rows() -> Tuple[Item, ...]: return tuple(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)) -def _frame_rows() -> Dict[int, int]: +def frame_rows() -> Dict[int, int]: """Where each pattern row of the shown frame stands among the table's rows, by its index. Runs on the render thread. """ @@ -181,6 +181,10 @@ def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool: """Whether the caret stands in ``channel``'s cell on ``row``, as the box drawn over the grid shows it.""" return self._bridge.ask(_caret_cell) == (row, channel) + def caret_box(self) -> Optional[Rect]: + """Where the caret's mark stands on the screen, while it shows one.""" + return self._bridge.ask(caret_mark) + def caret_row(self, channel: Optional[ChannelName]) -> Optional[int]: """The pattern row the caret stands on in ``channel``'s column, if it stands in that column.""" cell = self._bridge.ask(_caret_cell) @@ -208,7 +212,7 @@ def read() -> Optional[int]: return None middle = band.y + band.height * HALF - for row, table_index in _frame_rows().items(): + for row, table_index in frame_rows().items(): number = read_item(_number(table_index)) if number.rect is None or not number.visible: continue @@ -237,7 +241,7 @@ def numbers_below(self, count: int) -> Tuple[str, ...]: """What the row numbers of the ``count`` rows nearest below the frame read, top to bottom.""" def read() -> Tuple[str, ...]: - last = max(_frame_rows().values()) + last = max(frame_rows().values()) return tuple(read_label(_number(index)) for index in range(last + 1, last + 1 + count)) return self._bridge.ask(read) @@ -262,7 +266,7 @@ def tinted_rows_in_view(self) -> FrozenSet[int]: def read() -> FrozenSet[int]: return frozenset( row - for row, table_index in _frame_rows().items() + for row, table_index in frame_rows().items() if read_item(_number(table_index)).visible and dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, table_index) ) @@ -304,7 +308,7 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]: return None x, y = middle - for row, table_index in _frame_rows().items(): + for row, table_index in frame_rows().items(): number = read_item(_number(table_index)) if number.rect is None or not number.visible or not number.rect.y <= y < number.rect.y + number.rect.height: continue @@ -318,9 +322,9 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]: return None -def _caret_middle() -> Optional[Tuple[float, float]]: - """The middle of the box the caret overlay draws, while it shows one. Runs on the render thread.""" - rectangle = CaretOverlay._rectangle # pylint: disable=protected-access +def caret_mark() -> Optional[Rect]: + """The mark the caret overlay draws under the active character, while it shows one. Runs on the render thread.""" + rectangle = CaretOverlay._mark # pylint: disable=protected-access if rectangle is None or not dpg.does_item_exist(rectangle): return None @@ -329,4 +333,13 @@ def _caret_middle() -> Optional[Tuple[float, float]]: return None corner, far = configuration["pmin"], configuration["pmax"] - return (corner[0] + far[0]) * HALF, (corner[1] + far[1]) * HALF + return Rect(x=corner[0], y=corner[1], width=far[0] - corner[0], height=far[1] - corner[1]) + + +def _caret_middle() -> Optional[Tuple[float, float]]: + """The middle of the caret's mark, while it shows one. Runs on the render thread.""" + mark = caret_mark() + if mark is None: + return None + + return mark.x + mark.width * HALF, mark.y + mark.height * HALF diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md index 4530fb5ca..6da7bfdbf 100644 --- a/docs/development/application/sequencer-blocks.md +++ b/docs/development/application/sequencer-blocks.md @@ -132,6 +132,8 @@ Both panels read the cell under a held pointer off their own geometry, because D The tracker's row lookup is arithmetic. It measures from the first row's top edge and divides by the row height, which holds while the rows are evenly pitched. That is what the pattern table's zero vertical cell padding and item spacing are for: a vertical padding there would drift the lookup further down the grid. The order's position lookup is arithmetic in the same way and takes its pitch from the first two columns. Its channel lookup walks the rows, because the master row stands apart from the channels beneath it. +The caret (`ui/elements/table/caret.py`) draws on a front viewport drawlist from the rectangle the active cell reported: a mark under one character, measured off the label's width in the cell's font, and a frame around the cell. Both tables give their selectables zero item spacing, so a label starts at its rectangle's left edge and the character arithmetic holds in each. A table answers a scroll on the frame after the one that draws its highlights, so a move that re-centers the grid holds its painting one frame, the way the playhead does, and hands the caret the distance the pending scroll will carry the row. + ### A drag past the edge carries the view A pointer held past the cells on screen travels the grid under it, so a selection reaches further than the viewport holds. `grid/scroll/` holds the travel. A grid says which axis it scrolls along and where its cells stand along it, and one class reads the two each frame. The tracker travels vertically and the order horizontally, from that same class. diff --git a/src/sampletones_application/layout/general/caret.py b/src/sampletones_application/layout/general/caret.py index 4e9bb687f..d02a4a576 100644 --- a/src/sampletones_application/layout/general/caret.py +++ b/src/sampletones_application/layout/general/caret.py @@ -4,7 +4,10 @@ class CaretLayout(BaseModel, extra="forbid", frozen=True): - offset: int - width_padding: int - fill: WrittenColor - border: WrittenColor + """How the tracker's caret is drawn: the underline's height and color, and the color of the frame around the + cell. + """ + + height: int + color: WrittenColor + frame: WrittenColor diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index a7a4d1bd7..1afcddf4a 100644 --- a/src/sampletones_application/ui/elements/table/caret.py +++ b/src/sampletones_application/ui/elements/table/caret.py @@ -1,4 +1,5 @@ -from typing import Any, Optional, Tuple +from dataclasses import dataclass +from typing import Any, Final, Optional, Tuple import dearpygui.dearpygui as dpg @@ -7,11 +8,14 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.utils.gui.dpg import dpg_get_item_parent from sampletones_shared.meta import NonInstantiableMeta -from sampletones_shared.types.application import Sender +from sampletones_shared.types.application import ColorRGBA, Sender Box = Tuple[float, float, float, float] - +STILL: Final[float] = 0.0 +UNFILLED: Final[ColorRGBA] = (0, 0, 0, 0) +FRAME_THICKNESS: Final[float] = 1.0 +MARK_CLEARANCE: Final[float] = 1.0 _ANCESTOR_WALK_LIMIT = 32 @@ -27,31 +31,46 @@ def _as_item_id(item: Sender) -> Sender: return item +@dataclass(frozen=True) +class CaretBoxes: + """Where the caret draws this frame: the mark under the active character and the frame around its cell.""" + + mark: Box + frame: Box + + class CaretOverlay(metaclass=NonInstantiableMeta): - """A single-character translucent box marking the tracker edit cursor. - - Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a - whole, so a single rectangle is drawn on a front viewport drawlist at the active - character's position and redrawn every frame (from the application loop) so it - follows the table as it scrolls. Because at - most one cell across both tracker tables holds the cursor at a time, one - shared rectangle is enough; the ``owner`` token keeps the order and tracker - panels' arm/clear calls from clobbering each other during focus hand-off. + """A mark under the character the tracker edits and a frame around the cell it stands in. + + Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a whole, so the + mark is drawn on a front viewport drawlist at the active character's position and redrawn + every frame (from the application loop) so it follows the table as it scrolls. The mark is an + underline, so the glyph it stands under stays whole; the frame is an outline, so the cell's own + wash can stay faint without the cursor getting lost in a full grid. Because at most one cell + across both tracker tables holds the cursor at a time, one shared pair of rectangles is + enough; the ``owner`` token keeps the order and tracker panels' arm/clear calls from clobbering + each other during focus hand-off. + + A grid that re-centers the row it moved to asks for a scroll the table answers a frame later + than the rectangles it reports, so the panel hands the overlay the distance that scroll will + carry the row (``scroll_shift``), which the next redraw takes off the boxes and then forgets. """ _layout: Optional[CaretLayout] = None - _rectangle: Optional[Sender] = None + _mark: Optional[Sender] = None + _frame: Optional[Sender] = None _owner: Optional[Any] = None _widget: Optional[Sender] = None _caret_index: int = 0 _font: Optional[Font] = None _clip_widget: Optional[Sender] = None + _scroll_shift: float = STILL _root_window: Optional[str] = None @classmethod def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None: - """Creates the (hidden) overlay rectangle. Call once after the viewport exists. + """Creates the (hidden) overlay rectangles. Call once after the viewport exists. ``root_window_tag`` is the primary window that hosts the tracker tables. The caret shows only while the active window sits within that window's tree, so a dialog or @@ -61,12 +80,21 @@ def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None: cls._layout = layout cls._root_window = root_window_tag drawlist = dpg.add_viewport_drawlist(front=True) - cls._rectangle = dpg.draw_rectangle( + cls._frame = dpg.draw_rectangle( (0.0, 0.0), (0.0, 0.0), parent=drawlist, - fill=layout.fill.rgba, - color=layout.border.rgba, + fill=UNFILLED, + color=layout.frame.rgba, + thickness=FRAME_THICKNESS, + show=False, + ) + cls._mark = dpg.draw_rectangle( + (0.0, 0.0), + (0.0, 0.0), + parent=drawlist, + fill=layout.color.rgba, + color=UNFILLED, show=False, ) @@ -79,11 +107,14 @@ def set_target( caret_index: int, font: Font, clip_widget: Sender, + scroll_shift: float, ) -> None: """Arms the caret on ``widget`` at character ``caret_index``. - ``clip_widget`` is the scrolling table the cell lives in; the box is - clamped to its on-screen rectangle so it stays within the table. + ``clip_widget`` is the scrolling table the cell lives in; the boxes are clamped to its + on-screen rectangle so they stay within the table. ``scroll_shift`` is how far a scroll + already asked for will carry the cell before the next frame is drawn, ``STILL`` where + none is pending. """ if widget is None: cls.clear(owner) @@ -94,6 +125,7 @@ def set_target( cls._caret_index = caret_index cls._font = font cls._clip_widget = clip_widget + cls._scroll_shift = scroll_shift @classmethod def clear(cls, owner: Any) -> None: @@ -107,36 +139,49 @@ def clear(cls, owner: Any) -> None: @classmethod def redraw(cls) -> None: - """Repositions the box for the current frame. Cheap no-op when disarmed. + """Repositions the boxes for the current frame. Cheap no-op when disarmed. - Hides the box while the active window sits outside the tracker's window tree (a + Hides the boxes while the active window sits outside the tracker's window tree (a dialog or another window holds focus), keeping the armed state so the caret returns to the same cell once focus comes back. """ - if cls._rectangle is None or cls._layout is None: + if cls._mark is None or cls._frame is None or cls._layout is None: return if not cls._active_within_root(): cls._hide() return - box = cls._compute_box() - if box is None: + boxes = cls._compute_boxes() + cls._scroll_shift = STILL + if boxes is None: cls._hide() return - pmin, pmax = (box[0], box[1]), (box[2], box[3]) dpg.configure_item( - cls._rectangle, - pmin=pmin, - pmax=pmax, - fill=cls._layout.fill.rgba, - color=cls._layout.border.rgba, + cls._frame, + pmin=(boxes.frame[0], boxes.frame[1]), + pmax=(boxes.frame[2], boxes.frame[3]), + color=cls._layout.frame.rgba, + show=True, + ) + dpg.configure_item( + cls._mark, + pmin=(boxes.mark[0], boxes.mark[1]), + pmax=(boxes.mark[2], boxes.mark[3]), + fill=cls._layout.color.rgba, show=True, ) @classmethod - def _compute_box(cls) -> Optional[Box]: + def _compute_boxes(cls) -> Optional[CaretBoxes]: + """The mark and the frame for this frame, or None while the cell is off screen or empty. + + The mark spans one character of the cell's label, measured from the label's own width + in the cell's font, and stands under the text band, which the cell centers vertically. + The frame takes the cell the widget belongs to, the group that holds its slots, and the + widget's own rectangle where the widget stands alone in its cell. + """ widget = cls._widget if widget is None or cls._font is None or cls._layout is None: return None @@ -161,10 +206,36 @@ def _compute_box(cls) -> Optional[Box]: x0, y0, _, y1 = cell char_width = size[0] / len(text) - caret_x0 = x0 + cls._layout.offset + cls._caret_index * char_width - caret_x1 = caret_x0 + char_width + cls._layout.width_padding + mark_x0 = x0 + cls._caret_index * char_width + mark_x1 = mark_x0 + char_width + text_bottom = (y0 + y1 + size[1]) / 2 + mark_y1 = text_bottom + MARK_CLEARANCE + mark_y0 = mark_y1 - cls._layout.height + + mark = cls._clip(cls._shifted((mark_x0, mark_y0, mark_x1, mark_y1))) + frame = cls._clip(cls._shifted(cls._cell_box(widget, cell))) + if mark is None or frame is None: + return None + + return CaretBoxes(mark=mark, frame=frame) - return cls._clip((caret_x0, y0, caret_x1, y1)) + @classmethod + def _cell_box(cls, widget: Sender, own: Box) -> Box: + """The cell the frame outlines: the parent group's rectangle, or the widget's own where the parent reports + none. + """ + parent = dpg_get_item_parent(widget) + if parent is None: + return own + + group = cls._rect(parent) + return group if group is not None else own + + @classmethod + def _shifted(cls, box: Box) -> Box: + """``box`` carried by the scroll the table is about to answer.""" + shift = cls._scroll_shift + return (box[0], box[1] - shift, box[2], box[3] - shift) @classmethod def _clip(cls, box: Box) -> Optional[Box]: @@ -241,5 +312,6 @@ def _active_within_root(cls) -> bool: @classmethod def _hide(cls) -> None: - if cls._rectangle is not None and dpg.does_item_exist(cls._rectangle): - dpg.configure_item(cls._rectangle, show=False) + for rectangle in (cls._mark, cls._frame): + if rectangle is not None and dpg.does_item_exist(rectangle): + dpg.configure_item(rectangle, show=False) diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index 4996e37ca..356361afc 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -32,7 +32,7 @@ GUIPlusMinusButtons, PlusMinusOrder, ) -from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.channels import ( @@ -860,6 +860,7 @@ def _update_caret(self) -> None: caret_index=len(self._input_state.pending), font=font, clip_widget=TAG_SEQUENCER_ORDER_WINDOW, + scroll_shift=STILL, ) def _on_cell_clicked( diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/move.py b/src/sampletones_application/ui/panels/sequencer/tracker/move.py new file mode 100644 index 000000000..178d9604f --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/move.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple + +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_core.constants.enums import ChannelName + +CellPlace = Tuple[int, Optional[ChannelName]] + + +def cell_place(cursor: Optional[TrackerCursor]) -> Optional[CellPlace]: + """The cell a cursor stands in, its row and its channel, or None for no cursor.""" + if cursor is None: + return None + + return (cursor.row, cursor.channel) + + +@dataclass(frozen=True) +class CursorMove: + """The painting a change of the cursor owes the grid: the cell it left and the cell it reached. + + Attributes: + left: The cell the cursor stood in before, or None where it stood in none. + reached: The cell the cursor stands in now, or None where it stands in none. + """ + + left: Optional[CellPlace] + reached: Optional[CellPlace] + + def then(self, move: CursorMove) -> CursorMove: + """This move carried on by ``move``: the cell first left and the cell last reached.""" + return CursorMove(left=self.left, reached=move.reached) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 71010bc97..5c0e8e81b 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -1,4 +1,6 @@ -from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple +from contextlib import contextmanager +from functools import partial +from typing import Callable, Dict, Final, FrozenSet, Iterator, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -32,7 +34,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer import display as tracker_display @@ -106,6 +108,7 @@ ) from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu +from sampletones_application.ui.panels.sequencer.tracker.move import CursorMove, cell_place from sampletones_application.ui.panels.sequencer.tracker.row import ( add_empty_cell, add_slot_group, @@ -177,9 +180,13 @@ from sampletones_shared.types.callback import VoidCallback PLAYHEAD_PAINT_FRAMES: Final[int] = 1 +CURSOR_PAINT_FRAMES: Final[int] = 1 class GUISequencerTrackerPanel(GUIPanel): + _paint_held: bool = False + _held_move: Optional[CursorMove] = None + def __init__( self, initial_settings: SequencerSettingsViewModel, @@ -950,7 +957,7 @@ def _update_cursor(self) -> None: self._input_state = TrackerInputState() self._selection.repaint() - self._update_caret() + self._update_caret(STILL) def deselect_cell(self) -> None: cursor = self._input_state.cursor @@ -959,33 +966,93 @@ def deselect_cell(self) -> None: self._remove_cell_highlight(cursor.row, cursor.channel) self._selection.repaint() - self._update_caret() + self._update_caret(STILL) def _apply_state(self, new_state: TrackerInputState) -> None: - old_cursor = self._input_state.cursor - new_cursor = new_state.cursor - - old_pos = (old_cursor.row, old_cursor.channel) if old_cursor is not None else None - new_pos = (new_cursor.row, new_cursor.channel) if new_cursor is not None else None + """Takes the new input state and paints the cursor's move, at once or when the held painting is released. + The cells the cursor leaves and reaches are reported as soon as the state changes; their + painting waits while a gesture holds it (see :meth:`_holding_paint`), so a move that also + re-centers the grid lands on screen together with the scroll it asked for. + """ + move = CursorMove(left=cell_place(self._input_state.cursor), reached=cell_place(new_state.cursor)) self._input_state = new_state - if old_pos != new_pos and old_cursor is not None: - self._remove_cell_highlight(old_cursor.row, old_cursor.channel) + if move.reached != move.left and move.reached is not None: + self.call(self.on_cell_selected) + + if self._paint_held: + self._held_move = move if self._held_move is None else self._held_move.then(move) + else: + self._paint_move(move, STILL) - if old_cursor is not None: - self._update_cell_display(old_cursor.row, old_cursor.channel) + @contextmanager + def _holding_paint(self) -> Iterator[None]: + """Holds the painting of every cursor change made inside, for :meth:`_release_paint` to paint as one. - if new_cursor is not None: - if old_pos != new_pos: - self._apply_cell_highlight(new_cursor.row, new_cursor.channel) - self._update_cell_display(new_cursor.row, new_cursor.channel) + A hold belongs to the gesture that opens it, so between gestures the panel stands at its + class's rest: nothing held and nothing owed. + """ + self._paint_held = True + try: + yield + finally: + self._paint_held = False + + def _release_paint(self, *, recentering: bool) -> None: + """Paints the held cursor move: now, or on the frame the scroll of a re-centering grid lands on. + + A cell's mark is drawn on the very next frame while the grid answers a scroll on the frame + after that, so a move painted as it is made stands a row clear of the band's center for a + frame. The painting of a re-centering move is held until its scroll lands and carries the + distance that scroll moves the row, which the caret takes off the rectangle the cell + reported a frame earlier. + """ + landing = self._center_followed_row() if recentering else None + if landing is None: + self._paint_held_move(STILL) + return - if new_pos != old_pos and new_cursor is not None: - self.call(self.on_cell_selected) + FrameCallbackManager.set_frame_callback(partial(self._paint_landing_move, landing), CURSOR_PAINT_FRAMES) + + def _paint_landing_move(self, landing: float) -> None: + """Paints the held move for the frame on which the grid's scroll reaches ``landing``.""" + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return + + self._paint_held_move(landing - dpg.get_y_scroll(TAG_SEQUENCER_TRACKER_TABLE)) + + def _paint_held_move(self, scroll_shift: float) -> None: + move = self._held_move + self._held_move = None + if move is not None: + self._paint_move(move, scroll_shift) + + def _paint_move(self, move: CursorMove, scroll_shift: float) -> None: + """Marks the cell the cursor reached, clears the one it left, and redraws both cells' labels. + + A cell of a row the table no longer holds is left alone: the grid was rebuilt under the + move, and the rebuild painted the cursor where it stands. + """ + left = move.left if move.left is not None and self._holds_row(move.left[0]) else None + reached = move.reached if move.reached is not None and self._holds_row(move.reached[0]) else None + + if left is not None and left != reached: + self._remove_cell_highlight(*left) + + if left is not None: + self._update_cell_display(*left) + + if reached is not None: + if reached != left: + self._apply_cell_highlight(*reached) + self._update_cell_display(*reached) self._selection.repaint() - self._update_caret() + self._update_caret(scroll_shift) + + def _holds_row(self, row_index: int) -> bool: + return row_index < self._current_row_count def update_samples(self, view_model: SequencerVoicesViewModel) -> None: self._current_samples = view_model @@ -1065,8 +1132,10 @@ def _update_cell_display( if cell_id is not None: dpg.configure_item(cell_id, label=self._render_cell(key)) - def _update_caret(self) -> None: - """Arms (or clears) the shared caret box on the active subcolumn cell.""" + def _update_caret(self, scroll_shift: float) -> None: + """Arms (or clears) the shared caret on the active subcolumn cell, ``scroll_shift`` ahead of a landing + scroll. + """ cursor = self._input_state.cursor if cursor is None: CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE) @@ -1079,6 +1148,7 @@ def _update_caret(self) -> None: caret_index=len(self._input_state.pending), font=slot_font(cursor.channel), clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, + scroll_shift=scroll_shift, ) def _resolve_voice( @@ -1540,11 +1610,11 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if cursor is None: return False - consumed = self._answer_key(event, cursor) - moved = self._input_state.cursor - if consumed and moved is not None and moved.row != cursor.row: - self._center_followed_row() + with self._holding_paint(): + consumed = self._answer_key(event, cursor) + moved = self._input_state.cursor + self._release_paint(recentering=consumed and moved is not None and moved.row != cursor.row) return consumed def _answer_key(self, event: KeyEvent, cursor: TrackerCursor) -> bool: @@ -1663,8 +1733,10 @@ def _select(self, new_state: TrackerInputState) -> None: A shape ends at the frame's last row, so the band carries the end the cursor now holds — the same landing a Shift+End reach makes, whether a key or a menu item selected it. """ - self._apply_state(new_state) - self._center_followed_row() + with self._holding_paint(): + self._apply_state(new_state) + + self._release_paint(recentering=True) def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. @@ -1802,30 +1874,31 @@ def _followed_row(self) -> Optional[int]: cursor = self._input_state.cursor return cursor.row if cursor is not None else None - def _center_followed_row(self) -> None: - """Brings the row the band follows to its center, where it follows one.""" + def _center_followed_row(self) -> Optional[float]: + """Brings the row the band follows to its center, where it follows one, and answers the scroll asked for.""" row_index = self._followed_row - if row_index is not None: - self._center_row(row_index) + if row_index is None: + return None + + return self._center_row(row_index) - def _center_row(self, row_index: int) -> None: - """Scrolls the grid so a pattern row stands at the band's center. + def _center_row(self, row_index: int) -> Optional[float]: + """Scrolls the grid so a pattern row stands at the band's center, and answers the scroll asked for. The table holds as many rows of the song either side of the frame as stand above the band's center, so every row of the frame has the room to reach it, and the scroll is worked out from the rows' height. A band awaiting its first measurement, or a table holding no - frame, stays as it stands. + frame, stays as it stands, which answers None. """ if self._band.height <= UNMEASURED_BAND or self._current_row_count == 0: - return + return None if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return + return None - dpg.set_y_scroll( - TAG_SEQUENCER_TRACKER_TABLE, - self._band.centering(self._rows_layout.body_row(row_index)), - ) + landing = self._band.centering(self._rows_layout.body_row(row_index)) + dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, landing) + return landing def _row_top(self, row_index: int) -> Optional[float]: """Where a pattern row's top edge stands, in the coordinates the viewport is drawn in.""" diff --git a/src/sampletones_config/layout/general/caret.yaml b/src/sampletones_config/layout/general/caret.yaml index 76ba18227..ca942d320 100644 --- a/src/sampletones_config/layout/general/caret.yaml +++ b/src/sampletones_config/layout/general/caret.yaml @@ -1,4 +1,3 @@ -offset: 3 -width_padding: 2 -fill: .caret_fill -border: .caret_border +height: 2 +color: .caret +frame: .caret_frame diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index fe9493c6a..f0964a175 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#a99cff" feature_duty_cycle: "#e6c26a" - caret_fill: "#4fa6ff55" - caret_border: "#7cc4ffff" + caret: "#7cc4ffff" + caret_frame: "#7cc4ffff" graph_bar: "#4fa6ff" waveform_sample: "#4fa6ff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 5c0f08cdc..1694379c3 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#4a3fa8" feature_duty_cycle: "#7a5c00" - caret_fill: "#0b4c8c58" - caret_border: "#073a6cff" + caret: "#073a6cff" + caret_frame: "#073a6cff" graph_bar: "#0a5aa8" waveform_sample: "#0a5aa8" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 2223d0cd2..60abd2fdb 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#9664ff" feature_duty_cycle: "#ffc864" - caret_fill: "#8888ff80" - caret_border: "#88bbffff" + caret: "#88bbffff" + caret_frame: "#88bbffff" graph_bar: "#64c8ff" waveform_sample: "#64c8ff" diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml index 71a8af6e8..e7f0ad197 100644 --- a/src/sampletones_config/theme/tables/order.yaml +++ b/src/sampletones_config/theme/tables/order.yaml @@ -12,6 +12,14 @@ components: value: .transparent - item_type: Selectable entries: + - type: style + key: ItemSpacing + x: 0 + y: 0 + - type: style + key: SelectableTextAlign + x: 0 + y: 0.5 - type: color key: Header value: .block_selection diff --git a/tests/screens/sequencer/tracker/caret/__init__.py b/tests/screens/sequencer/tracker/caret/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/caret/constants.py b/tests/screens/sequencer/tracker/caret/constants.py new file mode 100644 index 000000000..bfec1706f --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/constants.py @@ -0,0 +1,18 @@ +from typing import Final + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName + +CHANNEL: Final[ChannelName] = ChannelName.PULSE2 +SLOT: Final[SubColumn] = SubColumn.VOICE +CLICKED_ROW: Final[int] = 2 +QUIET_FRAMES: Final[int] = 3 +LANDING_FRAMES: Final[int] = 6 +BURST: Final[int] = 3 +FIRST_POSITION: Final[int] = 0 +FIRST_CHARACTER: Final[int] = 0 +SECOND_CHARACTER: Final[int] = 1 +TYPED_DIGIT: Final[str] = "0" +TYPING_FRAMES: Final[int] = 10 +COLOR_TOLERANCE: Final[float] = 0.12 +RGB: Final[int] = 3 diff --git a/tests/screens/sequencer/tracker/caret/steps.py b/tests/screens/sequencer/tracker/caret/steps.py new file mode 100644 index 000000000..9db178a0c --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/steps.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass +from itertools import pairwise +from typing import Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from automation.boundaries.highlights import HighlightKind +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.views.tracker import caret_mark, frame_rows, tracker_cell +from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.panels.sequencer.columns import tracker_table_column +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.caret.constants import COLOR_TOLERANCE, RGB + + +@dataclass(frozen=True) +class Drawing: + """What one frame left for the next: the row the cursor's cell is laid on and the caret's mark, beside the row + tops the frame itself drew. + + Attributes: + cursor_row: The pattern row whose cell the cursor highlight stands on, or None for none. + mark: The caret's mark as set for the next frame, or None while it is hidden. + row_tops: The top edge of every frame row's slot, as the frame drew them. + """ + + cursor_row: Optional[int] + mark: Optional[Rect] + row_tops: Dict[int, float] + + def pitch(self) -> float: + """How far apart two rows' tops stand.""" + tops = sorted(self.row_tops.values()) + return min(later - earlier for earlier, later in pairwise(tops)) + + +def read_drawing(screen: Screen, channel: ChannelName, slot: SubColumn) -> Drawing: + """Reads the drawing a frame left: the highlighted row and the mark for the next frame, the row tops of this one. + Runs on the render thread. + """ + rows = frame_rows() + column = tracker_table_column(channel) + cursor_row = None + for place in screen.table_highlights(): + if place.table == TAG_SEQUENCER_TRACKER_TABLE and place.kind == HighlightKind.CELL and place.column == column: + cursor_row = next((row for row, index in rows.items() if index == place.row), None) + + tops: Dict[int, float] = {} + for row in rows: + rect = read_item(tracker_cell(row, channel, slot)).rect + if rect is not None: + tops[row] = rect.y + + return Drawing(cursor_row=cursor_row, mark=caret_mark(), row_tops=tops) + + +def frames_apart(drawings: Sequence[Drawing]) -> List[Tuple[int, int, float, float]]: + """Every frame that drew the caret's mark a row clear of the cursor's cell. + + A drawing's mark and cursor row are set for the frame after it, whose row tops the next drawing + reports, so a mark is held against the next drawing's top of the row it was set for. Each entry + names the frame's place in the run, the row, the row's top and the mark's top. + """ + apart = [] + for index, (set_for_next, next_frame) in enumerate(pairwise(drawings)): + if set_for_next.mark is None or set_for_next.cursor_row is None: + continue + + top = next_frame.row_tops[set_for_next.cursor_row] + if not top <= set_for_next.mark.y < top + next_frame.pitch(): + apart.append((index, set_for_next.cursor_row, top, set_for_next.mark.y)) + + return apart + + +def glyph_runs(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> List[Tuple[int, int]]: + """The stretches of pixel columns inside ``cell`` that carry ``color``, each a glyph, as (first, last) columns.""" + x0, y0 = int(cell.x), int(cell.y) + x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height) + window = pixels[y0:y1, x0:x1, :RGB] + distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2) + columns = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=0)) + runs: List[Tuple[int, int]] = [] + for column in columns: + absolute = x0 + int(column) + if runs and absolute == runs[-1][1] + 1: + runs[-1] = (runs[-1][0], absolute) + else: + runs.append((absolute, absolute)) + + return runs + + +def glyph_bottom(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> int: + """The lowest pixel row inside ``cell`` that carries ``color``.""" + x0, y0 = int(cell.x), int(cell.y) + x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height) + window = pixels[y0:y1, x0:x1, :RGB] + distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2) + rows = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=1)) + return y0 + int(rows.max()) diff --git a/tests/screens/sequencer/tracker/caret/test_alignment.py b/tests/screens/sequencer/tracker/caret/test_alignment.py new file mode 100644 index 000000000..afedd6c3b --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/test_alignment.py @@ -0,0 +1,114 @@ +import operator +from functools import partial +from typing import Optional, Sequence, Tuple + +import numpy as np + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.screen import Screen +from automation.steps.sequencer import on_the_sequencer +from automation.views.tracker import tracker_cell +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.caret.constants import ( + CHANNEL, + FIRST_CHARACTER, + FIRST_POSITION, + SECOND_CHARACTER, + SLOT, + TYPED_DIGIT, + TYPING_FRAMES, +) +from tests.screens.sequencer.tracker.caret.steps import glyph_bottom, glyph_runs +from tests.suite.screens.worlds.songs import PAD_ROW + +Color = Tuple[float, ...] + + +def under_run(mark: Rect, run: Tuple[int, int]) -> bool: + """Whether the glyph drawn over the columns ``run`` lies inside the mark's span.""" + first, last = run + return mark.x <= first and last < mark.x + mark.width + + +class TestTheMarkStandsUnderItsCharacter: + """The caret's mark spans exactly the character it marks, from that character's left edge, under the glyph. + + The frame's pixels say where each glyph of the cell stands. The mark is read on the first + character of a voice, on the second once a digit is typed, and on the order grid's first + position, and a glyph the mark is set beside stays outside it. + """ + + def test_the_mark_spans_the_marked_glyph_alone(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + order = screen.sequencer.order + + def mark_and_glyphs(cell: Rect, color: Optional[Color]) -> Tuple[Rect, Sequence[Tuple[int, int]], int]: + assert color is not None + pixels = screen.frame_pixels() + mark = tracker.caret_box() + assert mark is not None + return mark, glyph_runs(pixels, cell, color), glyph_bottom(pixels, cell, color) + + def tracker_cell_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect: + rect = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect) + assert rect is not None + return rect + + def the_first_character_of_a_voice(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(PAD_ROW, CHANNEL, SLOT) + screen.expect(lambda: tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret clicked in") + + mark, runs, bottom = mark_and_glyphs( + tracker_cell_box(PAD_ROW, CHANNEL, SLOT), + tracker.text_color(PAD_ROW, CHANNEL, SLOT), + ) + + assert len(runs) == 2 + assert under_run(mark, runs[FIRST_CHARACTER]) + assert not under_run(mark, runs[SECOND_CHARACTER]) + assert runs[SECOND_CHARACTER][0] >= mark.x + mark.width + assert mark.y >= bottom + + def the_second_character_once_a_digit_is_typed(screen: Screen) -> None: + screen.hand.type_text(TYPED_DIGIT) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, PAD_ROW, CHANNEL, SLOT), + operator.methodcaller("startswith", TYPED_DIGIT), + description="the typed digit pending", + ) + + mark, runs, _ = mark_and_glyphs( + tracker_cell_box(PAD_ROW, CHANNEL, SLOT), + tracker.text_color(PAD_ROW, CHANNEL, SLOT), + ) + + assert len(runs) == 2 + assert under_run(mark, runs[SECOND_CHARACTER]) + assert not under_run(mark, runs[FIRST_CHARACTER]) + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.frames(TYPING_FRAMES) + + def the_order_grids_first_position(screen: Screen) -> None: + order.click(CHANNEL, FIRST_POSITION) + screen.expect(lambda: not tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret handed over") + + cell = order.cell_box(CHANNEL, FIRST_POSITION) + assert cell is not None + mark, runs, bottom = mark_and_glyphs(cell, order.text_color(CHANNEL, FIRST_POSITION)) + + assert len(runs) == 2 + assert under_run(mark, runs[FIRST_CHARACTER]) + assert not under_run(mark, runs[SECOND_CHARACTER]) + assert mark.y >= bottom + + screen.scenario( + the_first_character_of_a_voice, + the_second_character_once_a_digit_is_typed, + the_order_grids_first_position, + ).run() diff --git a/tests/screens/sequencer/tracker/caret/test_follows_the_row.py b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py new file mode 100644 index 000000000..f3b9799b6 --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py @@ -0,0 +1,76 @@ +from functools import partial +from typing import List + +from automation.screen import Screen +from automation.steps.sequencer import on_the_sequencer +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.tracker.caret.constants import ( + BURST, + CHANNEL, + CLICKED_ROW, + LANDING_FRAMES, + QUIET_FRAMES, + SLOT, +) +from tests.screens.sequencer.tracker.caret.steps import Drawing, frames_apart, read_drawing + + +class TestTheCaretFollowsTheRow: + """A key that carries the caret to another row draws the caret and the cursor's cell on that row from the frame + the grid re-centers on, with no frame drawing the two a row apart. + + Every frame between the press and the landing is read: the caret's mark and the highlighted + row set for the next frame, against the row tops that next frame draws. A step down, a burst + of steps, and a step back up are walked, and a step along the row, which asks for no scroll, + ends the run with the caret where it stood. + """ + + def test_the_mark_stands_on_the_cursor_row_on_every_frame(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + reading = partial(read_drawing, screen, CHANNEL, SLOT) + + def frames_of(shortcut: ShortcutId, times: int) -> List[Drawing]: + with screen.record(reading) as recording: + screen.frames(QUIET_FRAMES) + for _ in range(times): + screen.press_shortcut(shortcut) + screen.frames(LANDING_FRAMES) + + return recording.values() + + def expect_landed(drawings: List[Drawing], row: int) -> None: + assert drawings[-1].cursor_row == row + assert frames_apart(drawings) == [] + + def a_step_down_lands_as_one(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(CLICKED_ROW, CHANNEL, SLOT) + screen.expect(lambda: tracker.has_caret(CLICKED_ROW, CHANNEL), bool, description="the caret clicked in") + + drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, 1) + + expect_landed(drawings, CLICKED_ROW + 1) + assert any(drawing.cursor_row == CLICKED_ROW for drawing in drawings) + + def a_burst_of_steps_lands_each_as_one(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, BURST) + + expect_landed(drawings, CLICKED_ROW + 1 + BURST) + + def a_step_up_lands_as_one(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_PREVIOUS_ROW, 1) + + expect_landed(drawings, CLICKED_ROW + BURST) + + def a_step_along_the_row_keeps_the_rows_still(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_NEXT_SUBCOLUMN, 1) + + expect_landed(drawings, CLICKED_ROW + BURST) + assert len({tuple(sorted(drawing.row_tops.items())) for drawing in drawings}) == 1 + + screen.scenario( + a_step_down_lands_as_one, + a_burst_of_steps_lands_each_as_one, + a_step_up_lands_as_one, + a_step_along_the_row_keeps_the_rows_still, + ).run() diff --git a/tests/unit/sampletones_application/ui/elements/table/test_caret.py b/tests/unit/sampletones_application/ui/elements/table/test_caret.py index 3d493f240..886926c16 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_caret.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_caret.py @@ -1,5 +1,5 @@ from typing import Dict, Iterator, Optional -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -23,11 +23,12 @@ DIALOG_WINDOW: DIALOG_ID, } +MARK = 123 +FRAME = 124 CARET_LAYOUT = CaretLayout( - fill=LiteralColor((102, 187, 255, 64)), - border=LiteralColor((102, 187, 255, 255)), - offset=3, - width_padding=2, + height=2, + color=LiteralColor((102, 187, 255, 255)), + frame=LiteralColor((102, 187, 255, 255)), ) @@ -35,12 +36,14 @@ def caret_state() -> Iterator[None]: CaretOverlay._root_window = ROOT_WINDOW CaretOverlay._layout = CARET_LAYOUT - CaretOverlay._rectangle = 123 + CaretOverlay._mark = MARK + CaretOverlay._frame = FRAME CaretOverlay._widget = None yield CaretOverlay._root_window = None CaretOverlay._layout = None - CaretOverlay._rectangle = None + CaretOverlay._mark = None + CaretOverlay._frame = None CaretOverlay._widget = None @@ -92,4 +95,4 @@ def test_redraw_hides_the_box_when_focus_leaves_the_root_tree(self) -> None: with patch("dearpygui.dearpygui.configure_item") as configure: CaretOverlay.redraw() - configure.assert_called_once_with(123, show=False) + assert configure.call_args_list == [call(MARK, show=False), call(FRAME, show=False)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py index 5f447bfd9..a0d09402b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py @@ -1,22 +1,30 @@ -from typing import Generator +from typing import Final, Generator import pytest from sampletones_application.ui.panels.sequencer.tracker import panel as tracker +from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback +UNSCROLLED: Final[float] = 0.0 + @pytest.fixture(autouse=True) def immediate_frame_callbacks(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: """Runs a panel's deferred frame work at once, since a suite renders no frames. - The tracker holds the playhead's mark back to the frame its scroll lands on, which a running - application reaches on its next render and a suite never does. Calling the work as it is - handed over keeps what a panel draws observable from the call that asks for it. + The tracker holds the playhead's mark and the cursor's move back to the frame their scroll + lands on, which a running application reaches on its next render and a suite never does. + Calling the work as it is handed over keeps what a panel draws observable from the call that + asks for it, and the table it reads the scroll it was drawn with from reports a grid at rest. """ def run_now(callback: VoidCallback, frame_count: int = 1) -> None: callback() + def at_rest(table: Sender) -> float: + return UNSCROLLED + monkeypatch.setattr(tracker.FrameCallbackManager, "set_frame_callback", run_now) + monkeypatch.setattr(tracker.dpg, "get_y_scroll", at_rest) yield From 0e83937293fe1db360528ec1b13ca0a18a8d9c46 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 17:51:47 +0200 Subject: [PATCH 05/37] Added: a meter for what an edit's landing costs, headless and on screen --- automation/environment.py | 12 + automation/steps/reconstructions.py | 12 + automation/views/instruments.py | 6 +- automation/vocabulary/graphs.py | 3 + .../benchmarks/history/test_channel_edits.py | 38 +-- tests/benchmarks/reconstruction/__init__.py | 0 tests/benchmarks/reconstruction/conftest.py | 3 + .../reconstruction/test_edit_landing.py | 281 ++++++++++++++++++ .../closing/test_during_a_long_rebuild.py | 80 +++++ .../edits/test_gestures_at_once.py | 35 +-- .../edits/test_long_document_drag.py | 97 ++++++ tests/suite/long_documents.py | 86 ++++++ tests/suite/screens/worlds/recordings.py | 19 ++ .../logic/shared/test_renders_threads.py | 157 ++++++++++ 14 files changed, 769 insertions(+), 60 deletions(-) create mode 100644 automation/vocabulary/graphs.py create mode 100644 tests/benchmarks/reconstruction/__init__.py create mode 100644 tests/benchmarks/reconstruction/conftest.py create mode 100644 tests/benchmarks/reconstruction/test_edit_landing.py create mode 100644 tests/screens/application/closing/test_during_a_long_rebuild.py create mode 100644 tests/screens/reconstructions/edits/test_long_document_drag.py create mode 100644 tests/suite/long_documents.py create mode 100644 tests/unit/sampletones_application/logic/shared/test_renders_threads.py diff --git a/automation/environment.py b/automation/environment.py index 6c17c3748..c7733815a 100644 --- a/automation/environment.py +++ b/automation/environment.py @@ -27,6 +27,8 @@ SIZE_SEPARATOR: Final[str] = "x" ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS" REPORT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPORT" +REPEATS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPEATS" +SINGLE_RUN: Final[int] = 1 STRICT_HISTORY_VARIABLE: Final[str] = f"{SAMPLETONES_ENV_PREFIX}STRICT_HISTORY" STRICT_HISTORY: Final[str] = "true" NO_INPUT_METHOD: Final[str] = "@im=none" @@ -111,6 +113,16 @@ def homes_root(environment: Mapping[str, str]) -> Path: return Path(stated) if stated is not None else Path(tempfile.gettempdir()) +def repeats(environment: Mapping[str, str]) -> int: + """How many times a scenario written to be tried over runs: once, which ``SAMPLETONES_SCREENS_REPEATS`` raises. + + A scenario that holds the application to a clean exit under a race is run many times in one + pass to be believed, and once in every other pass. + """ + stated = environment.get(REPEATS_VARIABLE) + return int(stated) if stated is not None else SINGLE_RUN + + @dataclass(frozen=True) class ScenarioFolders: """Where one scenario's run keeps its files: the home its application lives in, and what the run keeps. diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py index 773429fa4..79e025e62 100644 --- a/automation/steps/reconstructions.py +++ b/automation/steps/reconstructions.py @@ -6,6 +6,7 @@ from automation.holds.regeneration import RegenerationHold from automation.screen import Screen from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction @@ -223,3 +224,14 @@ def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, cha def edited_title(screen: Screen, document: Path) -> str: """The window title while the open reconstruction ``document`` carries unsaved changes.""" return titled(screen, marked(document.name, unsaved=True)) + + +def leave_letting_it_go(screen: Screen) -> None: + """Presses Exit, confirms the question about unsaved changes and waits for the application to close.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() diff --git a/automation/views/instruments.py b/automation/views/instruments.py index c0cf84926..0c562758f 100644 --- a/automation/views/instruments.py +++ b/automation/views/instruments.py @@ -1,4 +1,4 @@ -from typing import Final, Optional, Tuple +from typing import Final, List, Optional, Tuple from automation.dearpygui.bridge import Bridge from automation.dearpygui.hand import Hand @@ -99,6 +99,10 @@ def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" return str(self._bridge.ask(lambda: read_value(self.field(channel, feature)))) + def envelope_items(self, channel: ChannelName, feature: FeatureKey) -> List[int]: + """The items of the envelope ``channel`` draws for ``feature``, read from the field under its graph.""" + return [int(item) for item in self.envelope(channel, feature).split()] + def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str]: """The theme the field wears, which marks a sequence refused or one an export shortens.""" return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py new file mode 100644 index 000000000..fdfa834fa --- /dev/null +++ b/automation/vocabulary/graphs.py @@ -0,0 +1,3 @@ +from typing import Final + +WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating" diff --git a/tests/benchmarks/history/test_channel_edits.py b/tests/benchmarks/history/test_channel_edits.py index 41eca3ac3..493077362 100644 --- a/tests/benchmarks/history/test_channel_edits.py +++ b/tests/benchmarks/history/test_channel_edits.py @@ -1,6 +1,6 @@ import copy from pathlib import Path -from typing import Dict, Final, List +from typing import Final, List import pytest @@ -9,15 +9,11 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import ( - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) +from sampletones_core.instructions import InstructionUnion from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_shared.constants.general import BYTES_PER_MEGABYTE +from tests.suite.long_documents import turning_instructions from tests.suite.memory import retained_bytes from tests.suite.stems import single_entry_stems_data from tests.suite.timing import seconds @@ -25,40 +21,14 @@ DOCUMENT_FRAMES: Final[int] = 4_000 EDITS: Final[int] = 8 SHARE_MARGIN: Final[float] = 1.5 -LOWEST_PITCH: Final[int] = 36 -PITCH_SPAN: Final[int] = 48 -VOLUMES: Final[int] = 16 -DUTY_CYCLES: Final[int] = 4 -NOISE_PERIODS: Final[int] = 16 SAMPLE_NAME: Final[str] = "Long conversion" EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 -def _instruction( - channel_name: ChannelName, - frame: int, -) -> InstructionUnion: - match channel_name: - case ChannelName.PULSE1 | ChannelName.PULSE2: - return PulseInstruction( - on=True, - pitch=LOWEST_PITCH + frame % PITCH_SPAN, - volume=frame % VOLUMES, - duty_cycle=frame % DUTY_CYCLES, - ) - case ChannelName.TRIANGLE: - return TriangleInstruction(on=True, pitch=LOWEST_PITCH + frame % PITCH_SPAN) - case ChannelName.NOISE: - return NoiseInstruction(on=True, period=frame % NOISE_PERIODS, volume=frame % VOLUMES, short=False) - - @pytest.fixture(scope="module", name="document") def document_fixture() -> Reconstruction: """A document shaped like a long conversion: every channel sounding, each frame an instruction of its own.""" - instructions: Dict[ChannelName, List[InstructionUnion]] = { - channel_name: [_instruction(channel_name, frame) for frame in range(DOCUMENT_FRAMES)] - for channel_name in ChannelName.items() - } + instructions = turning_instructions(DOCUMENT_FRAMES) return Reconstruction.create( instructions=instructions, config=Config(), diff --git a/tests/benchmarks/reconstruction/__init__.py b/tests/benchmarks/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/benchmarks/reconstruction/conftest.py b/tests/benchmarks/reconstruction/conftest.py new file mode 100644 index 000000000..c451c3838 --- /dev/null +++ b/tests/benchmarks/reconstruction/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import scheduling + +__all__ = ["scheduling"] diff --git a/tests/benchmarks/reconstruction/test_edit_landing.py b/tests/benchmarks/reconstruction/test_edit_landing.py new file mode 100644 index 000000000..92e054e6a --- /dev/null +++ b/tests/benchmarks/reconstruction/test_edit_landing.py @@ -0,0 +1,281 @@ +import time +from dataclasses import dataclass +from pathlib import Path +from statistics import median +from typing import Callable, Dict, Final, FrozenSet, List, Tuple +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.coordinators.tabs.reconstruction import ( + ReconstructionTabCoordinator, +) +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.edit import ReconstructionEdit +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.reconstruction import ( + ReconstructionPanelLogic, +) +from sampletones_application.logic.reconstruction.rewrites.queue import ( + ReconstructionRewrites, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.general import BYTES_PER_MEGABYTE +from tests.suite.application import HeldQueue +from tests.suite.history.wiring import wired_history +from tests.suite.language import FakeLanguageManager +from tests.suite.long_documents import VOLUMES, long_document +from tests.suite.questions import dialogs_on_the_line + +SHORT_SECONDS: Final[float] = 30.0 +LONG_SECONDS: Final[float] = 120.0 +EDITS: Final[int] = 5 +HISTORY_BUDGET: Final[int] = 16 +RENDER_BUDGET: Final[int] = 1024 * BYTES_PER_MEGABYTE +EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +MUTED_STEM_ID: Final[int] = 1 +RECORDINGS: Final[int] = 2 +RECORDING_FREQUENCY: Final[float] = 220.0 +RECORDING_AMPLITUDE: Final[float] = 0.5 +LOWEST_LEVEL: Final[int] = 1 +NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() +DOCUMENT_NAME: Final[str] = "Long conversion" + + +@dataclass(frozen=True) +class Landing: + """What one edit cost: the worker's seconds up to its report, and the render thread's seconds landing it.""" + + worker_seconds: float + landing_seconds: float + + +def _timed(work: Callable[[], None]) -> float: + started = time.perf_counter() + work() + return time.perf_counter() - started + + +def _discard(*readings: object, **named: object) -> None: + """Where the panel's readings go: the tab would draw them, and the meter reads their cost alone.""" + + +def _session_manager() -> MagicMock: + session_manager = MagicMock() + session_manager.get_instrument_path.return_value = Path("/tmp/instruments") + session_manager.get_audio_path.return_value = Path("/tmp/audio") + return session_manager + + +class LandingMeter: + """The Reconstructions tab's edit path with no DearPyGui, wired the way the composition root wires it. + + The real regeneration runs on its worker, the rewrites take the steps, the coordinator lands each + edit through a strict history over a project, and the panel logic answers every reading the tab + would draw. The queue the render loop drains is held, so what a landing costs the render thread + is read by draining it. + """ + + def __init__(self, scheduling: SchedulingBehavior) -> None: + self.renders = RenderCache(budget_bytes=RENDER_BUDGET) + self.manager = ReconstructionManager(scheduling=scheduling, renders=self.renders) + self.rewrites = ReconstructionRewrites(self.manager, RegenerationService()) + self.controller = ProjectController(ProjectManager()) + self.history = wired_history(self.controller, budget=HISTORY_BUDGET, strict=True) + export_backends: Dict[ExportFormat, ExportBackend] = {} + self.panel = ReconstructionPanelLogic(_session_manager(), self.manager, MagicMock(), export_backends) + self.panel.on_view_changed = _discard + self.panel.on_audio_data_changed = _discard + self.panel.on_waveform_load_changed = _discard + self.panel.on_waveform_update_changed = _discard + self.panel.on_waveform_cleared = _discard + self.panel.on_waveform_source_changed = _discard + self.panel.on_stems_view_changed = _discard + self.panel.on_ownership_changed = _discard + self.panel.on_heard_changed = _discard + self.coordinator = ReconstructionCoordinator( + self.manager, + MagicMock(), + self.rewrites, + MagicMock(), + self.controller, + self.history, + dialogs=dialogs_on_the_line(), + language_manager=FakeLanguageManager({}), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=self._write_back, + ) + self.coordinator.set_reconstructions_tab(self._tab()) + self.controller.on_project_replaced = self.history.reset + self.controller.new() + + def open_as_sample(self, document: Reconstruction) -> float: + """Adds ``document`` to the project and opens it on the tab, which renders every channel cold. + + Returns: + float: The seconds the opening took. + """ + with self.history.transaction(HistoryAction.ADD_SAMPLE): + sample = self.controller.add_sample(document, DOCUMENT_NAME) + + return _timed( + lambda: self.manager.load_reconstruction_object( + sample.reconstruction, + name=DOCUMENT_NAME, + voice_id=sample.id, + ) + ) + + def open_standalone(self, document: Reconstruction) -> float: + """Opens ``document`` on the tab as a file would be, its recordings read, which renders every channel cold. + + Returns: + float: The seconds the opening took. + """ + return _timed( + lambda: self.manager.load_reconstruction_object( + document, + name=DOCUMENT_NAME, + voice_id=None, + ) + ) + + def mute(self, stem_id: int) -> None: + """Leaves one recording out of what the reader hears, the way a box on the stems card does.""" + self.panel.set_stem_channels(stem_id, NO_CHANNELS) + + def edit(self, step: int) -> Landing: + """Moves the edited channel's volume by ``step`` and lands the rebuild, reading what each side cost.""" + envelopes = self.manager.current_features + assert envelopes is not None + volume = envelopes[EDITED_CHANNEL].volume + moved = volume.model_copy( + update={"items": tuple(LOWEST_LEVEL + (item + step) % (VOLUMES - 1) for item in volume.items)} + ) + change = ChannelChange( + channel_name=EDITED_CHANNEL, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: moved}, + initial_pitch=None, + ) + queue = HeldQueue() + with patch.object(CallbackQueue, "add", queue.add): + self.rewrites.request(change) + worker_seconds = _timed(SingleThreadExecutor.join_all) + landing_seconds = _timed(queue.drain) + + return Landing(worker_seconds=worker_seconds, landing_seconds=landing_seconds) + + def _tab(self) -> MagicMock: + tab = MagicMock(spec=ReconstructionTabCoordinator) + tab.display_reconstruction.side_effect = self.panel.display_reconstruction + tab.update_reconstruction.side_effect = self.panel.update_reconstruction + tab.redraw_reconstruction.side_effect = self.panel.update_reconstruction + return tab + + def _write_back(self, edit: ReconstructionEdit) -> None: + voice_id = self.manager.voice_id + if voice_id is None: + return + + with self.history.transaction(edit.history_action, coalesce=edit.coalesce_key(voice_id)): + self.controller.replace_sample_reconstruction(voice_id, edit.reconstruction) + + +def _report( + label: str, + opening: float, + landings: List[Landing], + cache_bytes: int, +) -> None: + workers = [landing.worker_seconds for landing in landings] + landed = [landing.landing_seconds for landing in landings] + print( + f"\n{label}\n" + f" opening, every channel rendered cold: {opening:.3f} s\n" + f" per edit, median of {len(landings)} (least): worker {median(workers):.3f} s ({min(workers):.3f} s), " + f"landing on the render thread {median(landed):.3f} s ({min(landed):.3f} s)\n" + f" renders kept: {cache_bytes / BYTES_PER_MEGABYTE:.0f} MB" + ) + + +@pytest.fixture(scope="module", name="recordings") +def recordings_fixture(tmp_path_factory: pytest.TempPathFactory) -> Tuple[Path, ...]: + """Two recordings sharing the long document's length, each a tone of its own, written once for the module.""" + folder = tmp_path_factory.mktemp("recordings") + config = Config() + seconds = LONG_SECONDS / RECORDINGS + times = np.arange(round(seconds * config.sample_rate)) / config.sample_rate + paths: List[Path] = [] + for index in range(RECORDINGS): + path = folder / f"take{index}.wav" + tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * RECORDING_FREQUENCY * (index + 1) * times) + write_wave(path, config.sample_rate, tone.astype(np.float32)) + paths.append(path) + + return tuple(paths) + + +class TestWhatAnEditCosts: + """Readings of what one edit costs the worker and the render thread, beside what a cold opening costs. + + The readings hold the edit path to nothing; they are what a change to it is judged by, taken on + this meter before and after the change. Each case lands a burst of edits of one channel the way + a drag lands them, one after the other, and reports the median and the least of each side's + seconds. A project sample carries the strict history's fingerprint on each landing; a standalone + document with its recordings read carries the original mix and, with a recording muted, the + filtered reading. + """ + + @pytest.mark.parametrize("seconds", [SHORT_SECONDS, LONG_SECONDS], ids=["30s", "120s"]) + def test_a_project_sample_every_recording_heard( + self, + scheduling: SchedulingBehavior, + seconds: float, + ) -> None: + meter = LandingMeter(scheduling) + opening = meter.open_as_sample(long_document(seconds, config=Config())) + before = meter.manager.reconstruction + + landings = [meter.edit(step) for step in range(1, EDITS + 1)] + + _report(f"project sample, {seconds:.0f} s, every recording heard", opening, landings, meter.renders.held_bytes) + assert meter.manager.reconstruction is not before + + def test_a_standalone_document_with_a_recording_muted( + self, + scheduling: SchedulingBehavior, + recordings: Tuple[Path, ...], + ) -> None: + meter = LandingMeter(scheduling) + opening = meter.open_standalone(long_document(LONG_SECONDS, config=Config(), recordings=recordings)) + meter.mute(MUTED_STEM_ID) + before = meter.manager.reconstruction + + landings = [meter.edit(step) for step in range(1, EDITS + 1)] + + _report( + f"standalone document, {LONG_SECONDS:.0f} s, recordings read, one muted", + opening, + landings, + meter.renders.held_bytes, + ) + assert meter.manager.reconstruction is not before diff --git a/tests/screens/application/closing/test_during_a_long_rebuild.py b/tests/screens/application/closing/test_during_a_long_rebuild.py new file mode 100644 index 000000000..6131284e6 --- /dev/null +++ b/tests/screens/application/closing/test_during_a_long_rebuild.py @@ -0,0 +1,80 @@ +import os +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.environment import repeats +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world + +DRAGGED_ITEM: Final[int] = 3 +QUIET: Final[float] = 2.0 +NEAR_THE_START: Final[float] = 0.05 +ZOOM_NOTCHES: Final[int] = 5 +REPEATS: Final[int] = repeats(os.environ) + + +class TestClosingWhileALongRebuildRuns: + """Closing the window the moment a two-minute document's rebuild is let go waits for it and leaves. + + A volume bar is dragged while the rebuild is held, so the rebuild is released and the close asked + for in one breath, and the close meets the rebuild on its worker. The application waits for the + edit, asks about the unsaved document, and leaves on the answer. The scenario runs as many times + as ``SAMPLETONES_SCREENS_REPEATS`` says, which is how a change to the worker's work is held to a + clean exit over and over. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the two-minute reconstruction with its recording.""" + return long_document_world() + + @pytest.fixture + def startup(self) -> Startup: + """The two-minute reconstruction is open at start, with no project.""" + return Startup(reconstruction=LONG_RECONSTRUCTION, project=None) + + @pytest.mark.parametrize("attempt", range(REPEATS)) + def test_the_close_waits_for_the_rebuild_and_leaves( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + attempt: int, + ) -> None: + """The question about the edited document comes once the rebuild lands, and confirming it leaves.""" + prompt = screen.reconstructions.unsaved_prompt + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + + def drag_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, LONG_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) + + graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=QUIET) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def release_and_close_at_once(screen: Screen) -> None: + regeneration_hold.release() + screen.close_window() + + def the_question_comes_and_the_application_leaves(screen: Screen) -> None: + screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + drag_while_the_rebuild_is_held, + release_and_close_at_once, + the_question_comes_and_the_application_leaves, + ).run() diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py index 601f04ec5..aafd16c6c 100644 --- a/tests/screens/reconstructions/edits/test_gestures_at_once.py +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -11,6 +11,7 @@ from automation.steps.project import save_project_as from automation.steps.reconstructions import ( expect_open, + leave_letting_it_go, load_from_the_browser, marked, raise_the_first_level, @@ -51,11 +52,6 @@ SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" -def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]: - """The items of the envelope that ``channel`` draws for ``feature``, read from the instruments panel.""" - return [int(item) for item in instruments.envelope(channel, feature).split()] - - def dragged( graph: BarGraph, standing: List[int], @@ -82,23 +78,12 @@ def expect_items( ) -> None: """Waits until the envelope of ``channel`` and ``feature`` reads ``expected``.""" screen.expect( - lambda: items(instruments, channel, feature), + lambda: instruments.envelope_items(channel, feature), expected.__eq__, description=f"{channel} {feature} as the edits left it", ) -def leave_letting_it_go(screen: Screen) -> None: - """Presses Exit, confirms the question about unsaved changes and waits for the application to close.""" - prompt = screen.reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - - class TestADragAndARemovalAtOnce: """A volume dragged and a recording removed while the drag is on its way both land. @@ -124,7 +109,7 @@ def drag_while_the_rebuild_is_held(screen: Screen) -> None: instruments.bring_forward(ChannelName.PULSE1) screen.hand.scroll_into_view(graph.plot) graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) - standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) @@ -145,7 +130,7 @@ def both_land(screen: Screen) -> None: screen.expect(lambda: stems.has_row(LAST_RECORDING), operator.not_, description="the recording gone") index, expected = moved[0] screen.expect( - lambda: items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1], + lambda: instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1], expected[: index + 1].__eq__, description="the drag landed", ) @@ -189,7 +174,7 @@ def edit_the_sample_and_save(screen: Screen) -> None: ChannelName.PULSE1, title=voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True), ) - edited.append(items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)) + edited.append(instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)) save_project_as(screen, SAVED_SONG) @@ -199,7 +184,7 @@ def drag_and_undo_while_the_rebuild_is_held(screen: Screen) -> None: release = graph.drag_item(FIRST_ITEM, start=edited[0][FIRST_ITEM], end=QUIET) index, _ = graph.item_under(release) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") - assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index] + assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index] screen.press_shortcut(ShortcutId.UNDO) @@ -208,7 +193,7 @@ def the_drag_alone_is_undone(screen: Screen) -> None: expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, edited[0]) screen.frames(SETTLE_FRAMES) - assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0] + assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0] screen.scenario( edit_the_sample_and_save, drag_and_undo_while_the_rebuild_is_held, the_drag_alone_is_undone @@ -240,7 +225,7 @@ def drag_and_save_while_the_rebuild_is_held(screen: Screen) -> None: stored.append(SHORT_RECONSTRUCTION.read_bytes()) instruments.bring_forward(ChannelName.PULSE1) screen.hand.scroll_into_view(graph.plot) - standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") @@ -287,7 +272,7 @@ def drag_the_volume_while_held(screen: Screen) -> None: expect_open(screen, SHORT_RECONSTRUCTION) instruments.bring_forward(ChannelName.PULSE1) screen.hand.scroll_into_view(volume.plot) - standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) moved.append(dragged(volume, standing, end=QUIET, bounds=(0, LOUDEST))) @@ -295,7 +280,7 @@ def drag_the_volume_while_held(screen: Screen) -> None: def drag_the_arpeggio_meanwhile(screen: Screen) -> None: screen.hand.scroll_into_view(arpeggio.plot) - standing = items(instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.ARPEGGIO) moved.append(dragged(arpeggio, standing, end=ARPEGGIO_STEP, bounds=(ARPEGGIO_FLOOR, ARPEGGIO_CEILING))) diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py new file mode 100644 index 000000000..b12fc1d61 --- /dev/null +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -0,0 +1,97 @@ +import json +import os +import time +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.environment import ARTIFACTS_VARIABLE +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, leave_letting_it_go +from automation.vocabulary.graphs import WAVEFORM_REGENERATING +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world + +DRAGGED_ITEMS: Final[Tuple[int, ...]] = (2, 4, 6, 8) +QUIET: Final[float] = 2.0 +LOUD: Final[float] = 13.0 +NEAR_THE_START: Final[float] = 0.05 +ZOOM_NOTCHES: Final[int] = 5 +LONG_GAP_SECONDS: Final[float] = 0.1 +DRAIN_TIMEOUT_SECONDS: Final[float] = 120.0 +READINGS_FILE: Final[str] = "frame_gaps.json" + + +def _frame_readings(stamps: List[float], last_release: float) -> Dict[str, float]: + """What the clock said after every frame, read as the gaps between frames and the frames a second.""" + gaps = [later - earlier for earlier, later in zip(stamps, stamps[1:])] + span = stamps[-1] - stamps[0] + return { + "frames": len(stamps), + "seconds": span, + "longest_gap_seconds": max(gaps), + "gaps_over_a_tenth": sum(1 for gap in gaps if gap > LONG_GAP_SECONDS), + "frames_per_second": (len(stamps) - 1) / span, + "drain_seconds_after_the_last_release": stamps[-1] - last_release, + } + + +class TestADragOnALongDocument: + """How the frames keep coming while envelope bars of a two-minute document are dragged. + + The scenario records the clock after every frame through a burst of drags and until the + waveform's fade ends, then keeps the longest gap between two frames, how many gaps ran over a + tenth of a second, the frames drawn a second, and how long the line took to empty after the + last release, beside the scenario's other files. It holds the application to nothing: the + readings are what a change to the edit path is judged by, taken before and after the change. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the two-minute reconstruction with its recording.""" + return long_document_world() + + @pytest.fixture + def startup(self) -> Startup: + """The two-minute reconstruction is open at start, with no project.""" + return Startup(reconstruction=LONG_RECONSTRUCTION, project=None) + + def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: + """The readings are kept; the application is only asked to keep drawing and to leave cleanly.""" + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + busy = screen.words(WAVEFORM_REGENERATING) + stamps: List[float] = [] + releases: List[float] = [] + + def open_and_zoom_in(screen: Screen) -> None: + expect_open(screen, LONG_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + + def drag_until_the_line_empties(screen: Screen) -> None: + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) + with screen.record(time.monotonic) as recording: + for turn, item in enumerate(DRAGGED_ITEMS): + graph.drag_item(item, start=standing[item], end=QUIET if turn % 2 == 0 else LOUD) + releases.append(time.monotonic()) + + screen.bridge.expect( + screen.status, + busy.__ne__, + description="the line empty", + timeout=DRAIN_TIMEOUT_SECONDS, + ) + + stamps.extend(recording.values()) + + def keep_the_readings(screen: Screen) -> None: + readings = _frame_readings(stamps, releases[-1]) + print(json.dumps(readings)) + (Path(os.environ[ARTIFACTS_VARIABLE]) / READINGS_FILE).write_text(json.dumps(readings, indent=2)) + + screen.scenario(open_and_zoom_in, drag_until_the_line_empties, keep_the_readings, leave_letting_it_go).run() diff --git a/tests/suite/long_documents.py b/tests/suite/long_documents.py new file mode 100644 index 000000000..b728834a1 --- /dev/null +++ b/tests/suite/long_documents.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.reconstructions import Reconstruction +from tests.suite.stems import recorded_from, single_entry_stems_data + +LOWEST_PITCH: Final[int] = 36 +PITCH_SPAN: Final[int] = 48 +VOLUMES: Final[int] = 16 +DUTY_CYCLES: Final[int] = 4 +NOISE_PERIODS: Final[int] = 16 +UNIT_COEFFICIENT: Final[float] = 1.0 +NO_RECORDINGS: Final[Tuple[Path, ...]] = () + + +def turning_instruction( + channel_name: ChannelName, + frame: int, +) -> InstructionUnion: + """The instruction ``channel_name`` plays at ``frame`` of a long conversion. + + The pitch, the level and the timbre each turn with the frame, so every frame is an instruction + of its own and a stream of them compresses as a conversion's does. + """ + match channel_name: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return PulseInstruction( + on=True, + pitch=LOWEST_PITCH + frame % PITCH_SPAN, + volume=frame % VOLUMES, + duty_cycle=frame % DUTY_CYCLES, + ) + case ChannelName.TRIANGLE: + return TriangleInstruction(on=True, pitch=LOWEST_PITCH + frame % PITCH_SPAN) + case ChannelName.NOISE: + return NoiseInstruction(on=True, period=frame % NOISE_PERIODS, volume=frame % VOLUMES, short=False) + + +def turning_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: + """Every channel sounding through ``frames`` frames, each frame an instruction of its own.""" + return { + channel_name: [turning_instruction(channel_name, frame) for frame in range(frames)] + for channel_name in ChannelName.items() + } + + +def long_document( + seconds: float, + *, + config: Config, + recordings: Sequence[Path] = NO_RECORDINGS, +) -> Reconstruction: + """A document shaped like a long conversion, lasting ``seconds`` at the rate ``config`` runs at. + + Every channel sounds through every frame. With ``recordings``, the frames are shared out among + them in turn: the document names one recording per path, and each channel plays its part once + per recording, so a reader can leave a recording out and hear the rest. + """ + frames = round(seconds * config.nes_frequency) + if not recordings: + instructions = turning_instructions(frames) + return Reconstruction.create( + instructions=instructions, + config=config, + coefficient=UNIT_COEFFICIENT, + audio_filepath=(), + stems_data=single_entry_stems_data(ChannelName.items(), instructions), + ) + + instructions = turning_instructions(frames // len(recordings)) + document = Reconstruction.create( + instructions=instructions, + config=config, + coefficient=UNIT_COEFFICIENT, + audio_filepath=tuple(recordings[:1]), + stems_data=single_entry_stems_data(ChannelName.items(), instructions), + ) + return recorded_from(document, recordings) diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py index 9021c4c43..574003077 100644 --- a/tests/suite/screens/worlds/recordings.py +++ b/tests/suite/screens/worlds/recordings.py @@ -22,10 +22,13 @@ SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" +LONG_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Long.stn" TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") +LONG_TAKE: Final[str] = "long.wav" STEM_FRAMES: Final[int] = 60 PLAYABLE_FRAMES: Final[int] = 600 +LONG_FRAMES: Final[int] = 7200 FRAMES_PER_SECOND: Final[int] = 60 TAKE_FREQUENCY: Final[float] = 220.0 @@ -98,3 +101,19 @@ def playing_world() -> World: StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), ), ) + + +def long_document_world() -> World: + """A home holding :data:`LONG_RECONSTRUCTION`, two minutes of one recording, which an edit takes long enough + to rebuild and render for the frames to be read while it does. + """ + take = Path.cwd() / LONG_TAKE + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + Recording(destination=take, seconds=LONG_FRAMES / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY), + PlayableReconstruction(LONG_RECONSTRUCTION, (take,), LONG_FRAMES, DEFAULT_NES_FREQUENCY), + ), + ) diff --git a/tests/unit/sampletones_application/logic/shared/test_renders_threads.py b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py new file mode 100644 index 000000000..7f79184ed --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py @@ -0,0 +1,157 @@ +import gc +import sys +import threading +from functools import partial +from pathlib import Path +from typing import Callable, Final, Iterator, List + +import numpy as np +import pytest + +from sampletones_application.logic.shared import renders as renders_module +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +from tests.suite.stems import SHARED_CHANNEL, taking_turns_reconstruction + +JOIN_TIMEOUT: Final[float] = 10.0 +SWITCH_INTERVAL: Final[float] = 1e-5 +REPEATS: Final[int] = 5 +READERS: Final[int] = 3 +WRITERS: Final[int] = 2 +ROUNDS: Final[int] = 30 +KEPT_RENDERS: Final[int] = 3 +WRITER_SPACING: Final[int] = 1000 +RenderInstructions = Callable[[List[InstructionUnion], ChannelName, Config], np.ndarray] + + +def _document(index: int) -> Reconstruction: + """A small document of its own, which no other document shares a stream with.""" + return taking_turns_reconstruction((Path(f"{index}-a.wav"), Path(f"{index}-b.wav"))).detached() + + +@pytest.fixture(name="switching_often") +def switching_often_fixture() -> Iterator[None]: + """The interpreter handing the lock over every few microseconds, so the threads meet in more places.""" + interval = sys.getswitchinterval() + sys.setswitchinterval(SWITCH_INTERVAL) + yield + sys.setswitchinterval(interval) + + +@pytest.fixture(name="thread_failures") +def thread_failures_fixture(monkeypatch: pytest.MonkeyPatch) -> List[BaseException]: + """What a thread of the case raised and left unhandled, which the case reads once every thread has ended.""" + failures: List[BaseException] = [] + monkeypatch.setattr(threading, "excepthook", lambda args: failures.append(args.exc_value)) + return failures + + +def _run_together(workers: List[Callable[[], None]]) -> None: + """Runs every worker on a thread of its own and waits for all of them. + + Raises: + AssertionError: If a worker is still running once the join's time has run, which is what a + deadlock on the cache's lock looks like. + """ + threads = [threading.Thread(target=work, name=f"RenderCaseWorker-{index}") for index, work in enumerate(workers)] + for thread in threads: + thread.start() + + for thread in threads: + thread.join(JOIN_TIMEOUT) + + assert not any(thread.is_alive() for thread in threads), "a worker never finished" + + +def _reads(renders: RenderCache, shared: Reconstruction) -> None: + expected = rendered_channels(shared) + expected_mix = rendered_mix(shared) + for _ in range(ROUNDS): + channels = renders.channels(shared) + assert channels.keys() == expected.keys() + assert all(np.array_equal(channels[name], expected[name]) for name in expected) + assert np.array_equal(renders.mix(shared), expected_mix) + + +def _writes(renders: RenderCache, offset: int) -> None: + for turn in range(ROUNDS): + renders.mix(_document(offset + turn)) + + +class TestReadersAndWritersSharingOneCache: + """Readers on several threads read whole renders while writers fill the cache past its budget and let + their documents go. + + The budget holds a few renders, so the writers evict what the readers keep reading, and each + writer's documents die as it moves on, so their renders are forgotten from the writers' threads. + Every read answers with the render the document sounds, and once every document has gone the + cache keeps nothing and counts nothing. + """ + + @pytest.mark.parametrize("repeat", range(REPEATS)) + def test_every_read_is_whole_and_the_accounts_balance( + self, + repeat: int, + switching_often: None, + thread_failures: List[BaseException], + ) -> None: + shared = _document(0) + budget = KEPT_RENDERS * rendered_channels(shared)[SHARED_CHANNEL].nbytes + renders = RenderCache(budget_bytes=budget) + + _run_together( + [partial(_reads, renders, shared)] * READERS + + [partial(_writes, renders, WRITER_SPACING * (writer + 1)) for writer in range(WRITERS)] + ) + + assert thread_failures == [] + assert renders.held_bytes <= budget + del shared + gc.collect() + assert renders.held_bytes == 0 + + +class TestADocumentLetGoWhileItsRenderIsMade: + """A document let go of everywhere else stays with the thread rendering it, and its render leaves once + that thread lets go. + + The maker's own reference keeps the streams alive through the render, so the render is kept + under a live identity; the forgetting then runs on the maker's thread as its references die. + """ + + def test_the_render_leaves_once_the_maker_lets_go( + self, + renders: RenderCache, + monkeypatch: pytest.MonkeyPatch, + thread_failures: List[BaseException], + ) -> None: + rendering = threading.Event() + release = threading.Event() + real_render: RenderInstructions = renders_module.render_instructions + + def held_render( + instructions: List[InstructionUnion], + channel_name: ChannelName, + config: Config, + ) -> np.ndarray: + rendering.set() + assert release.wait(JOIN_TIMEOUT) + return real_render(instructions, channel_name, config) + + monkeypatch.setattr(renders_module, "render_instructions", held_render) + maker = threading.Thread(target=renders.mix, args=(_document(0),), name="RenderCaseMaker") + maker.start() + assert rendering.wait(JOIN_TIMEOUT) + gc.collect() + + release.set() + maker.join(JOIN_TIMEOUT) + + assert not maker.is_alive() + assert thread_failures == [] + gc.collect() + assert renders.held_bytes == 0 From c984868262ef3e6755e1c3b0bb3903a8838684bc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 17:57:32 +0200 Subject: [PATCH 06/37] Retuned: the tracker's palettes and fades for readable text under every wash --- .../layout/tabs/sequencer/colors/tracker.py | 5 +- .../ui/panels/sequencer/tracker/panel.py | 22 ++- .../ui/panels/sequencer/tracker/themes.py | 7 + .../layout/tabs/sequencer/colors.yaml | 1 + .../layout/tabs/sequencer/tracker.yaml | 2 +- src/sampletones_config/palettes/dark.yaml | 14 +- src/sampletones_config/palettes/light.yaml | 16 +- src/sampletones_config/palettes/studio.yaml | 22 +-- .../theme/tables/pattern.yaml | 2 +- src/sampletones_shared/utils/color.py | 36 ++++ .../sequencer/colors/test_readability.py | 176 ++++++++++++++++++ .../sampletones_shared/utils/test_color.py | 59 +++++- 12 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 tests/screens/sequencer/colors/test_readability.py diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py index c297d3682..ea537a970 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py @@ -11,11 +11,14 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True): is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are the two kinds a named voice can be, so the slot's color reports what it holds. ``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row`` - indices and the ``order`` entries carry the grids around them. Defining them once + indices and the ``order`` entries carry the grids around them. ``pending`` is what a slot + wears while an entry is being typed into it, whatever kind the entry will turn out to be, + so the digits being written stand out from everything around them. Defining them once keeps every panel in step. """ voice: WrittenColor + pending: WrittenColor transpose: WrittenColor volume: WrittenColor sample: WrittenColor diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 5c0e8e81b..c0aeed0ba 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -184,6 +184,7 @@ class GUISequencerTrackerPanel(GUIPanel): + _input_state: TrackerInputState = TrackerInputState() _paint_held: bool = False _held_move: Optional[CursorMove] = None @@ -1102,8 +1103,12 @@ def _cell_theme(self, key: CellKey) -> int: A voice slot takes the color of the kind of voice standing in it, so a reader tells a sample from an instrument across the whole grid; a slot naming none takes the - neutral shade the other slots' colors are read against. + neutral shade the other slots' colors are read against. The slot an entry is being + typed into wears the pending color instead, so the digits being written read first. """ + if self._is_pending_cell(key): + return self._themes.pending + _, channel, subcolumn = key return self._themes.cell( subcolumn, @@ -1111,6 +1116,15 @@ def _cell_theme(self, key: CellKey) -> int: muted=self._is_muted_cell(channel), ) + def _is_pending_cell(self, key: CellKey) -> bool: + """Whether an entry is being typed into the slot ``key`` names.""" + cursor = self._input_state.cursor + return ( + bool(self._input_state.pending) + and cursor is not None + and (cursor.row, cursor.channel, cursor.subcolumn) == key + ) + def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool: """Whether a cell's channel is silenced, the sample column speaking for every channel.""" return channel is not None and self._is_muted(channel) @@ -1126,11 +1140,17 @@ def _update_cell_display( row: int, channel: Optional[ChannelName], ) -> None: + """Redraws a cell's three slots, label and theme, as the input state now has them. + + The theme comes with the label because a slot changes color as an entry starts being typed + into it and again as the entry commits or is let go. + """ for subcolumn in SubColumn: key = (row, channel, subcolumn) cell_id = self._editable_cells.widget(key) if cell_id is not None: dpg.configure_item(cell_id, label=self._render_cell(key)) + dpg.bind_item_theme(cell_id, self._cell_theme(key)) def _update_caret(self, scroll_shift: float) -> None: """Arms (or clears) the shared caret on the active subcolumn cell, ``scroll_shift`` ahead of a landing diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py index eeddb942c..aa59a7670 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py @@ -30,6 +30,7 @@ def __init__(self, layout: SequencerLayout) -> None: self._subcolumn: Dict[ThemeKey, int] = {} self._muted_subcolumn: Dict[ThemeKey, int] = {} self._context_subcolumn: Dict[ThemeKey, int] = {} + self._pending: int = UNBUILT_THEME self._header: int = UNBUILT_THEME self._muted_header: int = UNBUILT_THEME self._column_label: int = UNBUILT_THEME @@ -39,6 +40,7 @@ def __init__(self, layout: SequencerLayout) -> None: def create(self) -> None: """Builds every theme the grid binds, which a DearPyGui context has to stand behind.""" self._create_subcolumn_themes() + self._pending = create_selectable_text_theme(self._layout.colors.text.pending) self._create_header_themes() row = self._layout.colors.text.row self._row_number = create_selectable_text_theme(row) @@ -113,6 +115,11 @@ def cell( themes = self._muted_subcolumn if muted else self._subcolumn return themes[(subcolumn, kind)] + @property + def pending(self) -> int: + """The theme a cell wears while an entry is being typed into it: the plain text color, whatever the channel.""" + return self._pending + def context_cell(self, subcolumn: SubColumn, kind: Optional[VoiceKind]) -> int: """The theme a cell of another frame's row wears: its slot's color, dimmed, as a plain label.""" return self._context_subcolumn[(subcolumn, kind)] diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index c2fd30026..58a6c1516 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -30,6 +30,7 @@ history: separator: .history_separator text: voice: .text_disabled + pending: .text transpose: .tracker_transpose volume: .tracker_volume sample: .voice_sample diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 10a90f729..8a3e4d232 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -4,7 +4,7 @@ row_height: 29 header_height: 30 octave_width: 90 channel_column_tint: 0.09 -muted_text_fraction: 0.45 +muted_text_fraction: 0.7 subcolumn_widths: voice: 26 transpose: 30 diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index f0964a175..f543f4c14 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -103,7 +103,7 @@ colors: text: "#e6e6e8" text_muted: "#9a9aa0" - text_disabled: "#75757b" + text_disabled: "#85858c" text_trace: "#c0c0c4" contrast: "#ffffff" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#2a2a30" tracker_beat_row: "#ffffff14" - tracker_bar_row: "#ffffff28" + tracker_bar_row: "#ffffff1c" - block_selection: "#b98af360" + block_selection: "#b98af330" pattern_highlight: "#ffffff40" - cell_cursor: "#4fa6ffb0" - cursor_row: "#4fa6ff3a" + cell_cursor: "#4fa6ff24" + cursor_row: "#4fa6ff2c" playback_row: "#5ec46e42" order_label: "#2f3a46" @@ -209,8 +209,8 @@ colors: tracker_transpose: "#c0c0c4ff" tracker_volume: "#7ee787ff" tracker_frame: "#4fa6ffff" - tracker_row: "#9a9aa2ff" + tracker_row: "#a6a6aeff" tracker_order: "#c4d0e0ff" voice_sample: "#e0c860ff" - voice_instrument: "#f476d8ff" + voice_instrument: "#ffa3ecff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 1694379c3..1db7633b8 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -103,7 +103,7 @@ colors: text: "#0a0c10" text_muted: "#414852" - text_disabled: "#767d88" + text_disabled: "#6b7380" text_trace: "#2c323b" contrast: "#000000" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#dfe4ec" tracker_beat_row: "#00000016" - tracker_bar_row: "#0000002c" + tracker_bar_row: "#00000024" - block_selection: "#6b4ea840" + block_selection: "#6b4ea82a" pattern_highlight: "#00000018" - cell_cursor: "#0b4c8c60" - cursor_row: "#0b4c8c24" + cell_cursor: "#0b4c8c1e" + cursor_row: "#0b4c8c1c" playback_row: "#16702e48" order_label: "#c6cbd5" @@ -207,10 +207,10 @@ colors: history_separator: "#8f97a4ff" tracker_transpose: "#3d434dff" - tracker_volume: "#16702eff" + tracker_volume: "#0f5a24ff" tracker_frame: "#0a5aa8ff" - tracker_row: "#5f6773ff" + tracker_row: "#4a515cff" tracker_order: "#28303dff" - voice_sample: "#7a5200ff" + voice_sample: "#704b00ff" voice_instrument: "#9a137cff" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 60abd2fdb..e6cbc49ca 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -103,7 +103,7 @@ colors: text: "#e7e7f0" text_muted: "#9497ab" - text_disabled: "#767a8e" + text_disabled: "#8c90a4" text_trace: "#c0c0c0" contrast: "#ffffff" @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#9664ff" feature_duty_cycle: "#ffc864" - caret: "#88bbffff" - caret_frame: "#88bbffff" + caret: "#a8c8ffff" + caret_frame: "#a8c8ffff" graph_bar: "#64c8ff" waveform_sample: "#64c8ff" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#1c1c1c" tracker_beat_row: "#ffffff14" - tracker_bar_row: "#ffffff26" + tracker_bar_row: "#ffffff1a" - block_selection: "#b98af360" + block_selection: "#b98af32e" pattern_highlight: "#ffffff40" - cell_cursor: "#66bbffa0" - cursor_row: "#ffffff18" + cell_cursor: "#66bbff18" + cursor_row: "#ffffff14" playback_row: "#64dc6440" order_label: "#2d3241" @@ -206,11 +206,11 @@ colors: history_value: "#d0d0d0ff" history_separator: "#707070ff" - tracker_transpose: "#c0c0c0ff" - tracker_volume: "#64dc64ff" + tracker_transpose: "#c8c8ccff" + tracker_volume: "#78e878ff" tracker_frame: "#22ccffff" - tracker_row: "#a0a0a0ff" + tracker_row: "#aaaab0ff" tracker_order: "#c8d0e0ff" voice_sample: "#e0c860ff" - voice_instrument: "#ff70dfff" + voice_instrument: "#ffadeeff" diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index 1b0d55127..97e9213f3 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -10,7 +10,7 @@ components: y: 0 - type: color key: HeaderHovered - value: .overlay/0.25 + value: .overlay/0.08 - type: color key: HeaderActive value: .transparent diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index 69e9ba481..b14d866e4 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -8,6 +8,13 @@ from sampletones_shared.utils.arrays import clamp MAX_CHANNEL_VALUE: Final[int] = 255 +RGB_CHANNELS: Final[int] = 3 +SRGB_LINEAR_LIMIT: Final[float] = 0.03928 +SRGB_LINEAR_SLOPE: Final[float] = 12.92 +SRGB_OFFSET: Final[float] = 0.055 +SRGB_GAMMA: Final[float] = 2.4 +LUMINANCE_WEIGHTS: Final[np.ndarray] = np.array([0.2126, 0.7152, 0.0722]) +CONTRAST_OFFSET: Final[float] = 0.05 def with_alpha_fraction(color: ColorRGBA, fraction: float) -> ColorRGBA: @@ -53,6 +60,35 @@ def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA: return (int(channels[0]), int(channels[1]), int(channels[2]), int(channels[3])) +def relative_luminance(color: ColorRGBA) -> float: + """Return how bright ``color`` reads, from ``0.0`` for black to ``1.0`` for white, as the WCAG defines it. + + Each channel is linearized out of the sRGB curve before the channels are weighted, so two + colors with the same figure read equally bright whatever their hue. The alpha is left out: + the figure describes the color as drawn. + """ + channels = np.array(color[:RGB_CHANNELS], dtype=np.float64) / MAX_CHANNEL_VALUE + linear = np.where( + channels <= SRGB_LINEAR_LIMIT, + channels / SRGB_LINEAR_SLOPE, + ((channels + SRGB_OFFSET) / (1.0 + SRGB_OFFSET)) ** SRGB_GAMMA, + ) + return float(np.dot(LUMINANCE_WEIGHTS, linear)) + + +def contrast_ratio(first: ColorRGBA, second: ColorRGBA) -> float: + """Return the WCAG contrast between two colors as drawn, from ``1.0`` for alike to ``21.0`` for black on white. + + The ratio reads the same whichever color is the text and whichever the background, so a + floor a palette is held to can be stated once for every pairing. + """ + first_luminance = relative_luminance(first) + second_luminance = relative_luminance(second) + lighter = max(first_luminance, second_luminance) + darker = min(first_luminance, second_luminance) + return (lighter + CONTRAST_OFFSET) / (darker + CONTRAST_OFFSET) + + def to_grayscale(color: ColorRGBA) -> ColorRGBA: """Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha. diff --git a/tests/screens/sequencer/colors/test_readability.py b/tests/screens/sequencer/colors/test_readability.py new file mode 100644 index 000000000..46478f4f5 --- /dev/null +++ b/tests/screens/sequencer/colors/test_readability.py @@ -0,0 +1,176 @@ +import operator +from functools import partial +from typing import Final, Optional, Tuple + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.palettes import shipped_palettes +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go +from automation.views.tracker import tracker_cell +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import MAX_CHANNEL_VALUE, composite, contrast_ratio +from tests.suite.screens.worlds.songs import PAD_ROW + +FILLED_FLOOR: Final[float] = 4.5 +STACKED_FLOOR: Final[float] = 3.5 +DIM_FLOOR: Final[float] = 3.0 +BAR_ROW: Final[int] = 0 +PLAIN_ROW: Final[int] = 1 +TYPED_ROW: Final[int] = 2 +HOVERED_ROW: Final[int] = 3 +LINE_NUMBER: Final[str] = "00" +PAD_NUMBER: Final[str] = "02" +TYPED_DIGIT: Final[str] = "0" +TYPING_FRAMES: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 6 +INSET: Final[int] = 2 + +Color = Tuple[float, ...] + + +def in_steps(color: Color) -> ColorRGBA: + """A color DearPyGui holds in fractions, in the whole steps the contrast arithmetic takes.""" + red, green, blue, alpha = (round(part * MAX_CHANNEL_VALUE) for part in color) + return (red, green, blue, alpha) + + +class TestEveryKindReadsOnEveryBackground: + """Every kind of text the tracker draws keeps a readable contrast against every background it can land on, in + every palette: plain, beat and bar rows, the cursor's row and cell, a selected block, a hovered slot, and a + muted channel. + + A filled voice, a transpose, a volume and the digit being typed are held to 4.5:1 on any row and under one + wash on a plain row: the cursor's, a selection's or the pointer's; 3.5:1 where such a wash stacks on a + beat or bar row. A placeholder and a muted channel's value are held to 3:1 on a plain row. The background is read from the + frame's own pixels beside the text, and the text color from the slot's theme, so the figure is the one a + reader sees. The scenario walks the default palette and then every shipped palette through Display + settings. + """ + + def test_contrast_holds_in_every_palette(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + settings = screen.display_settings + catalog = shipped_palettes() + + def slot_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect: + rect: Optional[Rect] = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect) + assert rect is not None + return rect + + def contrast(row: int, channel: ChannelName, slot: SubColumn) -> float: + """The contrast of a slot's text, as drawn over the background inside the slot's top left corner. + + A muted channel's text is translucent, so the text is laid over the background first and the + color that reaches the screen is what is held against it. + """ + screen.frames(SETTLING_FRAMES) + box = slot_box(row, channel, slot) + pixels = screen.frame_pixels() + background = in_steps(tuple(float(part) for part in pixels[int(box.y) + INSET, int(box.x) + INSET])) + text = tracker.text_color(row, channel, slot) + assert text is not None + return contrast_ratio(composite(background, in_steps(text)), background) + + def expect_at_least(floor: float, row: int, channel: ChannelName, slot: SubColumn) -> None: + ratio = contrast(row, channel, slot) + assert ratio >= floor, f"{slot} at row {row} of {channel} reads at {ratio:.2f}, under {floor}" + + def readable_in(palette: str) -> None: + """Reads every kind on every background in the palette now worn, leaving the grid as it found it.""" + expect_at_least(DIM_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.expect( + lambda: tracker.has_caret(TYPED_ROW, ChannelName.PULSE1), bool, description=f"the caret in {palette}" + ) + tracker.leave() + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + + screen.hand.type_text(TYPED_DIGIT) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + operator.methodcaller("startswith", TYPED_DIGIT), + description="the typed digit pending", + ) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the entry let go", + ) + + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect( + lambda: tracker.has_caret(PAD_ROW, ChannelName.PULSE2), bool, description="the caret on a beat row" + ) + tracker.leave() + expect_at_least(STACKED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + tracker.click(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.shift_click(TYPED_ROW, ChannelName.PULSE2, SubColumn.VOLUME) + tracker.leave() + expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(STACKED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + tracker.hover(HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(FILLED_FLOOR, HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + tracker.hover(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.leave() + + tracker.click_header(ChannelName.PULSE1) + tracker.leave() + expect_at_least(DIM_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.click_header(ChannelName.PULSE1) + + def a_voice_on_a_plain_row(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the voice typed on a plain row", + ) + tracker.leave() + + def the_default_palette_reads(screen: Screen) -> None: + readable_in(DEFAULT_PALETTE_NAME) + + def every_other_palette_reads(screen: Screen) -> None: + for name in catalog.names: + if name == DEFAULT_PALETTE_NAME: + continue + + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + readable_in(name) + + screen.scenario( + a_voice_on_a_plain_row, + the_default_palette_reads, + every_other_palette_reads, + leave_letting_the_project_go, + ).run() diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py index 959d1b51d..e760e5812 100644 --- a/tests/unit/sampletones_shared/utils/test_color.py +++ b/tests/unit/sampletones_shared/utils/test_color.py @@ -3,7 +3,14 @@ import pytest -from sampletones_shared.utils.color import blend, composite, parse_hex_color, to_grayscale +from sampletones_shared.utils.color import ( + blend, + composite, + contrast_ratio, + parse_hex_color, + relative_luminance, + to_grayscale, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error @@ -209,3 +216,53 @@ def test_clamps_below_zero(self) -> None: def test_clamps_above_one(self) -> None: assert blend(self.START, self.END, 2.0) == self.END + + +class TestRelativeLuminance(BaseTestSuite): + """Black reads as no light, white as all of it, and a mid gray as the sRGB curve places it, whatever its alpha.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + color: Tuple[int, int, int, int] + expected: float + + test_cases = ( + TestCase(label="black", color=(0, 0, 0, 255), expected=0.0), + TestCase(label="white", color=(255, 255, 255, 255), expected=1.0), + TestCase(label="mid_gray_sits_below_the_middle", color=(128, 128, 128, 255), expected=0.2158), + TestCase(label="alpha_is_left_out", color=(128, 128, 128, 0), expected=0.2158), + TestCase(label="green_weighs_most", color=(0, 255, 0, 255), expected=0.7152), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_luminance(self, test_case: TestCase) -> None: + assert relative_luminance(test_case.color) == pytest.approx(test_case.expected, abs=1e-4) + + +class TestContrastRatio(BaseTestSuite): + """Black on white reaches the full 21:1, alike colors 1:1, and swapping the two colors changes nothing.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + first: Tuple[int, int, int, int] + second: Tuple[int, int, int, int] + expected: float + + test_cases = ( + TestCase(label="black_on_white", first=(0, 0, 0, 255), second=(255, 255, 255, 255), expected=21.0), + TestCase(label="white_on_black", first=(255, 255, 255, 255), second=(0, 0, 0, 255), expected=21.0), + TestCase(label="alike", first=(100, 120, 140, 255), second=(100, 120, 140, 255), expected=1.0), + TestCase(label="gray_on_white", first=(118, 118, 118, 255), second=(255, 255, 255, 255), expected=4.54), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_contrast(self, test_case: TestCase) -> None: + assert contrast_ratio(test_case.first, test_case.second) == pytest.approx(test_case.expected, abs=0.01) From 160552711d9de8d84e2a736a9075fd262cc9328e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 18:02:17 +0200 Subject: [PATCH 07/37] Extended: the on-screen meter with a held burst and a single landing --- .../edits/test_long_document_drag.py | 166 ++++++++++++++---- 1 file changed, 136 insertions(+), 30 deletions(-) diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index b12fc1d61..371cda562 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -8,8 +8,11 @@ from automation.application.startup import Startup from automation.environment import ARTIFACTS_VARIABLE +from automation.holds.regeneration import RegenerationHold from automation.screen import Screen from automation.steps.reconstructions import expect_open, leave_letting_it_go +from automation.views.bar_graph import BarGraph +from automation.views.instruments import Instruments from automation.vocabulary.graphs import WAVEFORM_REGENERATING from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -22,7 +25,9 @@ ZOOM_NOTCHES: Final[int] = 5 LONG_GAP_SECONDS: Final[float] = 0.1 DRAIN_TIMEOUT_SECONDS: Final[float] = 120.0 -READINGS_FILE: Final[str] = "frame_gaps.json" +BURST_READINGS: Final[str] = "frame_gaps.json" +HELD_BURST_READINGS: Final[str] = "frame_gaps_held.json" +LANDING_READINGS: Final[str] = "frame_gaps_landing.json" def _frame_readings(stamps: List[float], last_release: float) -> Dict[str, float]: @@ -39,6 +44,51 @@ def _frame_readings(stamps: List[float], last_release: float) -> Dict[str, float } +def _keep_readings(name: str, readings: Dict[str, float]) -> None: + """Prints the readings and keeps them beside the scenario's other files.""" + print(json.dumps(readings)) + (Path(os.environ[ARTIFACTS_VARIABLE]) / name).write_text(json.dumps(readings, indent=2)) + + +def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> None: + """Opens the long document's first pulse volume graph wide enough to drag its first bars.""" + expect_open(screen, LONG_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + + +def _wait_for_the_line_to_empty(screen: Screen) -> None: + """Waits until the waveform's fade ends, which is when the last edit on its way has landed.""" + busy = screen.words(WAVEFORM_REGENERATING) + screen.bridge.expect( + screen.status, + busy.__ne__, + description="the line empty", + timeout=DRAIN_TIMEOUT_SECONDS, + ) + + +def _drag_the_bars(instruments: Instruments, graph: BarGraph, releases: List[float]) -> None: + """Drags each of :data:`DRAGGED_ITEMS` once, quiet and loud in turn, noting the clock at each release.""" + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) + for turn, item in enumerate(DRAGGED_ITEMS): + graph.drag_item(item, start=standing[item], end=QUIET if turn % 2 == 0 else LOUD) + releases.append(time.monotonic()) + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """The home holds the two-minute reconstruction with its recording.""" + return long_document_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The two-minute reconstruction is open at start, with no project.""" + return Startup(reconstruction=LONG_RECONSTRUCTION, project=None) + + class TestADragOnALongDocument: """How the frames keep coming while envelope bars of a two-minute document are dragged. @@ -49,49 +99,105 @@ class TestADragOnALongDocument: readings are what a change to the edit path is judged by, taken before and after the change. """ - @pytest.fixture - def world(self) -> World: - """The home holds the two-minute reconstruction with its recording.""" - return long_document_world() - - @pytest.fixture - def startup(self) -> Startup: - """The two-minute reconstruction is open at start, with no project.""" - return Startup(reconstruction=LONG_RECONSTRUCTION, project=None) - def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: """The readings are kept; the application is only asked to keep drawing and to leave cleanly.""" instruments = screen.reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) - busy = screen.words(WAVEFORM_REGENERATING) stamps: List[float] = [] releases: List[float] = [] def open_and_zoom_in(screen: Screen) -> None: - expect_open(screen, LONG_RECONSTRUCTION) - instruments.bring_forward(ChannelName.PULSE1) - screen.hand.scroll_into_view(graph.plot) - graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + _zoom_in_on(screen, instruments, graph) def drag_until_the_line_empties(screen: Screen) -> None: - standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) with screen.record(time.monotonic) as recording: - for turn, item in enumerate(DRAGGED_ITEMS): - graph.drag_item(item, start=standing[item], end=QUIET if turn % 2 == 0 else LOUD) - releases.append(time.monotonic()) - - screen.bridge.expect( - screen.status, - busy.__ne__, - description="the line empty", - timeout=DRAIN_TIMEOUT_SECONDS, - ) + _drag_the_bars(instruments, graph, releases) + _wait_for_the_line_to_empty(screen) stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: - readings = _frame_readings(stamps, releases[-1]) - print(json.dumps(readings)) - (Path(os.environ[ARTIFACTS_VARIABLE]) / READINGS_FILE).write_text(json.dumps(readings, indent=2)) + _keep_readings(BURST_READINGS, _frame_readings(stamps, releases[-1])) screen.scenario(open_and_zoom_in, drag_until_the_line_empties, keep_the_readings, leave_letting_it_go).run() + + +class TestADragWhileTheRebuildIsHeld: + """What the interface itself costs per move: the same burst of drags with every rebuild held, so no + edit lands while the bars move. + + The instruments panel draws each change as the pointer moves, so what the frames cost here is + the panel's own drawing of a long document's envelopes, apart from any landing. + """ + + def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The readings are kept, then the held rebuilds are released and the application leaves cleanly.""" + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + stamps: List[float] = [] + releases: List[float] = [] + + def open_and_zoom_in(screen: Screen) -> None: + _zoom_in_on(screen, instruments, graph) + + def drag_while_held(screen: Screen) -> None: + with screen.record(time.monotonic) as recording: + _drag_the_bars(instruments, graph, releases) + + stamps.extend(recording.values()) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def keep_the_readings(screen: Screen) -> None: + _keep_readings(HELD_BURST_READINGS, _frame_readings(stamps, releases[-1])) + + def release_and_wait(screen: Screen) -> None: + regeneration_hold.release() + _wait_for_the_line_to_empty(screen) + + screen.scenario( + open_and_zoom_in, + drag_while_held, + keep_the_readings, + release_and_wait, + leave_letting_it_go, + ).run() + + +class TestOneLandingOnALongDocument: + """What one landing costs on screen: a bar is dragged while the rebuild is held, and the frames are + recorded from the release until the waveform's fade ends. + + The drag's own drawing happens before the recording starts, so the longest gap read here is the + frame the landing holds up, with what the tab draws from it. + """ + + def test_the_frames_through_one_landing(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The readings are kept; the application is only asked to land the edit and to leave cleanly.""" + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + stamps: List[float] = [] + released: List[float] = [] + + def drag_one_bar_while_held(screen: Screen) -> None: + _zoom_in_on(screen, instruments, graph) + standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) + graph.drag_item(DRAGGED_ITEMS[0], start=standing[DRAGGED_ITEMS[0]], end=QUIET) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def release_and_record_the_landing(screen: Screen) -> None: + with screen.record(time.monotonic) as recording: + regeneration_hold.release() + released.append(time.monotonic()) + _wait_for_the_line_to_empty(screen) + + stamps.extend(recording.values()) + + def keep_the_readings(screen: Screen) -> None: + _keep_readings(LANDING_READINGS, _frame_readings(stamps, released[0])) + + screen.scenario( + drag_one_bar_while_held, + release_and_record_the_landing, + keep_the_readings, + leave_letting_it_go, + ).run() From 0b2f5a01ef125b467879f46aa9aa6c2a95ef83f9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 18:03:26 +0200 Subject: [PATCH 08/37] Redrawn: the guide pictures the tracker and the disabled shade changed --- .../guide/converting/source-settings.webp | Bin 9608 -> 9598 bytes docs/images/guide/interface/tabs.webp | Bin 6078 -> 6090 bytes .../guide/reconstruction/export-menu.webp | Bin 28580 -> 28624 bytes docs/images/guide/sequencer/history.webp | Bin 5226 -> 5252 bytes docs/images/guide/sequencer/nsf-window.webp | Bin 27426 -> 27492 bytes docs/images/guide/sequencer/playback.webp | Bin 370 -> 376 bytes .../guide/sequencer/project-properties.webp | Bin 12892 -> 12828 bytes docs/images/guide/sequencer/render.webp | Bin 13094 -> 13092 bytes docs/images/guide/sequencer/tracker.webp | Bin 18826 -> 18254 bytes docs/images/guide/sequencer/voices.webp | Bin 6306 -> 6262 bytes docs/images/sampletones.webp | Bin 88644 -> 88768 bytes 11 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/guide/converting/source-settings.webp b/docs/images/guide/converting/source-settings.webp index c90cf104c485ae25dbc4d279af1726d9cb317392..29981d6d4d3d8327bf89f98bcd66e47bbf5742a8 100644 GIT binary patch delta 9448 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 delta 9458 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 A<^TWy diff --git a/docs/images/guide/interface/tabs.webp b/docs/images/guide/interface/tabs.webp index e46dbef63e0923dd26291149996f55ff5ac9bbfa..c619dd423e436ac1052c4b01857c8ef24861f1e9 100644 GIT binary patch literal 6090 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
;)RtNqwQ_29 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 diff --git a/docs/images/guide/sequencer/nsf-window.webp b/docs/images/guide/sequencer/nsf-window.webp index 49b4c95a74b859f2e1e7dc6cf1f9f68ded89fce8..0f56889b3ea4e2d982f2b992f91f5b474d19e28c 100644 GIT binary patch delta 26720 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 delta 26670 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 diff --git a/docs/images/guide/sequencer/playback.webp b/docs/images/guide/sequencer/playback.webp index 1d3ac7ccb92d0407057c83df18a2c566dbf0c0a1..55a7980dd04986e60f7a67bdec01ecf90d4404a7 100644 GIT binary patch literal 376 zcmV-;0f+ulNk&F+0RRA3MM6+kP&iCv0RR9mwLl2~mB6iSNs$xCn(Hw$nHzx;U|D2S zKng+@T3SUQS9vT{&#BV+VjixoBZh6;hFSmr*RqYlVH<#L+U#6rl4O_Fe@AW`ITE!w z`a1K3TtQVyHhg#Z`!Y*^@%O*Ge2|X{i6Ua@TO--Y-fHsrjQu$kEMv?Uo$U20cPUuL zn*RtU)9?0W=IA#gV=fY%>=tv2Ny<${C%bj{x&4s}32xt_lihswNXDM%onDiQNa zmYM0of|A6l1j~r*SH$`HgXTb%G6{CO&0FtoW?S7C7K{y6K|;2cL&W`64_|S$?l6iW zQ@vVy5XF$GUas9w0Jii1Gu4ZAdr8bx&;DsBBX)c3Q8|=-pom8!{eWMdUUOWm0Dot{>TqE}Sz=Wc8<4Duyg+^Ahx#ZR W^%)J&XOG~Ysmr|T-F4YlV=e^^rM7nf literal 370 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

vOquKRZw!pH*Yw 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 BxU2vG diff --git a/docs/images/guide/sequencer/render.webp b/docs/images/guide/sequencer/render.webp index 954b6806c6f4c8646087c72fbe75cd515ec8c0ce..66069fafa6555bc17fbe49af55a326b09fe9bef9 100644 GIT binary patch literal 13092 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 literal 13094 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�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 RGWPX7^JB@0T;R`M0|3yqX(<2z literal 18826 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%0Dieta$IYSR;| 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 literal 6306 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 RSjxs4feKF^5%hvD}i3 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 literal 88644 zcmV))K#IRoNk&FIS^)r8MM6+kP&iC4S^)qr@5LMeRS(42wt_gMGg%Lj~U zAR;DUTjYD<&Y!xMQi{jCv$fXteJ_QV=CNn;a3({Z27_w|drE0f^N>k~^MHDJ5R2-bfFEK^;#FeUGg_&*L~??~jn> zOo;(nYfS)>g!(n+LJeYHwusq|${()%S9eEx6BQ~INiS(b;P>skJv$x?z?NN-vXOCb z*YfIH(^Ec8$tdpb?(XjH?(PmTk;4YuWeb1Z|NWlld7uCNpXd3WTYt_u+JK&Jf3*e00w8PPq$tQA4Ib;#zbJw*RW)l0OoOBzR}Fxx=EDrD{}LEUvf8Q_kev5hz~n_y zeC%>&2Qa_?zkZIJKcs&^*0AJtcI{y?S*=?IXr-uT>Jy2Qn0~PS2_x}Iok}c&w z8Q{9Rjk{eNcXv5*-NxOn`#pO7-gB;VkJqX8uB!cS_3!t4evj;3b^D&`Xguo)ac88F zk#o@-;dF%SPC6*$uGJAv>W5jK6i!S$Ya*+pSo2_aH4dHOa(2+j4#wTxv1YAWYa-m; zy^?0Fjb~bGafY=j!D-goXv7+yY1S$%az-AU$O@_8!BVRXw~XMA>!>y?fBNDrR6HU7hWqp8ElGlKgW zpXm&j!WnnP-Q|bVxR3RsWNUlcXkt@^vC5R#i9wcSTlzp=?B6`?87!dJKy$_!V=B~b zZClz*(YnjeneQ0J5B61f1&BKY8c*FVT6gzZ^``%_XxpmlW|E#3^E#L@Kv8c}Z@%c( z|F24J`Vz|w^WM(#@XVr^nVFgUoq6xx`*hAdZ>a!d?g}{E0t`hsbEeEqP)H>RVs}BC zD>Fk08!Oy`l)C}SRD_Oe5Hzr<*_k7D=uoK(*t3z@1-Jt2fg>MhRLh*rUByB+8?I7i z8<}1eO3e^D-a3q_&$)((>6glGz!}@ATcB(zaRKguA(Uesx4{^)Jry{D+enflspx5k z)7U%k{u|icup>!wtV(-Yoxa;1)w8$HfCm`BFHX>ZN01~bN;PsWg~Oi2@x%Q7|NE(p z{+-nxd$EIY?+%8T-cvBfHpOM!z!i@h8{%=JxPg1axObc4MnV?LS$0uyT(KrEow#tx{pOrJN8*h?{R)Pt3Ly*fvTb<>Vtb&$fRdY)P-*l#vl!(hK6Wy*DKO zfRS=S!(Mz#K|&nZHrtU#+Fr?Nd^1})y|g#xl->){_C<=7f;gqer@)Dbn<6o6+cuII;P#)l&)rcH;5LpV3+kT9 zO#lLruQUCZz($fHNzSA0rP{vxpIPPyI~D*OyXLHAW@6p@o7c9#tKYV5+qP}n_Lx<+ zlJ9-r=Xu`mzWL5Q-}j#R_we_z?R2(L8?BStF13N2ly-8f^s4i8gF5C0RLvsN+O`+L ze(oCX2GTmIlXB|6kKYYUI-9L+mZ^CAw04=)IeAwQYs>}A?b6y#%5-cq+xRyS+jvg8 z^k1-msbX|yYu{0w6&ST!CkvQU|`~GF@)H$i7+qPy%QpTrN8I+vSw?_i*EI;nED1p&gwQW)nP)$s~uy@|cBu@szH zISItJ5Ci~-A?w>tNk;2zMB_{sBnSYYB(lmE>;>K6Id%(nH+ptaLxNT2OXPG7rjuAOJuN$^QRuJ5wq5RT8-kAr*)M07_-2^b6CaOB1=~ z$gJrgQCbm6t+0+K3G2TkNRs5X$(0Yl*I>ce0cLST_=#uQwqb~i@b#yX`s>Batd32JTukzpD>7TvwWaEy zOW|VIK(wexkutO0k(m^ADRK>{CQ_+tv1_wdUD1ZUYmAt~sW$UaW>jQGMJh#I4o6Cq zn_02O|DeaAt81A;`ArqgVziM#wJK#_wA$>;%xp(&ccf76Xrap_RfkLZ3Cdix*tHm$ zIew#+BBcwFnVC^t6Pht&bvV&i)0x>GXw%f9GIwNJj@rz&qBchzEtoW+N>yEpn$V1q zq}c2wg)S}cAJ{V6ZRkiflv!2E95s^UNEx%grQr7re}r=f~m-wE@HAW_z-UOl@}P72paOMDc`Xw^B1t-kI6%NtN}O?I-B2GDECP zJLtg7PI(m^c?ZnQ>{i0KZQDRrjNAWp9NYp zyKotHY@MLYK0){lnO=aM(K92TL7oh4OJTS`Nt$Hat)6kD86BaFNAPIbRX|cv7f=~j zs8xB{nSi0B3&`WqBxm4dX%&w{2j~j7U6Mz4cr>|#%14kFa(sh=RrCqM$+o1DX1?XF zO&8Fz#RBP2`B(D0NAumC0Sr*yY2Y@BL^`6m^2I_X@GV9|9S36aqGVXwv8M~QeO^p+Ss?< zbB_ULs_J1!{{MAvoEt5hnOO$Ol38ernauVwGcz+YGcz;uIrh)euKxNz3TbkNB^@BN zs9;ZqJA|3K<=qQxm3cCgGUamRHq6ZYJwa$0ZV>Dky2T6F^Ek}R$q|@ygGZS?!^TzW zbW8s>{o)D2Of7RRka;#twbb|s;du4{sU8Qez)6)J{mkqMJfk>x0A@G;Eza-e+^kHe>%G=XtL4yw;vsbF5r*#n$`>YhruNj&0kioQ~C= zHm>cgBUL)vSg}*>E4FR3+Q&Mg%1&oHmA$&yHn+Oiwv*OYX>7Hzc3fJuZQGM$ql?vL zm$BELN;iGgWNc&UCSDJdfC_Y_2YLvUZHhot^jlz1hCY zXkBkJ&PGEwl4RAkHB~iFVvU*k;Jx?z@5qlQ{%-|c+qO;HR(9_mz4boE*wOuX+jzEZ z+lc)#mi|LxBT13uI;vU9c`es5QIY?D{ihsf-QC^Y-QC^Y-QC^Y-Cggrzu))w{mHES z`OkLJTIMG1QXN(+Z~|z~0ahB38+hK~1paAw)dScGcHR-!16a4J$suk+E@07butOy} zc{@(shPza6E=O>e=5PTx0Yh}BxnvT_={sAIJM5x2@a%R0mk;jA`wjr%l4R4?`P%=&o^AX6t9Nkk9m|qr+q7*_PrvW1F&4IM9oxL_ zKP0w|97%K+7I~{SU%vYt>`ME;UT$+EVlXo^Gcz+YGcz--nVFfHnVI22JpTQE=MU$9 zj_?GlR3vCsW9S03Bj}P&GpAD(X1D|9Y#C-|Hg%yA)v1cRFejqQSgAGJWf)GF)P-ZN z!&IhM=}{e2qgu0DY!xO$r%HWj zM^(7XHKa4v!PAA4+Cy7e=>#X^-sNfSD!en>8E)Woq1q>7rb;uPncRX(>3PhrfuVU-&|9ZQH80j6dSrbKTz~Tr$h8A0k?^ zjtFUjCi)Kxk|ae{_Hj7$;_zI)=;r^w&TSS;vQDHEvuvT6nVFfHnVFe+4*%|d|I?k} z{N!O~#tE>-2@Fc-tbna?hTsC&lcC2>9w4Lv!lXJ2SY2x7^dI+X!?1uiQ0q3Osrna& zGId;fg4E2Zy+v64xpt))+-CxLf?($=%r#T%(pdmE@Csqn0^T5eBe)IYc>k{^#5Zoy115Zq+Ju%~ja~ z-6$*-CRaM_nNdY^eETqZW?!*c0OJOzRH$5-hDqIp45?bcK)ExdFznfnXQuMQO@0{9 zFi*K&#h_+pRzW|C7jo4wDJ(UpcmYh+ewcit>IDpLftkt&)zDY99`>zN0HB4%eL=fyA~Me2T+)9yj3gmG(h!hcG;RDupyHgLQ?lSGigWsSsQ#I=1Tbvd#&Pb_ zpy&=wpVj7HRvRx!IiPaXpkkqIWMYSPq1Kr1j1&MD~jG|&p%8xZis1sJG-rpf{WO}qW6-vZ z;2eN8$^G}A7H!*c+h!vjpe1*`_W(R z+6K?^Fn1K!WnOtfN68ex7;ot6S)S&OvRxK^n&Kx}HiNY?rK#LS$VSPSJ`L<)E`zI2_qm?dc2|Dghj5}p&T;h!S;XIaZ|!F zjaX$|s*?-D3R7}8RY6|<<)Td#4@d_Y!bU+TDzI~9NDlu+h)<)TvY(@nc6?=Vu}8VL8mRF2w#9~1>`y%c+32Zvl?UF{`1q||W{I;W8;c1Snr@>e$8*-~-1nslZ{~_W39FO^^ z%py7&yq0CxTMv5EU}@(+AyVkiB!Kj}%@4`qfW-bQLWw36wMMHY6`~AgWA}fgku`f% zq3n1wua|GE8vIawECmE@J?w?-{u9>K-j9*Q4*e!cykRo&hbw0sO`lRl7I<3FX66`E zTi}>;ThD_YaR&#F&aBDr@2PK0`vFhUu=Std0Xf_{fviOT4S29(9??{i8A{&-OY@NU z24M+By1dN!;0Rf8TOJ5t5Sp31)FIz!6Lf2;7zE=W`2Mt#q{-#Q@k6(Rcmr`f1Z|zC zRUx9Jq{m9B{EJ1&=THgXW$X?dS^MRo(9dyjhla}$3nqTuwRMYQIW}R$11yrqrHI>g z?12o;4TY*qiMGaX{^zRzsV;j zO`j=afz)s{g@fEU87oLs_B+sxk?fkdsWniwclom<{I9mCL>b^3DK7+cRGJ%IJyM3) z0+h21<)(#EHWh5r*vq>mLe~%vY@_ceRsrTwN0ljewQ{X5o(XNE+*F|RAsn^?WW;T- z9Q2`oV{wi-P_`8Tz{Z-IhIk{Z09>Y?dN7Cnjs+{s`8K)`MZij}RtBSH%a23`*cFEb zEYI+*XtU3XZ8v6>Bw}#cvNwt-*QW{qg{Dg***sYa2mcx>i+2QRUDb8se}PQ64Obx@ zm$@=X2YdM^1o!!_4-5PM>JF_0V_Zw*OSVLrqp*+jI**YNdx?`uB2n^T%Hg|=y}`&@ zr&8!LZ}l=&#setr<1zBri(mZWg9ClX8{ptNBW!!RlCWrR94X-pWV}Cnro7;SysDlp z*Q_WdY&ni>DmbuR_IkTnv3r51Q*YwuGUKjz_P(JonS=JIvK81s&CxlH7oQ)S z$bVycTG<2rjjjKVsy>-;VbM(aC4`T21-;MLR|s2(nl?80AuMvy9>~CEOH+fVZg=JO z`28Gp^5)rz4FsU0=69LcR-2m+^qS81Gp4B2scK{E%Kz>@CxrAN&bKqREy@m~? zp99O-Q6;xaAyb8Q{C_^@t`qsE-JhZ)2l%O$2353aRHl8Iyi|*_V~9sov@+re#$69k zkP^EDW%b)xJISE7TCF2NT30xWJsdK`#=XO^B{?6LA=X%H0WkhfZ&O1WIQ@*djIPbg z5OpDfr+ydbyiA^~{)H%##F;H3e{}&P@+Bj+M51KFlp>5hfswqt{I2L6d?kRPg()Kv zqKNnRbmq^&KXNez694Cq;lNw3)x{FO`vq>aGZvj6;X)YM$n?lOhZ)%6;y}8n`dp!4 zHZfpx-P-=A^w;B~yzX4CLk^A_byWb3)2QX9QhG3SLkviHscJUB?Rx(kSjAdA(EX{m z!Nkd1Jpg_B0Y3lz^?=UKVodRiXP~rpho|OXKCK9h$j-y+tzy+zvNVg^i{*etiO#Og zp}L5>LYp1{_SyA$60R1AyI+>N_*~kQ{t}W2rWG&N?If&3CSHE>MVW z{~x1klTw5W#West-Ubh1r;Cs6K}vDVj2*#%2A@8Cx}}jcE@FSCaN)wKU4@a$a~#J4 z9^Ri7f`5AvYsp@KyJ~(6Ks3?f*nLO8Sd0r&C3Ym=0Jn<2fI?hj;N(6M#bH%j+EuQM zm{*sIw5%2ppP$V|(gQgCwqPY64G0yWb0&=a-Y(ar6<|%g#e%f37B?d$^G_ptE=a2k z87^G7H65D~?==zBV}-f`{1C3K!0+4;}{s@K%k6 zQcomGI)`G!u{$iBYjB6wACahW2@1afYKDI&X~bu<}wK#72wpL4PZ}mFsA1l}@VdPyADfdEa-EB%Gx-N8y(e)X7 z1MVy<5jfzlsdq%D^Dg(o*oeom2iW>A(grE-bzxms7jvY0UxgFx)iEvic}X`1@Xa*Pmj1>!HnkE-?~Zw;ckNi4D}UBT&%gUlw0Hz z0{{YRy?bCx1Z1s^8&uXe&t{k3GR`GCSD*ytzkV%m!%OWptlI~KFnQ@U>A|1fJ`p$v>y1)Biv*vIQax1Zx((MaxJ zTYJTIEj|)5)^3$}xQdkyPjOh@UBUi8wabs#(&tBF@dG>L$NJ_-Ha@DgHM^MoitkD= ztM@E>A;J@>UB%$mqermyA7$}+{C%lqd z14r7Al3+gFY+4TfVO;T%0#@fE2~QSW+oPNIq!7*A7&@ActzqGq<4}OD|K3n7V$?QO z7_TB}ums+2RGLaLok?l z?7f1h?F2CL_xT-xf~&xm(@;MIu-=0kSqHe8K;F*#esLg+sQNFUML>y;_e^<&+wgD% zqb;om(A$Gw^+wnJ(iiO zQg9AZY)iecWUeg84_R>8!c??|i)R(7m2s(FIx;slN{Rmq6Y?STq;)BW>8B-gW2>aJ z_6)G*Xic+=_jblotWVRXrHY6q5Y-0EED(kQ3(iA559SRMDH)CDnfyLyO-AE|wAKvA zct46s-cP!MrzFQgqTngXaZV|EN^l&5zVqC|qf7U7%l@nQ&+yK0BX{ypo*d7P!g+Q* z?v*zQ#$CCa9HsQ{n5OpN(78F>!q_d_ty)oC%dGfhHUXu45{hwZ^BtkA57l$niUH8i zWe_KCNOg-uF`y=rq=w8#&1ZyiFjRLkD8;+|qgfXdR&sRXuVz^&Y)x(o;^odscSSLx zs4W9RE)tL)p?t-6{Ng6FaWgq0+DPE%iWOF5Of4U#rbZ}dZqs$}%GtO<%(r0*{6Mk7 zGSq#joV*Ve*9hfgsIG%?9h^)6#f)M=PC|`P-r_s{(HNv9_mY3!%#*24+>^ExVjKiV zo~q3X6{QSnw~WLleGS@7BsIZvR86gYl0%vPC~EF4Y^p7&FAEnu55SAfRq|_*;FXHf zY}T$oM!ArSKIYNkC5!CbtpvTNPd?Za4P=Ctl@xwd98GskO?cKYT^7wvY&@#26w{<; z@Mu1M8M{m^HcNDm>&`^Wr_I^LqngBxE9n6qn2IV{1lV`qhD{r^n!#G~j3p1sk|s^@ zvT}PZpb+V(?^e8sLMv< zBA_zX%T_>|DLX3p)d<35q5<0%>5UbC^whd)wL-Mfn!+#PNhPv&YptrJCqP{-g$5{2 zUo@@ETA(z!cccPOX|iVneAq9YchoWf1cERT#wi(Va1s%wndqe^JJ|J7C{8DZ+mX+q)bz?C8SY=vCgDn>k2dSa%R*CFM*;+6^S&NJ# zoVps~9HjP`ByXtzb?&M&395*bwlaE7zpWlVX7r$yGom1xYm*vlOdcZ2Bs?)*v1*hS z+DhZ)%%ycRr7StfH`SLBP_0`Z3Cn3uCLZ;uxo&^AQe_}!t5h5NkE1A~KdMe2%?5r( zX#XS(8}~=*hDqF3ynq9I2>8|S6D%w}iRGtqo`_dleUd?8>VBwX^ zmM#W4be3vTk{hAQK$FsvUc?)QFlJUO1E!s3$QCB`=K4v2E?d(O)iXddh5D9Obfw=DRcGuk)fxKkVn>A1yN3lab8l<(~!fqbL30Z1{R+k29VV*6VNBYmb`fSm=ZTl=u zKoJ_EHPL?7L5n?}vxP;1W5q}#BX{}O>zYYn${?e!M6})7ZDi3juI~ujSNl^0Rtz9@_b}(|I{;IQWhsn8(k|{K$foRRE4x45#RI~6I?Dc*YJqDJXTm3fdsP5hcC z0|ChqG=u@Oh29$+7Tr8U0n%{r%z*0k(V<-KUMlJ{W5i|FjK(T!05+f3oea>bu)y}o z8cIIWUjle(VrPkSwpRYF#TWKMMy^zpnf+Va0n;g$yjhMgfPgu0`{3h9=&b=LT-G@u zm|QNmH#7#TGjf$kBtz4c3m0xtNlH}5ehUE6*{&`cZ>9_U<((v}Xr+C~D*`m^tAm!V zRonmodUw*A1@x_wfM5Zj=^2C{P|V-1r@OWi*XoymTJ4c7E`lxYrUqPW*{$3kRHsKF zUSe|~XSx0eN7G$^DxF_kzpK?6c6%&1?6Z79I#veIc-sQ_dg31$z@}!D>^iV^8g)m_ z>e9C%^_0^+hVh`~5o{Q&VgZcTp<)vKu`KxV78pPS>Tq)G<6Jy-hZvT$gUt0dQFFB2u)-#DM8^ zsXn(!Ko%7Stc;<*4o=H`pXm`y7m5~DpJ#xF4cx5eG*_H4mur~FYKHw5$IC^^poyi; z3<~C!7A&ebbm&BsLP9y#yF5v3xHS~)+$}O_feUM2*G?%I{Cbb_WRW1-f{`b)KaN0! z3@TF4{@X;@GPnzq54)pxTU#Ze3z};Zww#CV#O6^Wv|L2_qW&{XBXbb|N3NAKI&+$o zroQU2xGkKVk;L}KtPt>@$@^+XHv?wXYyw99N_B4_=$)hwJ!L@+SS3GLQj*Jg59ru#Rcc>*YyD|_l zZ2e=Cy#%G3OxIK90vsHPgj*?LGbd4DBs#Hq`skHIxgzyk0FXW^mwphCAeLuI(thp< zfMqw0tTDT)IprXC3NPU#!EX*2mhCWL2P|M+Fn)$Ym^fWgzM;F8-Mg05h+xMaB{&R> z1Ez;Ueta~BA@uJ{^TA*FPd3-%nW1Z0J(V!s(u2{7k)uH&ogbsgFM(%I{Lg=>N5SV- zx$2)h(EG{CLh_GRJNP!+&HWj|vEA^Y_wgjLAnSRNBd^l2hNz^W{ccrCg8s7QjMU}X zIPz6fylcET;en!=+$?*=jJznZ$r%&fyj~K2#gy(eb9h#UA3DC)&(ja=$KdSvT08rb ztRj6Gl=Hm}tHg~A1ak~vZ$rbZZz)W<>t3T1M;m>)oBNsc0ni@&m@a3b_}Sw73xrxZ zcmP(hUpv=$)uGQ+eg*)PfQ$Imgbuc}2rwv2q7ir?1G$OLSpm;h&at^o?cVTY^_$48 z`xt}H6%W~6Cd;B94=C4~%_pf8lGYz=GQHfm9L=SLB6`83su4J@Bv ztFdmp+2O`#A6+xebDu7KmY25Guh&!i1~64~E!ujyN#?AeLhdNqaoSXWw3BFHIrEL__YK|9WdbRmiL-4kwlcG^yaTQMk*RwS)haAaj0 zy&Yz1C*H`9V3(jhYRj+Q}4x`@a*9Q>8M$bM#m{s-dHVeROICowHz%=L1GyqebJ5vEvy}MQX zr&1mBI_^U0W!ks->T4FOO>BAKqlXdMdYAtup_ZpiM7o&_tlnR}w^|*nJGdJ~sJ7L| z89nN^wirZa>s|hvgj$|5(b(d^np;|hYb_fMZq4#q?>WXM(X!Dk(g1rd$$D!R*Lu%2 zHi?#vZZQf+|1A+ems)-+@C{mNt@oT`lW6&^qsQQZN41LBMm=yb!dmaS$0jlAfn(4z zTE>iSfzvYD{21NKOmUmU=tIU};^WAe$C+4+u-1F-u}M76#27SgHAHvFB9PTWR!8qc zlcN3mGJkQm;UC;;EJk+gU6k8IoJ~R{PnjsR2B@Qsm!|z%a0q~0@0Mo2M!rdC@N44Vn15~jcxxLpw@LgO3${k+G`0>xTf_1ecdNNg;%+%` zQYh6)LFgn4oWIcACh->>*e{f7zaX@q1>+a#9ZKxp-+yzUx=?rMSa@0!~r{+$B| ztws=nB`#FANi_7v0I??Bz>=v+KWzD(tI??Ig>}`as&2QtVcV_wsrXP%@PK%OwkuMo z*T_}Nf+r#r_|Do+n`J(P zQ&b>KZP{q8mraII6%ITgDy6s&7yL5O-LUH$5DqWOh^6|0b^lwH3s9i04%NzHQ#Eck zZ5npH4VVacQ`UH)Taxs5Hq{8Qy2-1gXuvO*-0r^s3Ub}mKV`uH@yBhcW`{Otl(86H zw{E-DXwY;}fzWbFd!{A|sqW+dN@O_SUA?LRwPfAhxb4ZSvczb2v;m;-W0IO=u2ZCQ zmkOAa7n(->z>Bg;(MGAK0IpGAv83rRTJH)i>-alR#_%U;H7)>8LJx|*lEQ%C)~u>d zpgbnJt{0iY@aOZ)E>z-Kat;!^ZC4JA{vV>M$aC0}JT9JNfOlj@eXLQDXHm7tQHjAR z9F2DKR!LyQo3_D1*PC5&_nz%z^M0otES9Hj^bw=0Lg-{!+u@D~Mgr3b{2tLE0{(q%gK*}!_1R4Q_bMMkLyIvYui z`oXX#k=Jn@jrxZns?923YNITnm0or*>f4g}(wKHe4IgsHEcGEA5<8eZ_5R-JvR($o zki(vI4!j6+G~3NvcoAwq@Hq5No&)u3nadw)yTHrhqRz{y>wX({M;Dqb=mE~^zTfil z_HYU@@tv0cb9K%Rxm4!BrMJ=R;37M%_J8Ax>$i%AJCcjbrk>_ofcr~k@+*(AGyKY$ z^q$AFy-|Ce?-I^+BGE+-h;HZKvF~LjKfhe)0hg&P^sv2YRN{^LNy~)YHd7~ai^rEU2dFSWl*<;5e_%Ki?PjkA89GenhKy22Av8wF|kk;~Y z+H!G4N-mS`C|2GWPLuZs4oZR-WuBZxK7`xuiVVkJka{#)j!V96`m923CZ1gKr91@d zC6v^K(yvE)e+T(`X(5!#dG;us3_gH$Z=vDIxlrWu9Kau5i1V)n=b3aiY!prQ|AlKa zCgh`DEuV%DTOQ#^dmoov=yhuuVgGQAQ9KX32imOG3oTI;JS5|Q%qk+MBy|)IUIhqx zH1L76R^~v9J;8QK{Nd^-T~XpMipqB)#OTexGlk4zDPJ!UK(k@Y!dt+Zx*bARWa%MG zg1e-~U4J>byz_Egmikz6hiVWRk$+)i{Uol#9G2z-xCCw0%TmrJJiGHjIEL{_ugN@Q z9nB|y+%&)p_->9X2$2-BdDnG0>tyhfs+IqDv!rQ1P&U=PPZ67|DS%%(J13X}^BGf3 z6tD-!`!Z3<)=~iXr=0Gc3%yS*SB>x1);~B;Zhe|Rl+yR5?E_x~&?X<@*iX=6jbm%Q zLQPrYcT-ch?Wz}DYL=F0+7G%GfDjj1+CokKFnAkP^dQAE&UYaz?f3W=rdj)Wr;z+W zNcUL0XAw*0p_4gqVDQ!m6?v9y)DO^P81d-ybTs>z*5iWNYsN7N=?_b?pM&o0B%UTA zq3~a%3H|^rTT*X3ixY|Ma*9fP}!vDUre2lD|@>^dk4R9Xt@JJqY(1 zWju)eOa*vlzO4abat*L@1>Ts+{-4r?N^5*7ww3Qb)t&C7yg9 zLZrCdcL#UK9Z%A>7W1j^FRweg?{_XOhj6n7Nr=W1eKEVO7pKB_q0b2OpVW~JvS{!8 zS^eP;sZW1BP5_)KgMv@8@LMz};g}9h4|>IfY2!1FH#NwDcbEP>f(t%e3$FEgI%)m1 zoRC+-q1y!W-o>{s8o$TW%)_IeueDxkoOzK)U3)ZNS}Hu6+#^YDyevA8@Go?HB7W&K zF;6pTOE1bCZ2Mn_ZN1FYhj7i+WMJ^f2NmpBEiTSqBia96Cu0#(#R>Zab129iM~+9T zjnTrD1FX)YfV0shEaMadp{1h>$rXUJ+jiyJD=SPa^e{hXHk%Bi@%+iS)^vSg_w6PD z9*DgBZsf*V7L-yx0gat$ONHFn_y|%Py|!~-#;;SwtSc_g@<60=CiA=X&$DR}1Knc+ zOWru7RdU^Jf_ra2COB?C=S=&KcvF_@bNxj@a}Z3R%>WSD+jS6^Ka{*f8*_Oesf}LC zc`Vsfzem1u(!Y!GiY5bTE+BS5blH8sYxb`3shoy~gkX;Z|0OOsb0z_<&dZS;>qEHy z4)XSiz_%pJdHg!hmI-XdKo1QFS9*IOatt@#4mdS!Gb{_efuAbct^`<_|ENo2{$BZ# z1f(6s1TR8s^-fyIscV4!bw-FpiWn_@o-Y#;Sh9ARcJIY_V}1G28VmqQ0}w&Z30akV z-lc;7XtBz(%&UH3&Mgz6C3E6RhO2|57V;j`Fmq7wIi`OeepTSZC-H4_$4)$`G6$v>df5LY0z7a< zteCi1ejA^-;$Ie<1M74OIfwN*wO<&UKZbAX)Ewk-m506argFE+9Bh#B56elj6a}|p ze+LVU#5Aa7aUppq13(msd8CF9Kl|*+v8}#y#yoC_>P+X^sPE^+k_e;G;u&*E;Dlr) zn@#0S$5!NNA~2EUjRCh+-N9&<@vcUbVpX$Zc#k=uoc(&ENj{FY3r10iQv<6WN8LcM z4nXYRs|)p8pel&tg7W|=HYFXGsfBJS+OKzil3mubdheb$Hp_kPCS|E>y?qWel>_Lu zwEYtB%v9pr(iFHnSHOr8Z)+f=0{)_4)#5VRa(SPj=>2*f54lG^j6^PAxVWQn_^pyT zr7R0V3c2Wg@D`KE*&?CZvT?A33E!OxZFo&uXN7V^_IFUfk-sRIk-sk!yKdJg>eKF` zY>;U;ZO*RSwoOqs1?TrXUv`nsEe!xMg8g;niQ7-yHi}UngLN0s?Yz5Bs9=DY(@g2N*RtJzkJq_!lPib% zU#G2LrqkSnGX0p47;1Z+wjVWf%Zl?gO06D3ev0T9rB=V=s(u|BMoSa!6?LNgDngx>thHuxF7lkkHgY2M>4T{ z-L}LwSJTx5%x$xLpGiAlUj z>J3P(*5@bb{rhIw7~k`Itk&eFJQ$v|IbnEM#j1G0m$BVi{!{vHC+KIN(B*?ebZyDQ=oK>@=XI#krJr5{}%%d z{{ChXvIT&cWKy#RJD&QWDj-LxAi}Z({H8#e{!IrUCG(bxi!f&?U8LT5@PP8>C( zDWHWM0+~$Q9{=!d@85opjZK$6v+l^-P+3i>l$!Ywo|Say+Oxc!Tbu5BkO9zJ@U^KF z1$2lZpmAT9)`rpO(3*5%G$b%!@Z*`0S{w!jEmLM-pwcU(i`?5ySL5=N0|@DM=Id6|g`z1WwE*UfWI7t4>R%kCmS%hW-PoS+PTj(y{I#`^tm|lsXoh2wz3mv`_IBcC#sWpdA&KOALVN3C%2IxNG>O-_9 z-MSQ(24E>+{aV$yo?N zRKNz!5L5|R0H8lD!5~>oO<(8(?U|3#feScjgJD!owc7y$S#Jv5)UmoS-x9eXz&f+d ze3y68p&@o3dy4)|mcSF%sDM?xP7Sc?J4TYX<#_Mc0Yn)dy7juOw8i#Q1BhzLAz-<& z0O*RK#nKUgcUWG!=`G>kmgMe&zWd_?fU!}$2MO$U9SMTuA(D@%9?eyS`8X21 z6K^vk7ck>y1<5i>4n+O#pqxT2r6ee`IGGHVBgPc~VpxT@;FT-d3G9 z1D4ee0zto_H%u7_! zV+n=J`wZd005((y$Q=qa1Y+2)t>?)I60zO=*jOc~T~STVIXmfhVu6 zbz(7ajUO0R&Jc{c(feNG3?oWjpxM@&m0f4&2pU|LpETQ?=LZPle8FkvPQsk{rcFQ) zfP)j2Re?F@wy827txn`UIspjns=GefEWv$M^Y;4z*0+sXrpo#<_Imfff?z31l{CDg zJl0USuHr;P$j<^N;I^)|HiKHkdoGr+EMPV%j|)+?o?r?drE%T7J=+ELwMEINo$8J) z)GDC#y?!`o4LZMg6$)3|L)!QCwmIkbu7vV607`G!q&)hB@(?loA1@Rm8A`GsP~0m_ zcUwxb$_q9U>!4KS;)Z*DWpApm#>v$Y$DoB!N?&uxs)>3>E8Ehys97UEK4RW+C2?9@TY}GTS2Fdo~p_hGD_R#AtR7`K-UmxhSgx(3m ziF1Vh_bqlbSU5v!8ro4?y@*-FXm?apFFJ^TOOWV zygZJ|7dCY@AIF<4ya~9vTQp@W2$a02*QhpAYKFoyq}afvT44cU831@S?G78@Una{y zP_83_Cv9riW#X?nh&j6WoJKXhjt$@zBQY^~V8_l)0FL^?UjcqEam01Yr2&o7MjVA8 zDqw?q2iTzUu&`?Fntcy`L3+h^a}J8`wgC(1F14@ zu~vfX1YmhWvP=U;&MTCS$gxb*x~9E_2KtNXGX6~6jm_b!q*ceF^%}oTom>U!d}A;E zd6{oJqhXr{uBjA!+q^zu8j}X_e9-iAKRDvebL2g7$TUPN~3E zmC(ID;j;6ws-Ck3l$P?ij`OZp86^R_CI-*Q=v@(If5;LGwBe#!|Nrnm00G>c$zc`{i;Vms8|K16zKlN{2Ig-z`$kzyc zm6d01H-vj#UxSHCt0SlHzZd&Ts0sW_j;k^%m;Y1=y@1;5~l{ zwa!%Yq1p&vv;X;z<*D(5(N%gABJAs`TD>N2O;yQ`5}1HQo0VMQ!iwe%uEsL##ComC zmx8ntro=Ut1mZ;U5&gh$ixt_qE)5QqBLMilQVfFf*8=vfyuFIAt=GH)jje;u3CgPR zkX2X)sGMsb>o!RiLva1mBz9khowK*op|q^wwi45gQu{#UK#3up?LL4(Z5i(+koVHEo~MP5ObR8i4Ji<49YUl69I?qheOB8iJ&vl%y>F zwuCzczNmlzQYfGU+%9yd0%7s!EX?n4e2!FUjR0@}sL)^mK;e)q+BNMZ>3HaUh6As@ z&q`0`I#3u6y4M5%siF`v(i4kSC9ql)LPWZ81WTjM#$kqDizU#gJ6xg{I}Ttw=IMaz zV$=ZJ`)Z($Q3dFpucyosoi@^k>8zZPflRXs;G*Lxdh00lPTIHxV2hIc4M>T;1JH+z z8NdNAx(=ZKE_eVATZ{peg{TPp&%^+_jWSY1sVf{pYwXFGnrxvP#&P@7rV1!%r;M=? z;J;1B^qIcCthXSLpva49BLMD|-%eO^L|sJG#m^$QSYzcMQ`}=-5%9@guMa#`8Mw1f zdQEyL>P5&Be}(NzI?W-kC{Dvg5Woa%a{|;?x==ymF=tveB&&@$m=wuHYAGnKYuFN$bL9owV$7_25D}M}w7f&LZI_3erZfhu(l;XzzxxvvFxQ-(1DU)j zvBotVG(|imX)V5eyT%frwxpO z_S};vm&UBo@{{J%?G(`2X@FeJB08lZP)n~Q@W5E+kixd9pXa)vC@Tjueb!P@Q3+1N zXJXPKSGJ}V7o)|GDvyp^c~zbAnoRdqmr`;Z6xbmPrV+0V(n}Q`fP@nOk@DV0TwN3CyXl$)!5xq( z9^e419!8M?idKds>kIr*x*Tk;U+;Te_Suvcbx|T)uy{-wQMJiHtY9K1?__ymEanhe z&bjh12_OY@5FP&TYp%KG#V*d$KY%wnkT+ZG*IaW`H>k_XvogOQZ8#9?CKixa5Qm&{ zvA+RVy3WdLu9?FR%N^@;C-bBfH#_q)N2uodTmk_#8UT6Run2ZapXx(o3*BZM`l`ig zf}0AmbbSHc(di>L8hceIp9p zdS=3IC(i(uNC_GMtc9HiTP_8+uS#hN^9ki3$RI3CSTQ@PuJ(LDeJyuNhopcJnju*H z4r0nf>j`L8D!SC`fS2P3%s_oQ{&P{_Zz<)>SlqBtEl8pe9rci@E}*QnymaPx|Llsf zAP89Q6DMkX71Gk$6tBunU8x0!d8`$ZQz3tT?09xsNT^PFwW^vx=V~%a<`kt)=Rh8M z6H|#-;^6AOq6DENX{iS!A!Gm>$4h_|y-9&1QUO+1P4it=Vh@GLybkDIgvvHf%)=$Z z3k;`#4F-z$sk&<^BLLuX^W>`D2)D%oh}tB!nSMSCTurS?2EzDsja)Zfj9YXRGV`wh zT|}z8T7n{{3vgOQmFQHY1nn?Z-|4pCa7LK5Vexns!W^rBF2w1gmUrlK^Adk<5%G=q0W(mPlQNSe7ktIqr- z4uZ--QUIuE2Y9-4 z6~-zB<&ftw;;7^cBITn}PFyH&nS`gSg&1)S?ReCK6Ul1Q9L3alM5$ydUC|N%E-%E- zuiU;1B;?aRGL<1338@BMnBvh1g|(Idz?Ln|f=7OsIL?2OmB86kzZmHWQp@QQ07EDz zUZPKD0hA_@id@7K+Hf@eLSw42ZA=P?+^CUJqz+ONt}2fe*$L$(v3&D}nUuuHd=tX< z;9;h&O1XW8`8}X>lPH<0#>ZvJHRaW~g%FK95F5M3_;IuTsn7hJEI|#h6T42l>)g&^ zIMH%`RFt>!MU*vbkcB+4uk>OjgKzn9nLFB< zx>K+VR5g+Fm&I@&4RlSuh^1kbqI}izK7xg24!JQiOORhg&g5?C4g%aaMT=iv?YOoZMg@vFjGH!??K zlk&kRi-J5HwBNN5dZ@x$5cC$Rjjq$21daG|HpP`8O_9XpZjMyv!$PJyFx1vG_Zmuk z&|a3Aw?PvIcT260eMB;pXYyD95J(OV;21l}6o0B(7|h4CNNt2W&7_NnlUl-`Y_WGms80mx6qcas!M|DN*gA(6!mQy} zS=?9B{GCt!tJ)J}32m>J5Ig;*#_vSCLo522LtbmM=R8~Ba0$~QwGfJXxuw6x@62&X z<4Jd-{kXjXc2>7Po;y&pVSwvSmo;1jG4I%lnTPE}nfj;isI^&nd1Q*Mz@dE-ZF#C* z{dr>_hx|LiwjPJ%zRlhy3u=rNiFqDYgPfPwV0ctzq)gPorV)cHR%CsEsVz%X#9gRy z8Xo84Kvcj2uOTq+uz^PgDT}m>TDORTh(|Hvr~-29858TIufc+dtpFeg4gl!KK)Ukb z4ewx4!zLXAwMrRnR9f+2&y(g7*}lsd*l)Jog57D#Y^5r4_UUAgucTM1cx8^x5Vr=E z6~QLema9EMwxW~&z$fmgT+ws?9ymY#%tLRp%g!Wb6`-kGtd7fZV+9?cDVs<9eY-_C zn})a~>Ho;8o=@m$%-E==;DoDH);E=*p*e1&-Qm0=(M^$`>$OhiqinJJ z1OjSx-@;vK4ml9J#+CS`uwmv_wZNfX*JC#Py&uqN5wq;x7Y%Rvvg7uRc>VJ701&#l z+svgnWPNz=D5Z7)Zw6vr35i~#7C6*9Hxtbt`?RgJrU91qv^m>sNfS;r&_=!R(-J_+h&F9Dsz4U>CJJr`4I)k);kqQyNHebjb+3h1^a=|NaN`+m z^!XA16|s)e#8K;Mh3YwP%ASymlE5wrp ze-aP1d`#v@WS&JS;Uv@T5!Al2Q@kgmj`o_&*ZDi{)UQQ9!TKU~VAh9rlGSzZx6{XJ z&8~%z<2O=&ld}qDr|auVIMXYyYTd_<>_TTo+0Y7(cHDTtDwz_3wz*;SfAGvvv8{Wz(*N|$l0 z+yeT=ndH3ho;Y6pzZ_bo%PE9J*-iEa(6+M@Dll_hiszu;y-lDIiskMg`Jzh4YB< zyg*v@K0AElww)pe>l$o9qC7r1;7qL_|9PIXxAK}5L}jPQI;V)L-E^X82`EV27>W^cz%3<|!YRY3&j>%H@c?=4SyL>tr% z12Xi%I>yHQ`(nH;#Y+_L7Ef3Dm(;angUS4jRhxnc(AR72%ZGM-T@rbYU%nXPtw-9J zgV_t=IX;`q_wgDOq*L#==D;97-+!!szv|J?ptVZ;6Xzt%D9w2 ze?W$t`eXMLcmA#S!Fv4%5Tvbtm$%-qALMLpR8jY$GmjukCJhIoHQ^3jc_E(V0h(5ffiL@fpVv zk3N0sqAI-pUUudYWWyN5V_>{WJSU@1T~viX*r8y?DW zn#=QLs)=d@(5qBhIPOe$Sj6EgGi&*L!tGZz=ebIZV3_S70vk;1cOM+8%22>i_jz z%>0WYv(?GkRB3umI#1T0JEeZHDz}>(#Sy<17d7sG9x-x}7?k;+N+fXproZU-yq1RK zMbFQbniP2uJ6P32ol{NfP$&$cFjWA;mdv62nl+ULMeAKFhURFe5Gb}6waaiBr=`_h z15j!f&0gHxN`f0nuq&FBI$nBe01`lcdS)E;{Ry&G<;7@ZbZm`!yBBylYh6F_r6sr3aN>8IY|*3+4W&VsVu4>4 z!gJTzT$9mW9P8#qYrC0f6m3)V^XF0+_wF5_s$B=ux$7&o;)bb_ZfraMbhY+`#yFae*W)& zUpqU)qkxSh2Fiy5BrhCFe3O(*s72Rm8F+hc6I#6(^}fC9rdCOLaIDJ;QZ|e`0Vb-= zXt`xMtN!y}8+caD>8JhMBeH--i^K#~F#WKP;s z7BYoAqSec*h7iV(O8G%ynZFoyPFtOUq2q`EpLx{L^?SV;IE&ee)%fK##BgbHqZl;t zS#&R@Z|G07<0_@Ru-MG`hyudMSgV+%SUMsPio^l$pmsSQotq6&S_(rf4HEPnbnEr3`!;M5Wu?d#na(6fRPwLYhWqLH+kU4D1Jy}Gqz zcBWu7c?^;O)E=u2fW8J71Cpa7kVo5S1!k|0=MurS@1KlQuEUAle&V!!mjzIh9`r{x zIb;qLB!CX&BIj$RlowX-k<#F7wsUOf&qxsuQpdfqVCU7R7mcD8UCTR>TihhY*J{NP z*S$*5-f{4igX_I}=eqYs>O*~dxBYq_ZO-NL`_-#TUD$R-v}zV6$*1)W!8 zI^Rni!l(P-{JB$yej0aN^$(uPQmZE978@CaDP|3ig4AI|>s`CkDc;gH_M-TO$+7-e zH+NU2e&Xxo;C5sXqz*9-qKlcDqeUnTd3*TY9+i^Jq0NT0H(C~yQ_MP0YEtH~k#Q=w z_-HRWnGU1;CI`<=7PpA`9ciV4kDLI(`f{{&L%FW^SxbYEYBwR%0q3NSe(7b&1Ixk z&7<*VX=!I$Jb;diO{2W`*T$FjyFRe>rl@Ic)wY{qbJj*0!Zl*==K%fpL=|!2;mh2P*~euXo}j zDRk{uhZ@`$&j0~Ih0U!9O{xRy*5i<0^C*kHS5in4yZ7V3mL9t>o4CX8{st$I7*1XY zJcg{i0MBY}C%BI;&@DcIPHudP-L6-IUGUzU+S1p{v`9IZnt!;ZJ$Z}kbu-d4jbCZG zONsf2Cz&nr4nc^i`B|S^XqcnKg zt#)JBLpDSDnf;~U4|(@LkGVK>aNR@gEV%dWT{MjHJB@(7(Z;!!#K72#fP=4MN^HlO0S_nu|k!z885g)QZc%;$Go=oypw(t%LIuH?_=CSqlEV)wYiQUoQi1 zk@$C_)~A@2`#uJ?J|qr_#G$v^=qKJ;8(&nKb}{q8=i#X=ORFj$N~xJzAlv(j?a(wq zj9f+rffTTQdrL}73*q@rtGsh?U8zYYUqARfJ=C_g>bXKwAKOm`Qip_+V>Aj)Qo2_- z2|sJmHDEC~N8%um?d?KK`kA-dkhF0zJWR};d{6=O(|DrR0T(kvY4F7==C($KQq%ds zeWf!rO&HowCQf3_(LN;v!}zrjoLV%!6#Q|K9{NF&-Mzw@WNQR7f2>sixy4clbnP4m zwKX=OCH?GfX`}OGeZ}_C|G%%;9CO*kVkSGFKx)yX4|7TcW+r`L6AP=DzuPanEA!COs^iTv zg+QDnP%Eys3JND*o24Ri7=`SlK<=)f!pU@#l&hUr4W#iewsqSS32MEIT}Tg`1gG@isB@ZI)sfxo`HFnVlkT>lYVwKDy5ms zTY-7AAz5Zq&p3$Q(w@lfm4gv z07aSY4rgjYFntXmK3uDybP1k8C0h6}lGDgsTwJCq55R09g+JP#D{2tBv;pAF?BfP$ zJIG+hlg2Np{=$fTr48d&8N9JSjCeH0m9mE4zfRkzlDKpi=4U08 z7nWesd=P+J2)Y1p)9Z-LSE)eDFbQ^{5m|8Wxa!ypC%#3@(R$a?2;5voq}q_gpzUUe z7tkE7-1mOgfUnqiDhrM0=YV1SS|S%RZUs}1w+tji;;{9u5i2m4Tbzucp~T;U!Vn3Z z_8e`*NbI6UgBs2qT(3a+P$vg@4?d4wh&(8mdgIM9e;mIG3aV_=qHC+*z6&TdlLdEY zZIr>V{i-klie^LPKuM}Z1?EnUlSJk)h&;$wI@9?+Zz+szjoN@=oO~9&u=s`NqR`k+ z=}q(%oBpkXYSPcBbAkWwOtN*EwJwkyEUns@ViwVQ*OqajIa+dCWg2gmf!qZ!QS14& z3&F`SP5`Mx7K2Og)?@f=6IumB( zwf$s(XDI|&<+a(=qiSpKB5@$MICS&y;pcTMAR2{MF=rUu1{rvxcEG^aTS0|dab&jQ zg2b_mQ^o>Gh)ZEi7HmQb$AN&6L0AY*0@uL>fU6j}FibJ)Uf~F^h4lri zNPx38{P~`t^J=WX{0YgHbnht)86z<{ICwrh3S_fmCe zJ=afOJB~*F>)3}b`bilVM|k~P5)%HXf$v!{^G)%(C#Mz*l~G5MvlKA+1}^OKpq)$j z6KF7gRw8*}@z#Z9e5j$JPqEMkkIZDQT5+IO3scm5hZ}1@fm4Mc$e14|$&Gc8w?Ps` z5`+F<4=_t5691w_(+@xY_C~use804c#r^NAjuu@Drk>nleA;g2qDoBg6@ZIy;=g=3 zDy0~S4da)>;J;DDeVN6xpeWRi0@=aI^t>D>>%jM5>Y*BrgN5+?LHxoDFTfH^JqKTc zhd*~FYZHKO8K)5td6hifJR;SWG|FWUj)RX@u_%<4-p*tmnTnlc>!c6Noh-L_LEFuM z;pN62h|TGIe+IU9)45AHxW0{r@H{k4h{f>SyM3_e79QA5qnqp@7^oq9N4t(h9@O{i z-TyqgR73s{1xGs!Z=U*!ZMWhYgo_#i+OPL-FUU#Fy~1Iz3vD(zieI>3teyR+l0GVu ztx;z(7q~U^^U;ZeWa&7GiQ!R*k(gnMn&g0gv=?Wx1L3Yr0hJ8Qbqmk(@bmxcwKKVHxqnRj!k)^S z&i7kTY5LLsKl#vDTlLqvNlMWuk~x(8vB%`=k-Y*{6baP--gr*rz`yAa2#|_&IJZ}V z`+q9n(;aj#V@h{{0O!25rZ0miuw&NL3Jd^x(OIddMO~IGr4kJQLXkS{i*S>A9MDjv zm}G#ht8#cVqh}V&_edLo;zg{05fhz?869+k5RG#Q-bCtLHEZZdEFobx84}UAtUQyP zB_h7TElcV>L6N!(z-VISHe@uR4JM_(Ox>igStR^%+`eF4@bj4x$qS1&mzMh~W>n;P zkT@jaXAKG&#kVms2r&|qn#S>Bc)p^%P5K#OlHw0&j`qXPzlYi`YMh?Fu0ub6PVm)r z?~TvMIDHAwnd0YY6rGZ$Iojqf?DzA&X$ z9G6PdO!LvnBqctPIIxNZ(Z6*LWfrA$TrAP(-8<8{n+1irldt_%Ft~kqDoX$=gWDjS z_!)T9&!5B#28l%yIQ^kroTbn|g=vSHNlFrjB{3k$vG>a8}u(kTOl1S)>v;rOtNS8IC+=)Nsy2i10J*^etk@gLKZu;@~a#^Pxn?9p1+x62)L+1Rh)1&bDjo(;B z?cDQAUs$S;dFugbr9;!+q=3iPDUUMNC4Cs%_BDipfgvL(nrZ5|Kp=kUM1>R3s;Gx> zVg0YpCA&Qe=glGk;&=CqG;KuH%^@cuK7O?UAvlC;?!{W&VWyTWh7*;S$L)cmB|eIa zji)}GEj2kp&x|HSEhJJOe}c%j|FA0j4*m&#zLo6uPb# zF0j!Y9FOY#?~AIKlTop6?}E=v=BZVa>uzbgsYL}+&w`Sw#$Wf~G`f-k#&z%QXOom! z0FehxF)N(-*Z$sAn$Chkt(shOacF60z!BlCw!UIRQ5fQ5x8i8d+6ZKO!X%}I@C*h3 zH`YPY2Vz@=iyBv))V7RM9-_C>vv>M`y{}(?jz*#8XekUy{wDpz=RoP;tM==iYj-J( z-$LS$`7jz81WdB^8A)duSSSp&M&?cq#{m&a{81@g_nkSpfT- z>5l$iFX3m(hkSg@n`_Z@HD@}aimX=>nQHCH6I)$Z1*e2ouDX;;#yb{ek~RFj=ugYs z)-6Y<*gY;Kk0jHv-+@E#G1R`DTUxLLC3YvJR!I>wS{m8(IF13WIz}ck#~aqdUv`_c z0wL`;9PB^%Hid(?7VOcZ)U%Ntu?hrUbl+}kxb=~O=aE^B+S&Z{$;560HdF%;g&!BR z(;Efg9ba~36R(>xYv6NiAsTNP=?SPm41ak&&EKgguB385VbuDRY})Rd|Bi{%dW>9DnihG` zsFc#^+IBMxomb-+6(`eYQx9^BEd_tZekwF*4zABdp~B!Tx)zCl3GF&q1%-YZmzxYW zxP6dXkGYeDMnTWf&VoX>@QtGs0wyUk$SpQBeo7sJzP+2kspP?A4hyCpnRsY6)@M$wk!g(0`tVsIYo=Jourd=9m0zS116{k!EhknOQXTeQyOLxmH687Cx@C4JEU>oo+f-88y(E3P3J`)M%sNETqJ2Xfa#?M=u0!!#;U{z0VsLTO zpJqcS4DKs;hrmG+gMqETt}w*b|KKaOOJS^hD99~FJ~T-mIJhpixbh)`sYlM5>QaYj zm*Hc0ZcnUU*IyC0 zrdIUXS{vaVR4EQE-~a~z2HaF<0kNdsAHS12r|%qyC8X4Lb?+dZ{-u~+2iKn{5fp(t z#rNDyi^OTH>EA;Op?qIp+_kV-{Ea5Ll>>RJ3EB8Cox*4kr37ZTz-&=Y zXd(%=3z%GyMkVQIn@mtXjlD(c?S{_QY@t^->SA`fy=V-?&#=C9Ye z_V>o1R?UUrw1SE>0&x;|o-8UOgOEbt$1$z*YDA;x*91;`_s%LP@*0NmD}}($&N$a9 zjVGTn@TOgd1%h@NCj4v_+&@}cRbXf`n44NzP$c{;0}JCIUi+(Pj*Np8%EH!}o8mqEWQJMr2y`FpcsIyy>Sgu<5SM zEGU2TR92O%{&EH82|vq%`@O=EI=nQ8M6IuaBIyI=L#=1v?ZM}HxZ}@vnuB8mx-}n0tKj}>WSoNa-Ohv0BLi>C zIPn$RqcsU7egdaia8IMFZXSUTzTW@7n1504uN@yOeRwXX zH)!7`fLgQEB04UoSl&%mcjoeg6*ErTC#7%J@RLIrOl41AD{l6^6JQRRPp~KN zLlOH*t(j2_kmOL$A(W%pQLAVcq*Lf3x|c0At+B^FU~mZIZy+T`@w9t0_O*dR!k-ko z_UZxthj)YK4@#(Y17AMfw}*FLSa54;4uGk^sj#4k?zg4se9s%+zRY38J@%kQ(;^SL z_V>mq07W5!3aftGvDkxd2G#f(XL3G$Zp#)9fY>v!fJbm6! zKBUwkSVL17oW5Cjy>dleOD}n0c8Jv~Y>jFcn%v@B9ar63+H-Np=_TqB4B{6C%c)6< zO%_)^ROiVCvi)@K7Wd>Jj+erg0IIEV+ly;&MIKbdhlbNfe^;Kv%;v3E8VQ4NG#pcf z;+G`+3qb~>H|de@m7WX8mCOL--3f4SwFoe0j3?Ef=>M#_g04;nu0J^Y&&=uubP+im zKy;P2JKLA@-Ow~CU4)_+j>LiTA;Fgb=EJDTVEC(Pm6^*ou~c!p zsYOR=Z!{h?I+(t_%Rj`^;S}z30I%8PNQZO{D)LdVf$@rB?-igyhMU;0XiOS5w^64UWW9m>M$qGG#6yN-Cn@#hX5 z&Yf2IJNn#xp2bF=`a_@-#4IT^>f$zTU(R-C^wU^wvC@$^RJ#r@`N%A;T?ggA9-C($rc~1O=t-}b1`$Qn+LK(Bo10{2K?VK{iF1r zmA#NsrtP=rT9K8hwww7dJWRR|ZgVY#tD`SN)1*r^*7m3*bHsBFZ&vpWCRBPfr~BWPgm*Btgs z`gmXF+b5rd9D1Mk>SFSYIL3LuNfov?lOen#^4>9S#eR<+u%vVMD(_rM%Q;l; zZi+GS`OxT)ml0@SWo+(D8s915O<#xmR|J-<6hmPPW4Uh1-fCa>xb_8VW`x zc5ax*J7js1$W6Ns$$3+ilpg zaT8CXz>$1B{(rWY^Qv3bFM2T zoKq?tqpDQ ziy~zK)SH%T7JyICt))~1Uaf#}sy#oIj>+AwrgtNvo3=lW-NBg(@9B{3QSkMLUzd6^ zHg`a?e}4@uz!~?ViwxztAVc$D?jcK%t(mV#G;8lwjEoHpkG#6On zS3@mxTw@U-%#l*xmaOFQ0?%CogbG_|ao0gX-M^o{`94l$#7OmrDgax(6X(mdJiTJ& zOCz*s7TKRo&*6`n4zB0^l^%iJ*Lrm;+uo>(pYere?G>QQe;L-@-g|fXl0g3u(Sk?PI zV(SbT)y$Qn{WL$_mwxH>>Y2U4+1}X>S9lLX9q|@LocEaAr+eDsjFv@LOt7_&o$O>M zm!t9kh2M9g-NlrXuDMVsWQeVDEnUJG#IYoXeeCc}R}}!j*o6-R%lFSRf-QivY(2b; z@?Cce>du@-chKu31ya=+AvI9m{tiS5wNb2;(NhgeUe^fXTcGz$Iy?m+o7oE5?W8r; zlhBN*?6*=^uJc?u=M1GSHof1Rf^5)4tx$D3sVE9lW-}^o%(3OZAuq{Jw?(R+I;1#j z>!1tMRVUoHYK(81N1q=Z@ZM65`y!NKum0mqdtpYdYL0hSKd(6NX<~+;=QM`n%T}&u zPXgvd|6lybBD>FW@LM!#bzQ=lbWZb_;*kB@k^oG5@dksN?|;v{yy!%9BsR7RXg+|3 z#*KSY8SoOHYCr@(t!yo?j?!NH*_E8Q<`0sh{&C*N`n`*FWq)>1p^%#dV zfodkla#&DeG{6N1{EvksZ*uB(&q-kR;jX)t!hP96)2Sx07Hm44^f&GZZM@W|m>!#?U#9u5QHFX~Y9;G)Vnz_SKPK}DWj%w1V@-ZVB@^XSR^rRx@ z4Oyt9cWTVQ$o-;Vgol)$^9<#{CYnA+ML|s(*m}xZ>`%WT;K5c-Y2Sd@OK+M^%M*lg z{<}--pADgAjqWJ6H`J=wngi%gMv-nHtR?r!VT(h%VM;R7rYQ|(PGX7s>}u|N>1{S0 zIW(zaJec9mzXbz+um}0%VQvx1jU2WtKC12WEeUMi+_y#vciY?UtqMSs{&e=})a!)- z7}*#&>px(g=>?9?C*4m}FCY2)TJl@5P8I0~d{GatL&UdK@nd8zeYj zapVpeeArU9rEyFHT1(oXMemg)hyLxz|!gTrw2Xye$xH#vQ_T>IXw$kO)8pb>NEmlN$ z*bbUZk2UMchA!e9K;K25l}#Lqu;x_*2Axm#v6~I#QqnkPYGazik=Oqa73wZO)T9K! zq4!Qb_HO~G0#3|6tV)JH+)4#>XfE=imsM>&{}>CD$=4Tvj|dd4$P)v|V`id2-ju5# zrUJvGK&HM3fKkx21OV{?<#ZCh*;C@Cf1fS3B}T_~o9L~7HakP}=Jbu)-kJPO!Pmv) z?vis|R@&_$3(W^~Gaj8*D@@_BW62}EU3b7SQ zU9a~7d%fV<#iQtR7&WEm$)iAmW5HaYzWgoawDejugL!$zTY#jz-yu9yHf~46RL z+zP!#Sai`hwDkBHaiT+-Pwr-k^P|WA%~2z*6lkGy@$>tV>8IWwN1MbmvOCNp=v%AY zna|VpKBYb0U+n6leIGo5-hDgfuCDLet^@0I+9xCg2VG9o z6t;%yI)hDoXryH-iJofMFpGr$2FmY#u6^-?bTpCC|Cc6(b;4XuM`|QfeK{*K_cbnG zF6c{NcjcR7(!hiASAhh_rQRv&3{`V_uM;`cbMV8Bv{KiZrg&r09f^T9%Ngs@u+r7U z1KZq>qU!dW%ZA8s@8&RTSRC&dyx0(P*Lx+Z41I4&pdNjyC7iv-&?#!1=KqI=hsCw+ za;e*VCMi#;(U0P`ov5ivVZ-bVr~`OnoBK&uU0e)&Cid|71J@Wfr495b;DN&7i(!Ky zgF14Y*z@rA#CE4`p=z$;cK&;%57^Fw>^=X5v%N6;BHh~dPFkWL{8`M@hbjQBq~=@l zT)hj1|A+@or9XbQ5l<{L4ifdpy#r-t$2wFKU@or+`F=#0@*d^CpE^;O&;Q}_^J)7r zPRhz94k~o;Z8Kna>y?2cCzz>E8$)^EO!_e!-Sc6ZK6zo8aZV{Ot~D{Y%PG{RNh!-! zuPp#Zjo?*=oqT-Kfu+1$Z+Ecy#WA>5mBp>8qIQ@~^SnRNSX|1I+^-g6O!K^+(OBI1 zlH6}DV@&hB{m~ebjEjNsesfLpyj;>4l8k$Yaa;_hdEQ-V3`xed#yRdCqa0c`y2UCH z-M~Ko4?n+`fb;EfH3GF-weC&286UBLAmi5L9M_s*o;Pnqbb}749%#P#nJ->?{!R_R z_jCCQ0ds&lHCa?`%8f@y6vjGkO}jiV_K3Lg0X26u1^}_&+zl6LI@0=ohM0)dyc#WGVG9hOIq5;e*p&G^fb9Zd zO{-(J>GI3=RP#~~moyNPLm2#wVxkv-vB6;ISh@sFwehD>`V>$};zw2rUdcmD2&P*T zrzxvJA*uOLg;59{+wGYp9ZVh(e`14P)J? zdjo!NMwIjiVq>S7TotvrU+QaRXh zymp`i=4W5&`G5WmvNS}*>Uvf#EFWdEbQSRL)z!pQL|SbjO5BqFWNY(GbU!PRU}-;d zh9iQ4oo=mSC`!?Z z5xnYAO@W*fCXWg^}pmxth_oGOU3NO}vDs{|O4h{s1v5QrsQL1rfbzE<* zZX)HB>kBJ!=GyG5^JAz0ym9gp5%`0N%F%PfXeJHE{yAZ>&R(8|5Fejvf5Y zSM-#930*%fiOb|rx|inpolha+Axhwmr!6g?0@)9|eav-|Maj~KtQ09&>Jx!3?<3W& z0f0;q-NsL^MmXydBri68(GA>Ch9^j(4_tJsCzRo-oXx6P^sA@D1>wu3E)T4X<|Y!p zD|yJ)*nu%niRjYy9387yrNV$mO=+t$ZmyK9HTYXuA#*$$H+PU5#EL=2fS0H;Z5uR-3VOs6nr zLgJ=l*x^GO7Tx;0X#~DDB+!rPmjKf zJ3oms#fvA5cF~EWH%bD&jfBqq6SPIc?U~Cxv)FbpC4BJE9$ElT!&p?Zn6ggytAu`S ze&VsL|5O+YbvOW(x^&>-3#SUoEX5!IGJ~4m@RymuJKG|*#qqr^(moP}eKfzDPM-Tq zYonXEkg?kQ<86-r(pgRy_dSFViS#z(t3jvl0`OWvy7G5p67AYz`vs6aGj%0T^jJO2 zRL%n+(*|G4Gov~TbOzux0`Y4;Fx+DK0DKLrMHAPbe-0554EG@RRa;9-YeE4L?VSPq8xz*g(Rl*EfdRm2NR~n(2!@k?WH(dM2wcS)wB3Ny)V60haWJw4 zju3zeY``*nX0e$&Ff{-BPBJNC*xG0uB;;so0sAT}F#cfGAiXTlu7bwBuXlBTmIX^h zd1zqYha}QeV^KpVD$(Gw7XYr4u>$=ljqN)>4FD3rwqrS9+?unp@MI?$K7`}W0=+l7 zQK>i-14tU;9l=?~QYyfh$gFBwbuY8@ejaF~R#Zct-ulC*0Q1kL2>@cpfu{tJ`GirP zcbnofJm0MK&mY{F{1zX`ijrp6MmtFUapN&Oznl5%Qi{1xMZ9!tu~h)(H>$b;gk*kr zv-8yl15D-j2mn3>d@$C?NiPE+)7SKC05Ndj2LOcgkR%SrXJ4^m$Bym705;k1IlWB@ zz(-7;^<|s>9tJAYq%;6~^5(GCRi+mun~I9cc~-`Ki^=sLeg~)E-s0VC;i=75Di9G3N(#9#O2`bL@M6OO zWRq?ndc2Q;%Pc*ZvBi}2^Qxc+iK8Vnt|A^182rK$oMQ+8WI&t0faL}j0zkH3d44J7 zy^j;il`5PK;Eq;`KUHr~jK*N?-g9b8mZcY~4a}#l=~1W81yOWvQvl~l!CS&u z$*J_LfcX=u7|cgJT{pK+u?*eWDXAzIt6&tT#iJE@2%OsTI3|D73Q|PCNaGlcK{Wy_ z)7N%ms8p?z)m3n0t1-$5ASxKi_Du^CWxloJvB~b(^zQ(LI*$K}M@-Hcps07JE(5?v zjnzOurFuH~yEf`5S?GY&%EzerN)y!?A+-T|HA9V@aeU2y$2@=i!59O@+z9SOx>QO+ zsGVlXGIenXq$GpGhvjB*qqp6hvg76B10ewr6tA<7j-Wf2b(3pNwr35>mt z2-s+xvMtd7LT2UAgyCgrOsr20=(#d-=V5Rh(s4L+Qie9&&Mr2QlIZ=$2?3xaqp4(R z!pQl|tTlLsbCQxq9tum>zb6}k_((J(O`0*Bh@3@1I0G8cth!c`2f4{G$MMU@fJR`Z zQhT>)HJ=PGLWubz(g%AJhf0PeI0t|_^zb63a$y8~F!eaX4`GHlyE#6_`grXZT!=0UT)+ zgP#YzY}YF&*H?K#M8HVn5RXLpO?O0Jw=#%FodLk}ph&G(Iz69{RGAS{K=UPdCXEDr zIyYYLN%aM;a&%5wwJJ%2-za~&(=7~JKKohDx7N)chMB0O%uGq{u-5UlYt#}%-jN6Z z_mF444nejW?}8otlr-vrHX^SFAgBfOY&e3*4~?x* z^_H^&pgd2u+66R0OHh4gYGluaYb#`7V_+@tj?f*wQWFaKRPJ+W)92cyj%f@!J%vRx zfDKq0sPX#{hes%E^E?2L#M2ycJsr1-&d}?!US~71(;sf-PUk8&WX7(uhMUblo70iz z(e*J*sS_7g5qPiITjc$kVa`@;CW6NJUeg3vH}Lk=I8R!!hZ^kLV=n@ zJNU6<%3%GEq$V`2rY^_AQ@zGm9T#9Kcb5WQuW`r5QOY(Omf*Ov4Ggf>yzW1mDqsGA z0Z!#{tGj$uEg?iF-qY=oRRhmL(tu}lbO4}b85V$m_USs9L?1koCMRY4=>*}b z`#sZs&jS&xV^T}<3vYAJ21>EJauBMx@=Wh4pvI2mm|A{}`FJCKx-Bv7fXljwBvK_* zvLG}Td6UB+IWQ-YNTfcoW7^?4t~Y6_Js%KDq>9Le2FzF@P~x{dp48DtBpQDeo`zHt zNF`zg%u@q@E#NK5q7mT1!?FA#N#J?$3#Y=TlZYhiKd%=6c9}pw+9z*h^1YSIoSo2ea5q^&@65jh`#hFBv zQ6%-9aem4K^aJ-jip#T{Eh~jM0?o{&1}dU(7~4FLb4V!z7M*1IpsfXF0tOo8f2$L6 zh52f=e9`-|2$!+V^R%d}0?EU(u@&lnn-m%T|C{6pczO+n7+dNO|R0s38HC-~i<5F&ji&8M%LZg1*7S+O8 zwaB>}cLM`JWZi|k>(#Oc!hu><&E23;JRzahR(E+!R4a>aC90QYl!I8kMpiQrB_WCz zsZmv1j>{Hma_&UwC1gE-SFb8QlobpRYf#JX`mORJvdBrl!X0t)%dR`({n8Utcun%U zvLaaD7QmTARhWtj{5^z2o_H2u0BFbM$Y};dw9-o|(Qe*ht3VIc4Aue@la-PUugD}5 zWqmsANt&yv2gKoliL-O~n^rGNO5sKGR$=Tq0M9S+B9yD!cJ*U|rUp%iOqI3P-RIPR zUgw)#moxCAeXLfph>iOpFw4s00WZn~ddT)h#T_~P`Sjc$qKvwyO9P{yC!%rzkUA)a zEa|b`<3_wGzc^MJlA;M+meYXX(OS1|OPpW;2r_pQ!q{~HZV+@e?pC>H(M4-+l%hTI z-E4MdG?MxUr`P@ra8`gdG*uBfvQFJX+j%D{`!*AUQ^*xS>aaVS+iu=+u?IxqMVM@M zT8<(MtE0B83q$T$srYB9|pjnjAQOLSf3ol zTZOUf06f_QpCkNU(QmTr`Xdi9#q}sf`2UUf`?u=XUnc8}dDeotY$I7I(7EY=;=QYP z&$d9;)kI!Z5*ga;?M`^-k!y=Z^|CbdPL@QnG6!0=vx^er?R%Mez-6+k$gd*n$cB6l zDBQm+IWi*Q`d)7OBBGdw2SnG0a%TC_xk@$YX7;ukd!8>N+XXs{w-<({xQ{jTX?O&F zH*XcTt^@F76LBxZ!gYTNiogEkWL!mTBN>0zHe;bAV`XXoHe+p(_`EzI(Aly8o&U;Y zeNXpykoUO8y1JuL>3$Bt#e%|5E!1J@T3J2-8;!zOvl3{m}ko}|5 z-1zwT6l{?3Bi@u(1&~w5qD0|sE9IH$pYn;+8+Ls`Z==`t&cEY*W*@x?Z)G8=1MtR@ zAK83UB&Q9kh;1a}3MBFzqTn1Dy^jYDn+kX!G9ks3gLtC9D6;!{Z8D75$s9ycwddek zi(SDzceWL`npSPsjmanL8kqo@gU!eRC%icSy%OoyfAHChQ;6QO&HnRJ^pR? zw7qmYqw-c^>N)^VHP=HdUBoSO<+M@dvW;ZC>1sT(Fv@cnIWq=d|NN7IZ{aBH@2Eg% zQ<|#@w{-KF|AjX?wVc3R$i7w&U%)LdhyoWon!Ud$=tYr|?RSccBXgu1J<$_AQC-=< zs!|>%6x@mrIa+XRNe)Tjk3zcA?e*TL#+^GDHSElpP5by%GX-0lf}o(y6ts$I~csAIr*t!HX^9t-{iE0G@1yU~5AVsDAymnX}qPGT!toIzgmN zxSM!C_rGF!N>qckYtre-44wzmn8E3oH=Z}1F(c(@mO)hEbyN{&L-|ro6tc3iPH&3y zGSthiRiyxnd73>{nG5;0S5|lv_UtUn2}^TEAlV?+E`ppkEZa!Nn@8TRNZIZq$HR*< zw{rYIj_DufOD0zXHzxTlVh4bzS%C8(ZoCbCbR1cD3xyK@WC{vytfXRnW*Ck3{M1{j znF2p+KOA9cfCWP1Y>_g%G3M)fFQt`69O(kw!YX_lzxYuBH0pYEh@wYtJub*GndZWF zXUevpW?YkDlw&OOL@x5qc!dEV28g*VKD^PK=9+}lxj@p&PbRiIGA^Qahqh{48j39o z;CRN$)F=tQJNZn$PfOD4v<-x6v)PDt#dJJpNYecTcQ;jFHA>>oU7&hph40to9-`E* zWjzvAL>bE|AkRc=qU*H$J^$Cd;adOf1X?#w7XKd98OwMVLN)K}WYzCsad+i=&X7M~ z)nn1TDu{(f7oeLn1Rch778EQ?#xnT5hr4%js>RNMFSkyHGBfRSgizW0cCC2dxUtS2 zp!C+>)!sx3@98bAcXn*Iwa;rSao-Pn#sB|k@_E{QpJ_J&saSbNj9jxTo~h9RJ8Rnp z;rm&_vmIx4IpCEB1)VHvSKn19Yt;v8Z;lRY*l3@GLr^>7;?B|VRHu4ByA0vlA}%?c zD18k?k^tq|D0BX~N+jx!QAA~x%HWKlA-iG-RkCNbdz_(TUbvpw`-!UC!+P&{Z<*aa zF&3qeYvP;sinEyZ-ylUi-MV)77HDJdDb@{Jo^m}yz0Nkx@2A>b%bPCF!j2vf5|?ln z8{KbitUM#`t8l@d*unbJVa@sUCW;2XcEso`pE7Ltq;V#PG_|@io74xst|Y48o_6t2 zWchZ?rlA>v&*a|YRPVP&3Os3T11-d)$xuNO(dQEc)C0Zj^Ey+nE7bGy^2}ARr>E+D z(U?sey(aDQ-E_jcqr0haPePv2p7~6L_i#wZRp{Is2JB@+_<8e2Go*E<+4_zC`5E^O zJ38K7$A6ri?PfQVerFA*Va(HItCS&M`}z{@Vxw^}#K|*)1n<+0Xb{LvYZcHLLjWYJ zjwE;sL1R>Xc{6f%A^^c?x;mV1EG2oX;d}Sf9~q@j340>Hc1arQ*8H}b4VbgK+cP`< zNOfgL>#k#7gf!!|VEnfI@kJ49y3*;f(*5|~Mozjr|L_mL`rkfCdrge9wpr7 z0sR@B7uKFq?c|D`CB_WMWmeP5x{Ti*A5UuMarOzJGJAp8JjTKy`J zi;{=K0m-o?#)+V$#LxodzRHbG45$=rdnn%ebi&D#Lvv6ni#kk%iy#V;8pE7y6dk3) z1>%%y*Ibonm1UM5CyZvQYzMH74f;wzA$EfNY)4_DtQ}l#j-%?4a*}PKm{61D0EdqA zaiNmC1F#veWye*7Djbj@GySC5Qmj61*XhAF<64VJHD)|}#u728_43DI^1co}NvFkR z7D{KLaw~0sAA;BkhzF(ty@TY;jpeC%)A&ByX=wIdNY#66r*CqND)uBcF98kP-kJC< z)->9m_>-jk_YdB>jeDf(5qzc040uy^guLVy2YXy##tl_=u>^bAhRgnrUMqaP+>|+2B zg_c>`FLRCBMpt-G#=V-F(FN-=yJwr?)=e8t%m|40)}r_*|2J~I5vSbA@|_GMv*q*Q zuYcu0H|hzy+1x4Rg4g4}g7czlW1MHJD~x z$}Bkj^4(#LS%LJ`$$ZBnI2;GP7wMFPY%X~eE{ZKvDw4IgKe9fKa`wv8GX@vS2IUl@ z@n(`U6C5W~xi~;^QTzz0-qvj;Tnhr&0y!{Tzh=@|I)T z!otfV0T*x>!SH|^aM&d{p3HU@i0 zF&mhzp4|zLJL9F&-<9mmJ;Ng{~^@E^c<@S6=H4b{nLQ-pmLB~r7a zYl_D%%OqqYhfIQ{Sh5=j3=PB0ISN&pDphT9Y0ZO>u~xsYc2%wEWT*Pm1eV`uX-a|K zE03HSZ1_9?Z>(Z$N6Hi;xH>!!$`9#XjwCnm8pd?#%q~Ehg+xd}o%nU62IaCQRguWF z`eOh%m1dB+R930XQmUF4FyiUiThcDCWY1~vhxGnf%5S0eczO56sl3(b><{zYd(*Q` zj5wyO6p2KJk)+T`=d1t?jX%_2@%PDx1t!r_*+A)i>k4 zhl5V1t@mL1i zRNx(W-M(BiW7%=zC16}{>HfZ00u24!W5gw;%7u$!q1aG7T#jO5+U?PMg9(ABI7Q0s z|5=+5%mRj6>|sCsaY~QPehP>ZgEmf-o%00HRN@KH&CY56pO*_U3ffP4THs2urx0FP z32Um#k2zq2Q`HuA=4m5@n5tO`uSB3|430x<;HQ76$fBS)NRmPpN?a#n1}uc-hHf6A zWUH#E`C%Tc=;LouC?1Me9B9@`QI<{!QdSNk0VXufMxm&L78F?Y6X#S~(0);|5Hs53 z?(}fhD1gWU6dRhhbbtjy3!H}je$w4@TpOB*LJ=pNRa{X}9D};u&&m&_G3NypQd!H= zqb*@V0Z8ViUL?G-9@MB*p(NsplNt*0(hc0&cT}hxI1&+;^c{+q&U>tJ(LdY^ZxaXb z(~W;m1QCl9UbKtkJw8tzQoOvt(kO>@?VHez$~d|Cv6~+5ez==O@wvYCZTF{K(BkC< zG;z*&UtX^y1sOOuO=oet{ztbU=KLVGq09Z$Bm-ENw%h36Bu2aLc1A3|#Z~3Pc&<(6 z&nrXZ9i7GT+MjkgKRiH_WpBmGLKE;V*8z;iPlEv$Xb?E%^QefeJFJ|oSP>SBhM>M< zf$x%MB@ik502nVhQp<7yShA>uB8Ns0`ITvtB*x0usV_6+JYu=GRW4p$=Z6JI!@?HN z=QHRCs&N`KqzAIFZ!>qgR`KZKrx3LXy@S{i4A6F)Dy>TRy%4EO118M_6`3JFDVF=% zQYOgKc`FDXy?r{>&aZ30_c-gC^mNY>f;xY5#tbnVGn)pJJj+y+RJklE!Gtnr4CvEb zkeI947W$#a43pk)gO{Mp0)67QI;0p#kQqa%>%Ka(siT8U=v>kp+Wi)Az*bc)Q$`BG zh^Nk4#<~DDR1|Y6Eusm;*5kTAfUUaIt%pozgf&OBRr`TqwNbLn5NGkcN_x_xgwY)% zLB5XU3n;{GNK#g5v1_h4a+9k~uRQCfVv5yUq{af`tGoy^Z+6oyJ08pdZdijpxSRO% z)80!enK!T@rseNvadKw&rfQ~R9g)R$ScqTb9=jU!=Jj?C-4+KEHNPe%|LBlqBEeXX z?arbYTN=Z>?*a-$f!f;Iay9Dm(mHn%00FcA4AyxaC7bMN*AjI`vLbj5;o!xi#xFuB zqLt3f%Ql^P7d6#^w=0Y(yqBuvt@8kT*a0~eY?N2)VPF5ZbN$e`@Rv1vl$Tqu%%cm{_QnsT$B(=oCwgm(%g~ z>xt;Z@uFK{H0vJpfWVFiu-h25hC`N#eJ*D?%c;bUeLy;8TJB;{19*8J0IS!3B?gw? zKtaar+T;Tumkom?xQmsP8%cAUfdW;rD2HYZb@@H5E7=TzVTIC_ab8IZ%*aK~<~mLd z^38RlnKTzcBYvaM9o1Tv|CsR%0eHkx1Z{XGl<6|i`s8wFh^4;18e}AV?-+@;{suwu z+D9;cH^GJWvaA@u>OH@wmR460DFe2a>L2$CLNZFz#fuUaI_j9~DaD>?Uo1>VWQ)7> zyP2`hx#&cF11+s%>re(N-`IE=o|F+Y){zsA`2S)m`3(}1MdNl&ugZ$jT+XtOwjtg~ zd?Su6W|eh-g5QfcmX|L12022xh~0NpD|!7>SXR3K&Ysw?HxU;(xxm)tn8_Yq!gGBF(8bzp@Qcq>n7y33F2;$UYO~V z3>^>M;mzdJQ-Mj}V5U1<{-a7~=nV;X-cH;4ZW!K{hez+ae%GU`JJbK~0slSKDe6`d z>8$6K1heJy9v~jSRW=y4Yx-nsR?C0|+P<5V6kKyCn;yl;Se@*qR;BJ zsZxc!gl^EE@#KUP$2<_DRkkw)%_H_60#M%Ei^AX=$0%*Fau5}x;j!QqUJnEYy?Inu z5oNLL1HvP50ri!!>nxu~FpdZ98LMT+&|84AYnI**_*gfvX0v1!eA=kRzz#>vGV(;e zf`Z;Q^cMQN3ayUqw8l zZU){#QfQ~$F<)i*H#qjW=l1MO)~|%p9Q7)^QX+h5mk4 zLP^Yt7Ur*vp-~&xBsIpAE^nb>JZCjkVu=+338@p0y0(yI3_Eu`{6C%cw=5y+#B|XN zs}9{qwO#;#Lw2R15lDrRol>Iux6jf=`td%}*SR@wsn1Q)eX4W(0 z39#4Y3o)x<6rMLrj8j%O@sDXU3_se@@?nEKw+a0z>Fj=xJUt3UtYrx`5?UN(XUZ5c z=;-D&p~N(VA*(r3n}UhAw86&tdN#%NmLMHs;g;8;tbP`d+HNM%7(QEY<;p%?TmyOMwY*ZaZlo1Nx3m5^Rc3^k%n$P~q15a%|$ zX3?@IOv+WZDTmYz@4*~xhxf3z2UAR~ zaI48C(@t9uQPLcMSG?Ju$*}ZtBR&2eCo?8kZ|QpyCu8=7u^PBTvgmlD3||>$ zOV*gaKgM?e5RAK-LbZ{aOXqsj19NeW@jMYdyKF89AOI6%7(AuKWpfY|a+9|&LR+!J zRSWqTgfVZAsI1jPHg%@zYofS%(nN#2^{&snxv!^ zZJo;`8yQ6T)!2>meX;GvTc=R#-C<;^G1M~B)x=lFzRMZ)yMfU$j^Q4pxnPSakJJdG z`$6)e<;U5`%X+OImQQP%v997=pZcB5sBMKrx2j-apnA8R>0n0oO*YhdIHPhmd6Rq{ z;iW2exL{kMJW?b44NDD?aYCRoX(K$!#I_%t8@+L_kWsjX zm^Q?5kc!;`GtpkLeOt>emk|O<0x`QEB!mS3K?bo|4z28!wkI_rquyJ)gHNwhPUP6F*|N9)$j&-$U>oYL$0F8@o;75_BK>dB3LmwBT_n| za*sf*nUTuO480CO^KL#>rXJTqjHb7P35Pm2GY74`69DNTaj0{L+7Pa|E|8Kx(!O8 z25EVD$GRo+kyqPrJppi$pyV{njumo7&j32ZqJ9X0v~I(BGS-!wQu2-Ro=VDqy4`S; zBo+#tVdaVxCh@w+rWFFUTrM64Di~I zb`V)U&x&H&x{Po$dk};P3F=5)8db{1E#O_@H4PT4Kd+Xcj?`t%^sL3|&!Z^# ziJGI|m0r5VB0zoK{N##vP8KzzJ-J796cNJlLs|O{(&m|OqH|>JCWolLxrp&15GOLW zefrazY`z#T0F|3hj>${ z^V{+z#7mD~UT@X>6q{cgxPR-ozv{pLD&3!X`CAtNAbmXoLgLMv&tl%3hCCxsLG4ke zhd*C3K*{mcB6r!3Z?~I-03# z+AZdV-Bq~@<37SPuJox+L#)B;0LEQL6C>WdJCPx-I%3EF5;0;z zHL=&>!*7dhN_M1=UZfqH<)52~XsLS@oEx z#mH3a0`VT>fWuMe|DxPk9rjf63olOi{5?PvnEe=^Jq7FvK_#(u7caAMk)Z5 zQyRp5mNd+RFMC?xIfY6}{n?vtZgf&JoN!Q6s;4rrz4u?(x4p^$=;z+2J>BT}VTZa4 z;NE4T55hn>(SuVxXyVF&?wb5pEL!}#@}3%T&ghv&Yk;In@15)64tGXTFAp{H0F)_h zptE`AqyYwcmKqUs>=4I5&je^rcY?qkj5jKFfx)?f!L3J4S{s0i;5|)QSt{dgte<>oMR+4>n~DTDdMR6cyuK)Nzr57rVU%Zv}T>y188swzMisNaxqy z49Eaby8D5do7QF3AhSV{sH@V+Er0@w+LA*7Jl&*3bYSpTHFE`WG4$hJGSG+hi&xF> zyJelTOQrx=(BY=;QWF(N1B)IbHKLJ*Job^6>@FQp+bqI8%qj@1lQSO^r3_OI$9mcl zV%oE+J!TH}43%}0Gve-Xp-0QI02{gPB%c;_HJ1w7uG0 zHU<2@JNF{U2q3|FR@b6N3*R9nfUQI+`Mcp#sL3~u;p=p^l5jmxP`g{NS`*|av1$fg z^$*1L$(JNFDES(o8FhOtp@iWxXlU+J#PO5vog7C)6^*;s1aKePxCZBX^lA@FvS6e= z3JG9W#b8hdS9XGOLb)%IXaI_qqHLW~26?LobDvgZB?zQy$^zN@_5c^TvU6byaIA+F z;Uasw|MYXay7@FS|F2tBZVpKsD6^df>1$N(ab%uE5sYoO;zXxeLWgDq(t~;FxldC} z#i-TLiIT_EuB0{Y{viTEYx9KN6{>zmbwel0uRB+0-Fi=`ZdS;^ZlOg!!Ei&cP!yLb z-5h|{%gf*T!}6zw{dD|=+8PZzuL}mv5sj<2mCi!jw^a1Nq)c7tiP8ugB0z<3UjqZw z@5f%6s9@<)I=Z_nqNvDpc>y+CToq?@YF}~h$-CwcK3e}K0-&>5^eSc-eFf40!#dI` zDm5hxLM^~r)6xxE#a|LFKu5*y`^!+S>>PZ+63A;6EEVO-PeqNR&{3*a<)&u$4>KdQ zUPMP-#gW!PxwU`C6_kPiK))2o${G>dsP*i2+#O@vAR5nYhNdb~Tk4lWp^Ntuo(1`I>G$;E(%A_43zj}VY;_bd|iMI{dxg@%SSbQ`Lg zU-|p?sM=#>Q2~T@Mcpia$HxYylHRo1T~P^|p}Q%QA<=o(D*A2SJa);V5;RBqZpjSM z=0iU&Lfp_l?B{j<^Dk~ox1%#VP)4y&ePkNwJh5o80V~_lZTFdeC=Cqtm)ZF5OeF;% zVfuBu&*auFQqTn&1wRerzNee-fkq0Yo&9$Nt@y;w(Y7#>$>?0*`AwzG&79A0)}_+xOOqaf5A>i_raAiC;WNIBeWuYg zJ4kA|kxXM2)D3hz_&en7*)Xz^26;`uA>qMsWvol{bP<4Ord1^2zm9XK)Un@=oZfX} zLwAGDskZ27W^bbvwx85SGEPXF@q950Ybh^(?mPbcinw#YGA{tC9gubD5?OBv3w&k6 z@i~QG=e-&efW^!;+D7jOH+?sF#J8Yow`ztf_VKzLYKM?_XVd%sgji{R_)o~&Ght+* zgT5v{ATii*@{)Ctvc--A?NjPbSf9`RzbbrpSHIq#Unic-FMG2Ns%CVwvsb$+fo(t8 ziHGn2UCenKee8xh_z*mue4#F|{kWb5Mq0YRHj!{P<9cgGblS%Sb0PjY=kIva|LMBe z*?99ofb)&efXMe}GXD9fc-vVTJm_}4>NR1bM<|SX_g#!{o#^jw4wV!rJRFmi;pyf)RWpD*i#97 z2XUKOQN?ywl^j-Ov8l~C9@w{$$DTUFKy4B7?u_5lEwKuGy_ogw8MOZ9!vN>&p`4fB z_Z@$ES=>2l|Jwtem@_seI@suE{4d0Kpq&Cu)AU^`J|fPH)u#dQvR~_-Qrz!X=`z76l@^20K?ugXDayxo>e#xsZVdp%GSVZ@Hcwmd@_{%F^e%}*|EUs5ldhxXw8G87a3@h^O>5P9pmekW9BUyj* zp?Km~>Gc#?=E#1!rv&%=6(vG75rAQkm^zE7vSc`sp`=`}efw95H9=gn<4!ql5A3!m zz05U?Tv;2}EU}UfoMyIc&o+J?ule9lVBAXDIM5KSCZsa^ZIiX!ivOO+7;@1z7x#9o zqV?0t*5Y6!QFybkn%4H;C06PU`I`_57mFCDvp!r=uKl`57yp=;r2OJ5F*5k@FT!&f zIQsOO_v5|L9bczogkx6Bbx$eo_bWOB-LyQM7zNuRw^KW=K1Q{}bbAQ0S(Y8-!#|J@ zuY4#fgOR;LrLJd9;Hm4erqb{oCM`j#FKj!Iia2Hj5gw`-F)I(ovceK=()BZ^l`Yw` z^)CFek{oC#ZZi8+2A+l2`;}rcNAN1 z-!F^G(Z|<=Trax)^>AoEw?~RwSODEqiu?VFP90%G;}H>0G}HR=2?dw>=mNzlDA>C+ zQ~Ob=PcyRu#17ILvqjHE){K1nU#4fyU{p^dTjAQ)L{W+SUM+gkl_*jbDAzQ+FHHS@ zCv`Vgj|eyWbXPKg!r>Q-o8+Lu#Cn_%5LhEkdRwyC(;akAN1pipfi`1I@; z_VX>%n8kKhck0eD6cnpg(FTGyya~4(Up_eVQI;1O9DE2k#D{Ji6>fAT6U4Y3C0j=S z&C0QbD=%^O=aj1!$JWK};$w>K?;^?1@m7W3zs;d6u9s7O@pZm{HT~kt1=@J?A?xwa zgEWF%FBD|@#pkRzn$z}+U+}_Frfkf`4wkmKX%IYjFn|t%fX@os^|uEcggG4s=^$v5 z;C{cN6(}2~mBWjQX0~Bm)emzi6SEWw+W&cm8b)Ez>oR#C> zoLSMbhLJ0@@wOnwAo~7sv!isYaX^P^>%k|9mWUd`2upU6uA8flO)xq1s|`K&A)Uiq zi~R7=Sh0%*2>X|>EgW12XPtDouM}lkC8lIr)1c>$|fA(=We_ zWaI6J?3!%7{h%z(_y3b|^X*xJ!%uGnxw%jfTcwy3_xlyC>MFf9A`~i%WS+a{NWF~l zpJ6|bH;IC_wp*Lg7h^DImfF1Ui7~Wh2sAON>TdD*r$nbuk_(*IMXGfIx(AY{5Yz~+p}1q zOAI45uQ%s;l-iu{(cQpAJ?{aNa&xtY%-0J z*mWiM?0Y6Mz7DtRMXMxcj{lw{1v*_Lu!@tUu=eKT=8p58RRSpw{eDFcnKAkiL^Anx zX9zM;iaHMJx4bkLv~~W)krvYsV$InqOPF-_c-L1-{r)FlChgHEskS)_7Ie-Yj`YAo zWIO9Sa0gV8^oi|`jcQsQjnk&kgj8dDTO~5)7^g2GsThOsm}k$nuO$Ep&em&QJZf;U z&pN=k!wqE>)DSjp*7wF3=C-+d_@f8vBWvez)w`)Grly}`DI~v!K=ULuZimVCsU25V z(T3_d{_Xir(yi5vG&lFJ{(`nN`Paj&U19iX9+%_bK>r`tAk8sbefsg171`yb#cV0) z5I4h2O=KB>(B6-E%yySIYnwy*pW$!;Y6x~r#M;49wOvK{k5Z65mBXr;Z7uJ0aIRG& zeir7=kwfrH51^uxSgNdVPftlP63fPP@4yxwiTP-|yrgKNkzwWf+s^?-8u|-wA9cx7 zXIWMzx#!>0GWOr8tZ&YDf!;P)8IwQzljJH3}5(#%e zxzqd9NNvAg5ugCuxY|SaBAE=)g6^=QKTBPrim#v`^&PpPs5WOqCY*nKn+v7R_bW+_ zeET1U^nlluN~ha8r`*y(hVbPejbE_)s`R-+8JFux^-Ww*iM;8<_1tl&L1FHAH7+!xy#~2*b;zh+icLR-+hB5;cOVc*%c_`P|b6^!|#3;Kh%Tn9Ax|ps( z%$Q90T<559JBqhX?XYrt%!~P&X6`rU<|ci|wD$Mk#+mH6FJkWSA9Km5)@IXz{ym8S z&QYl!*oQPPGy2JDOMXWO*;7f^ScneNhox$wEEDuI8tD!pY!TcJFp;QFrgVEk zS#06Th93RJj9k1fR_?yX@p-)S$~mv;IC3VQ{K^5N|DVeG=Gd*(P zZ$Fmd1WV{RsqjdqUw*YN;jhpBp`XV&Pc>MUT^Gu+X)d4q`fPvm1tfJA5Qb#|88L%B zh-6|FCD%fxuq|wSPuEFAL)&-kJjZH+G^giMLQ)jAO!#B!X4_KVZIDr?cg~*G)uUAP zoBx22Xh2g>I;=D-H3)d%MJ#mep)A~Nc(Xi#`QM5QW>z9!*wYr2zGHF_@r&=7$FIDlpo^zxU3T<}*F3X)T5n2nI91ezzQ@duHY5jU8r{ z3XpkrWZ{5)j9C@*v%P4-^{;A-T^7}C-ne;R>=ywFo4*h`S2o7Vec-98o)g%f?_4Z9BX?{N%C z4HEJI1XYB*qnW;`E&3g5%z%s(7z8vW+!KfR6-vanB1_>d9gLEMl0G|oh)j){ITdv6( zjsSrCa{Fm#3j*GJt$km2tf1ndf8Z|C$rD~jsbgE*0~wd6G+2um;UsSBV0{p0`!7Ur z-McfjtFOAbNY{*zzug)ju-7Vw4C8WP-c^S$(U!cNwUyIL!e&zjEEeDWL6ix`7ZfrzRsN~Mwk zMevyW%car(LX?V09Wlf+XE&6agHkG}W;kcUfKSk2%Wx#K1|eS*1*ue9Yw~OEsV=zx zaQjh>&hg_;17HFGN22x;wBOz{1G!)Xk~L5Q@D9Vgwv}}70~6N!^t(D&j{U~NRa;ne znf2$Ob(KM1NgJzb{cb|g6;&8fUOa5}9;W96wr8LFrzmxbU;etF^VVq2%~=y=NSPZ| zN_A9de)|@j#QyM^cAD+qJbF_xpfyM|*O;TDoxZ9q1a94!TS_=oMkZIn{J<^sb1&bB zknRVu6XmzFxYLVpz#)pgR#P~`Vvar;aW!RB#vu$(1VMpB-(JVfvW zS20KAizXtXw|^IVtc${f1wlvU@RpevL2zE#_o@F?wfhI^qf(KuYK9CVMmWY8w{@^C zh|BxOPqm2U8Y@y5kION&%dfjd8qnS-d#!UQ)-a9%na)#Tpr%edB#Hn(oi_P36vUS0 z3eY-pVK*Iq!u==#F$V4ZH(4z9w53v&AcnHCXfUWVxUy+)+%gcRin?G;DTpp;{v;i$R*KSgw%C!ucdHw!Y+Tr*>n z-dU@cT7o_bPJLu?<5Z{yFH57t&2)5&pZompX?tJkRM*MkoKg zJ9AYqC<#MXU(8KJt`7n#=ZY|R<3ir9;PmskPI<)u0g~YFRt|mQfi!&G!FDGU%DxVf zqixO}VuXy%_i3c1<*<05KKd;I6D(^#D>(zS#B{foCG?Hl;cvC-JHNp^Jm98QMZK<}_u1erd zbI$aiDOL0F@0@NVO$}kH^*J_bUyygK1)L#YHbggt-4Ws56-11L^g0|aoK$$=cNsZGGmA%!(kJUVq0Q-z(rsp!m)qF3aNe3Pt^^T!nQ(4TKdxGGHazaz zL*JX)hsa=9`h>c=PG!Ph0^%G;bh@?aQM`cNt!W)P@C)^ZR>uP=13 z8GHtgUwEqOlp`B`hSpa<*BcW(G?`oyzsu>2};hXvRfUv{!C|HnMP>H;o znr2LIHh{!O))6%3Qw9_v1l&yc!cZs({F~G~b#NVLdX7yV5&NsN(Ew!Z2@L~|1VT1Y zJ=Snt7Zi2?jg28sKKfe~h6B2Jq6_$gfeDCd=>0iUM@j#Q-1VoSyRVv-2pj1qN@!RE zg!)3e`v2xF@@?%Z!WZ9KiZbcBJuI9~wkWaThu)=xv#%!21wvzsh)5SjnIe(SXaJft zGE6e*3Ts5eM>U}=G+|;6$Th+TvY}>YN zYhv4;*tTt(Pi$ji+vdc!lSy*(e&4NIb^n|?UDefl_1;~l`gE_ocKqd=EIP7Y2xS1_ zkQaqeEV!7NT~UIC*E`(^tg>w?5ze2`T3L>+b|;)UsCIQARh)6CjM1_bE({lEXrZO% z(nKH_zVWd7gLg;kz2V=hu!W-ndfj50jC!FE>{kBi=@)HSq;P2uInsy3EU2u^Af%aw z@Cik%T+=mnVyqUqzT?dUcow93aULjIA;V#f&n?kDnG<|`D_EqK_Ih$mut?JQvP5H9 zkNeod>5jLiEMSGkr+cLBY!l@`R)4A8PeN3}I`NRuT!dLvp@JI{#5K{{v2I)0{R5^X zWo#2t`Pk0>)~8a*bKB5xDmyB8rR|^RXuYc@Z;@g8+i?6eZRWzwEFBAb_-l$f{xBL5-Uu)rq?u2WTcE zPvSdqV&f!Xh7uqn32XU_x`3`*PhON63Z^@l7_+Fjbjw8p8tam?5`^JN9HCIe)JMwR zKy=;jvn_-iGks^yvNe&4F7;9HT0mddh z#Vt60K{*4#pGwMZ(W`)&ikRnedbc)F%PR23n|ok>1_CQA9FH^jB-nflwL&BXFi5`d zx)})?lh`{K<8~BOgXe`%5aVW%IFW-KOduS?X1Z>Y=)uVt&CT+XptZsio$lDZ9|nvak+<(p;7H7Hb+bSuwXUEUa(_ z&Qnho8h2#>av%xaSNKEkqg!Tq8M1ha0U|4~8?%ejA$> zwg&}pCOc7OJdF_1oRlf;;k3j=qMRZ+_~${zZk4tV?W>$T5KxC~0k( z#q(>a&%lC2F@m{%vw4fxytSEDfXJ|BGq6!{FKWQD9 zyB{zasIqWY+Z%=dR)YKhe9M~TjJo%^I52hKt_^LpA0k@-xFfsT|Jx0=KM&7YzYXPq%p19;>>wF8WX)_X^Z5U@Fp~egn1%~Iv zw5pCrT9<8uy0>+(((Na%5gQ#a*-ck8OBgZ$*xankG}Il+L@b!ybB`%dcAXscfc}yp zgVAko1L0s={wb0aUN@ZpvE3DN?)UV2kEsaeA?;s`E#VkO+n=K`i2fOZjqie0rZ$lR z{N~*Jk6*wWkBAhitYrEt*-W!Awq`1AL{ZFX8h@4bHAkeOnlNq2B?G2j0hwCqB<$<; zC`tRTMXVCJ##LlZZ_ESTKp}tM&$w(-PJr7RvGLNY3H+8R*vf$}$geDh2aGWGNY6YV zWtT9LFgcOWjr3Ts%QVq&tnm>Oe{DG3h?er@Sputv6KR*XjEG_55-N+p@{PwNC`LA` zA_GB_MZyFNDMJM?wL|fcN6~pixHrt!SHiX`yIsn?U$$wDo`GA4>pS6TBD6?Fnfyya z9uNEQO-*&2tC^CH_S8CMw|EO{zha0B4Khj_M0330nTO0;6yY%4%8ncuH@Bsr*F$Eg zS6qu_+J@}h(=X*6=|e{ej}D<~O<+ReW)uq0R7ttY`!*%Hy$QdNVYdyZV{nYjwVYsVS%A-t>5p6fg+bxlde7S<>K8&uUt+KHxS+L z=}t~A);jDCmBuC#$X|)0^-F%HL+sEqOH#qjFne|2XCIQ79(OM^f~RHW_qz-(l{;^2 z4Uiy-6KUWgLcNAfztvusu~ExuTO!l7h9gDBztOM6#C=mMX4!DFgHehdTiWHJOuDZH zBeDRW_iN7aM{qJmcsarY;`h4Ugtks|T4@5>?VL}ZRT8B@XR@gPzmQ(qYEVmX%))kJ z!4+&Au%7UcIZWhN&8Cal0zg9%9zck44ZvBM!6&<*)zr~ZKdpI!;!57ROe}`&wO??B z6xJw2An_F*rH;-=&0w|o4sHs{MU{rUVu~b7f&&w{sU;W(FqKJukQ`)2;YH zH)WGrmL3%v98j3=?Oc1q%jbHsQv5=_QI_@QRd;A&lq%R+jMsUcUy6>AzN z{+uF{-`x}t$3pDSI#36ct%tjiY1;UqNcu_7v;n1Au(FA3(h;~r1$IUaKPhpE9YJ4! zDB+$~9RgLea`62It}FNtroyc=h%>~Mzk_39=Jp1TII6}=jDWD1b? z8UjqJW2CT6RK4imr?|fX(Xwcq)D43|U)7r!M7t)&R8keGqF4pt4N*gFA8l*Bl%aC( z?crpv*&bkAl|;5*XgjYs0Mpk}I!7w0yI|N~F~LZ#Hr!z3F1Kx?T-;jr`8PC;wkVHX zf2=g*pB#l!%x9d;62I^12d+SFmW7NU!vX7>{6(WujD(7K_r8)c8;ElQiI@zoN3rA@ZVWZ3<)e`q=M zp1vTRxr*rNsx~L~#GTZ))XxQ$w!pq< zqkWKmjDRYF)({RPS;)h+blqV7z(L0L^&MpQctS(ym$2+tm``%uAbGf5Z7GmP%|-pO zUs^6*RN}7gqf=V{ZWp^~67`TE)^Tw&!;p@$2NcIBE`8!sxC@X?GyyRb5OI#X2D8A> zu6NkKgpl^VUHdG^@{aSA-g0)eFPC$gKfo@<3@FUzR{|>q_6!s}(D+wS`GmI3^YSV# z)DI-{hEMq;^My#W+|apbGd+w$2jZ}%@T<8qly|DgH2OcRHOLgIAjc>*>2|kT3wE89;=B zE$E*o98=L6<2P|zCw3~@fY7QP99}52qGwjAtI;)$)>pUHUcu{0wj9-|;F?p+z)45F z%&x>Fco?FIX@oA+t4u=3H+-Hf&n$*TjA~AXpZi^2#0B$MD;x_-a$rJ(Y~yTZN89`r z&gF4oj&0bw3wYUGwhNgkB8Rb-xaX8tEG?+nGkx|GEn; z?cg6Szt0!U1g15!+}6n!F?y67j%Pt#l^RqbVq1+y2Y%(y36VQ8U#qbSAD_<=7O8ta@ALwv{$rQBxgDu( z`l!8Equ)V7S$K=nF-*%!!g!>6;K|%1Bl9^0=6jG!yYRoMy6*TZui_(0@(mN6HG>o+ zXQ4_|QoBYBV3C&p1mK9%QJ;lakQQd4zZfasNWsuYcP1#|1%4ITNEY??*>9!)ndD_x zki23kVgX_Pc192ehV=nXhLa?e7L@qZSf9ckE3M1I!e`xjsTYQc;zzhyac4wG-VePs zS*lD^2iwg#5l&(MSCv}oKn4@VcZTwnh+e@t8HT$9oM1@`xIB0;z`})>NIPaCw7uw{ z&pzs?+!2Ncn~|qqghgIhgfi5nZ!!6XurHbz3w6(cl3l4*4a7* ztfs@#sVLlnH5}PoMtQKl{We2sy%ad|Tm2Le`euyi4_7ebGBHxqz?3K08Yf>s|1^%{ zNr`5rLI%NHGp2MHGe178N-yEMnu=)j7>D*mP$n;Gog5_t&tr#g%W@q*r+$hCDIlb< zf{aID`=zOmjAqG(D;}++{8n>xC!tltZ<}YCyF^q+-QrhQ{}jDaOb7j$d30ZU!Y!2j zfO6EpFOK{xF;}r(dA>BMJ4U0L9aM;APs!N98dx=2=?$sy*fQriJNfbZ{r5&JZlc!Q zA>X)}+J;;R6ZsSr1+Vg*8BEgmWHp9BCn8d;xe)z-z9BT8Wd#TF_!~B8DGd@a=HjT^ z4sn+gnFy``)w%4tZ!={DEBC-538D|xj<$ls9`Xyb;3tsbW4}8^U<^hbY1u4^2$dFW z8c3`4U90fnH&QM?sis4WOi7hi7mVrcyCHBYv4eMe)8}o=F^cbrd4yR*oo6<~=;?D* z0D7x*w}g0t3@`z1uMU>$`+??tp&B5Dg;$+64{}84tg7v)q~a80AKPu&M8**)x;@t+ zKTCv?L{9bdH@Uw4>i{sv0j$BRYE;C0sfq|1A_LasccfxCM{3J0C3SUFB5A`4%h?2$ zJ$@)MCTkQ1MR5jv^AY>rh@hI^(&$dOGHY|XlM zjD&i2yFW!zU9><00U`keb`_zDt&ig@7`?Xk$b3*#{g#vQn+MvGbB9xfDpBH;Ly~H< z)|XRCjE;2@`Y!RCCN-cBHq-+x$j$hsh4$pMFe=S=K!J8Eg+6e>X$)@U&_2?OtsVo} z4>l~~MtBPp^iRShj6ob*b+A{jU5VCmv^MFl&c@|qoB3~5hzL#TuIde`Y3Zf->8#aa zc}+zHu`jGcG$8jYmVwg=t0Gzb&ggEi+JR22C35~Ykk+6g7Ex^J!1n{1*nn{{FxxpA zB{!eot(Kpf2^$Bw4hsHqWjEuUiug3>Q*C;-uD~GO!vc?l(2tDz-pv6+ueyx z-I*gh{hNtUL^fPTNk{rf22BE!Fp^1u%>38`3Zfh~oQNXT8<$OVF1KqcB#e2)$b!1M zrj1!y+-RTnHUe=L+9`r|pO0t~XWK+3(Q6`^7YNQ+YlQzwhasbG+#8EZn~9b)fdGjk zJ6U6KlU9q_H7cOKp}=FKDWNeaWK%ZFwCacKsA7^=(lM98)2ZtwkCq_W;pyo{(pxX{ z?67rpdF&{m{lpn~K;jTJ7lN*-B)X&T!OJb<=JY`>fQNl8Rr zvTxDJGh%Ww=nE$(tzACAFfU*qT*6FIoo#(34m;tdjg>f+b7J*@Cy%fN)?CB2q>_$> zxdz2Eip~+(*oQN`iQdZ;JaI+ud$auD*Z4_9GrIN+YI|x}9dOdNez96}LlTGj0`M_o znz@SUDP^>P&~Iug&|0Jj;>smmeaFNKoCb)lNc<@-J9v6?i9*v?Au@DkcRDg5r~OkB zRFK$v0)9p^4YX%GJS0hgvpA&RRCt*Mt+a6lmtu8j7f91_0Ft- zD5Cv2K}HR0X8T~hvxD|M+9>-To@6GRh~<{6yL4})?Zw*@)Q14a>qF06ZV>7sf0Qk- z@DndGViE@0qM}HQO8pWkjMA1(HDu}}z=a?s`NHL2ApN}qIRsWo8iw0Q6r2I<#S0XV zDTxU!R2H&&h|2v_cB@Js6^3|qUD@$cIc1a~RmJIVC_6MZh1?a(LgRW0Y5%&XCA*16 z|NFgbU&5fWY(~ClSxKi6Tz%=}u!aS~h&Bl9ix=yD+CNXh2zxI8Vf7H-eJBpUMzEHK z32yzhruJW)-guL^EvA|V01=ZmY~bP2d=j`|Kxm#&hpUoDq@YZ1 z$khq+MA#hb?2#ee2)VhxGIpgwgNiI9Ed8!|+hSZZjj^Z->6W?NWVg$v%5%xG2P1WR zBPFL|A8H_L(agTl^xY6jMa$Bs z1*50V@=vA#GWy=Eo(4Djv&}F{hCXR^$GB829%}`|#T2t-t>c+;t1<9mc1ns1Q$>XE zS-wUu9@rM#wMVHuoSEa~L{^<7wb>u$7gv?#hJJPd5k19wTVGhg3v16*FpJpqVD!o+~?A20k3Jn!nv-c@w0{np65wQqF294hPY8Wbe4R% z5L@avn%XwjUA3SUh_`%i%F<`6w$Ad8Wka`Zru_Y-25vEpC^#8U{aCZ^kU1e4#eVICUk$?1taD15WU zgFZ5wDRUgXPo;oc)@I4Ci&aRZe~v>sJczAn=FBwO zYVwQI^{d|d-|f%xc}n$~&ZR-~kHHsYT99P@YH6AiOeikS>8iq~Ukp-|Cuwaw#uJ0N1uFvE}+~$u$gI-Y+*e4Ui~U~CT9&-4r{bYW_Nx{ z?b}eUcu>1{x(l*S$8MO-R1=}NFIW`LC!YGOD$Aa(qJItWAn^ta_9+!_Xw6W+XOGh|dl$o`SU z*xxux#}T`p0-{23;d*XxqQ77+%bujhqf%n67KTYPhla3 zZ|kb#NPf3t+!_kE_9@zp<*!ra(DluyqJR2Pr0I^6x`{DO%rM-h>Kgui2j#WYn(j`Q z6U)~?egIKtn(+)TP2=3Ws8JGUd$*J;n0agHZe}TtuHp2lCrw>Ow`gj4BS(QL>`L@! zvxuzC7#EYkHRb2td!oJWJFt?d4L|OE|GlijoVr)4K@vX@ z{JdvyXG1~ddhDE%q(fqNA5}Nsk~QxyoPE26M0l}J-8U`$rUz_>7 zlVOcns2%6+YnHYa#lrA-gCpym*J1ziIYq1CbOI~+d9$fp=A}7(t#+8A#T8%sD`1oe z5d$4q3AqC}qol{NOhZHEyG2bjH_MBNCrFE}zj{>=G@< zoQVTdsb(m^;Vw~z4K8cG;EaLZ5Df667=E4>o)h#v9<%8z@o6KWjl-I0unwXlO);HK^Uku-I&u&dwlgQEE zJE^_Tc*_@`66*yq+XvD^6XJZx8${j7Ww{wtzE)8%c3*pf`btXbG{I(0EVItJ`)ywQ zn!dDkpl6V^-o9w2IP*$Q;HIXkHv5a+*=7>5Ce&YdjwN<`Engp>JVkkNb`=Dk4v9}G zZ`2yR6?2vKl|!0Z0w&<-+(mb9D+YnWm%S>IzZa{R?CgRtQmmP|ZB*MlJ_MUtnOHI7 z>fK}_@&2n29&Yw8lRxV93Q5;FE4P9_z1g@)Eb(*Xn{7UgvihVJVcweyOt`@^R*bTA zcODQY0irvdN8se-n{; zFP6G33FDUy-mzQav|%h+eixy>hx)Ab#+H&mWK!gkc*pnZltp>3UdDdfK_Tq2q8lsr zWkZ`VY`avQEV(pa$LkIno6`n`;TE8EXWB(w-pFlxBcM|^Bhn^BP|W^V5I3j%2b9Z? zHHZhC{Ek+>ElbBZDi!|ywz6uBXR=x(=aOf2C+sPu*&VW9aodYC80R={R)%E|Zs+Z; z93p33a^p+bM4;8P5o^-7+sU3fZm+qsjw4H=oG4Kfl_jCm%{jc#ahC6wDV$_U{6|gA zeu=jlowfcs_wi2mc6wr5KBJK86OE|5_^yE~TD1*m9RKEU{j;?3{tBAujCl@N3Nb2Y zjd?nJL*_n!OmbX4LSv3>W58OPtve0Gfo*!cH;QK~NiT2T6@+oa6HbP0B(ku#X$*#+*%X z4{Jg3CZka_GoK(OcNTajY2CMW zDg2BR21>&_<1xD{#WtPyZ+ExcaxSj~UlW-dY`t0)2BkJ!^V`f1-KzA8VhO(7&nSPQ zxjO6-eJ3!i%dC;5%_J&3?kl=9x0G8l@RyL~tjVfs7#;I6VNXmJ*%{kmH!JdS%kUT& zk>78qIvlG``ykEx^((jeA(i(|Dlbz}w+UDYIlo9c4@#^@dvK?BGWmNsG@^o|Ia_k3 zK$yX5r&Vv%SP573%_m{XmXV`}uf!-+n~;rnJN%~t>696%CBd~iT-M#2qIp{m01a`< z(1zGXoAT_Z4g9}cNPdFhqk!(;)1zVbr?I+OuyA|G&9(MW`bL{NeEV=Xo8sOOxa9Ir z&HnIus`^Chg~rqT2G8@0whMJnyH7ITt8!o%$Q|11iPf8@M zG+1S|D#>fvMC%+dXbI|C4$pOEz8T)iUgZYTGqz2qo0*OspyX*GNP2zKfv+-^r@#e-AM!_Wm!1ear${%IoD`?L|=ILa>Gn17P4gjZx*A z%gQh7qnV5XpaI(MRxctc7tXm-E;UBitD9W;7vXS4Tgx8Qy(*shuTEjD)y15=p#}T`XZus7H@jcfis=0dts`z{4agG5xrsTlm9n&7}LDdF+g!}TB15UXL z{N;z)QZZt;0UK+d zbJ%(#3a93#kMv!Aq5uPk)=F`BEl-fi>YiGrt)t^-8iV<1LfwBn92*i9%+BRa4C~*m z@I>HHmJ$p2l_U=f^3Ob-7=)9DEpb+)QgP3XJdq0mv_S@ONX+QFBNG@qBASgj2b1?@ zV8B61o(2G0LzEOfSGMX06Tx|Y^LQ+M2NoRnC_VgNJg8^WKs}q_i8r4PvjllE8C*gF zS!->bMj8rGh~o{Y#FL*{Uel|@~%QFAZr)YQw(Y6`2fjhX4|u+nZ% zP+DK`tLRD9(Q_>c)MmJ$FcY;rI5ThZO2KKvwk5Iu8KiOd&=^ge0rFyvxN*DVsWS(! zISAXv0L@hI@anb>?R7GXz~S5Ebn@XOJ!wqC%z8m7gtuv*F7}y4yBDAp*-3)|s7_{E zXENzss0HmrmbJt&wO&Fv0FPg5jpDyP#RN50#f3TT9Ja&$wQ^1#fK`wre^`y*m zqxCPp<%s|%`#44f=9|xl{cl1eZ(QydFF-sZ8br20xManS;qI<@dvFDupl^0`SIsrx zVaMHKRJs?xMBmFblF@fV+h?ODl1NL>>Cd!8H|pNJfI*T9&nae(E-(=S$8R$_Ld`U= z;jg;L>#i4*{zH6(aDC2|#M_N+LF`3pu_F^QP!jZ2o6gvgbtAR*TbxL_@}K!^>AJzI zTb%=Op2I_qqa8ktE8%rPa%OEs;j~|yi{J4DUY+49*9#{DB zXTJIbea)nKGM@S9g_0HOEuFOQ@;lHdVUWQ7*bdetbn8+etSEu&YT|=Dy{XT0wlT$t z{~s(v;&;-BA6Oi;w4W>k0<<8B%1=ev?^2qBv1RD*Gc{}-E+F9K*9BI3=A%s?h9rO|~4)V-) zubzdJK95@j)JHXxn6xWQdK~e0rN)UQRP}{g4F>SKs;E8iH~_r+oQ@)yT1e6jc%Eq{ zJTtrnjJ|LP))I7$2s@T{ZpY0=es;(Rk*Mc`ToKYDB>dWgcWd)oFK2uhRR1?P^0@T% z9<7GoUr>y)eXH1ig1xV@mNViett#z;*g7BRvAm%;l=mfHiRSm1{wCZ5*nN>dShu3* zaw3`7uK$tWg0c-2wy`9NOw!fjQPvu?Duota~Cd7Hg<=3;(mCc|fob9vRDx~yrd zh}*nHV1Pt<&`~K8Ze5#;f@ImAa2sy0VO-yeiMlPao~7=6sU9$o0RME_yxEw)Y3wK4__+*BKa zxa~8^#&9Rxw)AuD0Jkq;UgR*DQ+bC>t-BSDAKf0ANbT40g0!N#Y%jCNnfr8!#?U>4 z-H*?17tdx3Ib2A6O4g&s_RzK2|4Ja%wgS)w2Y3Ve~?Y2*q}^ zW^BQIqu=g<)9Zgm^^yGw9IYqq6#d7;5*vesDC-qsX(3j&qAi>Oabh-SUd2{6nJ>Ic z^PqAYy);f$4@XfhE!s`{9G}xjH|2zYpKgw*@&Z%grWjUX4L(d+61JukQ%%hO=hgDT zAA7&nc<3q5N%E9P;g%cd?)e)FnM^XRAln^qe^s zCoViJW=Z>UxC}GXE3UZoidY0{-5ZLwzUZSKod_>>x{bpCmE=tIdXVO7zD|nxadcN3 zU1GPlKWUi&wz$`;H)xpvviLjrDs?T;#m*`AgjPt>b}&<7C0iJ}yx|T&ynR0RXm*CR zplVL}+IT8lxLtd6m-;W*KI9ku8)h=Z2Umb6Y?z=Jx@RETh-H0P#O@Gr_sbav=twat1EKhJ3Sz{-xt3M%8z}f zpTVY9H)hhBPv_(jL3AN9>0N=)K?{D;M~s1Z`N2b^j5g+Ep;VM>=8=&p8Z1#sNx-Q= zXk?G)j3=o+gaJDgAP=}rJ8;rMu?OG0*Xh-N0u8UFsy4I#PHIxG_g24qrV9bvKO3zY zJ1n3GZ{JxHe^f_EdHBf%s3N%#LJm8FuO^BCp#;gVBcIf+8CjaHxKG5c`6$q&Q#7^c zB=eJSEGFwq@mBG-!NxxpegUbA;NNT_&OpcZ4&Zu4ql?{^@WjUS*S~_de zT~d{mF_0~NHJQ-=w7xXng!CoHoP;L?az`@L?!hxq3aLsM)DUbbLrV9jf;q&kHM5B` zI?ys+W;QS=$`^0>he6(9y1KT&?I6HxjSUV}EW89mDu^k*svJmguAXKri*F}Gma;^r zCQ@_qIY>A#mH8kz4_|4J9W%4@ms6uj`=_btIhLcUdc2{faA{g5nNovSh)9^&P`tep zpw!!V!&KWcRx-kV^f-x_PGv*ejmS;OhQa(sj#gnmV6>@2cG$kPY!->RcF3gwe(Hja z8g-tS?^fcy6@t8AWJf>AW0}OgjrB~Oc#DMzl{eL|A2SrADLEc8(iw5NXqa)C05>-y zzr7Y6qQ$a|1E|5;IR&GCOx$5KX@4}xl)c$c>iLQN2pChRsnlzVKuowm-rP;VKZPB% z0&S@q1iRs)O~Xf*pY^g2)SPmtR;I?HZR;Xef`ruVGN~bgm(I0AW>y1kPojay)F09g z4OS$|y;H`w$4*61cjs{3TuvN&IoDGf_B<`nQo_NXT-Xa-~;#oJL10}WSDbX3X{el-fIiv^oC}ZNEZRm1N zZ0sm58+l62MbkbV1+MRc2eSo5u`2eC>fUsR`=bGlXRK4zklSQBdW$1uYzH#8iqQfw zsEQ^~%a)13k*5^}!qK7e8(2BS!^2 zUZKJ$2WfxVe}89K9rglG1jjNOibGrv+yRF z{)V3`BG%o$_8JLEen<(D38rSBOrtB~k%b?YIW00ml#Fl>;|b0pTc!Km97;ICZJcr9 zSS0(^ShCA&mHzXrYS8|K(l1UIkA9R{pDyBm&o#KqFFa0qUshNy-09_N_K4th3FMN3 zR*Fh7G;_85tszO+^h@wVIsdf}XQ_DfWaoi7nZib7^7Rg;a$OXk3e*3Ke*-AT?lFVk zJ9Go$kHl6XEI>;7b|++2C3ul$WBo0SaPHgsE)QI~$? zBt|;1^Q8vDw{g*xIOTCE)U&wi5zhmJuv`=d6#TwUa(Xt);=`SooAyhL1jaje$+3F$ zo`5{~-n%IFu&}dbf*3diKaM%-aJxw{9x%um5B5`ji(Kw?xbwp|ocGCAe%>7O1^DO4THtaYjK z-`{SAIO6=BFKq}GvV=-Ac4y=Q1g2EGU;96ZE3B&BY`9RqUUK>0I9o#oeh8?aZ_lv_k$d>C<={@^QLeNXGox+-plk8dX4=f=F)_Ty8bd=*Zw=w?eT8a z40t0*PR^xi_5SY8b8RR4_pmA_u@T78sX5}emNuBIU*|8yuXUvQ&d~s~;)}UZv;uop z0ii5F2wVH*1^=6$0w>4vCGh?`!B@V_FoP_`VzX*7CEsOtkLiSc9?W^RqFM(#E==pon(o%wDVAR&EuVJb zuQ?0`+xc#IFKmZQoSIY07T_IS(l-v(esS&)U!DGW0AG|?D}*JWp`UBG;2#Xx;3 zRmqnGn1QZfpqw*h_0`?e426bNhbG88Y}Q0lyF_%{zhn90~0>jQ=6(I`ZXpP!!38 zalOcV51BTwCy;BwfRsm3ORj>ZUBo^jpu9v8!=O`{v@l3>bfYD{O}m9wfE~7(A(J%f zl}#BeFG;+)au;9J&RIYOyZGNj8>5!Wci_o{AE1cHkK%<|tWE_{cnE#b3CvFjqoI`Y z6yLrjq-9`DS`kzuNjVsLSiBlPUq{3;+ z2%Yvch{fhB{MF;us$MooRP8q+{M*o^WJ*D99aAZ+BTOWz7RGSuNg=tkfVf&Im3%gH z14<*7R`xuwc@Il*xsJ5s%V4r9r#UM_M^ecSVk-Qhf`>e5ZBk>a{oi6$ZqVoG*I(zO zq7_E^pTo2^i&7eKCRm2==QQ3QqE|@n-%E+lba`Fu16|qwVxttuOR{~?yY^IGlx&|$ zLTS^E^rJr3GrZ1Ctru+cqS!OQkKBa@ClqeKT_XGkBxzW~ji%rmHYTrq3w8cnNFl+e zX2ll2?9A06YqaO>w2?sR7U4wDD-&G+B7XgoE98x<7d_OM?j(V%g5+@D5~HmQy4*^# zEy`j<X_i1~ zjYMa4-aP{IafesT5RDXmBb9yi^|zVxIkS|-R;|m{fEl6s#G7I#H{a){uC8I<2bDbe zSOr>3%K|($4|}ed;$_kD8h^@B2mZ1}a1M@)EbqN;AYFrO*)y^;Z06l$A8Xso%`Cu7 z5~GHsHZjYX_z*FqxNI5-dr|K@0`wiO1*BRl+#4l4RlUy6ZJ%{Mez?2{<_zymKJax_)+8g(`$V1Lhbb_%D1xP1M$O4oy>ALX{s=n1HETvGP`+@?;pF(t#HbpyZrKh zF#A{8%xtMWTdyS0GM6%RD>_QJ4J;kKTOPNZ1`I*JOQP3-E*ldmnf$&t)4RFFZYS1j zk%&54!wuhj(lS`+!OkC!UDArvUz#4l>D^yc zwcmZB*?Cyy{wBF~cZ!C%O}-sZCPRms>oVPeOMj1zJ%IjYfx-&rR(r-bVdZ^}&ajVN zECXA*FRpplnavt|U>AqIO-B+}c?@53=zfBlL=O?Z)b$i%$F6`5jr{ZFwH@rW59d}W zUDzbNBrYfYTfl-vM26k+;66F&xkkm;hfE<8VCPW?wmoJFJy!=?O&SxaofAYG7#GruJ5B<;!fg zeLih7>BFFx5>5<4i-t#b2IkW!xRNAimf|^ma|0CcV&#-{rTSKIOA zCe1jEiLms(zBV5CR?f5Q_!1k;aa&5^u7SBd0YdV!ZxopP+6K=2{IU~#7~sloJJ*5Q zbrm&Zb(#Y1lXz4J)O8G7AM z2jSsq4eqTrF83Uw4xm!wz}0~)AH&B()CLf3`oE1%!ZrYF$Ny{e0!Vue|F>HwAz=yX za9Xxco`dlravkX4ceu4Sl+is++eRf4`N|s|sWbTxWnxzdqpnv>(Ae0CC>27R{LLaZ}`hcapWG4&6 z(bg@AL~RpIVVa>pc<(9*Lq{63py)#!9i^YVGsED?0Od%Wwc=&z8gbuv3hJ1Oi_wMvv!d&-%Lxh}>#2gl7Cd>aB4-rO? zM9rlL87ltzNs@!C6<#JpeXunM_@8G58&!wC^=*_PDAUv zJbr5hs)RNE@4x#A39=4>``Wk(njJFOEBTC-Z z9avs9Sa+4x@pj%W1WtyFt0x$tvlNYjc7Uaf*`KirgiZjrD)61IU}ehKi$zo{y@b+ zXe|U_8Vgg8C)orQ(1S(w5+1`6n3KqPn9Nr~VOlF=!|LF_qbl8V-{E&GUNEZEeVXT( zHY*X@4*$l;NmE!P!D)seZV2Q;s(utC`Q@XPT}54&t;NY4#puhYH@c37xexD6mSPU? zcU6P6ac#g=CF6*`!`W?NnxU--qO=?|@yItgZ3`?zr}0(iO?P=6raUeFTE)A#GA|Fz zyew}&86d+LtdB=sKEA!%`vdsrx4*3p3*Ke^Sh5J@FC{sB9<23Sd}?qFx3uU2qF^m3GON8_-W*dY=u#ZI!%T zMdpDOkHDB?%pt6WqI83&b;#2uSz`ob8D1(XzK+)h5`3-oEXNhnK6PUU;ZM2SX)~pp z?W6>C2j1>TbPU#Xgni`5RKTd#iWGPoQ z&gr#aUkfk$7_)C65D7`5RMND)SusP%XP%H$+Gy-N5!OT_*+_`2r$ z|Iqc$@sR~xm~h8-Cbn&7lF7ujjfrjBwr$&XGO?41ZQGgcdEeb{_xIcV{^`2aRdvpD z>OALG-R`6P`iKM+&-nmsH#RPcYJLPE0FR$(BEjmi3s&MSWkSk^-TXtnP_68 z5!Zwv7?Z;QJo`Fvou7j=wCKoHm9ljy{bh!%Om4i+R|kC%qL!#myiaNeFjH{K(7dFK zD`k|2$?8j~PYE&I^cF1Ow3#yU6?D7PSqDquT0KFl^qV9X*2@~GD#08?W0IN62Bbs#WVfv z^RZS2&AsnHEG7-kOlR!1N@*!Ny`@eHiApO(uN^`)h{2$Ieev+yGs1Q&MY)qQ;aY@! zoh{_UZqMqHnRitdY$hv;_s)2j^VybayMYa2Nh)k;K3`Wt#i1W%0Xq@;&e`6PbQZIw z>^Z`AadB|H(Y$mFA#dte7h6D|qa11-=m-wBfh5KBuMcfe+9DhDhd>1knOiv0cZX%O z9<7gK1jJt}5Tm&8XW+LP8=h*PD1jgRCsSaae~;H2c59E<1F4&AqrkfjetIYVYTcmU zYNR($E|f(d?35B($#f()7p(jkg$tpU66v7*#G?mk8HzT66W&{Kf^RzfeRbekfO zlQJy)5CX9tmGQusHFV@HlF)O)Fz37E^9MHnkP)v?S5$dr}AXTXn@+r1|5yS^Zu5wAHQ7_x5t=}KJBXv0BMBSIwtppF7<(i#602#zP z^*u`pxA)Lvc!Eo|Z-0CA7jWJgb{{3d2oGsn!Z7f(fDgKyz?w3vpw4*fAN=5Xd>WgR z8e#dr#hpla#Db$UxwF;fQ3wnKO0CQPU8!vj(mmF*vncc>rlc_BGn$VrZW?cQ3P|GN zGwyEo`Gu_*LbLpeiMM{D_d7UTeMa4_uo^Xhui0u|cnWH*foeE*aR*>nB?2G6R?jp>RsKc4r2|oE24q|apIHI#$^GhyvlB5Aq<^g8j212D zf;aR_dr*dS3Gc}@u!MyK>8ZH@M31y4jy`4?tOEN$#cOUGD1K)cXP(YTYG|AB@!9Hu zXN6hMqI622)`--R5_tY`a)Th~>gVn18u6{d14HExe#=cSv_03|ua_vzgXVq`H{jI; zJjYD6$W6fKt;X|+?-+VycD1S!g#YH; zyCJx_b;gZ(ncYa9D+51o(<$Q-q}?QV`#3rSf9t6FdPV$Z?4t(?GW6)f9|+C);Ic;G zeQG3W@Ffs=Z=pRrO>bgz^n?C#pWk-uK%J1n-4^*mJZfuM^|c7w2A&nuGOhB0|EAFc zabp_uhyMY6@Y&GzZs1j#dW6CcZx#9i<97i|mYh&xw%x+A9JLyH6I_p+>?P8>Z&Kb#aqO60qs z_fx}524Z23@WdjYcy!s`)MG6Aifk$1efcdQ3BrWo*ik~)lOV&udXfmSyxli&BQhFN zs=Ru39w@A4nNrSORHQ{>p@C9ecDH82m1nbxei2VXJH{4@Xq~G(f2ME-4_=iWfBioH z;KgAgesE-x6YgN+66ui!oUPp5a3+6eW`B_7qIxmjT_#xP<1@pWaPFij*^2>38VyV0 zc6o@n39NC&GbuK@vTvd~2o;$XK7oxibiOIsiv@%l{R$tM>q!>7HvOV2*0{$gF>H9UZz_9zQ#Ue;z)Tfu z(_=u0Fz{;(DU!dz6ruGbygs%gE*ns6{sQ8^m%j@coFWnR#Mjm{Nx{d1XR zXI5e;#~C4N?S-_AKX?r{j5ghNHxC0DC*8=5G4&6uFGd0_niFS z&6@Gdon0&#yRAB-g>A^?;od7&4zer6ITqSu3UIgXCwxuICBhH)M`MS_}wtIIbW zf16>N%@O%=?D~?`#Tzv}fv@AJ-I*$THIN;bJsG_I&rX95J)WVrY%*u@h>q%1;bG?` zD@fs$c{{pqIKZQ-e?S6lQUN4z}-sRjgoW=Day~6)@sks2Ww2|rF<=9h{g*Y$N_Xe+u{q+T%XHNi^{Cpl6 zyAsB`jc1?-z1hidEm6F4;-&d_{h-f5Ca)wP*L^Xk8gihOaJ15RR!hyC$g}~ ziwl8;v#`Yh6EthHJmgTw=*Imc#STGw~92F1>8ynMH2qdVz8JA6(E_TIt zH49m3FbB^vgF#mJfReEWq(_w!@%+SCvq2MA)TQ|G8-0rZ7bsmk99gML>PN{qHX}n2 z&yvn3Vf}WPovs9Rf+BE%`yCB1fI5F28~gJ&r!}YWh_-q6=o5J-x(SwGCs5NCRUd0NsG`6dsDhA2%z7pf5|2h9axL@4x&5Bjm!A2ldbuJvF8jMnaPkx-+CxbdVdx`qW>29N} zz?azB_$T)@`gbXdb&(m5P|@;Eq?EnVmkHCbBi1sQ87>loI-|p?)*LQtIpR9(|6h!( z7Cl4~wNVVY>4?yxIg2Ag!8*9)a+#gW?y>F;2u?eYrQr>5nViJMXu#bub1$`O-Ifp9 z_e$C^^X5XL<(1(g68Jq${RbkU@u_*1#B1Q~EF}fTHWnxtGl;$Y;FI9f=ed_{oVXK| zcsDn6mw;Q6r}KDM26D(ND3+cqFJ34m#~_tG;kIoQHhZC`suop+O!WbM%k_Wxwu_6+ zECtXtf)lR6<7CR7v-(!PkC0103^|_ki1SDGmF-untF{=pi+Vbo=rTNYw_Z9|?iD;A z)qGJmiGI!-Nt1=rM4Mj=R&Kf`f!XhBYygCw2%wCNO|ZDYPoJy&w=n>4=7DVmX)-X| zCxMMaRRm-zHjX$j3Flzj8P4B@U!h%?+O5{HnO2lRpxko z-btm+iy=c@b(ZJ94E!^OFWsOE;wLDG?gLYq*z6QTAYskTh!lWvU~aaPkAMIGOf!lr z)2FdibN3nW%zV$zToS3+!fdc#D0eovmxo=!<(O!gt|Y-b4KjC54zcq1#;nwxCe*mk zyf_8mcvROQO}F6C)Nnpdb+%S9%WukIM{D%D22rMwUnz4v&{T$mrZh0qFM>zV&Y}c{ zUCG)kz@eu?3jn%UGgtOBrUiB)8mUF~=pR)IrLCd@mnp4LZ)Jld*(y3gMegU0l89=| z7oa3uwE;`Ojzd3@U7-nH_-Zs~xW7aT+$apCF5f`Xu3?s!$jz?zvI$%5h<}CP%IE-7 z`4`A?N-S*jxLtM(Eezb%jb3>hfm zE>ZT9B`+_Gd~uSWf9$NgH9ss_Ofbav%_S%i1>7DIt$atuQTIWRftk6An4Fjl+cbq#bJ_ie6KUw~aVDH;%c@?>v zHAtanVJaYz)9VL7WG;p3te20kw~%RgCys+n{06Tu4I$rr=z7t)tk>r^M5>|+A78Zm zqmM6_MiwV2A^#cwbi}DXf^eoyoEIQqO~E8oT=~v9%l5bnp?;5Tq1T@q;U5Pj@MK(& za@k*izB4-HR|Y9?xPHBs5*Rn5_Q&~g&Bd?G7)oj(|1(2Alqo~PTG|&fQDCvFyQSQu zScBMNttTpH=S_e7!F3ETQyuv?5sWaxdaT#i0=TNi-!3k(v6a*Hg0(H!XWxTiK^G23 z&OoqvY)uv=8y6Ju2~& z8RC%9J5!=!%)vvgm**91j3Cj#9c+7L>Gq(n%qo`=rD(fQ<*yO$gdHbsJ5vy)*b#;m zlP50}!X27)wA@2yqksz@$*6sO(8VqnO;RhYAc%JBdpRq3*Jzg*c$gWH;%@%HssG`d zCIMa00HnqV9*UZ`E|0wkj3%paL)wqKNKVA>vkLGhr6Na(beZXXVS`{UJ>sXBb*i$$ zITJBAKq)Zc(~!#D+Tj%*0I7+~FE<(-f01dYuydS=QPN-6hf)DX`wlNaD%{~m)bRr# zfD>r!-}vnqqtTy)Nkww2MT?r7DIsmwlCT=dUNUw(AC0=jsXHDA-|U1t#!a6>(5f3F zlR}JS!8_x-`;fhK#%Z{8Yx(_|I!OJYDrx|T#hXB?`%R00RsCT^V;pu$D){YB`Isxh zh*G~lR&rhae%e7ZndtSWIeP)JAVp6;TOMHxQu+0yiK(MmaBF4nlcMCY{QVz-Is4vp8)YO%N9SflZt++Q>T-j4y{_u|v`P$IL!sxe<n96>;u} zBCP1gil%o5+HP#+OhB~|M#!@UDHE~h_E@8&s7m}`e|d;6+;BxrNcw;C_X?%!qV@>vF`Ry>M>-p5t!G zDw3Hvyc19Lowr_yvrs`uOyLcj6R__=!=SZDi!p&Pe?me&GPn@ZM{ydo?}AN0&y)j6 zEZfW|2;@s~)OSo!Hb553A)z2YbP#X%+a@vBJhFSDg(*2nCTXKUlxHPJ=zZVP1QZw= zCLYTH$U=;R2op0>y$wF3cJss*`l@DWeBYRgs``wKiz6i_ibbJt-r!jY(i({>?mw{N zPT3i@Op$Y$D}2Gs#c^#F#lFXomHEN;xhZ+gHj<1Z>EK}5Dz;PXnZA*BlQC@-4J^I~ z4NoI2`bFU@H<02`gL+F+wwE)pix`l=8Rr#1l(8|h>}favyvI3ICwUnHi%1jafM-#l zI1ez(h#cvv{xG4Sk0ND?H*YqsRMiA#WCtl13>j6cR|3k4tj=wC8SV%F8Hm7MJ#2}c zP2r8YI_5w^TdAOAK`VX;6Z|tmC&G9_3QJ2WnN(7&rhVr3K_0VQ8|zu9H2vhLTseDQ zN@}5yloqNjsWf@4=20J7z}=YbMEmE@QB(Dm5yN<+h-7ji(LwEbhw?f9kPcP(Imz4= z`R8AKXZ-d8xQx8l*v?a9Wl{yO69-w$p*AKgGgu2exA-p|YrkTY{#@viPdU+#{XV~i z!i_3@hhxc=NZv)XM4#>E!zLZX!@e#qhI=r^Q3$7)=oAna)zSAdD}cxNAy-w^$&ZcM zcrq00=(0E^Dr!n2(y3~BswR(#ML3?wfMNe?yt2|vsbruOiYS#y*>0nOlKN`>pN{x& z`VehzP6dot5w+GR7xUj3aEyw{@kpJ06aS`W4!boe9PW@-;?PeGlD2s!+IUM#QUuc% zjk@wq6Z4UHeCU?|%aMP=^8-QIjK`P*Do@v?q=?NGm2T~%@~#HC!w8w>QcaVAn`d_R zSM10)8P_;anH)XiBjaXNzpL1XAy9pvZ;qkAnpiGrTvwF97!qrKu>%}9&C@`geE^M6 zXi-RO&im>^^4WxDX1X!AeemAF4&Ff!ZB#`rxU3V{Qy{4U64@ho%%R71`I>;a*6~-i zND;eGj)ZlGpXf(u4;X4(6#5r%tyFv!WMzrpZj1ocojg7LEJshtea>pd^6k-Gin3;W zDt{p-uztB{C#-8<>t)t1v__-<9Fj~?r(9^!A&UM^w2lfmT*5-Y01R=mQNqTnVFW(q zp0B&M82`kL4ge5UBAqN^iqE7bpyHcRv7m6UB!+_u$^;?Is53JLO9_!`R*UTxAY)1^ z@hP^U<2|0DGdxbU!E3uh3l~^gl$6?OOfRJm7%zW`rxw`(Un2-bM`t)Izo62NLCbbi zfW>r=Tv2MHGvQ@!eRb7Mu5^CmDAg)C^jbqL07J;XE;QRD*ez+Ae-uGyh~eIy&+{Ws zpup^n86XcEc)N-T)M<#3Qwrp(;9ItOU!~UmNK+3lpuj78dl=9SFGxdnyC;EHdR$j( zOWHl{V|W#d>2tz>#e6m}PJ`}$s)ezNZj))c;qhLt5|2h?%R6%|Igf2xWAEpENm8gK>Ix$uZontS}cll{WcJ@PHv?PNp@R7ddvHFY>`A&|j(MfD;oW9k8X zYt{6ETvn_9`e$lF?37&wVW7y8Q8dcyL=lt@2G*Q3Y%=S~-i9OIGGw4X!$b_*QrUd~ z!)YVdOe=yzrgG2n>AJhIu_l=q6iyCt_;}Auorzf3epI@$ zBWaL2UFPle!B5OW2Z-;%&UmvTgCukdGW=1FaB{3;I1F~G6y^cP{6@FVa^}&-Lf>Axaz;tM(u5v zIHq9dx0V#AJb%meX=;jk+3n>7L^eC?b>`n>K)jN`$!N!AlFF6y+Y!D3wPhVw3M}#-95rOF)s~Rdfcnbn(wJDJ`5S)9+7>m@3Eo>jReaSG%aaV<|5|L!DK9JUewWt=IIZDAIS9` zDi;^LvPFshAR`2}#2#?pf{r&e<{FAjdv`D#b*e8#C_G7bd~pjiZ>IFgMt-?OAGtrD zcFmgdTNiX*EZO9Gv=G)jWlX~+LW#nLA8MhyDdrMXuO!w5K0PdaYsx6j<{wHI$A(Uz zmws89lYIH^C%EZShcgi6`EGc!9)sf$PndbTO)eME%u2S^soZo7!Q&jzBK7$~t#&X2 z^(Y?ojP>chi>Q#3hQln1#sY3|y)MCjZbcp|T!pw{6oejM*H(2ZI<|KoS>JA+O4V~P z{&(ROiX$U+U4QE6b*2fc-_6s#!IyI)rv_IdcVwyA=#|9&r6r={qi-Pu z%l93c*jS-`qj-%*th<%3+L5;P!z-k)>e`;~8vH2rT1hao!wd2J}+BgJv z!-|Ue5%!b6d}T7ho~7>5r1>#$*D19$4~QH!W7*5(>_FZxn%ETNd=`rfGZd9wtB9M| zj+4cq9H;PJ#f9NyuEeCr!q;Jn5~WzA%+U#+~3#j#1M%BTs#(D+T-#$&=G2f5R4d1E*cL-$l@+Yig`Ly$WD1S+)Q1w^N+g)v z&hA^^WAz&Cj%2SagedzEZBiD&pNgbhyAM#{gMRM2Lzd}e2=X;d^ihD~(gDtPRwM*X zd1lfGrdvOtM7=f9vu$5k_Gs+`n@+d527-w|L3P@Fy2rrtT{Pg#UxCMUHfNablSM50 z^&L#A1`GR-BQMi>w&2-7Q1)Tiw@)N3b%3KOURxvdV;GT65rz(3JhT=GbTPmkO00hu z8@lciz0;KN=@v1s{=F79Fx%@o+vQz`KrxWV8^=3#YNmT|N2aE+!*pI`ePVXXn1P+3 zyQhQD!N1LE7_lTGmYK`knE&u=7ILJ^O)LM57EQ1Wgz(ltVnaLt2`11Y>=Tz|y}u>R zLuErL!&xEfm4=d3`IP}tP^VvdETyhy{!q+QtKT067`QObYo!xqD|{HR2fZioznDC( zU!}8{{8wSQEsB|t3y`e^b(ofG;{R&Mge7P)V|jaucefTkh1>)pjuAP})?QCZdePC+ zl)fhg5i}DlySpm9d8ZkSW((H|p%iIDRl+;E)cwlZ<4^oJ7M^*{Nj}h%PqqY zo?z^q7M4PmiaVhxc?gQa%$F|CA0lTqn?6YpD0C0yOLmZndFwjySYW3L#ggLIt@Vq! z6|*|wm3hqQtnmo3~r3&!`K#GLfk9E zzaJ|>9;UJb%cyJnfVRR7;=*H{H#BL~k8#g0Llx}He=Y}OsA!wb*>J%I#gCUQu^XjRD~OQxfZ0Wo68_&SI7ol)6l>jkRg=ieK3g-<-kS022I^&hu% zie5<2Y$@qH%kV#}L^7#pK_kV-WMAg-YLiZ{*kIvX+^ z6Q5J%q_Sef+cnUbbnVHoH~l-CG}-BCc!t0nSIog~nM7%F#xC&zn=?oHygCxQ*CP{) zy+}^YYVwD_t;p@*`Fl8D7V|0!;-9WwM}hH^0mJ~bKR>&l=4{Sd3ZdGjtc<;E%%(8LqW#}FTWZR94Gx}RGf94<`|t!fNfR` z#SPVSRfB6Bl{&4BU>;VkffvP0x?ifzpLh*88)!o0iSS%z_>Y!xA(kqQAB}vP{&w6xwLAUf!M<_fubgE?!+Ams>>|S z+jHA6VogRi*P!JKVz>a{m?>=d`-6qLo`NANX z9O_cl0Pj~$ne(t^>NLCd~*Sc2jKf;_j3Ih*nrK>G2DruQ2Z$Rh$C6R+=IQOOsU zY7rzQ@02?QM8!q_is=y#JLZ>H?*m`TlWx~ z-gWmf9y`*qk3$>=eGs#fOvG=LCZMkkqn#~oxydB`9{;n|cImi5L%1O5;}$R?uRYK~RKtYAO!S=&Sul)IPO@-Wv8sWrc_J&wZZ zV~DbyxBy|yd^|5jQvO&3d*=H&V!J3r13_4r4B~fo#A0~BEdiqPb!gH#m3#E}$OrNU zoCqA!U~#%k&QIGgFaj#nL2hf6$L-%74Ok-NAWru^6AxNsUp{O5Lan{{YiwSgz`wMt zR>0Kz2Datyq^>|vRA5%1?@hV_E4^kK=@~;;9#LzkIIbEmFFic7HlIA(u-+JI4jn=2 z2Bp8ozb96NF~8&Y+0xIC2kUjq-b5Jg^LL*8bz+9G>YQeLU4Xvd2Kk6lu%H&`DEPhE zt6KL?SMDYZ?@r#!tZ1w26UiM9Gf^m6vW>CYHj~rifI8m8Qu=Eq-Xrps7o%PlfGyBf zkk*##^aNE`GR8%5oJ5CT?h5GLzZJq)W$|+pskhH%$A=s>aF7(e!|$slr1{t2Puole z1u=c?fhB`idHk!Z3kJ*|o;$G<{uZ;3Y7e-3O~*!kzm@nO4m%?IGlTjjmNqGHVZaIq z1!fM4QE zsA|BR!MowVl0xaSeT}15Ip_IrV(sYoDlA$ZXj^Sm#DD_zdi06xGj8JTm+lGHq?kU@ zp>u6#-ez5iVRZqkFA@ECcV){k^IMMZh4zaa(Ho#Ny{w;%6(*p^ajKh-%OuXh8*!tH)Ji{4CPk;bz-p~BD@nj0+Ynp z%e+Ed3DY$Y0vY#wNj3m`0yf(NOjYcw06>f58-AR||1Gs!t7k++CuX)ctQC>1E*)+p z+dgt^$C+Uj#V$h1jp-ROt3@E#sWP5+XKGo=mO}uD1u+kZ#e+g;ve+%UZ2C^ZuV*^) zKaDG+c?&VVrD~kr+~yPVc&BQD#9}Nf+EZlL5S6WF>GGY0EXMbW3q*|Q-Er?9-&T0J zU%3vdFep>rwg3jSpm6>Gcb?-xqh~;KZJek?S;8Xb(imh61oN3?vx6u$A*PY11p?zrGVl_CTqyef5ks-&mEw?mR0<87MHOW=j2wk!y{o z!arI#$`CiW2W5z~{|y{Hlc&UKU_Yu$_qy`KD1dw{lWYozrVkCve-@$LkXneucld(B zk&b_|ph3a>BM!@_=DU_1#Lats_QQcDw0-s5znevBCRgcy;z(h0FZ!Ag^~a$Y-<@oZ zB&@w8EL z52~!KZ455ILr)iv*`)O>P)4ax=4$v@#kS26xBb~EL-O7hdY5hRI3M~ z*i92-KrL8Nn|n4m)a-D^9A%uH!!MUt_dpboM(|f9H;QxL*PXlgY1-#b-k0{O!4Tjf zua-?5g1+<Y6N<(^iQT#WnUjv440b!{8pSlMij`(J? zrw{J07mkShf@AZdcx&!{kS6%$BZyrL6RfXHz;bEq;SF4?*fJXue@+U#nU707GC-_ z1=2h7HAmvQ#|)6or8J|H(Hz*7zzrXamV8wWa+C56{DE4N-2EC9pmU+jggL%6KZ3pR z{ieOh)%5{b*|XjKb$0D4NVX>EAUNreEesQ4Ghj*0h-?R}gdx5JdCac^RmpXv-R6-{ zfqI$U=MeDziX*?(vPAvbM~ZQ^QV$D{LB@ezyi5BrVkS_~V8%%XttiUKV@V7@0Cgj5 zcgvN`Gm;--Z2Hd?d`R*@?jXnK#`MemZG!o`@uLyjUMX-G5Zpndh%Pw(S5#*^uu+s)| zb$u7ug|$#+xOH>)BSBxJlc`(uhxAQ_q7wW$H4hF{Z|C|y3+#Xerv}9CekNlq9E0Bl zz-GI{k9S}#QmDB8p=S#IE-=>q$0It0hU3*)%^pwv=TuuiC6as$@V3K8M~pNJtqd{I zI{l6@`t#dj6u#FT;1CFZ-qLj&{tgfg#w3}Y4;ZvRp0K9@P4tmXUQz(u%@hw*Zwq35L|xiY5FEr#^Z zDXamY_7b4>4k(j2C^Nh&$OBmjy2j_j9p0CgF**vkRPxH9nG z_-sJ?8;lJV%{2leOK7-yWwiKxMKjM;@B$i{}Uip?Ch_R zT`SET_K0@Nzz}?hYS^*KZn%+e%jeOK(f?I2w6_FOa0Yys`8RMJ(pKMiwd#0g6|o<` z`$w+k!7N@Po@axbe;Ju9IB6h($p^K86*-w7_Itk2p-GKpzh8?qV@EiVi=MOtz zWYxk0M_FXDnu9LXBVaHl3?*i9P6OwOKjYdG?gcY}lO||XHHWR#e2o)r z45HuRwA{Au?&ku5ME2y)>3+b92iQ(fnR=Tle`u8O&En|4gjOQlfTqU$WMqX~tR~L^ zw$l2DVh|Z_pRSZFi#J=g4nJbT_?yqG_BP;qBAMD_N63R&!PFHx9c%%Aod0gajiG9e zAgiu7>`OE8vwRxzZVtXqNzfD57{wA><%5zRhZ_m>6IG%+*hKu2kd+`F?((-(etM|x z8(nk55U2lv0wXr-Gh-8i4sv6$L!JT$XPxx+GWTjXC&DAD0)a5~+Y(vVJGG(A4DK;u z5AslhI9_GpbeugplCnGeMM4N4lm}8g2M@xuHzq(h1y05~^gmnU6IBhvPRDs;TFXV1 zgPvt9A)o1d%Q7WQ^=>B4}n60Y*ZgiHjH9 z><{Nyy5~x}l4C>sT+sjVi{Lh>U>GGB)SR>clhDR!Dob1@OUJt z8c+CAeF5ub&ocj@LIR=An}qc~K z#3%V*4HwKp3+gfxAn{07WiLBBGg9$v>C)BeUhP zWq=6PTjB|`g7p9E*KvXW_G^6rtA1Xs1_F>i{q8Qxt!GQ!B@|?68u+cIddjVIiJZ6a z%sQP3A@FH@&pm&B%Lr9t+|d@v;K+8rModLA{$9r6A4w;n7>$#hkiI!Q_F z@ZTrp?Y~c`Phn~&rDNz}YGtKSbP?VIo57dbUFds%$-L>AE0;;+Fwey>=1z!4Q~;7H znnuwihoMKMYsgLQe*1M(mhwe*-lVBhEuMPQ5kx~o(j|Wbg3?NpY_fUw*A&(v3*a>W zjR5aNqsfyao@Lj@Dve;od#P<@cW;w{5__FKihn9M6>O8{xrRY;%e|39n)Al*OeZ>h zW%ZD`zyLlBqF*WIw|_cV1Y?qL-pBgybPj1t&J({3)4Q6&Ia1+4Xy{tj{Shth*|ql5 zDBr|mE+gyeWhp!TYFzM%v5|-|EK)Q)aGnh2YEJ$`8IC$!1g5KZ*9|f6@#t2Ed}nLY zZTA6};+NRzt*ancg7y|wp_0e2nRkNcwK9;-m1nmt(w4h?=OdHrqxj*MAh(0g)r(2h zcaa~Xs&18J;-6zpD(k}bI=Z)9Xw-3UiKq4y8FcjBOX!Q_=;**wwvJ&o1?^O|C=Y$u zqOV^#k9{TKTOC~mhpjQP{TpQIKhiaQ&QDTc8lcb@m&BlzKqhygAAX@aT+^pr=2g}6 zAoNwF>&umN`I4R7!OA$p3|j)yAbeS1S{oUn7H)KO^D7wg(;#+;Dv1Yjq(if)Hl{mn zSf4JT{N=GeI=tV13ZO%d@(XhRu3AasSjuK~UbCF!fsRUM#vf{5uW7_S-o#Fwih$nA zR+~0~0KcB5XgpPC?rUvy6NCC?cSUM-^JFA8N}=bTCG=O!mG3HDVezC21VGpD?id+29$Jx6cI096{;@kq5 z`Oz`TqY3dM*Mb>2ggiNUuPmQ}h9o!7H@4T9%f>@M4=>CDzO=Vw#N+gfHmbMSw!yyl zW#*`>*)>wAKho^FZWm^=Fj70xf*htuRuKPVSFE?qTE4IQNl^Hz>2(Bb+{xF|C0s&r z&x%(SV09DfhG~S&&{0QDp(YobuqZ&WTCb}VD4-juG1EcP+rlU4wu?6eaM)=0bVMxw zK@97}gUE}++|lSC8!xvk4@*T^Ug@$m`I&}UKAFBK7ZQJr=r~aC|smJkS zHoAwlp&V-N$=a~kK@#IsYv|bqw)}6WhAU(w8f!Ni<@3(NVEd4J#E+>9HpV=0&>7rXcHu6a{<#T3)2%nK@Qj?!g zv5PWm?cNw9?W%rqv$<}T7oz!;&E7NlkUf_lSMfVRc(;qGIG!~RrPuu6A}<7ilPXcM zs*fbcq1K)PsX|prkCEZIP3L>`oB8^GRqIE&(|+hT3bhw~BmIcptY5W&c=5@McHK)n z?R$w`@3H4P*tN4bmjPpueCgh`)7ZS2Fa-*|T!+Zxr+~9mtz6n)g@0+eC3RuTrTJk| zTG~Xi8{H0Cg=2=~9er(puI2ABYC!%B_D$d%CcXuBXM(y%13dEycmNXgwT!iG>|PeG zjPt?|mrekl=5j23MJK9^q0QuS2IVkPzWn1i`x&q$<<{ zl)S;JK8vcp-L}?T!81`llPMm{jYBu$M?(5EDVj5X8D2x5-m_hmhUanyW!2X2sTF+L z+1)JZTMB>tT4c$%qR}tr0QdT9eT{qv`yFI6fqV-wd>d~{eCRF})mM?$C z;qK1HYAO;Y6;f@R67Q06Y&+t=!~$t;yGy)CE`tx*z+xH^RKgHu z=zJ8rlB~AV2(`n6OfO> zDYR>g!icnE8wREDH>>_BRYVK9kD^3@N{>j~Swr35bT8%>i~r^p!U`ioA|+g#=k^NV zJ!^{4Kp8>WVL%d!Pzxfg6OdM6aD+W4W{Ao5)2||q=OuG0f)UU@8WyL|h9>2srcV3= zz=}>I300Km8*yhvl8VRop_ZE$O=jCKD3@3S47w6&>mCJ_FYPhLWE@xDaP$LO~ zqd=ids851|r<6aCi zjRD=|kLVZGR3uT$kaV&|{@z($ha^bFK4z%OX$%i&kj*asH7Yc6@w>~(Ki8oVo(a8~qBsS) ze@@wB;0K3@CQn*Qt_!73U*~){O!E3V&Eb4`0Q@RP`jxJ~%`p1O3YcE9p31rHe9E*i zq_Ln%puKg}ceM8A!I0*qMnL>}8%=A!wZ2!EA%)!*q85=EHo}|%XD#~dGnBLi?`%3T zSfUV3CB5T_2K`L&^>)^)=_|!ZR*Clj@7eQe2*~>a_usToc`hT!ojNNM&1u7OGSu~D zJH(d|FP{nJtCP&h`I7Z{1R}oqInz0hfS8V~dhO(L=HnBH=}v`O7304=nr|RZxF6Wp zu_r#rA|$gDi~_$d~EL!@Ox45>`X$j(8-c$8YH4i40Vy-ClE zEFlrU5;9b_i#&@Fupr54A#Z}PX`D}t34CysjEo@#?nLRyyP?g zyE#`1V+Z%f+O+PQI&6VqWcjpfJgT9$4eA9eDksm8nWZ}fnPrW?vMzEd#HOmQ3nZ7p zRH@JPsw15jDd|~421zPb*o=E?R&%$U^G5EEvmee z0ON@SJQlEPlY0@d4>=ok!Xb`=qE}cUD`BL%Jzc#K#2c?E;m(EKvgX35NNdCg0h4*x}9rVXUdPxoz6~A5e=_>3Eairu( z>sQ;xowDa1*+7&lZKtr7=GfG1MZ&~;Ap~CgcyV2{vRRk>*b9H}__U|-&|wVIb$5bf zgs3-s;=AWx!24Q>%KBG3MW>ea*2x@uOC*gH~(p7BstdZzr zUE}UCZ{QMdx&MGFLD@qsaqyuvboTJ8@OcLgj{cKR!qc4U>}^h=bCFno`uY0noOg-S`kVo zv)*4<+$?gLt_d8Vy#z2hKWKDyj{e0)wt*(AWyJ53iv4i6U=%T3kmy@qqbJ&B9NDk* z)3&{9r$e%@scl;RvlToVAHyA)<3j+$(Jc8aFzb+-hr6!!YK@&KD}`B-;ZXF6T*uyK zYH|F%vdqp5I)uuGrU0|DyHc}9DToTUx=prgd+2tD#sa(kiU`x0N3C&sQI>tvPE1>zWcZ1<~ zCC2pmmHqa^DPBmzGQQAU%;gP>-mm(y^)3^I;g(O!ui3TZzgKp3#eOxcc555S+-t$K zj#f@TJRvtQ{GhI?a15?L_b_2_25w$n*%#J0s(Hy6T)J&MWekd#_e;o$sPLmIU*)G} zF6bXo6^7Jr-!yU10|v9NH0qq zp9kMw9Bl7n@)7?iTwmBoUF++f@*_K^7L*X?hd|wuOsHZ$ot0J`S?0;fxoBQ)bu3Yj z!r;#FI1aRZi}SmsoPGS|dih?##G3@b^^Aa7C@Sr-Rm*f~e{nKoa$jjgsT|O=f6Vz& z=mhz0vo)F7o?{7@W?p;fyeMsd(Oif)qmM2#;oRPFVz!W;y_FBHqhKAI=eBTi*2-r^giz1?V6+*X$5<4W{!{~VgEd6?4kz9qi)KkqtjPPF8PYDoO zY$G+g=*VFTC|K|N>>OI#m`nB5#UoF!0mYEV2YtcSKkHL2+oepMjed>sBI8D5Yiv)y zMc1k`>t&gVamwAy3y_~eDe-c=@nh(b4s)V#MoRui0CW|L>UMgM$W-Lgv3mu!-8OeR zVgGX7SM3-w>?nPjYi@l2bO&pPFIhIIuk7}PvfXxqt~2McRVT=kDP52W$*1K44`9{h_eSWYhiAekQ=!np!<{=9t^L~*|=6TY?aBqT{~Ln8H^J8 zj{xU_gNwJaIZ6ZZ@-{0Ev|5#_$cp)H zrp)r_-Zk&g>KR9`jo3{GGuFJ@=r-f`n@s0+ZAr&V<$c>$4_2ChbI!fRmSiDdaHj^% zDTknuCw73C6q}9vxp^s2h+6?2%*Hrn(jL$Ng|RANu^=1w z{8c!B+jBDP$0xxbZ4pvV7Z8qh!gfLd?gliZ0WUD{X5gss1gJTJ3$bb7wBJg#%0->j z5Hd;_yix`49o$w007aIOxYVgE_3}1*Zr&7N4OoGAq=GV0!L=lBQtc)t6F|4qj*(Ud z`Pfm;UY{4`Ney9U5U|GzLzY22z7xqpAU*Ih9uZZMThDCsz^wu=$Ll@^W*g&m$4p=%W(MwMdiZbjXpOhnn@3`c zGXE)OBpvIKIBuA7F;vE&7#tg2PEWuVi&2sxuRfp~=WJ^@=_zFa#2G$+YM z*Cg42&X^Xc3RVeBhI#_JV8C{3;!2QFpyD3TGj$r zuYwiq1FUIVrA`4jfVS5LG;JpU+q@l3cK~lB9jN2K=$-4PaTI89Bd`np(3kmQ2h*N&hg4mo)bnUOO6lGF%*ZOjv$<7$%vKxH8WG|iIv(kHrPDG&}+0QM&~@VBN1 zxq-95w5t=9ZZBZ%xPnQG2FmD>5X=RIoEw1&=z6PBKfn;0T|wbyHGj`>Cf&3bC)$T7 zSY11Vtw!e41g{{ykCBrR-egshIxNx*^&O$oq=$&;n8glM#PmRwZ;CqME#1{28EEN^ z5_)w&mhlYP6HF@ZE5OE+m-s?GCD6HVk(1NLvt-RBDV8Tm)kF}xLz}K*MP&uhn<_Zu zq{~Q7K2{|uV3edvE{mG$#3f!DA^uAf9eV&=Ta2qnh(7r$dT$_g0-;B2b~! z2j2YPC3F_>gTFwYM~b-(20*22{CJo)X&g|1>db3ciFE;lI}D2*#lX$ji&)85fIXKU zWr1lRD{~R3HgKYWJwT~#RJi~o^4ozK8$gh344AD+z;GU^Kn|W0R9ga4i>pbYdb9$d zD_KYi1G?owlKy^^>GCH990}fda6|K2?TFQSvP3g-Bn2e}ClsJDIJdB>P2a5_J2^CrLwf z!D-MGOL~AkRHslCFcnnIWQ$B`0w7n9O-M%$ZSz6G(%)M((*iYhyF%5Hjv& zc(_5VY-MZY^1(oP%nZA0o#xIF0E)kx;OhbkeeYtxm95pl<$s1ErzfnjGJ3Yr98C`}8nnx@f|qgs1IJ_?g@x*pyuyK?sS zjBk{7^SVW&oHz%*LErj%aYwljkGaTs&ur#++mq_kz`_%SR0iP9`7*!?Dn)pHc^|+; z>@?v-6u81QH1qk|ybho{at+vo^ys1H66LF8rHv( zHaM8|bNh+~Y>h@qzU7QNA)3hslzb9<#mZXsUsn&8k}k8HWyA6nj_aTi;7px zMvstCLn}z3-qv-!^zw#K`^?~@j3~`e0{}VMMvwy}N3XvYB@Xvz*77J!mEdF)`~xAiq~^_d`)`^*5w z=jh5f209?oYJhrGrs;3u7R|IT!rS{S=_)&URfIj42hcdPRL;es7u;NYWe-VrMKND4 zQke|Utrz41e+p1A2taX;aj!Jts7O3pY zulXw42jbX(abRHJ)B=^z1fiWe=Y5RiQ#~6?0Ei5V@Rh_Pd~4nr3Xl5|$|DZIyFAM0sdB69Gdn?g%-19fATXXH^XK0CtU8q^;OEzlBUVZve1KJkQ2xezE_aN_8yN zl*BaLo@5l;k3ysd<-SDjS#BLLlFtA~tc$`Nn;;C10bb5x-b5fQ=#qO8&_}vONJ!1V zIt6(NYpi8JVcr!X;}${8W;CPIqywgNvT)a=0c+%|j_5gT;3=bR`Hbr!y7kx%QsBy> zbDc01D+eY5r-^1DKp*2>ZC?|k>u;sy)ht;Hv;@}2dlV5i7B5H7nD$QCh3xn%d5WI^ zQ1Px#4&G8gcpOu?XbA!4O|yWq5nxdT_w$G65(KtYHrW;i5{6E|p<<0y0mE+Em2D~~ z{Q*iXgH1rSK?XPz$M5wOfsK_BWg~cv6+ozNJ4Kj|yI;w1R0p&)dnPZ?<+BQC7y|xG zQ#%75OGJ#jnbiPqMH(|?PplB|*4k1c#9d?AojR(p)WXG{zruj~qfwgK8sMb{8*rK~ z?si13P}pUaF6UWM0+nI_O1}EfjLn89*eaLrs;G|TH z{kagZ+hG=Cy6@6_Gf*->tUXV0jHlj`;Fu*dV536Ik(QniUwpF)z_^PMo<0#qR>JiT zzH6wG~FrRKM_DP$!m6`@dQ8vbaYS7xF933D8A&#r2cA47F&CHO$RjzmD25Z+(K8w8sFbvXVdJ!LYvnSmHDS`$^mZC%QG7j zP>&^0Y&@sP6JFj^f@HCx@5H;SGmD@febkj$HxoZLKDWftA0Jdgy8I?hbfvFLfVWny z5Y^muX3LDikvHxQ<(?#X19f*o1hg2}6G~PB$bZrkk|VWc3_o|anKU1^wglQ4&qij{ zyC3{_D2kk=oVTjWJ2E`61@H90Die@VqlDERDsf}ec@X4*7Gkfy7_fzIWOM-GxilrD zozLiES56b`$4}UBEoqnp>P>njZB0cvF_fVI6~o42sU%Jn=|mnURG zk#QudR7ZE5>xtpMQP6VTU5+#Z|9$%|aWUv4E=HGgv782!<`v2OH+XoLgRJgQXiIut z(^nTx-0dQ1B4ts1lR?4VObv4VH&n=_!`)b?Vs+UV!B{Pyp1GqWW}eAiJsmW>h1ADx zsmM*U7#xj4*Lze6k<4b^wJv|?Y}A@RAj;B{5+y0d&`c&!~fW}n?xG@vy2IT$<+Fe#63Sp%o3V`Z{vs7jTjqB<)?mX7? zlQ*BwDGYVJhWIgatdt@vmZlU*?MyUBtWv3x)QqGxJmR;IL|LC%Nzd6)>CxD2fpVZ^ zKra(FkH>ZrrOwwNwLsgDcgp`vL2k00!0x(&zMfw}eD2>7m>~Mq?3(*ca9D!Qb}tCY zI$tn94%`T|q9}9Icz%_0YqJxA5YJc(4(vkA((lJ~_BE8q+^ z*@2c*X~3ORcY*&A+aR4f75J{BG69}0SBLXy>%W8CbSk(u(`YABQ~TEzQ7#32N9+(V zoELPDFRRE+zH$<)Jk{Yd^_)gp(04pN2Vj#XUj^c0Yfj^B-bo7%Lz!PI2d1SC=bP&m zz!jtt0J|Ak^UZgWpZk|roIY1G#L?z05Cz#Wbeu|-gaET480;;`IZ zGSW+!ZuOmzS)0jcw8>C;Pw68 zu_@>)<6)0g`EJR;?T&6-2$GK(v!&n$?EOxkWf+`xt~MPXBnY?qgN8Ezrj~=g8au>5 z_9wYHHtclYZi;}lT^aWcWFzg%7fd82t%x3hhZSgQ$ z(&uhtz>43B!Cl5d>}hgst%bnq zEq{gbx&Q3Gpi`|tG?dN-b5<0P$QD9A^HtPNUtcyyB#s&XMMFGjDxdN93SICgRs z?GPHe1Ryd%nJ9W~JieG6YvWbe&MTkrP~susb?e>HbmivP0QNAW*UlSHh|5wD3`C=& z7M=D@CgO{xbTM8e^m1$MlfG?a9xSt`>ZG9fKM zeAm*1Tf>DBrPSCC(L>DMVeu$+tMVh~<*QWy9WNe6>_;Um=9p+{#~`IcOW*!>JEa&p zVqfbZkSp76=c3fff^{Oki7Kjy*)kfI${R%nNTbn{Wa+&cp;eKi!WHHMNt|S&HjW~G z=V(PlndtJ8A$7?jb}7@#U|L1EH}G^PG$G!`O1;(f+9~0jBsPzZ_FYa^Gl!)lYBD+nPvLGoBi)JVNDjR|)y4*$Ww`4!!u!C^Rd z+-F5c^tU6q0IE;mxjcxwh#SvIPh*CYcvVbRtiP?&(6e+XzAMX;pLT9mI{m5$Tc9_L zS3W!$zLwCdz}}l4t!;^4T~;TJcb3dGzcLnfC}KEvo9V%a#`~S1@MJ0S; z_3 Date: Sat, 10 Oct 2026 18:18:57 +0200 Subject: [PATCH 09/37] Rendered: the edited channel and the mix on the regeneration worker --- automation/holds/regeneration.py | 6 +- .../application/reconstruction-edits.md | 8 +- docs/development/application/render-thread.md | 5 + docs/development/application/undo.md | 4 +- docs/development/architecture.md | 2 +- .../logic/reconstruction/manager.py | 4 +- .../logic/reconstruction/rewrites/queue.py | 6 +- .../reconstruction/rewrites/regeneration.py | 6 +- .../logic/shared/renders.py | 67 +++++++-- .../services/regeneration/result.py | 14 +- .../services/regeneration/service.py | 64 ++++++++- .../reconstructions/reconstruction/renders.py | 8 +- .../services/test_regeneration.py | 29 ++++ tests/suite/regeneration.py | 30 +++- .../reconstruction/rewrites/test_queue.py | 49 ++++++- .../logic/shared/test_renders.py | 64 ++++++++- .../services/test_regeneration.py | 135 +++++++++++++++++- 17 files changed, 456 insertions(+), 45 deletions(-) diff --git a/automation/holds/regeneration.py b/automation/holds/regeneration.py index 05697e30e..55f848188 100644 --- a/automation/holds/regeneration.py +++ b/automation/holds/regeneration.py @@ -1,6 +1,7 @@ import threading -from typing import AbstractSet, Optional +from typing import AbstractSet, Mapping, Optional +import numpy as np import pytest from sampletones_application.services.regeneration.service import ( @@ -39,13 +40,14 @@ def held( channel_name: ChannelName, features: Features, heard: AbstractSet[int], + kept: Mapping[ChannelName, np.ndarray], ) -> None: self._wait() if self._failure is not None: service._emit(ServiceError(exception=self._failure)) # pylint: disable=protected-access return - rebuild(service, reconstruction, channel_name, features, heard) + rebuild(service, reconstruction, channel_name, features, heard, kept) monkeypatch.setattr(RegenerationService, "_run", held) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index f6ed122ef..54b8fca76 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -5,9 +5,11 @@ This document describes how the Reconstructions tab changes the document it has wait on its edits. Consult it when adding a gesture that edits the open document, one that reads or puts away the whole document, or one that replaces the document from outside the tab. -A rebuild of an edited channel runs on a worker thread, so the reader can make the next change before the -last one has landed. Every rule below keeps the document as though each change had landed before the next -one was made. +A rebuild of an edited channel, and the render of the audio it sounds, run on a worker thread, so the +reader can make the next change before the last one has landed and the frames keep coming while the +rebuild renders. The worker takes the renders the cache holds for the document with it and hands back the +audio of every channel in play, which the landing keeps in the cache before the tab reads it. Every rule +below keeps the document as though each change had landed before the next one was made. --- diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index c38ae2d30..a5de615c5 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -19,6 +19,11 @@ budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across fr continues. Every background result reaches UI state this way. Applying one to UI state directly from the worker thread is forbidden. +A result carries what the landing installs. The regeneration renders the edited channel's audio on its +worker and hands it back inside its result; the landing keeps it in the render cache on the render thread. +What a worker produces for the interface therefore reaches what the interface reads through the queue +alone, and the render thread keeps drawing while the worker renders. + 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` belongs to the visual layers. diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md index d53478f55..eb2541546 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -31,11 +31,11 @@ An entry therefore owns the values its gesture made: | Project rate | each sample's document with its new setup, which shares the instructions | | Settings, properties | the settings and info shells | -A reconstruction edit follows the same rule. `RegenerationService` rebuilds one channel and builds the new document around it. The apply path installs that document through `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)). +A reconstruction edit follows the same rule. `RegenerationService` rebuilds one channel, builds the new document around it and renders the audio the document sounds, all on its worker. The apply path keeps that audio in the render cache and installs the document through `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)). **The types enforce the split.** A value's model is frozen, so a write in place raises where it is made. A core test walks every class a project reaches (`tests/unit/sampletones_core/project/test_values.py`). It requires each class to be a declared shell or a frozen value made of values. The history audit (`tests/suite/history/`) replays gestures against a model of the stack. After every step, it checks each stored entry against the state it recorded. -**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. +**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills: with what the screen reads cold, and with what a rebuild hands back from its worker. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. ## The open voice across a restore diff --git a/docs/development/architecture.md b/docs/development/architecture.md index e748e4ef3..1cd79acdc 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 knows no panel, view model or logic object. +- 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; `RegeneratedInstrument` carries the audio its worker rendered. --- diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index eea58a025..798fb31ab 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -56,7 +56,9 @@ def session(self) -> ReconstructionSession: @property def renders(self) -> RenderCache: - """Where the audio of the open document is rendered and kept for as long as the screen reads it.""" + """Where the audio of the open document is kept for as long as the screen reads it, rendered on the + first read or handed back by a rebuild. + """ return self._renders def load_reconstruction(self, path: Path) -> None: diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 6e69f08e0..60aaa1590 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -201,7 +201,8 @@ def _rebuild(self, change: ChannelChange) -> None: """Starts the rebuild of a changed channel from the document and the listening as they stand. The rebuild is marked running before it starts, since a result can arrive before the start - returns. + returns. The renders the cache holds for the document go with it, so the worker renders the + edited channel alone and the landing finds every channel's audio kept. """ reconstruction = self._manager.reconstruction envelopes = self._manager.current_features @@ -215,6 +216,7 @@ def _rebuild(self, change: ChannelChange) -> None: change.channel_name, change.rebased(envelopes[change.channel_name]), self._manager.listening.heard_on(change.channel_name), + kept=self._manager.renders.held(reconstruction), ) def _remove(self, removal: StemRemovalRequest) -> None: @@ -274,11 +276,13 @@ def _on_result(self, result: RegenerationResult) -> None: self._settle() def _land(self, rebuild: Rebuild, outcome: RegeneratedInstrument) -> None: + """Keeps the renders the rebuild made and hands the edit on, where its document is still open.""" if not rebuild.lands_on(self._manager.reconstruction): logger.info(f"A rebuild of {rebuild.change.channel_name} finished for a document put away since") self._let_go(rebuild) return + self._manager.renders.adopt(outcome.reconstruction, outcome.channels, outcome.mix) self.call( self.on_edit, ChannelEdit( diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py index 46cc25c3a..f0180eb2a 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -1,4 +1,6 @@ -from typing import AbstractSet, Callable, Protocol +from typing import AbstractSet, Callable, Mapping, Protocol + +import numpy as np from sampletones_application.services.regeneration.result import RegenerationResult from sampletones_core.constants.enums import ChannelName @@ -21,4 +23,6 @@ def start( channel_name: ChannelName, features: Features, heard: AbstractSet[int], + *, + kept: Mapping[ChannelName, np.ndarray], ) -> None: ... diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index b6aacf2a9..6ce8ae6f6 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -3,7 +3,7 @@ from collections import OrderedDict from dataclasses import dataclass from enum import StrEnum -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Mapping, Optional, Set, Tuple import numpy as np @@ -13,6 +13,7 @@ from sampletones_core.generators.render import render_instructions from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.renders import sounding_streams class RenderKind(StrEnum): @@ -41,9 +42,11 @@ class RenderCache: A render is keyed by the identity of the streams it sounds, which a lookup reads at once, where a stream's value would take every instruction it carries to hash. A render goes with the stream it was made from, and beyond ``budget_bytes`` the renders read longest ago go - first, so the cache keeps audio alone. The render thread reads the cache, and a stream's - collection may drop its renders from any thread, so every change to the cache is made - under one lock. + first, so the cache keeps audio alone. The render thread reads the cache and keeps what it + renders or adopts, and a stream's collection may drop its renders from any thread, so every + change to the cache is made under one lock. The lock lets its holder in again, since a stream + let go of while a keep runs on the same thread is forgotten from inside that keep. A kept + render is read-only, so the readers it is shared with copy what they change. """ def __init__(self, *, budget_bytes: int) -> None: @@ -51,7 +54,7 @@ def __init__(self, *, budget_bytes: int) -> None: self._renders: OrderedDict[RenderKey, np.ndarray] = OrderedDict() self._watched: Set[int] = set() self._held_bytes = 0 - self._lock = threading.Lock() + self._lock = threading.RLock() @property def held_bytes(self) -> int: @@ -62,13 +65,13 @@ def channels(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarr """The audio each channel in play renders from the stream it carries.""" return { stream.channel_name: self._channel(stream, reconstruction.config) - for stream in self._sounding(reconstruction) + for stream in sounding_streams(reconstruction) } def mix(self, reconstruction: Reconstruction) -> np.ndarray: """The whole reconstruction, summed from the channels that play.""" - streams = self._sounding(reconstruction) - key = RenderKey(RenderKind.MIX, tuple(id(stream) for stream in streams), reconstruction.config) + streams = sounding_streams(reconstruction) + key = self._mix_key(streams, reconstruction.config) cached = self._read(key) if cached is not None: return cached @@ -77,13 +80,52 @@ def mix(self, reconstruction: Reconstruction) -> np.ndarray: self._keep(key, mixed, streams) return mixed + def held(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: + """The renders the cache holds for the channels in play, by channel, which a lookup answers at once. + + A rebuild takes these with it, so the worker renders the channel the edit rewrote and + carries the rest over. + """ + held: Dict[ChannelName, np.ndarray] = {} + for stream in sounding_streams(reconstruction): + cached = self._read(self._channel_key(stream, reconstruction.config)) + if cached is not None: + held[stream.channel_name] = cached + + return held + + def adopt( + self, + reconstruction: Reconstruction, + channels: Mapping[ChannelName, np.ndarray], + mixed: np.ndarray, + ) -> None: + """Keeps renders made elsewhere, one per channel in play and their mix, as the document's own. + + A render the cache already holds for a stream stays as it is, so a channel rendered twice + keeps the one the screen read first. + + Args: + reconstruction: The document the renders sound. + channels: The audio each channel in play renders to, by channel. + mixed: The whole document, summed from ``channels``. + """ + streams = sounding_streams(reconstruction) + for stream in streams: + self._keep(self._channel_key(stream, reconstruction.config), channels[stream.channel_name], [stream]) + + self._keep(self._mix_key(streams, reconstruction.config), mixed, streams) + + @staticmethod + def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey: + return RenderKey(RenderKind.CHANNEL, (id(stream),), config) + @staticmethod - def _sounding(reconstruction: Reconstruction) -> List[InstructionsItem]: - """The streams that describe a frame, in channel order, the channels a render sounds.""" - return [stream for stream in reconstruction.instructions_data if stream.instructions] + def _mix_key(streams: List[InstructionsItem], config: Config) -> RenderKey: + return RenderKey(RenderKind.MIX, tuple(id(stream) for stream in streams), config) def _channel(self, stream: InstructionsItem, config: Config) -> np.ndarray: - key = RenderKey(RenderKind.CHANNEL, (id(stream),), config) + key = self._channel_key(stream, config) cached = self._read(key) if cached is not None: return cached @@ -114,6 +156,7 @@ def _keep( if key in self._renders: return + rendered.setflags(write=False) self._renders[key] = rendered self._held_bytes += rendered.nbytes for stream in streams: diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index a341bfd05..f01a5171b 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -1,24 +1,32 @@ from dataclasses import dataclass -from typing import Union +from typing import Mapping, Union + +import numpy as np from sampletones_application.services.result import ( ServiceError, ServiceSuccess, ) +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @dataclass(frozen=True) class RegeneratedInstrument: - """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed. + """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed, and its sound. - The caller keeps what it asked for, so the result carries the rebuilt document alone. + The caller keeps what it asked for, so the result carries the rebuilt document and the audio + the worker rendered for it, which the landing installs where the screen reads it. Attributes: reconstruction: The fresh reconstruction carrying the rebuilt channel. + channels: The audio each sounding channel of ``reconstruction`` renders to, by channel. + mix: The whole reconstruction, summed from ``channels``. """ reconstruction: Reconstruction + channels: Mapping[ChannelName, np.ndarray] + mix: np.ndarray RegenerationResult = Union[ diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index d0f3d6f65..0e8360eb9 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -1,5 +1,7 @@ from functools import partial -from typing import AbstractSet, List, cast +from typing import AbstractSet, Dict, List, Mapping, cast + +import numpy as np from sampletones_application.services.base import ServiceBase from sampletones_application.services.regeneration.result import ( @@ -8,23 +10,27 @@ ) from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.audio.mixing import mix from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features +from sampletones_core.generators.render import render_instructions from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import sounding_streams class RegenerationService(ServiceBase[RegenerationResult]): - """Recomputes one generator's instructions for a reconstruction. + """Recomputes one generator's instructions for a reconstruction and renders what the document then sounds. An edit reaches the frames the reader hears on the channel, so ``heard`` 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. - The result is a fresh reconstruction carrying the updated generator data; the - source reconstruction is left intact. Producing a new object lets callers swap - the edited reconstruction in while any history snapshot that shares the prior - object stays valid. + The result is a fresh reconstruction carrying the updated generator data, with the audio of + every sounding channel and their mix; the source reconstruction is left intact. Producing a + new object lets callers swap the edited reconstruction in while any history snapshot that + shares the prior object stays valid. Carrying the audio lets the landing install it where the + screen reads it, so the render thread keeps drawing while the worker renders. One job runs at a time on a single worker thread. Its caller starts the next job once the previous result has arrived, so each rebuild starts from the document the one before it left. @@ -40,6 +46,8 @@ def start( channel_name: ChannelName, features: Features, heard: AbstractSet[int], + *, + kept: Mapping[ChannelName, np.ndarray], ) -> None: """Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread. @@ -48,6 +56,10 @@ def start( channel_name: The channel rebuilt. features: The envelopes the channel is rebuilt from. heard: The recordings whose frames the rebuild writes. + 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 + render handed for ``channel_name`` is passed over, since the edit rebuilds it. """ self._executor.execute( partial( @@ -56,6 +68,7 @@ def start( channel_name, features, heard, + kept, ), wait=True, ) @@ -66,6 +79,7 @@ def _run( channel_name: ChannelName, features: Features, heard: AbstractSet[int], + kept: Mapping[ChannelName, np.ndarray], ) -> None: try: exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] @@ -81,6 +95,42 @@ def _run( features.held_features, heard=heard, ) - self._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=updated))) + if SingleThreadExecutor.is_shutting_down(): + return + + channels = self._rendered(updated, channel_name, kept) + self._emit( + ServiceSuccess( + value=RegeneratedInstrument( + reconstruction=updated, + channels=channels, + mix=mix(list(channels.values())), + ) + ) + ) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) + + @staticmethod + def _rendered( + updated: Reconstruction, + edited: ChannelName, + kept: Mapping[ChannelName, np.ndarray], + ) -> Dict[ChannelName, np.ndarray]: + """The audio each sounding channel of ``updated`` renders to, a handed render carried over where the + edit left the channel alone. + """ + rendered: Dict[ChannelName, np.ndarray] = {} + for stream in sounding_streams(updated): + handed = kept.get(stream.channel_name) + if handed is not None and stream.channel_name != edited: + rendered[stream.channel_name] = handed + continue + + rendered[stream.channel_name] = render_instructions( + [data.instruction for data in stream.instructions], + stream.channel_name, + updated.config, + ) + + return rendered diff --git a/src/sampletones_core/reconstructions/reconstruction/renders.py b/src/sampletones_core/reconstructions/reconstruction/renders.py index 5b5403a2c..fac358653 100644 --- a/src/sampletones_core/reconstructions/reconstruction/renders.py +++ b/src/sampletones_core/reconstructions/reconstruction/renders.py @@ -1,13 +1,19 @@ -from typing import Dict +from typing import Dict, List import numpy as np from sampletones_core.audio.mixing import mix from sampletones_core.constants.enums import ChannelName from sampletones_core.generators.render import render_channels +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +def sounding_streams(reconstruction: Reconstruction) -> List[InstructionsItem]: + """The streams that describe a frame, in channel order, which are the channels a render sounds.""" + return [stream for stream in reconstruction.instructions_data if stream.instructions] + + def rendered_channels(reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: """The audio each channel in play renders from the instructions it carries. diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 79b43f351..c6c136889 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -57,6 +57,7 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) assert len(results) == 1 @@ -72,6 +73,7 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) emitted = results[0].value @@ -80,6 +82,25 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat len(rendered_channels(emitted.reconstruction).get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0 ) + def test_the_result_sounds_what_the_core_renders(self, reconstruction_data, pulse_features) -> None: + """The audio the worker hands back is the render of the document it hands back.""" + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + reconstruction_data.reconstruction.recorded_stem_ids, + {}, + ) + + outcome = results[0].value + expected = rendered_channels(outcome.reconstruction) + assert outcome.channels.keys() == expected.keys() + assert all(np.array_equal(outcome.channels[name], expected[name]) for name in expected) + def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None: service = RegenerationService() @@ -88,6 +109,7 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) approximation = rendered_channels(reconstruction_data.reconstruction).get( @@ -103,6 +125,7 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1) @@ -123,6 +146,7 @@ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_da ChannelName.PULSE1, silenced, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) assert isinstance(results[0], ServiceSuccess) @@ -140,6 +164,7 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da ChannelName.PULSE1, {}, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) assert len(results) == 1 @@ -155,6 +180,7 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + kept={}, ) assert len(results) == 1 @@ -187,6 +213,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: ChannelName.PULSE1, edited, context.reconstruction.recorded_stem_ids, + {}, ) assert len(results) == 1 @@ -307,6 +334,7 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) # The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails @@ -337,6 +365,7 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, ChannelName.PULSE1, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, + {}, ) for _ in range(DELIVERY_BUDGET_FRAMES): diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py index 5832f2862..c89a54525 100644 --- a/tests/suite/regeneration.py +++ b/tests/suite/regeneration.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import AbstractSet, Callable, List, Tuple +from typing import AbstractSet, Callable, List, Mapping, Tuple + +import numpy as np from sampletones_application.services.regeneration.result import ( RegeneratedInstrument, @@ -10,6 +12,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix @dataclass(frozen=True) @@ -20,6 +23,7 @@ class HeldRebuild: channel_name: ChannelName features: Features heard: AbstractSet[int] + kept: Mapping[ChannelName, np.ndarray] class HeldRegeneration: @@ -43,8 +47,10 @@ def start( channel_name: ChannelName, features: Features, heard: AbstractSet[int], + *, + kept: Mapping[ChannelName, np.ndarray], ) -> None: - self._held.append(HeldRebuild(reconstruction, channel_name, features, heard)) + self._held.append(HeldRebuild(reconstruction, channel_name, features, heard, kept)) @property def held(self) -> Tuple[HeldRebuild, ...]: @@ -54,12 +60,26 @@ def held(self) -> Tuple[HeldRebuild, ...]: def finish(self) -> None: """Runs the earliest held rebuild and delivers what it produced.""" rebuild = self._held.pop(0) - self._service._run(rebuild.reconstruction, rebuild.channel_name, rebuild.features, rebuild.heard) + self._service._run( + rebuild.reconstruction, + rebuild.channel_name, + rebuild.features, + rebuild.heard, + rebuild.kept, + ) def finish_with(self, reconstruction: Reconstruction) -> None: - """Delivers ``reconstruction`` as what the earliest held rebuild produced.""" + """Delivers ``reconstruction``, rendered afresh, as what the earliest held rebuild produced.""" self._held.pop(0) - self._service._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=reconstruction))) + self._service._emit( + ServiceSuccess( + value=RegeneratedInstrument( + reconstruction=reconstruction, + channels=rendered_channels(reconstruction), + mix=rendered_mix(reconstruction), + ) + ) + ) def fail(self, exception: Exception) -> None: """Delivers ``exception`` as the earliest held rebuild's failure.""" 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 f465420a4..f85a1a1ff 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -344,7 +344,7 @@ def test_a_result_with_no_rebuild_running_is_refused( ) -> None: stray = HeldRegeneration() stray.subscribe(rewrites._on_result) - stray.start(taking_turns, SHARED_CHANNEL, taking_turns.export()[SHARED_CHANNEL], frozenset()) + stray.start(taking_turns, SHARED_CHANNEL, taking_turns.export()[SHARED_CHANNEL], frozenset(), kept={}) with pytest.raises(RuntimeError, match="no rebuild was running"): stray.finish() @@ -538,3 +538,50 @@ def test_steps_taken_at_once_leave_the_line_empty( gesture.assert_called_once_with() assert outcome.busy == [] + + +class TestTheRendersALandingKeeps: + """A landed rebuild leaves the renders it made in the cache, so the panel reads them without rendering, + and a rebuild that lands nowhere leaves none. + """ + + def test_a_landed_rebuild_leaves_its_renders_kept( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + regeneration.finish() + + landed = reconstruction_manager.reconstruction + assert landed is not None + assert reconstruction_manager.renders.held(landed).keys() == {SHARED_CHANNEL, SOLE_CHANNEL} + + def test_the_rebuild_takes_the_kept_renders_with_it( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + taking_turns: Reconstruction, + ) -> None: + kept = reconstruction_manager.renders.channels(taking_turns) + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert regeneration.held[0].kept.keys() == kept.keys() + assert all(regeneration.held[0].kept[name] is kept[name] for name in kept) + + def test_a_rebuild_landing_nowhere_leaves_no_render( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.drop() + + regeneration.finish() + + assert reconstruction_manager.renders.held_bytes == 0 diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index 9d06581e4..7451ab834 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -1,13 +1,15 @@ import gc from pathlib import Path -from typing import Final, Tuple +from typing import Final, List, Tuple import numpy as np import pytest +from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache +from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import PulseInstruction +from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix from tests.conftest import RENDER_BUDGET @@ -117,3 +119,61 @@ def test_a_collected_document_releases_its_renders(self, renders: RenderCache) - gc.collect() assert renders.held_bytes == 0 + + +def _never_rendering( + instructions: List[InstructionUnion], + channel_name: ChannelName, + config: Config, +) -> np.ndarray: + raise AssertionError(f"{channel_name} was rendered") + + +class TestAdoptingRendersMadeElsewhere: + """Renders a rebuild made on its worker are kept as the document's own and read back without rendering. + + ``held`` says what the cache holds for a document's channels without rendering, which is what + a rebuild takes with it. A render already kept stays when the same stream's render is adopted + again, and every kept render is read-only, since the readers it is shared with copy what they + change. + """ + + def test_held_names_the_channels_read_so_far(self, renders: RenderCache, document: Reconstruction) -> None: + assert renders.held(document) == {} + + kept = renders.channels(document) + + held = renders.held(document) + assert held.keys() == kept.keys() + assert all(held[name] is kept[name] for name in kept) + + def test_adopted_renders_answer_every_read( + self, + renders: RenderCache, + document: Reconstruction, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + channels = rendered_channels(document) + mixed = rendered_mix(document) + + renders.adopt(document, channels, mixed) + + monkeypatch.setattr(renders_module, "render_instructions", _never_rendering) + assert all(renders.channels(document)[name] is channels[name] for name in channels) + assert renders.mix(document) is mixed + + def test_a_kept_render_is_read_only(self, renders: RenderCache, document: Reconstruction) -> None: + assert all(not rendered.flags.writeable for rendered in renders.channels(document).values()) + assert not renders.mix(document).flags.writeable + + def test_a_render_already_kept_stays_when_adopted_again( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + first = renders.channels(document) + + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + + after = renders.channels(document) + assert all(after[name] is first[name] for name in first) diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 8090e1a31..159ffa654 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -5,20 +5,29 @@ import numpy as np import pytest +from sampletones_application.services.regeneration import service as service_module +from sampletones_application.services.regeneration.result import RegeneratedInstrument from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ( ServiceError, ServiceSuccess, ) +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName 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 tests.conftest import ReconstructionFactory +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns + +__all__ = ["taking_turns"] REFERENCE_PITCH: Final[int] = 60 +NO_RENDERS: Final[Dict[ChannelName, np.ndarray]] = {} MockReconstruction: TypeAlias = MagicMock SynthesisMocks: TypeAlias = SimpleNamespace @@ -68,6 +77,7 @@ def synthesis_mocks() -> Iterator[SynthesisMocks]: def reconstruction() -> MockReconstruction: reconstruction = MagicMock() reconstruction.config = MagicMock() + reconstruction.with_channel_data.return_value.instructions_data = () return reconstruction @@ -88,6 +98,7 @@ def test_start_runs_the_rebuild_on_the_worker( synthesis_mocks.channel_name, features, EVERY_STEM, + kept=NO_RENDERS, ) assert len(results) == 1 @@ -106,7 +117,7 @@ def test_a_job_started_as_the_last_one_winds_down_still_runs( """ service = RegenerationService() with patch.object(service._executor, "execute") as execute: - service.start(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM) + service.start(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM, kept=NO_RENDERS) assert execute.call_args.kwargs == {"wait": True} @@ -127,6 +138,7 @@ def test_run_success_emits_service_success( synthesis_mocks.channel_name, features, EVERY_STEM, + NO_RENDERS, ) assert len(results) == 1 @@ -144,7 +156,7 @@ def test_run_regenerates_from_the_envelopes_it_is_handed( """The caller writes the edit into the envelopes, so the service renders what it is given.""" service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM) + service._run(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM, NO_RENDERS) _, _, initial_pitch, held = reconstruction.with_channel_data.call_args.args assert initial_pitch == features.initial_pitch @@ -163,6 +175,7 @@ def test_run_asks_the_document_for_its_edited_channel( synthesis_mocks.channel_name, features, EVERY_STEM, + NO_RENDERS, ) reconstruction.with_channel_data.assert_called_once() @@ -187,6 +200,7 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( synthesis_mocks.channel_name, features, EVERY_STEM, + NO_RENDERS, ) call_args = reconstruction.with_channel_data.call_args @@ -202,7 +216,7 @@ def test_run_carries_a_moved_reference_pitch( moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12}) service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, moved, EVERY_STEM) + service._run(reconstruction, synthesis_mocks.channel_name, moved, EVERY_STEM, NO_RENDERS) _, _, initial_pitch, _ = reconstruction.with_channel_data.call_args.args assert initial_pitch == REFERENCE_PITCH + 12 @@ -223,6 +237,7 @@ def test_run_hands_on_every_instruction_the_envelopes_describe( synthesis_mocks.channel_name, features, EVERY_STEM, + NO_RENDERS, ) call_args = reconstruction.with_channel_data.call_args @@ -249,6 +264,7 @@ def test_run_exception_emits_service_error( ChannelName.PULSE1, cast(Features, {}), EVERY_STEM, + NO_RENDERS, ) assert len(results) == 1 @@ -273,6 +289,7 @@ def test_run_exception_does_not_update_reconstruction( ChannelName.PULSE1, cast(Features, {}), EVERY_STEM, + NO_RENDERS, ) reconstruction.with_channel_data.assert_not_called() @@ -300,6 +317,7 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: ChannelName.PULSE1, features, EVERY_STEM, + NO_RENDERS, ) assert isinstance(results[0], ServiceSuccess) @@ -350,3 +368,114 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing( self._regenerated(reconstruction) assert reconstruction.playing_channels == (ChannelName.PULSE1,) + + +def _never_rendering( + instructions: List[InstructionUnion], + channel_name: ChannelName, + config: Config, +) -> np.ndarray: + raise AssertionError(f"{channel_name} was rendered") + + +def _rebuilt(reconstruction: Reconstruction, kept: Dict[ChannelName, np.ndarray]) -> RegeneratedInstrument: + """What the service reports once it rebuilds the shared channel of ``reconstruction`` from its own envelopes.""" + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + reconstruction, + SHARED_CHANNEL, + reconstruction.export()[SHARED_CHANNEL], + reconstruction.recorded_stem_ids, + kept, + ) + + assert isinstance(results[0], ServiceSuccess) + outcome: RegeneratedInstrument = results[0].value + return outcome + + +class TestWhatTheRebuildSounds: + """The result carries the audio of every channel in play: the rebuilt channel rendered afresh, and a + render handed in carried over for a channel the edit left alone. + + A channel the edit leaves alone keeps its stream, so the render the caller holds for it is the + render of the rebuilt document too. A render handed for the rebuilt channel sounds the stream + the edit replaced, so the worker renders that channel whatever it was handed. + """ + + def test_the_rebuilt_channel_and_the_mix_are_rendered(self, taking_turns: Reconstruction) -> None: + outcome = _rebuilt(taking_turns, NO_RENDERS) + + expected = rendered_channels(outcome.reconstruction) + assert outcome.channels.keys() == expected.keys() + assert all(np.array_equal(outcome.channels[name], expected[name]) for name in expected) + assert np.array_equal(outcome.mix, rendered_mix(outcome.reconstruction)) + + def test_a_render_handed_for_a_channel_left_alone_carries_over( + self, + taking_turns: Reconstruction, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + handed = rendered_channels(taking_turns) + rendered: List[ChannelName] = [] + real_render = service_module.render_instructions + monkeypatch.setattr( + service_module, + "render_instructions", + lambda instructions, channel_name, config: rendered.append(channel_name) + or real_render(instructions, channel_name, config), + ) + + outcome = _rebuilt(taking_turns, {SOLE_CHANNEL: handed[SOLE_CHANNEL]}) + + assert outcome.channels[SOLE_CHANNEL] is handed[SOLE_CHANNEL] + assert rendered == [SHARED_CHANNEL] + + def test_a_channel_handed_no_render_is_rendered(self, taking_turns: Reconstruction) -> None: + outcome = _rebuilt(taking_turns, NO_RENDERS) + + assert np.array_equal(outcome.channels[SOLE_CHANNEL], rendered_channels(taking_turns)[SOLE_CHANNEL]) + + def test_a_render_handed_for_the_rebuilt_channel_is_passed_over(self, taking_turns: Reconstruction) -> None: + stale = rendered_channels(taking_turns)[SHARED_CHANNEL] + + outcome = _rebuilt(taking_turns, {SHARED_CHANNEL: stale}) + + assert outcome.channels[SHARED_CHANNEL] is not stale + + +class TestAShutdownMidRebuild: + """A shutdown asked for while a rebuild runs ends the job with no render and no report. + + The queue the report would reach is being stopped with the shutdown, so the job lets its result + go and the worker is joined the sooner. + """ + + @pytest.fixture(autouse=True) + def armed_again(self) -> Iterator[None]: + yield + SingleThreadExecutor.reset_shutdown() + + def test_the_job_renders_nothing_and_reports_nothing( + self, + taking_turns: Reconstruction, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(service_module, "render_instructions", _never_rendering) + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + SingleThreadExecutor.request_shutdown() + + service._run( + taking_turns, + SHARED_CHANNEL, + taking_turns.export()[SHARED_CHANNEL], + taking_turns.recorded_stem_ids, + NO_RENDERS, + ) + + assert results == [] From 0b8cfb0f90da8368f4c5927609c8507fed7194fa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 18:22:15 +0200 Subject: [PATCH 10/37] Fixed: the single-landing reading keeps the frames after the landing --- tests/screens/reconstructions/edits/test_long_document_drag.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index 371cda562..e0d692d9e 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -25,6 +25,7 @@ ZOOM_NOTCHES: Final[int] = 5 LONG_GAP_SECONDS: Final[float] = 0.1 DRAIN_TIMEOUT_SECONDS: Final[float] = 120.0 +FRAMES_AFTER_THE_LANDING: Final[int] = 3 BURST_READINGS: Final[str] = "frame_gaps.json" HELD_BURST_READINGS: Final[str] = "frame_gaps_held.json" LANDING_READINGS: Final[str] = "frame_gaps_landing.json" @@ -189,6 +190,7 @@ def release_and_record_the_landing(screen: Screen) -> None: regeneration_hold.release() released.append(time.monotonic()) _wait_for_the_line_to_empty(screen) + screen.frames(FRAMES_AFTER_THE_LANDING) stamps.extend(recording.values()) From 02c95c4de9ad8023ef829d15d3843869e7f98301 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 19:32:03 +0200 Subject: [PATCH 11/37] Changed: the long-document readings wait on the waveform's fade --- automation/vocabulary/graphs.py | 1 + .../edits/test_long_document_drag.py | 38 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py index fdfa834fa..787339f61 100644 --- a/automation/vocabulary/graphs.py +++ b/automation/vocabulary/graphs.py @@ -1,3 +1,4 @@ from typing import Final WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating" +WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index e0d692d9e..6a51cacfc 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -2,7 +2,7 @@ import os import time from pathlib import Path -from typing import Dict, Final, List, Tuple +from typing import Dict, Final, List, Optional, Tuple import pytest @@ -13,7 +13,7 @@ from automation.steps.reconstructions import expect_open, leave_letting_it_go from automation.views.bar_graph import BarGraph from automation.views.instruments import Instruments -from automation.vocabulary.graphs import WAVEFORM_REGENERATING +from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world @@ -59,13 +59,18 @@ def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> No graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) -def _wait_for_the_line_to_empty(screen: Screen) -> None: - """Waits until the waveform's fade ends, which is when the last edit on its way has landed.""" - busy = screen.words(WAVEFORM_REGENERATING) +def _reconstruction_shade(screen: Screen) -> Optional[str]: + """The theme the waveform's reconstruction line wears, which the fade swaps while an edit is on its way.""" + waveform = screen.reconstructions.waveform + return screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))) + + +def _wait_for_the_fade_to_end(screen: Screen, unfaded: Optional[str]) -> None: + """Waits until the reconstruction line wears ``unfaded`` again, which is when the last edit has landed.""" screen.bridge.expect( - screen.status, - busy.__ne__, - description="the line empty", + lambda: _reconstruction_shade(screen), + unfaded.__eq__, + description="the fade ended", timeout=DRAIN_TIMEOUT_SECONDS, ) @@ -106,21 +111,23 @@ def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] releases: List[float] = [] + unfaded: List[Optional[str]] = [] def open_and_zoom_in(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) + unfaded.append(_reconstruction_shade(screen)) - def drag_until_the_line_empties(screen: Screen) -> None: + def drag_until_the_fade_ends(screen: Screen) -> None: with screen.record(time.monotonic) as recording: _drag_the_bars(instruments, graph, releases) - _wait_for_the_line_to_empty(screen) + _wait_for_the_fade_to_end(screen, unfaded[0]) stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: _keep_readings(BURST_READINGS, _frame_readings(stamps, releases[-1])) - screen.scenario(open_and_zoom_in, drag_until_the_line_empties, keep_the_readings, leave_letting_it_go).run() + screen.scenario(open_and_zoom_in, drag_until_the_fade_ends, keep_the_readings, leave_letting_it_go).run() class TestADragWhileTheRebuildIsHeld: @@ -137,9 +144,11 @@ def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] releases: List[float] = [] + unfaded: List[Optional[str]] = [] def open_and_zoom_in(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) + unfaded.append(_reconstruction_shade(screen)) def drag_while_held(screen: Screen) -> None: with screen.record(time.monotonic) as recording: @@ -153,7 +162,7 @@ def keep_the_readings(screen: Screen) -> None: def release_and_wait(screen: Screen) -> None: regeneration_hold.release() - _wait_for_the_line_to_empty(screen) + _wait_for_the_fade_to_end(screen, unfaded[0]) screen.scenario( open_and_zoom_in, @@ -178,18 +187,21 @@ def test_the_frames_through_one_landing(self, screen: Screen, regeneration_hold: graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] released: List[float] = [] + unfaded: List[Optional[str]] = [] def drag_one_bar_while_held(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) + unfaded.append(_reconstruction_shade(screen)) standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) graph.drag_item(DRAGGED_ITEMS[0], start=standing[DRAGGED_ITEMS[0]], end=QUIET) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + assert _reconstruction_shade(screen) != unfaded[0] def release_and_record_the_landing(screen: Screen) -> None: with screen.record(time.monotonic) as recording: regeneration_hold.release() released.append(time.monotonic()) - _wait_for_the_line_to_empty(screen) + _wait_for_the_fade_to_end(screen, unfaded[0]) screen.frames(FRAMES_AFTER_THE_LANDING) stamps.extend(recording.values()) From d136f4c9feffa256b4bd3fabd61f6522d538b9fe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 20:03:12 +0200 Subject: [PATCH 12/37] Updated: the typing scenario for the entry's typing color --- tests/screens/sequencer/tracker/test_notes_typed.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py index d8e59c06c..f8029cab8 100644 --- a/tests/screens/sequencer/tracker/test_notes_typed.py +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -107,7 +107,8 @@ def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: class TestAVoiceTyped: """A voice number typed into a slot arrives with its kind's color in the same frame. - An instrument typed into the Sample column keeps the number and the color as they were. + An instrument typed into the Sample column is refused: the digits being typed wear the typing + color while they stand, and the cell ends with the number and the color it had. """ def test_the_color_comes_with_the_number(self, screen: Screen) -> None: @@ -154,8 +155,10 @@ def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) - assert all(frame[1] == standing[1] for frame in frames) + assert all(theme == standing[1] for label, theme in frames if label == standing[0]) + assert all(theme != standing[1] for label, theme in frames if label != standing[0]) assert all(frame[0] != PAD_NUMBER for frame in frames) + assert frames[-1] == standing assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] assert history_size(screen) == before From 29e735a3085c5194e6750861b21c9f6ca2f614ac Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 22:31:23 +0200 Subject: [PATCH 13/37] Read: whether a channel stands by from its volume envelope --- .../logic/reconstruction/instruments.py | 10 +- src/sampletones_core/exporters/sounding.py | 24 +++-- .../exporters/test_sounding.py | 94 +++++++++++++++++-- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 84c282f55..9e49c7200 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -145,20 +145,18 @@ def _measured_channels(self) -> Optional[Dict[ChannelName, Features]]: return None return { - channel_name: ( - features if features == document[channel_name] else self._as_regenerated(channel_name, features) - ) + channel_name: (features if features == document[channel_name] else self._as_regenerated(features)) for channel_name, features in self._pending_changes.drawn(document).channels.items() } @staticmethod - def _as_regenerated(channel_name: ChannelName, features: Features) -> Features: + def _as_regenerated(features: Features) -> Features: """The envelopes a channel holds once the regeneration has rebuilt it from ``features``. A channel the envelopes silence rests through every frame, which the document stands by, describing no frame. """ - if stands_by(channel_name, features): + if stands_by(features): return features.leave_to_channel(features.envelopes) return features @@ -316,7 +314,7 @@ def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> def _note_silenced(self, channel_name: ChannelName, features: Features) -> None: """Remembers whether the latest edit of a channel silences it, which its rebuild then shows.""" - if stands_by(channel_name, features): + if stands_by(features): self._silenced.add(channel_name) else: self._silenced.discard(channel_name) diff --git a/src/sampletones_core/exporters/sounding.py b/src/sampletones_core/exporters/sounding.py index 128262a38..c86bc0ae9 100644 --- a/src/sampletones_core/exporters/sounding.py +++ b/src/sampletones_core/exporters/sounding.py @@ -1,23 +1,27 @@ -from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import sounds - from .feature import Features -from .maps import CHANNEL_TO_EXPORTER_MAP -def stands_by(channel_name: ChannelName, features: Features) -> bool: +def stands_by(features: Features) -> bool: """Whether a channel given these envelopes rests through every frame, and so stands by. An edit reaches a reconstruction as envelopes, which the regeneration rebuilds into the frames - the channel plays. Reading them through that same rebuild answers what the document will hold - before it holds it, so a figure measured as an edit arrives agrees with the one the - regenerated document reports. + the channel plays, and a frame sounds where its volume is above zero, on every kind of channel. + A volume left to the channel holds the level a channel starts a song at, which sounds, and a + volume written holds one of its own items at every frame, the last or the looped one past its + end. The envelopes therefore stand by where they describe no frame, or where every item the + volume writes is zero, which is the reading the rebuilt frames give without rebuilding them. Args: - channel_name: The channel the envelopes are written into. features: The envelopes the channel is given. Returns: bool: True where no frame the envelopes describe sounds. """ - return not sounds(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)) + if not features.has_frames: + return True + + volume = features.volume + if not volume.written: + return False + + return all(item == 0 for item in volume.items) diff --git a/tests/unit/sampletones_core/exporters/test_sounding.py b/tests/unit/sampletones_core/exporters/test_sounding.py index 72dc13d29..4109659aa 100644 --- a/tests/unit/sampletones_core/exporters/test_sounding.py +++ b/tests/unit/sampletones_core/exporters/test_sounding.py @@ -1,13 +1,23 @@ -from typing import Final +from dataclasses import dataclass +from typing import Callable, Dict, Final, Optional, Tuple import pytest +from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import Features, stands_by +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features, stands_by from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import sounds from tests.conftest import ReconstructionFactory +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.long_documents import long_document CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SHORT_SECONDS: Final[float] = 1.0 +LOUD: Final[int] = 9 +LOOP_POINT: Final[int] = 1 +Shape = Callable[[Features], Features] @pytest.fixture @@ -16,23 +26,93 @@ def features(reconstruction_factory: ReconstructionFactory) -> Features: return reconstruction_factory().export()[CHANNEL] +@pytest.fixture(scope="module", name="converted_channels") +def converted_channels_fixture() -> Dict[ChannelName, Features]: + """The envelopes every kind of channel exports from a short conversion sounding through every frame.""" + return long_document(SHORT_SECONDS, config=Config()).export() + + class TestWhatStandsBy: - """Envelopes are read through the rebuild a regeneration makes, so the answer is the document's.""" + """Envelopes are read as the rebuild a regeneration makes reads them, so the answer is the document's.""" def test_envelopes_that_sound_keep_the_channel_in_play(self, features: Features) -> None: - assert not stands_by(CHANNEL, features) + assert not stands_by(features) def test_envelopes_written_silent_stand_by(self, features: Features) -> None: silent = features.with_envelope(FeatureKey.VOLUME, Envelope[int](items=(0,) * features.frame_count)) - assert stands_by(CHANNEL, silent) + assert stands_by(silent) def test_a_held_volume_sounds(self, features: Features) -> None: """A volume left to the channel plays at the level a channel holds from the start of a song.""" held = features.leave_to_channel((FeatureKey.VOLUME,)) assert held.has_frames - assert not stands_by(CHANNEL, held) + assert not stands_by(held) def test_envelopes_describing_no_frame_stand_by(self, features: Features) -> None: - assert stands_by(CHANNEL, features.leave_to_channel(features.envelopes)) + assert stands_by(features.leave_to_channel(features.envelopes)) + + +def _rebuilt_frames_sound(channel_name: ChannelName, features: Features) -> bool: + """The rebuild's own reading: whether any frame the exporter rebuilds from the envelopes is on.""" + return sounds(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)) + + +def _volume(items: Tuple[int, ...], loop_point: Optional[int] = None) -> Shape: + def shaped(features: Features) -> Features: + return features.with_envelope(FeatureKey.VOLUME, Envelope[int](items=items, loop_point=loop_point)) + + return shaped + + +def _silent_throughout(features: Features) -> Features: + return features.with_envelope(FeatureKey.VOLUME, Envelope[int](items=(0,) * features.frame_count)) + + +def _loud_in_the_last_frame(features: Features) -> Features: + return features.with_envelope( + FeatureKey.VOLUME, + Envelope[int](items=(0,) * (features.frame_count - 1) + (LOUD,)), + ) + + +class TestTheReadingAgreesWithTheRebuild(BaseTestSuite): + """Reading the envelopes answers what the rebuilt frames answer, on every kind of channel. + + The rebuild is how the regeneration reads an edit; the envelopes give the same answer in one + pass over the volume's items, which is what lets an edit's figures follow a drag frame by + frame. A volume shorter than the frames holds its last item past its end, and a looped one + cycles through its items, so a reading over the items alone covers every frame. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shape: Shape + + test_cases = ( + TestCase(label="as_converted", shape=lambda features: features), + TestCase(label="volume_written_silent_throughout", shape=_silent_throughout), + TestCase(label="loud_in_the_last_frame_alone", shape=_loud_in_the_last_frame), + TestCase(label="a_short_volume_whose_last_item_holds_loud", shape=_volume((0, LOUD))), + TestCase(label="a_short_volume_whose_last_item_holds_silent", shape=_volume((LOUD, 0))), + TestCase(label="a_short_silent_volume_held", shape=_volume((0, 0))), + TestCase(label="a_looped_volume_silent_after_its_opening", shape=_volume((LOUD, 0, 0), LOOP_POINT)), + TestCase(label="a_looped_volume_silent_throughout", shape=_volume((0, 0, 0), LOOP_POINT)), + TestCase( + label="volume_left_to_the_channel", shape=lambda features: features.leave_to_channel((FeatureKey.VOLUME,)) + ), + TestCase(label="no_frame_at_all", shape=lambda features: features.leave_to_channel(features.envelopes)), + ) + + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda channel_name: channel_name.value) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_envelopes_read_as_the_rebuilt_frames_do( + self, + test_case: TestCase, + channel_name: ChannelName, + converted_channels: Dict[ChannelName, Features], + ) -> None: + shaped = test_case.shape(converted_channels[channel_name]) + + assert stands_by(shaped) == (not _rebuilt_frames_sound(channel_name, shaped)) From 37958600fd35aa5b62016c782aa439cbbcce9b10 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 10 Oct 2026 23:18:25 +0200 Subject: [PATCH 14/37] Read: the waveform once per gesture, and the kept renders where nothing is filtered --- docs/development/application/stems.md | 2 +- .../logic/reconstruction/data.py | 90 +++++++++--------- .../logic/reconstruction/reconstruction.py | 92 ++++++++----------- .../view_model/shared/waveform_data.py | 26 ++++-- .../reconstruction/stems/data.py | 10 ++ .../reconstruction/stems/filter.py | 30 +++++- .../test_stems_reconstruction.py | 7 +- .../services/conftest.py | 1 + .../edits/test_long_document_drag.py | 34 +------ .../stems/test_long_document_boxes.py | 73 +++++++++++++++ tests/suite/screens/readings.py | 45 +++++++++ .../logic/reconstruction/test_data.py | 78 +++++++++------- .../reconstruction/test_reconstruction.py | 59 +++++++++++- .../view_model/shared/test_waveform_data.py | 76 +++++++++++---- .../reconstruction/test_stems_data.py | 17 ++++ .../reconstruction/test_stems_filter.py | 74 +++++++++++++++ 16 files changed, 510 insertions(+), 204 deletions(-) create mode 100644 tests/screens/reconstructions/stems/test_long_document_boxes.py create mode 100644 tests/suite/screens/readings.py diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index f5ea864d1..88f0d3e0c 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 the original mix covers the recordings of the stems heard on any channel. It is silence while no stem is heard, and there is none while no recording loaded. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. 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. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. 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/data.py b/src/sampletones_application/logic/reconstruction/data.py index db1d0f0d5..df4570ae9 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -13,7 +13,6 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.recordings import load_recordings from sampletones_core.reconstructions.reconstruction.renders import rendered_length -from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstruction.stems.filter import ( filter_approximations, ) @@ -23,11 +22,23 @@ @dataclass(frozen=True) class ReconstructionData: + """The open document beside what was read for it: its recordings and their mix. + + Attributes: + name: The name the document is shown under. + config: The configuration the document was converted at. + reconstruction: The document itself. + stem_audios: The loaded recordings, one per entry in entry order, or none at all. + filepath: The file the document lives in, ``None`` for one held in memory. + original_audio: The recorded source mixed into one waveform, ``None`` while no source loads. + """ + name: str config: Config reconstruction: Reconstruction stem_audios: Tuple[np.ndarray, ...] filepath: Optional[Path] + original_audio: Optional[np.ndarray] @classmethod def load(cls, path: Path) -> Self: @@ -79,13 +90,23 @@ def with_reconstruction(self, reconstruction: Reconstruction) -> Self: An edit produces a fresh reconstruction object; the display name and file location stand, so the reconstruction and the recordings its entries hold are what the rebind - refreshes. + refreshes. The original mix carries over while the recordings stay the same, and is + mixed again once an entry leaves or returns. """ + stem_audios = self._recordings_for(reconstruction) + original_audio = self.original_audio if self._same_recordings(stem_audios) else self._mixed(stem_audios) return replace( self, reconstruction=reconstruction, config=reconstruction.config, - stem_audios=self._recordings_for(reconstruction), + stem_audios=stem_audios, + original_audio=original_audio, + ) + + def _same_recordings(self, stem_audios: Tuple[np.ndarray, ...]) -> bool: + """Whether ``stem_audios`` holds the very recordings this data holds, in the same order.""" + return len(stem_audios) == len(self.stem_audios) and all( + incoming is held for incoming, held in zip(stem_audios, self.stem_audios) ) def _recordings_for(self, reconstruction: Reconstruction) -> Tuple[np.ndarray, ...]: @@ -115,12 +136,14 @@ def _assemble( filepath: Optional[Path], name: str, ) -> Self: + stem_audios = cls._load_stem_audios(reconstruction) return cls( config=reconstruction.config, reconstruction=reconstruction, - stem_audios=cls._load_stem_audios(reconstruction), + stem_audios=stem_audios, filepath=filepath, name=name, + original_audio=cls._mixed(stem_audios), ) @staticmethod @@ -155,10 +178,10 @@ def _load_stem_audios( logger.warning(f"Could not load the original audio: {error}. The original is unavailable") return () - @cached_property - def original_audio(self) -> Optional[np.ndarray]: - """The recorded source mixed into one waveform, ``None`` while no source loads.""" - return mix(list(self.stem_audios)) if self.stem_audios else None + @staticmethod + def _mixed(stem_audios: Tuple[np.ndarray, ...]) -> Optional[np.ndarray]: + """The recordings mixed into one waveform, ``None`` where none loaded.""" + return mix(list(stem_audios)) if stem_audios else None @cached_property def _stem_recording_indexes(self) -> Dict[int, int]: @@ -186,6 +209,9 @@ def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]: selected_stem_ids = selection.any_channel() indexes = self._stem_recording_indexes + if selected_stem_ids >= indexes.keys(): + return self.original_audio + recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids] if not recordings: return np.zeros(rendered_length(self.reconstruction), dtype=np.float32) @@ -195,48 +221,27 @@ def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]: def waveform_data( self, renders: RenderCache, - selection: Optional[StemSelection] = None, + selection: StemSelection, ) -> WaveformData: """Projects the slice of this data the waveform display renders, the audio read from ``renders``. - With a stems selection, the projection carries the frames each channel's selected - stems own and the mix of the recordings heard anywhere, so the waveform answers - exactly what plays. + The projection carries the frames each channel's heard stems own and the mix of the + recordings heard anywhere, so the waveform answers exactly what plays. A channel heard + whole carries the render the cache keeps, and a document heard whole carries the kept + mix, so a reading that filters nothing reads what is kept and computes nothing. """ - if selection is None: - return self._unfiltered_waveform(renders) - - return self._filtered_waveform( - renders, - selection, - self.reconstruction.stems_data, - ) - - def _unfiltered_waveform(self, renders: RenderCache) -> WaveformData: - """The whole document: every channel's rendered audio and the full original.""" - return self._waveform_data( - self.original_audio, - renders.channels(self.reconstruction), - renders.mix(self.reconstruction), - ) - - def _filtered_waveform( - self, - renders: RenderCache, - selection: StemSelection, - stems_data: StemsData, - ) -> WaveformData: - """The selected stems' frames and their original mix, in the unfiltered shape.""" + channels = renders.channels(self.reconstruction) approximations = filter_approximations( - stems_data, - renders.channels(self.reconstruction), + self.reconstruction.stems_data, + channels, selection, self.reconstruction.config.frame_length, ) + heard_whole = all(filtered is channels[channel_name] for channel_name, filtered in approximations.items()) return self._waveform_data( self.original_mix_for(selection), approximations, - mix(list(approximations.values())), + renders.mix(self.reconstruction) if heard_whole else mix(list(approximations.values())), ) def _waveform_data( @@ -254,13 +259,6 @@ def _waveform_data( sample_rate=self.reconstruction.config.sample_rate, ) - def get_partials( - self, - renders: RenderCache, - channel_names: List[ChannelName], - ) -> np.ndarray: - return self.waveform_data(renders).partials(channel_names) - def partials_for( self, renders: RenderCache, diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index dc6660ad2..a83775741 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -152,16 +152,10 @@ def display_reconstruction(self) -> None: self._build_stems_view_model(reconstruction_data), ) self.call(self.on_waveform_source_changed, self._current_audio_source) - self.call( - self.on_waveform_load_changed, - reconstruction_data.waveform_data( - self._reconstruction_manager.renders, - self._stem_selection, - ), - self._selected_channels, - ) + waveform = self._waveform(reconstruction_data) + self.call(self.on_waveform_load_changed, waveform, self._selected_channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data() + self._emit_audio_data(reconstruction_data, waveform) def update_reconstruction(self, *, refit_waveform: bool = False) -> None: """Re-answers every reading of the document after an edit. @@ -181,18 +175,11 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - self.call( - self.on_waveform_update_changed, - reconstruction_data.waveform_data( - self._reconstruction_manager.renders, - self._stem_selection, - ), - self._selected_channels, - refit=refit_waveform, - ) + waveform = self._waveform(reconstruction_data) + self.call(self.on_waveform_update_changed, waveform, self._selected_channels, refit=refit_waveform) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) if self._current_audio_source != AudioSourceType.ORIGINAL: - self._emit_audio_data() + self._emit_audio_data(reconstruction_data, waveform) def _adopt_playing_channels( self, @@ -273,9 +260,18 @@ def close_reconstruction(self) -> None: def set_audio_source(self, audio_source: AudioSourceType) -> None: self._current_audio_source = audio_source - self._emit_audio_data() + reconstruction_data = self._reconstruction_data + if reconstruction_data is None: + self.call(self.on_audio_data_changed, None) + else: + self._emit_audio_data(reconstruction_data, self._waveform(reconstruction_data)) + self.call(self.on_waveform_source_changed, audio_source) + def _waveform(self, reconstruction_data: ReconstructionData) -> WaveformData: + """One reading of what the document plays for the reader's listening, which the waveform and the audio share.""" + return reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._stem_selection) + def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" self._selected_channels = channels @@ -287,16 +283,10 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None: self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - self.call( - self.on_waveform_load_changed, - reconstruction_data.waveform_data( - self._reconstruction_manager.renders, - self._stem_selection, - ), - channels, - ) + waveform = self._waveform(reconstruction_data) + self.call(self.on_waveform_load_changed, waveform, channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data() + self._emit_audio_data(reconstruction_data, waveform) def set_stem_channels( self, @@ -331,16 +321,10 @@ def _refresh_listening(self) -> None: self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - self.call( - self.on_waveform_load_changed, - reconstruction_data.waveform_data( - self._reconstruction_manager.renders, - self._stem_selection, - ), - self._selected_channels, - ) + waveform = self._waveform(reconstruction_data) + self.call(self.on_waveform_load_changed, waveform, self._selected_channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data() + self._emit_audio_data(reconstruction_data, waveform) self.call(self.on_heard_changed) @property @@ -720,29 +704,27 @@ def _get_instrument_name(self, channel_name: ChannelName) -> str: return instrument_slice_name(reconstruction_data.name, channel_name) - def _emit_audio_data(self) -> None: - audio_data = self._compute_audio_data() - self.call(self.on_audio_data_changed, audio_data) - - def _compute_audio_data(self) -> Optional[AudioData]: - reconstruction_data = self._reconstruction_data - if not reconstruction_data: - return None + def _emit_audio_data( + self, + reconstruction_data: ReconstructionData, + waveform: WaveformData, + ) -> None: + self.call(self.on_audio_data_changed, self._audio_data(reconstruction_data, waveform)) + def _audio_data( + self, + reconstruction_data: ReconstructionData, + waveform: WaveformData, + ) -> Optional[AudioData]: + """What plays for the source chosen: the recordings heard, or the channels ticked of ``waveform``.""" sample_rate = reconstruction_data.reconstruction.config.sample_rate if self._current_audio_source == AudioSourceType.ORIGINAL: - selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection) - if selected_original_audio is None: + if waveform.original_audio is None: return None - return AudioData.from_array(selected_original_audio, sample_rate) + return AudioData.from_array(waveform.original_audio, sample_rate) - partial_approximation = reconstruction_data.partials_for( - self._reconstruction_manager.renders, - self._selected_channels, - self._stem_selection, - ) - return AudioData.from_array(partial_approximation, sample_rate) + return AudioData.from_array(waveform.partials(self._selected_channels), sample_rate) def _build_path_view_models( self, diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index c75e23801..825b62c84 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -9,6 +9,12 @@ @dataclass(frozen=True) class WaveformData: + """What the waveform display draws of a document: the audio each channel plays and their mix. + + ``approximation`` is the mix of ``approximations``, which is what lets a reading over every + channel answer with the mix itself. + """ + original_audio: Optional[np.ndarray] approximation: np.ndarray approximations: Dict[ChannelName, np.ndarray] @@ -17,20 +23,22 @@ class WaveformData: sample_rate: int def partials(self, channel_names: List[ChannelName]) -> np.ndarray: - """Sums the selected generators' approximations, silent when none apply. + """Sums the selected channels' approximations, silent when none apply. - The approximation sets the length, so the silent result matches the waveform even when - no original audio is present. Each selected approximation pads to that length before - the sum, so a channel that ends early leaves the tail in silence. + A selection covering every channel answers the mix the data already holds. A narrower + one is summed in channel order whatever order it was given in, so the same choice reads + the same samples. The approximation sets the length, so the silent result matches the + waveform even when no original audio is present, and each selected approximation pads to + that length before the sum, so a channel that ends early leaves the tail in silence. """ - if not channel_names: - return np.zeros_like(self.approximation) - + selected = [channel_name for channel_name in ChannelName.items() if channel_name in channel_names] selected_approximations = [ - self.approximations[channel_name] for channel_name in channel_names if channel_name in self.approximations + self.approximations[channel_name] for channel_name in selected if channel_name in self.approximations ] - if not selected_approximations: return np.zeros_like(self.approximation) + if len(selected_approximations) == len(self.approximations): + return self.approximation + return mix(align(selected_approximations, len(self.approximation))) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py index 5c4374494..a1887abab 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -6,6 +6,7 @@ from pydantic import ConfigDict, Field, model_validator +from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -89,6 +90,15 @@ def assignments_by_channel(self) -> Dict[ChannelName, Tuple[int, ...]]: """The per-frame stem ids each channel carries, keyed by channel.""" return {item.channel_name: item.stem_ids for item in self.assignments} + @cached_property + def owners_by_channel(self) -> Dict[ChannelName, FrozenSet[int]]: + """Who holds a frame on each channel: the recordings and the reader's own row, keyed by channel. + + A resting frame belongs to nobody, so it counts toward no owner; a channel whose owners a + reader all hears is heard whole. + """ + return {item.channel_name: frozenset(item.stem_ids) - {RESTING_STEM_ID} for item in self.assignments} + @cached_property def holding_stem_ids(self) -> FrozenSet[int]: """The recorded entries holding a frame on some channel. diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py index 75d68d2b7..09c0353a2 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py @@ -24,19 +24,39 @@ def filter_approximations( keep their lengths, which is what aligns a filtered mix with the unfiltered one sample for sample. The mask covers the frames the stored array holds; samples past the last recorded frame keep their values. + + A channel every owner of which is heard comes back as the very array it was given. A + resting frame holds a silent instruction, which every generator renders as silence, so the + frames a mask would zero on such a channel are zero already and the array stands as its own + filtered reading, which a reader tells by identity. """ filtered: Dict[ChannelName, np.ndarray] = {} + owners_by_channel = stems_data.owners_by_channel for channel, stem_ids in stems_data.assignments_by_channel.items(): approximation = approximations[channel] - keep = np.isin(np.array(stem_ids, dtype=int), list(selection.stems_for(channel))) - keep_samples = np.repeat(keep, frame_length) - masked = np.array(approximation, copy=True) - masked[~keep_samples[: len(masked)]] = 0 - filtered[channel] = masked + heard = selection.stems_for(channel) + if owners_by_channel[channel] <= heard: + filtered[channel] = approximation + else: + filtered[channel] = _silenced(approximation, stem_ids, heard, frame_length) return filtered +def _silenced( + approximation: np.ndarray, + stem_ids: Sequence[int], + heard: AbstractSet[int], + frame_length: int, +) -> np.ndarray: + """A copy of one channel's approximation with the frames of the recordings left out zeroed.""" + keep = np.isin(np.array(stem_ids, dtype=int), list(heard)) + keep_samples = np.repeat(keep, frame_length) + masked = np.array(approximation, copy=True) + masked[~keep_samples[: len(masked)]] = 0 + return masked + + def heard_instructions( stems_data: StemsData, instructions: Mapping[ChannelName, Sequence[InstructionUnion]], diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 29b07f97e..6be2664f2 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -376,13 +376,12 @@ def heard(selected: AbstractSet[int]) -> StemSelection: """The selection hearing the named stems on every channel.""" return StemSelection.everywhere(frozenset(selected), channels) - unfiltered = data.waveform_data(renders) full = data.waveform_data(renders, heard(all_stem_ids)) - np.testing.assert_allclose(full.approximation, unfiltered.approximation, atol=_MIX_TOLERANCE) - np.testing.assert_allclose(full.original_audio, unfiltered.original_audio, atol=_MIX_TOLERANCE) + assert full.approximation is renders.mix(data.reconstruction) + assert full.original_audio is data.original_audio np.testing.assert_allclose( data.partials_for(renders, channels, heard(all_stem_ids)), - data.get_partials(renders, channels), + rendered_mix(data.reconstruction), atol=_MIX_TOLERANCE, ) diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index 57566afda..a89e99b6b 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -66,4 +66,5 @@ def reconstruction_data(default_config, minimal_reconstruction) -> Reconstructio stem_audios=(), filepath=Path("/dev/null"), name="null", + original_audio=None, ) diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index 6a51cacfc..bf8c624e2 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -1,13 +1,9 @@ -import json -import os import time -from pathlib import Path -from typing import Dict, Final, List, Optional, Tuple +from typing import Final, List, Optional, Tuple import pytest from automation.application.startup import Startup -from automation.environment import ARTIFACTS_VARIABLE from automation.holds.regeneration import RegenerationHold from automation.screen import Screen from automation.steps.reconstructions import expect_open, leave_letting_it_go @@ -16,6 +12,7 @@ from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.readings import frame_readings, keep_readings from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world DRAGGED_ITEMS: Final[Tuple[int, ...]] = (2, 4, 6, 8) @@ -23,7 +20,6 @@ LOUD: Final[float] = 13.0 NEAR_THE_START: Final[float] = 0.05 ZOOM_NOTCHES: Final[int] = 5 -LONG_GAP_SECONDS: Final[float] = 0.1 DRAIN_TIMEOUT_SECONDS: Final[float] = 120.0 FRAMES_AFTER_THE_LANDING: Final[int] = 3 BURST_READINGS: Final[str] = "frame_gaps.json" @@ -31,26 +27,6 @@ LANDING_READINGS: Final[str] = "frame_gaps_landing.json" -def _frame_readings(stamps: List[float], last_release: float) -> Dict[str, float]: - """What the clock said after every frame, read as the gaps between frames and the frames a second.""" - gaps = [later - earlier for earlier, later in zip(stamps, stamps[1:])] - span = stamps[-1] - stamps[0] - return { - "frames": len(stamps), - "seconds": span, - "longest_gap_seconds": max(gaps), - "gaps_over_a_tenth": sum(1 for gap in gaps if gap > LONG_GAP_SECONDS), - "frames_per_second": (len(stamps) - 1) / span, - "drain_seconds_after_the_last_release": stamps[-1] - last_release, - } - - -def _keep_readings(name: str, readings: Dict[str, float]) -> None: - """Prints the readings and keeps them beside the scenario's other files.""" - print(json.dumps(readings)) - (Path(os.environ[ARTIFACTS_VARIABLE]) / name).write_text(json.dumps(readings, indent=2)) - - def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> None: """Opens the long document's first pulse volume graph wide enough to drag its first bars.""" expect_open(screen, LONG_RECONSTRUCTION) @@ -125,7 +101,7 @@ def drag_until_the_fade_ends(screen: Screen) -> None: stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: - _keep_readings(BURST_READINGS, _frame_readings(stamps, releases[-1])) + keep_readings(BURST_READINGS, frame_readings(stamps, releases[-1])) screen.scenario(open_and_zoom_in, drag_until_the_fade_ends, keep_the_readings, leave_letting_it_go).run() @@ -158,7 +134,7 @@ def drag_while_held(screen: Screen) -> None: screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") def keep_the_readings(screen: Screen) -> None: - _keep_readings(HELD_BURST_READINGS, _frame_readings(stamps, releases[-1])) + keep_readings(HELD_BURST_READINGS, frame_readings(stamps, releases[-1])) def release_and_wait(screen: Screen) -> None: regeneration_hold.release() @@ -207,7 +183,7 @@ def release_and_record_the_landing(screen: Screen) -> None: stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: - _keep_readings(LANDING_READINGS, _frame_readings(stamps, released[0])) + keep_readings(LANDING_READINGS, frame_readings(stamps, released[0])) screen.scenario( drag_one_bar_while_held, diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py new file mode 100644 index 000000000..478cd4450 --- /dev/null +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -0,0 +1,73 @@ +import time +from typing import Dict, Final, List + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.exports import leaving_asks_nothing +from automation.steps.reconstructions import expect_open +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world + +TOGGLED_CHANNEL: Final[ChannelName] = ChannelName.NOISE +RECORDING_KEY: Final[str] = "0" +TICKS_PER_BOX: Final[int] = 2 +FRAMES_AFTER_A_TICK: Final[int] = 5 +BOX_READINGS: Final[str] = "frame_gaps_boxes.json" + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """The home holds the two-minute reconstruction with its recording.""" + return long_document_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The two-minute reconstruction is open at start, with no project.""" + return Startup(reconstruction=LONG_RECONSTRUCTION, project=None) + + +class TestTheBoxesOnALongDocument: + """How the frames keep coming while a channel's box above the waveform and a recording's box on the + stems card are ticked off and on again on a two-minute document. + + A box changes what is heard, so the waveform, the audio and the figures are read again from the + document. The scenario records the clock after every frame through the ticks and keeps the + longest gap after each tick beside the readings the drag scenarios keep, holding the application + to nothing: the readings are what a change to the listening path is judged by, taken before and + after the change. + """ + + def test_the_frames_through_the_ticks(self, screen: Screen) -> None: + """The readings are kept; the application is only asked to keep drawing and to leave cleanly.""" + reconstructions = screen.reconstructions + stamps: List[float] = [] + ticks: List[float] = [] + + def open_it(screen: Screen) -> None: + expect_open(screen, LONG_RECONSTRUCTION) + + def tick_the_boxes(screen: Screen) -> None: + with screen.record(time.monotonic) as recording: + for _ in range(TICKS_PER_BOX): + ticks.append(time.monotonic()) + reconstructions.tick_channel(TOGGLED_CHANNEL) + screen.frames(FRAMES_AFTER_A_TICK) + + for _ in range(TICKS_PER_BOX): + ticks.append(time.monotonic()) + reconstructions.stems.tick(RECORDING_KEY) + screen.frames(FRAMES_AFTER_A_TICK) + + stamps.extend(recording.values()) + + def keep_the_readings(screen: Screen) -> None: + readings: Dict[str, object] = dict(frame_readings(stamps, ticks[-1])) + readings["longest_gap_after_each_tick_seconds"] = longest_gap_after_each(stamps, ticks) + keep_readings(BOX_READINGS, readings) + + screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run() diff --git a/tests/suite/screens/readings.py b/tests/suite/screens/readings.py new file mode 100644 index 000000000..cb00037af --- /dev/null +++ b/tests/suite/screens/readings.py @@ -0,0 +1,45 @@ +import json +import os +from pathlib import Path +from typing import Dict, Final, List, Sequence + +from automation.environment import ARTIFACTS_VARIABLE + +LONG_GAP_SECONDS: Final[float] = 0.1 + + +def frame_readings(stamps: List[float], last_gesture: float) -> Dict[str, float]: + """What the clock said after every frame, read as the gaps between frames and the frames a second. + + Args: + stamps: The clock after each frame drawn, in order. + last_gesture: The clock at the last gesture, which the drain after it is measured from. + """ + gaps = [later - earlier for earlier, later in zip(stamps, stamps[1:])] + span = stamps[-1] - stamps[0] + return { + "frames": len(stamps), + "seconds": span, + "longest_gap_seconds": max(gaps), + "gaps_over_a_tenth": sum(1 for gap in gaps if gap > LONG_GAP_SECONDS), + "frames_per_second": (len(stamps) - 1) / span, + "drain_seconds_after_the_last_release": stamps[-1] - last_gesture, + } + + +def longest_gap_after_each(stamps: List[float], gestures: Sequence[float]) -> List[float]: + """The longest gap between two frames after each gesture and before the next, one reading per gesture.""" + readings: List[float] = [] + bounds = [*gestures, float("inf")] + for started, ended in zip(bounds, bounds[1:]): + window = [stamp for stamp in stamps if started <= stamp < ended] + gaps = [later - earlier for earlier, later in zip(window, window[1:])] + readings.append(max(gaps, default=0.0)) + + return readings + + +def keep_readings(name: str, readings: Dict[str, object]) -> None: + """Prints the readings and keeps them beside the scenario's other files.""" + print(json.dumps(readings)) + (Path(os.environ[ARTIFACTS_VARIABLE]) / name).write_text(json.dumps(readings, indent=2)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index fc6b8b1c6..d21ba7ca1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -394,7 +394,7 @@ def test_original_mix_mixes_the_selected_recordings( np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.stem_audios[0]) assert data.original_audio is not None - np.testing.assert_allclose(data.original_mix_for(_heard(0, 1)), data.original_audio) + assert data.original_mix_for(_heard(0, 1)) is data.original_audio np.testing.assert_array_equal( data.original_mix_for(_heard()), np.zeros_like(rendered_mix(data.reconstruction)), @@ -574,6 +574,31 @@ def test_an_edit_keeping_every_entry_keeps_every_recording( assert rebound.stem_audios == data.stem_audios + def test_an_edit_keeping_every_entry_keeps_the_original_mix( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """The recordings stand as they were, so the mix of them stands too, which spares a landing the mixing.""" + data = self._three_recordings(reconstruction_factory, tmp_path) + + rebound = data.with_reconstruction(data.reconstruction.model_copy()) + + assert rebound.original_audio is data.original_audio + + def test_a_removal_mixes_the_original_again( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._three_recordings(reconstruction_factory, tmp_path) + first, third = data.stem_audios[0], data.stem_audios[2] + + remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) + + assert remaining.original_audio is not data.original_audio + np.testing.assert_allclose(remaining.original_audio, mix([first, third])) + def test_a_document_naming_a_recording_the_load_lost_holds_none( self, reconstruction_factory: Callable[[], Reconstruction], @@ -601,58 +626,41 @@ def test_projects_the_render_relevant_fields( name="Sample", ) - waveform_data = data.waveform_data(renders) + waveform_data = data.waveform_data(renders, _heard(0)) assert waveform_data.original_audio is data.original_audio assert waveform_data.approximation is renders.mix(reconstruction) - assert waveform_data.approximations == renders.channels(reconstruction) + assert waveform_data.approximations.keys() == renders.channels(reconstruction).keys() + assert all( + waveform_data.approximations[channel_name] is kept + for channel_name, kept in renders.channels(reconstruction).items() + ) assert waveform_data.coefficient == reconstruction.coefficient assert waveform_data.frame_length == reconstruction.config.frame_length - -class TestReconstructionDataGetPartials: - def test_empty_generator_list_returns_zeros( - self, - reconstruction_factory: Callable[[], Reconstruction], - renders: RenderCache, - ) -> None: - reconstruction = reconstruction_factory() - data = ReconstructionData.from_reconstruction( - reconstruction, - name="Sample", - ) - - result = data.get_partials(renders, []) - - assert np.all(result == 0.0) - - def test_unknown_generator_returns_zeros( + def test_a_document_heard_whole_reads_what_is_kept( self, reconstruction_factory: Callable[[], Reconstruction], renders: RenderCache, ) -> None: + """A reading that filters nothing reads the kept renders, which is what makes it cost nothing.""" reconstruction = reconstruction_factory() - data = ReconstructionData.from_reconstruction( - reconstruction, - name="Sample", - ) + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + kept_mix = renders.mix(reconstruction) - result = data.get_partials(renders, [ChannelName.TRIANGLE]) + twice = [data.waveform_data(renders, _heard(0)) for _ in range(2)] - assert np.all(result == 0.0) + assert all(waveform_data.approximation is kept_mix for waveform_data in twice) - def test_known_generator_returns_its_approximation( + def test_a_recording_left_out_mixes_the_heard_frames_afresh( self, reconstruction_factory: Callable[[], Reconstruction], renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() - data = ReconstructionData.from_reconstruction( - reconstruction, - name="Sample", - ) + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - result = data.get_partials(renders, [ChannelName.PULSE1]) + waveform_data = data.waveform_data(renders, _heard()) - expected = rendered_channels(reconstruction)[ChannelName.PULSE1] - assert np.array_equal(result, expected) + assert waveform_data.approximation is not renders.mix(reconstruction) + np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(renders.mix(reconstruction))) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 19eb6d316..db56a36ce 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, Final, List, Optional -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -752,6 +752,63 @@ def test_set_selected_generators_with_no_data_skips_waveform( callback.assert_not_called() +class TestOneReadingPerGesture: + """A gesture reads the waveform once, and what plays is read from the same reading. + + The reading is the costly part of what a gesture does on the render thread, so the waveform + the display draws and the audio that plays come from one reading of the document. + """ + + @dataclass(frozen=True) + class Gesture(BaseRegularTestCase): + act: Callable[[ReconstructionPanelLogic], None] + + gestures = ( + Gesture(label="display", act=lambda panel_logic: panel_logic.display_reconstruction()), + Gesture(label="update", act=lambda panel_logic: panel_logic.update_reconstruction()), + Gesture(label="channels", act=lambda panel_logic: panel_logic.set_selected_channels([ChannelName.PULSE1])), + Gesture(label="stem", act=lambda panel_logic: panel_logic.set_stem_channels(0, frozenset())), + Gesture(label="solo", act=lambda panel_logic: panel_logic.solo_stem(0)), + Gesture(label="source", act=lambda panel_logic: panel_logic.set_audio_source(AudioSourceType.RECONSTRUCTION)), + ) + + @pytest.mark.parametrize("gesture", gestures, ids=lambda gesture: gesture.label) + def test_the_waveform_is_read_once( + self, + gesture: Gesture, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + with patch.object( + ReconstructionData, + "waveform_data", + autospec=True, + side_effect=ReconstructionData.waveform_data, + ) as reading: + gesture.act(panel_logic) + + assert reading.call_count == 1 + + def test_what_plays_is_the_reading_the_waveform_draws( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + drawn: List[WaveformData] = [] + played: List[Optional[AudioData]] = [] + panel_logic.on_waveform_load_changed = lambda waveform, channels: drawn.append(waveform) + panel_logic.on_audio_data_changed = played.append + + panel_logic.display_reconstruction() + + assert played[0] is not None + np.testing.assert_array_equal(played[0].sample, drawn[0].partials(panel_logic._selected_channels)) + + class TestReconstructionPanelLogicExportInstrument: """What the tab offers to an export: the slice one channel holds, ready for a destination.""" diff --git a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py index 93c1d1914..c6b44a149 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py +++ b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py @@ -1,11 +1,15 @@ -from typing import Dict +from typing import Dict, Final import numpy as np import pytest from sampletones_application.view_model.shared.waveform_data import WaveformData +from sampletones_core.audio.mixing import mix from sampletones_core.constants.enums import ChannelName +LARGE: Final[float] = 1e8 +SMALL: Final[float] = 1.0 + @pytest.fixture def approximations() -> Dict[ChannelName, np.ndarray]: @@ -17,9 +21,14 @@ def approximations() -> Dict[ChannelName, np.ndarray]: @pytest.fixture def waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData: + return _waveform_data(approximations) + + +def _waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData: + """Waveform data holding ``approximations`` and the mix of them, which is the shape the data holder builds.""" return WaveformData( original_audio=np.zeros(4), - approximation=np.array([1.5, 0.5, -0.5, 0.5]), + approximation=mix(list(approximations.values())), approximations=approximations, coefficient=1.0, frame_length=2, @@ -45,9 +54,11 @@ def test_single_generator_returns_its_approximation( def test_selection_sums_the_selected_generators( self, - waveform_data: WaveformData, approximations: Dict[ChannelName, np.ndarray], ) -> None: + with_a_third = {**approximations, ChannelName.TRIANGLE: np.array([0.25, 0.25, 0.25, 0.25])} + waveform_data = _waveform_data(with_a_third) + result = waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE]) expected = approximations[ChannelName.PULSE1] + approximations[ChannelName.NOISE] @@ -64,13 +75,12 @@ def test_unknown_generators_are_skipped_within_a_selection( def test_short_generators_leave_the_tail_in_silence(self) -> None: approximation = np.array([1.0, 0.0, -1.0, 0.0]) - data = WaveformData( - original_audio=approximation, - approximation=approximation, - approximations={ChannelName.PULSE1: np.array([0.5, 0.5]), ChannelName.NOISE: approximation}, - coefficient=1.0, - frame_length=2, - sample_rate=44100, + data = _waveform_data( + { + ChannelName.PULSE1: np.array([0.5, 0.5]), + ChannelName.NOISE: approximation, + ChannelName.TRIANGLE: np.zeros(4), + } ) result = data.partials([ChannelName.PULSE1, ChannelName.NOISE]) @@ -79,17 +89,45 @@ def test_short_generators_leave_the_tail_in_silence(self) -> None: assert np.array_equal(result, expected) def test_a_short_single_generator_reaches_the_approximation_length(self) -> None: - approximation = np.array([1.0, 0.0, -1.0, 0.0]) - data = WaveformData( - original_audio=approximation, - approximation=approximation, - approximations={ChannelName.PULSE1: np.array([0.5, 0.5])}, - coefficient=1.0, - frame_length=2, - sample_rate=44100, - ) + data = _waveform_data({ChannelName.PULSE1: np.array([0.5, 0.5]), ChannelName.NOISE: np.zeros(4)}) result = data.partials([ChannelName.PULSE1]) expected = np.array([0.5, 0.5, 0.0, 0.0]) assert np.array_equal(result, expected) + + +class TestEveryChannel: + """A selection covering every channel answers the mix the data holds, in any listing.""" + + def test_every_channel_answers_the_mix_itself(self, waveform_data: WaveformData) -> None: + assert waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE]) is waveform_data.approximation + + def test_the_listing_order_makes_no_difference(self, waveform_data: WaveformData) -> None: + assert waveform_data.partials([ChannelName.NOISE, ChannelName.PULSE1]) is waveform_data.approximation + + def test_channels_the_data_holds_none_of_change_nothing(self, waveform_data: WaveformData) -> None: + every_channel = list(ChannelName.items()) + + assert waveform_data.partials(every_channel) is waveform_data.approximation + + +class TestTheSumFollowsChannelOrder: + """A narrower selection is summed in channel order, so the same choice reads the same samples. + + Adding the floats in another order rounds another way, which is what the readings would differ + by if the sum followed the listing. + """ + + def test_two_listings_of_one_choice_read_the_same_samples(self) -> None: + data = _waveform_data( + { + ChannelName.PULSE1: np.array([LARGE], dtype=np.float32), + ChannelName.PULSE2: np.array([SMALL], dtype=np.float32), + ChannelName.TRIANGLE: np.array([0.0], dtype=np.float32), + ChannelName.NOISE: np.array([-LARGE], dtype=np.float32), + } + ) + choice = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE] + + assert np.array_equal(data.partials(choice), data.partials(list(reversed(choice)))) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py index 3357e127f..025302372 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py @@ -52,6 +52,23 @@ def test_a_rest_and_the_readers_own_frames_hold_for_no_recording(self) -> None: assert _record([LEAD, RESTING_STEM_ID, AUTHORED_STEM_ID]).holding_stem_ids == frozenset({LEAD}) +class TestWhoHoldsAFrameOnEachChannel: + def test_the_recordings_and_the_readers_own_row_hold_frames(self) -> None: + owners = _record([LEAD, AUTHORED_STEM_ID, BASS]).owners_by_channel + + assert owners == {channel: frozenset({LEAD, BASS, AUTHORED_STEM_ID}) for channel in owners} + + def test_a_rest_holds_for_nobody(self) -> None: + owners = _record([LEAD, RESTING_STEM_ID]).owners_by_channel + + assert all(stem_ids == frozenset({LEAD}) for stem_ids in owners.values()) + + def test_a_channel_resting_throughout_has_no_owner(self) -> None: + owners = _record([RESTING_STEM_ID, RESTING_STEM_ID]).owners_by_channel + + assert all(stem_ids == frozenset() for stem_ids in owners.values()) + + class TestWhatSettlingLetsGoOf: """A settled record names the recordings behind its frames, unless nothing tells them apart.""" diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py index 4345a966a..92b061cd6 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -1,8 +1,13 @@ from typing import Final, List, Tuple import numpy as np +import pytest +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.generators.render import render_instructions +from sampletones_core.instructions import InstructionUnion 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.filter import ( @@ -13,9 +18,13 @@ 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.long_documents import turning_instruction from tests.suite.stems import RECORDED_SCALE FRAME_LENGTH: Final[int] = 2 +SOUNDING_FRAME: Final[int] = 0 +RESTING_FRAME: Final[int] = 1 +FRAMES_AROUND_A_REST: Final[int] = 3 EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) @@ -145,3 +154,68 @@ def test_a_stem_heard_on_one_channel_stays_quiet_on_the_other(self) -> None: np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.ones(2, dtype=np.float32)) np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.zeros(2, dtype=np.float32)) + + +class TestAChannelHeardWhole: + """A channel every owner of which is heard keeps its array, which a reader tells by identity. + + A resting frame belongs to nobody, so hearing every recording and the reader's own row is + hearing the channel whole; the frames a mask would zero are silent already, since every + generator renders a silent instruction as silence. + """ + + def test_every_owner_heard_hands_the_array_back_by_identity(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1, 0])) + approximations = {ChannelName.PULSE1: np.arange(6, dtype=np.float32)} + + filtered = filter_approximations(stems_data, approximations, _heard(0, 1), FRAME_LENGTH) + + assert filtered[ChannelName.PULSE1] is approximations[ChannelName.PULSE1] + + def test_resting_frames_leave_the_identity_standing(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, RESTING_STEM_ID, 0])) + approximations = {ChannelName.PULSE1: np.arange(6, dtype=np.float32)} + + filtered = filter_approximations(stems_data, approximations, _heard(0), FRAME_LENGTH) + + assert filtered[ChannelName.PULSE1] is approximations[ChannelName.PULSE1] + + def test_recordings_heard_beyond_the_channel_change_nothing(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 0])) + approximations = {ChannelName.PULSE1: np.arange(4, dtype=np.float32)} + + filtered = filter_approximations(stems_data, approximations, _heard(0, 1, 2), FRAME_LENGTH) + + assert filtered[ChannelName.PULSE1] is approximations[ChannelName.PULSE1] + + def test_the_readers_own_frames_left_out_are_silenced(self) -> None: + """The reader's own row answers to boxes like any recording, so leaving it out filters the channel.""" + stems_data = _stems_data((ChannelName.PULSE1, [0, AUTHORED_STEM_ID])) + approximations = {ChannelName.PULSE1: np.ones(4, dtype=np.float32)} + + filtered = filter_approximations(stems_data, approximations, _heard(0), FRAME_LENGTH) + + assert filtered[ChannelName.PULSE1] is not approximations[ChannelName.PULSE1] + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.array([1, 1, 0, 0], dtype=np.float32)) + + def test_an_owner_left_out_hands_back_a_copy(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1])) + approximations = {ChannelName.PULSE1: np.ones(4, dtype=np.float32)} + + filtered = filter_approximations(stems_data, approximations, _heard(0), FRAME_LENGTH) + + assert filtered[ChannelName.PULSE1] is not approximations[ChannelName.PULSE1] + + @pytest.mark.parametrize("channel_name", EVERY_CHANNEL, ids=lambda channel_name: channel_name.value) + def test_a_resting_frame_renders_as_silence(self, channel_name: ChannelName) -> None: + """What the identity rests on: a silent instruction between two sounding ones renders as exact zeros.""" + config = Config() + sounding = turning_instruction(channel_name, SOUNDING_FRAME) + stream: List[InstructionUnion] = [sounding, type(sounding).null_instruction(), sounding] + + rendered = render_instructions(stream, channel_name, config) + + frame_length = config.frame_length + assert len(rendered) == FRAMES_AROUND_A_REST * frame_length + resting = rendered[RESTING_FRAME * frame_length : (RESTING_FRAME + 1) * frame_length] + np.testing.assert_array_equal(resting, np.zeros(frame_length, dtype=np.float32)) From 216e2a6bd451891d9e77747bf7e706a4ab01143f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 00:22:51 +0200 Subject: [PATCH 15/37] Rendered: what a reader hears on the regeneration worker --- automation/application/running.py | 4 + automation/holds/regeneration.py | 30 ++- automation/plugin/fixtures.py | 1 + automation/screen.py | 6 + automation/steps/reconstructions.py | 25 ++- .../application/reconstruction-edits.md | 17 +- docs/development/application/render-thread.md | 8 +- docs/development/application/stems.md | 2 +- docs/development/application/undo.md | 2 +- docs/development/architecture.md | 2 +- .../coordinators/reconstruction.py | 5 + .../coordinators/tabs/reconstruction.py | 10 + .../logic/reconstruction/data.py | 73 +++---- .../logic/reconstruction/listening.py | 49 ++++- .../logic/reconstruction/manager.py | 15 +- .../logic/reconstruction/reconstruction.py | 134 +++++++----- .../logic/reconstruction/rewrites/queue.py | 150 +++++++++---- .../reconstruction/rewrites/regeneration.py | 13 +- .../logic/reconstruction/rewrites/steps.py | 11 +- .../logic/shared/renders.py | 167 ++++++++++++--- .../services/__init__.py | 4 +- .../services/regeneration/__init__.py | 4 +- .../services/regeneration/jobs.py | 47 +++++ .../services/regeneration/result.py | 20 +- .../services/regeneration/service.py | 118 ++++++----- .../ui/elements/graphs/waveform.py | 23 +- .../ui/panels/reconstruction/plot.py | 11 +- .../view_model/shared/waveform_data.py | 41 +--- .../reconstruction/stems/data.py | 5 + .../reconstruction/stems/hearing.py | 103 +++++++++ .../reconstruction/test_edit_landing.py | 45 ++-- .../test_stems_reconstruction.py | 60 ++++-- .../services/test_regeneration.py | 123 ++++++----- .../edits/test_long_document_drag.py | 39 ++-- .../stems/test_long_document_boxes.py | 17 +- tests/suite/regeneration.py | 72 ++++--- tests/suite/renders.py | 16 ++ .../reconstruction/rewrites/test_queue.py | 165 ++++++++++++++- .../logic/reconstruction/test_data.py | 82 ++++++-- .../logic/reconstruction/test_listening.py | 139 ++++++++---- .../reconstruction/test_reconstruction.py | 197 ++++++++++++++---- .../logic/shared/test_renders.py | 97 ++++++++- .../services/test_regeneration.py | 173 ++++++++------- .../sampletones_application/test_startup.py | 9 +- .../ui/elements/graphs/test_waveform.py | 4 +- .../view_model/shared/test_waveform_data.py | 133 ------------ .../reconstruction/test_stems_hearing.py | 134 ++++++++++++ 47 files changed, 1809 insertions(+), 796 deletions(-) create mode 100644 src/sampletones_application/services/regeneration/jobs.py create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/hearing.py create mode 100644 tests/suite/renders.py delete mode 100644 tests/unit/sampletones_application/view_model/shared/test_waveform_data.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py diff --git a/automation/application/running.py b/automation/application/running.py index 7ed0382f1..9e7cd07df 100644 --- a/automation/application/running.py +++ b/automation/application/running.py @@ -59,6 +59,10 @@ def language(self) -> LanguageManager: """The application's language manager, which turns a language key into the words a user reads.""" 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 + @property def shortcuts(self) -> ShortcutManager: """The application's shortcut manager, which says what keys each action has under the scheme in diff --git a/automation/holds/regeneration.py b/automation/holds/regeneration.py index 55f848188..eb619adae 100644 --- a/automation/holds/regeneration.py +++ b/automation/holds/regeneration.py @@ -1,16 +1,13 @@ import threading -from typing import AbstractSet, Mapping, Optional +from typing import Optional -import numpy as np import pytest +from sampletones_application.services.regeneration.jobs import ListeningJob, RenderJob from sampletones_application.services.regeneration.service import ( RegenerationService, ) from sampletones_application.services.result import ServiceError -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters import Features -from sampletones_core.reconstructions import Reconstruction class RegenerationHold: @@ -18,7 +15,9 @@ class RegenerationHold: it. 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. The hold lets + 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 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. @@ -32,22 +31,19 @@ def __init__(self) -> None: def install(self, monkeypatch: pytest.MonkeyPatch) -> None: """Replaces the service's rebuild with one that waits for the release before it computes.""" - rebuild = RegenerationService._run # pylint: disable=protected-access - - def held( - service: RegenerationService, - reconstruction: Reconstruction, - channel_name: ChannelName, - features: Features, - heard: AbstractSet[int], - kept: Mapping[ChannelName, np.ndarray], - ) -> None: + run = RegenerationService._run # pylint: disable=protected-access + + def held(service: RegenerationService, job: RenderJob) -> None: + if isinstance(job, ListeningJob): + run(service, job) + return + self._wait() if self._failure is not None: service._emit(ServiceError(exception=self._failure)) # pylint: disable=protected-access return - rebuild(service, reconstruction, channel_name, features, heard, kept) + run(service, job) monkeypatch.setattr(RegenerationService, "_run", held) diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py index f7289fe42..49729e3d5 100644 --- a/automation/plugin/fixtures.py +++ b/automation/plugin/fixtures.py @@ -155,6 +155,7 @@ def screen_fixture( window_manager=window_manager, language=screen_application.language, shortcuts=screen_application.shortcuts, + sound_settled=screen_application.sound_settled, dialogs=screen_boundaries.dialogs, errors=screen_boundaries.errors, file_manager=screen_boundaries.file_manager, diff --git a/automation/screen.py b/automation/screen.py index e5c011723..0b392a506 100644 --- a/automation/screen.py +++ b/automation/screen.py @@ -106,6 +106,7 @@ def __init__( window_manager: WindowManager, language: LanguageManager, shortcuts: ShortcutManager, + sound_settled: Callable[[], bool], dialogs: ScriptedFileDialogs, errors: ErrorRecords, file_manager: FileManagerStandIn, @@ -118,6 +119,7 @@ def __init__( self._window_manager = window_manager self._render_thread = render_thread self._shortcuts = shortcuts + self._sound_settled = sound_settled self._dialogs = dialogs self._errors = errors self._file_manager = file_manager @@ -143,6 +145,10 @@ def __init__( self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT) self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + def sound_settled(self) -> bool: + """Whether the open reconstruction's sound stands landed, which a box or an edit sends to the worker.""" + return bool(self.bridge.ask(self._sound_settled)) + def expect( self, reading: Callable[[], ReadingT], diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py index 79e025e62..8e04489d9 100644 --- a/automation/steps/reconstructions.py +++ b/automation/steps/reconstructions.py @@ -1,10 +1,12 @@ import operator +from functools import partial from pathlib import Path from typing import Final, List, Optional from automation.dearpygui.items.types import Item from automation.holds.regeneration import RegenerationHold from automation.screen import Screen +from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -13,6 +15,8 @@ from sampletones_shared.constants.symbols import TITLE_SEPARATOR from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +FADE_TIMEOUT_SECONDS: Final[float] = 120.0 + APPLICATION_NAME: Final[str] = "global.dialog.title.main_window" UNTITLED: Final[str] = "global.dialog.label.untitled" BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" @@ -58,7 +62,7 @@ def reconstruction_row(screen: Screen, path: Path) -> Item: if not browser.is_open(above): browser.open_by_click(above) screen.expect( - lambda: browser.is_open(above), + partial(browser.is_open, above), bool, description=f"{browser.label(above)} open", ) @@ -73,12 +77,29 @@ def load_from_the_browser(screen: Screen, path: Path) -> None: def expect_open(screen: Screen, path: Path) -> None: - """Waits until the reconstruction stored at ``path`` is the one open.""" + """Waits until the reconstruction stored at ``path`` is the one open and its sound has landed.""" screen.expect( lambda: screen.reconstructions.shows_open(path), bool, description=f"{path.name} open", ) + screen.expect(screen.sound_settled, bool, description=f"the sound of {path.name} landed") + + +def reconstruction_shade(screen: Screen) -> Optional[str]: + """The theme the waveform's reconstruction line wears, which the fade swaps while its sound is on its way.""" + waveform = screen.reconstructions.waveform + return screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))) + + +def wait_for_the_fade_to_end(screen: Screen, unfaded: Optional[str]) -> None: + """Waits until the reconstruction line wears ``unfaded`` again, which is when the last sound has landed.""" + screen.bridge.expect( + lambda: reconstruction_shade(screen), + unfaded.__eq__, + description="the fade ended", + timeout=FADE_TIMEOUT_SECONDS, + ) def edit_envelope( diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 54b8fca76..d7afd5327 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -8,8 +8,10 @@ away the whole document, or one that replaces the document from outside the tab. A rebuild of an edited channel, and the render of the audio it sounds, run on a worker thread, so the reader can make the next change before the last one has landed and the frames keep coming while the rebuild renders. The worker takes the renders the cache holds for the document with it and hands back the -audio of every channel in play, which the landing keeps in the cache before the tab reads it. Every rule -below keeps the document as though each change had landed before the next one was made. +audio of every channel in play and the mix of what the reader hears, which the landing keeps in the cache +before the tab reads it. A change to what is heard whose sound the cache keeps none of is a step of the +same line, rendered by the same worker. Every rule below keeps the document as though each change had +landed before the next one was made. --- @@ -71,6 +73,17 @@ edits before it, and the sample open on the tab then takes it as a step of its o joins the rate change's history entry with every other sample. A batch result for a sample edited since the batch started is retuned again from what the sample now holds. +## A change to what is heard is a step + +A box on the stems card or above the waveform changes the reader's listening, which the stems card, the +ribbon and the instruments panel answer at once. The waveform and the audio are read from the render +cache under the hearing they sound; where the cache keeps no sound for the hearing, the panel asks for a +listening step, which renders the hearing of the document as it then stands and lands like a rebuild. 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. + ## The waveform fades while the line is busy The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index a5de615c5..b945794f7 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -20,9 +20,11 @@ continues. Every background result reaches UI state this way. Applying one to UI worker thread is forbidden. A result carries what the landing installs. The regeneration renders the edited channel's audio on its -worker and hands it back inside its result; the landing keeps it in the render cache on the render thread. -What a worker produces for the interface therefore reaches what the interface reads through the queue -alone, and the render thread keeps drawing while the worker renders. +worker, and the mix of what the reader hears, and hands them back inside its result; the landing keeps +them in the render cache 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. 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/application/stems.md b/docs/development/application/stems.md index 88f0d3e0c..73e9aa588 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. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. 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. 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; a hearing the cache keeps no sound for is rendered on the regeneration 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 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/docs/development/application/undo.md b/docs/development/application/undo.md index eb2541546..4003e68c4 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -35,7 +35,7 @@ A reconstruction edit follows the same rule. `RegenerationService` rebuilds one **The types enforce the split.** A value's model is frozen, so a write in place raises where it is made. A core test walks every class a project reaches (`tests/unit/sampletones_core/project/test_values.py`). It requires each class to be a declared shell or a frozen value made of values. The history audit (`tests/suite/history/`) replays gestures against a model of the stack. After every step, it checks each stored entry against the state it recorded. -**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills: with what the screen reads cold, and with what a rebuild hands back from its worker. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. +**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills with what the regeneration worker hands back: the channels and the mix of a rebuilt document, and the mix of what the reader hears of it. The sequencer's preview mix alone is rendered cold on its first read. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. ## The open voice across a restore diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 1cd79acdc..65c10e2c4 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; `RegeneratedInstrument` 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. --- diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 081560cb1..bc87a21ab 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -99,6 +99,7 @@ def __init__( self._reconstruction_manager.session.on_state_changed = self._on_state_changed self._rewrites.on_edit = self.apply_edit + self._rewrites.on_heard_rendered = self._show_listening self._rewrites.on_dropped = self._redraw_open_document self._rewrites.on_failed = self._report_rebuild_failure self._rewrites.on_busy_changed = self._set_reconstruction_dimmed @@ -560,6 +561,10 @@ def _redraw_open_document(self) -> None: if self.is_loaded(): self._tab.redraw_reconstruction(refit_waveform=False) + def _show_listening(self) -> None: + """Shows what the reader hears once the worker has rendered it for the open document.""" + self._tab.redraw_listening() + def _report_rebuild_failure(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index b427fadee..da49bb6a0 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -39,6 +39,7 @@ ReconstructionPanelLogic, ) from sampletones_application.logic.reconstruction.rewrites.steps import ( + ListeningChange, RateChange, Rewrite, StemRemovalRequest, @@ -312,6 +313,7 @@ def __init__( self._reconstruction_plot_panel.update_waveform_data ) self._reconstruction_panel_logic.on_waveform_cleared = self._reconstruction_plot_panel.clear_waveform + self._reconstruction_panel_logic.on_renders_wanted = self._request_listening self._reconstruction_panel_logic.on_waveform_source_changed = ( self._reconstruction_plot_panel.set_waveform_top_source ) @@ -731,6 +733,10 @@ def _request_rate_change(self, nes_frequency: int) -> None: """Asks for the open document to be re-timed to the rate the reader typed.""" self._on_rewrite_requested(RateChange(nes_frequency=nes_frequency)) + def _request_listening(self) -> None: + """Asks for what the reader hears to be rendered, where the screen holds no audio for it yet.""" + self._on_rewrite_requested(ListeningChange()) + def _request_remove_directory(self, directory: Path) -> None: self._dialogs.show_confirmation( tag=TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_DIRECTORY_CONFIRMATION, @@ -806,6 +812,10 @@ def redraw_reconstruction(self, *, refit_waveform: bool) -> None: self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.update_display() + def redraw_listening(self) -> None: + """Shows what the reader hears once the worker has rendered it, where a showing waited for it.""" + self._reconstruction_panel_logic.redraw_listening() + def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index df4570ae9..e7c7ed91b 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, replace from functools import cached_property from pathlib import Path -from typing import Dict, List, Optional, Self, Tuple +from typing import Dict, Optional, Self, Tuple import numpy as np @@ -9,12 +9,13 @@ from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import mix from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.recordings import load_recordings from sampletones_core.reconstructions.reconstruction.renders import rendered_length -from sampletones_core.reconstructions.reconstruction.stems.filter import ( - filter_approximations, +from sampletones_core.reconstructions.reconstruction.stems.hearing import ( + HeardRenders, + Hearing, + heard_renders, ) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger @@ -221,49 +222,51 @@ def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]: def waveform_data( self, renders: RenderCache, - selection: StemSelection, - ) -> WaveformData: + hearing: Hearing, + ) -> Optional[WaveformData]: """Projects the slice of this data the waveform display renders, the audio read from ``renders``. - The projection carries the frames each channel's heard stems own and the mix of the - recordings heard anywhere, so the waveform answers exactly what plays. A channel heard - whole carries the render the cache keeps, and a document heard whole carries the kept - mix, so a reading that filters nothing reads what is kept and computes nothing. + The projection carries what ``hearing`` sounds of the document and the mix of the + recordings heard anywhere, so the waveform answers exactly what plays. The audio is read + from what the cache keeps, so the reading computes no sample. + + Returns: + Optional[WaveformData]: The projection, or ``None`` where the cache keeps no audio for + ``hearing`` yet, which is what a worker is then asked to render. + """ + heard = renders.reading(self.reconstruction, hearing) + if heard is None: + return None + + return self._waveform_data(self.original_mix_for(hearing.selection), heard) + + def audio_for( + self, + renders: RenderCache, + hearing: Hearing, + ) -> np.ndarray: + """What ``hearing`` plays of the document, rendered here where the cache keeps none of it. + + An export writes what plays and runs where the reader waits for it, so it reads what is + kept and renders the rest on the spot. """ + heard = renders.reading(self.reconstruction, hearing) + if heard is not None: + return heard.playing + channels = renders.channels(self.reconstruction) - approximations = filter_approximations( - self.reconstruction.stems_data, - channels, - selection, - self.reconstruction.config.frame_length, - ) - heard_whole = all(filtered is channels[channel_name] for channel_name, filtered in approximations.items()) - return self._waveform_data( - self.original_mix_for(selection), - approximations, - renders.mix(self.reconstruction) if heard_whole else mix(list(approximations.values())), - ) + return heard_renders(self.reconstruction, hearing, channels, renders.mix(self.reconstruction)).playing def _waveform_data( self, original_audio: Optional[np.ndarray], - approximations: Dict[ChannelName, np.ndarray], - approximation: np.ndarray, + heard: HeardRenders, ) -> WaveformData: return WaveformData( original_audio=original_audio, - approximation=approximation, - approximations=approximations, + approximation=heard.mix, + playing=heard.playing, coefficient=self.reconstruction.coefficient, frame_length=self.reconstruction.config.frame_length, sample_rate=self.reconstruction.config.sample_rate, ) - - def partials_for( - self, - renders: RenderCache, - channel_names: List[ChannelName], - selection: StemSelection, - ) -> np.ndarray: - """Sums the selected channels with the unselected stems' frames silenced.""" - return self.waveform_data(renders, selection).partials(channel_names) diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py index d87a32786..2355a4c93 100644 --- a/src/sampletones_application/logic/reconstruction/listening.py +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -3,20 +3,25 @@ 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.selection import StemSelection -class StemListening: - """Which recordings of the open reconstruction a reader is listening to, channel by channel. +class Listening: + """What a reader is listening to of the open reconstruction: the recordings heard on each channel, and + the channels kept on. One choice serves every reading of the document: the frames it admits are the frames the waveform draws, the envelopes the instruments panel shows, the figures measuring them, what playback sounds and what an export writes, and they are the frames an edit reaches. Holding - it beside the open reconstruction is what lets those readings answer from one state. + it beside the open reconstruction is what lets those readings answer from one state. The + 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, and one the recording - reaches for the first time joins heard. + 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. 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. @@ -26,6 +31,8 @@ def __init__(self) -> None: self._offered: Dict[int, FrozenSet[ChannelName]] = {} self._heard: Dict[int, FrozenSet[ChannelName]] = {} self._heard_before_solo: Optional[Dict[int, FrozenSet[ChannelName]]] = None + self._playing: FrozenSet[ChannelName] = frozenset() + self._channels_on: FrozenSet[ChannelName] = frozenset() @property def offered(self) -> Dict[int, FrozenSet[ChannelName]]: @@ -57,12 +64,32 @@ def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: """The recordings heard on one channel, which is the scope an edit writes in.""" return self.selection.stems_for(channel_name) - def adopt(self, stems_data: StemsData) -> None: + @property + def playing(self) -> FrozenSet[ChannelName]: + """The channels the document plays, which are the boxes above the waveform a reader can tick.""" + return self._playing + + @property + def channels_on(self) -> FrozenSet[ChannelName]: + """The playing channels the reader keeps on, which are what plays and what the waveform draws.""" + return self._channels_on + + @property + def hearing(self) -> Hearing: + """What the reader hears, as one value a reading of the document is taken for.""" + 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. 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 - opened reconstruction is heard whole. + opened reconstruction is heard whole. A channel coming into play joins the channels on + the same way, and one kept playing keeps whatever the reader chose for it. + + Args: + stems_data: The record naming the owner of each frame. + playing: The channels the document plays. """ offered = offered_channels(stems_data) self._heard = { @@ -72,6 +99,12 @@ def adopt(self, stems_data: StemsData) -> None: } self._offered = offered self._heard_before_solo = None + self._channels_on = (self._channels_on & playing) | (playing - self._playing) + self._playing = playing + + def set_channels_on(self, channels: FrozenSet[ChannelName]) -> None: + """Takes the channels the reader keeps on, as the boxes above the waveform leave them.""" + self._channels_on = channels & self._playing def set_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None: """Takes the channels one recording is heard on, as a box on its row leaves them.""" @@ -108,6 +141,8 @@ def release(self) -> None: self._offered = {} self._heard = {} self._heard_before_solo = None + self._playing = frozenset() + self._channels_on = frozenset() def _is_alone(self, stem_id: int) -> bool: """Whether the recording is heard on every channel it offers while no other is heard at all.""" diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 798fb31ab..a2919b10a 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -6,7 +6,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.envelopes import heard_envelopes -from sampletones_application.logic.reconstruction.listening import StemListening +from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.session import ReconstructionSession from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -45,7 +45,7 @@ def __init__( self._current_reconstruction: Optional[ReconstructionData] = None self._current_features: Optional[ChannelEnvelopesViewModel] = None self._voice_id: Optional[str] = None - self._listening: StemListening = StemListening() + self._listening: Listening = Listening() self.on_reconstruction_loaded: Optional[VoidCallback] = None self.on_reconstruction_closed: Optional[VoidCallback] = None @@ -56,8 +56,8 @@ def session(self) -> ReconstructionSession: @property def renders(self) -> RenderCache: - """Where the audio of the open document is kept for as long as the screen reads it, rendered on the - first read or handed back by a rebuild. + """Where the audio of the open document is kept for as long as the screen reads it, handed back by the + regeneration worker's jobs. """ return self._renders @@ -123,7 +123,8 @@ def _adopt_reconstruction( """ self._current_reconstruction = reconstruction_data self._voice_id = voice_id - self._listening.adopt(reconstruction_data.reconstruction.stems_data) + reconstruction = reconstruction_data.reconstruction + self._listening.adopt(reconstruction.stems_data, frozenset(reconstruction.playing_channels)) self._load_reconstruction_features() def _load_reconstruction_features(self) -> None: @@ -272,8 +273,8 @@ def is_project_sample(self) -> bool: return self._voice_id is not None @property - def listening(self) -> StemListening: - """Which recordings of the open document the reader is listening to.""" + def listening(self) -> Listening: + """What the reader is listening to of the open document: its recordings per channel, and its channels.""" return self._listening @property diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index a83775741..5033e2a2a 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import ( Callable, @@ -17,7 +18,7 @@ from sampletones_application.constants.sources import SourceKind from sampletones_application.logic.export.instrument.source import ExportableInstrument from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_application.logic.reconstruction.listening import StemListening +from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.ownership import ownership_lanes from sampletones_application.view_model.reconstruction.envelopes import ( @@ -73,6 +74,21 @@ EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel.empty() +@dataclass(frozen=True) +class OwedShowing: + """A showing of the waveform and the audio that waits for the worker to render what the reader hears. + + Attributes: + load: Whether the waveform is loaded afresh, as after a listening change, or updated in place. + refit: Whether the waveform's view is re-fitted to the audio's length. + play: Whether the audio is handed to the player too. + """ + + load: bool + refit: bool + play: bool + + class ExportServiceProtocol(Protocol): """The slice of the export service the reconstruction panel logic drives. @@ -116,13 +132,13 @@ def __init__( self._export_backends = export_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION - self._playing_channels: FrozenSet[ChannelName] = frozenset() - self._selected_channels: List[ChannelName] = [] + self._owed: Optional[OwedShowing] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None - self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None - self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None + self.on_waveform_load_changed: Optional[Callable[[WaveformData], None]] = None + self.on_waveform_update_changed: Optional[Callable[..., None]] = None + self.on_renders_wanted: Optional[VoidCallback] = None self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None @@ -139,9 +155,6 @@ def display_reconstruction(self) -> None: if not reconstruction_data: return - self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels) - self._selected_channels = self._in_channel_order(self._playing_channels) - view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: self._current_audio_source = AudioSourceType.RECONSTRUCTION @@ -152,10 +165,8 @@ def display_reconstruction(self) -> None: self._build_stems_view_model(reconstruction_data), ) self.call(self.on_waveform_source_changed, self._current_audio_source) - waveform = self._waveform(reconstruction_data) - self.call(self.on_waveform_load_changed, waveform, self._selected_channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data(reconstruction_data, waveform) + self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) def update_reconstruction(self, *, refit_waveform: bool = False) -> None: """Re-answers every reading of the document after an edit. @@ -168,32 +179,51 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: if not reconstruction_data: return - self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels)) - self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) self.call( self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - waveform = self._waveform(reconstruction_data) - self.call(self.on_waveform_update_changed, waveform, self._selected_channels, refit=refit_waveform) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - if self._current_audio_source != AudioSourceType.ORIGINAL: - self._emit_audio_data(reconstruction_data, waveform) + self._show( + reconstruction_data, + OwedShowing( + load=False, + refit=refit_waveform, + play=self._current_audio_source != AudioSourceType.ORIGINAL, + ), + ) - def _adopt_playing_channels( - self, - playing_channels: FrozenSet[ChannelName], - ) -> None: - """Carries the reader's choice of channels across an edit. + def redraw_listening(self) -> None: + """Shows what the reader hears once its sound is kept, where a showing waited for it.""" + owed = self._owed + reconstruction_data = self._reconstruction_data + if owed is None or not reconstruction_data: + return + + self._show(reconstruction_data, owed) - An edit puts a channel in play or takes it out. A channel that keeps playing keeps - whatever the reader chose for it, and one gaining its first frame joins the waveform, - so the checkboxes report what plays while a deliberate choice survives. + def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: + """Draws and plays what the reader hears, or asks for its sound where the screen holds none yet. + + The audio is read from what is kept, so a showing computes no sample on the render thread. + One the cache cannot answer is owed: the worker is asked to render it, and the showing + happens once the sound lands. """ - selected = (set(self._selected_channels) & playing_channels) | (playing_channels - self._playing_channels) - self._playing_channels = playing_channels - self._selected_channels = self._in_channel_order(frozenset(selected)) + waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) + if waveform is None: + self._owed = showing + self.call(self.on_renders_wanted) + return + + self._owed = None + if showing.load: + self.call(self.on_waveform_load_changed, waveform) + else: + self.call(self.on_waveform_update_changed, waveform, refit=showing.refit) + + if showing.play: + self._emit_audio_data(reconstruction_data, waveform) @staticmethod def _in_channel_order(channels: FrozenSet[ChannelName]) -> List[ChannelName]: @@ -206,8 +236,8 @@ def _build_view_model( reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data) return ReconstructionViewModel( reconstruction_loaded=True, - playing_channels=self._playing_channels, - selected_channels=frozenset(self._selected_channels), + playing_channels=self._listening.playing, + selected_channels=self._listening.channels_on, reconstruction_file=reconstruction_file, original_audio=original_audio, nes_frequency=reconstruction_data.config.nes_frequency, @@ -230,8 +260,7 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION - self._playing_channels = frozenset() - self._selected_channels = [] + self._owed = None self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) self.call( @@ -264,17 +293,23 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: if reconstruction_data is None: self.call(self.on_audio_data_changed, None) else: - self._emit_audio_data(reconstruction_data, self._waveform(reconstruction_data)) + self._play(reconstruction_data) self.call(self.on_waveform_source_changed, audio_source) - def _waveform(self, reconstruction_data: ReconstructionData) -> WaveformData: - """One reading of what the document plays for the reader's listening, which the waveform and the audio share.""" - return reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._stem_selection) + def _play(self, reconstruction_data: ReconstructionData) -> None: + """Hands the player what the source chosen plays, or asks for its sound where the screen holds none yet.""" + waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) + if waveform is None: + self._owed = OwedShowing(load=False, refit=False, play=True) + self.call(self.on_renders_wanted) + return + + self._emit_audio_data(reconstruction_data, waveform) def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" - self._selected_channels = channels + self._listening.set_channels_on(frozenset(channels)) reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -283,10 +318,8 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None: self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - waveform = self._waveform(reconstruction_data) - self.call(self.on_waveform_load_changed, waveform, channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data(reconstruction_data, waveform) + self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) def set_stem_channels( self, @@ -321,15 +354,13 @@ def _refresh_listening(self) -> None: self.on_stems_view_changed, self._build_stems_view_model(reconstruction_data), ) - waveform = self._waveform(reconstruction_data) - self.call(self.on_waveform_load_changed, waveform, self._selected_channels) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._emit_audio_data(reconstruction_data, waveform) + self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) self.call(self.on_heard_changed) @property - def _listening(self) -> StemListening: - """Which recordings the reader is listening to, which the open document holds.""" + def _listening(self) -> Listening: + """What the reader is listening to, which the open document holds.""" return self._reconstruction_manager.listening @property @@ -353,8 +384,8 @@ def _build_ownership_ribbon( stems_data = reconstruction_data.reconstruction.stems_data owned = stems_data.assignments_by_channel assignments: Dict[ChannelName, Sequence[int]] = { - channel_name: owned[channel_name] if channel_name in self._selected_channels else () - for channel_name in self._in_channel_order(self._playing_channels) + channel_name: owned[channel_name] if channel_name in self._listening.channels_on else () + for channel_name in self._in_channel_order(self._listening.playing) if channel_name in owned } lanes = tuple(ownership_lanes(assignments, self.heard_on).values()) @@ -412,7 +443,7 @@ def _build_stems_view_model( stems=StemsListViewModel( rows=rows, channels_in_play=tuple(channels_in_play), - muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels), + muted_channels=frozenset(channels_in_play) - self._listening.channels_on, picked_keys=frozenset(), picking_room=None, live=True, @@ -665,10 +696,9 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: logger.warning("No reconstruction data available for WAV export") return - audio_snapshot = reconstruction_data.partials_for( + audio_snapshot = reconstruction_data.audio_for( self._reconstruction_manager.renders, - self._selected_channels, - self._stem_selection, + self._listening.hearing, ) sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) @@ -724,7 +754,7 @@ def _audio_data( return AudioData.from_array(waveform.original_audio, sample_rate) - return AudioData.from_array(waveform.partials(self._selected_channels), sample_rate) + return AudioData.from_array(waveform.playing, sample_rate) def _build_path_view_models( self, diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 60aaa1590..f2ee7f8d2 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, replace -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Union from sampletones_application.logic.reconstruction.edit import ( ChannelEdit, @@ -14,13 +14,14 @@ from sampletones_application.logic.reconstruction.rewrites.steps import ( AfterEdits, ChannelChange, + ListeningChange, RateChange, Rewrite, StemRemovalRequest, ) from sampletones_application.services.regeneration.result import ( - RegeneratedInstrument, RegenerationResult, + RenderedDocument, ) from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.view_model.reconstruction.envelopes import ( @@ -54,23 +55,46 @@ def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: return self.base is not None and self.base is reconstruction +@dataclass(frozen=True) +class Listening: + """A reading of what the reader hears the worker is rendering, beside the document it reads. + + Attributes: + base: The document read, or ``None`` once an outside replacement put that document away. + """ + + base: Optional[Reconstruction] + + def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: + """Whether the result belongs to ``reconstruction``, the document it was computed from.""" + return self.base is not None and self.base is reconstruction + + +Running = Union[Rebuild, Listening] + + class ReconstructionRewrites(CallbackMixin): - """The steps that change the open document, taken one at a time in the order the reader asked. + """The steps that change the open document or what is heard of it, taken one at a time in the order the + reader asked. Every edit of the Reconstructions tab's document passes through here: a channel the reader moved, a recording taken out, a new NES frequency, and a gesture that reads or puts away the - whole document. Each step reads the document the step before it left, so one edit never - overwrites another and a rebuild always starts from what stands. - - Channel changes wait while a rebuild runs, and a change joins the one waiting at the end of the - line when both move the same channel, so a drag collapses into the place it ended. A rebuild's - result lands only on the document it was computed from. A removal or a whole-document gesture - changes what the panel was drawn on, so a channel change asked for while one waits is refused, - and the panel is redrawn once the line empties. + whole document. So does a change to what the reader hears whose sound the screen holds no + audio for yet, which the worker renders from the document as it then stands. Each step reads + the document the step before it left, so one edit never overwrites another, a rebuild always + starts from what stands, and a listening sounds the document the edits before it made. + + Channel changes wait while a job runs, and a change joins the one waiting at the end of the + line when both move the same channel, so a drag collapses into the place it ended; one + listening waiting answers every choice made before it is taken. A result lands only on the + document it was computed from. A removal or a whole-document gesture changes what the panel + was drawn on, so a channel change asked for while one waits is refused, and the panel is + redrawn once the line empties. The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that - lands, ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a - rebuild's failure, and ``on_busy_changed`` as the line fills and empties. + lands, ``on_heard_rendered`` once a listening's sound is kept for the open document, + ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a job's + failure, and ``on_busy_changed`` as the line fills and empties. """ def __init__( @@ -81,11 +105,12 @@ def __init__( self._manager = reconstruction_manager self._regeneration = regeneration self._waiting: List[Rewrite] = [] - self._running: Optional[Rebuild] = None + self._running: Optional[Running] = None self._redraw_owed: bool = False self._reported_busy: bool = False self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None + self.on_heard_rendered: Optional[VoidCallback] = None self.on_dropped: Optional[VoidCallback] = None self.on_failed: Optional[Callable[[Exception], None]] = None self.on_busy_changed: Optional[Callable[[bool], None]] = None @@ -94,20 +119,23 @@ def __init__( @property def is_busy(self) -> bool: - """Whether a rebuild runs or a step waits, which is the span the open document is being rewritten.""" + """Whether a job runs or a step waits, which is the span the open document's sound is on its way.""" return self._running is not None or bool(self._waiting) def request(self, rewrite: Rewrite) -> None: """Puts a step at the end of the line, and takes it at once where nothing runs before it. - A channel change joins a waiting change of the same channel, and a new rate replaces a - waiting one, so what the reader asked for last is what lands. + A channel change joins a waiting change of the same channel, a new rate replaces a + waiting one, and one listening waiting stands for every one asked for after it, so what + the reader asked for last is what lands. """ match rewrite: case ChannelChange() as change: self._enqueue_change(change) case RateChange() as rate_change: self._enqueue_rate(rate_change) + case ListeningChange() as listening: + self._enqueue_listening(listening) case StemRemovalRequest() | AfterEdits(): self._waiting.append(rewrite) @@ -117,8 +145,8 @@ def request(self, rewrite: Rewrite) -> None: def drop(self) -> None: """Lets go of the edits meant for a document an outside replacement puts away. - The channel changes, removals and rates waiting leave the line, and the running rebuild's - result no longer lands. A whole-document gesture the reader asked for keeps its place, so a + The channel changes, removals, rates and listenings waiting leave the line, and the running + job's result no longer lands. A whole-document gesture the reader asked for keeps its place, so a save, a load or an undo still happens, in order, on the document now open. The replacement draws the document it puts in place, which answers a redraw still owed. """ @@ -151,7 +179,7 @@ def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewMod def _pending_changes(self) -> List[ChannelChange]: """The channel changes on their way to the open document, the running one first.""" changes: List[ChannelChange] = [] - if self._running is not None and self._running.lands_on(self._manager.reconstruction): + if isinstance(self._running, Rebuild) and self._running.lands_on(self._manager.reconstruction): changes.append(self._running.change) changes.extend(step for step in self._waiting if isinstance(step, ChannelChange)) @@ -174,6 +202,10 @@ def _enqueue_change(self, change: ChannelChange) -> None: case _: self._waiting.append(change) + def _enqueue_listening(self, listening: ListeningChange) -> None: + if not any(isinstance(step, ListeningChange) for step in self._waiting): + self._waiting.append(listening) + def _enqueue_rate(self, rate_change: RateChange) -> None: match self._waiting[-1] if self._waiting else None: case RateChange(): @@ -182,7 +214,7 @@ def _enqueue_rate(self, rate_change: RateChange) -> None: self._waiting.append(rate_change) def _advance(self) -> None: - """Takes the waiting steps in order until a rebuild runs or the line empties.""" + """Takes the waiting steps in order until a job runs or the line empties.""" while self._running is None and self._waiting: self._take(self._waiting.pop(0)) @@ -196,6 +228,8 @@ def _take(self, step: Rewrite) -> None: self._retune(rate_change) case AfterEdits(gesture=gesture): gesture() + case ListeningChange(): + self._listen() def _rebuild(self, change: ChannelChange) -> None: """Starts the rebuild of a changed channel from the document and the listening as they stand. @@ -215,7 +249,26 @@ def _rebuild(self, change: ChannelChange) -> None: reconstruction, change.channel_name, change.rebased(envelopes[change.channel_name]), - self._manager.listening.heard_on(change.channel_name), + hearing=self._manager.listening.hearing, + kept=self._manager.renders.held(reconstruction), + ) + + def _listen(self) -> None: + """Starts rendering what the reader hears of the document as it stands. + + The listening is marked running before it starts, since a result can arrive before the + start returns. The renders the cache holds go with it, so the worker renders what is missing + alone. + """ + reconstruction = self._manager.reconstruction + if reconstruction is None: + logger.info("A change to what is heard found no document open") + return + + self._running = Listening(base=reconstruction) + self._regeneration.start_listening( + reconstruction, + hearing=self._manager.listening.hearing, kept=self._manager.renders.held(reconstruction), ) @@ -255,50 +308,55 @@ def _retune(self, rate_change: RateChange) -> None: ) def _on_result(self, result: RegenerationResult) -> None: - """Lands a finished rebuild on the document it was computed from, then takes the next step. + """Lands a finished job on the document it was computed from, then takes the next step. Raises: - RuntimeError: If a result arrives while no rebuild runs. + RuntimeError: If a result arrives while no job runs. """ - rebuild = self._running - if rebuild is None: - raise RuntimeError("A regeneration result arrived while no rebuild was running") + running = self._running + if running is None: + raise RuntimeError("A regeneration result arrived while no job was running") self._running = None match result: case ServiceSuccess(value=outcome): - self._land(rebuild, outcome) + self._land(running, outcome) case ServiceError(exception=exception): self.call(self.on_failed, exception) - self._let_go(rebuild) + self._let_go(running) self._advance() self._settle() - def _land(self, rebuild: Rebuild, outcome: RegeneratedInstrument) -> None: - """Keeps the renders the rebuild made and hands the edit on, where its document is still open.""" - if not rebuild.lands_on(self._manager.reconstruction): - logger.info(f"A rebuild of {rebuild.change.channel_name} finished for a document put away since") - self._let_go(rebuild) + def _land(self, running: Running, outcome: RenderedDocument) -> None: + """Keeps the renders the job made and reports it, where its document is still open.""" + if not running.lands_on(self._manager.reconstruction): + logger.info("A job finished for a document put away since") + self._let_go(running) return self._manager.renders.adopt(outcome.reconstruction, outcome.channels, outcome.mix) - self.call( - self.on_edit, - ChannelEdit( - reconstruction=outcome.reconstruction, - channel_name=rebuild.change.channel_name, - feature_key=rebuild.change.feature_key, - ), - ) - - def _let_go(self, rebuild: Rebuild) -> None: + self._manager.renders.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + match running: + case Rebuild(change=change): + self.call( + self.on_edit, + ChannelEdit( + reconstruction=outcome.reconstruction, + channel_name=change.channel_name, + feature_key=change.feature_key, + ), + ) + case Listening(): + self.call(self.on_heard_rendered) + + def _let_go(self, running: Running) -> None: """Redraws the panel where it still draws a change that will never land. A rebuild an outside replacement dropped needs nothing, since the replacement drew the - document it put in place. + document it put in place, and a listening drew nothing ahead of its sound. """ - if rebuild.base is not None: + if isinstance(running, Rebuild) and running.base is not None: self.call(self.on_dropped) def _settle(self) -> None: diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py index f0180eb2a..4dca9ddf1 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -1,4 +1,4 @@ -from typing import AbstractSet, Callable, Mapping, Protocol +from typing import Callable, Mapping, Protocol import numpy as np @@ -6,6 +6,7 @@ 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): @@ -22,7 +23,15 @@ def start( reconstruction: Reconstruction, channel_name: ChannelName, features: Features, - heard: AbstractSet[int], *, + 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/reconstruction/rewrites/steps.py b/src/sampletones_application/logic/reconstruction/rewrites/steps.py index dc83b7736..052ddd7d3 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/steps.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/steps.py @@ -83,4 +83,13 @@ class AfterEdits: gesture: VoidCallback -Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits] +@dataclass(frozen=True) +class ListeningChange: + """A change to what the reader hears, whose sound the worker renders for the document as it then stands. + + The step carries nothing: what the reader hears is read at its turn, so the latest choice is + what is rendered, and one such step waiting answers every choice made before it is taken. + """ + + +Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits, ListeningChange] diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index 6ce8ae6f6..a52fed9db 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -3,7 +3,7 @@ from collections import OrderedDict from dataclasses import dataclass from enum import StrEnum -from typing import Dict, List, Mapping, Optional, Set, Tuple +from typing import Dict, FrozenSet, List, Mapping, Optional, Sequence, Tuple import numpy as np @@ -14,6 +14,7 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_core.reconstructions.reconstruction.renders import sounding_streams +from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing class RenderKind(StrEnum): @@ -21,14 +22,45 @@ class RenderKind(StrEnum): MIX = "mix" +@dataclass(frozen=True) +class RenderPart: + """One channel as a render sounds it: its stream, and the owners heard where the reader leaves one out. + + Attributes: + stream: The identity of the stream the channel plays. + assignment: The identity of the record naming the owner of each frame, where an owner is + left out. + heard: The owners heard, where an owner is left out; ``None`` where the channel is heard whole. + """ + + stream: int + assignment: Optional[int] + heard: Optional[FrozenSet[int]] + + @dataclass(frozen=True) class RenderKey: - """What one render was made from: the identities of the streams it sounds and the configuration it ran at.""" + """What one render was made from: the parts it sounds and the configuration it ran at.""" kind: RenderKind - streams: Tuple[int, ...] + parts: Tuple[RenderPart, ...] config: Config + @property + def identities(self) -> FrozenSet[int]: + """The identities of every object the render was made from.""" + named = {part.stream for part in self.parts} + named.update(part.assignment for part in self.parts if part.assignment is not None) + return frozenset(named) + + +@dataclass(frozen=True) +class _Part: + """A part beside the objects it names, which the cache watches.""" + + part: RenderPart + watched: Tuple[object, ...] + class RenderCache: """The audio the documents on screen render to, kept apart from the documents themselves. @@ -36,23 +68,26 @@ class RenderCache: A reconstruction is a value that the undo history holds for as long as an entry names it, so audio cached on it would live as long as the history does. The cache keeps renders instead, each made from one channel's stream at one configuration, or one mix of such - streams. A stream an edit leaves alone is the very object the edited document holds, so its - render serves both documents. - - A render is keyed by the identity of the streams it sounds, which a lookup reads at once, - where a stream's value would take every instruction it carries to hash. A render goes with - the stream it was made from, and beyond ``budget_bytes`` the renders read longest ago go - first, so the cache keeps audio alone. The render thread reads the cache and keeps what it - renders or adopts, and a stream's collection may drop its renders from any thread, so every - change to the cache is made under one lock. The lock lets its holder in again, since a stream - let go of while a keep runs on the same thread is forgotten from inside that keep. A kept - render is read-only, so the readers it is shared with copy what they change. + streams as a reader hears them. A stream an edit leaves alone is the very object the edited + document holds, so its render serves both documents, and so is the record naming who holds + each of its frames. + + A render is keyed by the identity of the parts it sounds, which a lookup reads at once, where + a stream's value would take every instruction it carries to hash. A mix as a reader hears it + names, for each channel the reader leaves an owner out on, the record and the owners heard, so + two hearings leaving nothing out read one mix. A render goes with the objects it was made + from, and beyond ``budget_bytes`` the renders read longest ago go first, so the cache keeps + audio alone. The render thread reads the cache and keeps what it renders or adopts, and an + object's collection may drop its renders from any thread, so every change to the cache is + made under one lock. The lock lets its holder in again, since a stream let go of while a keep + runs on the same thread is forgotten from inside that keep. A kept render is read-only, so the + readers it is shared with copy what they change. """ def __init__(self, *, budget_bytes: int) -> None: self._budget_bytes = budget_bytes self._renders: OrderedDict[RenderKey, np.ndarray] = OrderedDict() - self._watched: Set[int] = set() + self._watched: set[int] = set() self._held_bytes = 0 self._lock = threading.RLock() @@ -71,13 +106,14 @@ def channels(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarr def mix(self, reconstruction: Reconstruction) -> np.ndarray: """The whole reconstruction, summed from the channels that play.""" streams = sounding_streams(reconstruction) - key = self._mix_key(streams, reconstruction.config) + parts = [self._plain_part(stream) for stream in streams] + key = self._mix_key(parts, reconstruction.config) cached = self._read(key) if cached is not None: return cached mixed = mix([self._channel(stream, reconstruction.config) for stream in streams]) - self._keep(key, mixed, streams) + self._keep(key, mixed, [stream for part in parts for stream in part.watched]) return mixed def held(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: @@ -94,6 +130,25 @@ def held(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: return held + def reading( + self, + reconstruction: Reconstruction, + hearing: Hearing, + ) -> Optional[HeardRenders]: + """What the cache holds of ``hearing``'s reading of ``reconstruction``, which a lookup answers at once. + + Returns: + Optional[HeardRenders]: The heard mix and what plays, or ``None`` where either is + missing, which is what a worker is then asked to render. + """ + parts = self._heard_parts(reconstruction, hearing) + mixed = self._read(self._mix_key(list(parts.values()), reconstruction.config)) + playing = self._read(self._mix_key(self._playing_parts(parts, hearing), reconstruction.config)) + if mixed is None or playing is None: + return None + + return HeardRenders(mix=mixed, playing=playing) + def adopt( self, reconstruction: Reconstruction, @@ -114,15 +169,67 @@ def adopt( for stream in streams: self._keep(self._channel_key(stream, reconstruction.config), channels[stream.channel_name], [stream]) - self._keep(self._mix_key(streams, reconstruction.config), mixed, streams) + parts = [self._plain_part(stream) for stream in streams] + self._keep(self._mix_key(parts, reconstruction.config), mixed, streams) + + def adopt_heard( + self, + reconstruction: Reconstruction, + hearing: Hearing, + heard: HeardRenders, + ) -> None: + """Keeps what a hearing of the document sounds, rendered elsewhere, under the parts it was heard as. + + Args: + reconstruction: The document the renders sound. + hearing: What the reader heard of it when the renders were made. + heard: The heard mix and what plays. + """ + parts = self._heard_parts(reconstruction, hearing) + self._keep_mix(list(parts.values()), reconstruction.config, heard.mix) + self._keep_mix(self._playing_parts(parts, hearing), reconstruction.config, heard.playing) + + def _keep_mix(self, parts: List[_Part], config: Config, mixed: np.ndarray) -> None: + self._keep( + self._mix_key(parts, config), + mixed, + [watched for part in parts for watched in part.watched], + ) + + @staticmethod + def _plain_part(stream: InstructionsItem) -> _Part: + return _Part(part=RenderPart(stream=id(stream), assignment=None, heard=None), watched=(stream,)) + + @staticmethod + def _heard_parts(reconstruction: Reconstruction, hearing: Hearing) -> Dict[ChannelName, _Part]: + """Each channel in play as ``hearing`` sounds it, in channel order.""" + stems_data = reconstruction.stems_data + parts: Dict[ChannelName, _Part] = {} + for stream in sounding_streams(reconstruction): + heard = hearing.filter_for(stream.channel_name, stems_data) + if heard is None: + parts[stream.channel_name] = RenderCache._plain_part(stream) + continue + + record = stems_data.assignment_records[stream.channel_name] + parts[stream.channel_name] = _Part( + part=RenderPart(stream=id(stream), assignment=id(record), heard=heard), + watched=(stream, record), + ) + + return parts + + @staticmethod + def _playing_parts(parts: Mapping[ChannelName, _Part], hearing: Hearing) -> List[_Part]: + return [part for channel_name, part in parts.items() if channel_name in hearing.channels_on] @staticmethod def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey: - return RenderKey(RenderKind.CHANNEL, (id(stream),), config) + return RenderKey(RenderKind.CHANNEL, (RenderPart(stream=id(stream), assignment=None, heard=None),), config) @staticmethod - def _mix_key(streams: List[InstructionsItem], config: Config) -> RenderKey: - return RenderKey(RenderKind.MIX, tuple(id(stream) for stream in streams), config) + def _mix_key(parts: Sequence[_Part], config: Config) -> RenderKey: + return RenderKey(RenderKind.MIX, tuple(part.part for part in parts), config) def _channel(self, stream: InstructionsItem, config: Config) -> np.ndarray: key = self._channel_key(stream, config) @@ -150,7 +257,7 @@ def _keep( self, key: RenderKey, rendered: np.ndarray, - streams: List[InstructionsItem], + watched: Sequence[object], ) -> None: with self._lock: if key in self._renders: @@ -159,18 +266,18 @@ def _keep( rendered.setflags(write=False) self._renders[key] = rendered self._held_bytes += rendered.nbytes - for stream in streams: - if id(stream) not in self._watched: - self._watched.add(id(stream)) - weakref.finalize(stream, self._forget, id(stream)) + for named in watched: + if id(named) not in self._watched: + self._watched.add(id(named)) + weakref.finalize(named, self._forget, id(named)) while self._held_bytes > self._budget_bytes and len(self._renders) > 1: _, evicted = self._renders.popitem(last=False) self._held_bytes -= evicted.nbytes - def _forget(self, stream_id: int) -> None: - """Drops every render a collected stream sounds in, before its identity can name another.""" + def _forget(self, identity: int) -> None: + """Drops every render a collected stream or record sounds in, before its identity can name another.""" with self._lock: - self._watched.discard(stream_id) - for key in [key for key in self._renders if stream_id in key.streams]: + self._watched.discard(identity) + for key in [key for key in self._renders if identity in key.identities]: self._held_bytes -= self._renders.pop(key).nbytes diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 0eebac09c..2141c6626 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -11,8 +11,8 @@ from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.regeneration.result import ( - RegeneratedInstrument, RegenerationResult, + RenderedDocument, ) from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.render import ( @@ -41,7 +41,7 @@ "ExportService", "ExportSuccess", "ReconstructionStep", - "RegeneratedInstrument", + "RenderedDocument", "RegenerationResult", "RegenerationService", "RenderResult", diff --git a/src/sampletones_application/services/regeneration/__init__.py b/src/sampletones_application/services/regeneration/__init__.py index 2f6415ed7..e2d80119e 100644 --- a/src/sampletones_application/services/regeneration/__init__.py +++ b/src/sampletones_application/services/regeneration/__init__.py @@ -1,8 +1,8 @@ -from .result import RegeneratedInstrument, RegenerationResult +from .result import RegenerationResult, RenderedDocument from .service import RegenerationService __all__ = [ - "RegeneratedInstrument", + "RenderedDocument", "RegenerationResult", "RegenerationService", ] diff --git a/src/sampletones_application/services/regeneration/jobs.py b/src/sampletones_application/services/regeneration/jobs.py new file mode 100644 index 000000000..d6d2eb34c --- /dev/null +++ b/src/sampletones_application/services/regeneration/jobs.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Mapping, Union + +import numpy as np + +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 + + +@dataclass(frozen=True) +class RebuildJob: + """One channel of a document rebuilt from envelopes, and the document rendered as the reader hears it. + + Attributes: + reconstruction: The document the rebuild starts from, which stays as it is. + channel_name: The channel rebuilt. + features: The envelopes the channel is rebuilt from. + hearing: What the reader hears, which names the recordings the edit writes and the reading + rendered. + kept: The renders the caller holds for the document's channels, by channel. + """ + + reconstruction: Reconstruction + channel_name: ChannelName + features: Features + hearing: Hearing + kept: Mapping[ChannelName, np.ndarray] + + +@dataclass(frozen=True) +class ListeningJob: + """A document rendered as the reader hears it, as it stands. + + Attributes: + reconstruction: The document read. + hearing: What the reader hears of it. + kept: The renders the caller holds for the document's channels, by channel. + """ + + reconstruction: Reconstruction + hearing: Hearing + kept: Mapping[ChannelName, np.ndarray] + + +RenderJob = Union[RebuildJob, ListeningJob] diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index f01a5171b..8a5c1092a 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -9,27 +9,33 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing @dataclass(frozen=True) -class RegeneratedInstrument: - """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed, and its sound. +class RenderedDocument: + """A document beside its sound: every channel's render, their mix, and what one hearing of it sounds. - The caller keeps what it asked for, so the result carries the rebuilt document and the audio - the worker rendered for it, which the landing installs where the screen reads it. + The caller keeps what it asked for, so the result carries the document and the audio the + worker rendered for it, which the landing installs where the screen reads it. A rebuild + carries a fresh document with one channel rebuilt; a listening carries the document as it was. Attributes: - reconstruction: The fresh reconstruction carrying the rebuilt channel. + reconstruction: The document the renders sound. channels: The audio each sounding channel of ``reconstruction`` renders to, by channel. - mix: The whole reconstruction, summed from ``channels``. + mix: The whole document, summed from ``channels``. + hearing: What the reader heard when the renders were made. + heard: What ``hearing`` sounds of the document. """ reconstruction: Reconstruction channels: Mapping[ChannelName, np.ndarray] mix: np.ndarray + hearing: Hearing + heard: HeardRenders RegenerationResult = Union[ - ServiceSuccess[RegeneratedInstrument], + ServiceSuccess[RenderedDocument], ServiceError, ] diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 0e8360eb9..125691847 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -1,12 +1,13 @@ from functools import partial -from typing import AbstractSet, Dict, List, Mapping, cast +from typing import Dict, List, Mapping, Optional, cast import numpy as np from sampletones_application.services.base import ServiceBase +from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob, RenderJob from sampletones_application.services.regeneration.result import ( - RegeneratedInstrument, RegenerationResult, + RenderedDocument, ) from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.parallelization.thread import SingleThreadExecutor @@ -17,20 +18,23 @@ 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 class RegenerationService(ServiceBase[RegenerationResult]): - """Recomputes one generator's instructions for a reconstruction and renders what the document then sounds. + """Recomputes one generator's instructions for a reconstruction and renders what the reader hears of it. - An edit reaches the frames the reader hears on the channel, so ``heard`` travels with it: + 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. - The result is a fresh reconstruction carrying the updated generator data, with the audio of - every sounding channel and their mix; the source reconstruction is left intact. Producing a - new object lets callers swap the edited reconstruction in while any history snapshot that - shares the prior object stays valid. Carrying the audio lets the landing install it where the - screen reads it, so the render thread keeps drawing while the worker renders. + A rebuild's result is a fresh reconstruction carrying the updated generator data, with the + audio of every sounding channel, their mix, and what the hearing sounds of it; the source + reconstruction is left intact. Producing a new object lets callers swap the edited + reconstruction in while any history snapshot that shares the prior object stays valid. A + listening renders the same reading of the document as it stands, for a hearing the screen + holds no audio for yet. Carrying the audio lets the landing install it where the screen reads + it, so the render thread keeps drawing while the worker renders. One job runs at a time on a single worker thread. Its caller starts the next job once the previous result has arrived, so each rebuild starts from the document the one before it left. @@ -45,8 +49,8 @@ def start( reconstruction: Reconstruction, channel_name: ChannelName, features: Features, - heard: AbstractSet[int], *, + hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], ) -> None: """Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread. @@ -55,73 +59,91 @@ def start( reconstruction: The document the rebuild starts from, which stays as it is. channel_name: The channel rebuilt. features: The envelopes the channel is rebuilt from. - heard: The recordings whose frames the rebuild writes. + hearing: What the reader hears, which names the recordings the rebuild writes on the + channel and 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 render handed for ``channel_name`` is passed over, since the edit rebuilds it. """ - self._executor.execute( - partial( - self._run, - reconstruction, - channel_name, - features, - heard, - kept, - ), - wait=True, - ) + self._execute(RebuildJob(reconstruction, channel_name, features, hearing, kept)) - def _run( + def start_listening( self, reconstruction: Reconstruction, - channel_name: ChannelName, - features: Features, - heard: AbstractSet[int], + *, + hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], ) -> None: + """Renders what ``hearing`` sounds of ``reconstruction`` as it stands, on the worker thread. + + Args: + reconstruction: The document read. + hearing: What the reader hears of it. + kept: The renders the caller holds for ``reconstruction``'s channels, by channel, each of + which carries over; the worker renders the rest. + """ + self._execute(ListeningJob(reconstruction, hearing, kept)) + + def _execute(self, job: RenderJob) -> None: + self._executor.execute(partial(self._run, job), wait=True) + + def _run(self, job: RenderJob) -> None: try: - exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] - instructions = cast( - List[InstructionUnion], - exporter_class.from_features(features), - ) + match job: + case RebuildJob(): + document = self._rebuilt(job) + edited: Optional[ChannelName] = job.channel_name + case ListeningJob(): + document = job.reconstruction + edited = None - updated = reconstruction.with_channel_data( - channel_name, - instructions, - features.initial_pitch, - features.held_features, - heard=heard, - ) if SingleThreadExecutor.is_shutting_down(): return - channels = self._rendered(updated, channel_name, kept) + channels = self._rendered(document, edited, job.kept) + mixed = mix(list(channels.values())) self._emit( ServiceSuccess( - value=RegeneratedInstrument( - reconstruction=updated, + value=RenderedDocument( + reconstruction=document, channels=channels, - mix=mix(list(channels.values())), + mix=mixed, + hearing=job.hearing, + heard=heard_renders(document, job.hearing, channels, mixed), ) ) ) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) + @staticmethod + def _rebuilt(job: RebuildJob) -> Reconstruction: + """The document with the job's channel rebuilt from its envelopes, on the recordings the reader hears.""" + exporter_class = CHANNEL_TO_EXPORTER_MAP[job.channel_name] + instructions = cast( + List[InstructionUnion], + exporter_class.from_features(job.features), + ) + return job.reconstruction.with_channel_data( + job.channel_name, + instructions, + job.features.initial_pitch, + job.features.held_features, + heard=job.hearing.selection.stems_for(job.channel_name), + ) + @staticmethod def _rendered( - updated: Reconstruction, - edited: ChannelName, + document: Reconstruction, + edited: Optional[ChannelName], kept: Mapping[ChannelName, np.ndarray], ) -> Dict[ChannelName, np.ndarray]: - """The audio each sounding channel of ``updated`` renders to, a handed render carried over where the - edit left the channel alone. + """The audio each sounding channel of ``document`` renders to, a handed render carried over where the + job left the channel alone. """ rendered: Dict[ChannelName, np.ndarray] = {} - for stream in sounding_streams(updated): + for stream in sounding_streams(document): handed = kept.get(stream.channel_name) if handed is not None and stream.channel_name != edited: rendered[stream.channel_name] = handed @@ -130,7 +152,7 @@ def _rendered( rendered[stream.channel_name] = render_instructions( [data.instruction for data in stream.instructions], stream.channel_name, - updated.config, + document.config, ) return rendered diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 29493d7f5..1bc6202aa 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -450,13 +450,9 @@ def load_voice_waveform( def _extract_reconstruction_layer_data( self, waveform_data: WaveformData, - selected_channels: Optional[List[ChannelName]] = None, ) -> Tuple[Optional[np.ndarray], np.ndarray, float]: - if selected_channels is None: - selected_channels = list(waveform_data.approximations.keys()) - original_audio = waveform_data.original_audio - approximation = waveform_data.partials(selected_channels) + approximation = waveform_data.playing full_approximation = waveform_data.approximation if not self.reconstruction_autoscale or original_audio is None: @@ -474,7 +470,6 @@ def _extract_reconstruction_layer_data( def _display_layers( self, waveform_data: WaveformData, - selected_channels: Optional[List[ChannelName]] = None, ) -> List[Union[ArrayLayer, InstructionLayer]]: """Builds the ordered waveform layers for the current data. @@ -482,10 +477,7 @@ def _display_layers( so a reconstruction whose recordings are missing or unreadable shows the approximation on its own. """ - original_audio, approximation_data, _ = self._extract_reconstruction_layer_data( - waveform_data, - selected_channels, - ) + original_audio, approximation_data, _ = self._extract_reconstruction_layer_data(waveform_data) reconstruction_layer = self.reconstruction_layer(approximation_data) if original_audio is None: return [reconstruction_layer] @@ -496,7 +488,6 @@ def _display_layers( def update_waveform_data( self, waveform_data: WaveformData, - selected_channels: Optional[List[ChannelName]] = None, *, refit: bool = False, ) -> None: @@ -510,7 +501,7 @@ def update_waveform_data( return self.current_data = waveform_data - displayed = self._display_layers(waveform_data, selected_channels) + displayed = self._display_layers(waveform_data) if any(layer.name not in self.layers for layer in displayed): self._delete_series() @@ -521,16 +512,12 @@ def update_waveform_data( self._update_display() - def load_waveform_data( - self, - waveform_data: WaveformData, - selected_channels: Optional[List[ChannelName]] = None, - ) -> None: + def load_waveform_data(self, waveform_data: WaveformData) -> None: self.clear_layers() self.current_data = waveform_data self._sample_rate = waveform_data.sample_rate self._plays_what_it_draws = True - for layer in self._display_layers(waveform_data, selected_channels): + for layer in self._display_layers(waveform_data): self.add_layer(layer) self._restate_clock_ticks(*self.x_range) diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index de9babb3d..09cb0ba97 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -141,22 +141,17 @@ def _show_recording_controls(self, *, shown: bool) -> None: dpg_configure_item(self.autoscale_tag, show=shown) dpg_configure_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, show=shown) - def load_waveform_data( - self, - waveform_data: WaveformData, - generators: List[ChannelName], - ) -> None: + def load_waveform_data(self, waveform_data: WaveformData) -> None: self._frame_length = waveform_data.frame_length - self.waveform_display.load_waveform_data(waveform_data, generators) + self.waveform_display.load_waveform_data(waveform_data) def update_waveform_data( self, waveform_data: WaveformData, - generators: List[ChannelName], *, refit: bool = False, ) -> None: - self.waveform_display.update_waveform_data(waveform_data, generators, refit=refit) + self.waveform_display.update_waveform_data(waveform_data, refit=refit) def clear_waveform(self) -> None: self._frame_length = None diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index 825b62c84..83d6f9b70 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -1,44 +1,25 @@ from dataclasses import dataclass -from typing import Dict, List, Optional +from typing import Optional import numpy as np -from sampletones_core.audio.mixing import align, mix -from sampletones_core.constants.enums import ChannelName - @dataclass(frozen=True) class WaveformData: - """What the waveform display draws of a document: the audio each channel plays and their mix. - - ``approximation`` is the mix of ``approximations``, which is what lets a reading over every - channel answer with the mix itself. + """What the waveform display draws of a document and the player plays, for one hearing of it. + + Attributes: + original_audio: The recordings heard mixed into one waveform, ``None`` while no recording loads. + approximation: Every channel in play mixed, the frames of the recordings left out silenced. + playing: The channels kept on mixed the same way, which is what plays and is drawn. + coefficient: The level the original is scaled by to stand beside the approximation. + frame_length: How many samples one frame spans. + sample_rate: How many samples one second spans. """ original_audio: Optional[np.ndarray] approximation: np.ndarray - approximations: Dict[ChannelName, np.ndarray] + playing: np.ndarray coefficient: float frame_length: int sample_rate: int - - def partials(self, channel_names: List[ChannelName]) -> np.ndarray: - """Sums the selected channels' approximations, silent when none apply. - - A selection covering every channel answers the mix the data already holds. A narrower - one is summed in channel order whatever order it was given in, so the same choice reads - the same samples. The approximation sets the length, so the silent result matches the - waveform even when no original audio is present, and each selected approximation pads to - that length before the sum, so a channel that ends early leaves the tail in silence. - """ - selected = [channel_name for channel_name in ChannelName.items() if channel_name in channel_names] - selected_approximations = [ - self.approximations[channel_name] for channel_name in selected if channel_name in self.approximations - ] - if not selected_approximations: - return np.zeros_like(self.approximation) - - if len(selected_approximations) == len(self.approximations): - return self.approximation - - return mix(align(selected_approximations, len(self.approximation))) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py index a1887abab..5e902de9a 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -90,6 +90,11 @@ def assignments_by_channel(self) -> Dict[ChannelName, Tuple[int, ...]]: """The per-frame stem ids each channel carries, keyed by channel.""" return {item.channel_name: item.stem_ids for item in self.assignments} + @cached_property + def assignment_records(self) -> Dict[ChannelName, ChannelAssignment]: + """The record naming the owner of each frame, keyed by channel.""" + return {item.channel_name: item for item in self.assignments} + @cached_property def owners_by_channel(self) -> Dict[ChannelName, FrozenSet[int]]: """Who holds a frame on each channel: the recordings and the reader's own row, keyed by channel. diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py new file mode 100644 index 000000000..c49f84483 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass +from typing import FrozenSet, List, Mapping, Optional + +import numpy as np +from pydantic import BaseModel, ConfigDict + +from sampletones_core.audio.mixing import align, mix +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 + + +class Hearing(BaseModel): + """What a reader hears of a document: the recordings heard on each channel, and the channels kept on. + + The two choices compose. The recordings heard filter each channel's frames, and the channels + kept on choose which of the filtered channels play and are drawn. Both are the reader's + listening state, so one hearing names one reading of a document wherever the reading is taken. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + selection: StemSelection + channels_on: FrozenSet[ChannelName] + + def filter_for( + self, + channel: ChannelName, + stems_data: StemsData, + ) -> Optional[FrozenSet[int]]: + """The owners of ``channel`` the reader hears, where an owner is left out. + + A channel every owner of which is heard is heard whole, which ``None`` names, so two hearings + leaving nothing out on a channel read it the same way whatever else they hear. Recordings + heard that hold no frame on the channel change nothing there and are left out of the answer. + + Args: + channel: The channel read. + stems_data: The record naming the owner of each frame. + + Returns: + Optional[FrozenSet[int]]: The owners heard, or ``None`` where every owner is heard. + """ + owners = stems_data.owners_by_channel.get(channel, frozenset()) + heard = self.selection.stems_for(channel) & owners + return None if heard == owners else heard + + +@dataclass(frozen=True) +class HeardRenders: + """The audio a hearing of a document sounds. + + Attributes: + mix: Every channel in play mixed, the frames of the recordings left out silenced. + playing: The channels kept on mixed the same way, which is what plays and is drawn. + """ + + mix: np.ndarray + playing: np.ndarray + + +def heard_renders( + reconstruction: Reconstruction, + hearing: Hearing, + channels: Mapping[ChannelName, np.ndarray], + mixed: np.ndarray, +) -> HeardRenders: + """What ``hearing`` sounds of ``reconstruction``, read from its channels' renders and their mix. + + A hearing leaving no recording out sounds ``mixed`` itself, and one keeping every channel on + plays what it mixes, so a reading that filters nothing hands the arrays it was given back by + identity. A narrower choice of channels is summed in channel order, so the same choice reads the + same samples whatever order it was made in. + + Args: + reconstruction: The document read. + hearing: What the reader hears of it. + channels: The render of every channel in play, by channel. + mixed: The mix of ``channels``. + + Returns: + HeardRenders: The heard mix and what plays. + """ + approximations = filter_approximations( + reconstruction.stems_data, + channels, + hearing.selection, + reconstruction.config.frame_length, + ) + heard_whole = all(filtered is channels[name] for name, filtered in approximations.items()) + heard_mix = mixed if heard_whole else mix(list(approximations.values())) + playing: List[np.ndarray] = [ + approximations[name] for name in ChannelName.items() if name in hearing.channels_on and name in approximations + ] + if len(playing) == len(approximations): + return HeardRenders(mix=heard_mix, playing=heard_mix) + + if not playing: + return HeardRenders(mix=heard_mix, playing=np.zeros_like(heard_mix)) + + return HeardRenders(mix=heard_mix, playing=mix(align(playing, len(heard_mix)))) diff --git a/tests/benchmarks/reconstruction/test_edit_landing.py b/tests/benchmarks/reconstruction/test_edit_landing.py index 92e054e6a..855a6908f 100644 --- a/tests/benchmarks/reconstruction/test_edit_landing.py +++ b/tests/benchmarks/reconstruction/test_edit_landing.py @@ -26,7 +26,7 @@ from sampletones_application.logic.reconstruction.rewrites.queue import ( ReconstructionRewrites, ) -from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange, ListeningChange from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -110,6 +110,7 @@ def __init__(self, scheduling: SchedulingBehavior) -> None: self.panel.on_stems_view_changed = _discard self.panel.on_ownership_changed = _discard self.panel.on_heard_changed = _discard + self.panel.on_renders_wanted = lambda: self.rewrites.request(ListeningChange()) self.coordinator = ReconstructionCoordinator( self.manager, MagicMock(), @@ -127,16 +128,17 @@ def __init__(self, scheduling: SchedulingBehavior) -> None: self.controller.on_project_replaced = self.history.reset self.controller.new() - def open_as_sample(self, document: Reconstruction) -> float: - """Adds ``document`` to the project and opens it on the tab, which renders every channel cold. + def open_as_sample(self, document: Reconstruction) -> Landing: + """Adds ``document`` to the project and opens it on the tab, which renders every channel on the worker. Returns: - float: The seconds the opening took. + Landing: The worker's seconds rendering the document cold, and the render thread's seconds + opening it and landing its sound. """ with self.history.transaction(HistoryAction.ADD_SAMPLE): sample = self.controller.add_sample(document, DOCUMENT_NAME) - return _timed( + return self._landed( lambda: self.manager.load_reconstruction_object( sample.reconstruction, name=DOCUMENT_NAME, @@ -144,13 +146,15 @@ def open_as_sample(self, document: Reconstruction) -> float: ) ) - def open_standalone(self, document: Reconstruction) -> float: - """Opens ``document`` on the tab as a file would be, its recordings read, which renders every channel cold. + def open_standalone(self, document: Reconstruction) -> Landing: + """Opens ``document`` on the tab as a file would be, its recordings read, which renders every channel on + the worker. Returns: - float: The seconds the opening took. + Landing: The worker's seconds rendering the document cold, and the render thread's seconds + opening it and landing its sound. """ - return _timed( + return self._landed( lambda: self.manager.load_reconstruction_object( document, name=DOCUMENT_NAME, @@ -158,9 +162,11 @@ def open_standalone(self, document: Reconstruction) -> float: ) ) - def mute(self, stem_id: int) -> None: - """Leaves one recording out of what the reader hears, the way a box on the stems card does.""" - self.panel.set_stem_channels(stem_id, NO_CHANNELS) + def mute(self, stem_id: int) -> Landing: + """Leaves one recording out of what the reader hears, the way a box on the stems card does, and lands + the sound of what is heard. + """ + return self._landed(lambda: self.panel.set_stem_channels(stem_id, NO_CHANNELS)) def edit(self, step: int) -> Landing: """Moves the edited channel's volume by ``step`` and lands the rebuild, reading what each side cost.""" @@ -176,13 +182,19 @@ def edit(self, step: int) -> Landing: envelopes={FeatureKey.VOLUME: moved}, initial_pitch=None, ) + return self._landed(lambda: self.rewrites.request(change)) + + def _landed(self, gesture: Callable[[], None]) -> Landing: + """Makes ``gesture``, waits for the worker it started, and drains what it reported, reading what each side + cost: the render thread's seconds cover the gesture and the landing. + """ queue = HeldQueue() with patch.object(CallbackQueue, "add", queue.add): - self.rewrites.request(change) + gesture_seconds = _timed(gesture) worker_seconds = _timed(SingleThreadExecutor.join_all) landing_seconds = _timed(queue.drain) - return Landing(worker_seconds=worker_seconds, landing_seconds=landing_seconds) + return Landing(worker_seconds=worker_seconds, landing_seconds=gesture_seconds + landing_seconds) def _tab(self) -> MagicMock: tab = MagicMock(spec=ReconstructionTabCoordinator) @@ -202,7 +214,7 @@ def _write_back(self, edit: ReconstructionEdit) -> None: def _report( label: str, - opening: float, + opening: Landing, landings: List[Landing], cache_bytes: int, ) -> None: @@ -210,7 +222,8 @@ def _report( landed = [landing.landing_seconds for landing in landings] print( f"\n{label}\n" - f" opening, every channel rendered cold: {opening:.3f} s\n" + f" opening, every channel rendered cold: worker {opening.worker_seconds:.3f} s, " + f"render thread {opening.landing_seconds:.3f} s\n" f" per edit, median of {len(landings)} (least): worker {median(workers):.3f} s ({min(workers):.3f} s), " f"landing on the render thread {median(landed):.3f} s ({min(landed):.3f} s)\n" f" renders kept: {cache_bytes / BYTES_PER_MEGABYTE:.0f} MB" diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 6be2664f2..43e95d68b 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -6,6 +6,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import mix, mix_scale, read_stems, scale_stems, write_wave from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MAX_DRIVE, MIN_DRIVE, RESTING_STEM_ID, UNIT_DRIVE @@ -19,6 +20,8 @@ from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.converter import GroupConversion, reconstruct_job from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +from sampletones_core.reconstructions.reconstruction.stems.filter import filter_approximations +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -27,6 +30,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.utils.progress import silent_reporter from sampletones_tools.corpus.catalog import build_mini_library +from tests.suite.renders import warmed from tests.suite.stems import ( STEM_A_ID, STEM_B_ID, @@ -376,44 +380,52 @@ def heard(selected: AbstractSet[int]) -> StemSelection: """The selection hearing the named stems on every channel.""" return StemSelection.everywhere(frozenset(selected), channels) - full = data.waveform_data(renders, heard(all_stem_ids)) + def hearing(selected: AbstractSet[int]) -> Hearing: + """Hearing the named stems on every channel, every channel kept on.""" + return Hearing(selection=heard(selected), channels_on=frozenset(channels)) + + def kept(selected: AbstractSet[int]) -> WaveformData: + """The reading of the loaded document once the worker's sound for it is kept.""" + warmed(renders, data.reconstruction, hearing(selected)) + waveform = data.waveform_data(renders, hearing(selected)) + assert waveform is not None + return waveform + + def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: + return filter_approximations( + stems_data, + rendered_channels(data.reconstruction), + heard(selected), + frame_length, + ) + + full = kept(all_stem_ids) assert full.approximation is renders.mix(data.reconstruction) + assert full.playing is full.approximation assert full.original_audio is data.original_audio - np.testing.assert_allclose( - data.partials_for(renders, channels, heard(all_stem_ids)), - rendered_mix(data.reconstruction), - atol=_MIX_TOLERANCE, - ) for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID): selected = frozenset({selected_id}) expected = masked_channels(selected) - waveform = data.waveform_data(renders, heard(selected)) + waveform = kept(selected) for channel, expected_audio in expected.items(): - np.testing.assert_array_equal(waveform.approximations[channel], expected_audio) + np.testing.assert_array_equal(filtered(selected)[channel], expected_audio) np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE) - np.testing.assert_allclose( - data.partials_for(renders, channels, heard(selected)), - mix(list(expected.values())), - atol=_MIX_TOLERANCE, - ) + np.testing.assert_allclose(waveform.playing, mix(list(expected.values())), atol=_MIX_TOLERANCE) np.testing.assert_allclose( data.original_mix_for(heard(selected)), data.stem_audios[selected_id], atol=_MIX_TOLERANCE ) selected = frozenset() - waveform = data.waveform_data(renders, heard(selected)) + waveform = kept(selected) for channel in stems_data.assignments_by_channel: np.testing.assert_array_equal( - waveform.approximations[channel], + filtered(selected)[channel], np.zeros_like(rendered_channels(data.reconstruction)[channel]), ) np.testing.assert_allclose(waveform.approximation, np.zeros_like(rendered_mix(data.reconstruction))) - np.testing.assert_allclose( - data.partials_for(renders, channels, heard(selected)), - np.zeros_like(rendered_mix(data.reconstruction)), - ) + np.testing.assert_allclose(waveform.playing, np.zeros_like(rendered_mix(data.reconstruction))) np.testing.assert_allclose( data.original_mix_for(heard(selected)), np.zeros_like(rendered_mix(data.reconstruction)), @@ -861,10 +873,14 @@ def test_soloing_a_stem_sounds_nothing_outside_its_span(self, tmp_path: Path, re assignments = reconstruction.stems_data.assignments_by_channel for stem_id, span in enumerate(spans): selection = StemSelection.everywhere(frozenset({stem_id}), list(assignments)) - heard = ReconstructionData.from_reconstruction(reconstruction, name="disjoint").waveform_data( - renders, selection + hearing = Hearing(selection=selection, channels_on=frozenset(assignments)) + heard = heard_renders( + reconstruction, + hearing, + rendered_channels(reconstruction), + rendered_mix(reconstruction), ) - outside = np.array(heard.approximation, copy=True) + outside = np.array(heard.mix, copy=True) for frame in span: outside[frame * frame_length : (frame + 1) * frame_length] = 0.0 diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index c6c136889..2d2540377 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -7,6 +7,7 @@ from automation.scenario import BaseTestScenario, ScenarioStep from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +from sampletones_application.services.regeneration.jobs import RebuildJob from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -18,9 +19,15 @@ from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing from tests.suite.stems import everything_heard +def _heard_whole(reconstruction: Reconstruction) -> Hearing: + """Every recording heard on every channel, and every channel kept on.""" + return Hearing(selection=everything_heard(reconstruction), channels_on=frozenset(ChannelName.items())) + + def _heard_features(reconstruction: Reconstruction) -> ChannelEnvelopesViewModel: """The envelopes of the whole document, which is what a fresh reader hears.""" return heard_envelopes(reconstruction, everything_heard(reconstruction)) @@ -53,11 +60,13 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> service.subscribe(results.append) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) assert len(results) == 1 @@ -69,11 +78,13 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat service.subscribe(results.append) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) emitted = results[0].value @@ -89,11 +100,13 @@ def test_the_result_sounds_what_the_core_renders(self, reconstruction_data, puls service.subscribe(results.append) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) outcome = results[0].value @@ -105,11 +118,13 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul service = RegenerationService() service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) approximation = rendered_channels(reconstruction_data.reconstruction).get( @@ -121,11 +136,13 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls service = RegenerationService() service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1) @@ -142,11 +159,13 @@ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_da service.subscribe(results.append) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - silenced, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + silenced, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) assert isinstance(results[0], ServiceSuccess) @@ -160,11 +179,13 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da service.subscribe(results.append) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - {}, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + {}, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) assert len(results) == 1 @@ -179,7 +200,7 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ reconstruction_data.reconstruction, ChannelName.PULSE1, pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, + hearing=_heard_whole(reconstruction_data.reconstruction), kept={}, ) @@ -209,11 +230,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: Envelope(items=tuple(int(offset) for offset in arpeggio)), ) service._run( - context.reconstruction, - ChannelName.PULSE1, - edited, - context.reconstruction.recorded_stem_ids, - {}, + RebuildJob(context.reconstruction, ChannelName.PULSE1, edited, _heard_whole(context.reconstruction), {}) ) assert len(results) == 1 @@ -330,11 +347,13 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio # The real synthesis emits its ServiceSuccess onto the real queue, due at the current frame. service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) # The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails @@ -361,11 +380,13 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, new_volume = np.zeros(len(pulse_features.volume.items), dtype=np.int8) service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, - reconstruction_data.reconstruction.recorded_stem_ids, - {}, + RebuildJob( + reconstruction_data.reconstruction, + ChannelName.PULSE1, + pulse_features, + _heard_whole(reconstruction_data.reconstruction), + {}, + ) ) for _ in range(DELIVERY_BUDGET_FRAMES): diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index bf8c624e2..20ce15772 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -6,10 +6,14 @@ from automation.application.startup import Startup from automation.holds.regeneration import RegenerationHold from automation.screen import Screen -from automation.steps.reconstructions import expect_open, leave_letting_it_go +from automation.steps.reconstructions import ( + expect_open, + leave_letting_it_go, + reconstruction_shade, + wait_for_the_fade_to_end, +) from automation.views.bar_graph import BarGraph from automation.views.instruments import Instruments -from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.readings import frame_readings, keep_readings @@ -20,7 +24,6 @@ LOUD: Final[float] = 13.0 NEAR_THE_START: Final[float] = 0.05 ZOOM_NOTCHES: Final[int] = 5 -DRAIN_TIMEOUT_SECONDS: Final[float] = 120.0 FRAMES_AFTER_THE_LANDING: Final[int] = 3 BURST_READINGS: Final[str] = "frame_gaps.json" HELD_BURST_READINGS: Final[str] = "frame_gaps_held.json" @@ -35,22 +38,6 @@ def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> No graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) -def _reconstruction_shade(screen: Screen) -> Optional[str]: - """The theme the waveform's reconstruction line wears, which the fade swaps while an edit is on its way.""" - waveform = screen.reconstructions.waveform - return screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))) - - -def _wait_for_the_fade_to_end(screen: Screen, unfaded: Optional[str]) -> None: - """Waits until the reconstruction line wears ``unfaded`` again, which is when the last edit has landed.""" - screen.bridge.expect( - lambda: _reconstruction_shade(screen), - unfaded.__eq__, - description="the fade ended", - timeout=DRAIN_TIMEOUT_SECONDS, - ) - - def _drag_the_bars(instruments: Instruments, graph: BarGraph, releases: List[float]) -> None: """Drags each of :data:`DRAGGED_ITEMS` once, quiet and loud in turn, noting the clock at each release.""" standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) @@ -91,12 +78,12 @@ def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: def open_and_zoom_in(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) - unfaded.append(_reconstruction_shade(screen)) + unfaded.append(reconstruction_shade(screen)) def drag_until_the_fade_ends(screen: Screen) -> None: with screen.record(time.monotonic) as recording: _drag_the_bars(instruments, graph, releases) - _wait_for_the_fade_to_end(screen, unfaded[0]) + wait_for_the_fade_to_end(screen, unfaded[0]) stamps.extend(recording.values()) @@ -124,7 +111,7 @@ def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold def open_and_zoom_in(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) - unfaded.append(_reconstruction_shade(screen)) + unfaded.append(reconstruction_shade(screen)) def drag_while_held(screen: Screen) -> None: with screen.record(time.monotonic) as recording: @@ -138,7 +125,7 @@ def keep_the_readings(screen: Screen) -> None: def release_and_wait(screen: Screen) -> None: regeneration_hold.release() - _wait_for_the_fade_to_end(screen, unfaded[0]) + wait_for_the_fade_to_end(screen, unfaded[0]) screen.scenario( open_and_zoom_in, @@ -167,17 +154,17 @@ def test_the_frames_through_one_landing(self, screen: Screen, regeneration_hold: def drag_one_bar_while_held(screen: Screen) -> None: _zoom_in_on(screen, instruments, graph) - unfaded.append(_reconstruction_shade(screen)) + unfaded.append(reconstruction_shade(screen)) standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) graph.drag_item(DRAGGED_ITEMS[0], start=standing[DRAGGED_ITEMS[0]], end=QUIET) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") - assert _reconstruction_shade(screen) != unfaded[0] + assert reconstruction_shade(screen) != unfaded[0] def release_and_record_the_landing(screen: Screen) -> None: with screen.record(time.monotonic) as recording: regeneration_hold.release() released.append(time.monotonic()) - _wait_for_the_fade_to_end(screen, unfaded[0]) + wait_for_the_fade_to_end(screen, unfaded[0]) screen.frames(FRAMES_AFTER_THE_LANDING) stamps.extend(recording.values()) diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index 478cd4450..fb44ca97f 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -1,12 +1,12 @@ import time -from typing import Dict, Final, List +from typing import Dict, Final, List, Optional import pytest from automation.application.startup import Startup from automation.screen import Screen from automation.steps.exports import leaving_asks_nothing -from automation.steps.reconstructions import expect_open +from automation.steps.reconstructions import expect_open, reconstruction_shade, wait_for_the_fade_to_end from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each @@ -36,10 +36,11 @@ class TestTheBoxesOnALongDocument: stems card are ticked off and on again on a two-minute document. A box changes what is heard, so the waveform, the audio and the figures are read again from the - document. The scenario records the clock after every frame through the ticks and keeps the - longest gap after each tick beside the readings the drag scenarios keep, holding the application - to nothing: the readings are what a change to the listening path is judged by, taken before and - after the change. + document, and a sound the screen holds none of yet is rendered on the worker while the frames + keep coming. The scenario records the clock after every frame through the ticks, each followed + by the fade's end, and keeps the longest gap after each tick beside the readings the drag + scenarios keep, holding the application to nothing: the readings are what a change to the + listening path is judged by, taken before and after the change. """ def test_the_frames_through_the_ticks(self, screen: Screen) -> None: @@ -47,20 +48,24 @@ def test_the_frames_through_the_ticks(self, screen: Screen) -> None: reconstructions = screen.reconstructions stamps: List[float] = [] ticks: List[float] = [] + unfaded: List[Optional[str]] = [] def open_it(screen: Screen) -> None: expect_open(screen, LONG_RECONSTRUCTION) + unfaded.append(reconstruction_shade(screen)) def tick_the_boxes(screen: Screen) -> None: with screen.record(time.monotonic) as recording: for _ in range(TICKS_PER_BOX): ticks.append(time.monotonic()) reconstructions.tick_channel(TOGGLED_CHANNEL) + wait_for_the_fade_to_end(screen, unfaded[0]) screen.frames(FRAMES_AFTER_A_TICK) for _ in range(TICKS_PER_BOX): ticks.append(time.monotonic()) reconstructions.stems.tick(RECORDING_KEY) + wait_for_the_fade_to_end(screen, unfaded[0]) screen.frames(FRAMES_AFTER_A_TICK) stamps.extend(recording.values()) diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py index c89a54525..0c38d4554 100644 --- a/tests/suite/regeneration.py +++ b/tests/suite/regeneration.py @@ -1,11 +1,11 @@ -from dataclasses import dataclass -from typing import AbstractSet, Callable, List, Mapping, Tuple +from typing import Callable, List, Mapping, Tuple import numpy as np +from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob, RenderJob from sampletones_application.services.regeneration.result import ( - RegeneratedInstrument, RegenerationResult, + RenderedDocument, ) from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess @@ -13,30 +13,20 @@ from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix - - -@dataclass(frozen=True) -class HeldRebuild: - """One rebuild the regeneration was asked for, as it was asked.""" - - reconstruction: Reconstruction - channel_name: ChannelName - features: Features - heard: AbstractSet[int] - kept: Mapping[ChannelName, np.ndarray] +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders class HeldRegeneration: - """The regeneration service holding each rebuild it is asked for until a case lets it finish. + """The regeneration service holding each job it is asked for until a case lets it finish. - A rebuild runs on a worker thread in the application, so its result arrives some time after it + A job runs on a worker thread in the application, so its result arrives some time after it was asked for. Holding it lets a case put gestures in between, and finishing it runs the real - rebuild. + job. """ def __init__(self) -> None: self._service = RegenerationService() - self._held: List[HeldRebuild] = [] + self._held: List[RenderJob] = [] def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: self._service.subscribe(handler) @@ -46,42 +36,50 @@ def start( reconstruction: Reconstruction, channel_name: ChannelName, features: Features, - heard: AbstractSet[int], *, + hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], ) -> None: - self._held.append(HeldRebuild(reconstruction, channel_name, features, heard, kept)) + self._held.append(RebuildJob(reconstruction, channel_name, features, hearing, kept)) + + def start_listening( + self, + reconstruction: Reconstruction, + *, + hearing: Hearing, + kept: Mapping[ChannelName, np.ndarray], + ) -> None: + self._held.append(ListeningJob(reconstruction, hearing, kept)) @property - def held(self) -> Tuple[HeldRebuild, ...]: - """The rebuilds asked for and not yet finished, the earliest first.""" + def held(self) -> Tuple[RenderJob, ...]: + """The jobs asked for and not yet finished, the earliest first.""" return tuple(self._held) def finish(self) -> None: - """Runs the earliest held rebuild and delivers what it produced.""" - rebuild = self._held.pop(0) - self._service._run( - rebuild.reconstruction, - rebuild.channel_name, - rebuild.features, - rebuild.heard, - rebuild.kept, - ) + """Runs the earliest held job and delivers what it produced.""" + self._service._run(self._held.pop(0)) def finish_with(self, reconstruction: Reconstruction) -> None: - """Delivers ``reconstruction``, rendered afresh, as what the earliest held rebuild produced.""" - self._held.pop(0) + """Delivers ``reconstruction``, rendered afresh for the hearing the earliest held job was asked with, as + what that job produced. + """ + job = self._held.pop(0) + channels = rendered_channels(reconstruction) + mixed = rendered_mix(reconstruction) self._service._emit( ServiceSuccess( - value=RegeneratedInstrument( + value=RenderedDocument( reconstruction=reconstruction, - channels=rendered_channels(reconstruction), - mix=rendered_mix(reconstruction), + channels=channels, + mix=mixed, + hearing=job.hearing, + heard=heard_renders(reconstruction, job.hearing, channels, mixed), ) ) ) def fail(self, exception: Exception) -> None: - """Delivers ``exception`` as the earliest held rebuild's failure.""" + """Delivers ``exception`` as the earliest held job's failure.""" self._held.pop(0) self._service._emit(ServiceError(exception=exception)) diff --git a/tests/suite/renders.py b/tests/suite/renders.py new file mode 100644 index 000000000..2a7ce4dbb --- /dev/null +++ b/tests/suite/renders.py @@ -0,0 +1,16 @@ +from sampletones_application.logic.shared.renders import RenderCache +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 + + +def warmed(renders: RenderCache, reconstruction: Reconstruction, hearing: Hearing) -> None: + """Keeps in ``renders`` what the worker would hand back for ``hearing`` of ``reconstruction``. + + A case driving the panel logic alone has no worker to render what the reader hears, so it + keeps the renders the way a landing does, and the panel reads them as it reads any kept sound. + """ + channels = rendered_channels(reconstruction) + mixed = rendered_mix(reconstruction) + renders.adopt(reconstruction, channels, mixed) + renders.adopt_heard(reconstruction, hearing, heard_renders(reconstruction, hearing, channels, mixed)) 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 f85a1a1ff..f31a14682 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -15,12 +15,15 @@ from sampletones_application.logic.reconstruction.rewrites.steps import ( AfterEdits, ChannelChange, + ListeningChange, RateChange, StemRemovalRequest, ) +from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing from tests.conftest import ReconstructionFactory from tests.suite.regeneration import HeldRegeneration from tests.suite.stems import ( @@ -28,6 +31,7 @@ SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, + everything_heard, taking_turns, ) @@ -61,6 +65,7 @@ class Outcome: """Everything the rewrites reported, in the order they reported it.""" edits: List[ReconstructionEdit] = field(default_factory=list) + heard: int = 0 dropped: int = 0 failures: List[Exception] = field(default_factory=list) busy: List[bool] = field(default_factory=list) @@ -93,8 +98,12 @@ def adopt(edit: ReconstructionEdit) -> None: def drop() -> None: outcome.dropped += 1 + def heard() -> None: + outcome.heard += 1 + rewrites = ReconstructionRewrites(reconstruction_manager, regeneration) rewrites.on_edit = adopt + rewrites.on_heard_rendered = heard rewrites.on_dropped = drop rewrites.on_failed = outcome.failures.append rewrites.on_busy_changed = outcome.busy.append @@ -216,7 +225,7 @@ def test_the_recordings_heard_are_read_at_the_changes_turn( regeneration.finish() (shared,) = regeneration.held - assert shared.heard == frozenset({STEM_A_ID}) + assert shared.hearing.selection.stems_for(SHARED_CHANNEL) == frozenset({STEM_A_ID}) class TestAStepThatNoLongerApplies: @@ -344,9 +353,15 @@ def test_a_result_with_no_rebuild_running_is_refused( ) -> None: stray = HeldRegeneration() stray.subscribe(rewrites._on_result) - stray.start(taking_turns, SHARED_CHANNEL, taking_turns.export()[SHARED_CHANNEL], frozenset(), kept={}) - - with pytest.raises(RuntimeError, match="no rebuild was running"): + stray.start( + taking_turns, + SHARED_CHANNEL, + taking_turns.export()[SHARED_CHANNEL], + hearing=Hearing(selection=everything_heard(taking_turns), channels_on=frozenset()), + kept={}, + ) + + with pytest.raises(RuntimeError, match="no job was running"): stray.finish() @@ -585,3 +600,145 @@ def test_a_rebuild_landing_nowhere_leaves_no_render( regeneration.finish() assert reconstruction_manager.renders.held_bytes == 0 + + +class TestAListening: + """A change to what is heard is a step of the line: it reads the document at its turn, one waiting stands + for every one asked for after it, and its sound lands only on the document it was read from. + """ + + def test_the_sound_lands_and_is_reported( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_A_ID, frozenset()) + rewrites.request(ListeningChange()) + + regeneration.finish() + + hearing = reconstruction_manager.listening.hearing + document = reconstruction_manager.reconstruction + assert document is not None + assert outcome.heard == 1 + assert outcome.edits == [] + assert reconstruction_manager.renders.reading(document, hearing) is not None + + def test_the_hearing_is_read_at_the_steps_turn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(ListeningChange()) + reconstruction_manager.listening.set_channels(STEM_A_ID, frozenset()) + + regeneration.finish() + + job = regeneration.held[0] + assert isinstance(job, ListeningJob) + assert job.hearing == reconstruction_manager.listening.hearing + assert job.reconstruction is reconstruction_manager.reconstruction + + def test_one_listening_waiting_stands_for_every_one_asked( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + rewrites.request(ListeningChange()) + rewrites.request(ListeningChange()) + regeneration.finish() + + assert [type(job) for job in regeneration.held] == [ListeningJob] + + def test_a_listening_asked_while_one_runs_waits_its_turn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(ListeningChange()) + + rewrites.request(ListeningChange()) + + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert [type(job) for job in regeneration.held] == [ListeningJob] + + def test_a_rebuild_carries_the_hearing_it_was_started_with( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + job = regeneration.held[0] + assert isinstance(job, RebuildJob) + assert job.hearing == reconstruction_manager.listening.hearing + + def test_a_landed_rebuild_leaves_its_hearing_kept( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + regeneration.finish() + + landed = reconstruction_manager.reconstruction + assert landed is not None + assert reconstruction_manager.renders.reading(landed, reconstruction_manager.listening.hearing) is not None + + def test_a_listening_for_a_document_put_away_lands_nowhere( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + rewrites.request(ListeningChange()) + rewrites.drop() + + regeneration.finish() + + assert outcome.heard == 0 + assert outcome.dropped == 0 + assert reconstruction_manager.renders.held_bytes == 0 + + def test_a_failed_listening_is_reported_and_the_next_step_runs( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + failure = RuntimeError("the worker fell over") + rewrites.request(ListeningChange()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + regeneration.fail(failure) + + assert outcome.failures == [failure] + assert outcome.dropped == 0 + assert [type(job) for job in regeneration.held] == [RebuildJob] + + def test_a_listening_fills_the_line( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(ListeningChange()) + assert outcome.busy == [True] + + regeneration.finish() + + assert outcome.busy == [True, False] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index d21ba7ca1..0922401e6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -1,10 +1,11 @@ from pathlib import Path -from typing import Callable +from typing import Callable, Final, FrozenSet import numpy as np from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, bending_channels @@ -13,6 +14,7 @@ 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 from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -20,12 +22,28 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) + def _heard(*stem_ids: int) -> StemSelection: """The selection hearing every named stem on every channel.""" return StemSelection.everywhere(frozenset(stem_ids), ChannelName.items()) +def _hearing(*stem_ids: int, channels_on: FrozenSet[ChannelName] = EVERY_CHANNEL) -> Hearing: + """Hearing every named stem on every channel, with ``channels_on`` kept on.""" + return Hearing(selection=_heard(*stem_ids), channels_on=channels_on) + + +def _kept(renders: RenderCache, data: ReconstructionData, hearing: Hearing) -> WaveformData: + """The waveform data read once the sound of ``hearing`` is kept, the way a landing keeps it.""" + warmed(renders, data.reconstruction, hearing) + waveform_data = data.waveform_data(renders, hearing) + assert waveform_data is not None + return waveform_data + + +from tests.suite.renders import warmed from tests.suite.stems import RECORDED_SCALE, recorded_from @@ -345,11 +363,11 @@ def test_partials_keep_only_the_selected_stems_frames( expected = rendered_mix(data.reconstruction).copy() expected[frame_length:] = 0 - partials = data.partials_for(renders, [ChannelName.PULSE1], _heard(0)) + partials = data.audio_for(renders, _hearing(0)) np.testing.assert_allclose(partials, expected) np.testing.assert_allclose( - data.partials_for(renders, [ChannelName.PULSE1], _heard(0, 1)), + data.audio_for(renders, _hearing(0, 1)), rendered_mix(data.reconstruction), ) @@ -413,7 +431,7 @@ def test_a_single_source_with_no_selection_is_silence( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") np.testing.assert_array_equal( - data.partials_for(renders, [ChannelName.PULSE1], _heard()), + data.audio_for(renders, _hearing()), np.zeros_like(rendered_mix(data.reconstruction)), ) assert data.original_audio is not None @@ -452,7 +470,7 @@ def test_the_heard_waveform_carries_no_original_line( ) -> None: data = self._missing_a_recording(reconstruction_factory, tmp_path) - assert data.waveform_data(renders, _heard(0, 1)).original_audio is None + assert _kept(renders, data, _hearing(0, 1)).original_audio is None def test_a_detached_document_mixes_into_no_original( self, @@ -479,7 +497,7 @@ def test_a_loaded_recording_heard_nowhere_is_a_silent_line( name="Sample", ) - original = data.waveform_data(renders, _heard()).original_audio + original = _kept(renders, data, _hearing()).original_audio assert original is not None np.testing.assert_array_equal(original, np.zeros_like(rendered_mix(data.reconstruction))) @@ -626,33 +644,25 @@ def test_projects_the_render_relevant_fields( name="Sample", ) - waveform_data = data.waveform_data(renders, _heard(0)) + waveform_data = _kept(renders, data, _hearing(0)) assert waveform_data.original_audio is data.original_audio assert waveform_data.approximation is renders.mix(reconstruction) - assert waveform_data.approximations.keys() == renders.channels(reconstruction).keys() - assert all( - waveform_data.approximations[channel_name] is kept - for channel_name, kept in renders.channels(reconstruction).items() - ) + assert waveform_data.playing is renders.mix(reconstruction) assert waveform_data.coefficient == reconstruction.coefficient assert waveform_data.frame_length == reconstruction.config.frame_length - def test_a_document_heard_whole_reads_what_is_kept( + def test_a_hearing_the_cache_keeps_no_sound_for_reads_nothing( self, reconstruction_factory: Callable[[], Reconstruction], renders: RenderCache, ) -> None: - """A reading that filters nothing reads the kept renders, which is what makes it cost nothing.""" - reconstruction = reconstruction_factory() - data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - kept_mix = renders.mix(reconstruction) - - twice = [data.waveform_data(renders, _heard(0)) for _ in range(2)] + """The reading computes no sample, so one the cache cannot answer is left to the worker.""" + data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") - assert all(waveform_data.approximation is kept_mix for waveform_data in twice) + assert data.waveform_data(renders, _hearing(0)) is None - def test_a_recording_left_out_mixes_the_heard_frames_afresh( + def test_a_recording_left_out_reads_what_the_worker_kept( self, reconstruction_factory: Callable[[], Reconstruction], renders: RenderCache, @@ -660,7 +670,35 @@ def test_a_recording_left_out_mixes_the_heard_frames_afresh( reconstruction = reconstruction_factory() data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - waveform_data = data.waveform_data(renders, _heard()) + waveform_data = _kept(renders, data, _hearing()) assert waveform_data.approximation is not renders.mix(reconstruction) np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(renders.mix(reconstruction))) + np.testing.assert_array_equal(waveform_data.playing, waveform_data.approximation) + + +class TestTheAudioAnExportWrites: + """An export writes what plays, read from what is kept and rendered on the spot where nothing is.""" + + def test_what_is_kept_is_handed_back( + self, + reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, + ) -> None: + data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") + kept = _kept(renders, data, _hearing(0)) + + assert data.audio_for(renders, _hearing(0)) is kept.playing + + def test_what_is_not_kept_is_rendered( + self, + reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, + ) -> None: + data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") + + np.testing.assert_array_equal(data.audio_for(renders, _hearing(0)), rendered_mix(data.reconstruction)) + np.testing.assert_array_equal( + data.audio_for(renders, _hearing(0, channels_on=frozenset())), + np.zeros_like(rendered_mix(data.reconstruction)), + ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py index f381f8e3a..b7900f37f 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py @@ -1,8 +1,8 @@ -from typing import Final, List +from typing import Final, FrozenSet, List import pytest -from sampletones_application.logic.reconstruction.listening import StemListening, offered_channels +from sampletones_application.logic.reconstruction.listening import Listening, offered_channels from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, bending_channels from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -16,6 +16,8 @@ STEM_A: Final[int] = 0 STEM_B: Final[int] = 1 STEM_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) +PULSES: Final[FrozenSet[ChannelName]] = frozenset({ChannelName.PULSE1, ChannelName.PULSE2}) def _stems_data(*assignments: ChannelAssignment) -> StemsData: @@ -37,8 +39,8 @@ def _stems_data(*assignments: ChannelAssignment) -> StemsData: @pytest.fixture -def listening() -> StemListening: - return StemListening() +def listening() -> Listening: + return Listening() class TestWhatARecordingOffers: @@ -67,23 +69,23 @@ def test_the_frames_the_reader_wrote_offer_a_row_of_their_own(self) -> None: class TestWhatAFreshDocumentIsHeardAs: - def test_every_recording_is_heard_everywhere_it_holds_frames(self, listening: StemListening) -> None: + def test_every_recording_is_heard_everywhere_it_holds_frames(self, listening: Listening) -> 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]), ) - listening.adopt(stems_data) + listening.adopt(stems_data, EVERY_CHANNEL) assert listening.heard == listening.offered - def test_the_selection_answers_each_channel_with_the_recordings_on_it(self, listening: StemListening) -> None: + def test_the_selection_answers_each_channel_with_the_recordings_on_it(self, listening: Listening) -> 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]), ) - listening.adopt(stems_data) + listening.adopt(stems_data, EVERY_CHANNEL) assert listening.heard_on(ChannelName.PULSE1) == frozenset({STEM_A, STEM_B}) assert listening.heard_on(ChannelName.TRIANGLE) == frozenset({STEM_A}) @@ -99,50 +101,51 @@ def _held_on(*channels: ChannelName) -> StemsData: *(ChannelAssignment(channel_name=channel_name, stem_ids=[STEM_A, STEM_B]) for channel_name in channels) ) - def test_a_channel_a_recording_keeps_holding_keeps_the_readers_choice(self, listening: StemListening) -> None: - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + def test_a_channel_a_recording_keeps_holding_keeps_the_readers_choice(self, listening: Listening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1}) - def test_a_channel_a_recording_newly_reaches_joins_what_is_heard(self, listening: StemListening) -> None: - listening.adopt(self._held_on(ChannelName.PULSE1)) + def test_a_channel_a_recording_newly_reaches_joins_what_is_heard(self, listening: Listening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1), EVERY_CHANNEL) listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) - def test_a_channel_a_recording_stops_holding_leaves_what_is_heard(self, listening: StemListening) -> None: - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + def test_a_channel_a_recording_stops_holding_leaves_what_is_heard(self, listening: Listening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) - listening.adopt(self._held_on(ChannelName.PULSE1)) + listening.adopt(self._held_on(ChannelName.PULSE1), EVERY_CHANNEL) assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1}) - def test_a_recording_leaving_the_record_leaves_the_choice(self, listening: StemListening) -> None: + def test_a_recording_leaving_the_record_leaves_the_choice(self, listening: Listening) -> None: """An edit letting a recording go takes its row away, and the rest keep what the reader chose.""" - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) listening.adopt( _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_A])).without_entries( frozenset({STEM_B}) - ) + ), + EVERY_CHANNEL, ) assert listening.offered == {STEM_A: frozenset({ChannelName.PULSE1})} assert listening.heard == {STEM_A: frozenset({ChannelName.PULSE1})} assert STEM_B not in listening.selection.stems_for(ChannelName.PULSE1) - def test_a_released_choice_hears_the_next_document_whole(self, listening: StemListening) -> None: - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + def test_a_released_choice_hears_the_next_document_whole(self, listening: Listening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) listening.set_channels(STEM_A, frozenset()) listening.release() - listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE), EVERY_CHANNEL) assert listening.heard == listening.offered @@ -159,9 +162,9 @@ def _held() -> StemsData: def test_the_recording_is_heard_on_every_channel_it_offers_and_no_other_is_heard( self, - listening: StemListening, + listening: Listening, ) -> None: - listening.adopt(self._held()) + listening.adopt(self._held(), EVERY_CHANNEL) listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) listening.solo(STEM_A) @@ -169,8 +172,8 @@ def test_the_recording_is_heard_on_every_channel_it_offers_and_no_other_is_heard assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) assert listening.heard[STEM_B] == frozenset() - def test_a_second_solo_returns_to_the_choice_it_replaced(self, listening: StemListening) -> None: - listening.adopt(self._held()) + def test_a_second_solo_returns_to_the_choice_it_replaced(self, listening: Listening) -> None: + listening.adopt(self._held(), EVERY_CHANNEL) listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) before = listening.heard @@ -181,9 +184,9 @@ def test_a_second_solo_returns_to_the_choice_it_replaced(self, listening: StemLi def test_soloing_another_recording_keeps_the_choice_the_first_solo_replaced( self, - listening: StemListening, + listening: Listening, ) -> None: - listening.adopt(self._held()) + listening.adopt(self._held(), EVERY_CHANNEL) listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) before = listening.heard @@ -193,17 +196,17 @@ def test_soloing_another_recording_keeps_the_choice_the_first_solo_replaced( assert listening.heard == before - def test_a_solo_with_nothing_remembered_hears_every_recording_whole(self, listening: StemListening) -> None: - listening.adopt(self._held()) + def test_a_solo_with_nothing_remembered_hears_every_recording_whole(self, listening: Listening) -> None: + listening.adopt(self._held(), EVERY_CHANNEL) listening.solo(STEM_A) - listening.adopt(self._held()) + listening.adopt(self._held(), EVERY_CHANNEL) listening.solo(STEM_A) assert listening.heard == listening.offered - def test_a_choice_made_by_hand_stands_as_the_new_state(self, listening: StemListening) -> None: - listening.adopt(self._held()) + def test_a_choice_made_by_hand_stands_as_the_new_state(self, listening: Listening) -> None: + listening.adopt(self._held(), EVERY_CHANNEL) listening.solo(STEM_A) listening.set_channels(STEM_B, frozenset({ChannelName.PULSE1})) @@ -214,11 +217,75 @@ def test_a_choice_made_by_hand_stands_as_the_new_state(self, listening: StemList def test_a_recording_offering_nothing_leaves_the_others_heard( self, - listening: StemListening, + listening: Listening, ) -> None: - listening.adopt(_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A]))) + listening.adopt( + _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A])), EVERY_CHANNEL + ) listening.solo(STEM_B) listening.solo(STEM_B) assert listening.heard == listening.offered + + +class TestTheChannelsKeptOn: + """The boxes above the waveform are listening state: a fresh document plays every channel, a channel + switched off stays off across an edit, and one coming into play joins the channels on. + """ + + def test_a_fresh_document_keeps_every_playing_channel_on(self, listening: Listening) -> None: + listening.adopt(_stems_data(), PULSES) + + assert listening.playing == PULSES + assert listening.channels_on == PULSES + + def test_a_channel_switched_off_stays_off_across_an_edit(self, listening: Listening) -> None: + listening.adopt(_stems_data(), PULSES) + listening.set_channels_on(frozenset({ChannelName.PULSE1})) + + listening.adopt(_stems_data(), PULSES) + + assert listening.channels_on == frozenset({ChannelName.PULSE1}) + + def test_a_channel_coming_into_play_joins_the_channels_on(self, listening: Listening) -> None: + listening.adopt(_stems_data(), frozenset({ChannelName.PULSE1})) + listening.set_channels_on(frozenset()) + + listening.adopt(_stems_data(), PULSES) + + assert listening.channels_on == frozenset({ChannelName.PULSE2}) + + def test_a_channel_leaving_play_leaves_the_channels_on(self, listening: Listening) -> None: + listening.adopt(_stems_data(), PULSES) + + listening.adopt(_stems_data(), frozenset({ChannelName.PULSE1})) + + assert listening.channels_on == frozenset({ChannelName.PULSE1}) + + def test_a_choice_names_playing_channels_alone(self, listening: Listening) -> None: + listening.adopt(_stems_data(), PULSES) + + listening.set_channels_on(EVERY_CHANNEL) + + assert listening.channels_on == PULSES + + def test_the_hearing_carries_both_choices(self, listening: Listening) -> None: + listening.adopt( + _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B])), PULSES + ) + listening.set_channels(STEM_B, frozenset()) + listening.set_channels_on(frozenset({ChannelName.PULSE1})) + + hearing = listening.hearing + + assert hearing.selection.stems_for(ChannelName.PULSE1) == frozenset({STEM_A}) + assert hearing.channels_on == frozenset({ChannelName.PULSE1}) + + def test_a_released_choice_leaves_nothing_on(self, listening: Listening) -> None: + listening.adopt(_stems_data(), PULSES) + + listening.release() + + assert listening.playing == frozenset() + assert listening.channels_on == frozenset() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index db56a36ce..254a7b106 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -9,7 +9,7 @@ from sampletones_application.exports import ExportBackends from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.envelopes import heard_envelopes -from sampletones_application.logic.reconstruction.listening import StemListening +from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( ReconstructionPanelLogic, @@ -37,6 +37,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 from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -53,7 +54,8 @@ ) from tests.conftest import RENDER_BUDGET from tests.suite.case import BaseRegularTestCase -from tests.suite.stems import RECORDED_SCALE, recorded_from +from tests.suite.renders import warmed +from tests.suite.stems import RECORDED_SCALE, everything_heard, recorded_from NO_EXTENSION: Final[str] = "" RETUNED_A4_FREQUENCY: Final[float] = 432.0 @@ -94,17 +96,43 @@ def mock_reconstruction_manager() -> MagicMock: mock.current_reconstruction = None mock.is_project_sample = False mock.audio_filepath = None - mock.listening = StemListening() + mock.listening = Listening() mock.refresh_features.side_effect = lambda: _refresh_features(mock) mock.renders = RenderCache(budget_bytes=RENDER_BUDGET) return mock +def _heard_whole(reconstruction_data: ReconstructionData) -> Hearing: + """Every recording heard on every channel, and every playing channel kept on.""" + reconstruction = reconstruction_data.reconstruction + return Hearing( + selection=everything_heard(reconstruction), + channels_on=frozenset(reconstruction.playing_channels), + ) + + def _open(manager: MagicMock, reconstruction_data: ReconstructionData) -> None: - """Puts a document in front of the panel the way the manager's own adoption does.""" + """Puts a document in front of the panel the way the manager's own adoption does, its sound kept. + + The worker would render what the reader hears and a landing would keep it; here the sound of + the document heard whole is kept up front, so the panel reads it as it reads any kept sound. + """ + _rebind(manager, reconstruction_data) + + +def _rebind(manager: MagicMock, reconstruction_data: ReconstructionData) -> None: + """Puts an edited document in the open one's place the way the manager's own adoption does, its sound kept.""" manager.current_reconstruction = reconstruction_data - manager.listening.adopt(reconstruction_data.reconstruction.stems_data) + reconstruction = reconstruction_data.reconstruction + manager.listening.adopt(reconstruction.stems_data, frozenset(reconstruction.playing_channels)) _refresh_features(manager) + _hear(manager) + + +def _hear(manager: MagicMock) -> None: + """Keeps the sound of what the reader hears now, the way a listening's landing does.""" + reconstruction_data = manager.current_reconstruction + warmed(manager.renders, reconstruction_data.reconstruction, manager.listening.hearing) def _refresh_features(manager: MagicMock) -> None: @@ -431,14 +459,17 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform( ) -> None: _open(mock_reconstruction_manager, loaded_data) panel_logic.display_reconstruction() - mock_reconstruction_manager.current_reconstruction = loaded_data.with_reconstruction( - loaded_data.reconstruction.with_channel_data( - ChannelName.TRIANGLE, - [TriangleInstruction(on=True, pitch=48)], - 48, - (), - heard=loaded_data.reconstruction.recorded_stem_ids, - ) + _rebind( + mock_reconstruction_manager, + loaded_data.with_reconstruction( + loaded_data.reconstruction.with_channel_data( + ChannelName.TRIANGLE, + [TriangleInstruction(on=True, pitch=48)], + 48, + (), + heard=loaded_data.reconstruction.recorded_stem_ids, + ) + ), ) received = self._received(panel_logic) @@ -455,14 +486,17 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform( ) -> None: _open(mock_reconstruction_manager, loaded_data) panel_logic.display_reconstruction() - mock_reconstruction_manager.current_reconstruction = loaded_data.with_reconstruction( - loaded_data.reconstruction.with_channel_data( - ChannelName.PULSE1, - [], - 60, - (), - heard=loaded_data.reconstruction.recorded_stem_ids, - ) + _rebind( + mock_reconstruction_manager, + loaded_data.with_reconstruction( + loaded_data.reconstruction.with_channel_data( + ChannelName.PULSE1, + [], + 60, + (), + heard=loaded_data.reconstruction.recorded_stem_ids, + ) + ), ) received = self._received(panel_logic) @@ -568,9 +602,7 @@ class TestAReTimedDocumentIsShown: def _retime(manager: MagicMock, loaded_data: ReconstructionData) -> None: """Opens the document, then lets the stand-in manager adopt it re-timed, as a landed retune leaves it.""" _open(manager, loaded_data) - manager.current_reconstruction = loaded_data.with_reconstruction( - loaded_data.reconstruction.with_nes_frequency(PAL_FREQUENCY) - ) + _rebind(manager, loaded_data.with_reconstruction(loaded_data.reconstruction.with_nes_frequency(PAL_FREQUENCY))) def test_the_view_states_the_new_rate( self, @@ -595,7 +627,7 @@ def test_the_waveform_and_the_audio_follow_the_new_rate( self._retime(mock_reconstruction_manager, loaded_data) waveforms: List[WaveformData] = [] audio: List[Optional[AudioData]] = [] - panel_logic.on_waveform_update_changed = lambda waveform, _channels, **_kwargs: waveforms.append(waveform) + panel_logic.on_waveform_update_changed = lambda waveform, **_kwargs: waveforms.append(waveform) panel_logic.on_audio_data_changed = audio.append panel_logic.update_reconstruction(refit_waveform=True) @@ -612,7 +644,7 @@ def test_the_waveform_view_re_fits_to_the_retuned_length( """A retune moves the audio's own length, so the old view no longer answers to it.""" self._retime(mock_reconstruction_manager, loaded_data) refits: List[bool] = [] - panel_logic.on_waveform_update_changed = lambda _waveform, _channels, *, refit: refits.append(refit) + panel_logic.on_waveform_update_changed = lambda _waveform, *, refit: refits.append(refit) panel_logic.update_reconstruction(refit_waveform=True) @@ -728,7 +760,7 @@ def test_set_selected_generators_updates_selection( ) -> None: _open(mock_reconstruction_manager, loaded_data) panel_logic.set_selected_channels([ChannelName.PULSE1]) - assert panel_logic._selected_channels == [ChannelName.PULSE1] + assert mock_reconstruction_manager.listening.channels_on == frozenset({ChannelName.PULSE1}) def test_set_selected_generators_fires_waveform_load( self, @@ -800,13 +832,98 @@ def test_what_plays_is_the_reading_the_waveform_draws( _open(mock_reconstruction_manager, loaded_data) drawn: List[WaveformData] = [] played: List[Optional[AudioData]] = [] - panel_logic.on_waveform_load_changed = lambda waveform, channels: drawn.append(waveform) + panel_logic.on_waveform_load_changed = drawn.append panel_logic.on_audio_data_changed = played.append panel_logic.display_reconstruction() assert played[0] is not None - np.testing.assert_array_equal(played[0].sample, drawn[0].partials(panel_logic._selected_channels)) + np.testing.assert_array_equal(played[0].sample, drawn[0].playing) + + +class TestAShowingTheScreenHoldsNoSoundFor: + """A gesture whose sound the cache keeps none of asks the worker for it, draws nothing yet, and shows + what it owes once the sound lands. + """ + + def test_a_gesture_asks_once_and_draws_nothing_yet( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + asked = MagicMock() + drawn = MagicMock() + played = MagicMock() + panel_logic.on_renders_wanted = asked + panel_logic.on_waveform_load_changed = drawn + panel_logic.on_audio_data_changed = played + + panel_logic.set_selected_channels([]) + + asked.assert_called_once() + drawn.assert_not_called() + played.assert_not_called() + + def test_the_showing_happens_once_the_sound_lands( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + panel_logic.on_renders_wanted = MagicMock() + drawn: List[WaveformData] = [] + played: List[Optional[AudioData]] = [] + panel_logic.on_waveform_load_changed = drawn.append + panel_logic.on_audio_data_changed = played.append + panel_logic.set_selected_channels([]) + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + assert len(drawn) == 1 + assert played[0] is not None + np.testing.assert_array_equal(drawn[0].playing, np.zeros_like(drawn[0].approximation)) + np.testing.assert_array_equal(played[0].sample, drawn[0].playing) + + def test_nothing_owed_shows_nothing( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + drawn = MagicMock() + panel_logic.on_waveform_load_changed = drawn + panel_logic.on_waveform_update_changed = drawn + + panel_logic.redraw_listening() + + drawn.assert_not_called() + + def test_an_edit_owed_a_showing_updates_the_waveform_in_place( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """What was owed is what is shown: an edit's update keeps the view, a listening change loads afresh.""" + _open(mock_reconstruction_manager, loaded_data) + panel_logic.on_renders_wanted = MagicMock() + mock_reconstruction_manager.listening.set_channels_on(frozenset()) + updated = MagicMock() + loaded = MagicMock() + panel_logic.on_waveform_update_changed = updated + panel_logic.on_waveform_load_changed = loaded + panel_logic.update_reconstruction() + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + updated.assert_called_once() + loaded.assert_not_called() class TestReconstructionPanelLogicExportInstrument: @@ -1119,7 +1236,6 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate( tmp_path: Path, ) -> None: _open(mock_reconstruction_manager, loaded_data) - panel_logic._selected_channels = [ChannelName.PULSE1] panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") mock_export_service.export_wav.assert_called_once() call_args = mock_export_service.export_wav.call_args @@ -1282,21 +1398,20 @@ def test_silencing_a_recording_filters_waveform_and_playback( panel_logic.display_reconstruction() waveform_updates = [] audio_updates = [] - panel_logic.on_waveform_load_changed = lambda waveform, channels: waveform_updates.append(waveform) - panel_logic.on_audio_data_changed = lambda audio: audio_updates.append(audio) + panel_logic.on_renders_wanted = lambda: _hear(mock_reconstruction_manager) + panel_logic.on_waveform_load_changed = waveform_updates.append + panel_logic.on_audio_data_changed = audio_updates.append panel_logic.set_stem_channels(0, frozenset()) + panel_logic.redraw_listening() - expected = stems_data.partials_for( - renders, - panel_logic._selected_channels, - panel_logic._stem_selection, - ) + expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) assert len(waveform_updates) == 1 - np.testing.assert_allclose(waveform_updates[0].partials(panel_logic._selected_channels), expected) + np.testing.assert_allclose(waveform_updates[0].playing, expected) assert len(audio_updates) == 1 assert audio_updates[0] is not None np.testing.assert_allclose(audio_updates[0].sample, expected) + assert np.any(expected != stems_data.audio_for(renders, _heard_whole(stems_data))) def test_a_channel_switched_off_for_everything_mutes_its_column( self, @@ -1333,11 +1448,7 @@ def test_export_wav_uses_the_stems_filter( mock_export_service.export_wav.assert_called_once() exported_audio = mock_export_service.export_wav.call_args.args[2] - expected = stems_data.partials_for( - renders, - panel_logic._selected_channels, - panel_logic._stem_selection, - ) + expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) np.testing.assert_allclose(exported_audio, expected) diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index 7451ab834..f12300e93 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -1,23 +1,27 @@ import gc from pathlib import Path -from typing import Final, List, Tuple +from typing import Final, FrozenSet, List, Tuple import numpy as np import pytest from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache +from sampletones_core.audio.mixing import align from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import InstructionUnion, PulseInstruction 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.selection import StemSelection from tests.conftest import RENDER_BUDGET -from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns_reconstruction +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, taking_turns_reconstruction SOURCES: Final[Tuple[Path, Path]] = (Path("a.wav"), Path("b.wav")) EDITED: Final[PulseInstruction] = PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1) EDITED_PITCH: Final[int] = 67 +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) def _document() -> Reconstruction: @@ -177,3 +181,92 @@ def test_a_render_already_kept_stays_when_adopted_again( after = renders.channels(document) assert all(after[name] is first[name] for name in first) + + +def _hearing(stem_ids: FrozenSet[int], channels_on: FrozenSet[ChannelName] = EVERY_CHANNEL) -> Hearing: + return Hearing(selection=StemSelection.everywhere(stem_ids, ChannelName.items()), channels_on=channels_on) + + +def _heard(document: Reconstruction, hearing: Hearing): # type: ignore[no-untyped-def] + return heard_renders(document, hearing, rendered_channels(document), rendered_mix(document)) + + +class TestReadingAHearing: + """A hearing's reading is a lookup alone: the heard mix and what plays, or nothing where either is missing. + + A hearing leaving nothing out and keeping every channel on reads the document's own mix, so the + renders a landing adopts answer it. One leaving a recording out reads a mix keyed by the owners + heard on each channel, which the worker renders and the landing adopts under the same key. + """ + + def test_a_document_heard_whole_reads_its_own_mix(self, renders: RenderCache, document: Reconstruction) -> None: + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + + reading = renders.reading(document, _hearing(document.recorded_stem_ids)) + + assert reading is not None + assert reading.mix is renders.mix(document) + assert reading.playing is renders.mix(document) + + def test_a_recording_left_out_reads_nothing_until_its_sound_is_kept( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + hearing = _hearing(frozenset({STEM_A_ID})) + + assert renders.reading(document, hearing) is None + + renders.adopt_heard(document, hearing, _heard(document, hearing)) + + reading = renders.reading(document, hearing) + assert reading is not None + np.testing.assert_array_equal(reading.mix, _heard(document, hearing).mix) + + def test_a_narrower_channel_choice_reads_nothing_until_its_sound_is_kept( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + hearing = _hearing(document.recorded_stem_ids, frozenset({SOLE_CHANNEL})) + + assert renders.reading(document, hearing) is None + + renders.adopt_heard(document, hearing, _heard(document, hearing)) + + reading = renders.reading(document, hearing) + assert reading is not None + assert reading.mix is renders.mix(document) + np.testing.assert_array_equal( + reading.playing, + align([rendered_channels(document)[SOLE_CHANNEL]], len(reading.mix))[0], + ) + + def test_recordings_heard_beyond_a_channel_read_as_the_channel_heard_whole( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + """A heard set is keyed by the owners it reaches, so a stem absent from a channel changes its key nowhere.""" + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + beyond = _hearing(document.recorded_stem_ids | frozenset({STEM_B_ID + 10})) + + reading = renders.reading(document, beyond) + + assert reading is not None + assert reading.mix is renders.mix(document) + + def test_a_heard_mix_goes_with_the_record_it_was_keyed_by(self, renders: RenderCache) -> None: + document = _document() + hearing = _hearing(frozenset({STEM_A_ID})) + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + renders.adopt_heard(document, hearing, _heard(document, hearing)) + before = renders.held_bytes + + del document + gc.collect() + + assert renders.held_bytes < before + assert renders.held_bytes == 0 diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 159ffa654..8e2998b02 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -6,7 +6,8 @@ import pytest from sampletones_application.services.regeneration import service as service_module -from sampletones_application.services.regeneration.result import RegeneratedInstrument +from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob +from sampletones_application.services.regeneration.result import RenderedDocument from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ( ServiceError, @@ -21,8 +22,10 @@ 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.selection import StemSelection from tests.conftest import ReconstructionFactory -from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, everything_heard, taking_turns __all__ = ["taking_turns"] @@ -32,6 +35,11 @@ MockReconstruction: TypeAlias = MagicMock SynthesisMocks: TypeAlias = SimpleNamespace EVERY_STEM: Final[FrozenSet[int]] = frozenset({0}) +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) +HEARD_WHOLE: Final[Hearing] = Hearing( + selection=StemSelection.everywhere(EVERY_STEM, ChannelName.items()), + channels_on=EVERY_CHANNEL, +) ResultCallback: TypeAlias = Callable[[Any], None] @@ -97,7 +105,7 @@ def test_start_runs_the_rebuild_on_the_worker( reconstruction, synthesis_mocks.channel_name, features, - EVERY_STEM, + hearing=HEARD_WHOLE, kept=NO_RENDERS, ) @@ -117,7 +125,7 @@ def test_a_job_started_as_the_last_one_winds_down_still_runs( """ service = RegenerationService() with patch.object(service._executor, "execute") as execute: - service.start(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM, kept=NO_RENDERS) + service.start(reconstruction, synthesis_mocks.channel_name, features, hearing=HEARD_WHOLE, kept=NO_RENDERS) assert execute.call_args.kwargs == {"wait": True} @@ -133,13 +141,7 @@ def test_run_success_emits_service_success( results: List[Any] = [] service.subscribe(results.append) - service._run( - reconstruction, - synthesis_mocks.channel_name, - features, - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) assert len(results) == 1 assert isinstance(results[0], ServiceSuccess) @@ -156,7 +158,7 @@ def test_run_regenerates_from_the_envelopes_it_is_handed( """The caller writes the edit into the envelopes, so the service renders what it is given.""" service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM, NO_RENDERS) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) _, _, initial_pitch, held = reconstruction.with_channel_data.call_args.args assert initial_pitch == features.initial_pitch @@ -170,13 +172,7 @@ def test_run_asks_the_document_for_its_edited_channel( ) -> None: service = RegenerationService() - service._run( - reconstruction, - synthesis_mocks.channel_name, - features, - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) reconstruction.with_channel_data.assert_called_once() call_args = reconstruction.with_channel_data.call_args @@ -195,13 +191,7 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( """ service = RegenerationService() - service._run( - reconstruction, - synthesis_mocks.channel_name, - features, - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) call_args = reconstruction.with_channel_data.call_args assert call_args.args[2] == REFERENCE_PITCH @@ -216,7 +206,7 @@ def test_run_carries_a_moved_reference_pitch( moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12}) service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, moved, EVERY_STEM, NO_RENDERS) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, moved, HEARD_WHOLE, NO_RENDERS)) _, _, initial_pitch, _ = reconstruction.with_channel_data.call_args.args assert initial_pitch == REFERENCE_PITCH + 12 @@ -232,13 +222,7 @@ def test_run_hands_on_every_instruction_the_envelopes_describe( synthesis_mocks.exporter.from_features.return_value = stream service = RegenerationService() - service._run( - reconstruction, - synthesis_mocks.channel_name, - features, - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) call_args = reconstruction.with_channel_data.call_args assert call_args.args[1] == stream @@ -259,13 +243,7 @@ def test_run_exception_emits_service_error( "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): - service._run( - reconstruction, - ChannelName.PULSE1, - cast(Features, {}), - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS)) assert len(results) == 1 result = results[0] @@ -284,13 +262,7 @@ def test_run_exception_does_not_update_reconstruction( "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): - service._run( - reconstruction, - ChannelName.PULSE1, - cast(Features, {}), - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS)) reconstruction.with_channel_data.assert_not_called() @@ -312,13 +284,7 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: results: List[Any] = [] service.subscribe(results.append) - service._run( - reconstruction, - ChannelName.PULSE1, - features, - EVERY_STEM, - NO_RENDERS, - ) + service._run(RebuildJob(reconstruction, ChannelName.PULSE1, features, HEARD_WHOLE, NO_RENDERS)) assert isinstance(results[0], ServiceSuccess) regenerated: Reconstruction = results[0].value.reconstruction @@ -378,25 +344,42 @@ def _never_rendering( raise AssertionError(f"{channel_name} was rendered") -def _rebuilt(reconstruction: Reconstruction, kept: Dict[ChannelName, np.ndarray]) -> RegeneratedInstrument: - """What the service reports once it rebuilds the shared channel of ``reconstruction`` from its own envelopes.""" - service = RegenerationService() +def _heard_whole(reconstruction: Reconstruction) -> Hearing: + """Every recording heard on every channel, and every channel kept on.""" + return Hearing(selection=everything_heard(reconstruction), channels_on=EVERY_CHANNEL) + + +def _reported(service: RegenerationService, job: Any) -> RenderedDocument: results: List[Any] = [] service.subscribe(results.append) - - service._run( - reconstruction, - SHARED_CHANNEL, - reconstruction.export()[SHARED_CHANNEL], - reconstruction.recorded_stem_ids, - kept, - ) + service._run(job) assert isinstance(results[0], ServiceSuccess) - outcome: RegeneratedInstrument = results[0].value + outcome: RenderedDocument = results[0].value return outcome +def _rebuilt(reconstruction: Reconstruction, kept: Dict[ChannelName, np.ndarray]) -> RenderedDocument: + """What the service reports once it rebuilds the shared channel of ``reconstruction`` from its own envelopes.""" + return _reported( + RegenerationService(), + RebuildJob( + reconstruction, + SHARED_CHANNEL, + reconstruction.export()[SHARED_CHANNEL], + _heard_whole(reconstruction), + kept, + ), + ) + + +def _listened( + reconstruction: Reconstruction, hearing: Hearing, kept: Dict[ChannelName, np.ndarray] +) -> RenderedDocument: + """What the service reports once it renders ``hearing`` of ``reconstruction`` as it stands.""" + return _reported(RegenerationService(), ListeningJob(reconstruction, hearing, kept)) + + class TestWhatTheRebuildSounds: """The result carries the audio of every channel in play: the rebuilt channel rendered afresh, and a render handed in carried over for a channel the edit left alone. @@ -446,6 +429,50 @@ def test_a_render_handed_for_the_rebuilt_channel_is_passed_over(self, taking_tur assert outcome.channels[SHARED_CHANNEL] is not stale + def test_the_result_sounds_the_hearing_it_was_asked_with(self, taking_turns: Reconstruction) -> None: + """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.heard.mix is outcome.mix + assert outcome.heard.playing is outcome.mix + + +class TestWhatAListeningSounds: + """A listening renders what the reader hears of the document as it stands: the document comes back as + it was, a handed render carries over, and the heard reading is the core's own. + """ + + def test_the_document_comes_back_as_it_was(self, taking_turns: Reconstruction) -> None: + outcome = _listened(taking_turns, _heard_whole(taking_turns), NO_RENDERS) + + assert outcome.reconstruction is taking_turns + assert np.array_equal(outcome.mix, rendered_mix(taking_turns)) + + def test_every_render_handed_carries_over( + self, taking_turns: Reconstruction, monkeypatch: pytest.MonkeyPatch + ) -> None: + handed = rendered_channels(taking_turns) + monkeypatch.setattr(service_module, "render_instructions", _never_rendering) + + outcome = _listened(taking_turns, _heard_whole(taking_turns), handed) + + assert all(outcome.channels[name] is handed[name] for name in handed) + + def test_a_recording_left_out_is_heard_as_the_core_hears_it(self, taking_turns: Reconstruction) -> None: + hearing = Hearing( + selection=StemSelection.everywhere(frozenset({STEM_A_ID}), ChannelName.items()), + channels_on=frozenset({SHARED_CHANNEL}), + ) + + outcome = _listened(taking_turns, hearing, NO_RENDERS) + + channels = rendered_channels(taking_turns) + expected = heard_renders(taking_turns, hearing, channels, rendered_mix(taking_turns)) + assert outcome.hearing == hearing + np.testing.assert_array_equal(outcome.heard.mix, expected.mix) + np.testing.assert_array_equal(outcome.heard.playing, expected.playing) + class TestAShutdownMidRebuild: """A shutdown asked for while a rebuild runs ends the job with no render and no report. @@ -471,11 +498,13 @@ def test_the_job_renders_nothing_and_reports_nothing( SingleThreadExecutor.request_shutdown() service._run( - taking_turns, - SHARED_CHANNEL, - taking_turns.export()[SHARED_CHANNEL], - taking_turns.recorded_stem_ids, - NO_RENDERS, + RebuildJob( + taking_turns, + SHARED_CHANNEL, + taking_turns.export()[SHARED_CHANNEL], + _heard_whole(taking_turns), + NO_RENDERS, + ) ) assert results == [] diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index f457bf4ec..cafb1b3af 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -719,10 +719,15 @@ class TestAGestureOnTheWholeDocumentWaitsForTheEdits: def edit_on_its_way( self, app: Application, - embedded_sample: Sample, held_queue: HeldQueue, + embedded_sample: Sample, ) -> Sample: - """The open sample with a volume edit rebuilt and its result held until the case drains the queue.""" + """The open sample with a volume edit rebuilt and its result held until the case drains the queue. + + The opening's own sound is rendered on a worker and reported through the held queue, so the + worker is joined and its report drained first, which leaves the line empty for the edit. + """ + SingleThreadExecutor.join_all() held_queue.drain() with patch.object( app.regeneration_service._executor, diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index f1c5f126d..bce395b28 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -162,7 +162,7 @@ def _waveform_data() -> WaveformData: return WaveformData( original_audio=None, approximation=approximation, - approximations={}, + playing=approximation, coefficient=1.0, frame_length=1, sample_rate=44100, @@ -359,7 +359,7 @@ def test_new_data_keeps_the_reconstruction_dimmed(self, monkeypatch: pytest.Monk waveform_data = WaveformData( original_audio=None, approximation=np.zeros(4), - approximations={}, + playing=np.zeros(4), coefficient=1.0, frame_length=1, sample_rate=4, diff --git a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py deleted file mode 100644 index c6b44a149..000000000 --- a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import Dict, Final - -import numpy as np -import pytest - -from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.audio.mixing import mix -from sampletones_core.constants.enums import ChannelName - -LARGE: Final[float] = 1e8 -SMALL: Final[float] = 1.0 - - -@pytest.fixture -def approximations() -> Dict[ChannelName, np.ndarray]: - return { - ChannelName.PULSE1: np.array([1.0, 0.0, -1.0, 0.0]), - ChannelName.NOISE: np.array([0.5, 0.5, 0.5, 0.5]), - } - - -@pytest.fixture -def waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData: - return _waveform_data(approximations) - - -def _waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData: - """Waveform data holding ``approximations`` and the mix of them, which is the shape the data holder builds.""" - return WaveformData( - original_audio=np.zeros(4), - approximation=mix(list(approximations.values())), - approximations=approximations, - coefficient=1.0, - frame_length=2, - sample_rate=44100, - ) - - -class TestPartials: - def test_empty_selection_is_silent(self, waveform_data: WaveformData) -> None: - assert np.all(waveform_data.partials([]) == 0.0) - - def test_missing_generator_is_silent(self, waveform_data: WaveformData) -> None: - assert np.all(waveform_data.partials([ChannelName.TRIANGLE]) == 0.0) - - def test_single_generator_returns_its_approximation( - self, - waveform_data: WaveformData, - approximations: Dict[ChannelName, np.ndarray], - ) -> None: - result = waveform_data.partials([ChannelName.PULSE1]) - - assert np.array_equal(result, approximations[ChannelName.PULSE1]) - - def test_selection_sums_the_selected_generators( - self, - approximations: Dict[ChannelName, np.ndarray], - ) -> None: - with_a_third = {**approximations, ChannelName.TRIANGLE: np.array([0.25, 0.25, 0.25, 0.25])} - waveform_data = _waveform_data(with_a_third) - - result = waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE]) - - expected = approximations[ChannelName.PULSE1] + approximations[ChannelName.NOISE] - assert np.array_equal(result, expected) - - def test_unknown_generators_are_skipped_within_a_selection( - self, - waveform_data: WaveformData, - approximations: Dict[ChannelName, np.ndarray], - ) -> None: - result = waveform_data.partials([ChannelName.PULSE1, ChannelName.TRIANGLE]) - - assert np.array_equal(result, approximations[ChannelName.PULSE1]) - - def test_short_generators_leave_the_tail_in_silence(self) -> None: - approximation = np.array([1.0, 0.0, -1.0, 0.0]) - data = _waveform_data( - { - ChannelName.PULSE1: np.array([0.5, 0.5]), - ChannelName.NOISE: approximation, - ChannelName.TRIANGLE: np.zeros(4), - } - ) - - result = data.partials([ChannelName.PULSE1, ChannelName.NOISE]) - - expected = np.array([1.5, 0.5, -1.0, 0.0]) - assert np.array_equal(result, expected) - - def test_a_short_single_generator_reaches_the_approximation_length(self) -> None: - data = _waveform_data({ChannelName.PULSE1: np.array([0.5, 0.5]), ChannelName.NOISE: np.zeros(4)}) - - result = data.partials([ChannelName.PULSE1]) - - expected = np.array([0.5, 0.5, 0.0, 0.0]) - assert np.array_equal(result, expected) - - -class TestEveryChannel: - """A selection covering every channel answers the mix the data holds, in any listing.""" - - def test_every_channel_answers_the_mix_itself(self, waveform_data: WaveformData) -> None: - assert waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE]) is waveform_data.approximation - - def test_the_listing_order_makes_no_difference(self, waveform_data: WaveformData) -> None: - assert waveform_data.partials([ChannelName.NOISE, ChannelName.PULSE1]) is waveform_data.approximation - - def test_channels_the_data_holds_none_of_change_nothing(self, waveform_data: WaveformData) -> None: - every_channel = list(ChannelName.items()) - - assert waveform_data.partials(every_channel) is waveform_data.approximation - - -class TestTheSumFollowsChannelOrder: - """A narrower selection is summed in channel order, so the same choice reads the same samples. - - Adding the floats in another order rounds another way, which is what the readings would differ - by if the sum followed the listing. - """ - - def test_two_listings_of_one_choice_read_the_same_samples(self) -> None: - data = _waveform_data( - { - ChannelName.PULSE1: np.array([LARGE], dtype=np.float32), - ChannelName.PULSE2: np.array([SMALL], dtype=np.float32), - ChannelName.TRIANGLE: np.array([0.0], dtype=np.float32), - ChannelName.NOISE: np.array([-LARGE], dtype=np.float32), - } - ) - choice = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE] - - assert np.array_equal(data.partials(choice), data.partials(list(reversed(choice)))) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py new file mode 100644 index 000000000..4bb8e9d3a --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py @@ -0,0 +1,134 @@ +from typing import 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.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.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 + +__all__ = ["taking_turns"] + +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: + entries = [ + StemEntry( + id=stem_id, + settings=StemSettings(channels=[CHANNEL], bends=bending_channels([CHANNEL])), + ) + for stem_id in range(3) + ] + return StemsData( + config=StemsConfig( + entries=entries, + hierarchy=StemsHierarchy(levels=[[entry.id] for entry in entries]), + ), + assignments=[ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in stem_lists], + scale=RECORDED_SCALE, + ) + + +def _hearing(stem_ids: FrozenSet[int], channels_on: FrozenSet[ChannelName] = EVERY_CHANNEL) -> Hearing: + return Hearing(selection=StemSelection.everywhere(stem_ids, ChannelName.items()), channels_on=channels_on) + + +class TestWhatAChannelIsFilteredBy: + """A channel's filter names the owners heard, so two hearings reaching the channel alike read it alike.""" + + def test_every_owner_heard_is_heard_whole(self) -> None: + stems_data = _stems_data((CHANNEL, [0, 1, RESTING_STEM_ID])) + + assert _hearing(frozenset({0, 1})).filter_for(CHANNEL, stems_data) is None + + def test_an_owner_left_out_names_the_owners_heard(self) -> None: + stems_data = _stems_data((CHANNEL, [0, 1])) + + assert _hearing(frozenset({0})).filter_for(CHANNEL, stems_data) == frozenset({0}) + + def test_recordings_beyond_the_channel_are_left_out_of_the_name(self) -> None: + stems_data = _stems_data((CHANNEL, [0, 1])) + + assert _hearing(frozenset({0, 2})).filter_for(CHANNEL, stems_data) == frozenset({0}) + assert _hearing(frozenset({0, 1, 2})).filter_for(CHANNEL, stems_data) is None + + def test_the_readers_own_row_counts_as_an_owner(self) -> None: + stems_data = _stems_data((CHANNEL, [0, AUTHORED_STEM_ID])) + + assert _hearing(frozenset({0})).filter_for(CHANNEL, stems_data) == frozenset({0}) + assert _hearing(frozenset({0, AUTHORED_STEM_ID})).filter_for(CHANNEL, stems_data) is None + + def test_a_channel_the_record_leaves_out_is_heard_whole(self) -> None: + stems_data = _stems_data((CHANNEL, [0])) + + assert _hearing(frozenset()).filter_for(ChannelName.NOISE, stems_data) is None + + +class TestWhatAHearingSounds: + """The heard mix and what plays, read from the channels' renders: the arrays handed in come back by + identity where nothing is filtered, and a narrower choice is summed in channel order. + """ + + def test_heard_whole_and_every_channel_on_sounds_the_mix_itself(self, taking_turns) -> None: + channels = rendered_channels(taking_turns) + mixed = rendered_mix(taking_turns) + + heard = heard_renders(taking_turns, _hearing(taking_turns.recorded_stem_ids), channels, mixed) + + assert heard.mix is mixed + assert heard.playing is mixed + + def test_a_recording_left_out_is_silenced_in_the_mix(self, taking_turns) -> None: + channels = rendered_channels(taking_turns) + mixed = rendered_mix(taking_turns) + + heard = heard_renders(taking_turns, _hearing(frozenset({STEM_A_ID})), channels, mixed) + + assert heard.mix is not mixed + assert np.any(heard.mix != mixed) + assert heard.playing is heard.mix + + def test_a_channel_switched_off_plays_the_rest(self, taking_turns) -> None: + channels = rendered_channels(taking_turns) + mixed = rendered_mix(taking_turns) + + heard = heard_renders( + taking_turns, + _hearing(taking_turns.recorded_stem_ids, frozenset({SOLE_CHANNEL})), + channels, + mixed, + ) + + assert heard.mix is mixed + np.testing.assert_array_equal(heard.playing, align([channels[SOLE_CHANNEL]], len(mixed))[0]) + assert len(heard.playing) == len(mixed) + + def test_every_channel_switched_off_plays_silence(self, taking_turns) -> None: + channels = rendered_channels(taking_turns) + mixed = rendered_mix(taking_turns) + + heard = heard_renders(taking_turns, _hearing(taking_turns.recorded_stem_ids, frozenset()), channels, mixed) + + np.testing.assert_array_equal(heard.playing, np.zeros_like(mixed)) + assert len(heard.playing) == len(mixed) + + def test_channels_switched_off_beyond_play_change_nothing(self, taking_turns) -> None: + channels = rendered_channels(taking_turns) + mixed = rendered_mix(taking_turns) + on = frozenset({SHARED_CHANNEL, SOLE_CHANNEL, ChannelName.NOISE}) + + heard = heard_renders(taking_turns, _hearing(taking_turns.recorded_stem_ids, on), channels, mixed) + + assert heard.playing is mixed + assert STEM_B_ID in taking_turns.recorded_stem_ids From a3139344c63a374b7c897a6f51b6918851605553 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 09:40:58 +0200 Subject: [PATCH 16/37] Painted: ownership one series per recording, repainted where it changed --- docs/development/application/stems.md | 2 +- .../logic/reconstruction/envelopes.py | 9 +- .../logic/reconstruction/ownership.py | 39 ++--- .../logic/reconstruction/reconstruction.py | 10 +- .../ui/elements/graphs/ownership.py | 138 +++++++++++++----- .../view_model/shared/ownership.py | 47 +++++- .../reconstruction/stems/data.py | 10 ++ .../reconstruction/stems/ownership.py | 20 +++ .../stems/test_long_document_boxes.py | 15 +- tests/suite/screens/seeds/reconstructions.py | 61 +++++++- tests/suite/screens/worlds/recordings.py | 31 +++- .../logic/reconstruction/test_ownership.py | 9 +- .../ui/elements/graphs/test_ownership.py | 138 ++++++++++++++++++ .../view_model/shared/test_ownership.py | 32 ++++ 14 files changed, 482 insertions(+), 79 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/graphs/test_ownership.py diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index 73e9aa588..c0b05062e 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. 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; a hearing the cache keeps no sound for is rendered on the regeneration 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 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, 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; a hearing the cache keeps no sound for is rendered on the regeneration 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 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/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py index 14f9a0f01..48ab6307f 100644 --- a/src/sampletones_application/logic/reconstruction/envelopes.py +++ b/src/sampletones_application/logic/reconstruction/envelopes.py @@ -11,6 +11,7 @@ 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.ownership import runs_up_to from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection @@ -60,10 +61,10 @@ def _lanes( if not tells_owners_apart(stems_data): return {} - owned = stems_data.assignments_by_channel - assignments = { - channel_name: owned[channel_name][: features.frame_count] + owned = stems_data.owner_runs_by_channel + runs = { + channel_name: runs_up_to(owned[channel_name], features.frame_count) for channel_name, features in channels.items() if features.has_frames and channel_name in owned } - return ownership_lanes(assignments, selection.stems_for) + return ownership_lanes(runs, selection.stems_for) diff --git a/src/sampletones_application/logic/reconstruction/ownership.py b/src/sampletones_application/logic/reconstruction/ownership.py index cd5d32ebe..43284b84f 100644 --- a/src/sampletones_application/logic/reconstruction/ownership.py +++ b/src/sampletones_application/logic/reconstruction/ownership.py @@ -9,7 +9,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.ownership import heard_frame, owner_runs +from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun, heard_frame DISTINGUISHABLE_OWNERS: Final[int] = 2 @@ -55,40 +55,41 @@ def tells_owners_apart(stems_data: StemsData) -> bool: def ownership_lanes( - assignments: Mapping[ChannelName, Sequence[int]], + runs: Mapping[ChannelName, Sequence[OwnerRun]], heard_on: HeardOn, ) -> Dict[ChannelName, OwnershipLaneViewModel]: - """A lane per channel of ``assignments``, each divided into the stretches its owners hold. - - Each surface states its own reading as the assignments it passes — which channels stand, and - how far each lane runs — so the rule dividing a channel into stretches is written once and - the ribbon under the waveform and the band under an instrument's bars agree by construction. - Whether a lane is worth drawing at all is the caller's own question: a surface with room for - only one reading, such as an instrument's bars, asks ``tells_owners_apart`` first and skips - this call where a document answering to a single owner has nothing to tell apart; a surface - reading every channel of a document at once, such as the waveform's ribbon, has a plainer - question to answer instead — whether a channel plays — and takes a lane for every one that - does. + """A lane per channel of ``runs``, each divided into the stretches its owners hold. + + Each surface states its own reading as the stretches it passes — which channels stand, and + how far each lane runs — so the rule painting a stretch is written once and the ribbon under + the waveform and the band under an instrument's bars agree by construction. The stretches + themselves are the record's own (``StemsData.owner_runs_by_channel``), read from its frames + once and shared by every surface. Whether a lane is worth drawing at all is the caller's own + question: a surface with room for only one reading, such as an instrument's bars, asks + ``tells_owners_apart`` first and skips this call where a document answering to a single owner + has nothing to tell apart; a surface reading every channel of a document at once, such as the + waveform's ribbon, has a plainer question to answer instead — whether a channel plays — and + takes a lane for every one that does. Args: - assignments: The stem holding each frame, per channel the surface draws. + runs: The stretches each channel divides into, per channel the surface draws. heard_on: The recordings the reader hears on a channel. Returns: Dict[ChannelName, OwnershipLaneViewModel]: One lane per channel given, in that order. """ return { - channel_name: _ownership_lane(channel_name, stem_ids, heard_on(channel_name)) - for channel_name, stem_ids in assignments.items() + channel_name: _ownership_lane(channel_name, channel_runs, heard_on(channel_name)) + for channel_name, channel_runs in runs.items() } def _ownership_lane( channel_name: ChannelName, - stem_ids: Sequence[int], + runs: Sequence[OwnerRun], heard: AbstractSet[int], ) -> OwnershipLaneViewModel: - """One channel's lane: the stretches it divides into, each under the recording holding it. + """One channel's lane: its stretches, each under the recording holding it. A stretch keeps the recording holding it and says whether the reader hears it there, so the lane names an owner wherever the record does and shows the reader's choice on top of it. @@ -104,7 +105,7 @@ def _ownership_lane( stem_id=run.stem_id, heard=heard_frame(run.stem_id, heard), ) - for run in owner_runs(stem_ids) + for run in runs if run.stem_id != RESTING_STEM_ID ), ) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 5033e2a2a..e1ef9cd9a 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -58,6 +58,7 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun from sampletones_core.reconstructions.reconstruction.stems.selection import ( StemSelection, ) @@ -382,17 +383,18 @@ def _build_ownership_ribbon( through it. """ stems_data = reconstruction_data.reconstruction.stems_data - owned = stems_data.assignments_by_channel - assignments: Dict[ChannelName, Sequence[int]] = { + owned = stems_data.owner_runs_by_channel + runs: Dict[ChannelName, Sequence[OwnerRun]] = { channel_name: owned[channel_name] if channel_name in self._listening.channels_on else () for channel_name in self._in_channel_order(self._listening.playing) if channel_name in owned } - lanes = tuple(ownership_lanes(assignments, self.heard_on).values()) + lanes = tuple(ownership_lanes(runs, self.heard_on).values()) + frames = stems_data.assignments_by_channel return OwnershipRibbonViewModel( lanes=lanes, frame_length=reconstruction_data.reconstruction.config.frame_length, - total_frames=max((len(owned[lane.channel_name]) for lane in lanes), default=0), + total_frames=max((len(frames[lane.channel_name]) for lane in lanes), default=0), ) def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: diff --git a/src/sampletones_application/ui/elements/graphs/ownership.py b/src/sampletones_application/ui/elements/graphs/ownership.py index 992992d69..7922c083d 100644 --- a/src/sampletones_application/ui/elements/graphs/ownership.py +++ b/src/sampletones_application/ui/elements/graphs/ownership.py @@ -1,4 +1,5 @@ -from typing import Dict, List, Sequence, Tuple +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -12,26 +13,79 @@ ) from sampletones_application.utils.gui.dpg import dpg_bind_item_theme, dpg_delete_item from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color -from sampletones_application.view_model.shared.ownership import OwnershipRunViewModel +from sampletones_application.view_model.shared.ownership import ( + OwnershipRunViewModel, + OwnerStretchesViewModel, + runs_by_owner, +) + +Stretches = Tuple[Tuple[int, int], ...] +Band = Tuple[float, float] +Geometry = Tuple[Stretches, float, Band] + + +def traced( + stretches: Stretches, + frame_span: float, + band: Band, +) -> Tuple[List[float], List[float], List[float]]: + """The points one shade series runs through to fill ``stretches`` and nothing between them. + + The series rises to the band's top at the start of each stretch and falls back to its bottom at + the end, so the fill between its two edges covers the stretches alone and has no height anywhere + else. + + Args: + stretches: The first frame and the frame one past the last of each stretch, in frame order. + frame_span: What one frame measures on the axis the plot counts along. + band: The lower and upper edge the stretches are drawn between. + + Returns: + Tuple[List[float], List[float], List[float]]: The positions along the axis, the lower edge at + each, and the upper edge at each. + """ + bottom, top = band + positions: List[float] = [] + upper: List[float] = [] + for start, end in stretches: + left, right = start * frame_span, end * frame_span + positions.extend((left, left, right, right)) + upper.extend((bottom, top, top, bottom)) + + return positions, [bottom] * len(positions), upper + + +@dataclass(frozen=True) +class _Painted: + """One recording's series on an axis, with what it was traced from and how it is heard.""" + + series_tag: str + geometry: Geometry + heard: bool class OwnershipRuns: - """Paints the recordings behind a stretch of a plot, each run a flat bar in its own color. + """Paints the recordings behind a stretch of a plot, each recording one flat series in its own color. Two surfaces show a frame's owner — the ribbon under the waveform and the stretch under an instrument's bars — so both paint through this, and a recording is one color wherever it is shown. Each surface states the span its own axis counts in and the band the bars sit in, so the same runs land under a waveform read in samples and under an envelope read in frames. - A theme is named after the reading it paints — the recording, the place it stands on the - record and whether the reader hears it — so two stretches of one reading share a theme - however many times a document divides, and a name reads the same from one run of the - application to the next. + A recording's stretches along one lane are one series traced over them, so a lane costs as many + series as it has owners however many times the document divides. A repaint touches a series only + where something changed: its points are traced again where the stretches moved, and its theme is + bound again where the reader's hearing of the recording changed, so a box on the stems card + repaints as a few theme bindings. + + A theme is named after the reading it paints — the recording and whether the reader hears it — + so two stretches of one reading share a theme however many times a document divides, and a name + reads the same from one run of the application to the next. """ def __init__(self, stem_colors: StemColors) -> None: self._stem_colors = stem_colors - self._painted: Dict[str, List[str]] = {} + self._painted: Dict[str, Dict[int, _Painted]] = {} def paint( self, @@ -39,7 +93,7 @@ def paint( runs: Sequence[OwnershipRunViewModel], *, frame_span: float, - band: Tuple[float, float], + band: Band, ) -> None: """Draws one lane's stretches into an axis. @@ -49,38 +103,50 @@ def paint( frame_span: What one frame measures on the axis the plot counts along. band: The lower and upper edge the stretches are drawn between. """ - self._clear(y_axis_tag) - bottom, top = band - painted: List[str] = [] - for run in runs: - series_tag = compose_tag(y_axis_tag, SUF_OWNERSHIP_RUN, str(run.start_frame)) - dpg.add_shade_series( - [run.start_frame * frame_span, run.end_frame * frame_span], - y1=[bottom, bottom], - y2=[top, top], - tag=series_tag, - parent=y_axis_tag, - ) - self._bind(series_tag, run) - painted.append(series_tag) - - self._painted[y_axis_tag] = painted - - def _clear(self, y_axis_tag: str) -> None: - """Takes the stretches this axis carries away, which is what a repaint begins with.""" - for series_tag in self._painted.pop(y_axis_tag, []): - dpg_delete_item(series_tag) - - def _bind(self, series_tag: str, run: OwnershipRunViewModel) -> None: - """Binds the run its recording's fill, through the theme every like reading shares.""" + owners = runs_by_owner(runs) + painted = self._painted.setdefault(y_axis_tag, {}) + for stem_id in [stem_id for stem_id in painted if stem_id not in owners]: + dpg_delete_item(painted.pop(stem_id).series_tag) + + for stem_id, owner in owners.items(): + painted[stem_id] = self._paint_owner(y_axis_tag, painted.get(stem_id), owner, frame_span, band) + + def _paint_owner( + self, + y_axis_tag: str, + standing: Optional[_Painted], + owner: OwnerStretchesViewModel, + frame_span: float, + band: Band, + ) -> _Painted: + """Brings one recording's series on the axis to ``owner``, drawing it where the axis holds none.""" + series_tag = compose_tag(y_axis_tag, SUF_OWNERSHIP_RUN, str(owner.stem_id)) + geometry: Geometry = (owner.stretches, frame_span, band) + if standing is not None and not dpg.does_item_exist(series_tag): + standing = None + + if standing is None: + positions, lower, upper = traced(owner.stretches, frame_span, band) + dpg.add_shade_series(positions, lower, y2=upper, tag=series_tag, parent=y_axis_tag) + elif standing.geometry != geometry: + positions, lower, upper = traced(owner.stretches, frame_span, band) + dpg.configure_item(series_tag, x=positions, y1=lower, y2=upper) + + if standing is None or standing.heard != owner.heard: + self._bind(series_tag, owner.stem_id, owner.heard) + + return _Painted(series_tag=series_tag, geometry=geometry, heard=owner.heard) + + def _bind(self, series_tag: str, stem_id: int, heard: bool) -> None: + """Binds the series its recording's fill, through the theme every like reading shares.""" theme_tag = compose_tag( SUF_OWNERSHIP_RUN, SUF_GRAPH_THEME, - str(run.stem_id), - SUF_OWNERSHIP_HEARD if run.heard else SUF_OWNERSHIP_LEFT_OUT, + str(stem_id), + SUF_OWNERSHIP_HEARD if heard else SUF_OWNERSHIP_LEFT_OUT, ) if not dpg.does_item_exist(theme_tag): - color = self._stem_colors.for_stem(run.stem_id, heard=run.heard) + color = self._stem_colors.for_stem(stem_id, heard=heard) with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvShadeSeries): dpg_add_palette_theme_color(dpg.mvPlotCol_Fill, color, category=dpg.mvThemeCat_Plots) diff --git a/src/sampletones_application/view_model/shared/ownership.py b/src/sampletones_application/view_model/shared/ownership.py index a1f34f554..bb723270f 100644 --- a/src/sampletones_application/view_model/shared/ownership.py +++ b/src/sampletones_application/view_model/shared/ownership.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Tuple +from typing import Dict, List, Sequence, Tuple from pydantic import BaseModel @@ -23,8 +23,51 @@ class OwnershipRunViewModel(BaseModel, extra="forbid", frozen=True): heard: bool +class OwnerStretchesViewModel(BaseModel, extra="forbid", frozen=True): + """One recording's stretches along a lane, as a surface paints them in one piece. + + Attributes: + stem_id: The stem holding the stretches, which picks the color. + stretches: The first frame and the frame one past the last of each stretch, in frame order. + heard: Whether the reader hears the recording on this lane, which settles how solidly it paints. + """ + + stem_id: int + stretches: Tuple[Tuple[int, int], ...] + heard: bool + + +def runs_by_owner(runs: Sequence[OwnershipRunViewModel]) -> Dict[int, OwnerStretchesViewModel]: + """The runs of a lane gathered by the recording holding them, in the order the recordings first appear. + + A surface paints a recording's stretches as one piece however many times the lane divides, so + what it draws costs as many pieces as the lane has owners. Hearing is chosen per channel, so a + lane hears a recording on every stretch it holds or on none, and the recording's stretches share + one reading of it. + + Args: + runs: The lane's runs, in frame order. + + Returns: + Dict[int, OwnerStretchesViewModel]: Each owner's stretches, keyed by stem id. + """ + stretches: Dict[int, List[Tuple[int, int]]] = {} + heard: Dict[int, bool] = {} + for run in runs: + stretches.setdefault(run.stem_id, []).append((run.start_frame, run.end_frame)) + heard[run.stem_id] = run.heard + + return { + stem_id: OwnerStretchesViewModel(stem_id=stem_id, stretches=tuple(owned), heard=heard[stem_id]) + for stem_id, owned in stretches.items() + } + + class OwnershipLaneViewModel(BaseModel, extra="forbid", frozen=True): - """One channel's stretch of ribbon: the channel it stands for and the runs along it.""" + """One channel's stretch of ribbon: the channel it stands for and the runs along it. + + Hearing is chosen per channel, so the lane hears a recording on every stretch it holds or on none. + """ channel_name: ChannelName runs: Tuple[OwnershipRunViewModel, ...] diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py index 5e902de9a..dbea77305 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -10,6 +10,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun, owner_runs from sampletones_core.reconstructions.reconstruction.stems.source import StemSource from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings @@ -90,6 +91,15 @@ def assignments_by_channel(self) -> Dict[ChannelName, Tuple[int, ...]]: """The per-frame stem ids each channel carries, keyed by channel.""" return {item.channel_name: item.stem_ids for item in self.assignments} + @cached_property + def owner_runs_by_channel(self) -> Dict[ChannelName, Tuple[OwnerRun, ...]]: + """The stretches each channel divides into, each held by one owner throughout, keyed by channel. + + A record is read by its stretches wherever a surface paints who holds what, and a record + is a value, so the stretches are read from its frames once and every surface shares them. + """ + return {item.channel_name: tuple(owner_runs(item.stem_ids)) for item in self.assignments} + @cached_property def assignment_records(self) -> Dict[ChannelName, ChannelAssignment]: """The record naming the owner of each frame, keyed by channel.""" diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py index 231ee7583..09aa421e4 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py @@ -53,6 +53,26 @@ class OwnerRun: stem_id: int +def runs_up_to(runs: Sequence[OwnerRun], frame_count: int) -> List[OwnerRun]: + """The stretches standing under the first ``frame_count`` frames, the last of them ending there. + + A surface draws a reading as far as that reading goes, so a surface drawing fewer frames than + the channel holds asks for the stretches standing under them. + + Args: + runs: A channel's stretches, in frame order. + frame_count: How many frames the surface draws. + + Returns: + List[OwnerRun]: The stretches under those frames, cut where the last one runs past them. + """ + return [ + run if run.end <= frame_count else OwnerRun(start=run.start, end=frame_count, stem_id=run.stem_id) + for run in runs + if run.start < frame_count + ] + + def owner_runs(stem_ids: Sequence[int]) -> List[OwnerRun]: """The stretches a channel divides into, each held by one recording throughout. diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index fb44ca97f..9496c0953 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -10,7 +10,7 @@ from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each -from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world TOGGLED_CHANNEL: Final[ChannelName] = ChannelName.NOISE RECORDING_KEY: Final[str] = "0" @@ -21,8 +21,8 @@ @pytest.fixture(name="world") def world_fixture() -> World: - """The home holds the two-minute reconstruction with its recording.""" - return long_document_world() + """The home holds the two-minute reconstruction over three recordings taking turns.""" + return alternating_document_world() @pytest.fixture(name="startup") @@ -33,11 +33,12 @@ def startup_fixture() -> Startup: class TestTheBoxesOnALongDocument: """How the frames keep coming while a channel's box above the waveform and a recording's box on the - stems card are ticked off and on again on a two-minute document. + stems card are ticked off and on again on a two-minute document over three recordings taking turns. - A box changes what is heard, so the waveform, the audio and the figures are read again from the - document, and a sound the screen holds none of yet is rendered on the worker while the frames - keep coming. The scenario records the clock after every frame through the ticks, each followed + A box changes what is heard, so the waveform, the audio, the figures and the stretches every + surface paints are read again from the document, and a sound the screen holds none of yet is + rendered on the worker while the frames keep coming. The recordings take four-frame turns, so + every lane divides into as many stretches as a long conversion over competing recordings does. The scenario records the clock after every frame through the ticks, each followed by the fade's end, and keeps the longest gap after each tick beside the readings the drag scenarios keep, holding the application to nothing: the readings are what a change to the listening path is judged by, taken before and after the change. diff --git a/tests/suite/screens/seeds/reconstructions.py b/tests/suite/screens/seeds/reconstructions.py index a4c0a0aa0..df9389494 100644 --- a/tests/suite/screens/seeds/reconstructions.py +++ b/tests/suite/screens/seeds/reconstructions.py @@ -4,12 +4,19 @@ from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction 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.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.compatibility import ARCHIVED_VERSIONS, archived from tests.suite.screens.seeds.constants import TURNING_LEVELS -from tests.suite.stems import recorded_from, single_entry_stems_data +from tests.suite.stems import RECORDED_SCALE, recorded_from, single_entry_stems_data LINE_START_PITCH: Final[int] = 60 LINE_STEP_FRAMES: Final[int] = 15 @@ -71,6 +78,58 @@ def write(self) -> None: _save(self.destination, _playable_reconstruction(self.recordings, self.frames, self.nes_frequency)) +@dataclass(frozen=True) +class AlternatingReconstruction: + """A :class:`PlayableReconstruction` whose recordings take turns holding the frames. + + Each channel plays the ``frames`` frames once, and the recordings of ``recordings`` hold them in + turn, ``turn_frames`` frames at a time, so every lane divides into as many stretches as a long + conversion over competing recordings does. The recordings laid at those paths last the document's + length. It was converted at ``nes_frequency`` ticks a second. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + turn_frames: int + nes_frequency: int + + def write(self) -> None: + """Saves the reconstruction to the destination, naming each recording as a source.""" + instructions = _playable_instructions(self.frames) + stem_ids = list(range(len(self.recordings))) + assignments = [ + ChannelAssignment( + channel_name=channel_name, + stem_ids=[ + stem_ids[(frame // self.turn_frames) % len(stem_ids)] if instruction.on else RESTING_STEM_ID + for frame, instruction in enumerate(stream) + ], + ) + for channel_name, stream in instructions.items() + ] + stems_data = StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=stem_id, settings=StemSettings.covering(list(instructions))) for stem_id in stem_ids + ], + hierarchy=StemsHierarchy(levels=[stem_ids]), + ), + assignments=assignments, + scale=RECORDED_SCALE, + ).with_sources(list(self.recordings)) + _save( + self.destination, + Reconstruction.create( + instructions=instructions, + config=Config().with_library(nes_frequency=self.nes_frequency), + coefficient=1.0, + audio_filepath=tuple(self.recordings), + stems_data=stems_data, + ), + ) + + @dataclass(frozen=True) class UnsoundReconstruction: """A :class:`PlayableReconstruction` over several recordings whose record states no scale. diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py index 574003077..746771f23 100644 --- a/tests/suite/screens/worlds/recordings.py +++ b/tests/suite/screens/worlds/recordings.py @@ -6,7 +6,11 @@ from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.seeds.libraries import MiniLibrary from tests.suite.screens.seeds.projects import StoredProject -from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, StoredReconstruction +from tests.suite.screens.seeds.reconstructions import ( + AlternatingReconstruction, + PlayableReconstruction, + StoredReconstruction, +) from tests.suite.screens.seeds.recordings import Recording, stored_recording OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" @@ -26,6 +30,8 @@ TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") LONG_TAKE: Final[str] = "long.wav" +LONG_TAKES: Final[Tuple[str, ...]] = ("long1.wav", "long2.wav", "long3.wav") +TURN_FRAMES: Final[int] = 4 STEM_FRAMES: Final[int] = 60 PLAYABLE_FRAMES: Final[int] = 600 LONG_FRAMES: Final[int] = 7200 @@ -103,6 +109,29 @@ def playing_world() -> World: ) +def alternating_document_world() -> World: + """A home holding :data:`LONG_RECONSTRUCTION` as two minutes over three recordings taking four-frame turns, + whose every lane divides into as many stretches as a long conversion over competing recordings does. + """ + takes = tuple(Path.cwd() / take for take in LONG_TAKES) + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + *( + Recording( + destination=take, + seconds=LONG_FRAMES / FRAMES_PER_SECOND, + frequency=TAKE_FREQUENCY * (index + 1), + ) + for index, take in enumerate(takes) + ), + AlternatingReconstruction(LONG_RECONSTRUCTION, takes, LONG_FRAMES, TURN_FRAMES, DEFAULT_NES_FREQUENCY), + ), + ) + + def long_document_world() -> World: """A home holding :data:`LONG_RECONSTRUCTION`, two minutes of one recording, which an edit takes long enough to rebuild and render for the frames to be read while it does. diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py index 77d5e6f28..05cbabc0d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py @@ -11,6 +11,7 @@ from sampletones_core.constants.enums import ChannelName, bending_channels 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.ownership import owner_runs 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 @@ -42,8 +43,8 @@ def _stems_data(stem_ids: Sequence[int], *, owners: Sequence[int] = (STEM_A, STE def _lane_runs(stem_ids: Sequence[int]) -> Tuple[Tuple[int, int, int], ...]: """The stretches the first channel divides into, each as its start, end and owner.""" - assignments: Dict[ChannelName, List[int]] = {CHANNEL: list(stem_ids)} - lanes = ownership_lanes(assignments, lambda _channel: {STEM_A, STEM_B}) + runs = {CHANNEL: owner_runs(list(stem_ids))} + lanes = ownership_lanes(runs, lambda _channel: {STEM_A, STEM_B}) return tuple((run.start_frame, run.end_frame, run.stem_id) for run in lanes[CHANNEL].runs) @@ -72,9 +73,9 @@ class TestALaneStandsWhateverTheDocumentHoldsToTellApart: owner still divides into the one stretch that owner holds.""" def test_a_single_owner_still_takes_a_lane(self) -> None: - assignments: Dict[ChannelName, List[int]] = {CHANNEL: [STEM_A, STEM_A]} + runs = {CHANNEL: owner_runs([STEM_A, STEM_A])} - lanes = ownership_lanes(assignments, lambda _channel: {STEM_A}) + lanes = ownership_lanes(runs, lambda _channel: {STEM_A}) assert [(run.start_frame, run.end_frame, run.stem_id) for run in lanes[CHANNEL].runs] == [(0, 2, STEM_A)] diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_ownership.py b/tests/unit/sampletones_application/ui/elements/graphs/test_ownership.py new file mode 100644 index 000000000..7a699ab73 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_ownership.py @@ -0,0 +1,138 @@ +from typing import Final, Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_OWNERSHIP_RUN +from sampletones_application.ui.elements.graphs.ownership import OwnershipRuns, traced +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_application.view_model.shared.ownership import OwnershipRunViewModel +from sampletones_shared.types.application import ColorRGBA + +RECORDING_COLORS: Final[Tuple[ColorRGBA, ...]] = ((200, 80, 40, 255), (80, 160, 220, 255)) +AUTHORED_COLOR: Final[ColorRGBA] = (180, 140, 240, 255) +REST_COLOR: Final[ColorRGBA] = (40, 40, 48, 255) +LEFT_OUT_FRACTION: Final[float] = 0.4 +FRAME_SPAN: Final[float] = 2.0 +BAND: Final[Tuple[float, float]] = (0.1, 0.9) +Y_AXIS: Final[str] = "ownership_test_axis" +FIRST: Final[int] = 0 +SECOND: Final[int] = 1 + + +def _runs(*entries: Tuple[int, int, int, bool]) -> Tuple[OwnershipRunViewModel, ...]: + return tuple( + OwnershipRunViewModel(start_frame=start, end_frame=end, stem_id=stem_id, heard=heard) + for start, end, stem_id, heard in entries + ) + + +def _series(stem_id: int) -> str: + return compose_tag(Y_AXIS, SUF_OWNERSHIP_RUN, str(stem_id)) + + +@pytest.fixture +def stem_colors() -> StemColors: + return StemColors( + recordings=tuple(LiteralColor(value) for value in RECORDING_COLORS), + authored=LiteralColor(AUTHORED_COLOR), + rest=LiteralColor(REST_COLOR), + left_out_fraction=LEFT_OUT_FRACTION, + ) + + +@pytest.fixture +def axis() -> Iterator[str]: + """A plot's y axis in a context of the case's own, which the painter draws into.""" + dpg.create_context() + with dpg.window(): + with dpg.plot(): + dpg.add_plot_axis(dpg.mvXAxis) + dpg.add_plot_axis(dpg.mvYAxis, tag=Y_AXIS) + + yield Y_AXIS + dpg.destroy_context() + + +class TestTracingStretches: + """A series rises at the start of each stretch and falls at its end, so it fills the stretches alone.""" + + def test_each_stretch_is_four_points_rising_and_falling(self) -> None: + positions, lower, upper = traced(((0, 4), (8, 12)), FRAME_SPAN, BAND) + + assert positions == [0.0, 0.0, 8.0, 8.0, 16.0, 16.0, 24.0, 24.0] + assert lower == [BAND[0]] * 8 + assert upper == [BAND[0], BAND[1], BAND[1], BAND[0]] * 2 + + def test_no_stretch_traces_nothing(self) -> None: + assert traced((), FRAME_SPAN, BAND) == ([], [], []) + + +class TestPaintingALane: + """A lane is painted as one series per recording, and a repaint touches a series only where it changed.""" + + def test_each_recording_is_one_series(self, stem_colors: StemColors, axis: str) -> None: + painter = OwnershipRuns(stem_colors) + + painter.paint( + axis, + _runs((0, 4, FIRST, True), (4, 8, SECOND, True), (8, 12, FIRST, True)), + frame_span=FRAME_SPAN, + band=BAND, + ) + + assert dpg.does_item_exist(_series(FIRST)) + assert dpg.does_item_exist(_series(SECOND)) + assert dpg.get_value(_series(FIRST))[0] == [0.0, 0.0, 8.0, 8.0, 16.0, 16.0, 24.0, 24.0] + + def test_a_recording_heard_no_more_keeps_its_series_and_changes_its_theme( + self, + stem_colors: StemColors, + axis: str, + ) -> None: + painter = OwnershipRuns(stem_colors) + painter.paint(axis, _runs((0, 4, FIRST, True), (4, 8, SECOND, True)), frame_span=FRAME_SPAN, band=BAND) + first = dpg.get_alias_id(_series(FIRST)) + heard_themes = (dpg.get_item_theme(_series(FIRST)), dpg.get_item_theme(_series(SECOND))) + + painter.paint(axis, _runs((0, 4, FIRST, False), (4, 8, SECOND, True)), frame_span=FRAME_SPAN, band=BAND) + + assert dpg.get_alias_id(_series(FIRST)) == first + assert dpg.get_item_theme(_series(FIRST)) != heard_themes[0] + assert dpg.get_item_theme(_series(SECOND)) == heard_themes[1] + + def test_moved_stretches_are_traced_again(self, stem_colors: StemColors, axis: str) -> None: + painter = OwnershipRuns(stem_colors) + painter.paint(axis, _runs((0, 4, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + + painter.paint(axis, _runs((0, 6, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + + assert dpg.get_value(_series(FIRST))[0] == [0.0, 0.0, 12.0, 12.0] + + def test_a_recording_leaving_the_lane_leaves_the_axis(self, stem_colors: StemColors, axis: str) -> None: + painter = OwnershipRuns(stem_colors) + painter.paint(axis, _runs((0, 4, FIRST, True), (4, 8, SECOND, True)), frame_span=FRAME_SPAN, band=BAND) + + painter.paint(axis, _runs((0, 8, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + + assert dpg.does_item_exist(_series(FIRST)) + assert not dpg.does_item_exist(_series(SECOND)) + + def test_an_empty_lane_leaves_nothing_painted(self, stem_colors: StemColors, axis: str) -> None: + painter = OwnershipRuns(stem_colors) + painter.paint(axis, _runs((0, 4, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + + painter.paint(axis, (), frame_span=FRAME_SPAN, band=BAND) + + assert not dpg.does_item_exist(_series(FIRST)) + + def test_a_series_taken_off_the_axis_elsewhere_is_drawn_again(self, stem_colors: StemColors, axis: str) -> None: + painter = OwnershipRuns(stem_colors) + painter.paint(axis, _runs((0, 4, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + dpg.delete_item(_series(FIRST)) + + painter.paint(axis, _runs((0, 4, FIRST, True)), frame_span=FRAME_SPAN, band=BAND) + + assert dpg.does_item_exist(_series(FIRST)) diff --git a/tests/unit/sampletones_application/view_model/shared/test_ownership.py b/tests/unit/sampletones_application/view_model/shared/test_ownership.py index 5bf8b2888..99aa41cd0 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_ownership.py +++ b/tests/unit/sampletones_application/view_model/shared/test_ownership.py @@ -5,6 +5,7 @@ from sampletones_application.view_model.shared.ownership import ( OwnershipLaneViewModel, OwnershipRunViewModel, + runs_by_owner, ) from sampletones_core.constants.enums import ChannelName @@ -59,3 +60,34 @@ def test_a_reading_writing_nothing_carries_no_stretch(self) -> None: def test_a_lane_with_no_stretches_answers_with_none(self) -> None: assert _lane().up_to(10) == () + + +class TestRunsGatheredByOwner: + """A lane's runs gather by the recording holding them, so a surface paints each recording in one piece.""" + + @staticmethod + def _runs(*entries: Tuple[int, int, int, bool]) -> Tuple[OwnershipRunViewModel, ...]: + return tuple( + OwnershipRunViewModel(start_frame=start, end_frame=end, stem_id=stem_id, heard=heard) + for start, end, stem_id, heard in entries + ) + + def test_each_recording_gathers_its_stretches_in_frame_order(self) -> None: + owners = runs_by_owner(self._runs((0, 2, 0, True), (2, 4, 1, True), (4, 6, 0, True))) + + assert owners[0].stretches == ((0, 2), (4, 6)) + assert owners[1].stretches == ((2, 4),) + + def test_the_recordings_stand_in_the_order_they_first_appear(self) -> None: + owners = runs_by_owner(self._runs((0, 2, 1, True), (2, 4, 0, True), (4, 6, 1, True))) + + assert list(owners) == [1, 0] + + def test_a_recording_left_out_reads_so_on_every_stretch(self) -> None: + owners = runs_by_owner(self._runs((0, 2, 0, False), (2, 4, 1, True), (4, 6, 0, False))) + + assert owners[0].heard is False + assert owners[1].heard is True + + def test_no_run_gathers_nothing(self) -> None: + assert runs_by_owner(()) == {} From 7f1d65acda419f63d70f03e084e62eef4f8b8942 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 11:28:50 +0200 Subject: [PATCH 17/37] Exported: the envelopes on the regeneration worker, installed at the landing --- .../application/reconstruction-edits.md | 18 ++- docs/development/application/render-thread.md | 5 +- docs/development/application/stems.md | 2 +- .../coordinators/reconstruction.py | 18 ++- .../coordinators/tabs/reconstruction.py | 12 +- .../logic/reconstruction/edit.py | 7 +- .../logic/reconstruction/envelopes.py | 45 ++++++- .../logic/reconstruction/manager.py | 60 +++++++-- .../logic/reconstruction/reconstruction.py | 22 ++-- .../logic/reconstruction/rewrites/queue.py | 15 ++- .../services/regeneration/result.py | 3 + .../services/regeneration/service.py | 12 +- .../reconstruction/test_edit_landing.py | 1 - tests/suite/regeneration.py | 1 + .../coordinators/test_reconstruction.py | 44 +++---- .../reconstruction/rewrites/test_queue.py | 41 +++++- .../logic/reconstruction/test_manager.py | 124 ++++++++++++++++++ .../reconstruction/test_reconstruction.py | 30 +++-- .../services/test_regeneration.py | 6 + .../sampletones_application/test_startup.py | 4 + 20 files changed, 370 insertions(+), 100 deletions(-) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index d7afd5327..6ed59173d 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -8,9 +8,10 @@ away the whole document, or one that replaces the document from outside the tab. A rebuild of an edited channel, and the render of the audio it sounds, run on a worker thread, so the reader can make the next change before the last one has landed and the frames keep coming while the rebuild renders. The worker takes the renders the cache holds for the document with it and hands back the -audio of every channel in play and the mix of what the reader hears, which the landing keeps in the cache -before the tab reads it. A change to what is heard whose sound the cache keeps none of is a step of the -same line, rendered by the same worker. Every rule below keeps the document as though each change had +audio of every channel in play, the mix of what the reader hears, and the envelopes each channel exports +for the recordings heard; the landing keeps the audio in the cache and installs the envelopes where the +instruments panel reads them before the tab reads either. A change to what is heard whose sound the +cache keeps none of is a step of the same line, rendered by the same worker. Every rule below keeps the document as though each change had landed before the next one was made. --- @@ -75,10 +76,13 @@ batch started is retuned again from what the sample now holds. ## A change to what is heard is a step -A box on the stems card or above the waveform changes the reader's listening, which the stems card, the -ribbon and the instruments panel answer at once. The waveform and the audio are read from the render -cache under the hearing they sound; where the cache keeps no sound for the hearing, the panel asks for a -listening step, which renders the hearing of the document as it then stands and lands like a rebuild. One +A box on the stems card or above the waveform changes the reader's listening, which the stems card and +the ribbon answer at once. 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 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 render on the worker too. The showing the panel owes happens once the sound lands, and a hearing the diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index b945794f7..d30fb50ef 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -20,8 +20,9 @@ continues. Every background result reaches UI state this way. Applying one to UI worker thread is forbidden. A result carries what the landing installs. The regeneration renders the edited channel's audio on its -worker, and the mix of what the reader hears, and hands them back inside its result; the landing keeps -them in the render cache on the render thread. A reading the cache cannot answer, such as a recording +worker, the mix of what the reader hears and the envelopes each channel exports for the recordings heard, +and hands them back inside its result; the landing keeps the audio in the render cache and installs the +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. diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index c0b05062e..d5258c69a 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; a hearing the cache keeps no sound for is rendered on the regeneration 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 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, 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/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index bc87a21ab..dce87200c 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -420,7 +420,12 @@ def apply_edit(self, edit: ReconstructionEdit) -> None: the tab shows it. """ self._on_reconstruction_updated_callback(edit) - self._reconstruction_manager.apply_edited(edit.reconstruction) + match edit: + case ChannelEdit(): + self._reconstruction_manager.apply_edited(edit.reconstruction, edit.envelopes) + case Retune() | StemRemoval(): + self._reconstruction_manager.apply_edited(edit.reconstruction) + self._show_edit(edit) self._reconstruction_manager.mark_updated() @@ -561,9 +566,14 @@ def _redraw_open_document(self) -> None: if self.is_loaded(): self._tab.redraw_reconstruction(refit_waveform=False) - def _show_listening(self) -> None: - """Shows what the reader hears once the worker has rendered it for the open document.""" - self._tab.redraw_listening() + def _show_listening(self, envelopes_changed: bool) -> None: + """Shows what the reader hears once the worker has rendered it for the open document. + + Args: + envelopes_changed: Whether the rendering brought envelopes the instruments panel has + yet to draw. + """ + self._tab.redraw_listening(envelopes_changed=envelopes_changed) def _report_rebuild_failure(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Regeneration failed") diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index da49bb6a0..72ba64f23 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -306,7 +306,6 @@ def __init__( self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view self._reconstruction_panel_logic.on_stems_view_changed = self._reconstruction_stems_panel.update_view self._reconstruction_panel_logic.on_ownership_changed = self._reconstruction_plot_panel.update_ownership - self._reconstruction_panel_logic.on_heard_changed = self._reconstruction_instruments_logic.update_display self._reconstruction_panel_logic.on_audio_data_changed = self._on_audio_data_changed self._reconstruction_panel_logic.on_waveform_load_changed = self._reconstruction_plot_panel.load_waveform_data self._reconstruction_panel_logic.on_waveform_update_changed = ( @@ -812,9 +811,16 @@ def redraw_reconstruction(self, *, refit_waveform: bool) -> None: self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.update_display() - def redraw_listening(self) -> None: - """Shows what the reader hears once the worker has rendered it, where a showing waited for it.""" + def redraw_listening(self, *, envelopes_changed: bool) -> None: + """Shows what the reader hears once the worker has rendered it, where a showing waited for it. + + A box on the stems card changes the part the instruments panel draws, so the panel is + redrawn when the rendering brought envelopes for a new choice; a box above the waveform + leaves them as they were. + """ self._reconstruction_panel_logic.redraw_listening() + if envelopes_changed: + self._reconstruction_instruments_logic.update_display() def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py index 7f9643183..997a51b5b 100644 --- a/src/sampletones_application/logic/reconstruction/edit.py +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -3,21 +3,24 @@ from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.transaction import CoalesceKey +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction @dataclass(frozen=True) class ChannelEdit: - """A regenerated instrument paired with the channel and feature the reader moved. + """A regenerated instrument paired with the channel and feature the reader moved, and its envelopes. Carrying the request context alongside the fresh reconstruction lets the project history - record which channel and feature an edit touched. + record which channel and feature an edit touched. The envelopes the worker exported travel + with it, so the landing installs them where the instruments panel reads them. """ reconstruction: Reconstruction channel_name: ChannelName feature_key: FeatureKey + envelopes: HeardEnvelopes @property def history_action(self) -> HistoryAction: diff --git a/src/sampletones_application/logic/reconstruction/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py index 48ab6307f..b598feea6 100644 --- a/src/sampletones_application/logic/reconstruction/envelopes.py +++ b/src/sampletones_application/logic/reconstruction/envelopes.py @@ -1,4 +1,5 @@ -from typing import Dict +from dataclasses import dataclass +from typing import Dict, Mapping from sampletones_application.logic.reconstruction.ownership import ( ownership_lanes, @@ -15,11 +16,27 @@ from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection +@dataclass(frozen=True) +class HeardEnvelopes: + """The envelopes a document exports for one choice of recordings, beside that choice. + + The worker exports them with the sound it renders, so a landing installs envelopes made for + the selection it names and tells a stale reading from a fresh one by that selection. + + Attributes: + selection: The recordings the reader heard when the envelopes were exported. + channels: The envelopes each channel exports for them. + """ + + selection: StemSelection + channels: Mapping[ChannelName, Features] + + def heard_envelopes( reconstruction: Reconstruction, selection: StemSelection, ) -> ChannelEnvelopesViewModel: - """The envelopes of the part each channel plays for the recordings a reader hears. + """The envelopes of the part each channel plays for the recordings a reader hears, exported here. A reconstruction exports one entry per channel whatever it sounds, so a subscript answers for any of them and :attr:`Features.has_frames` says which ones play. What is drawn, what is @@ -34,13 +51,27 @@ def heard_envelopes( Returns: ChannelEnvelopesViewModel: One entry per channel the reconstruction exports. """ - channels = { - ChannelName(generator_name): features - for generator_name, features in reconstruction.export_heard(selection).items() - } + return envelopes_view(reconstruction, HeardEnvelopes(selection, reconstruction.export_heard(selection))) + + +def envelopes_view( + reconstruction: Reconstruction, + heard: HeardEnvelopes, +) -> ChannelEnvelopesViewModel: + """The envelopes the instruments panel draws, built from what the document exported for the reader. + + Args: + reconstruction: The reconstruction the envelopes were exported from. + heard: The envelopes each channel exports for the recordings heard, beside that choice. + + Returns: + ChannelEnvelopesViewModel: One entry per channel the reconstruction exports, with the + stretches under each channel's bars. + """ + channels = {ChannelName(generator_name): features for generator_name, features in heard.channels.items()} return ChannelEnvelopesViewModel( channels=channels, - ownership=_lanes(reconstruction, selection, channels), + ownership=_lanes(reconstruction, heard.selection, channels), ) diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index a2919b10a..9abfce7a2 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -5,7 +5,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view, heard_envelopes from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.session import ReconstructionSession from sampletones_application.logic.shared.renders import RenderCache @@ -14,6 +14,7 @@ ChannelEnvelopesViewModel, ) from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -44,6 +45,7 @@ def __init__( self._session: ReconstructionSession = ReconstructionSession() self._current_reconstruction: Optional[ReconstructionData] = None self._current_features: Optional[ChannelEnvelopesViewModel] = None + self._features_selection: Optional[StemSelection] = None self._voice_id: Optional[str] = None self._listening: Listening = Listening() @@ -112,6 +114,7 @@ def _adopt_reconstruction( reconstruction_data: ReconstructionData, *, voice_id: Optional[str], + envelopes: Optional[HeardEnvelopes] = None, ) -> None: """Makes ``reconstruction_data`` the open document and refreshes its derived state. @@ -119,13 +122,19 @@ def _adopt_reconstruction( open, so every rebinding funnels through here to recompute them in one place. The listening is carried onto the new record before the features are read, so the envelopes 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. + 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. """ self._current_reconstruction = reconstruction_data self._voice_id = voice_id reconstruction = reconstruction_data.reconstruction self._listening.adopt(reconstruction.stems_data, frozenset(reconstruction.playing_channels)) - self._load_reconstruction_features() + if envelopes is not None and envelopes.selection == self._listening.selection: + self._install_envelopes(envelopes) + else: + self._load_reconstruction_features() def _load_reconstruction_features(self) -> None: """Reads the envelopes of the part the reader is listening to. @@ -137,16 +146,37 @@ def _load_reconstruction_features(self) -> None: raise RuntimeError("No reconstruction is loaded when trying to load features") reconstruction = self._current_reconstruction.reconstruction - self._current_features = heard_envelopes(reconstruction, self._listening.selection) + selection = self._listening.selection + self._current_features = heard_envelopes(reconstruction, selection) + self._features_selection = selection + + def _install_envelopes(self, envelopes: HeardEnvelopes) -> None: + if self._current_reconstruction is None: + raise RuntimeError("No reconstruction is loaded when trying to install envelopes") + + self._current_features = envelopes_view(self._current_reconstruction.reconstruction, envelopes) + self._features_selection = envelopes.selection - def refresh_features(self) -> None: - """Reads the envelopes again after a change to what the reader is listening to. + def adopt_envelopes(self, reconstruction: Reconstruction, envelopes: HeardEnvelopes) -> bool: + """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document. - A box on the stems card moves which recordings a channel is heard on, and the plot, - the figures and an export all read from the same envelopes, so they follow it together. + A box on the stems card moves which recordings a channel is heard on, and the plot, the + figures and an export all read from the same envelopes, so they follow it together once + the worker's reading lands. Envelopes exported for the choice the open ones already answer + change nothing. + + Returns: + bool: True where the open document's envelopes changed. """ - if self._current_reconstruction is not None: - self._load_reconstruction_features() + if self.reconstruction is not reconstruction or envelopes.selection == self._features_selection: + return False + + self._install_envelopes(envelopes) + return True + + def has_envelopes_for(self, selection: StemSelection) -> bool: + """Whether the open document's envelopes were exported for ``selection``.""" + return self._current_features is not None and self._features_selection == selection def save_reconstruction(self, filepath: Optional[Path] = None) -> bool: """Writes the open reconstruction to disk, reporting whether the write happened. @@ -210,8 +240,12 @@ def detach_current_reconstruction(self) -> None: name=name, ) - def apply_edited(self, reconstruction: Reconstruction) -> None: - """Adopts a reconstruction an edit produced. + def apply_edited( + self, + reconstruction: Reconstruction, + envelopes: Optional[HeardEnvelopes] = None, + ) -> None: + """Adopts a reconstruction an edit produced, with the envelopes its worker exported where it had one. The open document rebinds to the fresh reconstruction object and stays the voice it was, while the previous object is left untouched for the history to retain. @@ -222,6 +256,7 @@ def apply_edited(self, reconstruction: Reconstruction) -> None: self._adopt_reconstruction( self._current_reconstruction.with_reconstruction(reconstruction), voice_id=self._voice_id, + envelopes=envelopes, ) def mark_updated(self) -> None: @@ -230,6 +265,7 @@ def mark_updated(self) -> None: def close_reconstruction(self) -> None: self._current_reconstruction = None self._current_features = None + self._features_selection = None self._voice_id = None self._listening.release() self._session.mark_closed() diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index e1ef9cd9a..2653558e4 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -149,7 +149,6 @@ def __init__( self.on_locate_audio_not_found: Optional[PathCallback] = None self.on_stems_view_changed: Optional[Callable[[ReconstructionStemsViewModel], None]] = None self.on_ownership_changed: Optional[Callable[[OwnershipRibbonViewModel], None]] = None - self.on_heard_changed: Optional[VoidCallback] = None def display_reconstruction(self) -> None: reconstruction_data = self._reconstruction_data @@ -207,12 +206,15 @@ def redraw_listening(self) -> None: def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: """Draws and plays what the reader hears, or asks for its sound where the screen holds none yet. - The audio is read from what is kept, so a showing computes no sample on the render thread. - One the cache cannot answer is owed: the worker is asked to render it, and the showing - happens once the sound lands. + The audio is read from what is kept, so a showing computes no sample on the render thread, + and the envelopes the instruments panel draws are read as the worker exported them for the + recordings heard. A showing the cache cannot answer in full, for want of the sound or of + envelopes for the recordings heard, is owed: the worker is asked to render it, and the + showing happens once the sound lands. """ - waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) - if waveform is None: + hearing = self._listening.hearing + waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, hearing) + if waveform is None or not self._reconstruction_manager.has_envelopes_for(hearing.selection): self._owed = showing self.call(self.on_renders_wanted) return @@ -345,8 +347,11 @@ def solo_stem(self, stem_id: int) -> None: self._refresh_listening() def _refresh_listening(self) -> None: - """Re-answers every reading of the document after the listening choice changed.""" - self._reconstruction_manager.refresh_features() + """Re-answers every reading of the document after the listening choice changed. + + The stems card and the ribbon answer at once; the waveform, the audio and the envelopes + the instruments panel draws answer once the worker has rendered the new choice. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -357,7 +362,6 @@ def _refresh_listening(self) -> None: ) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) - self.call(self.on_heard_changed) @property def _listening(self) -> Listening: diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index f2ee7f8d2..77e51870c 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -7,6 +7,7 @@ Retune, StemRemoval, ) +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.rewrites.regeneration import ( RegenerationServiceProtocol, @@ -92,9 +93,10 @@ class ReconstructionRewrites(CallbackMixin): redrawn once the line empties. The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that - lands, ``on_heard_rendered`` once a listening's sound is kept for the open document, - ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a job's - failure, and ``on_busy_changed`` as the line fills and empties. + lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with + whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel + draws a change that will never land, ``on_failed`` with a job's failure, and + ``on_busy_changed`` as the line fills and empties. """ def __init__( @@ -110,7 +112,7 @@ def __init__( self._reported_busy: bool = False self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None - self.on_heard_rendered: Optional[VoidCallback] = None + self.on_heard_rendered: Optional[Callable[[bool], None]] = None self.on_dropped: Optional[VoidCallback] = None self.on_failed: Optional[Callable[[Exception], None]] = None self.on_busy_changed: Optional[Callable[[bool], None]] = None @@ -337,6 +339,7 @@ def _land(self, running: Running, outcome: RenderedDocument) -> None: self._manager.renders.adopt(outcome.reconstruction, outcome.channels, outcome.mix) self._manager.renders.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + envelopes = HeardEnvelopes(selection=outcome.hearing.selection, channels=outcome.envelopes) match running: case Rebuild(change=change): self.call( @@ -345,10 +348,12 @@ def _land(self, running: Running, outcome: RenderedDocument) -> None: reconstruction=outcome.reconstruction, channel_name=change.channel_name, feature_key=change.feature_key, + envelopes=envelopes, ), ) case Listening(): - self.call(self.on_heard_rendered) + changed = self._manager.adopt_envelopes(outcome.reconstruction, envelopes) + self.call(self.on_heard_rendered, changed) def _let_go(self, running: Running) -> None: """Redraws the panel where it still draws a change that will never land. diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index 8a5c1092a..38203a531 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -8,6 +8,7 @@ ServiceSuccess, ) 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 HeardRenders, Hearing @@ -26,6 +27,7 @@ class RenderedDocument: mix: The whole document, summed from ``channels``. hearing: What the reader heard when the renders were made. heard: What ``hearing`` sounds of the document. + envelopes: The envelopes each channel exports for the recordings ``hearing`` hears. """ reconstruction: Reconstruction @@ -33,6 +35,7 @@ class RenderedDocument: mix: np.ndarray hearing: Hearing heard: HeardRenders + envelopes: Mapping[ChannelName, Features] RegenerationResult = Union[ diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 125691847..1787fcd0b 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -29,11 +29,12 @@ class RegenerationService(ServiceBase[RegenerationResult]): recording's part be shaped while the recordings beside it carry on. A rebuild's result is a fresh reconstruction carrying the updated generator data, with the - audio of every sounding channel, their mix, and what the hearing sounds of it; the source - reconstruction is left intact. Producing a new object lets callers swap the edited - reconstruction in while any history snapshot that shares the prior object stays valid. A - listening renders the same reading of the document as it stands, for a hearing the screen - holds no audio for yet. Carrying the audio lets the landing install it where the screen reads + audio of every sounding channel, their mix, what the hearing sounds of it, and the envelopes + each channel exports for the recordings heard; the source reconstruction is left intact. + Producing a new object lets callers swap the edited reconstruction in while any history + snapshot that shares the prior object stays valid. A listening renders the same reading of the + document as it stands, for a hearing the screen holds no audio for yet. Carrying the audio + lets the landing install it where the screen reads it, so the render thread keeps drawing while the worker renders. One job runs at a time on a single worker thread. Its caller starts the next job once the @@ -111,6 +112,7 @@ def _run(self, job: RenderJob) -> None: mix=mixed, hearing=job.hearing, heard=heard_renders(document, job.hearing, channels, mixed), + envelopes=document.export_heard(job.hearing.selection), ) ) ) diff --git a/tests/benchmarks/reconstruction/test_edit_landing.py b/tests/benchmarks/reconstruction/test_edit_landing.py index 855a6908f..64d5b6ce6 100644 --- a/tests/benchmarks/reconstruction/test_edit_landing.py +++ b/tests/benchmarks/reconstruction/test_edit_landing.py @@ -109,7 +109,6 @@ def __init__(self, scheduling: SchedulingBehavior) -> None: self.panel.on_waveform_source_changed = _discard self.panel.on_stems_view_changed = _discard self.panel.on_ownership_changed = _discard - self.panel.on_heard_changed = _discard self.panel.on_renders_wanted = lambda: self.rewrites.request(ListeningChange()) self.coordinator = ReconstructionCoordinator( self.manager, diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py index 0c38d4554..2972714e8 100644 --- a/tests/suite/regeneration.py +++ b/tests/suite/regeneration.py @@ -75,6 +75,7 @@ def finish_with(self, reconstruction: Reconstruction) -> None: mix=mixed, hearing=job.hearing, heard=heard_renders(reconstruction, job.hearing, channels, mixed), + envelopes=reconstruction.export_heard(job.hearing.selection), ) ) ) diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index d400af1e0..6d1807128 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -19,6 +19,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.edit import ChannelEdit, ReconstructionEdit, Retune, StemRemoval +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites from sampletones_application.logic.reconstruction.rewrites.steps import ( @@ -67,10 +68,23 @@ STEM_B_ID, TAKING_TURNS_PITCH, TAKING_TURNS_VOLUME, + everything_heard, taking_turns, taking_turns_file, ) + +def _channel_edit(reconstruction: Reconstruction) -> ChannelEdit: + """A regenerated first pulse of ``reconstruction``, with the envelopes its worker would export heard whole.""" + selection = everything_heard(reconstruction) + return ChannelEdit( + reconstruction=reconstruction, + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + envelopes=HeardEnvelopes(selection=selection, channels=reconstruction.export_heard(selection)), + ) + + __all__ = [ "held_frames", "held_queue", @@ -364,13 +378,7 @@ def test_a_regenerated_instrument_keeps_the_envelopes_the_panel_draws( reconstruction_factory: ReconstructionFactory, ) -> None: """The regeneration carries what the reader typed, so a field being typed in keeps its text.""" - reconstruction_coordinator.apply_edit( - ChannelEdit( - reconstruction=reconstruction_factory(), - channel_name=ChannelName.PULSE1, - feature_key=FeatureKey.VOLUME, - ) - ) + reconstruction_coordinator.apply_edit(_channel_edit(reconstruction_factory())) tab.update_reconstruction.assert_called_once_with() tab.redraw_reconstruction.assert_not_called() @@ -418,11 +426,7 @@ def test_every_edit_leaves_the_document_unsaved( reconstruction_factory: ReconstructionFactory, ) -> None: edits = { - "channel": ChannelEdit( - reconstruction=reconstruction_factory(), - channel_name=ChannelName.PULSE1, - feature_key=FeatureKey.VOLUME, - ), + "channel": _channel_edit(reconstruction_factory()), "retune": Retune(reconstruction=reconstruction_factory(), nes_frequency=RETUNED_FREQUENCY), "removal": StemRemoval(reconstruction=reconstruction_factory(), stem_name="kick"), } @@ -763,13 +767,7 @@ def test_an_undo_keeping_the_sample_shows_the_reconstruction_it_restores( reconstruction_factory: ReconstructionFactory, ) -> None: original = open_sample.reconstruction - following_coordinator.apply_edit( - ChannelEdit( - reconstruction=reconstruction_factory(), - channel_name=ChannelName.PULSE1, - feature_key=FeatureKey.VOLUME, - ) - ) + following_coordinator.apply_edit(_channel_edit(reconstruction_factory())) tab.reset_mock() history.undo() @@ -901,13 +899,7 @@ def test_an_edit_writing_the_project_first_leaves_the_document_to_the_edit( """The write-back stamps the project while the document still holds what the edit started from.""" edited = reconstruction_factory() - following_coordinator.apply_edit( - ChannelEdit( - reconstruction=edited, - channel_name=ChannelName.PULSE1, - feature_key=FeatureKey.VOLUME, - ) - ) + following_coordinator.apply_edit(_channel_edit(edited)) assert reconstruction_manager.reconstruction is edited tab.redraw_reconstruction.assert_not_called() 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 f31a14682..9b14de8d7 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -65,7 +65,7 @@ class Outcome: """Everything the rewrites reported, in the order they reported it.""" edits: List[ReconstructionEdit] = field(default_factory=list) - heard: int = 0 + heard: List[bool] = field(default_factory=list) dropped: int = 0 failures: List[Exception] = field(default_factory=list) busy: List[bool] = field(default_factory=list) @@ -98,8 +98,8 @@ def adopt(edit: ReconstructionEdit) -> None: def drop() -> None: outcome.dropped += 1 - def heard() -> None: - outcome.heard += 1 + def heard(envelopes_changed: bool) -> None: + outcome.heard.append(envelopes_changed) rewrites = ReconstructionRewrites(reconstruction_manager, regeneration) rewrites.on_edit = adopt @@ -622,9 +622,40 @@ def test_the_sound_lands_and_is_reported( hearing = reconstruction_manager.listening.hearing document = reconstruction_manager.reconstruction assert document is not None - assert outcome.heard == 1 + assert outcome.heard == [True] assert outcome.edits == [] assert reconstruction_manager.renders.reading(document, hearing) is not None + assert reconstruction_manager.has_envelopes_for(hearing.selection) + + def test_a_listening_for_the_choice_already_drawn_reports_no_new_envelopes( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + reconstruction_manager.listening.set_channels_on(frozenset({SHARED_CHANNEL})) + rewrites.request(ListeningChange()) + + regeneration.finish() + + assert outcome.heard == [False] + + def test_a_landed_rebuild_carries_the_envelopes_its_worker_exported( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + regeneration.finish() + + (edit,) = outcome.edits + assert isinstance(edit, ChannelEdit) + assert edit.envelopes.selection == reconstruction_manager.listening.selection + assert edit.envelopes.channels == edit.reconstruction.export_heard(edit.envelopes.selection) def test_the_hearing_is_read_at_the_steps_turn( self, @@ -710,7 +741,7 @@ def test_a_listening_for_a_document_put_away_lands_nowhere( regeneration.finish() - assert outcome.heard == 0 + assert outcome.heard == [] assert outcome.dropped == 0 assert reconstruction_manager.renders.held_bytes == 0 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 9033f888f..865342fbc 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -5,6 +5,8 @@ import numpy as np import pytest +from sampletones_application.logic.reconstruction import manager as manager_module +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.shared.renders import RenderCache from sampletones_core.audio import write_wave @@ -14,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.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 @@ -823,3 +826,124 @@ def test_locate_audio_reports_the_first_missing_path( reconstruction_manager.locate_original_audio() assert raised.value.filename == str(missing) + + +def _exported( + reconstruction: Reconstruction, + selection: StemSelection, + *, + muted: bool = False, +) -> HeardEnvelopes: + """What the worker exports for ``reconstruction`` heard as ``selection``, or with every recording left out.""" + if muted: + selection = selection.model_copy(update={"channels": {name: frozenset() for name in selection.channels}}) + + return HeardEnvelopes(selection=selection, channels=reconstruction.export_heard(selection)) + + +class TestEnvelopesTheWorkerExported: + """The envelopes a landing installs are the worker's, read for the recordings heard, and a reading made for + another document or for the choice already drawn is passed over. + """ + + def test_a_landing_installs_them_for_the_open_document( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + muted = _exported(reconstruction, reconstruction_manager.listening.selection, muted=True) + + changed = reconstruction_manager.adopt_envelopes(reconstruction, muted) + + assert changed + assert reconstruction_manager.has_envelopes_for(muted.selection) + features = reconstruction_manager.current_features + assert features is not None + assert not features[ChannelName.PULSE1].has_frames + + def test_a_reading_for_the_choice_already_drawn_changes_nothing( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + before = reconstruction_manager.current_features + + changed = reconstruction_manager.adopt_envelopes( + reconstruction, + _exported(reconstruction, reconstruction_manager.listening.selection), + ) + + assert not changed + assert reconstruction_manager.current_features is before + + def test_a_reading_for_another_document_is_passed_over( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + other = reconstruction_factory() + selection = reconstruction_manager.listening.selection + + changed = reconstruction_manager.adopt_envelopes(other, _exported(other, selection, muted=True)) + + assert not changed + assert reconstruction_manager.has_envelopes_for(selection) + + def test_an_edit_arriving_with_envelopes_spares_the_reading( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + edited = reconstruction_factory() + selection = reconstruction_manager.listening.selection + monkeypatch.setattr(manager_module, "heard_envelopes", MagicMock(side_effect=AssertionError("read here"))) + + reconstruction_manager.apply_edited(edited, _exported(edited, selection)) + + 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( + 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 + edited = reconstruction.with_channel_data( + ChannelName.PULSE1, + [PulseInstruction(on=True, pitch=60, volume=7, duty_cycle=0)] * 2, + 60, + (), + heard=reconstruction.recorded_stem_ids, + ) + + reconstruction_manager.apply_edited(edited, _exported(edited, before)) + + after = reconstruction_manager.listening.selection + assert after != before + assert reconstruction_manager.has_envelopes_for(after) + + def test_an_edit_arriving_bare_is_read_on_the_spot( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + edited = reconstruction_factory() + + reconstruction_manager.apply_edited(edited) + + assert reconstruction_manager.has_envelopes_for(reconstruction_manager.listening.selection) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 254a7b106..b6cf28b02 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -97,7 +97,8 @@ def mock_reconstruction_manager() -> MagicMock: mock.is_project_sample = False mock.audio_filepath = None mock.listening = Listening() - mock.refresh_features.side_effect = lambda: _refresh_features(mock) + mock.features_selection = None + mock.has_envelopes_for.side_effect = lambda selection: mock.features_selection == selection mock.renders = RenderCache(budget_bytes=RENDER_BUDGET) return mock @@ -130,18 +131,20 @@ def _rebind(manager: MagicMock, reconstruction_data: ReconstructionData) -> None def _hear(manager: MagicMock) -> None: - """Keeps the sound of what the reader hears now, the way a listening's landing does.""" + """Keeps the sound and the envelopes of what the reader hears now, the way a listening's landing does.""" reconstruction_data = manager.current_reconstruction + _refresh_features(manager) warmed(manager.renders, reconstruction_data.reconstruction, manager.listening.hearing) def _refresh_features(manager: MagicMock) -> None: - """Reads the envelopes of what is heard, the way the manager's own refresh does.""" + """Installs the envelopes of what is heard, the way a landing installs what the worker exported.""" if manager.current_reconstruction is not None: manager.current_features = heard_envelopes( manager.current_reconstruction.reconstruction, manager.listening.selection, ) + manager.features_selection = manager.listening.selection @pytest.fixture @@ -1537,25 +1540,27 @@ def test_an_export_writes_what_the_panel_draws( panel_logic.display_reconstruction() panel_logic.set_stem_channels(1, frozenset()) + _hear(mock_reconstruction_manager) exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) assert exportable is not None assert exportable.source.features == mock_reconstruction_manager.current_features[ChannelName.PULSE1] - def test_the_reader_is_told_the_envelopes_moved( + def test_a_box_asks_the_worker_for_the_envelopes_it_moves( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, stems_data: ReconstructionData, ) -> None: + """The envelopes follow the choice once the worker exports them, so the box asks for a rendering.""" _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() - reported: List[None] = [] - panel_logic.on_heard_changed = lambda: reported.append(None) + asked = MagicMock() + panel_logic.on_renders_wanted = asked panel_logic.set_stem_channels(1, frozenset()) - assert len(reported) == 1 + asked.assert_called_once() def test_a_solo_leaves_the_envelopes_of_the_recording_alone( self, @@ -1568,6 +1573,7 @@ def test_a_solo_leaves_the_envelopes_of_the_recording_alone( whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count panel_logic.solo_stem(0) + _hear(mock_reconstruction_manager) assert mock_reconstruction_manager.listening.heard[1] == frozenset() assert 0 < mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count < whole @@ -1583,11 +1589,13 @@ def test_a_second_solo_returns_to_the_whole_mix( whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count panel_logic.solo_stem(0) + _hear(mock_reconstruction_manager) panel_logic.solo_stem(0) + _hear(mock_reconstruction_manager) assert mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count == whole - def test_a_solo_tells_the_reader_the_envelopes_moved_once( + def test_a_solo_asks_the_worker_once( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -1595,12 +1603,12 @@ def test_a_solo_tells_the_reader_the_envelopes_moved_once( ) -> None: _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() - reported: List[None] = [] - panel_logic.on_heard_changed = lambda: reported.append(None) + asked = MagicMock() + panel_logic.on_renders_wanted = asked panel_logic.solo_stem(0) - assert len(reported) == 1 + asked.assert_called_once() class TestTheLanesTheRibbonStandsOn: diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 8e2998b02..7c8754798 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -429,6 +429,12 @@ def test_a_render_handed_for_the_rebuilt_channel_is_passed_over(self, taking_tur assert outcome.channels[SHARED_CHANNEL] is not stale + def test_the_result_carries_the_envelopes_exported_for_the_hearing(self, taking_turns: Reconstruction) -> None: + """The instruments panel reads the worker's export, so a landing installs envelopes made for the hearing.""" + outcome = _rebuilt(taking_turns, NO_RENDERS) + + assert outcome.envelopes == outcome.reconstruction.export_heard(outcome.hearing.selection) + def test_the_result_sounds_the_hearing_it_was_asked_with(self, taking_turns: Reconstruction) -> None: """The landing reads what the reader hears from the result, so the result carries that reading.""" outcome = _rebuilt(taking_turns, NO_RENDERS) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index cafb1b3af..43cb3a71b 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -17,6 +17,7 @@ from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.reconstruction.edit import ChannelEdit +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -87,6 +88,7 @@ headless_application, profile_in, ) +from tests.suite.stems import everything_heard REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} DRAG_PAYLOAD_SLOT: Final[int] = 3 @@ -507,11 +509,13 @@ def _edit(app: Application, reconstruction_factory: ReconstructionFactory) -> Re it lands names none either. """ edited = reconstruction_factory().detached() + selection = everything_heard(edited) app._reconstruction_coordinator.apply_edit( ChannelEdit( reconstruction=edited, channel_name=ChannelName.PULSE1, feature_key=FeatureKey.VOLUME, + envelopes=HeardEnvelopes(selection=selection, channels=edited.export_heard(selection)), ) ) return edited From 35441451b50bd3f51bea7083469b722f600fe880 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 11:32:16 +0200 Subject: [PATCH 18/37] Kept: each waveform's reduction to the display's points by the array's identity --- .../ui/elements/graphs/layers/array.py | 28 +++++-- .../ui/elements/graphs/layers/decimation.py | 70 ++++++++++++++++ .../ui/elements/graphs/waveform.py | 79 ++++++++--------- .../ui/elements/graphs/test_decimation.py | 84 +++++++++++++++++++ .../ui/elements/graphs/test_waveform.py | 2 + 5 files changed, 218 insertions(+), 45 deletions(-) create mode 100644 src/sampletones_application/ui/elements/graphs/layers/decimation.py create mode 100644 tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py index 87ec8abac..07e7136d8 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/array.py +++ b/src/sampletones_application/ui/elements/graphs/layers/array.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional, Tuple import numpy as np @@ -9,20 +10,33 @@ @dataclass(frozen=True) class ArrayLayer(Layer): + """A waveform drawn as a line, reduced to the points the display shows. + + Attributes: + data: The waveform, one-dimensional. + name: The name the series is labeled by. + color: The color the line is drawn in. + max_display_points: How many points the display shows at most. + points: The positions and values already reduced for ``data``, where a display keeps them; + left out, the layer reduces ``data`` itself. + """ + data: np.ndarray name: str color: BaseColor max_display_points: int + points: Optional[Tuple[np.ndarray, np.ndarray]] = None def __post_init__(self) -> None: - y_data = self.data.astype(np.float32) - length = y_data.shape[0] - - assert y_data.ndim == 1, "ArrayLayer data must be a 1D numpy array" - if length > self.max_display_points: - x_data, y_data = minmax_decimate(y_data, num_buckets=self.max_display_points) + if self.points is not None: + x_data, y_data = self.points else: - x_data = np.arange(y_data.shape[0], dtype=np.float32) + y_data = self.data.astype(np.float32, copy=False) + assert y_data.ndim == 1, "ArrayLayer data must be a 1D numpy array" + if y_data.shape[0] > self.max_display_points: + x_data, y_data = minmax_decimate(y_data, num_buckets=self.max_display_points) + else: + x_data = np.arange(y_data.shape[0], dtype=np.float32) object.__setattr__(self, "x_data", x_data) object.__setattr__(self, "y_data", y_data) diff --git a/src/sampletones_application/ui/elements/graphs/layers/decimation.py b/src/sampletones_application/ui/elements/graphs/layers/decimation.py new file mode 100644 index 000000000..156af2d7e --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/layers/decimation.py @@ -0,0 +1,70 @@ +import weakref +from collections import OrderedDict +from typing import Final, Tuple + +import numpy as np + +from sampletones_core.audio import minmax_decimate + +KEPT_DECIMATIONS: Final[int] = 8 + +Points = Tuple[np.ndarray, np.ndarray] + + +class Decimations: + """The points a display draws an array as, kept by the array's identity for as long as the array lives. + + A render the cache keeps is read-only and keeps its identity for as long as the cache holds it, + so the min-max reduction a display draws it as is made once and read back on every showing. A + reduction goes when its array is collected, before the identity can name another, and the + reductions read longest ago go first beyond a small number kept, so the memo holds the few + arrays a display shows. The reduction keeps every extreme, so the peak of the points is the + array's own. + """ + + def __init__(self, max_display_points: int) -> None: + self._max_display_points = max_display_points + self._points: OrderedDict[int, Points] = OrderedDict() + + def points(self, audio: np.ndarray) -> Points: + """The positions and the values a display draws ``audio`` as. + + An array longer than the points the display shows reduces to its extremes per bucket; a + shorter one draws every sample. + + Args: + audio: The waveform, one-dimensional. + + Returns: + Points: The positions along the array and the value at each. + """ + kept = self._points.get(id(audio)) + if kept is not None: + self._points.move_to_end(id(audio)) + return kept + + points = self._reduced(audio) + self._points[id(audio)] = points + weakref.finalize(audio, self._forget, id(audio)) + while len(self._points) > KEPT_DECIMATIONS: + self._points.popitem(last=False) + + return points + + def peak(self, audio: np.ndarray) -> float: + """The farthest ``audio`` reaches from silence, read from the points it draws as.""" + _, values = self.points(audio) + return float(np.max(np.abs(values))) if values.size else 0.0 + + def _reduced(self, audio: np.ndarray) -> Points: + values = audio.astype(np.float32, copy=False) + if values.ndim != 1: + raise ValueError("A display draws a one-dimensional waveform") + + if values.shape[0] > self._max_display_points: + return minmax_decimate(values, num_buckets=self._max_display_points) + + return np.arange(values.shape[0], dtype=np.float32), values + + def _forget(self, identity: int) -> None: + self._points.pop(identity, None) diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 1bc6202aa..a42357c87 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -29,6 +29,7 @@ from sampletones_application.ui.elements.graphs.gesture import PlotClickGesture from sampletones_application.ui.elements.graphs.graph import GUIGraph from sampletones_application.ui.elements.graphs.layers.array import ArrayLayer +from sampletones_application.ui.elements.graphs.layers.decimation import Decimations from sampletones_application.ui.elements.graphs.layers.instruction import ( InstructionLayer, ) @@ -86,6 +87,7 @@ def __init__( ): self._language_manager = language_manager self._layout = layout + self._decimations = Decimations(layout.waveform.max_display_points) self._channel_colors = channel_colors self._status_bar = status_bar @@ -438,34 +440,7 @@ def load_voice_waveform( self._reconstruction_dimmed = False self._sample_rate = 0 self.clear_layers() - self.add_layer( - ArrayLayer( - data=audio, - name=name, - color=color, - max_display_points=self._layout.waveform.max_display_points, - ) - ) - - def _extract_reconstruction_layer_data( - self, - waveform_data: WaveformData, - ) -> Tuple[Optional[np.ndarray], np.ndarray, float]: - original_audio = waveform_data.original_audio - approximation = waveform_data.playing - full_approximation = waveform_data.approximation - - if not self.reconstruction_autoscale or original_audio is None: - return original_audio, approximation, 1.0 - - original_audio = original_audio / waveform_data.coefficient - - coefficient = max( - np.max(np.abs(full_approximation)), - np.max(np.abs(original_audio)), - ) - - return original_audio, approximation, coefficient + self.add_layer(self._array_layer(audio, name=name, color=color)) def _display_layers( self, @@ -475,14 +450,16 @@ def _display_layers( The original-audio layer joins the reconstruction layer while the source audio is loaded, so a reconstruction whose recordings are missing or unreadable shows the approximation on - its own. + its own. With the autoscale on, the original is drawn at the level the reconstruction was + scaled to, so the two lines compare. """ - original_audio, approximation_data, _ = self._extract_reconstruction_layer_data(waveform_data) - reconstruction_layer = self.reconstruction_layer(approximation_data) + reconstruction_layer = self.reconstruction_layer(waveform_data.playing) + original_audio = waveform_data.original_audio if original_audio is None: return [reconstruction_layer] - sample_layer = self.sample_layer(original_audio) + divisor = waveform_data.coefficient if self.reconstruction_autoscale else 1.0 + sample_layer = self.sample_layer(original_audio, divisor=divisor) return self._ordered_layers(sample_layer, reconstruction_layer) def update_waveform_data( @@ -545,19 +522,45 @@ def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._bind_series_theme(series_tag, layer) def reconstruction_layer(self, data: np.ndarray) -> ArrayLayer: - return ArrayLayer( - data=data, + return self._array_layer( + data, name=self._lbl_waveform_reconstruction, color=self._layout.colors.waveform_reconstruction, - max_display_points=self._layout.waveform.max_display_points, ) - def sample_layer(self, data: np.ndarray) -> ArrayLayer: - return ArrayLayer( - data=data, + def sample_layer(self, data: np.ndarray, *, divisor: float = 1.0) -> ArrayLayer: + """The original's line, its values divided by ``divisor``, which an autoscale sets to the document's level.""" + return self._array_layer( + data, name=self._lbl_waveform_original, color=self._layout.colors.waveform_sample, + divisor=divisor, + ) + + def _array_layer( + self, + data: np.ndarray, + *, + name: str, + color: BaseColor, + divisor: float = 1.0, + ) -> ArrayLayer: + """A line over ``data`` drawn from the points the display keeps for it. + + The reduction to the display's points is kept by the array's identity, so a render the + cache hands back again is drawn without reducing it again. A division scales the kept + points alone, since the extremes of a scaled waveform are its scaled extremes. + """ + positions, values = self._decimations.points(data) + if divisor != 1.0: + values = values / divisor + + return ArrayLayer( + data=data, + name=name, + color=color, max_display_points=self._layout.waveform.max_display_points, + points=(positions, values), ) def _ordered_layers( diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py b/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py new file mode 100644 index 000000000..7560d4d60 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py @@ -0,0 +1,84 @@ +import gc +from typing import Final + +import numpy as np + +from sampletones_application.ui.elements.graphs.layers.decimation import KEPT_DECIMATIONS, Decimations +from sampletones_core.audio import minmax_decimate + +MAX_POINTS: Final[int] = 8 +LONG: Final[int] = 64 +SHORT: Final[int] = 4 + + +def _waveform(length: int, seed: int) -> np.ndarray: + return np.random.default_rng(seed).standard_normal(length).astype(np.float32) + + +class TestThePointsAnArrayDrawsAs: + def test_a_long_array_reduces_to_its_extremes_per_bucket(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(LONG, 0) + + positions, values = decimations.points(audio) + + expected_positions, expected_values = minmax_decimate(audio, num_buckets=MAX_POINTS) + np.testing.assert_array_equal(positions, expected_positions) + np.testing.assert_array_equal(values, expected_values) + + def test_a_short_array_draws_every_sample(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(SHORT, 0) + + positions, values = decimations.points(audio) + + np.testing.assert_array_equal(positions, np.arange(SHORT, dtype=np.float32)) + np.testing.assert_array_equal(values, audio) + + def test_the_peak_is_the_arrays_own(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(LONG, 1) + + assert decimations.peak(audio) == float(np.max(np.abs(audio))) + + +class TestWhatTheMemoKeeps: + """A reduction is made once per array and read back by identity, and goes with the array.""" + + def test_a_second_showing_reads_the_same_points(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(LONG, 0) + + first = decimations.points(audio) + second = decimations.points(audio) + + assert second[0] is first[0] + assert second[1] is first[1] + + def test_an_equal_array_of_its_own_is_reduced_apart(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(LONG, 0) + twin = audio.copy() + + assert decimations.points(twin)[1] is not decimations.points(audio)[1] + + def test_a_collected_array_takes_its_points_with_it(self) -> None: + decimations = Decimations(MAX_POINTS) + audio = _waveform(LONG, 0) + identity = id(audio) + decimations.points(audio) + + del audio + gc.collect() + + assert identity not in decimations._points + + def test_the_reductions_read_longest_ago_go_first(self) -> None: + decimations = Decimations(MAX_POINTS) + kept = [_waveform(LONG, seed) for seed in range(KEPT_DECIMATIONS + 1)] + + for audio in kept: + decimations.points(audio) + + assert id(kept[0]) not in decimations._points + assert all(id(audio) in decimations._points for audio in kept[1:]) diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index bce395b28..2f74968cf 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -6,6 +6,7 @@ import pytest from sampletones_application.ui.elements.graphs import waveform as waveform_module +from sampletones_application.ui.elements.graphs.layers.decimation import Decimations from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.shared.waveform_data import WaveformData @@ -381,6 +382,7 @@ def _graph_reporting_clicks(monkeypatch: pytest.MonkeyPatch) -> Tuple[GUIWavefor graph._default_x_range = (0.0, 1.0) graph._default_y_range = (-1.0, 1.0) graph._layout = SimpleNamespace(waveform=SimpleNamespace(max_display_points=VOICE_SAMPLES)) + graph._decimations = Decimations(VOICE_SAMPLES) monkeypatch.setattr(graph, "add_layer", lambda _layer: None) monkeypatch.setattr(graph, "_update_display", lambda: None) clicked: List[int] = [] From 42eef96c3cbb3a036f28fe1375324e6159a41a54 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 12:26:19 +0200 Subject: [PATCH 19/37] Measured: a landing against the document's cold render, and a tick against the frame cadence --- docs/development/bugs-and-todos.md | 7 +++++ .../reconstruction/test_edit_landing.py | 28 +++++++++++++++---- .../edits/test_long_document_drag.py | 7 +++-- .../stems/test_long_document_boxes.py | 18 +++++++----- tests/suite/screens/readings.py | 17 +++++++++++ .../reconstruction/test_reconstruction.py | 3 ++ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ae5bc7fee..a404884be 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -92,6 +92,13 @@ dimension the import starts carrying. channel, and the song's hash grows with the song. Development runs and the test suite pay for it. Memoizing each pattern's and each stream's hash by identity, as each document's is memoized now, brings a commit down to the parts its gesture replaced. +* 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)); three 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. A WAV export + of a hearing the cache keeps no sound for renders it on the spot, where the reader waits for the file. * 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/tests/benchmarks/reconstruction/test_edit_landing.py b/tests/benchmarks/reconstruction/test_edit_landing.py index 64d5b6ce6..1d88c80d6 100644 --- a/tests/benchmarks/reconstruction/test_edit_landing.py +++ b/tests/benchmarks/reconstruction/test_edit_landing.py @@ -55,6 +55,7 @@ RECORDING_FREQUENCY: Final[float] = 220.0 RECORDING_AMPLITUDE: Final[float] = 0.5 LOWEST_LEVEL: Final[int] = 1 +LANDING_SHARE_OF_A_COLD_RENDER: Final[float] = 0.25 NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() DOCUMENT_NAME: Final[str] = "Long conversion" @@ -246,15 +247,28 @@ def recordings_fixture(tmp_path_factory: pytest.TempPathFactory) -> Tuple[Path, return tuple(paths) +def _lands_within_a_share_of_a_cold_render(opening: Landing, landings: List[Landing]) -> None: + """A landing on the render thread costs a share of what rendering the document cold costs the worker. + + The worker renders every channel of the document once at the opening, so the opening's worker + seconds measure the document on this machine, and the landing is held to a share of them: the + landing installs what the worker made and draws it, so its cost stays a fraction of the render + whatever the machine's speed. + """ + landing = median(landing.landing_seconds for landing in landings) + bound = opening.worker_seconds * LANDING_SHARE_OF_A_COLD_RENDER + assert landing < bound, f"landing {landing:.3f} s, a share of the cold render {bound:.3f} s" + + class TestWhatAnEditCosts: """Readings of what one edit costs the worker and the render thread, beside what a cold opening costs. - The readings hold the edit path to nothing; they are what a change to it is judged by, taken on - this meter before and after the change. Each case lands a burst of edits of one channel the way - a drag lands them, one after the other, and reports the median and the least of each side's - seconds. A project sample carries the strict history's fingerprint on each landing; a standalone - document with its recordings read carries the original mix and, with a recording muted, the - filtered reading. + The readings are what a change to the edit path is judged by, taken on this meter before and + after the change, and each case holds the landing to a share of the document's cold render on + the same machine. Each case lands a burst of edits of one channel the way a drag lands them, + one after the other, and reports the median and the least of each side's seconds. A project + sample carries the strict history's fingerprint on each landing; a standalone document with its + recordings read carries the original mix and, with a recording muted, the filtered reading. """ @pytest.mark.parametrize("seconds", [SHORT_SECONDS, LONG_SECONDS], ids=["30s", "120s"]) @@ -271,6 +285,7 @@ def test_a_project_sample_every_recording_heard( _report(f"project sample, {seconds:.0f} s, every recording heard", opening, landings, meter.renders.held_bytes) assert meter.manager.reconstruction is not before + _lands_within_a_share_of_a_cold_render(opening, landings) def test_a_standalone_document_with_a_recording_muted( self, @@ -291,3 +306,4 @@ def test_a_standalone_document_with_a_recording_muted( meter.renders.held_bytes, ) assert meter.manager.reconstruction is not before + _lands_within_a_share_of_a_cold_render(opening, landings) diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index 20ce15772..abcde7222 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -16,7 +16,7 @@ from automation.views.instruments import Instruments from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.readings import frame_readings, keep_readings +from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world DRAGGED_ITEMS: Final[Tuple[int, ...]] = (2, 4, 6, 8) @@ -66,6 +66,7 @@ class TestADragOnALongDocument: tenth of a second, the frames drawn a second, and how long the line took to empty after the last release, beside the scenario's other files. It holds the application to nothing: the readings are what a change to the edit path is judged by, taken before and after the change. + The one promise is relative: a landing holds a frame for a few of the machine's own frames at most. """ def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: @@ -88,7 +89,9 @@ def drag_until_the_fade_ends(screen: Screen) -> None: stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: - keep_readings(BURST_READINGS, frame_readings(stamps, releases[-1])) + readings = frame_readings(stamps, releases[-1]) + keep_readings(BURST_READINGS, readings) + expect_no_long_hold(readings) screen.scenario(open_and_zoom_in, drag_until_the_fade_ends, keep_the_readings, leave_letting_it_go).run() diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index 9496c0953..203d8d32e 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -9,7 +9,7 @@ from automation.steps.reconstructions import expect_open, reconstruction_shade, wait_for_the_fade_to_end from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each +from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings, longest_gap_after_each from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world TOGGLED_CHANNEL: Final[ChannelName] = ChannelName.NOISE @@ -38,14 +38,16 @@ class TestTheBoxesOnALongDocument: A box changes what is heard, so the waveform, the audio, the figures and the stretches every surface paints are read again from the document, and a sound the screen holds none of yet is rendered on the worker while the frames keep coming. The recordings take four-frame turns, so - every lane divides into as many stretches as a long conversion over competing recordings does. The scenario records the clock after every frame through the ticks, each followed - by the fade's end, and keeps the longest gap after each tick beside the readings the drag - scenarios keep, holding the application to nothing: the readings are what a change to the - listening path is judged by, taken before and after the change. + every lane divides into as many stretches as a long conversion over competing recordings does. + The scenario records the clock after every frame through the ticks, each followed by the fade's + end, and keeps the longest gap after each tick beside the readings the drag scenarios keep. The + readings are what a change to the listening path is judged by, taken before and after the + change; the one promise is relative: a tick holds a frame for a few of the machine's own + frames at most. """ def test_the_frames_through_the_ticks(self, screen: Screen) -> None: - """The readings are kept; the application is only asked to keep drawing and to leave cleanly.""" + """The readings are kept and held to the machine's frame cadence; the application leaves cleanly.""" reconstructions = screen.reconstructions stamps: List[float] = [] ticks: List[float] = [] @@ -72,8 +74,10 @@ def tick_the_boxes(screen: Screen) -> None: stamps.extend(recording.values()) def keep_the_readings(screen: Screen) -> None: - readings: Dict[str, object] = dict(frame_readings(stamps, ticks[-1])) + frames = frame_readings(stamps, ticks[-1]) + readings: Dict[str, object] = dict(frames) readings["longest_gap_after_each_tick_seconds"] = longest_gap_after_each(stamps, ticks) keep_readings(BOX_READINGS, readings) + expect_no_long_hold(frames) screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run() diff --git a/tests/suite/screens/readings.py b/tests/suite/screens/readings.py index cb00037af..5579908dd 100644 --- a/tests/suite/screens/readings.py +++ b/tests/suite/screens/readings.py @@ -1,11 +1,13 @@ import json import os from pathlib import Path +from statistics import median from typing import Dict, Final, List, Sequence from automation.environment import ARTIFACTS_VARIABLE LONG_GAP_SECONDS: Final[float] = 0.1 +LONGEST_GAP_IN_MEDIAN_GAPS: Final[int] = 12 def frame_readings(stamps: List[float], last_gesture: float) -> Dict[str, float]: @@ -21,6 +23,7 @@ def frame_readings(stamps: List[float], last_gesture: float) -> Dict[str, float] "frames": len(stamps), "seconds": span, "longest_gap_seconds": max(gaps), + "median_gap_seconds": median(gaps), "gaps_over_a_tenth": sum(1 for gap in gaps if gap > LONG_GAP_SECONDS), "frames_per_second": (len(stamps) - 1) / span, "drain_seconds_after_the_last_release": stamps[-1] - last_gesture, @@ -39,6 +42,20 @@ def longest_gap_after_each(stamps: List[float], gestures: Sequence[float]) -> Li return readings +def expect_no_long_hold(readings: Dict[str, float]) -> None: + """The longest gap between two frames stays within a number of the frames the machine draws at rest. + + The bound is read against the machine's own frame cadence through the recording, so it holds + on a slow display and a fast one alike: a landing or a tick holds a frame for a few frames' + worth and no more. + """ + bound = readings["median_gap_seconds"] * LONGEST_GAP_IN_MEDIAN_GAPS + assert readings["longest_gap_seconds"] < bound, ( + f"the longest gap {readings['longest_gap_seconds']:.3f} s against {bound:.3f} s, " + f"{LONGEST_GAP_IN_MEDIAN_GAPS} times the median gap of {readings['median_gap_seconds']:.3f} s" + ) + + def keep_readings(name: str, readings: Dict[str, object]) -> None: """Prints the readings and keeps them beside the scenario's other files.""" print(json.dumps(readings)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index b6cf28b02..8d708e176 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1498,6 +1498,7 @@ def test_a_recording_switched_off_leaves_the_envelopes_it_held( whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count panel_logic.set_stem_channels(1, frozenset()) + _hear(mock_reconstruction_manager) assert mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count < whole @@ -1512,6 +1513,7 @@ def test_a_channel_no_recording_is_heard_on_describes_no_frame( panel_logic.set_stem_channels(0, frozenset()) panel_logic.set_stem_channels(1, frozenset()) + _hear(mock_reconstruction_manager) assert not mock_reconstruction_manager.current_features[ChannelName.PULSE1].has_frames @@ -1526,6 +1528,7 @@ def test_a_channel_no_recording_is_heard_on_is_written_nowhere( panel_logic.set_stem_channels(0, frozenset()) panel_logic.set_stem_channels(1, frozenset()) + _hear(mock_reconstruction_manager) assert panel_logic.exportable_instrument(ChannelName.PULSE1) is None assert ChannelName.PULSE1 not in {export.channel for export in panel_logic.sample_request().instruments} From f2c062b0cf80d037b29f30a705bb5e2333edc62a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 12:47:53 +0200 Subject: [PATCH 20/37] Memoized: each stream's, each owner record's and each pattern's digest in the strict history --- docs/development/application/undo.md | 2 +- docs/development/bugs-and-todos.md | 5 - .../logic/history/fingerprint.py | 262 +++++++++++++++--- .../logic/history/manager.py | 37 +-- tests/suite/history/audit.py | 21 +- .../logic/history/test_fingerprint.py | 256 ++++++++++++++--- 6 files changed, 475 insertions(+), 108 deletions(-) diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md index 4003e68c4..69dcb0386 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -53,7 +53,7 @@ A transaction can carry a *coalesce key* that names the gesture's target, such a **Detection happens in the controller.** `ProjectController._touch()` fires `on_mutation` on every fine-grained mutation as it lands. A batch defers the view notifications and the dirty stamp and leaves this signal immediate, so the check below sees each mutation inside the transaction that caused it. `HistoryManager.handle_mutation` counts the mutations inside a transaction and rejects any that occur outside one. Under strict deployment it raises `UntrackedMutationError`. Otherwise it heals by recording the mutation as its own entry. This makes completeness a checkable property. -Under strict deployment each committed snapshot also carries a fingerprint, and every restore verifies that the reproduced project matches it. Capture-time fingerprints memoize each reconstruction's hash by object identity, since a reconstruction is a value and its content is fixed for the object's lifetime. That reduces the per-gesture cost to the light structure. Restore-time verification always hashes fresh, so an in-place mutation of shared state is caught and not masked by the memo. +Under strict deployment each committed snapshot also carries a fingerprint, and every restore verifies that the reproduced project matches it. Capture-time fingerprints memoize by object identity the hash of every value the project is made of: each reconstruction, each stream and each owner record in it, and each pattern of the song. Each is a value whose content is fixed for the object's lifetime, and a gesture keeps every value it leaves alone by identity, so a commit hashes the values the gesture replaced and the light structure around them. Restore-time verification always hashes everything fresh, so an in-place mutation of shared state is caught and not masked by the memo. ## Save point and lifecycle diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index a404884be..896084c8a 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -87,11 +87,6 @@ dimension the import starts carrying. frames. A rebuild makes each frame of the channel anew, so on a four-channel conversion an entry has about a quarter of the document. Keeping the old instruction wherever the rebuilt one equals it brings an entry down to the frames the edit changed ([undo](application/undo.md)). -* Strict history hashes the whole song and the whole new document at every commit. On a four-channel - conversion of 13,677 frames, hashing the document costs more than twice the rebuild of the edited - channel, and the song's hash grows with the song. Development runs and the test suite pay for it. - Memoizing each pattern's and each stream's hash by identity, as each document's is memoized now, brings - a commit down to the parts its gesture replaced. * 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)); three readings stay on the render thread. The diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index 55bba7f44..4bb7f0329 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -1,83 +1,279 @@ -from typing import Callable, Dict, Final, Iterable, List, Tuple +from typing import ( + AbstractSet, + Callable, + Dict, + Final, + Generic, + Iterable, + List, + Optional, + Protocol, + Set, + Tuple, + TypeVar, +) from pydantic import BaseModel +from sampletones_core.constants.enums import ChannelName from sampletones_core.project import Project +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.song import Song from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction -from sampletones_shared.utils.hashing import identity_digest +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_shared.utils.hashing import hash_model, identity_digest from sampletones_shared.utils.serialization import dump FINGERPRINT_LENGTH: Final[int] = 64 +SONG_STRUCTURE: Final[Set[str]] = {"rows_per_pattern", "order"} -ReconstructionHash = Callable[[Reconstruction], str] +StreamHash = Callable[[InstructionsItem], str] +AssignmentHash = Callable[[ChannelAssignment], str] +PatternHash = Callable[[Pattern], str] +Value = TypeVar("Value") -def _canonical(model: BaseModel) -> str: - """The model spelled as JSON with its keys sorted, one spelling for one state.""" - return dump(model.model_dump(mode="json")) + +class PartHashes(Protocol): + """How the heavy parts of a project are hashed: each sample's document and the song's patterns. + + A fingerprint spells the project's light structure itself and hands the heavy parts here, so + one object states the choice for every part a commit pays for: a digest read afresh, or one + memoized by the object's identity. + """ + + def reconstruction(self, reconstruction: Reconstruction) -> str: ... + + def song(self, song: Song) -> str: ... + + +def _canonical( + model: BaseModel, + *, + fields: Optional[Set[str]] = None, +) -> str: + """The model, or the fields of it named, spelled as JSON with its keys sorted, one spelling for one state.""" + return dump(model.model_dump(mode="json", include=fields)) def fingerprint_project( project: Project, *, - reconstruction_hash: ReconstructionHash, + hashes: PartHashes, ) -> str: """Returns a content hash used to verify that a restore reproduces a snapshot. The hash covers the full project state, spelled out part by part so that two projects differing anywhere in it differ here. Each part is hashed with its keys sorted, so a mapping - holding the same entries in another order reads as the same state. Each sample's - reconstruction content enters through ``reconstruction_hash``, so the caller decides between - a memoized digest (capture, where copy-on-write keeps it valid) and a fresh one - (verification, where recomputing from scratch catches any divergence). + holding the same entries in another order reads as the same state. The song and each + sample's document enter through ``hashes``, so the caller decides between memoized digests + (capture, where copy-on-write keeps them valid) and fresh ones (verification, where + recomputing from scratch catches any divergence). """ parts: List[str] = [ _canonical(project.metadata), _canonical(project.info), _canonical(project.settings), - _canonical(project.song), + hashes.song(project.song), ] for voice in project.voices: parts.append(voice.id) parts.append(voice.name) match voice: case Sample(): - parts.append(reconstruction_hash(voice.reconstruction)) + parts.append(hashes.reconstruction(voice.reconstruction)) case Instrument(): parts.append(_canonical(voice)) return identity_digest(*parts, length=FINGERPRINT_LENGTH) -class ReconstructionHashCache: - """Memoizes per-reconstruction content hashes by object identity. +def reconstruction_digest( + reconstruction: Reconstruction, + *, + stream_hash: StreamHash, + assignment_hash: AssignmentHash, +) -> str: + """A reconstruction's content spelled out part by part, its streams and its owner records through the hashes given. + + Every field the document stores enters: its metadata, id, configuration and coefficient, each + channel's stream, and the record of its recordings, which is the setup, the sources, the + assignment of each frame to an owner and the scale. The streams' digests join as one part and + the assignments' as another, so a document of three streams and two records reads apart from + one of two streams and three. The light parts are spelled in JSON mode; a stream and a record + are spelled by the hash handed in, which capture memoizes by identity and verification reads + afresh, and the two agree because each part is spelled one way on both sides. - Copy-on-write discipline makes a reconstruction's content immutable for the - object's lifetime, so one hash per object suffices and capture-time - fingerprinting collapses to hashing the light structure. Each entry stores a - strong reference to its reconstruction alongside the digest: the reference - keeps the object alive, so its ``id()`` stays bound to that object for as - long as the entry exists. :meth:`prune` keeps only entries whose - reconstruction still appears in one of the given projects, releasing the rest - and bounding the cache by what the history retains. + Args: + reconstruction: The document hashed. + stream_hash: How one channel's stream is hashed. + assignment_hash: How one channel's owner record is hashed. + + Returns: + str: The digest of the document's content. """ + stems_data = reconstruction.stems_data + return identity_digest( + _canonical(reconstruction.metadata), + reconstruction.id, + _canonical(reconstruction.config), + identity_digest(*(stream_hash(stream) for stream in reconstruction.instructions_data)), + _canonical(stems_data.config), + identity_digest(*(_canonical(source) for source in stems_data.sources)), + identity_digest(*(assignment_hash(assignment) for assignment in stems_data.assignments)), + dump(stems_data.scale), + dump(reconstruction.coefficient), + ) + + +def song_digest( + song: Song, + *, + pattern_hash: PatternHash, +) -> str: + """A song's content spelled out part by part, its patterns through the hash given. + + The row count and the order are spelled in JSON mode as they stand. Each channel's pool is + one nested part: the channel it is keyed under and named for, then each index it holds a + pattern at beside that pattern's digest, the pools in channel order and the indices + ascending, so a pool read in another order reads as the same pool and a pattern moved to + another index reads as a change. A pattern is spelled by the hash handed in, which capture + memoizes by identity and verification reads afresh. - def __init__(self, *, reconstruction_hash: ReconstructionHash) -> None: - self._reconstruction_hash = reconstruction_hash - self._hashes: Dict[int, Tuple[Reconstruction, str]] = {} + Args: + song: The song hashed. + pattern_hash: How one pattern is hashed. + + Returns: + str: The digest of the song's content. + """ + return identity_digest( + _canonical(song, fields=SONG_STRUCTURE), + *(_pool_digest(name, song.channels[name], pattern_hash) for name in sorted(song.channels)), + ) + + +def _pool_digest( + name: ChannelName, + channel: Channel, + pattern_hash: PatternHash, +) -> str: + parts = [dump(name), dump(channel.name)] + for index in sorted(channel.patterns): + parts.append(dump(index)) + parts.append(pattern_hash(channel.patterns[index])) - def hash(self, reconstruction: Reconstruction) -> str: - key = id(reconstruction) - cached = self._hashes.get(key) + return identity_digest(*parts) + + +def hash_reconstruction(reconstruction: Reconstruction) -> str: + """A reconstruction's digest with every part read afresh, which a verification spells.""" + return reconstruction_digest(reconstruction, stream_hash=hash_model, assignment_hash=hash_model) + + +def hash_song(song: Song) -> str: + """A song's digest with every pattern read afresh, which a verification spells.""" + return song_digest(song, pattern_hash=hash_model) + + +class FreshHashes: + """Every heavy part read afresh, so a verification reproduces a digest from the content alone.""" + + def reconstruction(self, reconstruction: Reconstruction) -> str: + return hash_reconstruction(reconstruction) + + def song(self, song: Song) -> str: + return hash_song(song) + + +class _Memo(Generic[Value]): + """Digests by object identity, each entry holding its object so its ``id()`` stays bound to it.""" + + def __init__(self, compute: Callable[[Value], str]) -> None: + self._compute = compute + self._entries: Dict[int, Tuple[Value, str]] = {} + + def digest(self, value: Value) -> str: + cached = self._entries.get(id(value)) if cached is None: - cached = (reconstruction, self._reconstruction_hash(reconstruction)) - self._hashes[key] = cached + cached = (value, self._compute(value)) + self._entries[id(value)] = cached return cached[1] + def keep(self, live: AbstractSet[int]) -> None: + """Releases every entry whose object's identity is outside ``live``.""" + self._entries = {key: value for key, value in self._entries.items() if key in live} + + def __len__(self) -> int: + return len(self._entries) + + +class HashCache: + """Memoizes content hashes by object identity for every value a project is made of. + + Copy-on-write discipline makes a value's content immutable for the object's lifetime: a + reconstruction, each stream and each owner record it is made of, and each pattern of the + song. A gesture replaces the values it changes and keeps every other by identity, so a fresh + document's digest composes from one new stream's hash and the memoized rest, the song's from + the one pattern a row edit replaced and the memoized rest, and capture-time fingerprinting + collapses to hashing what the gesture replaced. The song and its channels are shells written + in place, so they are spelled afresh on every commit around the memoized patterns. Each entry + stores a strong reference beside the digest: the reference keeps the object alive, so its + ``id()`` stays bound to that object for as long as the entry exists. :meth:`prune` keeps only + the entries whose object still appears in one of the given projects, releasing the rest and + bounding the cache by what the history retains. + """ + + def __init__( + self, + *, + stream_hash: StreamHash = hash_model, + assignment_hash: AssignmentHash = hash_model, + pattern_hash: PatternHash = hash_model, + ) -> None: + self._streams: _Memo[InstructionsItem] = _Memo(stream_hash) + self._assignments: _Memo[ChannelAssignment] = _Memo(assignment_hash) + self._patterns: _Memo[Pattern] = _Memo(pattern_hash) + self._reconstructions: _Memo[Reconstruction] = _Memo(self._compose_reconstruction) + + def reconstruction(self, reconstruction: Reconstruction) -> str: + return self._reconstructions.digest(reconstruction) + + def song(self, song: Song) -> str: + return song_digest(song, pattern_hash=self._patterns.digest) + + def _compose_reconstruction(self, reconstruction: Reconstruction) -> str: + return reconstruction_digest( + reconstruction, + stream_hash=self._streams.digest, + assignment_hash=self._assignments.digest, + ) + def prune(self, projects: Iterable[Project]) -> None: - live = {id(sample.reconstruction) for project in projects for sample in samples(project.voices)} - self._hashes = {key: value for key, value in self._hashes.items() if key in live} + retained = list(projects) + reconstructions = [sample.reconstruction for project in retained for sample in samples(project.voices)] + self._reconstructions.keep({id(reconstruction) for reconstruction in reconstructions}) + self._streams.keep( + {id(stream) for reconstruction in reconstructions for stream in reconstruction.instructions_data} + ) + self._assignments.keep( + { + id(assignment) + for reconstruction in reconstructions + for assignment in reconstruction.stems_data.assignments + } + ) + self._patterns.keep( + { + id(pattern) + for project in retained + for channel in project.song.channels.values() + for pattern in channel.patterns.values() + } + ) diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index 64dec819c..b9243a7a1 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -6,11 +6,10 @@ from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.hashing import hash_model from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError -from .fingerprint import ReconstructionHashCache, fingerprint_project +from .fingerprint import FreshHashes, HashCache, fingerprint_project from .snapshot import HistoryEntry from .transaction import CoalesceKey, PendingTransaction @@ -41,9 +40,7 @@ def __init__( self._controller = controller self._budget = budget self._strict = strict - self._hash_cache: Optional[ReconstructionHashCache] = ( - ReconstructionHashCache(reconstruction_hash=hash_model) if strict else None - ) + self._hash_cache: Optional[HashCache] = HashCache() if strict else None self._entries: List[HistoryEntry] = [] self._cursor: int = -1 self._saved_cursor: Optional[int] = None @@ -305,20 +302,13 @@ def _capture( ) -> HistoryEntry: """Snapshots the live project, fingerprinting it under strict deployment. - Capture-time fingerprints reuse the memoized per-reconstruction hashes: - copy-on-write keeps a reconstruction's content fixed for the object's - lifetime, so the per-gesture cost collapses to hashing the light - structure. + Capture-time fingerprints reuse the memoized hashes of every value the project is made + of: each reconstruction with its streams and owner records, and each pattern of the song. + Copy-on-write keeps their content fixed for the object's lifetime, so the per-gesture cost + collapses to hashing what the gesture replaced and the light structure. """ project = self._controller.project - fingerprint = ( - fingerprint_project( - project, - reconstruction_hash=self._hash_cache.hash, - ) - if self._hash_cache is not None - else None - ) + fingerprint = fingerprint_project(project, hashes=self._hash_cache) if self._hash_cache is not None else None return HistoryEntry( project=project.snapshot(), action=action, @@ -371,7 +361,7 @@ def _reinstall(self, entry: HistoryEntry) -> None: def _verify(self, entry: HistoryEntry) -> None: """Checks the restored project against the entry's recorded fingerprint. - Verification always hashes reconstructions fresh: an in-place mutation of + Verification always hashes every value fresh: an in-place mutation of shared state keeps the object's identity, so a memoized digest would reproduce the pre-mutation hash and mask exactly the divergence this tripwire exists to catch. @@ -379,10 +369,7 @@ def _verify(self, entry: HistoryEntry) -> None: if entry.fingerprint is None: return - actual = fingerprint_project( - self._controller.project, - reconstruction_hash=hash_model, - ) + actual = fingerprint_project(self._controller.project, hashes=FreshHashes()) if actual != entry.fingerprint: raise HistoryIntegrityError( f"Restoring history entry '{entry.action}' produced a project that " @@ -390,12 +377,12 @@ def _verify(self, entry: HistoryEntry) -> None: ) def _prune_hash_cache(self) -> None: - """Prunes the hash cache to the reconstructions the history still retains. + """Prunes the hash cache to the values the history still retains. Runs wherever snapshots are discarded — reset, redo-branch truncation, budget eviction, and coalescing replacement — keeping the cache bounded - by the reconstructions still reachable from the stack or the live - project. + by the reconstructions and patterns still reachable from the stack or + the live project. """ if self._hash_cache is None: return diff --git a/tests/suite/history/audit.py b/tests/suite/history/audit.py index 280552c04..bf654438e 100644 --- a/tests/suite/history/audit.py +++ b/tests/suite/history/audit.py @@ -6,7 +6,11 @@ import numpy as np from sampletones_application.logic.history.action import HistoryAction -from sampletones_application.logic.history.fingerprint import ReconstructionHashCache, fingerprint_project +from sampletones_application.logic.history.fingerprint import ( + FreshHashes, + HashCache, + fingerprint_project, +) from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_core.constants.enums import ChannelName @@ -15,15 +19,14 @@ from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_shared.types.callback import VoidCallback -from sampletones_shared.utils.hashing import hash_model Settle = Callable[[VoidCallback], None] CommitKey = Tuple[HistoryAction, Hashable] def fresh_fingerprint(project: Project) -> str: - """The project's whole state, every reconstruction hashed afresh rather than through a memo.""" - return fingerprint_project(project, reconstruction_hash=hash_model) + """The project's whole state, every reconstruction and every pattern hashed afresh.""" + return fingerprint_project(project, hashes=FreshHashes()) def immediately(gesture: VoidCallback) -> None: @@ -381,7 +384,7 @@ def walk(self) -> None: def check(self) -> None: self._check_shape() - hashes = ReconstructionHashCache(reconstruction_hash=hash_model) + hashes = HashCache() projects = [entry.project for entry in self._history.entries] if self._controller.is_open: projects.append(self._controller.project) @@ -407,15 +410,15 @@ def _check_shape(self) -> None: assert self._history.can_undo == (self.model.cursor > 0) assert self._history.can_redo == (self.model.cursor < len(self.model.entries) - 1) - def _check_entries(self, hashes: ReconstructionHashCache) -> None: + def _check_entries(self, hashes: HashCache) -> None: for index, (entry, expected) in enumerate(zip(self._history.entries, self.model.entries)): - fingerprint = fingerprint_project(entry.project, reconstruction_hash=hashes.hash) + fingerprint = fingerprint_project(entry.project, hashes=hashes) assert ( fingerprint == expected.fingerprint ), f"Entry {index} ({entry.action}) no longer holds the state it recorded" - def _check_live(self, hashes: ReconstructionHashCache) -> None: - fingerprint = fingerprint_project(self._controller.project, reconstruction_hash=hashes.hash) + def _check_live(self, hashes: HashCache) -> None: + fingerprint = fingerprint_project(self._controller.project, hashes=hashes) assert ( fingerprint == self.model.live.fingerprint ), f"The live project differs from entry {self.model.cursor} ({self.model.live.action})" diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index 6add7f66d..eafa36462 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -1,21 +1,31 @@ import copy -from typing import Final, FrozenSet, List +from typing import Final, FrozenSet, Generic, List, Sequence, TypeVar import pytest from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.errors import HistoryIntegrityError from sampletones_application.logic.history.fingerprint import ( - ReconstructionHashCache, + FreshHashes, + HashCache, fingerprint_project, + hash_reconstruction, + hash_song, ) from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.project import Project +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_shared.utils.hashing import hash_model from tests.conftest import ReconstructionFactory from tests.suite.history.audit import fresh_fingerprint from tests.suite.history.perturbation import perturbed_project, project_leaves -from tests.suite.history.projects import every_part_project +from tests.suite.history.projects import FIRST_PATTERN, SECOND_PATTERN, every_part_project from tests.unit.sampletones_application.logic.history.conftest import HistoryFactory REACHED_LABELS: Final[FrozenSet[str]] = frozenset( @@ -26,6 +36,7 @@ "ProjectSettings.tempo", "Song.rows_per_pattern", "Song.order[]{}", + "Channel.name", "Row.volume", "Note.value", "Step.value", @@ -42,15 +53,60 @@ "Instrument.initial_period", } ) +EDITED_ROW: Final[Row] = Row(volume=5) +MUTATED_NAME: Final[str] = "mutated" +Value = TypeVar("Value", InstructionsItem, Pattern) + + +class CountingHash(Generic[Value]): + """A hash that remembers every value it was asked for.""" -class CountingHash: def __init__(self) -> None: - self.calls: List[Reconstruction] = [] + self.calls: List[Value] = [] + + def __call__(self, value: Value) -> str: + self.calls.append(value) + return hash_model(value) + + +def _streams(reconstruction: Reconstruction) -> List[InstructionsItem]: + return list(reconstruction.instructions_data) + + +def _patterns(song: Song) -> List[Pattern]: + """The song's patterns in the order its digest spells them: channels by name, indices ascending.""" + return [ + song.channels[name].patterns[index] + for name in sorted(song.channels) + for index in sorted(song.channels[name].patterns) + ] + + +def _every_pattern(projects: Sequence[Project]) -> FrozenSet[int]: + return frozenset( + id(pattern) + for project in projects + for channel in project.song.channels.values() + for pattern in channel.patterns.values() + ) - def __call__(self, reconstruction: Reconstruction) -> str: - self.calls.append(reconstruction) - return hash_model(reconstruction) + +def _edited(reconstruction: Reconstruction) -> Reconstruction: + """The document with its first pulse rebuilt, every other stream and record kept by identity.""" + return reconstruction.with_channel_data( + ChannelName.PULSE1, + [PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1)], + 67, + (), + heard=reconstruction.recorded_stem_ids, + ) + + +def _edit_a_row(song: Song) -> Pattern: + """Writes one row of the first pulse's first pattern, and gives back the pattern now standing there.""" + song.channels[ChannelName.PULSE1].set_row(FIRST_PATTERN, 0, EDITED_ROW) + return song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] class TestFingerprint: @@ -61,23 +117,20 @@ def test_fingerprint_stable_across_snapshot( ) -> None: project_controller.add_sample(reconstruction_factory(), name="lead") - original = fingerprint_project(project_controller.project, reconstruction_hash=hash_model) + original = fingerprint_project(project_controller.project, hashes=FreshHashes()) snapshot = project_controller.project.snapshot() - assert fingerprint_project(snapshot, reconstruction_hash=hash_model) == original + assert fingerprint_project(snapshot, hashes=FreshHashes()) == original def test_fingerprint_changes_with_state( self, project_controller: ProjectController, ) -> None: - before = fingerprint_project( - project_controller.project, - reconstruction_hash=hash_model, - ) + before = fingerprint_project(project_controller.project, hashes=FreshHashes()) project_controller.set_tempo(project_controller.project.settings.tempo + 7) - assert fingerprint_project(project_controller.project, reconstruction_hash=hash_model) != before + assert fingerprint_project(project_controller.project, hashes=FreshHashes()) != before class TestFingerprintCompleteness: @@ -111,56 +164,139 @@ def test_mappings_in_another_order_keep_the_fingerprint(self) -> None: project.song.order = [dict(reversed(list(frame.items()))) for frame in project.song.order] project.song.channels = dict(reversed(list(project.song.channels.items()))) + for channel in project.song.channels.values(): + channel.patterns = dict(reversed(list(channel.patterns.items()))) assert fresh_fingerprint(project) == baseline + def test_two_patterns_swapping_indices_change_the_fingerprint(self) -> None: + project = every_part_project() + baseline = fresh_fingerprint(project) + pool = project.song.channels[ChannelName.PULSE1].patterns + + pool[FIRST_PATTERN], pool[SECOND_PATTERN] = pool[SECOND_PATTERN], pool[FIRST_PATTERN] + + assert fresh_fingerprint(project) != baseline + class TestHashCache: - def test_hash_computed_once_per_reconstruction_object( + """A value is hashed once per object, so a digest costs the values the gesture replaced.""" + + def test_streams_hashed_once_per_reconstruction_object( self, reconstruction_factory: ReconstructionFactory, ) -> None: - counting = CountingHash() - cache = ReconstructionHashCache(reconstruction_hash=counting) + counting: CountingHash[InstructionsItem] = CountingHash() + cache = HashCache(stream_hash=counting) reconstruction = reconstruction_factory() - first = cache.hash(reconstruction) - second = cache.hash(reconstruction) + first = cache.reconstruction(reconstruction) + second = cache.reconstruction(reconstruction) assert first == second - assert counting.calls == [reconstruction] + assert counting.calls == _streams(reconstruction) + + def test_the_memoized_digest_is_the_fresh_one(self, reconstruction_factory: ReconstructionFactory) -> None: + reconstruction = reconstruction_factory() + + assert HashCache().reconstruction(reconstruction) == hash_reconstruction(reconstruction) def test_distinct_reconstructions_hash_independently( self, reconstruction_factory: ReconstructionFactory, ) -> None: - counting = CountingHash() - cache = ReconstructionHashCache(reconstruction_hash=counting) + counting: CountingHash[InstructionsItem] = CountingHash() + cache = HashCache(stream_hash=counting) first = reconstruction_factory() second = reconstruction_factory() - cache.hash(first) - cache.hash(second) + cache.reconstruction(first) + cache.reconstruction(second) - assert counting.calls == [first, second] + assert counting.calls == _streams(first) + _streams(second) + + def test_an_edit_hashes_the_one_stream_it_replaced(self, reconstruction_factory: ReconstructionFactory) -> None: + counting: CountingHash[InstructionsItem] = CountingHash() + cache = HashCache(stream_hash=counting) + reconstruction = reconstruction_factory() + edited = _edited(reconstruction) + cache.reconstruction(reconstruction) + + cache.reconstruction(edited) + + assert counting.calls == _streams(reconstruction) + [edited.streams[ChannelName.PULSE1]] + assert cache.reconstruction(edited) == hash_reconstruction(edited) def test_prune_drops_hashes_for_discarded_reconstructions( self, project_controller: ProjectController, reconstruction_factory: ReconstructionFactory, ) -> None: - counting = CountingHash() - cache = ReconstructionHashCache(reconstruction_hash=counting) + counting: CountingHash[InstructionsItem] = CountingHash() + cache = HashCache(stream_hash=counting) kept = project_controller.add_sample(reconstruction_factory(), name="kept").reconstruction discarded = reconstruction_factory() - cache.hash(kept) - cache.hash(discarded) + cache.reconstruction(kept) + cache.reconstruction(discarded) cache.prune([project_controller.project]) - cache.hash(kept) - cache.hash(discarded) - assert counting.calls == [kept, discarded, discarded] + cache.reconstruction(kept) + cache.reconstruction(discarded) + assert counting.calls == _streams(kept) + _streams(discarded) + _streams(discarded) + + def test_patterns_hashed_once_per_pattern_object(self) -> None: + counting: CountingHash[Pattern] = CountingHash() + cache = HashCache(pattern_hash=counting) + song = every_part_project().song + + first = cache.song(song) + second = cache.song(song) + + assert first == second + assert counting.calls == _patterns(song) + + def test_the_memoized_song_digest_is_the_fresh_one(self) -> None: + song = every_part_project().song + + assert HashCache().song(song) == hash_song(song) + + def test_a_row_edit_hashes_the_one_pattern_it_replaced(self) -> None: + counting: CountingHash[Pattern] = CountingHash() + cache = HashCache(pattern_hash=counting) + song = every_part_project().song + original = _patterns(song) + cache.song(song) + + written = _edit_a_row(song) + cache.song(song) + + assert counting.calls == original + [written] + assert cache.song(song) == hash_song(song) + + def test_a_snapshot_shares_its_patterns_with_the_song_it_copies(self) -> None: + counting: CountingHash[Pattern] = CountingHash() + cache = HashCache(pattern_hash=counting) + project = every_part_project() + cache.song(project.song) + + assert cache.song(project.snapshot().song) == cache.song(project.song) + assert counting.calls == _patterns(project.song) + + def test_prune_drops_hashes_for_discarded_patterns(self) -> None: + counting: CountingHash[Pattern] = CountingHash() + cache = HashCache(pattern_hash=counting) + project = every_part_project() + before = project.snapshot() + replaced = project.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] + cache.song(before.song) + written = _edit_a_row(project.song) + cache.song(project.song) + + cache.prune([project]) + + cache.song(before.song) + assert counting.calls == _patterns(before.song) + [written, replaced] class TestStrictManagerFingerprinting: @@ -179,13 +315,17 @@ def test_capture_memoized_restore_verified_fresh( with history.transaction(HistoryAction.ADD_SAMPLE): controller.add_sample(reconstruction_factory(), name="lead") + with history.transaction(HistoryAction.EDIT_ROW): + controller.set_row(ChannelName.PULSE1, FIRST_PATTERN, 0, volume=EDITED_ROW.volume) + with history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) + history.undo() history.undo() history.undo() history.redo() - history.jump_to(2) + history.jump_to(3) assert controller.project.settings.tempo == 150 @@ -213,6 +353,40 @@ def test_restore_raises_on_mutated_snapshot_shared_state( with pytest.raises(HistoryIntegrityError): history.undo() + def test_restore_raises_on_a_stream_mutated_in_place( + self, + history_factory: HistoryFactory, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A stream's digest is memoized by identity on capture and read afresh on verification.""" + controller, history = history_factory() + with history.transaction(HistoryAction.ADD_SAMPLE): + sample = controller.add_sample(reconstruction_factory(), name="lead") + + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(150) + + stream = sample.reconstruction.instructions_data[0] + object.__setattr__(stream, "initial_pitch", stream.initial_pitch + 1) + + with pytest.raises(HistoryIntegrityError): + history.undo() + + def test_restore_raises_on_a_pattern_mutated_in_place(self, history_factory: HistoryFactory) -> None: + """A pattern's digest is memoized by identity on capture and read afresh on verification.""" + controller, history = history_factory() + with history.transaction(HistoryAction.EDIT_ROW): + controller.set_row(ChannelName.PULSE1, FIRST_PATTERN, 0, volume=EDITED_ROW.volume) + + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(150) + + pattern = controller.project.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] + object.__setattr__(pattern, "name", MUTATED_NAME) + + with pytest.raises(HistoryIntegrityError): + history.undo() + def test_eviction_prunes_cache_to_retained_reconstructions( self, history_factory: HistoryFactory, @@ -229,4 +403,16 @@ def test_eviction_prunes_cache_to_retained_reconstructions( controller.set_tempo(150) assert history._hash_cache is not None - assert len(history._hash_cache._hashes) == 0 + assert len(history._hash_cache._reconstructions) == 0 + assert len(history._hash_cache._streams) == 0 + assert len(history._hash_cache._assignments) == 0 + + def test_eviction_prunes_cache_to_retained_patterns(self, history_factory: HistoryFactory) -> None: + controller, history = history_factory(budget=2) + for row_index in range(3): + with history.transaction(HistoryAction.EDIT_ROW): + controller.set_row(ChannelName.PULSE1, FIRST_PATTERN, row_index, volume=EDITED_ROW.volume) + + retained = [entry.project for entry in history.entries] + [controller.project] + assert history._hash_cache is not None + assert len(history._hash_cache._patterns) == len(_every_pattern(retained)) From 167383bb4a39475d3ebf467b984495e7d832e7d6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 15:16:10 +0200 Subject: [PATCH 21/37] Named: what the waveform's fade waits for --- automation/screen.py | 4 ++ automation/vocabulary/graphs.py | 1 + .../application/reconstruction-edits.md | 6 ++- .../coordinators/reconstruction.py | 14 ++--- .../coordinators/tabs/reconstruction.py | 5 +- .../logic/reconstruction/rewrites/queue.py | 42 +++++++++++---- .../ui/elements/graphs/waveform.py | 42 ++++++++++----- .../ui/panels/reconstruction/plot.py | 5 +- .../view_model/reconstruction/work.py | 13 +++++ src/sampletones_config/lang/en.yaml | 1 + .../reconstruction/rewrites/test_queue.py | 5 +- .../stems/test_long_document_boxes.py | 29 +++++++++++ .../coordinators/test_reconstruction.py | 5 +- .../reconstruction/rewrites/test_queue.py | 41 ++++++++++++--- .../ui/elements/graphs/test_waveform.py | 52 ++++++++++++++----- 15 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 src/sampletones_application/view_model/reconstruction/work.py diff --git a/automation/screen.py b/automation/screen.py index 0b392a506..850b48345 100644 --- a/automation/screen.py +++ b/automation/screen.py @@ -192,6 +192,10 @@ def status(self) -> str: """What the status bar along the bottom of the window says.""" return self.bridge.ask(lambda: read_label(STATUS_BAR)) + def status_drawn(self) -> str: + """What the status bar says, read on the render thread itself, which a recording takes after every frame.""" + return read_label(STATUS_BAR) + def shows_text(self, words: str) -> bool: """Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open.""" return self.bridge.ask(lambda: read_visible_text(words)) diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py index 787339f61..e56189bb1 100644 --- a/automation/vocabulary/graphs.py +++ b/automation/vocabulary/graphs.py @@ -1,4 +1,5 @@ from typing import Final WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating" +WAVEFORM_READING: Final[str] = "global.graph.message.waveform_reading" WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 6ed59173d..0dc15f199 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -91,4 +91,8 @@ cache already answers is shown at once. ## The waveform fades while the line is busy The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the -document is being rewritten for the whole span, however many steps it takes. +document is being rewritten for the whole span, however many steps it takes. The status bar names what +the fade waits for: an edit on its way reads "Regenerating reconstruction...", and a change to what is +heard, or a document just opened, reads "Preparing the sound...". The line reports the kind of work it +is busy with, so a hand-over from an edit to a reading of the sound changes the words under the same +fade. diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index dce87200c..2210c31a5 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -38,6 +38,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample @@ -102,7 +103,7 @@ def __init__( self._rewrites.on_heard_rendered = self._show_listening self._rewrites.on_dropped = self._redraw_open_document self._rewrites.on_failed = self._report_rebuild_failure - self._rewrites.on_busy_changed = self._set_reconstruction_dimmed + self._rewrites.on_work_changed = self._show_work self._reconstruction_manager.set_callbacks( on_reconstruction_loaded=self.on_reconstruction_loaded, on_reconstruction_closed=self._on_closed, @@ -579,16 +580,17 @@ def _report_rebuild_failure(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) - def _set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Fades the reconstruction waveform while the open document is being rewritten. + def _show_work(self, work: Optional[Work]) -> None: + """Fades the reconstruction waveform under the words naming what the line is busy with. - The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform - faded until the last step lands, and it restores once nothing runs or waits. + The fade follows the rewrites' busy span, so a continuous edit stream keeps the waveform + faded until the last step lands, and it restores once nothing runs or waits. The words + follow the work, so a hand-over from an edit to a reading of the sound changes them. """ if self._reconstructions_tab is None: return - self._reconstructions_tab.set_reconstruction_dimmed(dimmed) + self._reconstructions_tab.show_work(work) def _on_state_changed(self) -> None: self._on_session_state_changed_callback() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 72ba64f23..36be1efcc 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -104,6 +104,7 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.audio import AudioDeviceManager @@ -822,8 +823,8 @@ def redraw_listening(self, *, envelopes_changed: bool) -> None: if envelopes_changed: self._reconstruction_instruments_logic.update_display() - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) + def show_work(self, work: Optional[Work]) -> None: + self._reconstruction_plot_panel.show_work(work) def toggle_channel(self, channel: ChannelName) -> None: """Switches one channel's slice in and out of the waveform and of what plays.""" diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 77e51870c..edd5bf0a3 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -28,6 +28,7 @@ from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.removal import ( can_remove_stem, @@ -96,7 +97,8 @@ class ReconstructionRewrites(CallbackMixin): lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a job's failure, and - ``on_busy_changed`` as the line fills and empties. + ``on_work_changed`` as the line fills, hands over from one kind of work to the other, and + empties. """ def __init__( @@ -109,20 +111,42 @@ def __init__( self._waiting: List[Rewrite] = [] self._running: Optional[Running] = None self._redraw_owed: bool = False - self._reported_busy: bool = False + self._reported_work: Optional[Work] = None self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None self.on_heard_rendered: Optional[Callable[[bool], None]] = None self.on_dropped: Optional[VoidCallback] = None self.on_failed: Optional[Callable[[Exception], None]] = None - self.on_busy_changed: Optional[Callable[[bool], None]] = None + self.on_work_changed: Optional[Callable[[Optional[Work]], None]] = None regeneration.subscribe(self._on_result) + @property + def work(self) -> Optional[Work]: + """What the line is busy with, which the waveform's fade names, or ``None`` while it stands empty. + + The running job says what it does. While steps wait with none running, the first of them + says what comes: a change to what is heard has the sound read, and every other step + rewrites the document. + """ + match self._running: + case Rebuild(): + return Work.REBUILDING + case Listening(): + return Work.READING + + match self._waiting[0] if self._waiting else None: + case None: + return None + case ListeningChange(): + return Work.READING + case _: + return Work.REBUILDING + @property def is_busy(self) -> bool: """Whether a job runs or a step waits, which is the span the open document's sound is on its way.""" - return self._running is not None or bool(self._waiting) + return self.work is not None def request(self, rewrite: Rewrite) -> None: """Puts a step at the end of the line, and takes it at once where nothing runs before it. @@ -365,12 +389,12 @@ def _let_go(self, running: Running) -> None: self.call(self.on_dropped) def _settle(self) -> None: - """Pays a redraw owed once the line empties, and reports the line filling or emptying.""" + """Pays a redraw owed once the line empties, and reports what the line is busy with as it changes.""" if not self.is_busy and self._redraw_owed: self._redraw_owed = False self.call(self.on_dropped) - busy = self.is_busy - if busy != self._reported_busy: - self._reported_busy = busy - self.call(self.on_busy_changed, busy) + work = self.work + if work != self._reported_work: + self._reported_work = work + self.call(self.on_work_changed, work) diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index a42357c87..1781381c0 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -47,6 +47,7 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.audio import START_OF_AUDIO from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -94,9 +95,10 @@ def __init__( self._lbl_waveform_original = language_manager["global.graph.label.waveform_original"] self._lbl_waveform_reconstruction = language_manager["global.graph.label.waveform_reconstruction"] self._msg_regenerating = language_manager["global.graph.message.waveform_regenerating"] + self._msg_reading = language_manager["global.graph.message.waveform_reading"] self.reconstruction_autoscale = True - self._reconstruction_dimmed: bool = False + self._work: Optional[Work] = None self._top_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self.position_indicator_tag = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) @@ -359,8 +361,8 @@ def _on_hover(self, sender: Sender, app_data: Any, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks) self._status_bar.set( - self._msg_regenerating - if self._reconstruction_dimmed + self._work_words(self._work) + if self._work is not None else self._language_manager[ ( "global.graph.message.waveform_playable_navigation" @@ -437,7 +439,7 @@ def load_voice_waveform( name: The name the series is labeled by. color: The color the line is drawn in. """ - self._reconstruction_dimmed = False + self._work = None self._sample_rate = 0 self.clear_layers() self.add_layer(self._array_layer(audio, name=name, color=color)) @@ -499,19 +501,23 @@ def load_waveform_data(self, waveform_data: WaveformData) -> None: self._restate_clock_ticks(*self.x_range) - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Grays the reconstruction line while its audio is being regenerated, restoring it when done. + @property + def _reconstruction_dimmed(self) -> bool: + return self._work is not None + + def show_work(self, work: Optional[Work]) -> None: + """Grays the reconstruction line under the words naming ``work``, and restores it once nothing is on its way. Only the reconstruction series is grayed; the original-audio series and the axes keep full - strength, so the fade reads as "this waveform is being recomputed", and the status bar shows a - regenerating hint for the same span. The state is remembered so an async data update arriving - mid-regeneration redraws the reconstruction still grayed. + strength, so the fade reads as "this waveform is being recomputed", and the status bar names + what it waits for: the document being rebuilt, or its sound being prepared. The work is + remembered so an async data update arriving mid-way redraws the reconstruction still grayed. """ - if self._reconstruction_dimmed == dimmed: + if self._work == work: return - self._reconstruction_dimmed = dimmed - self._status_bar.set(self._msg_regenerating if dimmed else "") + self._work = work + self._status_bar.set(self._work_words(work)) layer = self.layers.get(self._lbl_waveform_reconstruction) if layer is None: @@ -521,6 +527,16 @@ def set_reconstruction_dimmed(self, dimmed: bool) -> None: if dpg.does_item_exist(series_tag): self._bind_series_theme(series_tag, layer) + def _work_words(self, work: Optional[Work]) -> str: + """The status line naming ``work``, and an empty one where nothing is on its way.""" + match work: + case Work.REBUILDING: + return self._msg_regenerating + case Work.READING: + return self._msg_reading + case None: + return "" + def reconstruction_layer(self, data: np.ndarray) -> ArrayLayer: return self._array_layer( data, @@ -611,7 +627,7 @@ def _delete_series(self) -> None: def clear(self) -> None: """Empties the plot down to the marks it keeps for whatever it draws next: the position indicator and the overlay rectangle, both of which live among the axis's children.""" - self._reconstruction_dimmed = False + self._work = None self._sample_rate = 0 self._release_clock_ticks() self.clear_layers() diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index 09cb0ba97..46c40eee2 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -31,6 +31,7 @@ from sampletones_application.view_model.reconstruction.waveform import ( InstrumentWaveformViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -161,8 +162,8 @@ def clear_waveform(self) -> None: def set_waveform_top_source(self, audio_source: AudioSourceType) -> None: self.waveform_display.set_top_source(audio_source) - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - self.waveform_display.set_reconstruction_dimmed(dimmed) + def show_work(self, work: Optional[Work]) -> None: + self.waveform_display.show_work(work) def set_overlay(self, index: Optional[int]) -> None: if self._frame_length is None: diff --git a/src/sampletones_application/view_model/reconstruction/work.py b/src/sampletones_application/view_model/reconstruction/work.py new file mode 100644 index 000000000..289c851b0 --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/work.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class Work(StrEnum): + """What the open document's line is busy with, which the waveform's fade names. + + An edit on its way rebuilds the document, and a change to what is heard, or a document just + opened, has its sound rendered. The reader sees one fade for both and reads from its words + which of the two the waveform waits for. + """ + + REBUILDING = "rebuilding" + READING = "reading" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index cff2df205..c9d9e606b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -332,6 +332,7 @@ global.graph.message.spectrum_navigation: "Right-click for more options." global.graph.message.waveform_navigation: "Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." global.graph.message.waveform_playable_navigation: "Click to play from a point. Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." global.graph.message.waveform_regenerating: "Regenerating reconstruction..." +global.graph.message.waveform_reading: "Preparing the sound..." # ============================================================================= # Main tab — Explorer 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 a3ce31308..937794088 100644 --- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -17,6 +17,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.enums import FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -135,6 +136,6 @@ def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordina assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None: - dims = [entry.args[0] for entry in settled._tab.set_reconstruction_dimmed.call_args_list] + work = [entry.args[0] for entry in settled._tab.show_work.call_args_list] - assert dims == [True, False] + assert work == [Work.REBUILDING, None] diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index 203d8d32e..ebf37bcd1 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -7,6 +7,7 @@ from automation.screen import Screen from automation.steps.exports import leaving_asks_nothing from automation.steps.reconstructions import expect_open, reconstruction_shade, wait_for_the_fade_to_end +from automation.vocabulary.graphs import WAVEFORM_READING from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings, longest_gap_after_each @@ -81,3 +82,31 @@ def keep_the_readings(screen: Screen) -> None: expect_no_long_hold(frames) screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run() + + +class TestWhatTheFadeSays: + """The status bar names what the fade waits for after a tick: the sound being prepared. + + The words stand for the few frames the sound takes, so they are read after every frame through + the tick. The words on opening are pinned where the line reports a listening and where the + waveform shows them, since the browser's own hover text holds the status bar while the pointer + rests on the row that opened the document. + """ + + def test_a_tick_reads_as_the_sound_being_prepared(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + reading = screen.words(WAVEFORM_READING) + unfaded: List[Optional[str]] = [] + + def open_it(screen: Screen) -> None: + expect_open(screen, LONG_RECONSTRUCTION) + unfaded.append(reconstruction_shade(screen)) + + def tick_a_box(screen: Screen) -> None: + with screen.record(screen.status_drawn) as recording: + reconstructions.tick_channel(TOGGLED_CHANNEL) + wait_for_the_fade_to_end(screen, unfaded[0]) + + assert reading in recording.values(), f"the status bar never read {reading!r} through the tick" + + screen.scenario(open_it, tick_a_box, leaving_asks_nothing).run() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 6d1807128..b5e8cd395 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -33,6 +33,7 @@ from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED from sampletones_application.utils.callbacks.gates import Gate, pass_gates from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP @@ -1911,11 +1912,11 @@ def test_the_waveform_stays_faded_until_every_step_has_landed( ) -> None: _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) - assert tab.set_reconstruction_dimmed.call_args_list == [call(True)] + assert tab.show_work.call_args_list == [call(Work.REBUILDING)] held_queue.drain() - assert tab.set_reconstruction_dimmed.call_args_list == [call(True), call(False)] + assert tab.show_work.call_args_list == [call(Work.REBUILDING), call(None)] def test_a_drag_collapses_into_the_place_it_ended( self, 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 9b14de8d7..1e9a909c6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -20,6 +20,7 @@ StemRemovalRequest, ) from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -68,7 +69,7 @@ class Outcome: heard: List[bool] = field(default_factory=list) dropped: int = 0 failures: List[Exception] = field(default_factory=list) - busy: List[bool] = field(default_factory=list) + work: List[Optional[Work]] = field(default_factory=list) @pytest.fixture @@ -106,7 +107,7 @@ def heard(envelopes_changed: bool) -> None: rewrites.on_heard_rendered = heard rewrites.on_dropped = drop rewrites.on_failed = outcome.failures.append - rewrites.on_busy_changed = outcome.busy.append + rewrites.on_work_changed = outcome.work.append return rewrites @@ -522,7 +523,8 @@ def test_a_change_after_the_step_has_run_is_taken( class TestTheBusySpan: - """The line reports filling as the first step waits or runs, and emptying once the last one lands.""" + """The line reports what it is busy with as the first step waits or runs, at a hand-over from one kind of + work to the other, and once the last step lands.""" def test_a_rebuild_fills_the_line_until_it_lands( self, @@ -533,12 +535,32 @@ def test_a_rebuild_fills_the_line_until_it_lands( rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) assert rewrites.is_busy + assert rewrites.work is Work.REBUILDING regeneration.finish() - assert outcome.busy == [True] + assert outcome.work == [Work.REBUILDING] regeneration.finish() - assert outcome.busy == [True, False] + assert outcome.work == [Work.REBUILDING, None] + assert not rewrites.is_busy + assert rewrites.work is None + + def test_a_listening_waiting_behind_a_rebuild_is_named_at_the_hand_over( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(ListeningChange()) + assert outcome.work == [Work.REBUILDING] + + regeneration.finish() + + assert outcome.work == [Work.REBUILDING, Work.READING] + assert rewrites.is_busy + regeneration.finish() + assert outcome.work == [Work.REBUILDING, Work.READING, None] assert not rewrites.is_busy def test_steps_taken_at_once_leave_the_line_empty( @@ -552,7 +574,8 @@ def test_steps_taken_at_once_leave_the_line_empty( rewrites.request(AfterEdits(gesture=gesture)) gesture.assert_called_once_with() - assert outcome.busy == [] + assert outcome.work == [] + assert not rewrites.is_busy class TestTheRendersALandingKeeps: @@ -768,8 +791,10 @@ def test_a_listening_fills_the_line( outcome: Outcome, ) -> None: rewrites.request(ListeningChange()) - assert outcome.busy == [True] + assert outcome.work == [Work.READING] + assert rewrites.is_busy regeneration.finish() - assert outcome.busy == [True, False] + assert outcome.work == [Work.READING, None] + assert not rewrites.is_busy diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index 2f74968cf..686e6d290 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from typing import Any, Dict, List, Tuple, Union +from typing import Any, Dict, Final, List, Tuple, Union from unittest.mock import MagicMock import numpy as np @@ -9,8 +9,12 @@ from sampletones_application.ui.elements.graphs.layers.decimation import Decimations from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.waveform_data import WaveformData +REGENERATING: Final[str] = "Regenerating reconstruction..." +READING: Final[str] = "Preparing the sound..." + VOICE_SAMPLES = 64 @@ -108,10 +112,11 @@ def _graph() -> GUIWaveformGraph: graph.position_indicator_tag = "indicator" graph.overlay_rectangle_tag = "overlay" graph.layers = {} - graph._reconstruction_dimmed = False + graph._work = None graph._lbl_waveform_reconstruction = "Reconstruction" graph._status_bar = MagicMock() - graph._msg_regenerating = "Regenerating reconstruction..." + graph._msg_regenerating = REGENERATING + graph._msg_reading = READING graph.tag = "waveform" graph._series_themes = {} return graph @@ -293,7 +298,7 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None: def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None: graph = _graph() _with_layout(graph, opacity=0.4) - graph._reconstruction_dimmed = True + graph._work = Work.REBUILDING layer = _Layer("Reconstruction") faded = graph._series_color(layer, graph._series_shade(layer)) @@ -304,7 +309,7 @@ def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None: def test_series_color_leaves_other_layers_opaque_when_dimmed(self) -> None: graph = _graph() _with_layout(graph) - graph._reconstruction_dimmed = True + graph._work = Work.REBUILDING layer = _Layer("Sample Name") assert graph._series_color(layer, graph._series_shade(layer)) == layer.color @@ -326,33 +331,54 @@ def test_set_dimmed_rebinds_the_reconstruction_series_once( lambda tag, theme: binds.append(theme), ) - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert graph._reconstruction_dimmed is True assert len(binds) == 1 - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert len(binds) == 1 def test_set_dimmed_without_reconstruction_layer_is_a_noop(self) -> None: graph = _graph() - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert graph._reconstruction_dimmed is True - def test_set_dimmed_shows_then_clears_the_status_message(self) -> None: + @pytest.mark.parametrize( + ("work", "words"), + [ + pytest.param(Work.REBUILDING, REGENERATING, id="an edit on its way"), + pytest.param(Work.READING, READING, id="the sound being prepared"), + ], + ) + def test_showing_work_names_it_then_clears_the_status_message( + self, + work: Work, + words: str, + ) -> None: graph = _graph() - graph.set_reconstruction_dimmed(True) - graph._status_bar.set.assert_called_with("Regenerating reconstruction...") + graph.show_work(work) + graph._status_bar.set.assert_called_with(words) - graph.set_reconstruction_dimmed(False) + graph.show_work(None) graph._status_bar.set.assert_called_with("") + assert graph._reconstruction_dimmed is False + + def test_a_hand_over_changes_the_words_under_the_same_fade(self) -> None: + graph = _graph() + + graph.show_work(Work.REBUILDING) + graph.show_work(Work.READING) + + graph._status_bar.set.assert_called_with(READING) + assert graph._reconstruction_dimmed is True def test_new_data_keeps_the_reconstruction_dimmed(self, monkeypatch: pytest.MonkeyPatch) -> None: """An edit landing while later ones are still on their way redraws the waveform still faded.""" graph = _graph() - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) monkeypatch.setattr(graph, "clear_layers", MagicMock()) monkeypatch.setattr(graph, "_restate_clock_ticks", MagicMock()) monkeypatch.setattr(graph, "_display_layers", MagicMock(return_value=[])) From 3238d66c24c553f17b79fc9d957fb0a3b47bffd1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 15:20:42 +0200 Subject: [PATCH 22/37] Owed: a WAV export to the worker where the cache keeps no sound for the hearing --- .../application/reconstruction-edits.md | 4 +- docs/development/bugs-and-todos.md | 5 +- .../logic/reconstruction/data.py | 18 ----- .../logic/reconstruction/reconstruction.py | 51 +++++++++--- .../logic/reconstruction/test_data.py | 33 +------- .../reconstruction/test_reconstruction.py | 79 +++++++++++++++++-- 6 files changed, 121 insertions(+), 69 deletions(-) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 0dc15f199..8b4a29274 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -86,7 +86,9 @@ moves the choice the listening makes on the new record, so its landing reads the 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. +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 +once the worker's reading lands, for what the reader hears then. ## The waveform fades while the line is busy diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 896084c8a..ac28e14a8 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -89,11 +89,10 @@ 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)); three readings stay on the render thread. The + 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. A WAV export - of a hearing the cache keeps no sound for renders it on the spot, where the reader waits for the file. + 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/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index e7c7ed91b..5a2332ca3 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -15,7 +15,6 @@ from sampletones_core.reconstructions.reconstruction.stems.hearing import ( HeardRenders, Hearing, - heard_renders, ) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger @@ -240,23 +239,6 @@ def waveform_data( return self._waveform_data(self.original_mix_for(hearing.selection), heard) - def audio_for( - self, - renders: RenderCache, - hearing: Hearing, - ) -> np.ndarray: - """What ``hearing`` plays of the document, rendered here where the cache keeps none of it. - - An export writes what plays and runs where the reader waits for it, so it reads what is - kept and renders the rest on the spot. - """ - heard = renders.reading(self.reconstruction, hearing) - if heard is not None: - return heard.playing - - channels = renders.channels(self.reconstruction) - return heard_renders(self.reconstruction, hearing, channels, renders.mix(self.reconstruction)).playing - def _waveform_data( self, original_audio: Optional[np.ndarray], diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 2653558e4..a3f4298a0 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -134,6 +134,7 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._owed: Optional[OwedShowing] = None + self._export_owed: Optional[Path] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -195,13 +196,16 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: ) def redraw_listening(self) -> None: - """Shows what the reader hears once its sound is kept, where a showing waited for it.""" - owed = self._owed + """Shows what the reader hears once its sound is kept, where a showing or an export waited for it.""" reconstruction_data = self._reconstruction_data - if owed is None or not reconstruction_data: + if not reconstruction_data: return - self._show(reconstruction_data, owed) + if self._owed is not None: + self._show(reconstruction_data, self._owed) + + if self._export_owed is not None: + self._export(reconstruction_data, self._export_owed) def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: """Draws and plays what the reader hears, or asks for its sound where the screen holds none yet. @@ -264,6 +268,10 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION self._owed = None + if self._export_owed is not None: + logger.info(f"The export to {self._export_owed} waited for the sound of a document put away") + self._export_owed = None + self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) self.call( @@ -702,13 +710,38 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: logger.warning("No reconstruction data available for WAV export") return - audio_snapshot = reconstruction_data.audio_for( - self._reconstruction_manager.renders, - self._listening.hearing, - ) + self._export(reconstruction_data, filepath) + + def _export( + self, + reconstruction_data: ReconstructionData, + filepath: Path, + ) -> None: + """Writes what the reader hears to ``filepath``, or asks for its sound where the screen holds none yet. + + An export is a reading of what plays like a showing, so it reads the kept sound and computes + no sample on the render thread. An export the cache cannot answer is owed beside a showing: + the worker is asked, and the file is written once the sound lands, for what the reader hears + then. A document put away before that takes the export with it. + """ + waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) + if waveform is None: + self._export_owed = filepath + self.call(self.on_renders_wanted) + return + + self._export_owed = None + self._write_wav(reconstruction_data, waveform.playing, filepath) + + def _write_wav( + self, + reconstruction_data: ReconstructionData, + playing: np.ndarray, + filepath: Path, + ) -> None: sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) - self._export_service.export_wav(filepath, sample_rate, audio_snapshot) + self._export_service.export_wav(filepath, sample_rate, playing) def handle_locate_original_audio(self) -> None: if not self._reconstruction_manager.source_paths: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 0922401e6..f80730130 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -363,11 +363,11 @@ def test_partials_keep_only_the_selected_stems_frames( expected = rendered_mix(data.reconstruction).copy() expected[frame_length:] = 0 - partials = data.audio_for(renders, _hearing(0)) + partials = _kept(renders, data, _hearing(0)).playing np.testing.assert_allclose(partials, expected) np.testing.assert_allclose( - data.audio_for(renders, _hearing(0, 1)), + _kept(renders, data, _hearing(0, 1)).playing, rendered_mix(data.reconstruction), ) @@ -431,7 +431,7 @@ def test_a_single_source_with_no_selection_is_silence( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") np.testing.assert_array_equal( - data.audio_for(renders, _hearing()), + _kept(renders, data, _hearing()).playing, np.zeros_like(rendered_mix(data.reconstruction)), ) assert data.original_audio is not None @@ -675,30 +675,3 @@ def test_a_recording_left_out_reads_what_the_worker_kept( assert waveform_data.approximation is not renders.mix(reconstruction) np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(renders.mix(reconstruction))) np.testing.assert_array_equal(waveform_data.playing, waveform_data.approximation) - - -class TestTheAudioAnExportWrites: - """An export writes what plays, read from what is kept and rendered on the spot where nothing is.""" - - def test_what_is_kept_is_handed_back( - self, - reconstruction_factory: Callable[[], Reconstruction], - renders: RenderCache, - ) -> None: - data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") - kept = _kept(renders, data, _hearing(0)) - - assert data.audio_for(renders, _hearing(0)) is kept.playing - - def test_what_is_not_kept_is_rendered( - self, - reconstruction_factory: Callable[[], Reconstruction], - renders: RenderCache, - ) -> None: - data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") - - np.testing.assert_array_equal(data.audio_for(renders, _hearing(0)), rendered_mix(data.reconstruction)) - np.testing.assert_array_equal( - data.audio_for(renders, _hearing(0, channels_on=frozenset())), - np.zeros_like(rendered_mix(data.reconstruction)), - ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 8d708e176..d16635303 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -35,9 +35,10 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.instructions import PulseInstruction, TriangleInstruction 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 +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -112,6 +113,14 @@ def _heard_whole(reconstruction_data: ReconstructionData) -> Hearing: ) +def _playing(reconstruction_data: ReconstructionData, hearing: Hearing) -> np.ndarray: + """What ``hearing`` plays of the document, rendered afresh by the core as the oracle.""" + reconstruction = reconstruction_data.reconstruction + return heard_renders( + reconstruction, hearing, rendered_channels(reconstruction), rendered_mix(reconstruction) + ).playing + + def _open(manager: MagicMock, reconstruction_data: ReconstructionData) -> None: """Puts a document in front of the panel the way the manager's own adoption does, its sound kept. @@ -805,6 +814,7 @@ class Gesture(BaseRegularTestCase): Gesture(label="stem", act=lambda panel_logic: panel_logic.set_stem_channels(0, frozenset())), Gesture(label="solo", act=lambda panel_logic: panel_logic.solo_stem(0)), Gesture(label="source", act=lambda panel_logic: panel_logic.set_audio_source(AudioSourceType.RECONSTRUCTION)), + Gesture(label="export", act=lambda panel_logic: panel_logic.handle_export_wav_confirmed(Path("output.wav"))), ) @pytest.mark.parametrize("gesture", gestures, ids=lambda gesture: gesture.label) @@ -906,6 +916,51 @@ def test_nothing_owed_shows_nothing( drawn.assert_not_called() + def test_an_export_owed_is_written_once_the_sound_lands( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + asked = MagicMock() + panel_logic.on_renders_wanted = asked + mock_reconstruction_manager.listening.set_channels_on(frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + asked.assert_called_once() + mock_export_service.export_wav.assert_not_called() + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + mock_export_service.export_wav.assert_called_once() + kept = loaded_data.waveform_data( + mock_reconstruction_manager.renders, mock_reconstruction_manager.listening.hearing + ) + assert kept is not None + assert mock_export_service.export_wav.call_args.args[2] is kept.playing + + def test_a_close_forgets_an_owed_export( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + panel_logic.on_renders_wanted = MagicMock() + mock_reconstruction_manager.listening.set_channels_on(frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + + panel_logic.close_reconstruction() + + _open(mock_reconstruction_manager, loaded_data) + panel_logic.redraw_listening() + mock_export_service.export_wav.assert_not_called() + def test_an_edit_owed_a_showing_updates_the_waveform_in_place( self, panel_logic: ReconstructionPanelLogic, @@ -1243,6 +1298,11 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate( mock_export_service.export_wav.assert_called_once() call_args = mock_export_service.export_wav.call_args assert call_args.args[1] == loaded_data.reconstruction.config.sample_rate + kept = loaded_data.waveform_data( + mock_reconstruction_manager.renders, mock_reconstruction_manager.listening.hearing + ) + assert kept is not None + assert call_args.args[2] is kept.playing class TestReconstructionPanelLogicComputeAudio: @@ -1395,7 +1455,6 @@ def test_silencing_a_recording_filters_waveform_and_playback( panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, stems_data: ReconstructionData, - renders: RenderCache, ) -> None: _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() @@ -1408,13 +1467,13 @@ def test_silencing_a_recording_filters_waveform_and_playback( panel_logic.set_stem_channels(0, frozenset()) panel_logic.redraw_listening() - expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) + expected = _playing(stems_data, mock_reconstruction_manager.listening.hearing) assert len(waveform_updates) == 1 np.testing.assert_allclose(waveform_updates[0].playing, expected) assert len(audio_updates) == 1 assert audio_updates[0] is not None np.testing.assert_allclose(audio_updates[0].sample, expected) - assert np.any(expected != stems_data.audio_for(renders, _heard_whole(stems_data))) + assert np.any(expected != _playing(stems_data, _heard_whole(stems_data))) def test_a_channel_switched_off_for_everything_mutes_its_column( self, @@ -1434,25 +1493,29 @@ def test_a_channel_switched_off_for_everything_mutes_its_column( assert stems_views[0].stems.muted_channels == frozenset({ChannelName.PULSE1}) assert rows["0"].channels == frozenset({ChannelName.PULSE1}) - def test_export_wav_uses_the_stems_filter( + def test_export_wav_writes_the_kept_sound_of_the_recordings_heard( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, stems_data: ReconstructionData, tmp_path: Path, - renders: RenderCache, ) -> None: _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() + panel_logic.on_renders_wanted = lambda: _hear(mock_reconstruction_manager) panel_logic.set_stem_channels(0, frozenset()) + panel_logic.redraw_listening() panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") mock_export_service.export_wav.assert_called_once() exported_audio = mock_export_service.export_wav.call_args.args[2] - expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) - np.testing.assert_allclose(exported_audio, expected) + hearing = mock_reconstruction_manager.listening.hearing + kept = stems_data.waveform_data(mock_reconstruction_manager.renders, hearing) + assert kept is not None + assert exported_audio is kept.playing + np.testing.assert_allclose(exported_audio, _playing(stems_data, hearing)) class TestWhatTheEnvelopesShow: From 50df3ae58418a684f7b13e0db31c02a864a5889d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 17:33:10 +0200 Subject: [PATCH 23/37] 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 8b4a29274..6d7787e61 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 d5258c69a..9b38d7244 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 2355a4c93..b7184834a 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 9abfce7a2..f22281a1a 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 43284b84f..1d3e84fef 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 1787fcd0b..bad826b7d 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 c49f84483..ee6938f7c 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 937794088..462caf9a5 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 1e9a909c6..8eb43d967 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 b7900f37f..72dc0e469 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 865342fbc..1085e875a 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 7c8754798..bdb331984 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 4bb8e9d3a..2cfda45cb 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 24/37] 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 9e7cd07df..2bc402f22 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 eb619adae..cfbe9584e 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 dee2b7839..04ff6ea76 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 d30fb50ef..a2c571dfd 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 65c10e2c4..8e566d41d 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 ac28e14a8..90e27636c 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 8ddcb2ca0..94e2aa0e8 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 58921e891..bb9755792 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 4dca9ddf1..ea851c2e7 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 6ba283ede..c1e1c2595 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 000000000..4dfd7e165 --- /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 000000000..8592a29c2 --- /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 000000000..e69de29bb 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 000000000..749977ad1 --- /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 000000000..739f275b3 --- /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 8f71c81e7..4d762c94d 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 b330986ef..add84101b 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 000000000..8a0c246a3 --- /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 From a0c6a60c71b5d2fc6d2891040295220555e5e810 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 19:37:18 +0200 Subject: [PATCH 25/37] Removed: the render cache's own rendering, every sound now a worker's result --- docs/development/application/render-thread.md | 6 +- docs/development/application/undo.md | 2 +- docs/development/bugs-and-todos.md | 5 - .../logic/shared/renders.py | 48 +---- .../test_stems_reconstruction.py | 4 +- .../reconstruction/rewrites/test_queue.py | 8 +- .../logic/reconstruction/test_data.py | 9 +- .../logic/shared/test_renders.py | 186 ++++++++++-------- .../logic/shared/test_renders_threads.py | 51 +++-- 9 files changed, 149 insertions(+), 170 deletions(-) diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index a2c571dfd..c32d9308d 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -27,8 +27,10 @@ left out for the first time or a freshly opened document, is asked of the same w 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. 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. +for what the cache lacks (`DocumentPreviews` in `logic/shared/previews.py`), and sounds at the landing. +The render cache keeps what the landings adopt and renders nothing itself, so its writers are the +landings, every one of them on the render thread, and every sound the interface draws or plays is a +worker's result. 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/application/undo.md b/docs/development/application/undo.md index 69dcb0386..10cfad301 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -35,7 +35,7 @@ A reconstruction edit follows the same rule. `RegenerationService` rebuilds one **The types enforce the split.** A value's model is frozen, so a write in place raises where it is made. A core test walks every class a project reaches (`tests/unit/sampletones_core/project/test_values.py`). It requires each class to be a declared shell or a frozen value made of values. The history audit (`tests/suite/history/`) replays gestures against a model of the stack. After every step, it checks each stored entry against the state it recorded. -**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills with what the regeneration worker hands back: the channels and the mix of a rebuilt document, and the mix of what the reader hears of it. The sequencer's preview mix alone is rendered cold on its first read. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. +**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills with what the regeneration workers hand back: the channels and the mix of a rebuilt document, the mix of what the reader hears of it, and the whole of a voice previewed in the sequencer. The cache renders nothing itself. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. ## The open voice across a restore diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 90e27636c..ec334f1c3 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -87,11 +87,6 @@ dimension the import starts carrying. frames. A rebuild makes each frame of the channel anew, so on a four-channel conversion an entry has about a quarter of the document. Keeping the old instruction wherever the rebuilt one equals it brings an 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)); 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/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index a52fed9db..b2c3c6fb7 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -7,10 +7,8 @@ import numpy as np -from sampletones_core.audio.mixing import mix from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.generators.render import render_instructions from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_core.reconstructions.reconstruction.renders import sounding_streams @@ -77,11 +75,13 @@ class RenderCache: names, for each channel the reader leaves an owner out on, the record and the owners heard, so two hearings leaving nothing out read one mix. A render goes with the objects it was made from, and beyond ``budget_bytes`` the renders read longest ago go first, so the cache keeps - audio alone. The render thread reads the cache and keeps what it renders or adopts, and an - object's collection may drop its renders from any thread, so every change to the cache is - made under one lock. The lock lets its holder in again, since a stream let go of while a keep - runs on the same thread is forgotten from inside that keep. A kept render is read-only, so the - readers it is shared with copy what they change. + audio alone. The cache renders nothing: every render it keeps is a worker's result, adopted at + its landing on the render thread, and a reading the cache cannot answer is what a worker is + asked for, so the render thread computes no sample. The render thread reads the cache and + keeps what it adopts, and an object's collection may drop its renders from any thread, so + every change to the cache is made under one lock. The lock lets its holder in again, since a + stream let go of while a keep runs on the same thread is forgotten from inside that keep. A + kept render is read-only, so the readers it is shared with copy what they change. """ def __init__(self, *, budget_bytes: int) -> None: @@ -96,26 +96,6 @@ def held_bytes(self) -> int: """How many bytes the renders the cache keeps take.""" return self._held_bytes - def channels(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: - """The audio each channel in play renders from the stream it carries.""" - return { - stream.channel_name: self._channel(stream, reconstruction.config) - for stream in sounding_streams(reconstruction) - } - - def mix(self, reconstruction: Reconstruction) -> np.ndarray: - """The whole reconstruction, summed from the channels that play.""" - streams = sounding_streams(reconstruction) - parts = [self._plain_part(stream) for stream in streams] - key = self._mix_key(parts, reconstruction.config) - cached = self._read(key) - if cached is not None: - return cached - - mixed = mix([self._channel(stream, reconstruction.config) for stream in streams]) - self._keep(key, mixed, [stream for part in parts for stream in part.watched]) - return mixed - def held(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: """The renders the cache holds for the channels in play, by channel, which a lookup answers at once. @@ -231,20 +211,6 @@ def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey: def _mix_key(parts: Sequence[_Part], config: Config) -> RenderKey: return RenderKey(RenderKind.MIX, tuple(part.part for part in parts), config) - def _channel(self, stream: InstructionsItem, config: Config) -> np.ndarray: - key = self._channel_key(stream, config) - cached = self._read(key) - if cached is not None: - return cached - - rendered = render_instructions( - [data.instruction for data in stream.instructions], - stream.channel_name, - config, - ) - self._keep(key, rendered, [stream]) - return rendered - def _read(self, key: RenderKey) -> Optional[np.ndarray]: with self._lock: rendered = self._renders.get(key) diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 43e95d68b..24b078c4f 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -400,7 +400,9 @@ def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: ) full = kept(all_stem_ids) - assert full.approximation is renders.mix(data.reconstruction) + whole = renders.reading(data.reconstruction, hearing(all_stem_ids)) + assert whole is not None + assert full.approximation is whole.mix assert full.playing is full.approximation assert full.original_audio is data.original_audio 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 8eb43d967..874a4e810 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -28,6 +28,7 @@ from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing from tests.conftest import ReconstructionFactory from tests.suite.regeneration import HeldRegeneration +from tests.suite.renders import warmed from tests.suite.stems import ( SHARED_CHANNEL, SOLE_CHANNEL, @@ -606,7 +607,12 @@ def test_the_rebuild_takes_the_kept_renders_with_it( reconstruction_manager: ReconstructionManager, taking_turns: Reconstruction, ) -> None: - kept = reconstruction_manager.renders.channels(taking_turns) + warmed( + reconstruction_manager.renders, + taking_turns, + Hearing(selection=everything_heard(taking_turns), channels_on=frozenset(ChannelName.items())), + ) + kept = reconstruction_manager.renders.held(taking_turns) rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index f80730130..71b795caa 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -646,9 +646,11 @@ def test_projects_the_render_relevant_fields( waveform_data = _kept(renders, data, _hearing(0)) + reading = renders.reading(reconstruction, _hearing(0)) + assert reading is not None assert waveform_data.original_audio is data.original_audio - assert waveform_data.approximation is renders.mix(reconstruction) - assert waveform_data.playing is renders.mix(reconstruction) + assert waveform_data.approximation is reading.mix + assert waveform_data.playing is reading.mix assert waveform_data.coefficient == reconstruction.coefficient assert waveform_data.frame_length == reconstruction.config.frame_length @@ -672,6 +674,5 @@ def test_a_recording_left_out_reads_what_the_worker_kept( waveform_data = _kept(renders, data, _hearing()) - assert waveform_data.approximation is not renders.mix(reconstruction) - np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(renders.mix(reconstruction))) + np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(rendered_mix(reconstruction))) np.testing.assert_array_equal(waveform_data.playing, waveform_data.approximation) diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index f12300e93..b520bcd9c 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -1,6 +1,6 @@ import gc from pathlib import Path -from typing import Final, FrozenSet, List, Tuple +from typing import Final, FrozenSet, Tuple import numpy as np import pytest @@ -8,9 +8,8 @@ from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache from sampletones_core.audio.mixing import align -from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import InstructionUnion, PulseInstruction +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.hearing import Hearing, heard_renders @@ -38,77 +37,113 @@ def _edited(document: Reconstruction) -> Reconstruction: ) +def _adopted(renders: RenderCache, document: Reconstruction) -> Tuple[dict, np.ndarray]: # type: ignore[type-arg] + """Keeps the document's renders the way a landing does, and hands back what was kept.""" + channels = rendered_channels(document) + mixed = rendered_mix(document) + renders.adopt(document, channels, mixed) + return channels, mixed + + +def _whole_bytes(document: Reconstruction) -> int: + """The bytes a document's channel renders and its mix take together.""" + return sum(rendered.nbytes for rendered in rendered_channels(document).values()) + rendered_mix(document).nbytes + + +def _room_for_one_more(document: Reconstruction) -> int: + """A budget holding the document whole and one more render of its edited channel, so the next one evicts.""" + return _whole_bytes(document) + rendered_channels(document)[SHARED_CHANNEL].nbytes + + @pytest.fixture def document() -> Reconstruction: return _document() -class TestReadingARender: - """A render is read from the streams it sounds and kept for the next read.""" +class TestTheRendersALandingAdopts: + """Renders a worker made are kept as the document's own and read back by identity, each stream's once.""" - def test_the_render_is_what_the_document_sounds( + def test_adopted_renders_are_read_back_by_identity( self, renders: RenderCache, document: Reconstruction, ) -> None: - channels = renders.channels(document) - - expected = rendered_channels(document) - assert channels.keys() == expected.keys() - assert all(np.array_equal(channels[name], expected[name]) for name in expected) - assert np.array_equal(renders.mix(document), rendered_mix(document)) + channels, mixed = _adopted(renders, document) - def test_a_second_read_answers_with_the_kept_render( - self, - renders: RenderCache, - document: Reconstruction, - ) -> None: - first = renders.channels(document) - mixed = renders.mix(document) - - assert renders.channels(document) == first - assert renders.mix(document) is mixed + held = renders.held(document) + assert held.keys() == channels.keys() + assert all(held[name] is channels[name] for name in channels) + reading = renders.reading(document, _hearing(document.recorded_stem_ids)) + assert reading is not None + assert reading.mix is mixed - def test_a_stream_two_documents_share_renders_once( + def test_a_stream_two_documents_share_is_kept_once( self, renders: RenderCache, document: Reconstruction, ) -> None: - before = renders.channels(document) + channels, _ = _adopted(renders, document) - after = renders.channels(_edited(document)) + held = renders.held(_edited(document)) - assert after[SOLE_CHANNEL] is before[SOLE_CHANNEL] - assert after[SHARED_CHANNEL] is not before[SHARED_CHANNEL] + assert held.keys() == {SOLE_CHANNEL} + assert held[SOLE_CHANNEL] is channels[SOLE_CHANNEL] - def test_equal_streams_of_two_documents_render_apart(self, renders: RenderCache) -> None: - first = renders.channels(_document()) - second = renders.channels(_document()) + def test_equal_streams_of_two_documents_are_kept_apart(self, renders: RenderCache) -> None: + first_document = _document() + second_document = _document() + first, _ = _adopted(renders, first_document) + second, _ = _adopted(renders, second_document) + assert renders.held(first_document)[SOLE_CHANNEL] is first[SOLE_CHANNEL] + assert renders.held(second_document)[SOLE_CHANNEL] is second[SOLE_CHANNEL] assert second[SOLE_CHANNEL] is not first[SOLE_CHANNEL] assert np.array_equal(second[SOLE_CHANNEL], first[SOLE_CHANNEL]) + def test_the_cache_names_no_renderer(self) -> None: + """Every render the cache keeps is a worker's result, so the module reaches no renderer.""" + assert not hasattr(renders_module, "render_instructions") + class TestTheBudget: - """The cache keeps audio within its budget, letting the renders read longest ago go first.""" + """The cache keeps audio within its budget, letting the renders read longest ago go first. + + A document is adopted channel by channel in channel order and its mix last, so with no read + between, the first channel's render is the oldest and goes first. + """ def test_the_oldest_render_goes_first(self, document: Reconstruction) -> None: - sole = rendered_channels(document)[SOLE_CHANNEL].nbytes - renders = RenderCache(budget_bytes=sole) - kept = renders.channels(document) + renders = RenderCache(budget_bytes=_room_for_one_more(document)) + _adopted(renders, document) + edited = _edited(document) - renders.channels(_edited(document)) + shared, _ = _adopted(renders, edited) - assert renders.held_bytes <= sole - assert renders.channels(document)[SHARED_CHANNEL] is not kept[SHARED_CHANNEL] + assert renders.held_bytes <= _room_for_one_more(document) + assert renders.held(document).keys() == {SOLE_CHANNEL} + held = renders.held(edited) + assert held.keys() == {SOLE_CHANNEL, SHARED_CHANNEL} + assert held[SHARED_CHANNEL] is shared[SHARED_CHANNEL] def test_a_render_read_again_stays(self, document: Reconstruction) -> None: + renders = RenderCache(budget_bytes=_room_for_one_more(document)) + channels, _ = _adopted(renders, document) + renders.held(document) + + _adopted(renders, _edited(document)) + + held = renders.held(document) + assert held.keys() == channels.keys() + assert all(held[name] is channels[name] for name in channels) + + def test_a_budget_leaves_room_for_what_fits(self, document: Reconstruction) -> None: renders = RenderCache(budget_bytes=RENDER_BUDGET) - kept = renders.channels(document) + channels, _ = _adopted(renders, document) - renders.channels(_edited(document)) + _adopted(renders, _edited(document)) - assert renders.channels(document) == kept + held = renders.held(document) + assert all(held[name] is channels[name] for name in channels) class TestARenderGoesWithItsStream: @@ -116,7 +151,7 @@ class TestARenderGoesWithItsStream: def test_a_collected_document_releases_its_renders(self, renders: RenderCache) -> None: document = _document() - renders.mix(document) + _adopted(renders, document) assert renders.held_bytes > 0 del document @@ -125,61 +160,38 @@ def test_a_collected_document_releases_its_renders(self, renders: RenderCache) - assert renders.held_bytes == 0 -def _never_rendering( - instructions: List[InstructionUnion], - channel_name: ChannelName, - config: Config, -) -> np.ndarray: - raise AssertionError(f"{channel_name} was rendered") - - class TestAdoptingRendersMadeElsewhere: - """Renders a rebuild made on its worker are kept as the document's own and read back without rendering. + """``held`` says what the cache holds for a document's channels, which is what a rebuild takes with it. - ``held`` says what the cache holds for a document's channels without rendering, which is what - a rebuild takes with it. A render already kept stays when the same stream's render is adopted - again, and every kept render is read-only, since the readers it is shared with copy what they - change. + A render already kept stays when the same stream's render is adopted again, and every kept + render is read-only, since the readers it is shared with copy what they change. """ - def test_held_names_the_channels_read_so_far(self, renders: RenderCache, document: Reconstruction) -> None: + def test_held_names_the_channels_kept_so_far(self, renders: RenderCache, document: Reconstruction) -> None: assert renders.held(document) == {} - kept = renders.channels(document) + channels, _ = _adopted(renders, document) held = renders.held(document) - assert held.keys() == kept.keys() - assert all(held[name] is kept[name] for name in kept) - - def test_adopted_renders_answer_every_read( - self, - renders: RenderCache, - document: Reconstruction, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - channels = rendered_channels(document) - mixed = rendered_mix(document) - - renders.adopt(document, channels, mixed) - - monkeypatch.setattr(renders_module, "render_instructions", _never_rendering) - assert all(renders.channels(document)[name] is channels[name] for name in channels) - assert renders.mix(document) is mixed + assert held.keys() == channels.keys() + assert all(held[name] is channels[name] for name in channels) def test_a_kept_render_is_read_only(self, renders: RenderCache, document: Reconstruction) -> None: - assert all(not rendered.flags.writeable for rendered in renders.channels(document).values()) - assert not renders.mix(document).flags.writeable + _, mixed = _adopted(renders, document) + + assert all(not rendered.flags.writeable for rendered in renders.held(document).values()) + assert not mixed.flags.writeable def test_a_render_already_kept_stays_when_adopted_again( self, renders: RenderCache, document: Reconstruction, ) -> None: - first = renders.channels(document) + first, _ = _adopted(renders, document) - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _adopted(renders, document) - after = renders.channels(document) + after = renders.held(document) assert all(after[name] is first[name] for name in first) @@ -200,20 +212,20 @@ class TestReadingAHearing: """ def test_a_document_heard_whole_reads_its_own_mix(self, renders: RenderCache, document: Reconstruction) -> None: - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _, mixed = _adopted(renders, document) reading = renders.reading(document, _hearing(document.recorded_stem_ids)) assert reading is not None - assert reading.mix is renders.mix(document) - assert reading.playing is renders.mix(document) + assert reading.mix is mixed + assert reading.playing is mixed def test_a_recording_left_out_reads_nothing_until_its_sound_is_kept( self, renders: RenderCache, document: Reconstruction, ) -> None: - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _adopted(renders, document) hearing = _hearing(frozenset({STEM_A_ID})) assert renders.reading(document, hearing) is None @@ -229,7 +241,7 @@ def test_a_narrower_channel_choice_reads_nothing_until_its_sound_is_kept( renders: RenderCache, document: Reconstruction, ) -> None: - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _, mixed = _adopted(renders, document) hearing = _hearing(document.recorded_stem_ids, frozenset({SOLE_CHANNEL})) assert renders.reading(document, hearing) is None @@ -238,7 +250,7 @@ def test_a_narrower_channel_choice_reads_nothing_until_its_sound_is_kept( reading = renders.reading(document, hearing) assert reading is not None - assert reading.mix is renders.mix(document) + assert reading.mix is mixed np.testing.assert_array_equal( reading.playing, align([rendered_channels(document)[SOLE_CHANNEL]], len(reading.mix))[0], @@ -250,18 +262,18 @@ def test_recordings_heard_beyond_a_channel_read_as_the_channel_heard_whole( document: Reconstruction, ) -> None: """A heard set is keyed by the owners it reaches, so a stem absent from a channel changes its key nowhere.""" - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _, mixed = _adopted(renders, document) beyond = _hearing(document.recorded_stem_ids | frozenset({STEM_B_ID + 10})) reading = renders.reading(document, beyond) assert reading is not None - assert reading.mix is renders.mix(document) + assert reading.mix is mixed def test_a_heard_mix_goes_with_the_record_it_was_keyed_by(self, renders: RenderCache) -> None: document = _document() hearing = _hearing(frozenset({STEM_A_ID})) - renders.adopt(document, rendered_channels(document), rendered_mix(document)) + _adopted(renders, document) renders.adopt_heard(document, hearing, _heard(document, hearing)) before = renders.held_bytes diff --git a/tests/unit/sampletones_application/logic/shared/test_renders_threads.py b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py index 7f79184ed..137d1b6ef 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders_threads.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py @@ -8,11 +8,7 @@ import numpy as np import pytest -from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache -from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix from tests.suite.stems import SHARED_CHANNEL, taking_turns_reconstruction @@ -25,7 +21,6 @@ ROUNDS: Final[int] = 30 KEPT_RENDERS: Final[int] = 3 WRITER_SPACING: Final[int] = 1000 -RenderInstructions = Callable[[List[InstructionUnion], ChannelName, Config], np.ndarray] def _document(index: int) -> Reconstruction: @@ -68,18 +63,22 @@ def _run_together(workers: List[Callable[[], None]]) -> None: def _reads(renders: RenderCache, shared: Reconstruction) -> None: + """Reads the shared document's renders over and over, adopting them again wherever the writers evicted them.""" expected = rendered_channels(shared) expected_mix = rendered_mix(shared) for _ in range(ROUNDS): - channels = renders.channels(shared) - assert channels.keys() == expected.keys() - assert all(np.array_equal(channels[name], expected[name]) for name in expected) - assert np.array_equal(renders.mix(shared), expected_mix) + held = renders.held(shared) + if held.keys() != expected.keys(): + renders.adopt(shared, expected, expected_mix) + held = renders.held(shared) + + assert all(np.array_equal(held[name], expected[name]) for name in held) def _writes(renders: RenderCache, offset: int) -> None: for turn in range(ROUNDS): - renders.mix(_document(offset + turn)) + document = _document(offset + turn) + renders.adopt(document, rendered_channels(document), rendered_mix(document)) class TestReadersAndWritersSharingOneCache: @@ -116,36 +115,32 @@ def test_every_read_is_whole_and_the_accounts_balance( class TestADocumentLetGoWhileItsRenderIsMade: - """A document let go of everywhere else stays with the thread rendering it, and its render leaves once - that thread lets go. + """A document let go of everywhere else stays with the thread making and adopting its render, and its + render leaves once that thread lets go. - The maker's own reference keeps the streams alive through the render, so the render is kept - under a live identity; the forgetting then runs on the maker's thread as its references die. + The maker's own reference keeps the streams alive through the render and the keep, so the + render is kept under a live identity; the forgetting then runs as the maker's references die. """ def test_the_render_leaves_once_the_maker_lets_go( self, renders: RenderCache, - monkeypatch: pytest.MonkeyPatch, thread_failures: List[BaseException], ) -> None: - rendering = threading.Event() + rendered = threading.Event() release = threading.Event() - real_render: RenderInstructions = renders_module.render_instructions - - def held_render( - instructions: List[InstructionUnion], - channel_name: ChannelName, - config: Config, - ) -> np.ndarray: - rendering.set() + + def make_and_adopt() -> None: + document = _document(0) + channels = rendered_channels(document) + mixed = rendered_mix(document) + rendered.set() assert release.wait(JOIN_TIMEOUT) - return real_render(instructions, channel_name, config) + renders.adopt(document, channels, mixed) - monkeypatch.setattr(renders_module, "render_instructions", held_render) - maker = threading.Thread(target=renders.mix, args=(_document(0),), name="RenderCaseMaker") + maker = threading.Thread(target=make_and_adopt, name="RenderCaseMaker") maker.start() - assert rendering.wait(JOIN_TIMEOUT) + assert rendered.wait(JOIN_TIMEOUT) gc.collect() release.set() From fcfb85521732ef751144914561a6144b09787b4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 22:26:52 +0200 Subject: [PATCH 26/37] Answered: silence of the document's own length, a landing kept whole, and a line that starts or stays idle --- .../logic/reconstruction/rewrites/queue.py | 88 ++++++--------- .../logic/shared/previews.py | 3 +- .../logic/shared/renders.py | 103 ++++++++++++++---- .../services/regeneration/service.py | 14 ++- .../reconstruction/rewrites/test_queue.py | 31 +++++- .../reconstruction/test_reconstruction.py | 71 +++++++++--- .../logic/shared/test_renders.py | 57 ++++++++++ .../logic/shared/test_renders_threads.py | 25 +++++ .../services/test_regeneration.py | 26 +++++ 9 files changed, 319 insertions(+), 99 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index edd5bf0a3..95a8906bb 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, replace -from typing import Callable, List, Optional, Union +from typing import Callable, List, Optional from sampletones_application.logic.reconstruction.edit import ( ChannelEdit, @@ -40,16 +40,14 @@ @dataclass(frozen=True) -class Rebuild: - """A channel change the regeneration is rebuilding, beside the document it started from. +class Running: + """A job the worker runs, beside the document it started from. Attributes: - change: What the reader moved. - base: The document the rebuild started from, or ``None`` once an outside replacement put - that document away. + base: The document the job started from, or ``None`` once an outside replacement put that + document away. """ - change: ChannelChange base: Optional[Reconstruction] def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: @@ -58,21 +56,19 @@ def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: @dataclass(frozen=True) -class Listening: - """A reading of what the reader hears the worker is rendering, beside the document it reads. +class Rebuild(Running): + """A channel change the worker is rebuilding. Attributes: - base: The document read, or ``None`` once an outside replacement put that document away. + change: What the reader moved. """ - base: Optional[Reconstruction] - - def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: - """Whether the result belongs to ``reconstruction``, the document it was computed from.""" - return self.base is not None and self.base is reconstruction + change: ChannelChange -Running = Union[Rebuild, Listening] +@dataclass(frozen=True) +class Reading(Running): + """A reading of what the reader hears, which the worker is rendering.""" class ReconstructionRewrites(CallbackMixin): @@ -123,30 +119,19 @@ def __init__( @property def work(self) -> Optional[Work]: - """What the line is busy with, which the waveform's fade names, or ``None`` while it stands empty. - - The running job says what it does. While steps wait with none running, the first of them - says what comes: a change to what is heard has the sound read, and every other step - rewrites the document. - """ + """What the running job does, which the waveform's fade names, or ``None`` while none runs.""" match self._running: case Rebuild(): return Work.REBUILDING - case Listening(): + case Reading(): return Work.READING - match self._waiting[0] if self._waiting else None: - case None: - return None - case ListeningChange(): - return Work.READING - case _: - return Work.REBUILDING + return None @property def is_busy(self) -> bool: """Whether a job runs or a step waits, which is the span the open document's sound is on its way.""" - return self.work is not None + return self._running is not None or bool(self._waiting) def request(self, rewrite: Rewrite) -> None: """Puts a step at the end of the line, and takes it at once where nothing runs before it. @@ -260,9 +245,11 @@ def _take(self, step: Rewrite) -> None: def _rebuild(self, change: ChannelChange) -> None: """Starts the rebuild of a changed channel from the document and the listening as they stand. - The rebuild is marked running before it starts, since a result can arrive before the start - returns. The renders the cache holds for the document go with it, so the worker renders the - edited channel alone and the landing finds every channel's audio kept. + Everything the job takes is read first, so a reading that raises leaves the line idle; + the rebuild is then marked running before it starts, since a case's synchronous seam can + deliver the result inside the start. The renders the cache holds for the document go with + it, so the worker renders the edited channel alone and the landing finds every channel's + audio kept. """ reconstruction = self._manager.reconstruction envelopes = self._manager.current_features @@ -270,33 +257,29 @@ def _rebuild(self, change: ChannelChange) -> None: logger.info(f"A change of {change.channel_name} found no document open") return + rebased = change.rebased(envelopes[change.channel_name]) + hearing = self._manager.listening.hearing + kept = self._manager.renders.held(reconstruction) self._running = Rebuild(change=change, base=reconstruction) - self._regeneration.start( - reconstruction, - change.channel_name, - change.rebased(envelopes[change.channel_name]), - hearing=self._manager.listening.hearing, - kept=self._manager.renders.held(reconstruction), - ) + self._regeneration.start(reconstruction, change.channel_name, rebased, hearing=hearing, kept=kept) def _listen(self) -> None: """Starts rendering what the reader hears of the document as it stands. - The listening is marked running before it starts, since a result can arrive before the - start returns. The renders the cache holds go with it, so the worker renders what is missing - alone. + Everything the job takes is read first, so a reading that raises leaves the line idle; the + reading is then marked running before it starts, since a case's synchronous seam can + deliver the result inside the start. The renders the cache holds go with it, so the worker + renders what is missing alone. """ reconstruction = self._manager.reconstruction if reconstruction is None: logger.info("A change to what is heard found no document open") return - self._running = Listening(base=reconstruction) - self._regeneration.start_listening( - reconstruction, - hearing=self._manager.listening.hearing, - kept=self._manager.renders.held(reconstruction), - ) + hearing = self._manager.listening.hearing + kept = self._manager.renders.held(reconstruction) + self._running = Reading(base=reconstruction) + self._regeneration.start_listening(reconstruction, hearing=hearing, kept=kept) def _remove(self, removal: StemRemovalRequest) -> None: """Takes a recording out, where a step before it left the recording and another beside it.""" @@ -361,8 +344,7 @@ def _land(self, running: Running, outcome: RenderedDocument) -> None: self._let_go(running) return - self._manager.renders.adopt(outcome.reconstruction, outcome.channels, outcome.mix) - self._manager.renders.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + self._manager.renders.adopt_rendered(outcome) envelopes = HeardEnvelopes(selection=outcome.hearing.selection, channels=outcome.envelopes) match running: case Rebuild(change=change): @@ -375,7 +357,7 @@ def _land(self, running: Running, outcome: RenderedDocument) -> None: envelopes=envelopes, ), ) - case Listening(): + case Reading(): changed = self._manager.adopt_envelopes(outcome.reconstruction, envelopes) self.call(self.on_heard_rendered, changed) diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py index 4dfd7e165..b65a62c97 100644 --- a/src/sampletones_application/logic/shared/previews.py +++ b/src/sampletones_application/logic/shared/previews.py @@ -136,8 +136,7 @@ def _on_result(self, result: RegenerationResult) -> None: 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) + self._renders.adopt_rendered(outcome) wanted = self._wanted if wanted is not None and wanted.reconstruction is outcome.reconstruction: self._wanted = None diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index b2c3c6fb7..0afa3bf33 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -1,17 +1,20 @@ import threading import weakref from collections import OrderedDict +from contextlib import contextmanager from dataclasses import dataclass from enum import StrEnum -from typing import Dict, FrozenSet, List, Mapping, Optional, Sequence, Tuple +from typing import Dict, FrozenSet, Iterator, List, Mapping, Optional, Sequence, Set, Tuple, Union import numpy as np +from sampletones_application.services.regeneration.result import RenderedDocument from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_core.reconstructions.reconstruction.renders import sounding_streams +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing @@ -52,12 +55,15 @@ def identities(self) -> FrozenSet[int]: return frozenset(named) +Watched = Union[InstructionsItem, ChannelAssignment] + + @dataclass(frozen=True) class _Part: """A part beside the objects it names, which the cache watches.""" part: RenderPart - watched: Tuple[object, ...] + watched: Tuple[Watched, ...] class RenderCache: @@ -75,21 +81,27 @@ class RenderCache: names, for each channel the reader leaves an owner out on, the record and the owners heard, so two hearings leaving nothing out read one mix. A render goes with the objects it was made from, and beyond ``budget_bytes`` the renders read longest ago go first, so the cache keeps - audio alone. The cache renders nothing: every render it keeps is a worker's result, adopted at - its landing on the render thread, and a reading the cache cannot answer is what a worker is - asked for, so the render thread computes no sample. The render thread reads the cache and - keeps what it adopts, and an object's collection may drop its renders from any thread, so - every change to the cache is made under one lock. The lock lets its holder in again, since a - stream let go of while a keep runs on the same thread is forgotten from inside that keep. A - kept render is read-only, so the readers it is shared with copy what they change. + audio alone. A landing is kept whole: the renders one landing keeps are spared by its own + eviction, so a document larger than the budget stands until the next landing, and a reading + taken right after a landing answers. A hearing with no channel on plays silence as long as + the document's mix, answered at the read and kept nowhere, since it names no part. The cache + renders nothing: every render it keeps is a worker's result, adopted at its landing on the + render thread, and a reading the cache leaves unanswered is what a worker is asked for, so the + render thread computes no sample. The render thread reads the cache and keeps what it adopts, + and an object's collection may drop its renders from any thread, so every change to the cache + is made under one lock. The lock lets its holder in again, since a stream let go of while a + keep runs on the same thread is forgotten from inside that keep. A kept render is read-only, + so the readers it is shared with copy what they change. """ def __init__(self, *, budget_bytes: int) -> None: self._budget_bytes = budget_bytes self._renders: OrderedDict[RenderKey, np.ndarray] = OrderedDict() - self._watched: set[int] = set() + self._watched: Set[int] = set() self._held_bytes = 0 self._lock = threading.RLock() + self._landing_depth = 0 + self._landed: Set[RenderKey] = set() @property def held_bytes(self) -> int: @@ -123,12 +135,29 @@ def reading( """ parts = self._heard_parts(reconstruction, hearing) mixed = self._read(self._mix_key(list(parts.values()), reconstruction.config)) - playing = self._read(self._mix_key(self._playing_parts(parts, hearing), reconstruction.config)) - if mixed is None or playing is None: + if mixed is None: + return None + + playing_parts = self._playing_parts(parts, hearing) + if not playing_parts: + return HeardRenders(mix=mixed, playing=np.zeros_like(mixed)) + + playing = self._read(self._mix_key(playing_parts, reconstruction.config)) + if playing is None: return None return HeardRenders(mix=mixed, playing=playing) + def adopt_rendered(self, outcome: RenderedDocument) -> None: + """Keeps everything a job rendered as one landing: the channels, the mix, and the hearing's reading. + + Args: + outcome: What the worker handed back. + """ + with self._landing(): + self.adopt(outcome.reconstruction, outcome.channels, outcome.mix) + self.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + def adopt( self, reconstruction: Reconstruction, @@ -146,11 +175,12 @@ def adopt( mixed: The whole document, summed from ``channels``. """ streams = sounding_streams(reconstruction) - for stream in streams: - self._keep(self._channel_key(stream, reconstruction.config), channels[stream.channel_name], [stream]) + with self._landing(): + for stream in streams: + self._keep(self._channel_key(stream, reconstruction.config), channels[stream.channel_name], [stream]) - parts = [self._plain_part(stream) for stream in streams] - self._keep(self._mix_key(parts, reconstruction.config), mixed, streams) + parts = [self._plain_part(stream) for stream in streams] + self._keep(self._mix_key(parts, reconstruction.config), mixed, streams) def adopt_heard( self, @@ -160,14 +190,33 @@ def adopt_heard( ) -> None: """Keeps what a hearing of the document sounds, rendered elsewhere, under the parts it was heard as. + What plays with no channel on is silence, answered at every read, so it is kept nowhere. + Args: reconstruction: The document the renders sound. hearing: What the reader heard of it when the renders were made. heard: The heard mix and what plays. """ parts = self._heard_parts(reconstruction, hearing) - self._keep_mix(list(parts.values()), reconstruction.config, heard.mix) - self._keep_mix(self._playing_parts(parts, hearing), reconstruction.config, heard.playing) + with self._landing(): + self._keep_mix(list(parts.values()), reconstruction.config, heard.mix) + playing_parts = self._playing_parts(parts, hearing) + if playing_parts: + self._keep_mix(playing_parts, reconstruction.config, heard.playing) + + @contextmanager + def _landing(self) -> Iterator[None]: + """One landing, whose keeps are spared by the eviction it runs; a landing inside another joins it.""" + with self._lock: + self._landing_depth += 1 + + try: + yield + finally: + with self._lock: + self._landing_depth -= 1 + if self._landing_depth == 0: + self._landed.clear() def _keep_mix(self, parts: List[_Part], config: Config, mixed: np.ndarray) -> None: self._keep( @@ -223,9 +272,10 @@ def _keep( self, key: RenderKey, rendered: np.ndarray, - watched: Sequence[object], + watched: Sequence[Watched], ) -> None: with self._lock: + self._landed.add(key) if key in self._renders: return @@ -237,9 +287,18 @@ def _keep( self._watched.add(id(named)) weakref.finalize(named, self._forget, id(named)) - while self._held_bytes > self._budget_bytes and len(self._renders) > 1: - _, evicted = self._renders.popitem(last=False) - self._held_bytes -= evicted.nbytes + self._evict_beyond_the_budget() + + def _evict_beyond_the_budget(self) -> None: + """Lets the renders read longest ago go, the landing's own spared, until the budget holds the rest.""" + for key in list(self._renders): + if self._held_bytes <= self._budget_bytes: + return + + if key in self._landed: + continue + + self._held_bytes -= self._renders.pop(key).nbytes def _forget(self, identity: int) -> None: """Drops every render a collected stream or record sounds in, before its identity can name another.""" diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index bad826b7d..8b6a0a06b 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -109,10 +109,10 @@ def _run(self, job: RenderJob) -> None: edited = None hearing = job.hearing - if SingleThreadExecutor.is_shutting_down(): + channels = self._rendered(document, edited, job.kept) + if channels is None or SingleThreadExecutor.is_shutting_down(): return - channels = self._rendered(document, edited, job.kept) mixed = mix(list(channels.values())) self._emit( ServiceSuccess( @@ -150,12 +150,18 @@ def _rendered( document: Reconstruction, edited: Optional[ChannelName], kept: Mapping[ChannelName, np.ndarray], - ) -> Dict[ChannelName, np.ndarray]: + ) -> Optional[Dict[ChannelName, np.ndarray]]: """The audio each sounding channel of ``document`` renders to, a handed render carried over where the - job left the channel alone. + job left the channel alone, or ``None`` where a shutdown asked for meanwhile ends the job. + + The shutdown is read before each channel, so a worker joined at the teardown gives up at + its next step. """ rendered: Dict[ChannelName, np.ndarray] = {} for stream in sounding_streams(document): + if SingleThreadExecutor.is_shutting_down(): + return None + handed = kept.get(stream.channel_name) if handed is not None and stream.channel_name != edited: rendered[stream.channel_name] = handed 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 874a4e810..8d6ff1866 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field -from typing import Final, List, Optional, Tuple +from typing import Dict, Final, List, Optional, Tuple from unittest.mock import MagicMock +import numpy as np import pytest from sampletones_application.logic.reconstruction.edit import ( @@ -581,6 +582,34 @@ def test_steps_taken_at_once_leave_the_line_empty( assert not rewrites.is_busy +class TestAJobThatFailsToStart: + """A job whose start raises leaves the line idle, so the fade lifts and the next request runs.""" + + def test_the_line_stands_idle_after_the_raise( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def refusing(reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: + raise RuntimeError("the kept renders could not be read") + + monkeypatch.setattr(reconstruction_manager.renders, "held", refusing) + with pytest.raises(RuntimeError): + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert not rewrites.is_busy + assert rewrites.work is None + assert regeneration.held == () + + monkeypatch.undo() + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert len(regeneration.held) == 1 + assert rewrites.work is Work.REBUILDING + + class TestTheRendersALandingKeeps: """A landed rebuild leaves the renders it made in the cache, so the panel reads them without rendering, and a rebuild that lands nowhere leaves none. diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index d16635303..02b95cce1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -857,15 +857,28 @@ def test_what_plays_is_the_reading_the_waveform_draws( class TestAShowingTheScreenHoldsNoSoundFor: """A gesture whose sound the cache keeps none of asks the worker for it, draws nothing yet, and shows what it owes once the sound lands. + + The document carries two recordings, so leaving one out is a hearing the cache keeps no sound + for; every channel off is silence, which the cache answers at once. """ + @pytest.fixture(name="recorded_data") + def recorded_data_fixture( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + """Two recordings taking turns on the first pulse, one frame each.""" + recorded = recorded_from(reconstruction_factory(), (tmp_path / "a.wav", tmp_path / "b.wav")) + return ReconstructionData.from_reconstruction(recorded, name="Sample") + def test_a_gesture_asks_once_and_draws_nothing_yet( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, + recorded_data: ReconstructionData, ) -> None: - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) asked = MagicMock() drawn = MagicMock() played = MagicMock() @@ -873,7 +886,7 @@ def test_a_gesture_asks_once_and_draws_nothing_yet( panel_logic.on_waveform_load_changed = drawn panel_logic.on_audio_data_changed = played - panel_logic.set_selected_channels([]) + panel_logic.set_stem_channels(1, frozenset()) asked.assert_called_once() drawn.assert_not_called() @@ -883,19 +896,43 @@ def test_the_showing_happens_once_the_sound_lands( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, + recorded_data: ReconstructionData, ) -> None: - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) panel_logic.on_renders_wanted = MagicMock() drawn: List[WaveformData] = [] played: List[Optional[AudioData]] = [] panel_logic.on_waveform_load_changed = drawn.append panel_logic.on_audio_data_changed = played.append - panel_logic.set_selected_channels([]) + panel_logic.set_stem_channels(1, frozenset()) _hear(mock_reconstruction_manager) panel_logic.redraw_listening() + assert len(drawn) == 1 + assert played[0] is not None + np.testing.assert_array_equal( + drawn[0].playing, _playing(recorded_data, mock_reconstruction_manager.listening.hearing) + ) + np.testing.assert_array_equal(played[0].sample, drawn[0].playing) + + def test_every_channel_off_shows_silence_at_once( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + asked = MagicMock() + panel_logic.on_renders_wanted = asked + drawn: List[WaveformData] = [] + played: List[Optional[AudioData]] = [] + panel_logic.on_waveform_load_changed = drawn.append + panel_logic.on_audio_data_changed = played.append + + panel_logic.set_selected_channels([]) + + asked.assert_not_called() assert len(drawn) == 1 assert played[0] is not None np.testing.assert_array_equal(drawn[0].playing, np.zeros_like(drawn[0].approximation)) @@ -921,13 +958,13 @@ def test_an_export_owed_is_written_once_the_sound_lands( panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, - loaded_data: ReconstructionData, + recorded_data: ReconstructionData, tmp_path: Path, ) -> None: - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) asked = MagicMock() panel_logic.on_renders_wanted = asked - mock_reconstruction_manager.listening.set_channels_on(frozenset()) + mock_reconstruction_manager.listening.set_channels(1, frozenset()) panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") asked.assert_called_once() mock_export_service.export_wav.assert_not_called() @@ -936,7 +973,7 @@ def test_an_export_owed_is_written_once_the_sound_lands( panel_logic.redraw_listening() mock_export_service.export_wav.assert_called_once() - kept = loaded_data.waveform_data( + kept = recorded_data.waveform_data( mock_reconstruction_manager.renders, mock_reconstruction_manager.listening.hearing ) assert kept is not None @@ -947,17 +984,17 @@ def test_a_close_forgets_an_owed_export( panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, - loaded_data: ReconstructionData, + recorded_data: ReconstructionData, tmp_path: Path, ) -> None: - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) panel_logic.on_renders_wanted = MagicMock() - mock_reconstruction_manager.listening.set_channels_on(frozenset()) + mock_reconstruction_manager.listening.set_channels(1, frozenset()) panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") panel_logic.close_reconstruction() - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) panel_logic.redraw_listening() mock_export_service.export_wav.assert_not_called() @@ -965,12 +1002,12 @@ def test_an_edit_owed_a_showing_updates_the_waveform_in_place( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, + recorded_data: ReconstructionData, ) -> None: """What was owed is what is shown: an edit's update keeps the view, a listening change loads afresh.""" - _open(mock_reconstruction_manager, loaded_data) + _open(mock_reconstruction_manager, recorded_data) panel_logic.on_renders_wanted = MagicMock() - mock_reconstruction_manager.listening.set_channels_on(frozenset()) + mock_reconstruction_manager.listening.set_channels(1, frozenset()) updated = MagicMock() loaded = MagicMock() panel_logic.on_waveform_update_changed = updated diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index b520bcd9c..66d6152cb 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -20,6 +20,7 @@ SOURCES: Final[Tuple[Path, Path]] = (Path("a.wav"), Path("b.wav")) EDITED: Final[PulseInstruction] = PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1) EDITED_PITCH: Final[int] = 67 +LONGER_FRAMES: Final[int] = 4 EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) @@ -136,6 +137,28 @@ def test_a_render_read_again_stays(self, document: Reconstruction) -> None: assert held.keys() == channels.keys() assert all(held[name] is channels[name] for name in channels) + def test_a_landing_is_kept_whole_within_a_budget_below_it(self, document: Reconstruction) -> None: + """A document larger than the budget is kept whole as it lands, and the previous renders go.""" + renders = RenderCache(budget_bytes=_whole_bytes(document) - 1) + _adopted(renders, _document()) + channels, mixed = _adopted(renders, document) + + held = renders.held(document) + assert held.keys() == channels.keys() + assert all(held[name] is channels[name] for name in channels) + reading = renders.reading(document, _hearing(document.recorded_stem_ids)) + assert reading is not None + assert reading.mix is mixed + + def test_the_next_landing_evicts_a_document_larger_than_the_budget(self, document: Reconstruction) -> None: + renders = RenderCache(budget_bytes=_whole_bytes(document) - 1) + _adopted(renders, document) + + _adopted(renders, _document()) + + assert renders.held(document) == {} + assert renders.held_bytes <= _whole_bytes(document) + def test_a_budget_leaves_room_for_what_fits(self, document: Reconstruction) -> None: renders = RenderCache(budget_bytes=RENDER_BUDGET) channels, _ = _adopted(renders, document) @@ -270,6 +293,40 @@ def test_recordings_heard_beyond_a_channel_read_as_the_channel_heard_whole( assert reading is not None assert reading.mix is mixed + def test_no_channel_on_reads_silence_of_the_documents_own_length(self, renders: RenderCache) -> None: + """Two documents at equal settings, every channel off, each read a silence as long as their own mix.""" + shorter = _document() + longer = _document().with_channel_data( + SHARED_CHANNEL, + [EDITED] * LONGER_FRAMES, + EDITED_PITCH, + (), + heard=shorter.recorded_stem_ids, + ) + assert len(rendered_mix(longer)) > len(rendered_mix(shorter)) + nothing_on = _hearing(shorter.recorded_stem_ids, frozenset()) + for document in (shorter, longer): + _adopted(renders, document) + renders.adopt_heard(document, nothing_on, _heard(document, nothing_on)) + + readings = [renders.reading(document, nothing_on) for document in (shorter, longer)] + + assert all(reading is not None for reading in readings) + assert [len(reading.playing) for reading in readings if reading is not None] == [ + len(rendered_mix(shorter)), + len(rendered_mix(longer)), + ] + assert all(not reading.playing.any() for reading in readings if reading is not None) + + def test_silence_is_kept_nowhere(self, renders: RenderCache, document: Reconstruction) -> None: + nothing_on = _hearing(document.recorded_stem_ids, frozenset()) + _adopted(renders, document) + before = renders.held_bytes + + renders.adopt_heard(document, nothing_on, _heard(document, nothing_on)) + + assert renders.held_bytes == before + def test_a_heard_mix_goes_with_the_record_it_was_keyed_by(self, renders: RenderCache) -> None: document = _document() hearing = _hearing(frozenset({STEM_A_ID})) diff --git a/tests/unit/sampletones_application/logic/shared/test_renders_threads.py b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py index 137d1b6ef..fc4ff203e 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders_threads.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders_threads.py @@ -114,6 +114,31 @@ def test_every_read_is_whole_and_the_accounts_balance( assert renders.held_bytes == 0 +class TestTheLockLetsItsHolderInAgain: + """A watched stream let go of while the cache's lock is held on the same thread is forgotten from inside. + + A collector pass can run a stream's finalizer on the thread holding the lock, from inside a + keep; the lock lets that thread in again, so the forgetting completes and the keep goes on. + """ + + def test_a_stream_collected_under_the_lock_is_forgotten_at_once( + self, + renders: RenderCache, + thread_failures: List[BaseException], + ) -> None: + def keep_and_let_go() -> None: + document = _document(0) + renders.adopt(document, rendered_channels(document), rendered_mix(document)) + with renders._lock: + del document + gc.collect() + assert renders.held_bytes == 0 + + _run_together([keep_and_let_go]) + + assert thread_failures == [] + + class TestADocumentLetGoWhileItsRenderIsMade: """A document let go of everywhere else stays with the thread making and adopting its render, and its render leaves once that thread lets go. diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index bdb331984..d8d183aa4 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -515,6 +515,32 @@ def armed_again(self) -> Iterator[None]: yield SingleThreadExecutor.reset_shutdown() + def test_a_shutdown_during_the_render_ends_the_job_before_the_next_channel( + self, + taking_turns: Reconstruction, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + rendered: List[ChannelName] = [] + + def rendering_then_shutting_down( + instructions: List[InstructionUnion], + channel_name: ChannelName, + config: Config, + ) -> np.ndarray: + rendered.append(channel_name) + SingleThreadExecutor.request_shutdown() + return np.zeros(1, dtype=np.float32) + + monkeypatch.setattr(service_module, "render_instructions", rendering_then_shutting_down) + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run(ListeningJob(taking_turns, _heard_whole(taking_turns), NO_RENDERS)) + + assert len(rendered) == 1 + assert results == [] + def test_the_job_renders_nothing_and_reports_nothing( self, taking_turns: Reconstruction, From 9c656165b8e5c5533f17768a6efeb5fec7f71751 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 23:41:11 +0200 Subject: [PATCH 27/37] Owed: one showing merged across gestures, and the envelopes to the worker like the sound --- .../categories/export.py | 3 + .../coordinators/reconstruction.py | 9 +- .../coordinators/tabs/reconstruction.py | 15 +- .../logic/reconstruction/envelopes.py | 22 --- .../logic/reconstruction/manager.py | 116 ++++++----- .../logic/reconstruction/reconstruction.py | 187 ++++++++++++------ .../logic/reconstruction/rewrites/queue.py | 53 +++-- src/sampletones_config/lang/en.yaml | 1 + .../reconstruction/rewrites/test_queue.py | 3 + .../services/test_regeneration.py | 2 +- .../test_export_waits_for_the_sound.py | 61 ++++++ .../reconstructions/stems/test_stems_card.py | 26 ++- tests/suite/envelopes.py | 31 +++ .../coordinators/tabs/test_reconstruction.py | 51 ++++- .../coordinators/test_reconstruction.py | 48 ++++- .../reconstruction/rewrites/test_queue.py | 71 ++++++- .../logic/reconstruction/test_envelopes.py | 2 +- .../logic/reconstruction/test_instruments.py | 2 +- .../logic/reconstruction/test_manager.py | 137 ++++++++----- .../reconstruction/test_reconstruction.py | 155 ++++++++++++++- .../sampletones_application/test_startup.py | 14 +- 21 files changed, 784 insertions(+), 225 deletions(-) create mode 100644 tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py create mode 100644 tests/suite/envelopes.py diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py index 4a0cf458d..6203826b3 100644 --- a/src/sampletones_application/categories/export.py +++ b/src/sampletones_application/categories/export.py @@ -25,6 +25,7 @@ class ExportMessages: instruments_failed: Shown when a reconstruction's instrument export fails. wav_success: Shown when the reconstruction reaches a WAV file. wav_failed: Shown when the WAV export fails. + wav_dropped: Shown when a WAV export waited for a sound that was never rendered. """ status_title: str @@ -37,6 +38,7 @@ class ExportMessages: instruments_failed: str wav_success: str wav_failed: str + wav_dropped: str @classmethod def build(cls, language_manager: LanguageManager) -> ExportMessages: @@ -59,4 +61,5 @@ def build(cls, language_manager: LanguageManager) -> ExportMessages: instruments_failed=language_manager["reconstructions.instruments.message.export_instruments_failed"], wav_success=language_manager["reconstructions.reconstruction.message.export_wav_success"], wav_failed=language_manager["reconstructions.reconstruction.message.export_wav_failed"], + wav_dropped=language_manager["reconstructions.reconstruction.message.export_wav_dropped"], ) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 2210c31a5..98e662dbb 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -103,6 +103,7 @@ def __init__( self._rewrites.on_heard_rendered = self._show_listening self._rewrites.on_dropped = self._redraw_open_document self._rewrites.on_failed = self._report_rebuild_failure + self._rewrites.on_reading_failed = self._abandon_showing self._rewrites.on_work_changed = self._show_work self._reconstruction_manager.set_callbacks( on_reconstruction_loaded=self.on_reconstruction_loaded, @@ -425,7 +426,7 @@ def apply_edit(self, edit: ReconstructionEdit) -> None: case ChannelEdit(): self._reconstruction_manager.apply_edited(edit.reconstruction, edit.envelopes) case Retune() | StemRemoval(): - self._reconstruction_manager.apply_edited(edit.reconstruction) + self._reconstruction_manager.apply_edited(edit.reconstruction, None) self._show_edit(edit) self._reconstruction_manager.mark_updated() @@ -559,7 +560,7 @@ def _rebind(self, reconstruction: Reconstruction) -> None: retimed = reconstruction.config.nes_frequency != open_reconstruction.config.nes_frequency self._drop_pending() - self._reconstruction_manager.apply_edited(reconstruction) + self._reconstruction_manager.apply_edited(reconstruction, None) self._tab.redraw_reconstruction(refit_waveform=retimed) def _redraw_open_document(self) -> None: @@ -580,6 +581,10 @@ def _report_rebuild_failure(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) + def _abandon_showing(self) -> None: + """Lets the tab go of what it owes the reader, where the listening that would have paid it failed.""" + self._tab.abandon_showing() + def _show_work(self, work: Optional[Work]) -> None: """Fades the reconstruction waveform under the words naming what the line is busy with. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 36be1efcc..0091b7cd9 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -319,6 +319,7 @@ def __init__( ) self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog + self._reconstruction_panel_logic.on_export_dropped = self._report_dropped_export self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found( path, language_manager["reconstructions.reconstruction.message.locate_audio_failed"], @@ -392,6 +393,11 @@ def _on_export_result(self, result: ExportResult) -> None: case ExportError(kind=ExportKind.SAMPLE, exception=exception): self._dialogs.show_error(exception, messages.instruments_failed) + def _report_dropped_export(self, filepath: Path) -> None: + """Reports an export that waited for a sound the worker never rendered, in the WAV dialog's words.""" + messages = self._export_messages + self._dialogs.show_message_with_path(messages.wav_title, messages.wav_dropped, filepath) + def _export_message( self, success: str, @@ -812,6 +818,10 @@ def redraw_reconstruction(self, *, refit_waveform: bool) -> None: self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.update_display() + def abandon_showing(self) -> None: + """Lets the panel go of what it owes the reader, where the worker will never render it.""" + self._reconstruction_panel_logic.abandon_showing() + def redraw_listening(self, *, envelopes_changed: bool) -> None: """Shows what the reader hears once the worker has rendered it, where a showing waited for it. @@ -844,7 +854,10 @@ def request_export_instruments_dialog( """Writes the loaded reconstruction's slices in ``export_format``, asking first for what the format leaves open: its own setup where it has one, and the destination alone otherwise.""" if export_format in self._format_setups: - self._format_setups[export_format].open_sample(self._reconstruction_panel_logic.sample_request()) + request = self._reconstruction_panel_logic.sample_request() + if request is not None: + self._format_setups[export_format].open_sample(request) + return self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) diff --git a/src/sampletones_application/logic/reconstruction/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py index b598feea6..80d3288b0 100644 --- a/src/sampletones_application/logic/reconstruction/envelopes.py +++ b/src/sampletones_application/logic/reconstruction/envelopes.py @@ -32,28 +32,6 @@ class HeardEnvelopes: channels: Mapping[ChannelName, Features] -def heard_envelopes( - reconstruction: Reconstruction, - selection: StemSelection, -) -> ChannelEnvelopesViewModel: - """The envelopes of the part each channel plays for the recordings a reader hears, exported here. - - A reconstruction exports one entry per channel whatever it sounds, so a subscript answers - for any of them and :attr:`Features.has_frames` says which ones play. What is drawn, what is - measured and what an export writes are one reading, so a recording switched off on a channel - leaves the envelopes it held there, and a channel every recording is switched off on - describes no frame at all. - - Args: - reconstruction: The reconstruction being read. - selection: The recordings the reader hears, channel by channel. - - Returns: - ChannelEnvelopesViewModel: One entry per channel the reconstruction exports. - """ - return envelopes_view(reconstruction, HeardEnvelopes(selection, reconstruction.export_heard(selection))) - - def envelopes_view( reconstruction: Reconstruction, heard: HeardEnvelopes, diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index f22281a1a..9b6efb660 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -1,11 +1,12 @@ import errno import os +from dataclasses import dataclass from pathlib import Path from typing import Optional, Tuple from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view, heard_envelopes +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.session import ReconstructionSession from sampletones_application.logic.shared.renders import RenderCache @@ -22,6 +23,25 @@ from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer +@dataclass(frozen=True) +class InstalledEnvelopes: + """The envelopes a worker exported, beside the document and the choice of recordings they were exported for. + + Attributes: + reconstruction: The document the envelopes read. + selection: The recordings the reader heard when they were exported. + view: The envelopes as the instruments panel draws them. + """ + + reconstruction: Reconstruction + selection: StemSelection + view: ChannelEnvelopesViewModel + + def answers_for(self, reconstruction: Optional[Reconstruction], selection: StemSelection) -> bool: + """Whether the envelopes were exported for ``reconstruction`` heard as ``selection``.""" + return self.reconstruction is reconstruction and self.selection == selection + + class ReconstructionManager(CallbackMixin): """ The single authority on which reconstruction is currently loaded. @@ -44,8 +64,7 @@ def __init__( self._renders = renders self._session: ReconstructionSession = ReconstructionSession() self._current_reconstruction: Optional[ReconstructionData] = None - self._current_features: Optional[ChannelEnvelopesViewModel] = None - self._features_selection: Optional[StemSelection] = None + self._envelopes: Optional[InstalledEnvelopes] = None self._voice_id: Optional[str] = None self._listening: Listening = Listening() @@ -105,7 +124,7 @@ def _open_reconstruction( belongs to the document it was made on, and an opened document starts a choice of its own. """ self._listening.release() - self._adopt_reconstruction(reconstruction_data, voice_id=voice_id) + self._adopt_reconstruction(reconstruction_data, voice_id=voice_id, envelopes=None) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) @@ -114,71 +133,55 @@ def _adopt_reconstruction( reconstruction_data: ReconstructionData, *, voice_id: Optional[str], - envelopes: Optional[HeardEnvelopes] = None, + envelopes: Optional[HeardEnvelopes], ) -> None: - """Makes ``reconstruction_data`` the open document and refreshes its derived state. - - The reader's listening choice and the cached features track whichever reconstruction is - open, so every rebinding funnels through here to recompute them in one place. The - listening is carried onto the new record before the features are read, so the envelopes - 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, 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. + """Makes ``reconstruction_data`` the open document and carries the reader's listening choice onto it. + + Every rebinding funnels through here, so the listening tracks whichever reconstruction is + open. ``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 a rebuild + hands over: the worker carries the choice onto the document it rebuilds by the rule the + listening carries it by, so they answer for the choice the listening makes on the new + record. A document arriving with none leaves its envelopes owed to the worker, like its + sound, until a listening lands them. """ self._current_reconstruction = reconstruction_data self._voice_id = voice_id reconstruction = reconstruction_data.reconstruction self._listening.adopt(reconstruction.stems_data, frozenset(reconstruction.playing_channels)) - if envelopes is not None and envelopes.selection == self._listening.selection: - self._install_envelopes(envelopes) - else: - self._load_reconstruction_features() - - def _load_reconstruction_features(self) -> None: - """Reads the envelopes of the part the reader is listening to. - - Raises: - RuntimeError: If no reconstruction is open to read. - """ - if self._current_reconstruction is None: - raise RuntimeError("No reconstruction is loaded when trying to load features") - - reconstruction = self._current_reconstruction.reconstruction - selection = self._listening.selection - self._current_features = heard_envelopes(reconstruction, selection) - self._features_selection = selection - - def _install_envelopes(self, envelopes: HeardEnvelopes) -> None: - if self._current_reconstruction is None: - raise RuntimeError("No reconstruction is loaded when trying to install envelopes") - - self._current_features = envelopes_view(self._current_reconstruction.reconstruction, envelopes) - self._features_selection = envelopes.selection + if envelopes is not None: + self._install_envelopes(reconstruction, envelopes) + + def _install_envelopes(self, reconstruction: Reconstruction, envelopes: HeardEnvelopes) -> None: + self._envelopes = InstalledEnvelopes( + reconstruction=reconstruction, + selection=envelopes.selection, + view=envelopes_view(reconstruction, envelopes), + ) def adopt_envelopes(self, reconstruction: Reconstruction, envelopes: HeardEnvelopes) -> bool: """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document. A box on the stems card moves which recordings a channel is heard on, and the plot, the figures and an export all read from the same envelopes, so they follow it together once - the worker's reading lands. Envelopes exported for the choice the open ones already answer - change nothing. + the worker's reading lands. Envelopes exported for the document and the choice the open + ones already answer change nothing. Returns: bool: True where the open document's envelopes changed. """ - if self.reconstruction is not reconstruction or envelopes.selection == self._features_selection: + if self.reconstruction is not reconstruction: return False - self._install_envelopes(envelopes) + if self._envelopes is not None and self._envelopes.answers_for(reconstruction, envelopes.selection): + return False + + self._install_envelopes(reconstruction, envelopes) return True def has_envelopes_for(self, selection: StemSelection) -> bool: - """Whether the open document's envelopes were exported for ``selection``.""" - return self._current_features is not None and self._features_selection == selection + """Whether the worker's envelopes for the open document heard as ``selection`` are in place.""" + return self._envelopes is not None and self._envelopes.answers_for(self.reconstruction, selection) def save_reconstruction(self, filepath: Optional[Path] = None) -> bool: """Writes the open reconstruction to disk, reporting whether the write happened. @@ -217,6 +220,7 @@ def save_reconstruction_as(self, filepath: Path) -> None: self._adopt_reconstruction( self._current_reconstruction.detached_copy(filepath), voice_id=None, + envelopes=None, ) self._session.mark_saved(self._current_reconstruction.name) @@ -245,12 +249,13 @@ def detach_current_reconstruction(self) -> None: def apply_edited( self, reconstruction: Reconstruction, - envelopes: Optional[HeardEnvelopes] = None, + envelopes: Optional[HeardEnvelopes], ) -> None: """Adopts a reconstruction an edit produced, with the envelopes its worker exported where it had one. The open document rebinds to the fresh reconstruction object and stays the voice it was, - while the previous object is left untouched for the history to retain. + while the previous object is left untouched for the history to retain. An edit arriving + bare, as a removal, a retune and an undo do, leaves the envelopes owed to the worker. """ if self._current_reconstruction is None: return @@ -266,8 +271,7 @@ def mark_updated(self) -> None: def close_reconstruction(self) -> None: self._current_reconstruction = None - self._current_features = None - self._features_selection = None + self._envelopes = None self._voice_id = None self._listening.release() self._session.mark_closed() @@ -298,7 +302,13 @@ def current_reconstruction(self) -> Optional[ReconstructionData]: @property def current_features(self) -> Optional[ChannelEnvelopesViewModel]: - return self._current_features + """The envelopes the worker exported for the open document heard as the reader hears it now, or ``None`` + while they are on their way. + """ + if self._envelopes is None or not self._envelopes.answers_for(self.reconstruction, self._listening.selection): + return None + + return self._envelopes.view @property def voice_id(self) -> Optional[str]: diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index a3f4298a0..a61c30378 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from pathlib import Path from typing import ( @@ -77,17 +79,35 @@ @dataclass(frozen=True) class OwedShowing: - """A showing of the waveform and the audio that waits for the worker to render what the reader hears. + """What the screen owes the reader of the document's sound, settled once the worker has rendered what + the reader hears. + + Every gesture that reads the sound joins what is owed, so a flip of the source during an + opening still loads the waveform, and a retune followed by a flip still re-fits it. Attributes: - load: Whether the waveform is loaded afresh, as after a listening change, or updated in place. + load: Whether the waveform is loaded afresh, as after a listening change. + update: Whether the waveform is updated in place, as after an edit. refit: Whether the waveform's view is re-fitted to the audio's length. - play: Whether the audio is handed to the player too. + play: Whether the audio is handed to the player. + export_to: The file what plays is written to, where an export waits for the sound. """ load: bool + update: bool refit: bool play: bool + export_to: Optional[Path] + + def joined(self, later: OwedShowing) -> OwedShowing: + """What is owed once ``later`` is asked for on top of this: every showing kept, the newest export's file.""" + return OwedShowing( + load=self.load or later.load, + update=self.update or later.update, + refit=self.refit or later.refit, + play=self.play or later.play, + export_to=later.export_to if later.export_to is not None else self.export_to, + ) class ExportServiceProtocol(Protocol): @@ -134,7 +154,6 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._owed: Optional[OwedShowing] = None - self._export_owed: Optional[Path] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -146,12 +165,15 @@ def __init__( self.on_open_export_instruments_dialog: Optional[Callable[[str, str, ExportFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None + self.on_export_dropped: Optional[PathCallback] = None self.on_locate_audio_not_found: Optional[PathCallback] = None self.on_stems_view_changed: Optional[Callable[[ReconstructionStemsViewModel], None]] = None self.on_ownership_changed: Optional[Callable[[OwnershipRibbonViewModel], None]] = None def display_reconstruction(self) -> None: + """Puts the open document in front of the reader, letting go of what the one before it was owed.""" + self._forget_owed() reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -167,7 +189,7 @@ def display_reconstruction(self) -> None: ) self.call(self.on_waveform_source_changed, self._current_audio_source) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) + self._show(reconstruction_data, self._loading()) def update_reconstruction(self, *, refit_waveform: bool = False) -> None: """Re-answers every reading of the document after an edit. @@ -190,48 +212,86 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: reconstruction_data, OwedShowing( load=False, + update=True, refit=refit_waveform, play=self._current_audio_source != AudioSourceType.ORIGINAL, + export_to=None, ), ) def redraw_listening(self) -> None: - """Shows what the reader hears once its sound is kept, where a showing or an export waited for it.""" + """Settles what the reader is owed once the sound of what they hear is kept.""" reconstruction_data = self._reconstruction_data - if not reconstruction_data: + owed = self._owed + if reconstruction_data is None or owed is None: return - if self._owed is not None: - self._show(reconstruction_data, self._owed) + self._owed = None + self._show(reconstruction_data, owed) + + def abandon_showing(self) -> None: + """Lets go of what the reader is owed where the worker will never render it, keeping the waveform as it + stands; an export that waited is reported dropped. + """ + self._forget_owed() + + def _forget_owed(self) -> None: + owed = self._owed + self._owed = None + if owed is not None and owed.export_to is not None: + logger.info(f"The export to {logger.format_path(owed.export_to)} waited for a sound that never landed") + self.call(self.on_export_dropped, owed.export_to) - if self._export_owed is not None: - self._export(reconstruction_data, self._export_owed) + @staticmethod + def _loading() -> OwedShowing: + """A showing loading the waveform afresh and playing it, which a change to what is heard asks for.""" + return OwedShowing(load=True, update=False, refit=False, play=True, export_to=None) def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: - """Draws and plays what the reader hears, or asks for its sound where the screen holds none yet. + """Settles ``showing`` with what is owed already, or owes the whole where the screen holds no sound for + it yet and asks the worker for it. - The audio is read from what is kept, so a showing computes no sample on the render thread, - and the envelopes the instruments panel draws are read as the worker exported them for the - recordings heard. A showing the cache cannot answer in full, for want of the sound or of - envelopes for the recordings heard, is owed: the worker is asked to render it, and the - showing happens once the sound lands. + The reading is whole when the cache keeps the sound of what the reader hears and the + manager holds the envelopes the worker exported for it, so a showing computes no sample + on the render thread and draws what an export writes. What is owed settles in one reading + once the sound lands. """ - hearing = self._listening.hearing - waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, hearing) - if waveform is None or not self._reconstruction_manager.has_envelopes_for(hearing.selection): - self._owed = showing + owed = showing if self._owed is None else self._owed.joined(showing) + waveform = self._kept_waveform(reconstruction_data) + if waveform is None: + self._owed = owed self.call(self.on_renders_wanted) return self._owed = None - if showing.load: + self._settle(reconstruction_data, waveform, owed) + + def _kept_waveform(self, reconstruction_data: ReconstructionData) -> Optional[WaveformData]: + """The waveform of what the reader hears, where the screen keeps both its sound and its envelopes.""" + hearing = self._listening.hearing + if not self._reconstruction_manager.has_envelopes_for(hearing.selection): + return None + + return reconstruction_data.waveform_data(self._reconstruction_manager.renders, hearing) + + def _settle( + self, + reconstruction_data: ReconstructionData, + waveform: WaveformData, + owed: OwedShowing, + ) -> None: + """Draws, plays and writes what ``owed`` names from one reading of the sound.""" + if owed.load: self.call(self.on_waveform_load_changed, waveform) - else: - self.call(self.on_waveform_update_changed, waveform, refit=showing.refit) + elif owed.update: + self.call(self.on_waveform_update_changed, waveform, refit=owed.refit) - if showing.play: + if owed.play: self._emit_audio_data(reconstruction_data, waveform) + if owed.export_to is not None: + self._write_wav(reconstruction_data, waveform.playing, owed.export_to) + @staticmethod def _in_channel_order(channels: FrozenSet[ChannelName]) -> List[ChannelName]: return [channel_name for channel_name in ChannelName.items() if channel_name in channels] @@ -267,11 +327,7 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION - self._owed = None - if self._export_owed is not None: - logger.info(f"The export to {self._export_owed} waited for the sound of a document put away") - self._export_owed = None - + self._forget_owed() self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) self.call( @@ -309,14 +365,8 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: self.call(self.on_waveform_source_changed, audio_source) def _play(self, reconstruction_data: ReconstructionData) -> None: - """Hands the player what the source chosen plays, or asks for its sound where the screen holds none yet.""" - waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) - if waveform is None: - self._owed = OwedShowing(load=False, refit=False, play=True) - self.call(self.on_renders_wanted) - return - - self._emit_audio_data(reconstruction_data, waveform) + """Hands the player what the source chosen plays, or owes it where the screen holds no sound yet.""" + self._show(reconstruction_data, OwedShowing(load=False, update=False, refit=False, play=True, export_to=None)) def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" @@ -330,7 +380,7 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None: self._build_stems_view_model(reconstruction_data), ) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) + self._show(reconstruction_data, self._loading()) def set_stem_channels( self, @@ -369,7 +419,7 @@ def _refresh_listening(self) -> None: self._build_stems_view_model(reconstruction_data), ) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) - self._show(reconstruction_data, OwedShowing(load=True, refit=False, play=True)) + self._show(reconstruction_data, self._loading()) @property def _listening(self) -> Listening: @@ -524,12 +574,17 @@ def exportable_instrument( Returns: Optional[ExportableInstrument]: The slice and the name to suggest for it, or ``None`` - where that channel describes no frame and is written nowhere. + where that channel describes no frame and is written nowhere, or where the envelopes + of what the reader hears are still on their way from the worker. Raises: AssertionError: If no reconstruction is loaded. """ - features = self._heard_features()[channel_name] + envelopes = self._heard_features() + if envelopes is None: + return None + + features = envelopes[channel_name] if not features.has_frames: return None @@ -543,15 +598,19 @@ def exportable_instrument( ), ) - def _heard_features(self) -> ChannelEnvelopesViewModel: - """The envelopes of the part the reader is listening to, as the open document reads them. + def _heard_features(self) -> Optional[ChannelEnvelopesViewModel]: + """The envelopes of the part the reader is listening to, as the worker exported them for the open document, + or ``None`` while they are still on their way. Raises: AssertionError: If no reconstruction is loaded. """ + if self._reconstruction_data is None: + raise AssertionError("Expected reconstruction data to be loaded before reading its envelopes") + features = self._reconstruction_manager.current_features if features is None: - raise AssertionError("Expected reconstruction data to be loaded before reading its envelopes") + logger.warning("The envelopes of what is heard are still on their way from the worker") return features @@ -617,6 +676,9 @@ def handle_export_instruments_confirmed( return request = self._sample_request(destination.stem) + if request is None: + return + self._session_manager.set_instrument_path(destination.parent) self._export_service.export_sample( destination, @@ -624,8 +686,9 @@ def handle_export_instruments_confirmed( request, ) - def sample_request(self) -> SampleExport: - """The loaded reconstruction's slices as one export, under the reconstruction's own name. + def sample_request(self) -> Optional[SampleExport]: + """The loaded reconstruction's slices as one export, under the reconstruction's own name, or ``None`` + while the envelopes of what the reader hears are still on their way from the worker. A format asking for its choices in a setup of its own names the export before a destination exists, so the slices take the name the reconstruction is known by. @@ -639,12 +702,17 @@ def sample_request(self) -> SampleExport: return self._sample_request(reconstruction_data.name) - def _sample_request(self, name: str) -> SampleExport: - """The slice of every playing channel of the reconstruction as one export named ``name``. + def _sample_request(self, name: str) -> Optional[SampleExport]: + """The slice of every playing channel of the reconstruction as one export named ``name``, or ``None`` + while the envelopes of what the reader hears are still on their way from the worker. Each slice takes its channel suffix from the name, and a channel describing no frame of what the reader is listening to is left to rest. """ + envelopes = self._heard_features() + if envelopes is None: + return None + return SampleExport( name=name, instruments=tuple( @@ -653,7 +721,7 @@ def _sample_request(self, name: str) -> SampleExport: feature, instrument_slice_name(name, channel_name), ) - for channel_name, feature in self._heard_features().channels.items() + for channel_name, feature in envelopes.channels.items() if feature.has_frames ), nes_frequency=self._nes_frequency(), @@ -717,21 +785,16 @@ def _export( reconstruction_data: ReconstructionData, filepath: Path, ) -> None: - """Writes what the reader hears to ``filepath``, or asks for its sound where the screen holds none yet. + """Writes what the reader hears to ``filepath``, or owes the file where the screen holds no sound yet. An export is a reading of what plays like a showing, so it reads the kept sound and computes - no sample on the render thread. An export the cache cannot answer is owed beside a showing: - the worker is asked, and the file is written once the sound lands, for what the reader hears - then. A document put away before that takes the export with it. + no sample on the render thread. An export owed is written once the sound lands, for what + the reader hears then; a document put away before that reports the export dropped. """ - waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) - if waveform is None: - self._export_owed = filepath - self.call(self.on_renders_wanted) - return - - self._export_owed = None - self._write_wav(reconstruction_data, waveform.playing, filepath) + self._show( + reconstruction_data, + OwedShowing(load=False, update=False, refit=False, play=False, export_to=filepath), + ) def _write_wav( self, diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 95a8906bb..5d6691bc0 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -84,17 +84,19 @@ class ReconstructionRewrites(CallbackMixin): Channel changes wait while a job runs, and a change joins the one waiting at the end of the line when both move the same channel, so a drag collapses into the place it ended; one - listening waiting answers every choice made before it is taken. A result lands only on the - document it was computed from. A removal or a whole-document gesture changes what the panel - was drawn on, so a channel change asked for while one waits is refused, and the panel is - redrawn once the line empties. + listening waiting answers every choice made before it is taken. A change reaching its turn + while the envelopes of what is heard are still on their way, as after a box ticked behind it, + waits behind a listening of that choice and rebuilds from the envelopes it lands. A result + lands only on the document it was computed from. A removal or a whole-document gesture + changes what the panel was drawn on, so a channel change asked for while one waits is + refused, and the panel is redrawn once the line empties. The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel - draws a change that will never land, ``on_failed`` with a job's failure, and - ``on_work_changed`` as the line fills, hands over from one kind of work to the other, and - empties. + draws a change that will never land, ``on_failed`` with a job's failure, ``on_reading_failed`` + when a listening the open document waits for failed, and ``on_work_changed`` as the line + fills, hands over from one kind of work to the other, and empties. """ def __init__( @@ -106,6 +108,7 @@ def __init__( self._regeneration = regeneration self._waiting: List[Rewrite] = [] self._running: Optional[Running] = None + self._awaiting_envelopes: Optional[ChannelChange] = None self._redraw_owed: bool = False self._reported_work: Optional[Work] = None @@ -113,6 +116,7 @@ def __init__( self.on_heard_rendered: Optional[Callable[[bool], None]] = None self.on_dropped: Optional[VoidCallback] = None self.on_failed: Optional[Callable[[Exception], None]] = None + self.on_reading_failed: Optional[VoidCallback] = None self.on_work_changed: Optional[Callable[[Optional[Work]], None]] = None regeneration.subscribe(self._on_result) @@ -249,20 +253,39 @@ def _rebuild(self, change: ChannelChange) -> None: the rebuild is then marked running before it starts, since a case's synchronous seam can deliver the result inside the start. The renders the cache holds for the document go with it, so the worker renders the edited channel alone and the landing finds every channel's - audio kept. + audio kept. A change finding the envelopes of what is heard on their way waits for them. """ reconstruction = self._manager.reconstruction - envelopes = self._manager.current_features - if reconstruction is None or envelopes is None: + if reconstruction is None: logger.info(f"A change of {change.channel_name} found no document open") return + envelopes = self._manager.current_features + if envelopes is None: + self._await_envelopes(change) + return + + self._awaiting_envelopes = None rebased = change.rebased(envelopes[change.channel_name]) hearing = self._manager.listening.hearing kept = self._manager.renders.held(reconstruction) self._running = Rebuild(change=change, base=reconstruction) self._regeneration.start(reconstruction, change.channel_name, rebased, hearing=hearing, kept=kept) + def _await_envelopes(self, change: ChannelChange) -> None: + """Puts ``change`` back at the head of the line behind a listening of the choice its envelopes are owed for. + + The change waits once: where the listening lands no envelopes for it, as a failed one + does, the change is let go and the line moves on. + """ + if self._awaiting_envelopes is change: + logger.info(f"A change of {change.channel_name} found no envelopes to rebuild from and was let go") + self._awaiting_envelopes = None + return + + self._awaiting_envelopes = change + self._waiting[:0] = [ListeningChange(), change] + def _listen(self) -> None: """Starts rendering what the reader hears of the document as it stands. @@ -332,7 +355,7 @@ def _on_result(self, result: RegenerationResult) -> None: self._land(running, outcome) case ServiceError(exception=exception): self.call(self.on_failed, exception) - self._let_go(running) + self._fail(running) self._advance() self._settle() @@ -370,6 +393,14 @@ def _let_go(self, running: Running) -> None: if isinstance(running, Rebuild) and running.base is not None: self.call(self.on_dropped) + def _fail(self, running: Running) -> None: + """Tells the panel what a failed job leaves it with: a change drawn that will never land, or a sound the + open document waited for that will never land. + """ + self._let_go(running) + if isinstance(running, Reading) and running.lands_on(self._manager.reconstruction): + self.call(self.on_reading_failed) + def _settle(self) -> None: """Pays a redraw owed once the line empties, and reports what the line is busy with as it changes.""" if not self.is_busy and self._redraw_owed: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index c9d9e606b..8ba535160 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -516,6 +516,7 @@ reconstructions.reconstruction.tooltip.autoscale_tooltip: "Scale the waveforms b reconstructions.reconstruction.message.locate_audio_failed: "Original audio file could not be found." reconstructions.reconstruction.message.export_wav_success: "Reconstruction rendered successfully." reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed to save." +reconstructions.reconstruction.message.export_wav_dropped: "The WAV file was not saved. The sound it was waiting for was never rendered." reconstructions.reconstruction.label.stems: "Stems" reconstructions.reconstruction.label.stems_empty: "This reconstruction has no sources" reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin" 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 462caf9a5..c3179e0d5 100644 --- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -24,6 +24,7 @@ from sampletones_core.reconstructions import Reconstruction from tests.conftest import RENDER_BUDGET from tests.suite.application import draw_frame, live_queue, scheduling +from tests.suite.envelopes import land_envelopes from tests.suite.stems import ( SHARED_CHANNEL, SHARED_OWNERS, @@ -67,6 +68,7 @@ def coordinator( ) coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator)) manager.load_reconstruction(taking_turns_file) + land_envelopes(manager) yield coordinator @@ -156,6 +158,7 @@ def test_the_landing_answers_the_listening_in_one_job( ) -> None: manager = coordinator._reconstruction_manager manager.listening.set_channels(STEM_B_ID, frozenset()) + land_envelopes(manager) listenings: List[Reconstruction] = [] service = coordinator._rewrites._regeneration diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 2d2540377..36a3240a0 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -6,7 +6,6 @@ import pytest from automation.scenario import BaseTestScenario, ScenarioStep -from sampletones_application.logic.reconstruction.envelopes import heard_envelopes from sampletones_application.services.regeneration.jobs import RebuildJob from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess @@ -20,6 +19,7 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.renders import rendered_channels from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing +from tests.suite.envelopes import heard_envelopes from tests.suite.stems import everything_heard diff --git a/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py b/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py new file mode 100644 index 000000000..7e07f5165 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py @@ -0,0 +1,61 @@ +from typing import Final, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.exports import leaving_asks_nothing +from automation.steps.reconstructions import load_from_the_browser +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world + +CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """The home holds the two-minute reconstruction over three recordings taking turns.""" + return alternating_document_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The application opens with no reconstruction and no project.""" + return Startup(reconstruction=None, project=None) + + +class TestAnExportAskedRightAfterAnOpening: + """The instruments panel offers an export once the opening's sound has landed, and nothing before. + + An opening sends the document's sound and its envelopes to the worker together, so the panel + draws no bars and offers no export until they land. The scenario opens the two-minute document + from the browser, reads the export buttons while the sound is on its way, waits for it to land, + and reads them again. + """ + + def test_the_export_answers_once_the_sound_has_landed(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def exportable() -> Tuple[ChannelName, ...]: + return tuple(channel for channel in CHANNELS if instruments.can_export(channel)) + + def open_it_and_find_no_export_yet(screen: Screen) -> None: + load_from_the_browser(screen, LONG_RECONSTRUCTION) + + screen.expect( + lambda: screen.reconstructions.shows_open(LONG_RECONSTRUCTION), + bool, + description=f"{LONG_RECONSTRUCTION.name} open", + ) + assert not screen.sound_settled() + assert exportable() == () + + def the_export_answers_with_the_sound(screen: Screen) -> None: + screen.expect(screen.sound_settled, bool, description="the sound landed") + + offered = screen.expect(exportable, bool, description="an export offered") + assert offered is not None + assert all(instruments.envelope(channel, FeatureKey.VOLUME) for channel in offered) + + screen.scenario(open_it_and_find_no_export_yet, the_export_answers_with_the_sound, leaving_asks_nothing).run() diff --git a/tests/screens/reconstructions/stems/test_stems_card.py b/tests/screens/reconstructions/stems/test_stems_card.py index c70a8ce10..11d3dcae1 100644 --- a/tests/screens/reconstructions/stems/test_stems_card.py +++ b/tests/screens/reconstructions/stems/test_stems_card.py @@ -83,11 +83,13 @@ def another_row_shows_its_own(screen: Screen) -> None: class TestRemovingARecording: - """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go. + """Removing a recording asks first; the rest keep their colors, draw their bars again and play, and the last + one cannot go. The scenario cancels the question, then removes the middle recording and confirms. The rest play, the first goes too, and the last recording stays without a remove gesture. Saved and opened again, the card - lists only the last recording in its old color. + lists only the last recording in its old color. A removal sends the sound and the envelopes of what is + left to the worker, so the instruments panel draws its bars again once that sound has landed. """ @pytest.fixture @@ -96,11 +98,13 @@ def startup(self) -> Startup: return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None: - """Cancel keeps every row; Remove drops one, and the others keep their colors and play.""" + """Cancel keeps every row; Remove drops one, and the others keep their colors, their bars and play.""" reconstructions = screen.reconstructions stems = reconstructions.stems + instruments = reconstructions.instruments prompt = stems.remove_prompt colors: List[Dict[str, Tuple[float, ...]]] = [] + drawn: List[Tuple[ChannelName, ...]] = [] def the_file_named(screen: Screen) -> None: expect_open(screen, STEMS_RECONSTRUCTION) @@ -108,6 +112,10 @@ def the_file_named(screen: Screen) -> None: assert screen.title() == stems_title(screen, unsaved=False) assert all(stems.has_row(key) for key in KEYS) colors.append(swatches(stems, KEYS)) + drawn.append( + tuple(channel for channel in PLAYING_CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME)) + ) + assert drawn[0] def cancel_keeps_it(screen: Screen) -> None: stems.remove(MIDDLE) @@ -132,6 +140,17 @@ def remove_the_middle(screen: Screen) -> None: assert screen.title() == stems_title(screen, unsaved=True) assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + def the_bars_come_back_with_the_sound(screen: Screen) -> None: + screen.expect(screen.sound_settled, bool, description="the sound of the rest landed") + + for channel in drawn[0]: + screen.expect( + partial(instruments.envelope, channel, FeatureKey.VOLUME), + bool, + description=f"the bars of {channel} drawn again", + ) + assert instruments.can_export(channel) + def the_rest_plays(screen: Screen) -> None: waveform = reconstructions.waveform @@ -170,6 +189,7 @@ def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None: the_file_named, cancel_keeps_it, remove_the_middle, + the_bars_come_back_with_the_sound, the_rest_plays, the_last_cannot_go, saved_and_opened_again_it_keeps_its_color, diff --git a/tests/suite/envelopes.py b/tests/suite/envelopes.py new file mode 100644 index 000000000..47066615b --- /dev/null +++ b/tests/suite/envelopes.py @@ -0,0 +1,31 @@ +from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection + + +def exported(reconstruction: Reconstruction, selection: StemSelection) -> HeardEnvelopes: + """What the worker exports for ``selection`` of ``reconstruction``, beside that choice.""" + return HeardEnvelopes(selection=selection, channels=reconstruction.export_heard(selection)) + + +def heard_envelopes(reconstruction: Reconstruction, selection: StemSelection) -> ChannelEnvelopesViewModel: + """The envelopes the instruments panel draws for ``selection`` of ``reconstruction``, as a landing builds them.""" + return envelopes_view(reconstruction, exported(reconstruction, selection)) + + +def land_envelopes(manager: ReconstructionManager) -> None: + """Installs on ``manager`` the envelopes the opening's listening lands: the open document's, for the choice + its listening makes. + + A case driving the manager alone has no worker to export them, so it installs them the way a + landing does, and the panel reads them as it reads any landed envelopes. + """ + reconstruction = manager.reconstruction + if reconstruction is None: + raise RuntimeError("No reconstruction is open to land the envelopes of") + + manager.adopt_envelopes(reconstruction, exported(reconstruction, manager.listening.selection)) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 40f0830d7..bad30eac5 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -51,6 +51,7 @@ UnhandledReconstructionError, ) from tests.conftest import RENDER_BUDGET +from tests.suite.envelopes import exported, land_envelopes from tests.suite.history.wiring import wired_history from tests.suite.language import FakeLanguageManager from tests.suite.regeneration import HeldRegeneration @@ -506,6 +507,14 @@ def test_a_wav_export_shows_its_own_message( assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success + def test_a_dropped_wav_export_shows_its_own_message( + self, + export_coordinator: ReconstructionTabCoordinator, + ) -> None: + export_coordinator._report_dropped_export(Path("track.wav")) + + assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_dropped + class TestUpdateReconstructionRefitsTheWaveformOnRequest: """A retune moves the audio's own length, so the caller that knows this asks the waveform to @@ -564,6 +573,7 @@ def reconstruction_manager( ) -> ReconstructionManager: manager = ReconstructionManager(scheduling=scheduling, renders=RenderCache(budget_bytes=RENDER_BUDGET)) manager.load_reconstruction_object(reconstruction, name="lead", voice_id=OPEN_VOICE_ID) + land_envelopes(manager) return manager @pytest.fixture @@ -604,29 +614,51 @@ def coordinator( instance._reconstruction_instruments_logic = instruments_logic return instance - def test_a_removal_draws_the_document_it_leaves( + @staticmethod + def _remove( + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + ) -> None: + """A removal landing on the open document the way the coordinator lands it, and the listening it owes.""" + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID), None) + coordinator.redraw_reconstruction(refit_waveform=False) + land_envelopes(reconstruction_manager) + coordinator.redraw_listening(envelopes_changed=True) + + def test_a_removal_draws_no_envelopes_until_its_listening_lands( self, coordinator: ReconstructionTabCoordinator, reconstruction_manager: ReconstructionManager, reconstruction: Reconstruction, drawn: List[Optional[ChannelEnvelopesViewModel]], ) -> None: - reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID), None) coordinator.redraw_reconstruction(refit_waveform=False) - assert drawn == [reconstruction_manager.current_features] + assert drawn == [None] - def test_a_removal_draws_the_frames_it_released_as_rests( + def test_a_removal_draws_the_document_it_leaves_once_its_listening_lands( self, coordinator: ReconstructionTabCoordinator, reconstruction_manager: ReconstructionManager, reconstruction: Reconstruction, drawn: List[Optional[ChannelEnvelopesViewModel]], ) -> None: - reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + self._remove(coordinator, reconstruction_manager, reconstruction) - coordinator.redraw_reconstruction(refit_waveform=False) + assert drawn[-1] is not None + assert drawn[-1] is reconstruction_manager.current_features + + def test_a_removal_draws_the_frames_it_released_as_rests( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + self._remove(coordinator, reconstruction_manager, reconstruction) envelopes = drawn[-1] assert envelopes is not None @@ -639,9 +671,7 @@ def test_a_removal_draws_a_channel_it_emptied_standing_by( reconstruction: Reconstruction, drawn: List[Optional[ChannelEnvelopesViewModel]], ) -> None: - reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) - - coordinator.redraw_reconstruction(refit_waveform=False) + self._remove(coordinator, reconstruction_manager, reconstruction) envelopes = drawn[-1] assert envelopes is not None @@ -658,7 +688,8 @@ def test_a_regeneration_leaves_a_field_being_typed_in_alone( envelopes = reconstruction_manager.current_features assert envelopes is not None typed = envelopes[SHARED_CHANNEL].with_envelope(FeatureKey.VOLUME, Envelope[int](items=TYPED_VOLUME)) - reconstruction_manager.apply_edited(regenerated(reconstruction, SHARED_CHANNEL, typed)) + rebuilt = regenerated(reconstruction, SHARED_CHANNEL, typed) + reconstruction_manager.apply_edited(rebuilt, exported(rebuilt, reconstruction_manager.listening.selection)) coordinator.update_reconstruction() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index b5e8cd395..b81097b02 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -24,6 +24,7 @@ from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites from sampletones_application.logic.reconstruction.rewrites.steps import ( ChannelChange, + ListeningChange, RateChange, StemRemovalRequest, ) @@ -52,6 +53,7 @@ from tests.suite.application import HeldQueue, held_queue, scheduling, synchronous_executor from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.envelopes import land_envelopes from tests.suite.frames import held_frames from tests.suite.history.wiring import wired_history from tests.suite.language import FakeLanguageManager @@ -313,6 +315,7 @@ def recording_coordinator( taking_turns: Reconstruction, ) -> ReconstructionCoordinator: reconstruction_manager.load_reconstruction_object(taking_turns, name="lead", voice_id=OPEN_VOICE_ID) + land_envelopes(reconstruction_manager) coordinator = ReconstructionCoordinator( reconstruction_manager, MagicMock(), @@ -719,6 +722,7 @@ def realign() -> None: @pytest.fixture def open_sample( following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, project_controller: ProjectController, history: HistoryManager, tab: MagicMock, @@ -726,11 +730,12 @@ def open_sample( reconstruction_factory: ReconstructionFactory, decline: MagicMock, ) -> Sample: - """A sample added to the project and opened on the tab, with the calls opening it cleared.""" + """A sample added to the project and opened on the tab, its envelopes landed, with the calls opening it cleared.""" with history.transaction(HistoryAction.ADD_SAMPLE): sample = project_controller.add_sample(reconstruction_factory(), "lead") _edit_voice(following_coordinator, sample.id, decline) held_queue.drain() + land_envelopes(reconstruction_manager) tab.reset_mock() return sample @@ -745,6 +750,7 @@ def standalone_path( path = tmp_path / "lead.stn" reconstruction_factory().save(path) reconstruction_manager.load_reconstruction(path) + land_envelopes(reconstruction_manager) return path @@ -1568,6 +1574,7 @@ def test_cancel_on_a_load_or_a_close_turns_the_request_away(self, guard: str) -> @pytest.fixture def turns_sample( following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, project_controller: ProjectController, history: HistoryManager, tab: MagicMock, @@ -1580,6 +1587,7 @@ def turns_sample( sample = project_controller.add_sample(taking_turns, "turns") _edit_voice(following_coordinator, sample.id, decline) held_queue.drain() + land_envelopes(reconstruction_manager) tab.reset_mock() return sample @@ -1595,6 +1603,7 @@ def turns_path( """The two-recording document opened from its file, with both recordings loaded beside it.""" reconstruction_manager.load_reconstruction(taking_turns_file) held_queue.drain() + land_envelopes(reconstruction_manager) tab.reset_mock() return taking_turns_file @@ -1886,6 +1895,7 @@ def test_a_failed_rebuild_is_shown_and_the_next_step_still_runs( following_coordinator: ReconstructionCoordinator, reconstruction_manager: ReconstructionManager, held_queue: HeldQueue, + tab: MagicMock, turns_sample: Sample, ) -> None: failure = RuntimeError("synthesis failed") @@ -1902,6 +1912,42 @@ def test_a_failed_rebuild_is_shown_and_the_next_step_still_runs( assert following_coordinator._dialogs.show_error.call_args.args[0] is failure assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == [TAKING_TURNS_VOLUME] * len(SHARED_OWNERS) assert _sounding_volumes(reconstruction, SOLE_CHANNEL) == list(SOLE_VOLUME) + tab.abandon_showing.assert_not_called() + + def test_a_failed_listening_is_shown_and_the_tab_lets_its_debts_go( + self, + following_coordinator: ReconstructionCoordinator, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + failure = RuntimeError("rendering failed") + with patch("sampletones_application.services.regeneration.service.heard_renders", side_effect=failure): + following_coordinator.request_rewrite(ListeningChange()) + held_queue.drain() + + assert following_coordinator._dialogs.show_error.call_args.args[0] is failure + tab.abandon_showing.assert_called_once_with() + tab.redraw_listening.assert_not_called() + + def test_a_removal_leaves_the_envelopes_owed_until_its_listening_lands( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + _remove(following_coordinator, STEM_B_ID) + held_queue.drain() + assert reconstruction_manager.current_features is None + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + following_coordinator.request_rewrite(ListeningChange()) + held_queue.drain() + + assert reconstruction_manager.current_features is not None + tab.redraw_listening.assert_called_once_with(envelopes_changed=True) def test_the_waveform_stays_faded_until_every_step_has_landed( self, 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 8d6ff1866..7fb4ef227 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -20,6 +20,7 @@ RateChange, StemRemovalRequest, ) +from sampletones_application.services.regeneration import service as service_module 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 @@ -28,13 +29,16 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing from tests.conftest import ReconstructionFactory +from tests.suite.envelopes import land_envelopes from tests.suite.regeneration import HeldRegeneration from tests.suite.renders import warmed from tests.suite.stems import ( SHARED_CHANNEL, + SHARED_OWNERS, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, + TAKING_TURNS_VOLUME, everything_heard, taking_turns, ) @@ -95,10 +99,12 @@ def rewrites( ) -> ReconstructionRewrites: """The steps of the two-recording document, each landing edit adopted the way the coordinator adopts it.""" reconstruction_manager.load_reconstruction_object(taking_turns, name="turns", voice_id=VOICE_ID) + land_envelopes(reconstruction_manager) def adopt(edit: ReconstructionEdit) -> None: outcome.edits.append(edit) - reconstruction_manager.apply_edited(edit.reconstruction) + envelopes = edit.envelopes if isinstance(edit, ChannelEdit) else None + reconstruction_manager.apply_edited(edit.reconstruction, envelopes) def drop() -> None: outcome.dropped += 1 @@ -224,12 +230,19 @@ def test_the_recordings_heard_are_read_at_the_changes_turn( regeneration: HeldRegeneration, reconstruction_manager: ReconstructionManager, ) -> None: + """A box ticked behind a change leaves the envelopes of the new choice on their way, so the change + waits behind a listening of it and rebuilds on the recordings heard then. + """ rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset({SOLE_CHANNEL})) regeneration.finish() + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() (shared,) = regeneration.held + assert isinstance(shared, RebuildJob) + assert shared.channel_name == SHARED_CHANNEL assert shared.hearing.selection.stems_for(SHARED_CHANNEL) == frozenset({STEM_A_ID}) @@ -462,6 +475,7 @@ def test_a_document_put_away_draws_nothing_of_its_rebuild( ) -> None: rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) reconstruction_manager.load_reconstruction_object(taking_turns.model_copy(), name="copy", voice_id="copy") + land_envelopes(reconstruction_manager) document = reconstruction_manager.current_features assert document is not None @@ -517,13 +531,16 @@ def test_a_change_after_the_step_has_run_is_taken( rewrites: ReconstructionRewrites, regeneration: HeldRegeneration, ) -> None: + """The removal leaves the envelopes owed, so the change waits behind a listening and is then taken.""" rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) rewrites.request(_removal(STEM_B_ID)) regeneration.finish() rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) - assert len(regeneration.held) == 1 + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert [type(job) for job in regeneration.held] == [RebuildJob] class TestTheBusySpan: @@ -582,6 +599,53 @@ def test_steps_taken_at_once_leave_the_line_empty( assert not rewrites.is_busy +class TestAChangeWhoseEnvelopesAreOwed: + """A change reaching its turn while the envelopes of what is heard are on their way waits behind a listening + of that choice, once: it rebuilds from the envelopes the listening lands, and is let go where it lands none. + """ + + def test_the_change_waits_behind_a_listening_and_rebuilds_from_what_it_lands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert [type(job) for job in regeneration.held] == [RebuildJob] + regeneration.finish() + (edit,) = outcome.edits + assert isinstance(edit, ChannelEdit) + volumes = _volume_items(edit.reconstruction, SHARED_CHANNEL) + assert volumes[SHARED_OWNERS.index(STEM_A_ID)] == FIRST_VOLUME.items[0] + assert volumes[SHARED_OWNERS.index(STEM_B_ID)] == TAKING_TURNS_VOLUME + assert not rewrites.is_busy + + def test_a_listening_landing_no_envelopes_lets_the_change_go( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + monkeypatch.setattr(service_module, "heard_renders", MagicMock(side_effect=RuntimeError("rendering failed"))) + + regeneration.finish() + + assert regeneration.held == () + assert outcome.edits == [] + assert not rewrites.is_busy + assert rewrites.work is None + + class TestAJobThatFailsToStart: """A job whose start raises leaves the line idle, so the fade lifts and the next request runs.""" @@ -728,6 +792,7 @@ def test_an_edit_first_writing_frames_of_the_readers_own_lands_heard_as_the_list ones the listening reads after the landing, a recording muted beside the new row included. """ reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + land_envelopes(reconstruction_manager) rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LONGER_VOLUME)) regeneration.finish() @@ -790,6 +855,7 @@ def test_a_rebuild_carries_the_hearing_it_was_started_with( reconstruction_manager: ReconstructionManager, ) -> None: reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + land_envelopes(reconstruction_manager) rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) @@ -804,6 +870,7 @@ def test_a_landed_rebuild_leaves_its_hearing_kept( reconstruction_manager: ReconstructionManager, ) -> None: reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + land_envelopes(reconstruction_manager) rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) regeneration.finish() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py b/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py index 6f7b79354..de8c4e54e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py @@ -4,7 +4,6 @@ import pytest -from sampletones_application.logic.reconstruction.envelopes import heard_envelopes from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) @@ -20,6 +19,7 @@ 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.envelopes import heard_envelopes from tests.suite.stems import RECORDED_SCALE, STEM_A_ID, STEM_B_ID diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 67df032e4..4efe8949f 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -9,7 +9,6 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.editor import InstrumentEditor -from sampletones_application.logic.reconstruction.envelopes import heard_envelopes from sampletones_application.logic.reconstruction.instruments import ( ReconstructionInstrumentsLogic, ) @@ -33,6 +32,7 @@ from sampletones_core.project.voices.creation import new_instrument from sampletones_core.reconstructions import Reconstruction from tests.suite.application import HeldQueue, held_queue +from tests.suite.envelopes import heard_envelopes from tests.suite.history.wiring import wired_history from tests.suite.regeneration import HeldRegeneration from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, everything_heard, regenerated, taking_turns diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 1085e875a..ab0914272 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -5,8 +5,6 @@ import numpy as np import pytest -from sampletones_application.logic.reconstruction import manager as manager_module -from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.shared.renders import RenderCache from sampletones_core.audio import write_wave @@ -24,6 +22,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import LoadReconstructionError from tests.conftest import RENDER_BUDGET +from tests.suite.envelopes import exported, land_envelopes from tests.suite.errors import DIRECTORY_READ_ERRORS from tests.suite.stems import RECORDED_SCALE, single_entry_stems_data @@ -494,7 +493,7 @@ def test_source_paths_return_reconstruction_source_paths( reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.source_paths == reconstruction.audio_filepath - def test_current_features_is_populated_after_load( + def test_an_opening_leaves_the_envelopes_owed_to_the_worker( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], @@ -504,10 +503,10 @@ def test_current_features_is_populated_after_load( name="Sample", voice_id=SAMPLE_VOICE_ID, ) - assert reconstruction_manager.current_features is not None + assert reconstruction_manager.current_features is None + assert not reconstruction_manager.has_envelopes_for(reconstruction_manager.listening.selection) -class TestReconstructionManagerPropertiesWhenEmpty: def test_reconstruction_is_none_when_nothing_loaded( self, reconstruction_manager: ReconstructionManager, @@ -551,7 +550,8 @@ def test_the_recording_leaves_what_the_reader_hears( ) -> None: reconstruction_manager.load_reconstruction_object(two_recordings, name="Sample", voice_id=SAMPLE_VOICE_ID) - reconstruction_manager.apply_edited(self._edited(two_recordings)) + reconstruction_manager.apply_edited(self._edited(two_recordings), None) + land_envelopes(reconstruction_manager) assert reconstruction_manager.listening.offered == {0: frozenset({ChannelName.PULSE1})} assert reconstruction_manager.source_paths == (tmp_path / "kick.wav",) @@ -616,7 +616,7 @@ def test_an_edit_keeps_the_choice( muted: ReconstructionManager, two_recordings: Reconstruction, ) -> None: - muted.apply_edited(two_recordings.model_copy(deep=True)) + muted.apply_edited(two_recordings.model_copy(deep=True), None) assert muted.listening.heard[self.MUTED_STEM_ID] == frozenset() @@ -677,7 +677,7 @@ def test_an_edit_keeps_the_voice( voice_id=SAMPLE_VOICE_ID, ) - reconstruction_manager.apply_edited(reconstruction_factory()) + reconstruction_manager.apply_edited(reconstruction_factory(), None) assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID @@ -748,15 +748,6 @@ def test_mark_updated_sets_unsaved_changes( class TestReconstructionManagerInternalGuards: - def test_load_features_without_reconstruction_raises_runtime_error( - self, - reconstruction_manager: ReconstructionManager, - ) -> None: - with pytest.raises(RuntimeError): - reconstruction_manager._load_reconstruction_features() - - -class TestReconstructionManagerLocateOriginalAudio: def test_locate_audio_raises_file_not_found_when_audio_missing( self, reconstruction_manager: ReconstructionManager, @@ -829,22 +820,16 @@ def test_locate_audio_reports_the_first_missing_path( assert raised.value.filename == str(missing) -def _exported( - reconstruction: Reconstruction, - selection: StemSelection, - *, - muted: bool = False, -) -> HeardEnvelopes: - """What the worker exports for ``reconstruction`` heard as ``selection``, or with every recording left out.""" - if muted: - selection = selection.model_copy(update={"channels": {name: frozenset() for name in selection.channels}}) - - return HeardEnvelopes(selection=selection, channels=reconstruction.export_heard(selection)) +def _mute(reconstruction_manager: ReconstructionManager) -> None: + """The reader leaving every recording out of what is heard, box by box.""" + for stem_id in reconstruction_manager.listening.heard: + reconstruction_manager.listening.set_channels(stem_id, frozenset()) class TestEnvelopesTheWorkerExported: """The envelopes a landing installs are the worker's, read for the recordings heard, and a reading made for - another document or for the choice already drawn is passed over. + another document or for the choice already drawn is passed over; a document arriving without them leaves + them owed to the worker. """ def test_a_landing_installs_them_for_the_open_document( @@ -854,7 +839,8 @@ def test_a_landing_installs_them_for_the_open_document( ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) - muted = _exported(reconstruction, reconstruction_manager.listening.selection, muted=True) + _mute(reconstruction_manager) + muted = exported(reconstruction, reconstruction_manager.listening.selection) changed = reconstruction_manager.adopt_envelopes(reconstruction, muted) @@ -871,12 +857,11 @@ def test_a_reading_for_the_choice_already_drawn_changes_nothing( ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + selection = reconstruction_manager.listening.selection + reconstruction_manager.adopt_envelopes(reconstruction, exported(reconstruction, selection)) before = reconstruction_manager.current_features - changed = reconstruction_manager.adopt_envelopes( - reconstruction, - _exported(reconstruction, reconstruction_manager.listening.selection), - ) + changed = reconstruction_manager.adopt_envelopes(reconstruction, exported(reconstruction, selection)) assert not changed assert reconstruction_manager.current_features is before @@ -890,34 +875,36 @@ def test_a_reading_for_another_document_is_passed_over( reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) other = reconstruction_factory() selection = reconstruction_manager.listening.selection + reconstruction_manager.adopt_envelopes(reconstruction, exported(reconstruction, selection)) - changed = reconstruction_manager.adopt_envelopes(other, _exported(other, selection, muted=True)) + installed = reconstruction_manager.current_features + + changed = reconstruction_manager.adopt_envelopes(other, exported(other, selection)) assert not changed assert reconstruction_manager.has_envelopes_for(selection) + assert reconstruction_manager.current_features is installed - def test_an_edit_arriving_with_envelopes_spares_the_reading( + def test_an_edit_arriving_with_envelopes_installs_them( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], - monkeypatch: pytest.MonkeyPatch, ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) edited = reconstruction_factory() selection = reconstruction_manager.listening.selection - monkeypatch.setattr(manager_module, "heard_envelopes", MagicMock(side_effect=AssertionError("read here"))) - reconstruction_manager.apply_edited(edited, _exported(edited, selection)) + reconstruction_manager.apply_edited(edited, exported(edited, selection)) assert reconstruction_manager.reconstruction is edited assert reconstruction_manager.has_envelopes_for(selection) + assert reconstruction_manager.current_features is not None 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() @@ -931,16 +918,15 @@ def test_an_edit_whose_new_frames_moved_the_choice_is_installed_as_the_worker_ca 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)) + 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( + def test_envelopes_exported_for_another_choice_are_left_owed( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], @@ -956,21 +942,76 @@ def test_envelopes_exported_for_another_choice_are_read_on_the_spot( heard=reconstruction.recorded_stem_ids, ) - reconstruction_manager.apply_edited(edited, _exported(edited, before)) + reconstruction_manager.apply_edited(edited, exported(edited, before)) after = reconstruction_manager.listening.selection assert after != before - assert reconstruction_manager.has_envelopes_for(after) + assert not reconstruction_manager.has_envelopes_for(after) + assert reconstruction_manager.current_features is None - def test_an_edit_arriving_bare_is_read_on_the_spot( + def test_an_edit_arriving_bare_leaves_the_envelopes_owed( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.adopt_envelopes( + reconstruction, exported(reconstruction, reconstruction_manager.listening.selection) + ) edited = reconstruction_factory() - reconstruction_manager.apply_edited(edited) + reconstruction_manager.apply_edited(edited, None) + + assert not reconstruction_manager.has_envelopes_for(reconstruction_manager.listening.selection) + assert reconstruction_manager.current_features is None + + def test_the_envelopes_of_a_document_put_back_answer_again( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """An undo puts the document the envelopes were exported for back, so they answer for it as they did.""" + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + selection = reconstruction_manager.listening.selection + reconstruction_manager.adopt_envelopes(reconstruction, exported(reconstruction, selection)) + installed = reconstruction_manager.current_features + reconstruction_manager.apply_edited(reconstruction_factory(), None) + + reconstruction_manager.apply_edited(reconstruction, None) - assert reconstruction_manager.has_envelopes_for(reconstruction_manager.listening.selection) + assert reconstruction_manager.current_features is installed + + def test_a_save_as_leaves_the_envelopes_owed( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """The saved copy is a document of its own, whose envelopes a listening lands like its sound.""" + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.adopt_envelopes( + reconstruction, exported(reconstruction, reconstruction_manager.listening.selection) + ) + + reconstruction_manager.save_reconstruction_as(tmp_path / "saved.stn") + + assert reconstruction_manager.current_features is None + + def test_a_close_forgets_the_envelopes( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.adopt_envelopes( + reconstruction, exported(reconstruction, reconstruction_manager.listening.selection) + ) + + reconstruction_manager.close_reconstruction() + + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + assert reconstruction_manager.current_features is None diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 02b95cce1..6cdda6277 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -8,7 +8,6 @@ from sampletones_application.exports import ExportBackends from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_application.logic.reconstruction.envelopes import heard_envelopes from sampletones_application.logic.reconstruction.listening import Listening from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( @@ -55,6 +54,7 @@ ) from tests.conftest import RENDER_BUDGET from tests.suite.case import BaseRegularTestCase +from tests.suite.envelopes import heard_envelopes from tests.suite.renders import warmed from tests.suite.stems import RECORDED_SCALE, everything_heard, recorded_from @@ -797,10 +797,11 @@ def test_set_selected_generators_with_no_data_skips_waveform( class TestOneReadingPerGesture: - """A gesture reads the waveform once, and what plays is read from the same reading. + """A gesture reads the waveform at most once, and what plays is read from the same reading. The reading is the costly part of what a gesture does on the render thread, so the waveform - the display draws and the audio that plays come from one reading of the document. + the display draws and the audio that plays come from one reading of the document, and a + gesture whose envelopes are on their way owes its showing before reading anything. """ @dataclass(frozen=True) @@ -834,7 +835,7 @@ def test_the_waveform_is_read_once( ) as reading: gesture.act(panel_logic) - assert reading.call_count == 1 + assert reading.call_count <= 1 def test_what_plays_is_the_reading_the_waveform_draws( self, @@ -979,7 +980,7 @@ def test_an_export_owed_is_written_once_the_sound_lands( assert kept is not None assert mock_export_service.export_wav.call_args.args[2] is kept.playing - def test_a_close_forgets_an_owed_export( + def test_a_close_drops_an_owed_export_and_says_so( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -989,15 +990,147 @@ def test_a_close_forgets_an_owed_export( ) -> None: _open(mock_reconstruction_manager, recorded_data) panel_logic.on_renders_wanted = MagicMock() + dropped = MagicMock() + panel_logic.on_export_dropped = dropped mock_reconstruction_manager.listening.set_channels(1, frozenset()) panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") panel_logic.close_reconstruction() + dropped.assert_called_once_with(tmp_path / "output.wav") + _open(mock_reconstruction_manager, recorded_data) + panel_logic.redraw_listening() + mock_export_service.export_wav.assert_not_called() + + def test_another_document_put_in_front_drops_an_owed_export_and_says_so( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + recorded_data: ReconstructionData, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, recorded_data) + panel_logic.on_renders_wanted = MagicMock() + dropped = MagicMock() + panel_logic.on_export_dropped = dropped + mock_reconstruction_manager.listening.set_channels(1, frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + + _open(mock_reconstruction_manager, loaded_data) + panel_logic.display_reconstruction() + + dropped.assert_called_once_with(tmp_path / "output.wav") + panel_logic.redraw_listening() + mock_export_service.export_wav.assert_not_called() + + def test_a_source_flip_while_a_showing_is_owed_still_loads_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + recorded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, recorded_data) + panel_logic.on_renders_wanted = MagicMock() + drawn: List[WaveformData] = [] + played: List[Optional[AudioData]] = [] + panel_logic.on_waveform_load_changed = drawn.append + panel_logic.on_audio_data_changed = played.append + panel_logic.set_stem_channels(1, frozenset()) + panel_logic.set_audio_source(AudioSourceType.RECONSTRUCTION) + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + assert len(drawn) == 1 + assert len(played) == 1 + assert played[0] is not None + np.testing.assert_array_equal( + drawn[0].playing, _playing(recorded_data, mock_reconstruction_manager.listening.hearing) + ) + np.testing.assert_array_equal(played[0].sample, drawn[0].playing) + + def test_a_retune_then_a_flip_keeps_the_refit( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + recorded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, recorded_data) + panel_logic.on_renders_wanted = MagicMock() + updated = MagicMock() + loaded = MagicMock() + panel_logic.on_waveform_update_changed = updated + panel_logic.on_waveform_load_changed = loaded + mock_reconstruction_manager.listening.set_channels(1, frozenset()) + panel_logic.update_reconstruction(refit_waveform=True) + panel_logic.set_audio_source(AudioSourceType.RECONSTRUCTION) + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + updated.assert_called_once() + assert updated.call_args.kwargs == {"refit": True} + loaded.assert_not_called() + + def test_a_failed_listening_forgets_the_debts_and_keeps_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + recorded_data: ReconstructionData, + tmp_path: Path, + ) -> None: _open(mock_reconstruction_manager, recorded_data) + panel_logic.on_renders_wanted = MagicMock() + drawn = MagicMock() + cleared = MagicMock() + dropped = MagicMock() + panel_logic.on_waveform_load_changed = drawn + panel_logic.on_waveform_update_changed = drawn + panel_logic.on_waveform_cleared = cleared + panel_logic.on_export_dropped = dropped + panel_logic.set_stem_channels(1, frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + + panel_logic.abandon_showing() + + dropped.assert_called_once_with(tmp_path / "output.wav") + _hear(mock_reconstruction_manager) panel_logic.redraw_listening() + drawn.assert_not_called() + cleared.assert_not_called() mock_export_service.export_wav.assert_not_called() + def test_a_landing_pays_a_showing_and_an_export_from_one_reading( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + recorded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, recorded_data) + panel_logic.on_renders_wanted = MagicMock() + drawn = MagicMock() + panel_logic.on_waveform_load_changed = drawn + panel_logic.set_stem_channels(1, frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + _hear(mock_reconstruction_manager) + + with patch.object( + ReconstructionData, + "waveform_data", + autospec=True, + side_effect=ReconstructionData.waveform_data, + ) as reading: + panel_logic.redraw_listening() + + assert reading.call_count == 1 + drawn.assert_called_once() + mock_export_service.export_wav.assert_called_once() + def test_an_edit_owed_a_showing_updates_the_waveform_in_place( self, panel_logic: ReconstructionPanelLogic, @@ -1031,6 +1164,18 @@ def test_with_no_data_raises_assertion_error( with pytest.raises(AssertionError): panel_logic.exportable_instrument(ChannelName.PULSE1) + def test_envelopes_still_owed_offer_nothing( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + mock_reconstruction_manager.current_features = None + + assert panel_logic.exportable_instrument(ChannelName.PULSE1) is None + assert panel_logic.sample_request() is None + def test_a_playing_channel_offers_its_slice( self, panel_logic: ReconstructionPanelLogic, diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 43cb3a71b..ae00e590e 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -574,18 +574,28 @@ def test_the_sample_an_undo_keeps_still_owns_the_tab( assert title is not None assert title.included - def test_an_undo_redraws_the_instruments_panel( + def test_an_undo_redraws_the_instruments_panel_once_its_sound_lands( self, app: Application, + held_queue: HeldQueue, embedded_sample: Sample, reconstruction_factory: ReconstructionFactory, ) -> None: + """The restored document's envelopes are owed to the worker, so the panel draws them with its sound. + + The queue is held before the sample opens, so the opening's own report is drained here too. + """ self._edit(app, reconstruction_factory) drawn = self._drawn_envelopes(app) app.history.undo() + assert drawn == [None] + while app.reconstruction_rewrites.is_busy: + SingleThreadExecutor.join_all() + held_queue.drain() - assert drawn == [app.reconstruction_manager.current_features] + assert drawn[-1] is not None + assert drawn[-1] is app.reconstruction_manager.current_features def test_a_redo_shows_the_edit_again( self, From 9dd50a6cb21738c1e7f5527829a6dbc4a77499cf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 00:15:51 +0200 Subject: [PATCH 28/37] Forgotten: a wanted preview when playback starts, and the trees' autoplay on the worker --- docs/development/application/playback.md | 2 +- docs/development/application/render-thread.md | 3 +- docs/development/bugs-and-todos.md | 4 + src/sampletones_application/application.py | 9 +- .../coordinators/playback/guard.py | 11 +- .../coordinators/tabs/instructions.py | 5 +- .../coordinators/tabs/main.py | 6 +- .../coordinators/tabs/reconstruction.py | 5 +- .../tabs/sequencer/coordinator.py | 10 +- .../logic/shared/file_playback.py | 29 ++- .../coordinators/playback/test_guard.py | 37 +++- .../coordinators/tabs/test_sequencer.py | 16 ++ .../logic/shared/test_file_playback.py | 188 ++++++++++++++---- .../logic/shared/test_tree.py | 3 +- 14 files changed, 261 insertions(+), 67 deletions(-) diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 04ff6ea76..ba07661e8 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 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 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 playback the reader starts forgets one, so the voice heard is the last one asked for and a song started meanwhile keeps the device. A reconstruction a browser tree plays is read from disk and previewed the same way. **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. diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index c32d9308d..c56152a64 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -27,7 +27,8 @@ left out for the first time or a freshly opened document, is asked of the same w 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. 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. +for what the cache lacks (`DocumentPreviews` in `logic/shared/previews.py`), and sounds at the landing; +a reconstruction a browser tree plays is read from disk and previewed the same way. The render cache keeps what the landings adopt and renders nothing itself, so its writers are the landings, every one of them on the render thread, and every sound the interface draws or plays is a worker's result. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ec334f1c3..1dcb2b3dd 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -121,6 +121,10 @@ currently out of line. An entry leaves when the code meets the contract again. `RegenerationService._run` and reports through `_emit`, and the tracker view reads the caret's box from `CaretOverlay._rectangle`. Each is a seam a scenario needs, which [screens](application/screens.md) says the application offers as a public one. +* The render thread still reads a reconstruction file from disk, at an opening and when a browser tree + plays one, and mixes the original audio of the recordings heard for the waveform's Original source; + every other sound the interface draws or plays is a worker's result + ([the render thread](application/render-thread.md)). ## Bugs diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 94e2aa0e8..58857f6fb 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -294,7 +294,6 @@ def __init__( ) 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) @@ -532,6 +531,7 @@ def __init__( instrument_exports=self._instrument_exports, history=self.history, instrument_edit_detail=self._instrument_edit_detail, + previews=self.document_previews, key_router=self.key_router, tab_active=self._is_reconstructions_tab_current, layout=ReconstructionTabParameters.from_config(self.layout), @@ -552,6 +552,7 @@ def __init__( on_generation_state_changed=self._on_library_operation_changed, is_operation_active=self._is_operation_active, is_converter_visible=self._is_converter_panel_visible, + previews=self.document_previews, layout=InstructionsTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, @@ -577,6 +578,7 @@ def __init__( on_refresh_trees=self._refresh_browsers, on_prepare_library=self._instructions_tab.prepare_library, ), + previews=self.document_previews, layout=MainTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, @@ -1113,11 +1115,6 @@ 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/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py index 9f86d8a48..9592c0d50 100644 --- a/src/sampletones_application/coordinators/playback/guard.py +++ b/src/sampletones_application/coordinators/playback/guard.py @@ -1,5 +1,6 @@ from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_shared.exceptions import PlaybackError from sampletones_shared.types.callback import VoidCallback @@ -11,7 +12,9 @@ class GuardedPlayer: Panels only fire intent hooks, so this wrapper is the coordinator-layer recovery boundary for the transport commands that can raise ``PlaybackError``; queries pass straight through. A start that fails at once - reads the way a failure on the playing thread does. + reads the way a failure on the playing thread does. Every command here is + playback the reader asked for, so each forgets a preview still on its way: + the sound the reader asked for last is the one that takes the device. """ def __init__( @@ -19,9 +22,11 @@ def __init__( player: AudioPlayerProtocol, *, failures: PlaybackFailurePresenter, + previews: DocumentPreviews, ) -> None: self._player = player self._failures = failures + self._previews = previews def play(self) -> None: self.run_guarded(self._player.play) @@ -48,8 +53,10 @@ def run_guarded(self, command: VoidCallback) -> None: """Runs a playback command, showing the reader a failure to start the audio. A command beyond the transport — sounding a sample from a point the reader clicked — goes - through the same boundary the transport commands do. + through the same boundary the transport commands do, and forgets a preview on its way the + same way. """ + self._previews.cancel() try: command() except PlaybackError as exception: diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 3b6c36e76..81fc10049 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -19,6 +19,7 @@ ) from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.instructions import InstructionsTabParameters from sampletones_application.tags.compose import compose_tag @@ -101,6 +102,7 @@ def __init__( on_generation_state_changed: VoidCallback, is_operation_active: Callable[[], bool], is_converter_visible: Callable[[], bool], + previews: DocumentPreviews, *, layout: InstructionsTabParameters, language_manager: LanguageManager, @@ -131,7 +133,7 @@ def __init__( language_manager=language_manager, is_operation_active=is_operation_active, ) - self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews) self._library_tree_logic = TreeLogic( session_manager, self._file_playback, @@ -180,6 +182,7 @@ def __init__( self._guarded_player = GuardedPlayer( self._instruction_player_logic, failures=playback_failures, + previews=previews, ) self._waveform_panel = GUIInstructionWaveformPanel( initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM), diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6a8945ab9..2e88e60d5 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -17,6 +17,7 @@ from sampletones_application.logic.main.explorer_manager import ExplorerManager from sampletones_application.logic.main.sources.scan import FolderScan from sampletones_application.logic.shared.file_playback import FilePlayback +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters from sampletones_application.services.conversion.service import ConversionService @@ -105,6 +106,7 @@ def __init__( library_manager: InstructionsLibraryManager, conversion_service: ConversionService, hooks: MainTabHooks, + previews: DocumentPreviews, *, layout: MainTabParameters, language_manager: LanguageManager, @@ -135,6 +137,7 @@ def __init__( config_manager, session_manager, audio_device_manager, + previews, layout=layout, language_manager=language_manager, status_bar=status_bar, @@ -165,6 +168,7 @@ def _build_explorer( config_manager: ConfigManager, session_manager: SessionManager, audio_device_manager: AudioDeviceManager, + previews: DocumentPreviews, *, layout: MainTabParameters, language_manager: LanguageManager, @@ -176,7 +180,7 @@ def _build_explorer( language_manager=language_manager, open_directories=session_manager.expanded_directories, ) - self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews) self._folder_scan: FolderScan = FolderScan(FolderScanService(priority=self._repaint_priority)) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 0091b7cd9..f41d2c720 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -46,6 +46,7 @@ ) from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.reconstruction import ( ReconstructionTabParameters, @@ -153,6 +154,7 @@ def __init__( instrument_exports: InstrumentExportCoordinator, history: HistoryManager, instrument_edit_detail: InstrumentEditDetail, + previews: DocumentPreviews, *, key_router: KeyRouter, tab_active: ActivePredicate, @@ -201,7 +203,7 @@ def __init__( config_manager, browser_manager, ) - self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews) self._browser_tree_logic: TreeLogic = TreeLogic( session_manager, self._file_playback, @@ -233,6 +235,7 @@ def __init__( self._guarded_player = GuardedPlayer( self._reconstruction_player_logic, failures=playback_failures, + previews=previews, ) self._reconstruction_audio_panel: GUIReconstructionAudioPanel = GUIReconstructionAudioPanel( path_colors=layout.path_colors, diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index bb9755792..557b5995d 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -105,6 +105,7 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode +from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback, VoidCallback @@ -162,7 +163,7 @@ def __init__( browser_manager, project_controller, ) - self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews) self._sequencer_tree_logic: TreeLogic = TreeLogic( session_manager, self._file_playback, @@ -217,7 +218,9 @@ def __init__( self._guarded_player = GuardedPlayer( self._song_player_logic, failures=playback_failures, + previews=previews, ) + previews.on_failed = self._report_preview_failure self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( self._sequencer_tracker_logic.settings, layout=layout.sequencer, @@ -831,6 +834,11 @@ def _on_player_position_changed( def _on_preview_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) + def _report_preview_failure(self, exception: Exception) -> None: + """Tells the reader a document'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_browser_recordings_requested(self, path: Path) -> None: """Hands the browser what a document names, where it has already been read.""" recordings = self._sequencer_browser_logic.recordings(path) diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py index f33d0a653..06aebf571 100644 --- a/src/sampletones_application/logic/shared/file_playback.py +++ b/src/sampletones_application/logic/shared/file_playback.py @@ -1,10 +1,13 @@ +from functools import partial from pathlib import Path from typing import Callable, Optional +import numpy as np + from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_core.audio import AudioDeviceManager from sampletones_core.reconstructions import Reconstruction -from sampletones_core.reconstructions.reconstruction.renders import rendered_mix from sampletones_shared.exceptions import PlaybackError, SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.paths import extensions @@ -14,13 +17,21 @@ class FilePlayback(CallbackMixin): """Wherever a file is named and asked to sound, this is what answers. - A reconstruction is read and its approximation played; an audio file is played from disk. The - browser's tree, a row in the converter's list and a menu item all name a file the same way, so - what a suffix means and what a failure to read one reports stand in one place. + A reconstruction is read and previewed whole through the document previews, so its sound is + the render the cache keeps or the one a worker lands a moment later; an audio file is played + from disk. The browser's tree, a row in the converter's list and a menu item all name a file + the same way, so what a suffix means and what a failure to read one reports stand in one + place. A device that refuses the sound reports through ``on_error`` whenever the refusal + comes, at once for a file or at the landing for a document. """ - def __init__(self, audio_device_manager: AudioDeviceManager) -> None: + def __init__( + self, + audio_device_manager: AudioDeviceManager, + previews: DocumentPreviews, + ) -> None: self._audio_device_manager = audio_device_manager + self._previews = previews self.on_error: Optional[Callable[[Exception], None]] = None @@ -64,4 +75,10 @@ def _play_reconstruction(self, path: Path, priority: PlaybackPriority) -> None: self.call(self.on_error, exception) return - self._audio_device_manager.play(rendered_mix(reconstruction), update=False, priority=priority) + self._previews.play(reconstruction, partial(self._sound, priority=priority)) + + def _sound(self, audio: np.ndarray, *, priority: PlaybackPriority) -> None: + try: + self._audio_device_manager.play(audio, update=False, priority=priority) + except PlaybackError as exception: + self.call(self.on_error, exception) diff --git a/tests/unit/sampletones_application/coordinators/playback/test_guard.py b/tests/unit/sampletones_application/coordinators/playback/test_guard.py index f5873fe87..48e71b4ca 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_guard.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_guard.py @@ -4,6 +4,7 @@ import pytest from sampletones_application.coordinators.playback.guard import GuardedPlayer +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError GUARDED_COMMANDS = ("play", "pause_or_resume") @@ -20,8 +21,13 @@ def failures() -> MagicMock: @pytest.fixture -def guarded_player(player_logic: MagicMock, failures: MagicMock) -> GuardedPlayer: - return GuardedPlayer(player_logic, failures=failures) +def previews() -> MagicMock: + return MagicMock(spec=DocumentPreviews) + + +@pytest.fixture +def guarded_player(player_logic: MagicMock, failures: MagicMock, previews: MagicMock) -> GuardedPlayer: + return GuardedPlayer(player_logic, failures=failures, previews=previews) class TestGuardedCommands: @@ -66,6 +72,33 @@ def test_a_playback_error_reaches_the_presenter( failures.present_playing_failure.assert_called_once_with(exception) +class TestAStartForgetsThePreviewOnItsWay: + """Playback the reader asks for forgets a preview still on its way before it starts, so a late render + sounds nothing over it. + """ + + @pytest.mark.parametrize("command", GUARDED_COMMANDS) + def test_a_transport_command_forgets_it_first( + self, + guarded_player: GuardedPlayer, + player_logic: MagicMock, + previews: MagicMock, + command: str, + ) -> None: + getattr(player_logic, command).side_effect = lambda: previews.cancel.assert_called_once_with() + + getattr(guarded_player, command)() + + previews.cancel.assert_called_once_with() + + def test_a_guarded_run_forgets_it_first(self, guarded_player: GuardedPlayer, previews: MagicMock) -> None: + ran: List[int] = [] + + guarded_player.run_guarded(lambda: ran.append(previews.cancel.call_count)) + + assert ran == [1] + + class TestAGuardedRun: """A command beyond the transport runs under the same boundary the transport commands keep.""" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index f83461be0..9aca1c2d9 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -36,6 +36,7 @@ from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, ) +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel @@ -1840,6 +1841,7 @@ def exposure_coordinator() -> SequencerTabCoordinator: instance._guarded_player = GuardedPlayer( instance._song_player_logic, failures=MagicMock(), + previews=MagicMock(spec=DocumentPreviews), ) return instance @@ -1875,6 +1877,7 @@ def refusing_coordinator_fixture(failures: MagicMock) -> SequencerTabCoordinator instance._guarded_player = GuardedPlayer( instance._song_player_logic, failures=failures, + previews=MagicMock(spec=DocumentPreviews), ) return instance @@ -2428,3 +2431,16 @@ def test_the_grid_reads_the_mark_from_the_voices_list(self, history_coordinator: history_coordinator._sequencer_voices_panel = MagicMock() assert history_coordinator._marked_voice() is history_coordinator._sequencer_voices_panel.selection + + +class TestAPreviewThatFailsToRender: + """A document's preview whose render fails is told to the reader by the tab that owns the previews.""" + + def test_the_failure_is_shown(self) -> None: + instance = object.__new__(SequencerTabCoordinator) + instance._dialogs = MagicMock() + failure = RuntimeError("rendering failed") + + instance._report_preview_failure(failure) + + instance._dialogs.show_error.assert_called_once_with(failure) diff --git a/tests/unit/sampletones_application/logic/shared/test_file_playback.py b/tests/unit/sampletones_application/logic/shared/test_file_playback.py index 715dc0459..327011ca2 100644 --- a/tests/unit/sampletones_application/logic/shared/test_file_playback.py +++ b/tests/unit/sampletones_application/logic/shared/test_file_playback.py @@ -1,15 +1,54 @@ from pathlib import Path +from typing import Final, List from unittest.mock import MagicMock, patch +import numpy as np import pytest from sampletones_application.logic.shared.file_playback import FilePlayback 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_core.constants.enums import ChannelName +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix from sampletones_shared.exceptions import InvalidReconstructionError, NoOutputDeviceError from sampletones_shared.paths import extensions +from tests.suite.regeneration import HeldRegeneration +from tests.suite.sequencer import sample_reconstruction -RECONSTRUCTION_LOAD = "sampletones_application.logic.shared.file_playback.Reconstruction.load" -RECONSTRUCTION_RENDER = "sampletones_application.logic.shared.file_playback.rendered_mix" +RECONSTRUCTION_LOAD: Final[str] = "sampletones_application.logic.shared.file_playback.Reconstruction.load" +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 audio_device_manager() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def playback(audio_device_manager: MagicMock, previews: DocumentPreviews) -> FilePlayback: + playback = FilePlayback(audio_device_manager, previews) + playback.on_error = MagicMock() + return playback + + +def _document() -> Reconstruction: + return sample_reconstruction(PREVIEWED_CHANNELS) + + +def _reconstruction_file(tmp_path: Path, name: str) -> Path: + return tmp_path / f"{name}{extensions.EXT_FILE_RECONSTRUCTION}" class TestWhatItSounds: @@ -28,10 +67,13 @@ def test_anything_else_stays_quiet(self, tmp_path: Path) -> None: class TestHowItSounds: """A file named on demand outranks the preview a selection sounds on its own.""" - def test_an_audio_file_asked_for_by_name_plays_at_normal_priority(self, tmp_path: Path) -> None: - audio_device_manager = MagicMock() - - FilePlayback(audio_device_manager).play(tmp_path / "audio.wav") + def test_an_audio_file_asked_for_by_name_plays_at_normal_priority( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + tmp_path: Path, + ) -> None: + playback.play(tmp_path / "audio.wav") audio_device_manager.play_file.assert_called_once_with( tmp_path / "audio.wav", @@ -39,10 +81,13 @@ def test_an_audio_file_asked_for_by_name_plays_at_normal_priority(self, tmp_path priority=PlaybackPriority.NORMAL, ) - def test_a_priority_the_caller_names_is_the_one_it_plays_at(self, tmp_path: Path) -> None: - audio_device_manager = MagicMock() - - FilePlayback(audio_device_manager).play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) + def test_a_priority_the_caller_names_is_the_one_it_plays_at( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + tmp_path: Path, + ) -> None: + playback.play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) audio_device_manager.play_file.assert_called_once_with( tmp_path / "audio.wav", @@ -50,15 +95,80 @@ def test_a_priority_the_caller_names_is_the_one_it_plays_at(self, tmp_path: Path priority=PlaybackPriority.PREVIEW, ) - def test_a_file_of_another_kind_sounds_nothing(self, tmp_path: Path) -> None: - audio_device_manager = MagicMock() - - FilePlayback(audio_device_manager).play(tmp_path / "notes.txt") + def test_a_file_of_another_kind_sounds_nothing( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + tmp_path: Path, + ) -> None: + playback.play(tmp_path / "notes.txt") audio_device_manager.play_file.assert_not_called() audio_device_manager.play.assert_not_called() +class TestAReconstructionSoundsThroughThePreviews: + """A reconstruction file is read and previewed whole: its sound is a worker's render, played at the priority + asked once it lands, and the next file asked for replaces one still on its way. + """ + + def test_its_document_sounds_once_the_worker_lands_it_at_the_priority_asked( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + regeneration: HeldRegeneration, + tmp_path: Path, + ) -> None: + document = _document() + with patch(RECONSTRUCTION_LOAD, return_value=document): + playback.play_at(_reconstruction_file(tmp_path, "sample"), PlaybackPriority.PREVIEW) + + audio_device_manager.play.assert_not_called() + regeneration.finish() + + audio_device_manager.play.assert_called_once() + (played,) = audio_device_manager.play.call_args.args + assert np.array_equal(played, rendered_mix(document)) + assert audio_device_manager.play.call_args.kwargs == {"update": False, "priority": PlaybackPriority.PREVIEW} + + def test_the_next_file_replaces_one_on_its_way( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + regeneration: HeldRegeneration, + tmp_path: Path, + ) -> None: + first = _document() + second = _document() + with patch(RECONSTRUCTION_LOAD, return_value=first): + playback.play(_reconstruction_file(tmp_path, "first")) + with patch(RECONSTRUCTION_LOAD, return_value=second): + playback.play(_reconstruction_file(tmp_path, "second")) + + regeneration.finish() + regeneration.finish() + + audio_device_manager.play.assert_called_once() + (played,) = audio_device_manager.play.call_args.args + assert np.array_equal(played, rendered_mix(second)) + + def test_a_device_refusing_at_the_landing_reports( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + regeneration: HeldRegeneration, + tmp_path: Path, + ) -> None: + refusal = NoOutputDeviceError("no device") + audio_device_manager.play.side_effect = refusal + with patch(RECONSTRUCTION_LOAD, return_value=_document()): + playback.play(_reconstruction_file(tmp_path, "sample")) + + regeneration.finish() + + playback.on_error.assert_called_once_with(refusal) + + class TestAReconstructionThatWillNotRead: """A reconstruction file under the cursor is untrusted input: any load or playback failure in the domain (``SampleToNESError``) or I/O (``OSError``) families reports through ``on_error``; @@ -69,50 +179,40 @@ class TestAReconstructionThatWillNotRead: [InvalidReconstructionError("corrupt"), PermissionError("denied")], ids=["domain", "io"], ) - def test_a_load_failure_is_reported(self, tmp_path: Path, error: Exception) -> None: - audio_device_manager = MagicMock() - playback = FilePlayback(audio_device_manager) - playback.on_error = MagicMock() - + def test_a_load_failure_is_reported( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + regeneration: HeldRegeneration, + tmp_path: Path, + error: Exception, + ) -> None: with patch(RECONSTRUCTION_LOAD, side_effect=error): - playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + playback.play(_reconstruction_file(tmp_path, "sample")) playback.on_error.assert_called_once_with(error) audio_device_manager.play.assert_not_called() + assert regeneration.held == () - def test_an_unexpected_failure_propagates(self, tmp_path: Path) -> None: - playback = FilePlayback(MagicMock()) - playback.on_error = MagicMock() - + def test_an_unexpected_failure_propagates(self, playback: FilePlayback, tmp_path: Path) -> None: with patch(RECONSTRUCTION_LOAD, side_effect=RuntimeError("bug")), pytest.raises(RuntimeError): - playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + playback.play(_reconstruction_file(tmp_path, "sample")) playback.on_error.assert_not_called() class TestADeviceThatRefuses: - """A device with no output in force refuses the playback, and the refusal reports through ``on_error`` - for an audio file and for a reconstruction alike.""" - - def test_an_audio_file_reports_the_refusal(self, tmp_path: Path) -> None: + """A device with no output in force refuses the playback, and the refusal reports through ``on_error``.""" + + def test_an_audio_file_reports_the_refusal( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + tmp_path: Path, + ) -> None: refusal = NoOutputDeviceError("no device") - audio_device_manager = MagicMock() audio_device_manager.play_file.side_effect = refusal - playback = FilePlayback(audio_device_manager) - playback.on_error = MagicMock() playback.play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) playback.on_error.assert_called_once_with(refusal) - - def test_a_reconstruction_reports_the_refusal(self, tmp_path: Path) -> None: - refusal = NoOutputDeviceError("no device") - audio_device_manager = MagicMock() - audio_device_manager.play.side_effect = refusal - playback = FilePlayback(audio_device_manager) - playback.on_error = MagicMock() - - with patch(RECONSTRUCTION_LOAD), patch(RECONSTRUCTION_RENDER): - playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") - - playback.on_error.assert_called_once_with(refusal) diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index a085d6cdc..0911f7a12 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -10,6 +10,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_application.logic.shared.tree import TreeLogic from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import InvalidReconstructionError @@ -46,7 +47,7 @@ def _tree( return TreeLogic( session_manager, - FilePlayback(audio_device_manager), + FilePlayback(audio_device_manager, MagicMock(spec=DocumentPreviews)), scheduling=scheduling, ) From ede8bd2551396743fc4a87cbb502d0986520d60e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 00:39:08 +0200 Subject: [PATCH 29/37] Mixed: the original of the recordings heard on the worker, kept beside the renders --- docs/development/application/render-thread.md | 6 +- docs/development/bugs-and-todos.md | 7 +- .../logic/reconstruction/data.py | 104 ++++++-------- .../logic/reconstruction/manager.py | 12 +- .../logic/reconstruction/reconstruction.py | 7 +- .../logic/reconstruction/rewrites/queue.py | 16 ++- .../reconstruction/rewrites/regeneration.py | 1 + .../logic/shared/previews.py | 9 +- .../logic/shared/regeneration.py | 1 + .../logic/shared/renders.py | 57 ++++++-- .../services/regeneration/jobs.py | 6 + .../services/regeneration/result.py | 21 ++- .../services/regeneration/service.py | 40 +++++- .../test_stems_reconstruction.py | 17 +-- .../services/conftest.py | 1 - .../services/test_regeneration.py | 12 +- tests/suite/regeneration.py | 7 +- tests/suite/renders.py | 19 ++- .../reconstruction/rewrites/test_queue.py | 32 +++++ .../logic/reconstruction/test_data.py | 103 +++++++------- .../logic/reconstruction/test_manager.py | 23 +++ .../reconstruction/test_reconstruction.py | 14 +- .../logic/sequencer/test_voices.py | 2 +- .../logic/shared/test_previews.py | 6 +- .../logic/shared/test_renders.py | 52 ++++++- .../services/test_regeneration.py | 133 ++++++++++++++++-- 26 files changed, 523 insertions(+), 185 deletions(-) diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index c56152a64..a505ec960 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -20,9 +20,9 @@ continues. Every background result reaches UI state this way. Applying one to UI worker thread is forbidden. A result carries what the landing installs. The regeneration renders the edited channel's audio on its -worker, the mix of what the reader hears and the envelopes each channel exports for the recordings heard, -and hands them back inside its result; the landing keeps the audio in the render cache and installs the -envelopes on the render thread. A reading the cache cannot answer, such as a recording +worker, the mix of what the reader hears, the envelopes each channel exports for the recordings heard and +the mix of those recordings, which the waveform draws as the original, and hands them back inside its +result; the landing keeps the audio in the render cache and installs the 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. A document previewed diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 1dcb2b3dd..980d2123a 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -121,10 +121,9 @@ currently out of line. An entry leaves when the code meets the contract again. `RegenerationService._run` and reports through `_emit`, and the tracker view reads the caret's box from `CaretOverlay._rectangle`. Each is a seam a scenario needs, which [screens](application/screens.md) says the application offers as a public one. -* The render thread still reads a reconstruction file from disk, at an opening and when a browser tree - plays one, and mixes the original audio of the recordings heard for the waveform's Original source; - every other sound the interface draws or plays is a worker's result - ([the render thread](application/render-thread.md)). +* The render thread still reads a reconstruction file and its recordings from disk, at an opening and + when a browser tree plays one; every sound the interface draws or plays, the original line included, is + a worker's result ([the render thread](application/render-thread.md)). ## Bugs diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 5a2332ca3..977e148c9 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -7,7 +7,6 @@ from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.audio import mix from sampletones_core.configs import Config from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.recordings import load_recordings @@ -22,15 +21,15 @@ @dataclass(frozen=True) class ReconstructionData: - """The open document beside what was read for it: its recordings and their mix. + """The open document beside the recordings read for it. Attributes: name: The name the document is shown under. config: The configuration the document was converted at. reconstruction: The document itself. - stem_audios: The loaded recordings, one per entry in entry order, or none at all. + stem_audios: The loaded recordings, one per entry in entry order, or none at all; each is + read-only, since a worker mixes them while the render thread holds them. filepath: The file the document lives in, ``None`` for one held in memory. - original_audio: The recorded source mixed into one waveform, ``None`` while no source loads. """ name: str @@ -38,7 +37,6 @@ class ReconstructionData: reconstruction: Reconstruction stem_audios: Tuple[np.ndarray, ...] filepath: Optional[Path] - original_audio: Optional[np.ndarray] @classmethod def load(cls, path: Path) -> Self: @@ -59,7 +57,7 @@ def from_reconstruction(cls, reconstruction: Reconstruction, *, name: str) -> Se caller (the project sample's name). A document whose file was removed in the Browser is wrapped the same way, under the name - it had. It keeps the locations of its recordings, so its original audio loads again from them. + it had. It keeps the locations of its recordings, so they load again from them. """ return cls._assemble( reconstruction, @@ -74,7 +72,7 @@ def detached_copy(self, filepath: Path) -> Self: document. The copy holds a reconstruction object of its own, which shares every part with the one it was copied from, so a document that was a project sample becomes a standalone entity: later edits reach only the saved file, leaving the project's sample unchanged. The - copy shares the loaded recordings, so the original audio carries over without a reload. + copy shares the loaded recordings, so the mix of them the cache keeps serves it too. """ reconstruction = self.reconstruction.model_copy() return replace( @@ -90,23 +88,13 @@ def with_reconstruction(self, reconstruction: Reconstruction) -> Self: An edit produces a fresh reconstruction object; the display name and file location stand, so the reconstruction and the recordings its entries hold are what the rebind - refreshes. The original mix carries over while the recordings stay the same, and is - mixed again once an entry leaves or returns. + refreshes. """ - stem_audios = self._recordings_for(reconstruction) - original_audio = self.original_audio if self._same_recordings(stem_audios) else self._mixed(stem_audios) return replace( self, reconstruction=reconstruction, config=reconstruction.config, - stem_audios=stem_audios, - original_audio=original_audio, - ) - - def _same_recordings(self, stem_audios: Tuple[np.ndarray, ...]) -> bool: - """Whether ``stem_audios`` holds the very recordings this data holds, in the same order.""" - return len(stem_audios) == len(self.stem_audios) and all( - incoming is held for incoming, held in zip(stem_audios, self.stem_audios) + stem_audios=self._recordings_for(reconstruction), ) def _recordings_for(self, reconstruction: Reconstruction) -> Tuple[np.ndarray, ...]: @@ -136,14 +124,12 @@ def _assemble( filepath: Optional[Path], name: str, ) -> Self: - stem_audios = cls._load_stem_audios(reconstruction) return cls( config=reconstruction.config, reconstruction=reconstruction, - stem_audios=stem_audios, + stem_audios=cls._load_stem_audios(reconstruction), filepath=filepath, name=name, - original_audio=cls._mixed(stem_audios), ) @staticmethod @@ -160,11 +146,13 @@ def _derive_name(filepath: Path) -> str: def _load_stem_audios( reconstruction: Reconstruction, ) -> Tuple[np.ndarray, ...]: - """Loads the recorded source, one recording per path, in path order. + """Loads the recorded source, one recording per path, in path order, each kept read-only. Every recording is read at the level its conversion read it at, which the document records, so one heard on its own sounds at the level it holds in the mix, and it keeps - that level once another recording leaves the document. + that level once another recording leaves the document. A worker mixes the recordings + heard while the render thread holds them, so each is kept read-only and what both read + stands as it was loaded. A reconstruction detached from its origin (a project sample) records no source path, and a file-backed reconstruction may point at audio absent or unreadable on @@ -173,50 +161,31 @@ def _load_stem_audios( own in playback and the display. """ try: - return load_recordings(reconstruction) + recordings = load_recordings(reconstruction) except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as error: logger.warning(f"Could not load the original audio: {error}. The original is unavailable") return () - @staticmethod - def _mixed(stem_audios: Tuple[np.ndarray, ...]) -> Optional[np.ndarray]: - """The recordings mixed into one waveform, ``None`` where none loaded.""" - return mix(list(stem_audios)) if stem_audios else None + for recording in recordings: + recording.setflags(write=False) - @cached_property - def _stem_recording_indexes(self) -> Dict[int, int]: - """Maps each stem id to the index of its recording in ``stem_audios``. + return recordings - The entries' ids map to their recordings in entry order, and source audio absent - or unreadable maps nothing. + @cached_property + def recordings_by_stem(self) -> Dict[int, np.ndarray]: + """The loaded recordings by the id of the entry each was loaded for, in record order, or none where + the recordings are absent or unreadable. """ if not self.stem_audios: return {} - stems_data = self.reconstruction.stems_data - return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} + entries = self.reconstruction.stems_data.config.entries + return {entry.id: self.stem_audios[index] for index, entry in enumerate(entries)} - def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]: - """The original audio of the stems heard anywhere. - - Returns: - Optional[np.ndarray]: The mix of the recordings heard, silence where the recordings - loaded and none of them is heard, and ``None`` where no recording loaded, which leaves - the approximation on its own in playback and the display. - """ - if not self.stem_audios: - return None - - selected_stem_ids = selection.any_channel() - indexes = self._stem_recording_indexes - if selected_stem_ids >= indexes.keys(): - return self.original_audio - - recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids] - if not recordings: - return np.zeros(rendered_length(self.reconstruction), dtype=np.float32) - - return mix(recordings) + def recordings_heard(self, selection: StemSelection) -> Tuple[np.ndarray, ...]: + """The loaded recordings ``selection`` hears anywhere, in record order.""" + heard = selection.any_channel() + return tuple(recording for stem_id, recording in self.recordings_by_stem.items() if stem_id in heard) def waveform_data( self, @@ -226,18 +195,31 @@ def waveform_data( """Projects the slice of this data the waveform display renders, the audio read from ``renders``. The projection carries what ``hearing`` sounds of the document and the mix of the - recordings heard anywhere, so the waveform answers exactly what plays. The audio is read - from what the cache keeps, so the reading computes no sample. + recordings heard anywhere, both read from what the cache keeps, so the reading computes + no sample. A document whose recordings loaded and none of which is heard draws a silent + original, answered here, and one with no recording loaded draws none. Returns: Optional[WaveformData]: The projection, or ``None`` where the cache keeps no audio for - ``hearing`` yet, which is what a worker is then asked to render. + ``hearing`` yet or no mix of the recordings heard, which is what a worker is then + asked to render. """ heard = renders.reading(self.reconstruction, hearing) if heard is None: return None - return self._waveform_data(self.original_mix_for(hearing.selection), heard) + if not self.stem_audios: + return self._waveform_data(None, heard) + + recordings = self.recordings_heard(hearing.selection) + if not recordings: + return self._waveform_data(np.zeros(rendered_length(self.reconstruction), dtype=np.float32), heard) + + original = renders.original(recordings, self.config) + if original is None: + return None + + return self._waveform_data(original, heard) def _waveform_data( self, diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 9b6efb660..f588ad2bd 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -2,7 +2,9 @@ import os from dataclasses import dataclass from pathlib import Path -from typing import Optional, Tuple +from typing import Mapping, Optional, Tuple + +import numpy as np from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData @@ -300,6 +302,14 @@ def locate_original_audio(self) -> None: def current_reconstruction(self) -> Optional[ReconstructionData]: return self._current_reconstruction + @property + def recordings(self) -> Mapping[int, np.ndarray]: + """The recordings loaded for the open document, by stem id, which a worker mixes the original from.""" + if self._current_reconstruction is None: + return {} + + return self._current_reconstruction.recordings_by_stem + @property def current_features(self) -> Optional[ChannelEnvelopesViewModel]: """The envelopes the worker exported for the open document heard as the reader hears it now, or ``None`` diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index a61c30378..c32639fdc 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -873,7 +873,7 @@ def _build_path_view_models( reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath) original_audio = self._build_audio_path_view_model( reconstruction_data.reconstruction.audio_filepath, - reconstruction_data.original_audio, + loaded=bool(reconstruction_data.stem_audios), ) return reconstruction_file, original_audio @@ -895,11 +895,12 @@ def _build_file_path_view_model( @staticmethod def _build_audio_path_view_model( source_paths: Tuple[Path, ...], - original_audio: Optional[np.ndarray], + *, + loaded: bool, ) -> ReconstructionPathViewModel: """Reports the original-audio location, treating a recorded path with unusable content the same as a missing one, so the source toggle and waveform agree with what actually loaded.""" - if source_paths and original_audio is None: + if source_paths and not loaded: return ReconstructionPathViewModel( state=ReconstructionPathState.NOT_FOUND, paths=(), diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 5d6691bc0..f80d66a33 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -270,7 +270,14 @@ def _rebuild(self, change: ChannelChange) -> None: hearing = self._manager.listening.hearing kept = self._manager.renders.held(reconstruction) self._running = Rebuild(change=change, base=reconstruction) - self._regeneration.start(reconstruction, change.channel_name, rebased, hearing=hearing, kept=kept) + self._regeneration.start( + reconstruction, + change.channel_name, + rebased, + hearing=hearing, + kept=kept, + recordings=self._manager.recordings, + ) def _await_envelopes(self, change: ChannelChange) -> None: """Puts ``change`` back at the head of the line behind a listening of the choice its envelopes are owed for. @@ -302,7 +309,12 @@ def _listen(self) -> None: hearing = self._manager.listening.hearing kept = self._manager.renders.held(reconstruction) self._running = Reading(base=reconstruction) - self._regeneration.start_listening(reconstruction, hearing=hearing, kept=kept) + self._regeneration.start_listening( + reconstruction, + hearing=hearing, + kept=kept, + recordings=self._manager.recordings, + ) def _remove(self, removal: StemRemovalRequest) -> None: """Takes a recording out, where a step before it left the recording and another beside it.""" diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py index ea851c2e7..60ddc22e8 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -20,4 +20,5 @@ def start( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: ... diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py index b65a62c97..616ee5b18 100644 --- a/src/sampletones_application/logic/shared/previews.py +++ b/src/sampletones_application/logic/shared/previews.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, Optional +from typing import Callable, Dict, Final, Optional import numpy as np @@ -15,6 +15,7 @@ from sampletones_shared.utils.callbacks import CallbackMixin Play = Callable[[np.ndarray], None] +NO_RECORDINGS: Final[Dict[int, np.ndarray]] = {} @dataclass(frozen=True) @@ -93,7 +94,10 @@ def cancel(self) -> None: self._wanted = None def _pursue(self) -> None: - """Sounds the wanted preview from the cache, or asks the worker for it while no job runs.""" + """Sounds the wanted preview from the cache, or asks the worker for it while no job runs. + + A preview sounds the document alone, so the job is handed no recording and mixes no original. + """ wanted = self._wanted if wanted is None or self._running is not None: return @@ -110,6 +114,7 @@ def _pursue(self) -> None: wanted.reconstruction, hearing=hearing, kept=self._renders.held(wanted.reconstruction), + recordings=NO_RECORDINGS, ) def _on_result(self, result: RegenerationResult) -> None: diff --git a/src/sampletones_application/logic/shared/regeneration.py b/src/sampletones_application/logic/shared/regeneration.py index 8592a29c2..2262f404d 100644 --- a/src/sampletones_application/logic/shared/regeneration.py +++ b/src/sampletones_application/logic/shared/regeneration.py @@ -26,4 +26,5 @@ def start_listening( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: ... diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index 0afa3bf33..fb3e8ba75 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -21,20 +21,22 @@ class RenderKind(StrEnum): CHANNEL = "channel" MIX = "mix" + ORIGINAL = "original" @dataclass(frozen=True) class RenderPart: - """One channel as a render sounds it: its stream, and the owners heard where the reader leaves one out. + """One part as a render sounds it: a channel's stream and the owners heard where the reader leaves one + out, or a recording. Attributes: - stream: The identity of the stream the channel plays. + source: The identity of the object the part sounds: a channel's stream, or a recording. assignment: The identity of the record naming the owner of each frame, where an owner is left out. - heard: The owners heard, where an owner is left out; ``None`` where the channel is heard whole. + heard: The owners heard, where an owner is left out; ``None`` where the part is heard whole. """ - stream: int + source: int assignment: Optional[int] heard: Optional[FrozenSet[int]] @@ -50,12 +52,12 @@ class RenderKey: @property def identities(self) -> FrozenSet[int]: """The identities of every object the render was made from.""" - named = {part.stream for part in self.parts} + named = {part.source for part in self.parts} named.update(part.assignment for part in self.parts if part.assignment is not None) return frozenset(named) -Watched = Union[InstructionsItem, ChannelAssignment] +Watched = Union[InstructionsItem, ChannelAssignment, np.ndarray] @dataclass(frozen=True) @@ -79,7 +81,9 @@ class RenderCache: A render is keyed by the identity of the parts it sounds, which a lookup reads at once, where a stream's value would take every instruction it carries to hash. A mix as a reader hears it names, for each channel the reader leaves an owner out on, the record and the owners heard, so - two hearings leaving nothing out read one mix. A render goes with the objects it was made + two hearings leaving nothing out read one mix. The mix of the recordings heard, which the + waveform draws as the original, is kept under the recordings it was mixed from, so every + document holding those recordings reads one mix. A render goes with the objects it was made from, and beyond ``budget_bytes`` the renders read longest ago go first, so the cache keeps audio alone. A landing is kept whole: the renders one landing keeps are spared by its own eviction, so a document larger than the budget stands until the next landing, and a reading @@ -148,8 +152,32 @@ def reading( return HeardRenders(mix=mixed, playing=playing) + def original(self, recordings: Sequence[np.ndarray], config: Config) -> Optional[np.ndarray]: + """The mix of ``recordings``, heard in record order, which a lookup answers at once. + + Returns: + Optional[np.ndarray]: The mix, or ``None`` where the cache keeps none, which is what a + worker is then asked for. + """ + return self._read(self._original_key(recordings, config)) + + def adopt_original( + self, + recordings: Sequence[np.ndarray], + config: Config, + mixed: np.ndarray, + ) -> None: + """Keeps the mix of ``recordings`` made elsewhere, under the recordings it was mixed from. + + A recording is shared by every document holding it, so its mix serves each of them, and + the mix goes with the recordings once the last document lets them go. + """ + with self._landing(): + self._keep(self._original_key(recordings, config), mixed, list(recordings)) + def adopt_rendered(self, outcome: RenderedDocument) -> None: - """Keeps everything a job rendered as one landing: the channels, the mix, and the hearing's reading. + """Keeps everything a job rendered as one landing: the channels, the mix, the hearing's reading, and the + original of the recordings heard. Args: outcome: What the worker handed back. @@ -157,6 +185,8 @@ def adopt_rendered(self, outcome: RenderedDocument) -> None: with self._landing(): self.adopt(outcome.reconstruction, outcome.channels, outcome.mix) self.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard) + if outcome.original is not None: + self.adopt_original(outcome.original.recordings, outcome.reconstruction.config, outcome.original.audio) def adopt( self, @@ -227,7 +257,7 @@ def _keep_mix(self, parts: List[_Part], config: Config, mixed: np.ndarray) -> No @staticmethod def _plain_part(stream: InstructionsItem) -> _Part: - return _Part(part=RenderPart(stream=id(stream), assignment=None, heard=None), watched=(stream,)) + return _Part(part=RenderPart(source=id(stream), assignment=None, heard=None), watched=(stream,)) @staticmethod def _heard_parts(reconstruction: Reconstruction, hearing: Hearing) -> Dict[ChannelName, _Part]: @@ -242,7 +272,7 @@ def _heard_parts(reconstruction: Reconstruction, hearing: Hearing) -> Dict[Chann record = stems_data.assignment_records[stream.channel_name] parts[stream.channel_name] = _Part( - part=RenderPart(stream=id(stream), assignment=id(record), heard=heard), + part=RenderPart(source=id(stream), assignment=id(record), heard=heard), watched=(stream, record), ) @@ -254,7 +284,12 @@ def _playing_parts(parts: Mapping[ChannelName, _Part], hearing: Hearing) -> List @staticmethod def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey: - return RenderKey(RenderKind.CHANNEL, (RenderPart(stream=id(stream), assignment=None, heard=None),), config) + return RenderKey(RenderKind.CHANNEL, (RenderPart(source=id(stream), assignment=None, heard=None),), config) + + @staticmethod + def _original_key(recordings: Sequence[np.ndarray], config: Config) -> RenderKey: + parts = tuple(RenderPart(source=id(recording), assignment=None, heard=None) for recording in recordings) + return RenderKey(RenderKind.ORIGINAL, parts, config) @staticmethod def _mix_key(parts: Sequence[_Part], config: Config) -> RenderKey: diff --git a/src/sampletones_application/services/regeneration/jobs.py b/src/sampletones_application/services/regeneration/jobs.py index d6d2eb34c..62ccb1323 100644 --- a/src/sampletones_application/services/regeneration/jobs.py +++ b/src/sampletones_application/services/regeneration/jobs.py @@ -20,6 +20,8 @@ class RebuildJob: hearing: What the reader hears, which names the recordings the edit writes and the reading rendered. kept: The renders the caller holds for the document's channels, by channel. + recordings: The recordings loaded for the document, by stem id, which the original of the + recordings heard is mixed from. """ reconstruction: Reconstruction @@ -27,6 +29,7 @@ class RebuildJob: features: Features hearing: Hearing kept: Mapping[ChannelName, np.ndarray] + recordings: Mapping[int, np.ndarray] @dataclass(frozen=True) @@ -37,11 +40,14 @@ class ListeningJob: reconstruction: The document read. hearing: What the reader hears of it. kept: The renders the caller holds for the document's channels, by channel. + recordings: The recordings loaded for the document, by stem id, which the original of the + recordings heard is mixed from. """ reconstruction: Reconstruction hearing: Hearing kept: Mapping[ChannelName, np.ndarray] + recordings: Mapping[int, np.ndarray] RenderJob = Union[RebuildJob, ListeningJob] diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index 38203a531..cbf738ca2 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Mapping, Union +from typing import Mapping, Optional, Tuple, Union import numpy as np @@ -13,9 +13,23 @@ from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing +@dataclass(frozen=True) +class OriginalMix: + """The recordings heard anywhere mixed into one waveform, beside the recordings it was mixed from. + + Attributes: + recordings: The recordings mixed, in the document's record order. + audio: Their mix. + """ + + recordings: Tuple[np.ndarray, ...] + audio: np.ndarray + + @dataclass(frozen=True) class RenderedDocument: - """A document beside its sound: every channel's render, their mix, and what one hearing of it sounds. + """A document beside its sound: every channel's render, their mix, what one hearing of it sounds, and the + original of the recordings heard. The caller keeps what it asked for, so the result carries the document and the audio the worker rendered for it, which the landing installs where the screen reads it. A rebuild @@ -28,6 +42,8 @@ class RenderedDocument: hearing: What the reader heard when the renders were made. heard: What ``hearing`` sounds of the document. envelopes: The envelopes each channel exports for the recordings ``hearing`` hears. + original: The mix of the recordings ``hearing`` hears anywhere, which the waveform draws as + the original, or ``None`` where none is heard or none loaded. """ reconstruction: Reconstruction @@ -36,6 +52,7 @@ class RenderedDocument: hearing: Hearing heard: HeardRenders envelopes: Mapping[ChannelName, Features] + original: Optional[OriginalMix] RegenerationResult = Union[ diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 8b6a0a06b..2117e3130 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -1,11 +1,12 @@ from functools import partial -from typing import Dict, List, Mapping, Optional, cast +from typing import Dict, List, Mapping, Optional, Tuple, cast import numpy as np from sampletones_application.services.base import ServiceBase from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob, RenderJob from sampletones_application.services.regeneration.result import ( + OriginalMix, RegenerationResult, RenderedDocument, ) @@ -23,6 +24,7 @@ heard_renders, hearing_carried_onto, ) +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection class RegenerationService(ServiceBase[RegenerationResult]): @@ -37,8 +39,9 @@ class RegenerationService(ServiceBase[RegenerationResult]): 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 - each channel exports for the recordings heard; the source reconstruction is left intact. + audio of every sounding channel, their mix, what the hearing sounds of it, the envelopes + each channel exports for the recordings heard, and the mix of the recordings heard anywhere, + which the waveform draws as the original; the source reconstruction is left intact. Producing a new object lets callers swap the edited reconstruction in while any history snapshot that shares the prior object stays valid. A listening renders the same reading of the document as it stands, for a hearing the screen holds no audio for yet. Carrying the audio @@ -61,6 +64,7 @@ def start( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: """Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread. @@ -74,8 +78,10 @@ def start( 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 render handed for ``channel_name`` is passed over, since the edit rebuilds it. + recordings: The recordings loaded for the document, by stem id; the worker mixes the + ones the reading hears anywhere. """ - self._execute(RebuildJob(reconstruction, channel_name, features, hearing, kept)) + self._execute(RebuildJob(reconstruction, channel_name, features, hearing, kept, recordings)) def start_listening( self, @@ -83,6 +89,7 @@ def start_listening( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: """Renders what ``hearing`` sounds of ``reconstruction`` as it stands, on the worker thread. @@ -91,8 +98,10 @@ def start_listening( hearing: What the reader hears of it. kept: The renders the caller holds for ``reconstruction``'s channels, by channel, each of which carries over; the worker renders the rest. + recordings: The recordings loaded for the document, by stem id; the worker mixes the + ones ``hearing`` hears anywhere. """ - self._execute(ListeningJob(reconstruction, hearing, kept)) + self._execute(ListeningJob(reconstruction, hearing, kept, recordings)) def _execute(self, job: RenderJob) -> None: self._executor.execute(partial(self._run, job), wait=True) @@ -123,6 +132,7 @@ def _run(self, job: RenderJob) -> None: hearing=hearing, heard=heard_renders(document, hearing, channels, mixed), envelopes=document.export_heard(hearing.selection), + original=self._original(document, hearing.selection, job.recordings), ) ) ) @@ -145,6 +155,26 @@ def _rebuilt(job: RebuildJob) -> Reconstruction: heard=job.hearing.selection.stems_for(job.channel_name), ) + @staticmethod + def _original( + document: Reconstruction, + selection: StemSelection, + recordings: Mapping[int, np.ndarray], + ) -> Optional[OriginalMix]: + """The recordings ``selection`` hears anywhere mixed into one waveform, in ``document``'s record order, or + ``None`` where none is heard or none loaded. + """ + heard = selection.any_channel() + mixed_from: Tuple[np.ndarray, ...] = tuple( + recordings[entry.id] + for entry in document.stems_data.config.entries + if entry.id in heard and entry.id in recordings + ) + if not mixed_from: + return None + + return OriginalMix(recordings=mixed_from, audio=mix(list(mixed_from))) + @staticmethod def _rendered( document: Reconstruction, diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 24b078c4f..06a4dbd16 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -386,7 +386,7 @@ def hearing(selected: AbstractSet[int]) -> Hearing: def kept(selected: AbstractSet[int]) -> WaveformData: """The reading of the loaded document once the worker's sound for it is kept.""" - warmed(renders, data.reconstruction, hearing(selected)) + warmed(renders, data.reconstruction, hearing(selected), data.recordings_by_stem) waveform = data.waveform_data(renders, hearing(selected)) assert waveform is not None return waveform @@ -404,7 +404,7 @@ def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: assert whole is not None assert full.approximation is whole.mix assert full.playing is full.approximation - assert full.original_audio is data.original_audio + np.testing.assert_allclose(full.original_audio, mix(list(data.stem_audios)), atol=_MIX_TOLERANCE) for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID): selected = frozenset({selected_id}) @@ -415,9 +415,7 @@ def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: np.testing.assert_array_equal(filtered(selected)[channel], expected_audio) np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE) np.testing.assert_allclose(waveform.playing, mix(list(expected.values())), atol=_MIX_TOLERANCE) - np.testing.assert_allclose( - data.original_mix_for(heard(selected)), data.stem_audios[selected_id], atol=_MIX_TOLERANCE - ) + np.testing.assert_allclose(waveform.original_audio, data.stem_audios[selected_id], atol=_MIX_TOLERANCE) selected = frozenset() waveform = kept(selected) @@ -428,10 +426,7 @@ def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: ) np.testing.assert_allclose(waveform.approximation, np.zeros_like(rendered_mix(data.reconstruction))) np.testing.assert_allclose(waveform.playing, np.zeros_like(rendered_mix(data.reconstruction))) - np.testing.assert_allclose( - data.original_mix_for(heard(selected)), - np.zeros_like(rendered_mix(data.reconstruction)), - ) + np.testing.assert_allclose(waveform.original_audio, np.zeros_like(rendered_mix(data.reconstruction))) class TestRemovingAStem: @@ -634,15 +629,13 @@ def test_mixes_the_recorded_stems_at_the_balance_they_were_captured_in(self, tmp assert data.reconstruction.audio_filepath == (tone_path, noise_path) assert data.name == save_path.stem - assert data.original_audio is not None loaded_tone, loaded_noise = data.stem_audios np.testing.assert_allclose( np.max(np.abs(loaded_tone)) / np.max(np.abs(loaded_noise)), np.max(np.abs(tone)) / np.max(np.abs(noise)), rtol=1e-5, ) - np.testing.assert_allclose(data.original_audio, mix([loaded_tone, loaded_noise]), atol=_MIX_TOLERANCE) - np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-5) + np.testing.assert_allclose(np.max(np.abs(mix([loaded_tone, loaded_noise]))), 1.0, rtol=1e-5) def test_the_conversion_records_the_scale_it_read_the_set_at(self, tmp_path: Path) -> None: config = Config() diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index a89e99b6b..57566afda 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -66,5 +66,4 @@ def reconstruction_data(default_config, minimal_reconstruction) -> Reconstructio stem_audios=(), filepath=Path("/dev/null"), name="null", - original_audio=None, ) diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 36a3240a0..c3149a575 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -66,6 +66,7 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -84,6 +85,7 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -106,6 +108,7 @@ def test_the_result_sounds_what_the_core_renders(self, reconstruction_data, puls pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -124,6 +127,7 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -142,6 +146,7 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -165,6 +170,7 @@ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_da silenced, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -185,6 +191,7 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da {}, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -202,6 +209,7 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ pulse_features, hearing=_heard_whole(reconstruction_data.reconstruction), kept={}, + recordings={}, ) assert len(results) == 1 @@ -230,7 +238,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: Envelope(items=tuple(int(offset) for offset in arpeggio)), ) service._run( - RebuildJob(context.reconstruction, ChannelName.PULSE1, edited, _heard_whole(context.reconstruction), {}) + RebuildJob(context.reconstruction, ChannelName.PULSE1, edited, _heard_whole(context.reconstruction), {}, {}) ) assert len(results) == 1 @@ -353,6 +361,7 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) @@ -386,6 +395,7 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, pulse_features, _heard_whole(reconstruction_data.reconstruction), {}, + {}, ) ) diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py index 2972714e8..d54d0605c 100644 --- a/tests/suite/regeneration.py +++ b/tests/suite/regeneration.py @@ -39,8 +39,9 @@ def start( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: - self._held.append(RebuildJob(reconstruction, channel_name, features, hearing, kept)) + self._held.append(RebuildJob(reconstruction, channel_name, features, hearing, kept, recordings)) def start_listening( self, @@ -48,8 +49,9 @@ def start_listening( *, hearing: Hearing, kept: Mapping[ChannelName, np.ndarray], + recordings: Mapping[int, np.ndarray], ) -> None: - self._held.append(ListeningJob(reconstruction, hearing, kept)) + self._held.append(ListeningJob(reconstruction, hearing, kept, recordings)) @property def held(self) -> Tuple[RenderJob, ...]: @@ -76,6 +78,7 @@ def finish_with(self, reconstruction: Reconstruction) -> None: hearing=job.hearing, heard=heard_renders(reconstruction, job.hearing, channels, mixed), envelopes=reconstruction.export_heard(job.hearing.selection), + original=None, ) ) ) diff --git a/tests/suite/renders.py b/tests/suite/renders.py index 2a7ce4dbb..87d53e8b1 100644 --- a/tests/suite/renders.py +++ b/tests/suite/renders.py @@ -1,11 +1,22 @@ +from typing import Mapping + +import numpy as np + from sampletones_application.logic.shared.renders import RenderCache +from sampletones_core.audio import mix 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 -def warmed(renders: RenderCache, reconstruction: Reconstruction, hearing: Hearing) -> None: - """Keeps in ``renders`` what the worker would hand back for ``hearing`` of ``reconstruction``. +def warmed( + renders: RenderCache, + reconstruction: Reconstruction, + hearing: Hearing, + recordings: Mapping[int, np.ndarray], +) -> None: + """Keeps in ``renders`` what the worker would hand back for ``hearing`` of ``reconstruction``, the mix of the + recordings heard included. A case driving the panel logic alone has no worker to render what the reader hears, so it keeps the renders the way a landing does, and the panel reads them as it reads any kept sound. @@ -14,3 +25,7 @@ def warmed(renders: RenderCache, reconstruction: Reconstruction, hearing: Hearin mixed = rendered_mix(reconstruction) renders.adopt(reconstruction, channels, mixed) renders.adopt_heard(reconstruction, hearing, heard_renders(reconstruction, hearing, channels, mixed)) + heard = hearing.selection.any_channel() + mixed_from = [recording for stem_id, recording in recordings.items() if stem_id in heard] + if mixed_from: + renders.adopt_original(mixed_from, reconstruction.config, mix(mixed_from)) 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 7fb4ef227..6dba91ffe 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -377,6 +377,7 @@ def test_a_result_with_no_rebuild_running_is_refused( taking_turns.export()[SHARED_CHANNEL], hearing=Hearing(selection=everything_heard(taking_turns), channels_on=frozenset()), kept={}, + recordings={}, ) with pytest.raises(RuntimeError, match="no job was running"): @@ -599,6 +600,36 @@ def test_steps_taken_at_once_leave_the_line_empty( assert not rewrites.is_busy +class TestTheRecordingsAJobCarries: + """A rebuild and a listening take the open document's recordings with them, so the worker mixes the original + of the recordings heard. + """ + + def test_a_rebuild_carries_them( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + job = regeneration.held[0] + assert isinstance(job, RebuildJob) + assert job.recordings is reconstruction_manager.recordings + + def test_a_listening_carries_them( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(ListeningChange()) + + job = regeneration.held[0] + assert isinstance(job, ListeningJob) + assert job.recordings is reconstruction_manager.recordings + + class TestAChangeWhoseEnvelopesAreOwed: """A change reaching its turn while the envelopes of what is heard are on their way waits behind a listening of that choice, once: it rebuilds from the envelopes the listening lands, and is let go where it lands none. @@ -704,6 +735,7 @@ def test_the_rebuild_takes_the_kept_renders_with_it( reconstruction_manager.renders, taking_turns, Hearing(selection=everything_heard(taking_turns), channels_on=frozenset(ChannelName.items())), + {}, ) kept = reconstruction_manager.renders.held(taking_turns) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 71b795caa..bbc82cb06 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -37,7 +37,7 @@ def _hearing(*stem_ids: int, channels_on: FrozenSet[ChannelName] = EVERY_CHANNEL def _kept(renders: RenderCache, data: ReconstructionData, hearing: Hearing) -> WaveformData: """The waveform data read once the sound of ``hearing`` is kept, the way a landing keeps it.""" - warmed(renders, data.reconstruction, hearing) + warmed(renders, data.reconstruction, hearing, data.recordings_by_stem) waveform_data = data.waveform_data(renders, hearing) assert waveform_data is not None return waveform_data @@ -83,7 +83,7 @@ def test_uses_the_supplied_display_name( ) assert data.name == "Kick drum" - def test_detached_reconstruction_has_no_original_audio( + def test_detached_reconstruction_has_no_recording( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: @@ -96,9 +96,9 @@ def test_detached_reconstruction_has_no_original_audio( ) assert reconstruction.audio_filepath == () - assert data.original_audio is None + assert data.stem_audios == () - def test_loads_original_audio_when_source_file_is_available( + def test_loads_the_recording_when_its_file_is_available( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, @@ -116,7 +116,8 @@ def test_loads_original_audio_when_source_file_is_available( name="Sample", ) - assert data.original_audio is not None + assert len(data.stem_audios) == 1 + assert not data.stem_audios[0].flags.writeable def test_mixes_several_recorded_paths_at_the_balance_they_were_captured_in( self, @@ -137,11 +138,9 @@ def test_mixes_several_recorded_paths_at_the_balance_they_were_captured_in( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - assert data.original_audio is not None louder, quieter = data.stem_audios np.testing.assert_allclose(louder, 2.0 * quieter, rtol=1e-6) - np.testing.assert_allclose(data.original_audio, mix([louder, quieter])) - np.testing.assert_allclose(np.max(np.abs(data.original_audio)), (0.5 + 0.25) / RECORDED_SCALE, rtol=1e-6) + np.testing.assert_allclose(np.max(np.abs(mix([louder, quieter]))), (0.5 + 0.25) / RECORDED_SCALE, rtol=1e-6) def test_one_unreadable_stem_costs_the_whole_original( self, @@ -155,7 +154,7 @@ def test_one_unreadable_stem_costs_the_whole_original( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - assert data.original_audio is None + assert data.stem_audios == () class TestReconstructionDataLoad: @@ -194,7 +193,7 @@ def test_an_in_memory_document_keeps_the_name_it_was_given( assert data.name == "Sample" - def test_load_has_no_original_audio_when_source_file_missing( + def test_load_has_no_recording_when_source_file_missing( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, @@ -205,7 +204,7 @@ def test_load_has_no_original_audio_when_source_file_missing( data = ReconstructionData.load(save_path) - assert data.original_audio is None + assert data.stem_audios == () class TestDetachedCopy: @@ -287,7 +286,7 @@ def test_a_stems_document_is_named_after_its_file( assert copy.name == "lead" - def test_reuses_the_already_loaded_original_audio( + def test_reuses_the_already_loaded_recordings( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, @@ -307,8 +306,6 @@ def test_reuses_the_already_loaded_original_audio( copy = data.detached_copy(tmp_path / "lead.stn") assert copy.stem_audios is data.stem_audios - assert data.original_audio is not None - np.testing.assert_allclose(copy.original_audio, data.original_audio) class TestStemFilteredProjections: @@ -371,10 +368,11 @@ def test_partials_keep_only_the_selected_stems_frames( rendered_mix(data.reconstruction), ) - def test_original_mix_mixes_the_selected_recordings( + def test_the_waveform_reads_the_original_of_the_recordings_heard( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, + renders: RenderCache, ) -> None: first = tmp_path / "kick.wav" second = tmp_path / "snare.wav" @@ -410,11 +408,10 @@ def test_original_mix_mixes_the_selected_recordings( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.stem_audios[0]) - assert data.original_audio is not None - assert data.original_mix_for(_heard(0, 1)) is data.original_audio + np.testing.assert_allclose(_kept(renders, data, _hearing(0)).original_audio, data.stem_audios[0]) + np.testing.assert_allclose(_kept(renders, data, _hearing(0, 1)).original_audio, mix(list(data.stem_audios))) np.testing.assert_array_equal( - data.original_mix_for(_heard()), + _kept(renders, data, _hearing()).original_audio, np.zeros_like(rendered_mix(data.reconstruction)), ) @@ -430,12 +427,9 @@ def test_a_single_source_with_no_selection_is_silence( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - np.testing.assert_array_equal( - _kept(renders, data, _hearing()).playing, - np.zeros_like(rendered_mix(data.reconstruction)), - ) - assert data.original_audio is not None - np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio) + waveform = _kept(renders, data, _hearing()) + np.testing.assert_array_equal(waveform.playing, np.zeros_like(rendered_mix(data.reconstruction))) + np.testing.assert_array_equal(waveform.original_audio, np.zeros_like(rendered_mix(data.reconstruction))) class TestTheOriginalAMissingRecordingLeaves: @@ -452,15 +446,15 @@ def _missing_a_recording( reconstruction = recorded_from(reconstruction_factory(), (first, tmp_path / "gone.wav")) return ReconstructionData.from_reconstruction(reconstruction, name="Sample") - def test_no_recording_mixes_into_no_original( + def test_no_recording_heard_mixes_into_no_original( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, + renders: RenderCache, ) -> None: data = self._missing_a_recording(reconstruction_factory, tmp_path) - assert data.original_mix_for(_heard(0, 1)) is None - assert data.original_mix_for(_heard()) is None + assert _kept(renders, data, _hearing()).original_audio is None def test_the_heard_waveform_carries_no_original_line( self, @@ -475,13 +469,14 @@ def test_the_heard_waveform_carries_no_original_line( def test_a_detached_document_mixes_into_no_original( self, reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() reconstruction = reconstruction.detached() data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - assert data.original_mix_for(_heard(0)) is None + assert _kept(renders, data, _hearing(0)).original_audio is None def test_a_loaded_recording_heard_nowhere_is_a_silent_line( self, @@ -540,7 +535,7 @@ def test_a_recording_follows_the_entry_it_was_loaded_for( remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) - np.testing.assert_allclose(remaining.original_mix_for(_heard(2)), third) + assert remaining.recordings_by_stem[2] is third def test_the_removed_recording_is_released( self, @@ -579,7 +574,7 @@ def test_an_edit_letting_a_recording_go_releases_its_audio( assert [entry.id for entry in edited.stems_data.config.entries] == [0, 2] assert len(remaining.stem_audios) == 2 assert all(not np.array_equal(audio, second) for audio in remaining.stem_audios) - np.testing.assert_allclose(remaining.original_mix_for(_heard(2)), third) + assert remaining.recordings_by_stem[2] is third def test_an_edit_keeping_every_entry_keeps_every_recording( self, @@ -592,30 +587,20 @@ def test_an_edit_keeping_every_entry_keeps_every_recording( assert rebound.stem_audios == data.stem_audios - def test_an_edit_keeping_every_entry_keeps_the_original_mix( - self, - reconstruction_factory: Callable[[], Reconstruction], - tmp_path: Path, - ) -> None: - """The recordings stand as they were, so the mix of them stands too, which spares a landing the mixing.""" - data = self._three_recordings(reconstruction_factory, tmp_path) - - rebound = data.with_reconstruction(data.reconstruction.model_copy()) - - assert rebound.original_audio is data.original_audio - - def test_a_removal_mixes_the_original_again( + def test_a_removal_leaves_the_recordings_that_stay_in_place( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: + """The recordings that stay are the very objects they were, so the mix the cache keeps of them stands.""" data = self._three_recordings(reconstruction_factory, tmp_path) first, third = data.stem_audios[0], data.stem_audios[2] remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) - assert remaining.original_audio is not data.original_audio - np.testing.assert_allclose(remaining.original_audio, mix([first, third])) + assert remaining.stem_audios[0] is first + assert remaining.stem_audios[1] is third + assert remaining.recordings_heard(_heard(0, 2)) == (first, third) def test_a_document_naming_a_recording_the_load_lost_holds_none( self, @@ -629,7 +614,7 @@ def test_a_document_naming_a_recording_the_load_lost_holds_none( restored = remaining.with_reconstruction(data.reconstruction) assert restored.stem_audios == () - assert restored.original_audio is None + assert restored.recordings_by_stem == {} class TestWaveformData: @@ -648,7 +633,7 @@ def test_projects_the_render_relevant_fields( reading = renders.reading(reconstruction, _hearing(0)) assert reading is not None - assert waveform_data.original_audio is data.original_audio + assert waveform_data.original_audio is None assert waveform_data.approximation is reading.mix assert waveform_data.playing is reading.mix assert waveform_data.coefficient == reconstruction.coefficient @@ -664,6 +649,28 @@ def test_a_hearing_the_cache_keeps_no_sound_for_reads_nothing( assert data.waveform_data(renders, _hearing(0)) is None + def test_a_mix_of_the_recordings_heard_the_cache_keeps_none_of_reads_nothing( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + renders: RenderCache, + ) -> None: + """The original is a worker's render like the sound, so a reading missing it is left to the worker.""" + source_audio = tmp_path / "source.wav" + write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + data = ReconstructionData.from_reconstruction( + recorded_from(reconstruction_factory(), (source_audio,)), + name="Sample", + ) + warmed(renders, data.reconstruction, _hearing(0), {}) + assert data.waveform_data(renders, _hearing(0)) is None + + renders.adopt_original(data.recordings_heard(_heard(0)), data.config, mix(list(data.stem_audios))) + + waveform = data.waveform_data(renders, _hearing(0)) + assert waveform is not None + np.testing.assert_allclose(waveform.original_audio, data.stem_audios[0]) + def test_a_recording_left_out_reads_what_the_worker_kept( self, reconstruction_factory: Callable[[], Reconstruction], diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index ab0914272..3fc78f640 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -826,6 +826,29 @@ def _mute(reconstruction_manager: ReconstructionManager) -> None: reconstruction_manager.listening.set_channels(stem_id, frozenset()) +class TestTheRecordingsTheWorkerMixes: + """The manager hands on the open document's recordings by stem id, and none while no document is open.""" + + def test_none_while_nothing_is_open(self, reconstruction_manager: ReconstructionManager) -> None: + assert reconstruction_manager.recordings == {} + + def test_the_open_documents_recordings_by_stem( + self, + reconstruction_manager: ReconstructionManager, + two_recordings: Reconstruction, + ) -> None: + for path in two_recordings.audio_filepath: + write_wave(path, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction_manager.load_reconstruction_object(two_recordings, name="Sample", voice_id=SAMPLE_VOICE_ID) + data = reconstruction_manager.current_reconstruction + assert data is not None + + recordings = reconstruction_manager.recordings + + assert list(recordings) == [entry.id for entry in two_recordings.stems_data.config.entries] + assert all(recordings[stem_id] is data.recordings_by_stem[stem_id] for stem_id in recordings) + + class TestEnvelopesTheWorkerExported: """The envelopes a landing installs are the worker's, read for the recordings heard, and a reading made for another document or for the choice already drawn is passed over; a document arriving without them leaves diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 6cdda6277..31ed06118 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -143,7 +143,12 @@ def _hear(manager: MagicMock) -> None: """Keeps the sound and the envelopes of what the reader hears now, the way a listening's landing does.""" reconstruction_data = manager.current_reconstruction _refresh_features(manager) - warmed(manager.renders, reconstruction_data.reconstruction, manager.listening.hearing) + warmed( + manager.renders, + reconstruction_data.reconstruction, + manager.listening.hearing, + reconstruction_data.recordings_by_stem, + ) def _refresh_features(manager: MagicMock) -> None: @@ -337,11 +342,10 @@ class AudioPathCase(BaseRegularTestCase): @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) def test_audio_path_state_follows_loaded_content(self, case: AudioPathCase) -> None: source_paths = (Path("/songs/source.wav"),) if case.has_filepath else () - original_audio = np.zeros(4, dtype=np.float32) if case.has_content else None view_model = ReconstructionPanelLogic._build_audio_path_view_model( source_paths, - original_audio, + loaded=case.has_content, ) assert view_model.state is case.expected @@ -351,11 +355,9 @@ def test_stem_paths_report_multiple_state(self) -> None: Path("/stems/drums/kick.wav"), Path("/stems/drums/snare.wav"), ) - original_audio = np.zeros(4, dtype=np.float32) - view_model = ReconstructionPanelLogic._build_audio_path_view_model( stem_paths, - original_audio, + loaded=True, ) assert view_model.state is ReconstructionPathState.MULTIPLE diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index add84101b..e862eed60 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -62,7 +62,7 @@ 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)) + warmed(self.renders, sample.reconstruction, whole_hearing(sample.reconstruction), {}) return sample diff --git a/tests/unit/sampletones_application/logic/shared/test_previews.py b/tests/unit/sampletones_application/logic/shared/test_previews.py index 8a0c246a3..1601fb78b 100644 --- a/tests/unit/sampletones_application/logic/shared/test_previews.py +++ b/tests/unit/sampletones_application/logic/shared/test_previews.py @@ -53,7 +53,7 @@ def test_it_plays_at_once( played: Played, ) -> None: document = _document() - warmed(renders, document, whole_hearing(document)) + warmed(renders, document, whole_hearing(document), {}) previews.play(document, played.append) @@ -136,7 +136,7 @@ def test_a_document_no_longer_wanted_is_kept_while_the_wanted_one_plays_from_the first = _document() second = _document() previews.play(first, played.append) - warmed(renders, second, whole_hearing(second)) + warmed(renders, second, whole_hearing(second), {}) previews.play(second, played.append) assert played == [] @@ -189,7 +189,7 @@ def test_a_result_arriving_with_nothing_on_its_way_raises(self, previews: Docume document = _document() stray = HeldRegeneration() stray.subscribe(previews._on_result) - stray.start_listening(document, hearing=whole_hearing(document), kept={}) + stray.start_listening(document, hearing=whole_hearing(document), kept={}, recordings={}) with pytest.raises(RuntimeError, match="no preview was on its way"): stray.finish() diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index 66d6152cb..069eb41b1 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -7,7 +7,7 @@ from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache -from sampletones_core.audio.mixing import align +from sampletones_core.audio.mixing import align, mix from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction @@ -183,6 +183,56 @@ def test_a_collected_document_releases_its_renders(self, renders: RenderCache) - assert renders.held_bytes == 0 +class TestTheOriginalOfTheRecordingsHeard: + """The mix of the recordings heard is kept under the recordings it was mixed from, in record order, so every + document holding those recordings reads one mix, and the mix goes with the recordings. + """ + + @staticmethod + def _recordings() -> Tuple[np.ndarray, np.ndarray]: + return np.ones(8, dtype=np.float32), np.full(8, 0.5, dtype=np.float32) + + def test_it_is_read_back_by_the_recordings_in_order(self, renders: RenderCache, document: Reconstruction) -> None: + first, second = self._recordings() + mixed = mix([first, second]) + + renders.adopt_original((first, second), document.config, mixed) + + assert renders.original((first, second), document.config) is mixed + assert renders.original((first,), document.config) is None + assert renders.original((second, first), document.config) is None + + def test_two_documents_holding_the_recordings_read_one_mix( + self, renders: RenderCache, document: Reconstruction + ) -> None: + first, second = self._recordings() + mixed = mix([first, second]) + other = document.model_copy() + renders.adopt_original((first, second), document.config, mixed) + + assert renders.original((first, second), other.config) is mixed + + def test_the_kept_mix_is_read_only(self, renders: RenderCache, document: Reconstruction) -> None: + first, second = self._recordings() + renders.adopt_original((first, second), document.config, mix([first, second])) + + kept = renders.original((first, second), document.config) + + assert kept is not None + assert not kept.flags.writeable + + def test_a_recording_let_go_of_takes_the_mix_with_it(self, renders: RenderCache, document: Reconstruction) -> None: + first, second = self._recordings() + renders.adopt_original((first, second), document.config, mix([first, second])) + assert renders.held_bytes > 0 + + del first + gc.collect() + + assert renders.held_bytes == 0 + assert renders.original((second,), document.config) is None + + class TestAdoptingRendersMadeElsewhere: """``held`` says what the cache holds for a document's channels, which is what a rebuild takes with it. diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index d8d183aa4..1da929a35 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,6 +14,7 @@ ServiceSuccess, ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.audio import mix from sampletones_core.configs import Config from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -30,12 +31,13 @@ ) 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 +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, everything_heard, taking_turns __all__ = ["taking_turns"] REFERENCE_PITCH: Final[int] = 60 NO_RENDERS: Final[Dict[ChannelName, np.ndarray]] = {} +NO_RECORDINGS: Final[Dict[int, np.ndarray]] = {} MockReconstruction: TypeAlias = MagicMock SynthesisMocks: TypeAlias = SimpleNamespace @@ -112,6 +114,7 @@ def test_start_runs_the_rebuild_on_the_worker( features, hearing=HEARD_WHOLE, kept=NO_RENDERS, + recordings=NO_RECORDINGS, ) assert len(results) == 1 @@ -130,7 +133,14 @@ def test_a_job_started_as_the_last_one_winds_down_still_runs( """ service = RegenerationService() with patch.object(service._executor, "execute") as execute: - service.start(reconstruction, synthesis_mocks.channel_name, features, hearing=HEARD_WHOLE, kept=NO_RENDERS) + service.start( + reconstruction, + synthesis_mocks.channel_name, + features, + hearing=HEARD_WHOLE, + kept=NO_RENDERS, + recordings=NO_RECORDINGS, + ) assert execute.call_args.kwargs == {"wait": True} @@ -146,7 +156,9 @@ def test_run_success_emits_service_success( results: List[Any] = [] service.subscribe(results.append) - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) assert len(results) == 1 assert isinstance(results[0], ServiceSuccess) @@ -163,7 +175,9 @@ def test_run_regenerates_from_the_envelopes_it_is_handed( """The caller writes the edit into the envelopes, so the service renders what it is given.""" service = RegenerationService() - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) _, _, initial_pitch, held = reconstruction.with_channel_data.call_args.args assert initial_pitch == features.initial_pitch @@ -177,7 +191,9 @@ def test_run_asks_the_document_for_its_edited_channel( ) -> None: service = RegenerationService() - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) reconstruction.with_channel_data.assert_called_once() call_args = reconstruction.with_channel_data.call_args @@ -196,7 +212,9 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( """ service = RegenerationService() - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) call_args = reconstruction.with_channel_data.call_args assert call_args.args[2] == REFERENCE_PITCH @@ -211,7 +229,9 @@ def test_run_carries_a_moved_reference_pitch( moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12}) service = RegenerationService() - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, moved, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, moved, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) _, _, initial_pitch, _ = reconstruction.with_channel_data.call_args.args assert initial_pitch == REFERENCE_PITCH + 12 @@ -227,7 +247,9 @@ def test_run_hands_on_every_instruction_the_envelopes_describe( synthesis_mocks.exporter.from_features.return_value = stream service = RegenerationService() - service._run(RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob(reconstruction, synthesis_mocks.channel_name, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS) + ) call_args = reconstruction.with_channel_data.call_args assert call_args.args[1] == stream @@ -248,7 +270,11 @@ def test_run_exception_emits_service_error( "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): - service._run(RebuildJob(reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob( + reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS + ) + ) assert len(results) == 1 result = results[0] @@ -267,7 +293,11 @@ def test_run_exception_does_not_update_reconstruction( "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): - service._run(RebuildJob(reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS)) + service._run( + RebuildJob( + reconstruction, ChannelName.PULSE1, cast(Features, {}), HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS + ) + ) reconstruction.with_channel_data.assert_not_called() @@ -289,7 +319,7 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: results: List[Any] = [] service.subscribe(results.append) - service._run(RebuildJob(reconstruction, ChannelName.PULSE1, features, HEARD_WHOLE, NO_RENDERS)) + service._run(RebuildJob(reconstruction, ChannelName.PULSE1, features, HEARD_WHOLE, NO_RENDERS, NO_RECORDINGS)) assert isinstance(results[0], ServiceSuccess) regenerated: Reconstruction = results[0].value.reconstruction @@ -374,6 +404,7 @@ def _rebuilt(reconstruction: Reconstruction, kept: Dict[ChannelName, np.ndarray] reconstruction.export()[SHARED_CHANNEL], _heard_whole(reconstruction), kept, + NO_RECORDINGS, ), ) @@ -382,7 +413,7 @@ def _listened( reconstruction: Reconstruction, hearing: Hearing, kept: Dict[ChannelName, np.ndarray] ) -> RenderedDocument: """What the service reports once it renders ``hearing`` of ``reconstruction`` as it stands.""" - return _reported(RegenerationService(), ListeningJob(reconstruction, hearing, kept)) + return _reported(RegenerationService(), ListeningJob(reconstruction, hearing, kept, NO_RECORDINGS)) class TestWhatTheRebuildSounds: @@ -457,7 +488,7 @@ def test_an_edit_writing_frames_of_the_readers_own_hears_them(self, taking_turns outcome = _reported( RegenerationService(), - RebuildJob(taking_turns, SHARED_CHANNEL, longer, _heard_whole(taking_turns), NO_RENDERS), + RebuildJob(taking_turns, SHARED_CHANNEL, longer, _heard_whole(taking_turns), NO_RENDERS, NO_RECORDINGS), ) assert AUTHORED_STEM_ID in outcome.hearing.selection.stems_for(SHARED_CHANNEL) @@ -503,6 +534,79 @@ def test_a_recording_left_out_is_heard_as_the_core_hears_it(self, taking_turns: np.testing.assert_array_equal(outcome.heard.playing, expected.playing) +class TestWhatTheOriginalSounds: + """The result carries the recordings heard anywhere mixed into one waveform, in record order, and none + where no recording is heard or none loaded. + """ + + @staticmethod + def _recordings(taking_turns: Reconstruction) -> Dict[int, np.ndarray]: + length = len(rendered_mix(taking_turns)) + return { + STEM_A_ID: np.full(length, 0.5, dtype=np.float32), + STEM_B_ID: np.full(length, 0.25, dtype=np.float32), + } + + def test_the_recordings_heard_anywhere_are_mixed_in_record_order(self, taking_turns: Reconstruction) -> None: + recordings = self._recordings(taking_turns) + + outcome = _reported( + RegenerationService(), + ListeningJob(taking_turns, _heard_whole(taking_turns), NO_RENDERS, recordings), + ) + + assert outcome.original is not None + assert [recording is held for recording, held in zip(outcome.original.recordings, recordings.values())] + assert len(outcome.original.recordings) == len(recordings) + np.testing.assert_allclose(outcome.original.audio, mix(list(recordings.values()))) + + def test_a_recording_left_out_everywhere_stays_out_of_the_mix(self, taking_turns: Reconstruction) -> None: + recordings = self._recordings(taking_turns) + hearing = Hearing( + selection=StemSelection.everywhere(frozenset({STEM_B_ID}), ChannelName.items()), + channels_on=EVERY_CHANNEL, + ) + + outcome = _reported(RegenerationService(), ListeningJob(taking_turns, hearing, NO_RENDERS, recordings)) + + assert outcome.original is not None + assert outcome.original.recordings == (recordings[STEM_B_ID],) + np.testing.assert_allclose(outcome.original.audio, mix([recordings[STEM_B_ID]])) + + def test_no_recording_heard_mixes_into_none(self, taking_turns: Reconstruction) -> None: + hearing = Hearing(selection=StemSelection(channels={}), channels_on=EVERY_CHANNEL) + + outcome = _reported( + RegenerationService(), + ListeningJob(taking_turns, hearing, NO_RENDERS, self._recordings(taking_turns)), + ) + + assert outcome.original is None + + def test_no_recording_loaded_mixes_into_none(self, taking_turns: Reconstruction) -> None: + outcome = _listened(taking_turns, _heard_whole(taking_turns), NO_RENDERS) + + assert outcome.original is None + + def test_a_rebuild_mixes_the_recordings_its_hearing_hears(self, taking_turns: Reconstruction) -> None: + recordings = self._recordings(taking_turns) + + outcome = _reported( + RegenerationService(), + RebuildJob( + taking_turns, + SHARED_CHANNEL, + taking_turns.export()[SHARED_CHANNEL], + _heard_whole(taking_turns), + NO_RENDERS, + recordings, + ), + ) + + assert outcome.original is not None + np.testing.assert_allclose(outcome.original.audio, mix(list(recordings.values()))) + + class TestAShutdownMidRebuild: """A shutdown asked for while a rebuild runs ends the job with no render and no report. @@ -536,7 +640,7 @@ def rendering_then_shutting_down( results: List[Any] = [] service.subscribe(results.append) - service._run(ListeningJob(taking_turns, _heard_whole(taking_turns), NO_RENDERS)) + service._run(ListeningJob(taking_turns, _heard_whole(taking_turns), NO_RENDERS, NO_RECORDINGS)) assert len(rendered) == 1 assert results == [] @@ -559,6 +663,7 @@ def test_the_job_renders_nothing_and_reports_nothing( taking_turns.export()[SHARED_CHANNEL], _heard_whole(taking_turns), NO_RENDERS, + NO_RECORDINGS, ) ) From e1bc3512b891d89cb6e9b71caa46130db8e8682e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 01:05:34 +0200 Subject: [PATCH 30/37] Witnessed: the fade and the edit on screen, the landing's cost in the benchmark alone --- automation/steps/reconstructions.py | 48 ++++++++- automation/vocabulary/graphs.py | 1 - automation/vocabulary/playback.py | 1 + .../closing/test_during_a_long_rebuild.py | 23 +++-- .../prompts/closing/test_during_an_edit.py | 10 +- .../every_door/test_edited_reconstruction.py | 13 +-- .../prompts/modals/test_held_rebuild.py | 18 +--- .../edits/test_gestures_at_once.py | 9 +- .../edits/test_long_document_drag.py | 98 +++++++++++++------ .../stems/test_long_document_boxes.py | 22 +++-- .../screens/sequencer/voices/test_previews.py | 4 +- .../sequencer/voices/test_voices_card.py | 3 +- tests/suite/screens/readings.py | 15 --- .../logic/shared/test_renders.py | 5 - .../reconstruction/test_stems_hearing.py | 21 +++- 15 files changed, 166 insertions(+), 125 deletions(-) diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py index 8e04489d9..ca221945d 100644 --- a/automation/steps/reconstructions.py +++ b/automation/steps/reconstructions.py @@ -1,11 +1,13 @@ import operator from functools import partial from pathlib import Path -from typing import Final, List, Optional +from typing import Callable, Final, List, Optional, Tuple +from automation.dearpygui.items.colors import read_theme from automation.dearpygui.items.types import Item from automation.holds.regeneration import RegenerationHold from automation.screen import Screen +from automation.views.bar_graph import BarGraph from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId @@ -86,13 +88,28 @@ def expect_open(screen: Screen, path: Path) -> None: screen.expect(screen.sound_settled, bool, description=f"the sound of {path.name} landed") -def reconstruction_shade(screen: Screen) -> Optional[str]: - """The theme the waveform's reconstruction line wears, which the fade swaps while its sound is on its way.""" +def reconstruction_shade(screen: Screen) -> str: + """The theme the waveform's reconstruction line wears, which the fade swaps while its sound is on its way. + + Raises: + AssertionError: If the line wears no theme, which would leave a fade nothing to swap. + """ waveform = screen.reconstructions.waveform - return screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))) + shade = screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))) + assert shade is not None, "the reconstruction line wears no theme" + return shade + + +def shade_reading(screen: Screen) -> Callable[[], Optional[str]]: + """A reading of the shade the reconstruction line wears, for a frame recording to take after every frame. + + The reading runs on the render thread, so it reads the theme directly. + """ + tag = screen.reconstructions.waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION)) + return partial(read_theme, tag) -def wait_for_the_fade_to_end(screen: Screen, unfaded: Optional[str]) -> None: +def wait_for_the_fade_to_end(screen: Screen, unfaded: str) -> None: """Waits until the reconstruction line wears ``unfaded`` again, which is when the last sound has landed.""" screen.bridge.expect( lambda: reconstruction_shade(screen), @@ -102,6 +119,27 @@ def wait_for_the_fade_to_end(screen: Screen, unfaded: Optional[str]) -> None: ) +def drag_item_to( + graph: BarGraph, + standing: List[int], + item: int, + *, + end: float, + bounds: Tuple[int, int], +) -> Tuple[int, List[int]]: + """Drags the bar of ``item`` to ``end`` and returns the item the plot moved and the items expected after. + + The item the plot puts under the release takes the value there, brought within ``bounds``, and + every other item stands as it did. + """ + release = graph.drag_item(item, start=standing[item], end=end) + index, value = graph.item_under(release) + low, high = bounds + expected = [*standing] + expected[index] = min(max(round(value), low), high) + return index, expected + + def edit_envelope( screen: Screen, *, diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py index e56189bb1..5fb5726a9 100644 --- a/automation/vocabulary/graphs.py +++ b/automation/vocabulary/graphs.py @@ -1,5 +1,4 @@ from typing import Final -WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating" WAVEFORM_READING: Final[str] = "global.graph.message.waveform_reading" WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/automation/vocabulary/playback.py b/automation/vocabulary/playback.py index 7361bedc0..5434e193c 100644 --- a/automation/vocabulary/playback.py +++ b/automation/vocabulary/playback.py @@ -2,4 +2,5 @@ PAUSE: Final[str] = "global.menu.label.item_playback_pause" PLAY: Final[str] = "global.menu.label.item_playback_play" +PLAY_VOICE: Final[str] = "global.context.label.play" RESUME: Final[str] = "global.menu.label.item_playback_resume" diff --git a/tests/screens/application/closing/test_during_a_long_rebuild.py b/tests/screens/application/closing/test_during_a_long_rebuild.py index 6131284e6..0f2b0f3cf 100644 --- a/tests/screens/application/closing/test_during_a_long_rebuild.py +++ b/tests/screens/application/closing/test_during_a_long_rebuild.py @@ -1,5 +1,5 @@ import os -from typing import Final +from typing import Final, List, Tuple import pytest @@ -7,13 +7,14 @@ from automation.environment import repeats from automation.holds.regeneration import RegenerationHold from automation.screen import Screen -from automation.steps.reconstructions import expect_open +from automation.steps.reconstructions import LOUDEST, drag_item_to, expect_open from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world DRAGGED_ITEM: Final[int] = 3 QUIET: Final[float] = 2.0 +VOLUME_BOUNDS: Final[Tuple[int, int]] = (0, LOUDEST) NEAR_THE_START: Final[float] = 0.05 ZOOM_NOTCHES: Final[int] = 5 REPEATS: Final[int] = repeats(os.environ) @@ -24,8 +25,9 @@ class TestClosingWhileALongRebuildRuns: A volume bar is dragged while the rebuild is held, so the rebuild is released and the close asked for in one breath, and the close meets the rebuild on its worker. The application waits for the - edit, asks about the unsaved document, and leaves on the answer. The scenario runs as many times - as ``SAMPLETONES_SCREENS_REPEATS`` says, which is how a change to the worker's work is held to a + edit, asks about the unsaved document once the edit has landed and the bar reads as the drag + left it, and leaves on the answer. The scenario runs as many times as + ``SAMPLETONES_SCREENS_REPEATS`` says, which is how a change to the worker's work is held to a clean exit over and over. """ @@ -50,6 +52,7 @@ def test_the_close_waits_for_the_rebuild_and_leaves( prompt = screen.reconstructions.unsaved_prompt instruments = screen.reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] def drag_while_the_rebuild_is_held(screen: Screen) -> None: expect_open(screen, LONG_RECONSTRUCTION) @@ -58,7 +61,7 @@ def drag_while_the_rebuild_is_held(screen: Screen) -> None: graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) - graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=QUIET) + moved.append(drag_item_to(graph, standing, DRAGGED_ITEM, end=QUIET, bounds=VOLUME_BOUNDS)) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") @@ -66,9 +69,14 @@ def release_and_close_at_once(screen: Screen) -> None: regeneration_hold.release() screen.close_window() - def the_question_comes_and_the_application_leaves(screen: Screen) -> None: + def the_question_comes_once_the_edit_has_landed(screen: Screen) -> None: screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction") + assert screen.sound_settled() + index, expected = moved[0] + assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[index] == expected[index] + + def the_application_leaves_on_the_answer(screen: Screen) -> None: prompt.confirm() assert screen.wait_for_exit() @@ -76,5 +84,6 @@ def the_question_comes_and_the_application_leaves(screen: Screen) -> None: screen.scenario( drag_while_the_rebuild_is_held, release_and_close_at_once, - the_question_comes_and_the_application_leaves, + the_question_comes_once_the_edit_has_landed, + the_application_leaves_on_the_answer, ).run() diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py index 1e50146a6..991d062ce 100644 --- a/tests/screens/prompts/closing/test_during_an_edit.py +++ b/tests/screens/prompts/closing/test_during_an_edit.py @@ -9,11 +9,11 @@ from automation.steps.reconstructions import ( expect_open, first_raised, + leave_letting_it_go, marked, raise_the_first_level_while_held, titled, ) -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.screens.prompts.closing.constants import SETTLING_FRAMES from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION @@ -110,14 +110,6 @@ def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: ) assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving again") - - prompt.confirm() - - assert screen.wait_for_exit() - screen.scenario( edit_and_close_while_the_rebuild_is_held, edit_again_after_the_close, diff --git a/tests/screens/prompts/every_door/test_edited_reconstruction.py b/tests/screens/prompts/every_door/test_edited_reconstruction.py index adfa36ff0..c1c083246 100644 --- a/tests/screens/prompts/every_door/test_edited_reconstruction.py +++ b/tests/screens/prompts/every_door/test_edited_reconstruction.py @@ -7,6 +7,7 @@ from automation.screen import Screen from automation.steps.reconstructions import ( expect_open, + leave_letting_it_go, marked, raise_the_first_level, remove_from_the_browser, @@ -25,7 +26,6 @@ LOAD_TITLE, SAVE, ) -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.screens.prompts.every_door.cases import ASKING_DOORS, Door, Question, Standing from tests.screens.prompts.every_door.constants import EXIT, SETTLING_FRAMES @@ -107,17 +107,6 @@ def step(screen: Screen) -> None: return step -def leave_letting_it_go(screen: Screen) -> None: - """Exits and answers Exit to the question about the reconstruction.""" - prompt = screen.reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") - - prompt.confirm() - - assert screen.wait_for_exit() - - class TestAnEditedReconstructionAtEveryDoor: """Every door asks about an edited reconstruction in its own words, and Cancel keeps everything as it stood. diff --git a/tests/screens/prompts/modals/test_held_rebuild.py b/tests/screens/prompts/modals/test_held_rebuild.py index fd091020f..c2f2555da 100644 --- a/tests/screens/prompts/modals/test_held_rebuild.py +++ b/tests/screens/prompts/modals/test_held_rebuild.py @@ -8,6 +8,7 @@ from automation.screen import Screen from automation.steps.reconstructions import ( expect_open, + leave_letting_it_go, load_from_the_browser, marked, raise_the_first_level_while_held, @@ -15,7 +16,6 @@ titled, ) from automation.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.screens.prompts.modals.constants import SETTLING_FRAMES from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION @@ -86,14 +86,6 @@ def nothing_asks_after_them(screen: Screen) -> None: assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - screen.scenario( edit_while_the_rebuild_is_held, load_and_close_while_it_is_held, @@ -169,14 +161,6 @@ def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: assert screen.shown_windows() == () assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - screen.scenario( edit_a_removed_reconstruction_while_the_rebuild_is_held, close_while_it_is_held, diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py index aafd16c6c..7abf7cd02 100644 --- a/tests/screens/reconstructions/edits/test_gestures_at_once.py +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -5,11 +5,11 @@ import pytest from automation.application.startup import Startup -from automation.dearpygui.geometry import Point from automation.holds.regeneration import RegenerationHold from automation.screen import Screen from automation.steps.project import save_project_as from automation.steps.reconstructions import ( + drag_item_to, expect_open, leave_letting_it_go, load_from_the_browser, @@ -65,12 +65,7 @@ def dragged( The item the plot puts under the release takes the value there, brought within ``bounds``, and every other item stands as it did. """ - release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end) - index, value = graph.item_under(release) - low, high = bounds - expected = [*standing] - expected[index] = min(max(round(value), low), high) - return index, expected + return drag_item_to(graph, standing, DRAGGED_ITEM, end=end, bounds=bounds) def expect_items( diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py index abcde7222..f266b5251 100644 --- a/tests/screens/reconstructions/edits/test_long_document_drag.py +++ b/tests/screens/reconstructions/edits/test_long_document_drag.py @@ -1,5 +1,5 @@ import time -from typing import Final, List, Optional, Tuple +from typing import Final, List, Tuple import pytest @@ -7,21 +7,25 @@ from automation.holds.regeneration import RegenerationHold from automation.screen import Screen from automation.steps.reconstructions import ( + LOUDEST, + drag_item_to, expect_open, leave_letting_it_go, reconstruction_shade, + shade_reading, wait_for_the_fade_to_end, ) from automation.views.bar_graph import BarGraph from automation.views.instruments import Instruments from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings +from tests.suite.screens.readings import frame_readings, keep_readings from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world DRAGGED_ITEMS: Final[Tuple[int, ...]] = (2, 4, 6, 8) QUIET: Final[float] = 2.0 LOUD: Final[float] = 13.0 +VOLUME_BOUNDS: Final[Tuple[int, int]] = (0, LOUDEST) NEAR_THE_START: Final[float] = 0.05 ZOOM_NOTCHES: Final[int] = 5 FRAMES_AFTER_THE_LANDING: Final[int] = 3 @@ -30,21 +34,41 @@ LANDING_READINGS: Final[str] = "frame_gaps_landing.json" -def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> None: - """Opens the long document's first pulse volume graph wide enough to drag its first bars.""" +def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> str: + """Opens the long document's first pulse volume graph wide enough to drag its first bars. + + Returns: + str: The shade the reconstruction line wears at rest, which the fade swaps. + """ expect_open(screen, LONG_RECONSTRUCTION) instruments.bring_forward(ChannelName.PULSE1) screen.hand.scroll_into_view(graph.plot) graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + return reconstruction_shade(screen) + +def _drag_the_bars(instruments: Instruments, graph: BarGraph, releases: List[float]) -> List[int]: + """Drags each of :data:`DRAGGED_ITEMS` once, quiet and loud in turn, noting the clock at each release. -def _drag_the_bars(instruments: Instruments, graph: BarGraph, releases: List[float]) -> None: - """Drags each of :data:`DRAGGED_ITEMS` once, quiet and loud in turn, noting the clock at each release.""" - standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) + Returns: + List[int]: The volume items expected once every drag has landed. + """ + expected = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) for turn, item in enumerate(DRAGGED_ITEMS): - graph.drag_item(item, start=standing[item], end=QUIET if turn % 2 == 0 else LOUD) + _, expected = drag_item_to(graph, expected, item, end=QUIET if turn % 2 == 0 else LOUD, bounds=VOLUME_BOUNDS) releases.append(time.monotonic()) + return expected + + +def _expect_the_drags_drawn(screen: Screen, instruments: Instruments, expected: List[int]) -> None: + """Waits until the volume items read as the drags left them.""" + screen.expect( + lambda: instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME), + expected.__eq__, + description="the drags drawn", + ) + @pytest.fixture(name="world") def world_fixture() -> World: @@ -64,34 +88,37 @@ class TestADragOnALongDocument: The scenario records the clock after every frame through a burst of drags and until the waveform's fade ends, then keeps the longest gap between two frames, how many gaps ran over a tenth of a second, the frames drawn a second, and how long the line took to empty after the - last release, beside the scenario's other files. It holds the application to nothing: the - readings are what a change to the edit path is judged by, taken before and after the change. - The one promise is relative: a landing holds a frame for a few of the machine's own frames at most. + last release, beside the scenario's other files. The readings are what a change to the edit + path is judged by, taken before and after the change. What the scenario holds the application + to are the events: the fade is seen on some frame through the burst, ends once the rebuilds + land, and the bars read as the drags left them. """ def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None: - """The readings are kept; the application is only asked to keep drawing and to leave cleanly.""" + """The readings are kept; the fade begins and ends, the drags land, and the application leaves cleanly.""" instruments = screen.reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] releases: List[float] = [] - unfaded: List[Optional[str]] = [] + unfaded: List[str] = [] + expected: List[int] = [] def open_and_zoom_in(screen: Screen) -> None: - _zoom_in_on(screen, instruments, graph) - unfaded.append(reconstruction_shade(screen)) + unfaded.append(_zoom_in_on(screen, instruments, graph)) def drag_until_the_fade_ends(screen: Screen) -> None: - with screen.record(time.monotonic) as recording: - _drag_the_bars(instruments, graph, releases) + shade = shade_reading(screen) + with screen.record(lambda: (time.monotonic(), shade())) as recording: + expected.extend(_drag_the_bars(instruments, graph, releases)) wait_for_the_fade_to_end(screen, unfaded[0]) - stamps.extend(recording.values()) + readings = recording.values() + stamps.extend(stamp for stamp, _ in readings) + assert any(seen != unfaded[0] for _, seen in readings), "the fade was never seen through the burst" + _expect_the_drags_drawn(screen, instruments, expected) def keep_the_readings(screen: Screen) -> None: - readings = frame_readings(stamps, releases[-1]) - keep_readings(BURST_READINGS, readings) - expect_no_long_hold(readings) + keep_readings(BURST_READINGS, frame_readings(stamps, releases[-1])) screen.scenario(open_and_zoom_in, drag_until_the_fade_ends, keep_the_readings, leave_letting_it_go).run() @@ -101,7 +128,8 @@ class TestADragWhileTheRebuildIsHeld: edit lands while the bars move. The instruments panel draws each change as the pointer moves, so what the frames cost here is - the panel's own drawing of a long document's envelopes, apart from any landing. + the panel's own drawing of a long document's envelopes, apart from any landing. The bars read as + the drags left them before the rebuilds land and after. """ def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: @@ -110,18 +138,20 @@ def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] releases: List[float] = [] - unfaded: List[Optional[str]] = [] + unfaded: List[str] = [] + expected: List[int] = [] def open_and_zoom_in(screen: Screen) -> None: - _zoom_in_on(screen, instruments, graph) - unfaded.append(reconstruction_shade(screen)) + unfaded.append(_zoom_in_on(screen, instruments, graph)) def drag_while_held(screen: Screen) -> None: with screen.record(time.monotonic) as recording: - _drag_the_bars(instruments, graph, releases) + expected.extend(_drag_the_bars(instruments, graph, releases)) stamps.extend(recording.values()) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + assert reconstruction_shade(screen) != unfaded[0] + _expect_the_drags_drawn(screen, instruments, expected) def keep_the_readings(screen: Screen) -> None: keep_readings(HELD_BURST_READINGS, frame_readings(stamps, releases[-1])) @@ -129,6 +159,7 @@ def keep_the_readings(screen: Screen) -> None: def release_and_wait(screen: Screen) -> None: regeneration_hold.release() wait_for_the_fade_to_end(screen, unfaded[0]) + _expect_the_drags_drawn(screen, instruments, expected) screen.scenario( open_and_zoom_in, @@ -144,22 +175,24 @@ class TestOneLandingOnALongDocument: recorded from the release until the waveform's fade ends. The drag's own drawing happens before the recording starts, so the longest gap read here is the - frame the landing holds up, with what the tab draws from it. + frame the landing holds up, with what the tab draws from it. The bar reads as the drag left it + once the edit has landed. """ def test_the_frames_through_one_landing(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: - """The readings are kept; the application is only asked to land the edit and to leave cleanly.""" + """The readings are kept; the edit lands, the fade ends, and the application leaves cleanly.""" instruments = screen.reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) stamps: List[float] = [] released: List[float] = [] - unfaded: List[Optional[str]] = [] + unfaded: List[str] = [] + expected: List[int] = [] def drag_one_bar_while_held(screen: Screen) -> None: - _zoom_in_on(screen, instruments, graph) - unfaded.append(reconstruction_shade(screen)) + unfaded.append(_zoom_in_on(screen, instruments, graph)) standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) - graph.drag_item(DRAGGED_ITEMS[0], start=standing[DRAGGED_ITEMS[0]], end=QUIET) + _, items = drag_item_to(graph, standing, DRAGGED_ITEMS[0], end=QUIET, bounds=VOLUME_BOUNDS) + expected.extend(items) screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") assert reconstruction_shade(screen) != unfaded[0] @@ -171,6 +204,7 @@ def release_and_record_the_landing(screen: Screen) -> None: screen.frames(FRAMES_AFTER_THE_LANDING) stamps.extend(recording.values()) + _expect_the_drags_drawn(screen, instruments, expected) def keep_the_readings(screen: Screen) -> None: keep_readings(LANDING_READINGS, frame_readings(stamps, released[0])) diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index ebf37bcd1..d8671798c 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -1,5 +1,5 @@ import time -from typing import Dict, Final, List, Optional +from typing import Dict, Final, List import pytest @@ -10,7 +10,7 @@ from automation.vocabulary.graphs import WAVEFORM_READING from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings, longest_gap_after_each +from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world TOGGLED_CHANNEL: Final[ChannelName] = ChannelName.NOISE @@ -43,16 +43,17 @@ class TestTheBoxesOnALongDocument: The scenario records the clock after every frame through the ticks, each followed by the fade's end, and keeps the longest gap after each tick beside the readings the drag scenarios keep. The readings are what a change to the listening path is judged by, taken before and after the - change; the one promise is relative: a tick holds a frame for a few of the machine's own - frames at most. + change. What the scenario holds the application to are the events: each tick moves the box it + was aimed at, and the fade ends after each. """ def test_the_frames_through_the_ticks(self, screen: Screen) -> None: - """The readings are kept and held to the machine's frame cadence; the application leaves cleanly.""" + """The readings are kept; each box answers its tick, each fade ends, and the application leaves cleanly.""" reconstructions = screen.reconstructions + stems = reconstructions.stems stamps: List[float] = [] ticks: List[float] = [] - unfaded: List[Optional[str]] = [] + unfaded: List[str] = [] def open_it(screen: Screen) -> None: expect_open(screen, LONG_RECONSTRUCTION) @@ -61,15 +62,19 @@ def open_it(screen: Screen) -> None: def tick_the_boxes(screen: Screen) -> None: with screen.record(time.monotonic) as recording: for _ in range(TICKS_PER_BOX): + ticked = reconstructions.channel_ticked(TOGGLED_CHANNEL) ticks.append(time.monotonic()) reconstructions.tick_channel(TOGGLED_CHANNEL) wait_for_the_fade_to_end(screen, unfaded[0]) + assert reconstructions.channel_ticked(TOGGLED_CHANNEL) != ticked screen.frames(FRAMES_AFTER_A_TICK) for _ in range(TICKS_PER_BOX): + heard = stems.hears(RECORDING_KEY) ticks.append(time.monotonic()) - reconstructions.stems.tick(RECORDING_KEY) + stems.tick(RECORDING_KEY) wait_for_the_fade_to_end(screen, unfaded[0]) + assert stems.hears(RECORDING_KEY) != heard screen.frames(FRAMES_AFTER_A_TICK) stamps.extend(recording.values()) @@ -79,7 +84,6 @@ def keep_the_readings(screen: Screen) -> None: readings: Dict[str, object] = dict(frames) readings["longest_gap_after_each_tick_seconds"] = longest_gap_after_each(stamps, ticks) keep_readings(BOX_READINGS, readings) - expect_no_long_hold(frames) screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run() @@ -96,7 +100,7 @@ class TestWhatTheFadeSays: def test_a_tick_reads_as_the_sound_being_prepared(self, screen: Screen) -> None: reconstructions = screen.reconstructions reading = screen.words(WAVEFORM_READING) - unfaded: List[Optional[str]] = [] + unfaded: List[str] = [] def open_it(screen: Screen) -> None: expect_open(screen, LONG_RECONSTRUCTION) diff --git a/tests/screens/sequencer/voices/test_previews.py b/tests/screens/sequencer/voices/test_previews.py index 739f275b3..924b13d51 100644 --- a/tests/screens/sequencer/voices/test_previews.py +++ b/tests/screens/sequencer/voices/test_previews.py @@ -1,13 +1,11 @@ from functools import partial -from typing import Final from automation.dearpygui.items.types import Item from automation.screen import Screen +from automation.vocabulary.playback import PLAY_VOICE 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``.""" diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py index f0d83f8c6..cebabd0b9 100644 --- a/tests/screens/sequencer/voices/test_voices_card.py +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -11,7 +11,7 @@ from automation.steps.reconstructions import BY_CONFIGURATION from automation.steps.sequencer import leave_letting_the_project_go from automation.views.menus import MenuEntry -from automation.vocabulary.playback import PAUSE +from automation.vocabulary.playback import PAUSE, PLAY_VOICE from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.categories.hierarchy import Tab from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE @@ -28,7 +28,6 @@ "sequencer.voices.label.import_instrument", ) -PLAY_VOICE: Final[str] = "global.context.label.play" NEW_INSTRUMENT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument" NO_PROJECT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument_no_project" STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" diff --git a/tests/suite/screens/readings.py b/tests/suite/screens/readings.py index 5579908dd..deb871600 100644 --- a/tests/suite/screens/readings.py +++ b/tests/suite/screens/readings.py @@ -7,7 +7,6 @@ from automation.environment import ARTIFACTS_VARIABLE LONG_GAP_SECONDS: Final[float] = 0.1 -LONGEST_GAP_IN_MEDIAN_GAPS: Final[int] = 12 def frame_readings(stamps: List[float], last_gesture: float) -> Dict[str, float]: @@ -42,20 +41,6 @@ def longest_gap_after_each(stamps: List[float], gestures: Sequence[float]) -> Li return readings -def expect_no_long_hold(readings: Dict[str, float]) -> None: - """The longest gap between two frames stays within a number of the frames the machine draws at rest. - - The bound is read against the machine's own frame cadence through the recording, so it holds - on a slow display and a fast one alike: a landing or a tick holds a frame for a few frames' - worth and no more. - """ - bound = readings["median_gap_seconds"] * LONGEST_GAP_IN_MEDIAN_GAPS - assert readings["longest_gap_seconds"] < bound, ( - f"the longest gap {readings['longest_gap_seconds']:.3f} s against {bound:.3f} s, " - f"{LONGEST_GAP_IN_MEDIAN_GAPS} times the median gap of {readings['median_gap_seconds']:.3f} s" - ) - - def keep_readings(name: str, readings: Dict[str, object]) -> None: """Prints the readings and keeps them beside the scenario's other files.""" print(json.dumps(readings)) diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py index 069eb41b1..29b0bba90 100644 --- a/tests/unit/sampletones_application/logic/shared/test_renders.py +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -5,7 +5,6 @@ import numpy as np import pytest -from sampletones_application.logic.shared import renders as renders_module from sampletones_application.logic.shared.renders import RenderCache from sampletones_core.audio.mixing import align, mix from sampletones_core.constants.enums import ChannelName @@ -101,10 +100,6 @@ def test_equal_streams_of_two_documents_are_kept_apart(self, renders: RenderCach assert second[SOLE_CHANNEL] is not first[SOLE_CHANNEL] assert np.array_equal(second[SOLE_CHANNEL], first[SOLE_CHANNEL]) - def test_the_cache_names_no_renderer(self) -> None: - """Every render the cache keeps is a worker's result, so the module reaches no renderer.""" - assert not hasattr(renders_module, "render_instructions") - class TestTheBudget: """The cache keeps audio within its budget, letting the renders read longest ago go first. 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 2cfda45cb..10b21f046 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_hearing.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.audio.mixing import align +from sampletones_core.audio.mixing import align, mix 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 @@ -27,6 +27,7 @@ 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.sequencer import sample_reconstruction from tests.suite.stems import ( RECORDED_SCALE, SHARED_CHANNEL, @@ -299,6 +300,24 @@ def test_a_channel_switched_off_plays_the_rest(self, taking_turns) -> None: np.testing.assert_array_equal(heard.playing, align([channels[SOLE_CHANNEL]], len(mixed))[0]) assert len(heard.playing) == len(mixed) + def test_two_of_three_channels_on_are_summed_in_channel_order(self) -> None: + document = sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]) + channels = rendered_channels(document) + mixed = rendered_mix(document) + + heard = heard_renders( + document, + _hearing(document.recorded_stem_ids, frozenset({ChannelName.TRIANGLE, ChannelName.PULSE1})), + channels, + mixed, + ) + + assert heard.mix is mixed + np.testing.assert_array_equal( + heard.playing, + mix(align([channels[ChannelName.PULSE1], channels[ChannelName.TRIANGLE]], len(mixed))), + ) + def test_every_channel_switched_off_plays_silence(self, taking_turns) -> None: channels = rendered_channels(taking_turns) mixed = rendered_mix(taking_turns) From 168141bd13a6c59e96c1708fed8bd3b409d6dfb9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 01:24:33 +0200 Subject: [PATCH 31/37] Simplified: one memo in the history, required inputs, and the docs to one fact a sentence --- .../application/reconstruction-edits.md | 45 +++++------ docs/development/application/render-thread.md | 24 +++--- docs/development/application/stems.md | 2 +- docs/development/architecture.md | 4 +- .../logic/history/fingerprint.py | 80 ++++++++++--------- .../logic/history/manager.py | 5 +- .../logic/reconstruction/listening.py | 17 ++-- .../logic/reconstruction/manager.py | 18 ++++- .../logic/reconstruction/reconstruction.py | 21 +++-- .../logic/reconstruction/rewrites/queue.py | 18 +++-- .../logic/shared/previews.py | 10 +-- .../logic/shared/renders.py | 21 +++-- .../ui/elements/graphs/layers/array.py | 23 ++---- .../ui/elements/graphs/layers/decimation.py | 5 -- .../ui/elements/graphs/waveform.py | 1 - .../view_model/shared/ownership.py | 6 +- tests/suite/history/audit.py | 3 +- tests/suite/history/projects.py | 5 +- .../logic/history/test_fingerprint.py | 79 ++++++++++++------ .../logic/reconstruction/test_listening.py | 11 +++ .../ui/elements/graphs/test_decimation.py | 6 -- 21 files changed, 233 insertions(+), 171 deletions(-) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 6d7787e61..473a5b570 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -11,8 +11,8 @@ rebuild renders. The worker takes the renders the cache holds for the document w audio of every channel in play, the mix of what the reader hears, and the envelopes each channel exports for the recordings heard; the landing keeps the audio in the cache and installs the envelopes where the instruments panel reads them before the tab reads either. A change to what is heard whose sound the -cache keeps none of is a step of the same line, rendered by the same worker. Every rule below keeps the document as though each change had -landed before the next one was made. +cache keeps none of is a step of the same line, rendered by the same worker. Every rule below keeps the +document as though each change had landed before the next one was made. --- @@ -76,29 +76,26 @@ batch started is retuned again from what the sample now holds. ## A change to what is heard is a step -A box on the stems card or above the waveform changes the reader's listening, which the stems card and -the ribbon answer at once. 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 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. 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 -once the worker's reading lands, for what the reader hears then. +A box on the stems card or above the waveform changes the reader's listening. The stems card and the +ribbon answer at once. The waveform, the audio and the envelopes the instruments panel draws are read +from what the worker landed for the hearing. Where the screen holds none of it, the panel owes the showing +and asks for a listening step, which renders the hearing of the 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 (`carried_hearing`), so the sound and envelopes a landing installs +are the ones the listening then reads. One listening waiting answers every choice made before it is +taken. A freshly opened document, a removal, a retune, an undo and a save-as are shown the same way, +their sound and envelopes landed by a listening. Every gesture that reads the sound joins what the panel +owes, so a flip of the source or a retune during a showing on its way settles with it. A WAV export is a +reading of what plays like a showing: it writes the kept sound at once, or waits beside the showing and +is written for what the reader hears once the sound lands. A document put away before that, or a reading +that fails, drops the export and tells the reader so. A channel change reaching its turn while the +envelopes of what is heard are on their way waits behind a listening of that choice. ## The waveform fades while the line is busy -The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the +The waveform is faded from the moment a step waits or runs until the last one lands, so a reader sees the document is being rewritten for the whole span, however many steps it takes. The status bar names what -the fade waits for: an edit on its way reads "Regenerating reconstruction...", and a change to what is -heard, or a document just opened, reads "Preparing the sound...". The line reports the kind of work it -is busy with, so a hand-over from an edit to a reading of the sound changes the words under the same -fade. +the fade waits for: `global.graph.message.waveform_regenerating` for an edit on its way, and +`global.graph.message.waveform_reading` for a change to what is heard or a document just opened. The line +reports the kind of work it is busy with, so a hand-over from an edit to a reading of the sound changes +the words under the same fade. diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index a505ec960..60aa99b03 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -19,19 +19,17 @@ budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across fr continues. Every background result reaches UI state this way. Applying one to UI state directly from the worker thread is forbidden. -A result carries what the landing installs. The regeneration renders the edited channel's audio on its -worker, the mix of what the reader hears, the envelopes each channel exports for the recordings heard and -the mix of those recordings, which the waveform draws as the original, and hands them back inside its -result; the landing keeps the audio in the render cache and installs the 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. 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; -a reconstruction a browser tree plays is read from disk and previewed the same way. -The render cache keeps what the landings adopt and renders nothing itself, so its writers are the -landings, every one of them on the render thread, and every sound the interface draws or plays is a -worker's result. +A result carries what the landing installs. The regeneration worker renders every sound the +Reconstructions tab draws or plays: each channel's audio, the mix of what the reader hears, the mix of the +recordings heard, which the waveform draws as the original, and the envelopes each channel exports for the +recordings heard. It hands them back inside its result. The landing keeps the audio in the render cache +and installs the envelopes, on the render thread. A reading the cache leaves unanswered 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. A document previewed elsewhere 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; a +reconstruction file a browser tree plays is read from disk and previewed the same way. The render cache +keeps what the landings adopt, so its 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/application/stems.md b/docs/development/application/stems.md index 9b38d7244..b4b1ca1e6 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, 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. +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`). 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 as they are. The original mix covers the recordings of the stems heard on any channel. It is a worker's render like the rest, kept under the recordings it was mixed from, so every document holding those recordings reads one mix. 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. 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. It carries both onto a new record by the core's one rule (`carried_hearing`), which the regeneration worker applies to the document it rebuilds. 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/docs/development/architecture.md b/docs/development/architecture.md index 8e566d41d..abe568907 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -207,7 +207,9 @@ 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 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. +- 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 is 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/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index 4bb7f0329..e49529128 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -3,7 +3,6 @@ Callable, Dict, Final, - Generic, Iterable, List, Optional, @@ -57,7 +56,10 @@ def _canonical( *, fields: Optional[Set[str]] = None, ) -> str: - """The model, or the fields of it named, spelled as JSON with its keys sorted, one spelling for one state.""" + """The model, or the fields of it named, spelled as JSON with its keys sorted, one spelling for one state. + + ``fields`` is the set pydantic's ``include`` takes. + """ return dump(model.model_dump(mode="json", include=fields)) @@ -191,17 +193,17 @@ def song(self, song: Song) -> str: return hash_song(song) -class _Memo(Generic[Value]): +class _Memo: """Digests by object identity, each entry holding its object so its ``id()`` stays bound to it.""" - def __init__(self, compute: Callable[[Value], str]) -> None: - self._compute = compute - self._entries: Dict[int, Tuple[Value, str]] = {} + def __init__(self) -> None: + self._entries: Dict[int, Tuple[object, str]] = {} - def digest(self, value: Value) -> str: + def digest(self, value: Value, compute: Callable[[Value], str]) -> str: + """The digest kept for ``value``, computed by ``compute`` the first time the object is met.""" cached = self._entries.get(id(value)) if cached is None: - cached = (value, self._compute(value)) + cached = (value, compute(value)) self._entries[id(value)] = cached return cached[1] @@ -210,9 +212,6 @@ def keep(self, live: AbstractSet[int]) -> None: """Releases every entry whose object's identity is outside ``live``.""" self._entries = {key: value for key, value in self._entries.items() if key in live} - def __len__(self) -> int: - return len(self._entries) - class HashCache: """Memoizes content hashes by object identity for every value a project is made of. @@ -233,47 +232,50 @@ class HashCache: def __init__( self, *, - stream_hash: StreamHash = hash_model, - assignment_hash: AssignmentHash = hash_model, - pattern_hash: PatternHash = hash_model, + stream_hash: StreamHash, + assignment_hash: AssignmentHash, + pattern_hash: PatternHash, ) -> None: - self._streams: _Memo[InstructionsItem] = _Memo(stream_hash) - self._assignments: _Memo[ChannelAssignment] = _Memo(assignment_hash) - self._patterns: _Memo[Pattern] = _Memo(pattern_hash) - self._reconstructions: _Memo[Reconstruction] = _Memo(self._compose_reconstruction) + self._stream_hash = stream_hash + self._assignment_hash = assignment_hash + self._pattern_hash = pattern_hash + self._memo = _Memo() def reconstruction(self, reconstruction: Reconstruction) -> str: - return self._reconstructions.digest(reconstruction) + return self._memo.digest(reconstruction, self._compose_reconstruction) def song(self, song: Song) -> str: - return song_digest(song, pattern_hash=self._patterns.digest) + return song_digest(song, pattern_hash=self._pattern) def _compose_reconstruction(self, reconstruction: Reconstruction) -> str: return reconstruction_digest( reconstruction, - stream_hash=self._streams.digest, - assignment_hash=self._assignments.digest, + stream_hash=self._stream, + assignment_hash=self._assignment, ) + def _stream(self, stream: InstructionsItem) -> str: + return self._memo.digest(stream, self._stream_hash) + + def _assignment(self, assignment: ChannelAssignment) -> str: + return self._memo.digest(assignment, self._assignment_hash) + + def _pattern(self, pattern: Pattern) -> str: + return self._memo.digest(pattern, self._pattern_hash) + def prune(self, projects: Iterable[Project]) -> None: + """Keeps the entries of the objects ``projects`` still hold, one live set over every kind of part.""" retained = list(projects) reconstructions = [sample.reconstruction for project in retained for sample in samples(project.voices)] - self._reconstructions.keep({id(reconstruction) for reconstruction in reconstructions}) - self._streams.keep( - {id(stream) for reconstruction in reconstructions for stream in reconstruction.instructions_data} - ) - self._assignments.keep( - { - id(assignment) - for reconstruction in reconstructions - for assignment in reconstruction.stems_data.assignments - } + live: Set[int] = {id(reconstruction) for reconstruction in reconstructions} + live.update(id(stream) for reconstruction in reconstructions for stream in reconstruction.instructions_data) + live.update( + id(assignment) for reconstruction in reconstructions for assignment in reconstruction.stems_data.assignments ) - self._patterns.keep( - { - id(pattern) - for project in retained - for channel in project.song.channels.values() - for pattern in channel.patterns.values() - } + live.update( + id(pattern) + for project in retained + for channel in project.song.channels.values() + for pattern in channel.patterns.values() ) + self._memo.keep(live) diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index b9243a7a1..b05a455b2 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -6,6 +6,7 @@ from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin +from sampletones_shared.utils.hashing import hash_model from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError @@ -40,7 +41,9 @@ def __init__( self._controller = controller self._budget = budget self._strict = strict - self._hash_cache: Optional[HashCache] = HashCache() if strict else None + self._hash_cache: Optional[HashCache] = ( + HashCache(stream_hash=hash_model, assignment_hash=hash_model, pattern_hash=hash_model) if strict else None + ) self._entries: List[HistoryEntry] = [] self._cursor: int = -1 self._saved_cursor: Optional[int] = None diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py index b7184834a..a799758dd 100644 --- a/src/sampletones_application/logic/reconstruction/listening.py +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -4,8 +4,8 @@ from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstruction.stems.hearing import ( Hearing, - carried_channels_on, - carried_heard, + carried_hearing, + heard_by_stem, offered_channels, selection_of, ) @@ -88,11 +88,18 @@ def adopt(self, stems_data: StemsData, playing: FrozenSet[ChannelName]) -> None: playing: The channels the document plays. """ offered = offered_channels(stems_data) - self._heard = carried_heard(self._heard, self._offered, offered) + hearing = carried_hearing( + self.hearing, + offered_before=self._offered, + playing_before=self._playing, + offered_after=offered, + playing_after=playing, + ) self._offered = offered - self._heard_before_solo = None - self._channels_on = carried_channels_on(self._channels_on, self._playing, playing) self._playing = playing + self._heard = heard_by_stem(hearing.selection, offered) + self._channels_on = hearing.channels_on + self._heard_before_solo = None def set_channels_on(self, channels: FrozenSet[ChannelName]) -> None: """Takes the channels the reader keeps on, as the boxes above the waveform leave them.""" diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index f588ad2bd..3dc1990c3 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -39,7 +39,11 @@ class InstalledEnvelopes: selection: StemSelection view: ChannelEnvelopesViewModel - def answers_for(self, reconstruction: Optional[Reconstruction], selection: StemSelection) -> bool: + def answers_for( + self, + reconstruction: Optional[Reconstruction], + selection: StemSelection, + ) -> bool: """Whether the envelopes were exported for ``reconstruction`` heard as ``selection``.""" return self.reconstruction is reconstruction and self.selection == selection @@ -154,14 +158,22 @@ def _adopt_reconstruction( if envelopes is not None: self._install_envelopes(reconstruction, envelopes) - def _install_envelopes(self, reconstruction: Reconstruction, envelopes: HeardEnvelopes) -> None: + def _install_envelopes( + self, + reconstruction: Reconstruction, + envelopes: HeardEnvelopes, + ) -> None: self._envelopes = InstalledEnvelopes( reconstruction=reconstruction, selection=envelopes.selection, view=envelopes_view(reconstruction, envelopes), ) - def adopt_envelopes(self, reconstruction: Reconstruction, envelopes: HeardEnvelopes) -> bool: + def adopt_envelopes( + self, + reconstruction: Reconstruction, + envelopes: HeardEnvelopes, + ) -> bool: """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document. A box on the stems card moves which recordings a channel is heard on, and the plot, the diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index c32639fdc..991923422 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -110,6 +110,12 @@ def joined(self, later: OwedShowing) -> OwedShowing: ) +class WaveformUpdate(Protocol): + """What draws an updated waveform in place, re-fitting its view to the audio's length where asked.""" + + def __call__(self, waveform: WaveformData, *, refit: bool) -> None: ... + + class ExportServiceProtocol(Protocol): """The slice of the export service the reconstruction panel logic drives. @@ -158,7 +164,7 @@ def __init__( self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None self.on_waveform_load_changed: Optional[Callable[[WaveformData], None]] = None - self.on_waveform_update_changed: Optional[Callable[..., None]] = None + self.on_waveform_update_changed: Optional[WaveformUpdate] = None self.on_renders_wanted: Optional[VoidCallback] = None self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None @@ -195,8 +201,7 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: """Re-answers every reading of the document after an edit. ``refit_waveform`` names an edit that moved the audio's own length, such as a retune, - so the waveform's view is re-fitted to the new span rather than held at a position the - old length no longer answers to. + so the waveform's view is re-fitted to the new span. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -230,7 +235,7 @@ def redraw_listening(self) -> None: self._show(reconstruction_data, owed) def abandon_showing(self) -> None: - """Lets go of what the reader is owed where the worker will never render it, keeping the waveform as it + """Lets go of what the reader is owed where no render is on its way for it, keeping the waveform as it stands; an export that waited is reported dropped. """ self._forget_owed() @@ -247,7 +252,11 @@ def _loading() -> OwedShowing: """A showing loading the waveform afresh and playing it, which a change to what is heard asks for.""" return OwedShowing(load=True, update=False, refit=False, play=True, export_to=None) - def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: + def _show( + self, + reconstruction_data: ReconstructionData, + showing: OwedShowing, + ) -> None: """Settles ``showing`` with what is owed already, or owes the whole where the screen holds no sound for it yet and asks the worker for it. @@ -474,7 +483,7 @@ def _build_stems_view_model( """The recorded assignment as the stems list draws it, banded by the picking levels. Each row reads as the recording the document remembers, so one whose file this machine - no longer names — a sample embedded in a project — keeps its name, its boxes and its + names nowhere — a sample embedded in a project — keeps its name, its boxes and its place among the levels. A document whose record names its recordings nowhere shows the card's empty state. """ diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index f80d66a33..05a757246 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -79,7 +79,7 @@ class ReconstructionRewrites(CallbackMixin): moved, a recording taken out, a new NES frequency, and a gesture that reads or puts away the whole document. So does a change to what the reader hears whose sound the screen holds no audio for yet, which the worker renders from the document as it then stands. Each step reads - the document the step before it left, so one edit never overwrites another, a rebuild always + the document the step before it left, so each edit stands on the one before it, a rebuild starts from what stands, and a listening sounds the document the edits before it made. Channel changes wait while a job runs, and a change joins the one waiting at the end of the @@ -138,7 +138,7 @@ def is_busy(self) -> bool: return self._running is not None or bool(self._waiting) def request(self, rewrite: Rewrite) -> None: - """Puts a step at the end of the line, and takes it at once where nothing runs before it. + """Puts a step at the end of the line, and takes it at once where the line stands idle. A channel change joins a waiting change of the same channel, a new rate replaces a waiting one, and one listening waiting stands for every one asked for after it, so what @@ -161,7 +161,7 @@ def drop(self) -> None: """Lets go of the edits meant for a document an outside replacement puts away. The channel changes, removals, rates and listenings waiting leave the line, and the running - job's result no longer lands. A whole-document gesture the reader asked for keeps its place, so a + job's result is let go. A whole-document gesture the reader asked for keeps its place, so a save, a load or an undo still happens, in order, on the document now open. The replacement draws the document it puts in place, which answers a redraw still owed. """ @@ -176,7 +176,7 @@ def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewMod """The document's envelopes with every change still on its way written over them, in order. The instruments panel draws these, so what the reader sees is what the document will hold - once the line empties. A rebuild whose document was put away writes nothing. + once the line empties. A rebuild whose document was put away leaves the envelopes as drawn. Args: envelopes: The envelopes the open document holds. @@ -372,7 +372,11 @@ def _on_result(self, result: RegenerationResult) -> None: self._advance() self._settle() - def _land(self, running: Running, outcome: RenderedDocument) -> None: + def _land( + self, + running: Running, + outcome: RenderedDocument, + ) -> None: """Keeps the renders the job made and reports it, where its document is still open.""" if not running.lands_on(self._manager.reconstruction): logger.info("A job finished for a document put away since") @@ -399,8 +403,8 @@ def _land(self, running: Running, outcome: RenderedDocument) -> None: def _let_go(self, running: Running) -> None: """Redraws the panel where it still draws a change that will never land. - A rebuild an outside replacement dropped needs nothing, since the replacement drew the - document it put in place, and a listening drew nothing ahead of its sound. + A rebuild an outside replacement dropped is answered by the replacement, which drew the + document it put in place, and a listening draws only once its sound lands. """ if isinstance(running, Rebuild) and running.base is not None: self.call(self.on_dropped) diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py index 616ee5b18..cecf4a872 100644 --- a/src/sampletones_application/logic/shared/previews.py +++ b/src/sampletones_application/logic/shared/previews.py @@ -42,11 +42,11 @@ class DocumentPreviews(CallbackMixin): 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 + way, whose landing then fills the cache alone, 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. A landing sounds the + result's own audio and pursues the next wanted document from there. 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. diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index fb3e8ba75..04df68558 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -88,10 +88,10 @@ class RenderCache: audio alone. A landing is kept whole: the renders one landing keeps are spared by its own eviction, so a document larger than the budget stands until the next landing, and a reading taken right after a landing answers. A hearing with no channel on plays silence as long as - the document's mix, answered at the read and kept nowhere, since it names no part. The cache - renders nothing: every render it keeps is a worker's result, adopted at its landing on the - render thread, and a reading the cache leaves unanswered is what a worker is asked for, so the - render thread computes no sample. The render thread reads the cache and keeps what it adopts, + the document's mix, answered at the read and kept nowhere, since it names no part. Every + render the cache keeps is a worker's result, adopted at its landing on the render thread, and + a reading the cache leaves unanswered is what a worker is asked for, so the render thread + computes no sample. The render thread reads the cache and keeps what it adopts, and an object's collection may drop its renders from any thread, so every change to the cache is made under one lock. The lock lets its holder in again, since a stream let go of while a keep runs on the same thread is forgotten from inside that keep. A kept render is read-only, @@ -152,7 +152,11 @@ def reading( return HeardRenders(mix=mixed, playing=playing) - def original(self, recordings: Sequence[np.ndarray], config: Config) -> Optional[np.ndarray]: + def original( + self, + recordings: Sequence[np.ndarray], + config: Config, + ) -> Optional[np.ndarray]: """The mix of ``recordings``, heard in record order, which a lookup answers at once. Returns: @@ -248,7 +252,12 @@ def _landing(self) -> Iterator[None]: if self._landing_depth == 0: self._landed.clear() - def _keep_mix(self, parts: List[_Part], config: Config, mixed: np.ndarray) -> None: + def _keep_mix( + self, + parts: List[_Part], + config: Config, + mixed: np.ndarray, + ) -> None: self._keep( self._mix_key(parts, config), mixed, diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py index 07e7136d8..0fa7e3389 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/array.py +++ b/src/sampletones_application/ui/elements/graphs/layers/array.py @@ -1,42 +1,29 @@ from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Tuple import numpy as np from sampletones_application.ui.elements.graphs.layers.layer import Layer from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_core.audio import minmax_decimate @dataclass(frozen=True) class ArrayLayer(Layer): - """A waveform drawn as a line, reduced to the points the display shows. + """A waveform drawn as a line, at the points the display reduced it to. Attributes: data: The waveform, one-dimensional. name: The name the series is labeled by. color: The color the line is drawn in. - max_display_points: How many points the display shows at most. - points: The positions and values already reduced for ``data``, where a display keeps them; - left out, the layer reduces ``data`` itself. + points: The positions and values ``data`` was reduced to for the display. """ data: np.ndarray name: str color: BaseColor - max_display_points: int - points: Optional[Tuple[np.ndarray, np.ndarray]] = None + points: Tuple[np.ndarray, np.ndarray] def __post_init__(self) -> None: - if self.points is not None: - x_data, y_data = self.points - else: - y_data = self.data.astype(np.float32, copy=False) - assert y_data.ndim == 1, "ArrayLayer data must be a 1D numpy array" - if y_data.shape[0] > self.max_display_points: - x_data, y_data = minmax_decimate(y_data, num_buckets=self.max_display_points) - else: - x_data = np.arange(y_data.shape[0], dtype=np.float32) - + x_data, y_data = self.points object.__setattr__(self, "x_data", x_data) object.__setattr__(self, "y_data", y_data) diff --git a/src/sampletones_application/ui/elements/graphs/layers/decimation.py b/src/sampletones_application/ui/elements/graphs/layers/decimation.py index 156af2d7e..cbddd7667 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/decimation.py +++ b/src/sampletones_application/ui/elements/graphs/layers/decimation.py @@ -51,11 +51,6 @@ def points(self, audio: np.ndarray) -> Points: return points - def peak(self, audio: np.ndarray) -> float: - """The farthest ``audio`` reaches from silence, read from the points it draws as.""" - _, values = self.points(audio) - return float(np.max(np.abs(values))) if values.size else 0.0 - def _reduced(self, audio: np.ndarray) -> Points: values = audio.astype(np.float32, copy=False) if values.ndim != 1: diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 1781381c0..6597ca011 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -575,7 +575,6 @@ def _array_layer( data=data, name=name, color=color, - max_display_points=self._layout.waveform.max_display_points, points=(positions, values), ) diff --git a/src/sampletones_application/view_model/shared/ownership.py b/src/sampletones_application/view_model/shared/ownership.py index bb723270f..8e92b5b38 100644 --- a/src/sampletones_application/view_model/shared/ownership.py +++ b/src/sampletones_application/view_model/shared/ownership.py @@ -90,8 +90,10 @@ class OwnershipRibbonViewModel(BaseModel, extra="forbid", frozen=True): """The recordings behind each stretch of what a reader is listening to. The ribbon reads along the same span the waveform above it draws, so a lane is stated in - frames and the frame length turns them into the samples the plot counts. A document whose - frames all answer to one recording has nothing to tell apart, so it offers no lanes. + frames and the frame length turns them into the samples the plot counts. A lane stands for + every channel the document plays and paints the stretches the recordings heard there hold, + so a channel switched off keeps its lane and stands empty, and a document answering to one + recording throughout paints each lane as one stretch. """ lanes: Tuple[OwnershipLaneViewModel, ...] diff --git a/tests/suite/history/audit.py b/tests/suite/history/audit.py index bf654438e..b783ab152 100644 --- a/tests/suite/history/audit.py +++ b/tests/suite/history/audit.py @@ -19,6 +19,7 @@ from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.hashing import hash_model Settle = Callable[[VoidCallback], None] CommitKey = Tuple[HistoryAction, Hashable] @@ -384,7 +385,7 @@ def walk(self) -> None: def check(self) -> None: self._check_shape() - hashes = HashCache() + hashes = HashCache(stream_hash=hash_model, assignment_hash=hash_model, pattern_hash=hash_model) projects = [entry.project for entry in self._history.entries] if self._controller.is_open: projects.append(self._controller.project) diff --git a/tests/suite/history/projects.py b/tests/suite/history/projects.py index 5c476d2b9..9b274b5f3 100644 --- a/tests/suite/history/projects.py +++ b/tests/suite/history/projects.py @@ -50,13 +50,12 @@ def lead_reconstruction() -> Reconstruction: def bass_reconstruction() -> Reconstruction: - """Two recordings each playing the triangle and the noise in turn.""" + """Two recordings each playing the triangle and the noise in turn, detached as a project sample is.""" reconstruction = recorded_from( sample_reconstruction([ChannelName.TRIANGLE, ChannelName.NOISE]), BASS_RECORDINGS, ) - reconstruction = reconstruction.detached() - return reconstruction + return reconstruction.detached() def pad_instrument() -> Instrument: diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index eafa36462..04c90b9fb 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -1,5 +1,6 @@ import copy -from typing import Final, FrozenSet, Generic, List, Sequence, TypeVar +from pathlib import Path +from typing import Callable, Final, FrozenSet, Generic, List, TypeVar import pytest @@ -26,6 +27,7 @@ from tests.suite.history.audit import fresh_fingerprint from tests.suite.history.perturbation import perturbed_project, project_leaves from tests.suite.history.projects import FIRST_PATTERN, SECOND_PATTERN, every_part_project +from tests.suite.stems import recorded_from from tests.unit.sampletones_application.logic.history.conftest import HistoryFactory REACHED_LABELS: Final[FrozenSet[str]] = frozenset( @@ -83,13 +85,13 @@ def _patterns(song: Song) -> List[Pattern]: ] -def _every_pattern(projects: Sequence[Project]) -> FrozenSet[int]: - return frozenset( - id(pattern) - for project in projects - for channel in project.song.channels.values() - for pattern in channel.patterns.values() - ) +def _cache( + *, + stream_hash: Callable[[InstructionsItem], str] = hash_model, + pattern_hash: Callable[[Pattern], str] = hash_model, +) -> HashCache: + """A cache hashing afresh wherever the case hands no hash of its own.""" + return HashCache(stream_hash=stream_hash, assignment_hash=hash_model, pattern_hash=pattern_hash) def _edited(reconstruction: Reconstruction) -> Reconstruction: @@ -153,6 +155,19 @@ def test_the_walk_reaches_every_kind_of_part(self) -> None: assert REACHED_LABELS <= labels + def test_a_recordings_location_reaches_a_documents_digest( + self, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + """A project's sample records no location, so the walk over a project meets none; a document of its + own spells each recording's location, which its digest tells apart. + """ + recorded = recorded_from(reconstruction_factory(), (tmp_path / "a.wav", tmp_path / "b.wav")) + moved = recorded_from(reconstruction_factory(), (tmp_path / "a.wav", tmp_path / "c.wav")) + + assert hash_reconstruction(recorded) != hash_reconstruction(moved) + def test_an_independent_copy_keeps_the_fingerprint(self) -> None: project = every_part_project() @@ -187,7 +202,7 @@ def test_streams_hashed_once_per_reconstruction_object( reconstruction_factory: ReconstructionFactory, ) -> None: counting: CountingHash[InstructionsItem] = CountingHash() - cache = HashCache(stream_hash=counting) + cache = _cache(stream_hash=counting) reconstruction = reconstruction_factory() first = cache.reconstruction(reconstruction) @@ -199,14 +214,14 @@ def test_streams_hashed_once_per_reconstruction_object( def test_the_memoized_digest_is_the_fresh_one(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() - assert HashCache().reconstruction(reconstruction) == hash_reconstruction(reconstruction) + assert _cache().reconstruction(reconstruction) == hash_reconstruction(reconstruction) def test_distinct_reconstructions_hash_independently( self, reconstruction_factory: ReconstructionFactory, ) -> None: counting: CountingHash[InstructionsItem] = CountingHash() - cache = HashCache(stream_hash=counting) + cache = _cache(stream_hash=counting) first = reconstruction_factory() second = reconstruction_factory() @@ -217,7 +232,7 @@ def test_distinct_reconstructions_hash_independently( def test_an_edit_hashes_the_one_stream_it_replaced(self, reconstruction_factory: ReconstructionFactory) -> None: counting: CountingHash[InstructionsItem] = CountingHash() - cache = HashCache(stream_hash=counting) + cache = _cache(stream_hash=counting) reconstruction = reconstruction_factory() edited = _edited(reconstruction) cache.reconstruction(reconstruction) @@ -233,7 +248,7 @@ def test_prune_drops_hashes_for_discarded_reconstructions( reconstruction_factory: ReconstructionFactory, ) -> None: counting: CountingHash[InstructionsItem] = CountingHash() - cache = HashCache(stream_hash=counting) + cache = _cache(stream_hash=counting) kept = project_controller.add_sample(reconstruction_factory(), name="kept").reconstruction discarded = reconstruction_factory() cache.reconstruction(kept) @@ -247,7 +262,7 @@ def test_prune_drops_hashes_for_discarded_reconstructions( def test_patterns_hashed_once_per_pattern_object(self) -> None: counting: CountingHash[Pattern] = CountingHash() - cache = HashCache(pattern_hash=counting) + cache = _cache(pattern_hash=counting) song = every_part_project().song first = cache.song(song) @@ -259,11 +274,11 @@ def test_patterns_hashed_once_per_pattern_object(self) -> None: def test_the_memoized_song_digest_is_the_fresh_one(self) -> None: song = every_part_project().song - assert HashCache().song(song) == hash_song(song) + assert _cache().song(song) == hash_song(song) def test_a_row_edit_hashes_the_one_pattern_it_replaced(self) -> None: counting: CountingHash[Pattern] = CountingHash() - cache = HashCache(pattern_hash=counting) + cache = _cache(pattern_hash=counting) song = every_part_project().song original = _patterns(song) cache.song(song) @@ -276,7 +291,7 @@ def test_a_row_edit_hashes_the_one_pattern_it_replaced(self) -> None: def test_a_snapshot_shares_its_patterns_with_the_song_it_copies(self) -> None: counting: CountingHash[Pattern] = CountingHash() - cache = HashCache(pattern_hash=counting) + cache = _cache(pattern_hash=counting) project = every_part_project() cache.song(project.song) @@ -285,7 +300,7 @@ def test_a_snapshot_shares_its_patterns_with_the_song_it_copies(self) -> None: def test_prune_drops_hashes_for_discarded_patterns(self) -> None: counting: CountingHash[Pattern] = CountingHash() - cache = HashCache(pattern_hash=counting) + cache = _cache(pattern_hash=counting) project = every_part_project() before = project.snapshot() replaced = project.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] @@ -392,7 +407,11 @@ def test_eviction_prunes_cache_to_retained_reconstructions( history_factory: HistoryFactory, reconstruction_factory: ReconstructionFactory, ) -> None: + """A document the history let go of is hashed afresh when met again, since the eviction pruned it.""" controller, history = history_factory(budget=2) + counting: CountingHash[InstructionsItem] = CountingHash() + cache = _cache(stream_hash=counting) + history._hash_cache = cache with history.transaction(HistoryAction.ADD_SAMPLE): sample = controller.add_sample(reconstruction_factory(), name="lead") @@ -402,17 +421,29 @@ def test_eviction_prunes_cache_to_retained_reconstructions( with history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) - assert history._hash_cache is not None - assert len(history._hash_cache._reconstructions) == 0 - assert len(history._hash_cache._streams) == 0 - assert len(history._hash_cache._assignments) == 0 + hashed = list(counting.calls) + cache.reconstruction(sample.reconstruction) + + assert counting.calls == hashed + _streams(sample.reconstruction) def test_eviction_prunes_cache_to_retained_patterns(self, history_factory: HistoryFactory) -> None: + """The patterns the retained entries hold stay memoized, and one the eviction let go of is hashed afresh.""" controller, history = history_factory(budget=2) + counting: CountingHash[Pattern] = CountingHash() + cache = _cache(pattern_hash=counting) + history._hash_cache = cache + baseline = history.entries[0].project for row_index in range(3): with history.transaction(HistoryAction.EDIT_ROW): controller.set_row(ChannelName.PULSE1, FIRST_PATTERN, row_index, volume=EDITED_ROW.volume) retained = [entry.project for entry in history.entries] + [controller.project] - assert history._hash_cache is not None - assert len(history._hash_cache._patterns) == len(_every_pattern(retained)) + assert baseline not in retained + hashed = list(counting.calls) + for project in retained: + cache.song(project.song) + assert counting.calls == hashed + + cache.song(baseline.song) + + assert counting.calls == hashed + [baseline.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN]] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py index 72dc0e469..1bf51bde5 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py @@ -278,6 +278,17 @@ def test_soloing_another_recording_keeps_the_choice_the_first_solo_replaced( assert listening.heard == before + def test_a_record_adopted_forgets_the_choice_a_solo_replaced(self, listening: Listening) -> None: + """An edit landing between two solos ends the first solo's memory, so the second hears the whole record.""" + listening.adopt(self._held(), EVERY_CHANNEL) + listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) + listening.solo(STEM_A) + + listening.adopt(self._held(), EVERY_CHANNEL) + listening.solo(STEM_A) + + assert listening.heard == listening.offered + def test_a_solo_with_nothing_remembered_hears_every_recording_whole(self, listening: Listening) -> None: listening.adopt(self._held(), EVERY_CHANNEL) listening.solo(STEM_A) diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py b/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py index 7560d4d60..bcc6e3586 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_decimation.py @@ -35,12 +35,6 @@ def test_a_short_array_draws_every_sample(self) -> None: np.testing.assert_array_equal(positions, np.arange(SHORT, dtype=np.float32)) np.testing.assert_array_equal(values, audio) - def test_the_peak_is_the_arrays_own(self) -> None: - decimations = Decimations(MAX_POINTS) - audio = _waveform(LONG, 1) - - assert decimations.peak(audio) == float(np.max(np.abs(audio))) - class TestWhatTheMemoKeeps: """A reduction is made once per array and read back by identity, and goes with the array.""" From 59b035df79fdedafd3efb149dc78ece9571bea44 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 01:43:56 +0200 Subject: [PATCH 32/37] Answered: the fresh review's findings, a change waiting behind the tick's listening and Stop forgetting a preview --- automation/steps/reconstructions.py | 5 +- docs/development/bugs-and-todos.md | 5 +- src/sampletones_application/application.py | 1 + .../coordinators/playback/guard.py | 1 + .../coordinators/playback/router.py | 10 ++- .../tabs/sequencer/coordinator.py | 7 -- .../logic/history/fingerprint.py | 6 +- .../logic/reconstruction/reconstruction.py | 66 +++++++++++++------ .../logic/reconstruction/rewrites/queue.py | 20 ++++-- .../logic/sequencer/voices.py | 10 ++- .../logic/shared/file_playback.py | 15 ++++- .../logic/shared/previews.py | 39 ++++++----- .../logic/shared/renders.py | 5 +- .../services/regeneration/jobs.py | 8 +-- .../logic/shared/test_previews.py | 8 ++- .../reconstructions/stems/test_stems_card.py | 20 +----- .../coordinators/playback/test_guard.py | 11 ++++ .../coordinators/playback/test_router.py | 14 ++++ .../coordinators/tabs/test_sequencer.py | 13 ---- .../reconstruction/rewrites/test_queue.py | 49 +++++++++++++- .../reconstruction/test_reconstruction.py | 2 +- .../logic/sequencer/test_voices.py | 13 ++++ .../logic/shared/test_file_playback.py | 16 +++++ .../logic/shared/test_previews.py | 31 +++++---- .../services/test_regeneration.py | 17 +++++ 25 files changed, 276 insertions(+), 116 deletions(-) diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py index ca221945d..167ef648b 100644 --- a/automation/steps/reconstructions.py +++ b/automation/steps/reconstructions.py @@ -109,7 +109,10 @@ def shade_reading(screen: Screen) -> Callable[[], Optional[str]]: return partial(read_theme, tag) -def wait_for_the_fade_to_end(screen: Screen, unfaded: str) -> None: +def wait_for_the_fade_to_end( + screen: Screen, + unfaded: str, +) -> None: """Waits until the reconstruction line wears ``unfaded`` again, which is when the last sound has landed.""" screen.bridge.expect( lambda: reconstruction_shade(screen), diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 980d2123a..86b0fc47a 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -122,8 +122,9 @@ currently out of line. An entry leaves when the code meets the contract again. `CaretOverlay._rectangle`. Each is a seam a scenario needs, which [screens](application/screens.md) says the application offers as a public one. * The render thread still reads a reconstruction file and its recordings from disk, at an opening and - when a browser tree plays one; every sound the interface draws or plays, the original line included, is - a worker's result ([the render thread](application/render-thread.md)). + when a browser tree plays one, and renders an instrument's audition and the sequencer's instrument + preview; every other sound the interface draws or plays, the original line included, is a worker's + result ([the render thread](application/render-thread.md)). ## Bugs diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 58857f6fb..a37c26ee7 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -629,6 +629,7 @@ def __init__( active_source_resolver=self._get_active_source, audio_device_manager=self.audio_device_manager, language_manager=self.language_manager, + previews=self.document_previews, ) self._config_coordinator = ConfigCoordinator( diff --git a/src/sampletones_application/coordinators/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py index 9592c0d50..6181ccc3a 100644 --- a/src/sampletones_application/coordinators/playback/guard.py +++ b/src/sampletones_application/coordinators/playback/guard.py @@ -35,6 +35,7 @@ def pause_or_resume(self) -> None: self.run_guarded(self._player.pause_or_resume) def stop(self) -> None: + self._previews.cancel() self._player.stop() def is_playing(self) -> bool: diff --git a/src/sampletones_application/coordinators/playback/router.py b/src/sampletones_application/coordinators/playback/router.py index ab1206d27..f625a4e97 100644 --- a/src/sampletones_application/coordinators/playback/router.py +++ b/src/sampletones_application/coordinators/playback/router.py @@ -2,6 +2,7 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol +from sampletones_application.logic.shared.previews import DocumentPreviews from sampletones_core.audio import AudioDeviceManager ActiveSourceResolver = Callable[[], Optional[AudioPlayerProtocol]] @@ -12,7 +13,8 @@ class PlaybackRouter: Every command acts on one *target*: the active tab's own source when that tab has something to play, and otherwise the intentional source currently engaged (owning the device). Preview - sounds belong to no source and answer only to Stop. The full contract is documented in + sounds belong to no source and answer only to Stop, which silences one sounding and forgets + one still on its way. The full contract is documented in ``docs/development/application/playback.md``. It is stateless: the target is recomputed from the live sources on each call, so the transport @@ -26,11 +28,13 @@ def __init__( active_source_resolver: ActiveSourceResolver, audio_device_manager: AudioDeviceManager, language_manager: LanguageManager, + previews: DocumentPreviews, ) -> None: self._language_manager = language_manager self._sources = tuple(sources) self._active_source_resolver = active_source_resolver self._audio_device_manager = audio_device_manager + self._previews = previews def play(self) -> None: """Play/Pause: pause or resume the target when engaged, otherwise start it.""" @@ -45,7 +49,8 @@ def play_from_start(self) -> None: active_source.play() def stop(self) -> None: - """Silence everything: the engaged source and any sounding preview.""" + """Silence everything: the engaged source, any sounding preview, and a preview still on its way.""" + self._previews.cancel() engaged_source = self._engaged_source() if engaged_source is not None: engaged_source.stop() @@ -60,6 +65,7 @@ def shutdown(self) -> None: stream. Teardown therefore reaches every source rather than the engaged one alone, so a source holding a stream is wound down whatever the transport reports at that moment. """ + self._previews.cancel() for source in self._sources: source.stop() diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 557b5995d..146b2e1fa 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -105,7 +105,6 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode -from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback, VoidCallback @@ -220,7 +219,6 @@ def __init__( failures=playback_failures, previews=previews, ) - previews.on_failed = self._report_preview_failure self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( self._sequencer_tracker_logic.settings, layout=layout.sequencer, @@ -834,11 +832,6 @@ def _on_player_position_changed( def _on_preview_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) - def _report_preview_failure(self, exception: Exception) -> None: - """Tells the reader a document'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_browser_recordings_requested(self, path: Path) -> None: """Hands the browser what a document names, where it has already been read.""" recordings = self._sequencer_browser_logic.recordings(path) diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index e49529128..beb9c5a84 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -199,7 +199,11 @@ class _Memo: def __init__(self) -> None: self._entries: Dict[int, Tuple[object, str]] = {} - def digest(self, value: Value, compute: Callable[[Value], str]) -> str: + def digest( + self, + value: Value, + compute: Callable[[Value], str], + ) -> str: """The digest kept for ``value``, computed by ``compute`` the first time the object is met.""" cached = self._entries.get(id(value)) if cached is None: diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 991923422..d3dde48cd 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import ( Callable, @@ -77,6 +78,13 @@ EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel.empty() +class Drawing(Enum): + """How a showing draws the waveform: loaded afresh after a listening change, or updated in place after an edit.""" + + LOAD = "load" + UPDATE = "update" + + @dataclass(frozen=True) class OwedShowing: """What the screen owes the reader of the document's sound, settled once the worker has rendered what @@ -86,15 +94,13 @@ class OwedShowing: opening still loads the waveform, and a retune followed by a flip still re-fits it. Attributes: - load: Whether the waveform is loaded afresh, as after a listening change. - update: Whether the waveform is updated in place, as after an edit. + drawing: How the waveform is drawn, or ``None`` where the showing plays or writes alone. refit: Whether the waveform's view is re-fitted to the audio's length. play: Whether the audio is handed to the player. export_to: The file what plays is written to, where an export waits for the sound. """ - load: bool - update: bool + drawing: Optional[Drawing] refit: bool play: bool export_to: Optional[Path] @@ -102,18 +108,37 @@ class OwedShowing: def joined(self, later: OwedShowing) -> OwedShowing: """What is owed once ``later`` is asked for on top of this: every showing kept, the newest export's file.""" return OwedShowing( - load=self.load or later.load, - update=self.update or later.update, + drawing=self._drawn_either(self.drawing, later.drawing), refit=self.refit or later.refit, play=self.play or later.play, export_to=later.export_to if later.export_to is not None else self.export_to, ) + @staticmethod + def _drawn_either( + first: Optional[Drawing], + second: Optional[Drawing], + ) -> Optional[Drawing]: + """The drawing covering both: a load draws everything an update would, so it outranks one.""" + drawings = {first, second} + if Drawing.LOAD in drawings: + return Drawing.LOAD + + if Drawing.UPDATE in drawings: + return Drawing.UPDATE + + return None + class WaveformUpdate(Protocol): """What draws an updated waveform in place, re-fitting its view to the audio's length where asked.""" - def __call__(self, waveform: WaveformData, *, refit: bool) -> None: ... + def __call__( + self, + waveform: WaveformData, + *, + refit: bool, + ) -> None: ... class ExportServiceProtocol(Protocol): @@ -216,8 +241,7 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: self._show( reconstruction_data, OwedShowing( - load=False, - update=True, + drawing=Drawing.UPDATE, refit=refit_waveform, play=self._current_audio_source != AudioSourceType.ORIGINAL, export_to=None, @@ -235,8 +259,8 @@ def redraw_listening(self) -> None: self._show(reconstruction_data, owed) def abandon_showing(self) -> None: - """Lets go of what the reader is owed where no render is on its way for it, keeping the waveform as it - stands; an export that waited is reported dropped. + """Lets go of what the reader is owed once the worker has given up the render it waited for, keeping the + waveform as it stands; an export that waited is reported dropped. """ self._forget_owed() @@ -250,7 +274,7 @@ def _forget_owed(self) -> None: @staticmethod def _loading() -> OwedShowing: """A showing loading the waveform afresh and playing it, which a change to what is heard asks for.""" - return OwedShowing(load=True, update=False, refit=False, play=True, export_to=None) + return OwedShowing(drawing=Drawing.LOAD, refit=False, play=True, export_to=None) def _show( self, @@ -290,10 +314,11 @@ def _settle( owed: OwedShowing, ) -> None: """Draws, plays and writes what ``owed`` names from one reading of the sound.""" - if owed.load: - self.call(self.on_waveform_load_changed, waveform) - elif owed.update: - self.call(self.on_waveform_update_changed, waveform, refit=owed.refit) + match owed.drawing: + case Drawing.LOAD: + self.call(self.on_waveform_load_changed, waveform) + case Drawing.UPDATE: + self.call(self.on_waveform_update_changed, waveform, refit=owed.refit) if owed.play: self._emit_audio_data(reconstruction_data, waveform) @@ -375,7 +400,10 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: def _play(self, reconstruction_data: ReconstructionData) -> None: """Hands the player what the source chosen plays, or owes it where the screen holds no sound yet.""" - self._show(reconstruction_data, OwedShowing(load=False, update=False, refit=False, play=True, export_to=None)) + self._show( + reconstruction_data, + OwedShowing(drawing=None, refit=False, play=True, export_to=None), + ) def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" @@ -619,7 +647,7 @@ def _heard_features(self) -> Optional[ChannelEnvelopesViewModel]: features = self._reconstruction_manager.current_features if features is None: - logger.warning("The envelopes of what is heard are still on their way from the worker") + logger.info("The envelopes of what is heard are still on their way from the worker") return features @@ -802,7 +830,7 @@ def _export( """ self._show( reconstruction_data, - OwedShowing(load=False, update=False, refit=False, play=False, export_to=filepath), + OwedShowing(drawing=None, refit=False, play=False, export_to=filepath), ) def _write_wav( diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 05a757246..852e327e4 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -29,6 +29,7 @@ ChannelEnvelopesViewModel, ) from sampletones_application.view_model.reconstruction.work import Work +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.removal import ( can_remove_stem, @@ -108,7 +109,7 @@ def __init__( self._regeneration = regeneration self._waiting: List[Rewrite] = [] self._running: Optional[Running] = None - self._awaiting_envelopes: Optional[ChannelChange] = None + self._awaiting_envelopes: Optional[ChannelName] = None self._redraw_owed: bool = False self._reported_work: Optional[Work] = None @@ -280,17 +281,24 @@ def _rebuild(self, change: ChannelChange) -> None: ) def _await_envelopes(self, change: ChannelChange) -> None: - """Puts ``change`` back at the head of the line behind a listening of the choice its envelopes are owed for. + """Puts ``change`` back in line behind a listening of the choice its envelopes are owed for. - The change waits once: where the listening lands no envelopes for it, as a failed one - does, the change is let go and the line moves on. + A listening already waiting, as a box ticked meanwhile asks for, lands them, so the change + takes its place behind it. With none waiting, the line asks for one, once per channel: a + change whose listening landed no envelopes for it, as a failed one does, is let go and the + line moves on. """ - if self._awaiting_envelopes is change: + for index, step in enumerate(self._waiting): + if isinstance(step, ListeningChange): + self._waiting.insert(index + 1, change) + return + + if self._awaiting_envelopes == change.channel_name: logger.info(f"A change of {change.channel_name} found no envelopes to rebuild from and was let go") self._awaiting_envelopes = None return - self._awaiting_envelopes = change + self._awaiting_envelopes = change.channel_name self._waiting[:0] = [ListeningChange(), change] def _listen(self) -> None: diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index c1e1c2595..ae77b7a82 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -331,10 +331,18 @@ def _play_voice( 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)) + self._previews.play( + sample.reconstruction, + partial(self._sound, priority=priority), + self._report_failed_render, + ) case Instrument() as instrument: self._sound(self._audition_audio(instrument), priority=priority) + def _report_failed_render(self, exception: Exception) -> None: + """Reports a sample's preview whose render failed the way a refused one is reported.""" + self.call(self.on_autoplay_error, exception) + def _sound( self, audio: Optional[np.ndarray], diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py index 06aebf571..e2b340668 100644 --- a/src/sampletones_application/logic/shared/file_playback.py +++ b/src/sampletones_application/logic/shared/file_playback.py @@ -22,7 +22,8 @@ class FilePlayback(CallbackMixin): from disk. The browser's tree, a row in the converter's list and a menu item all name a file the same way, so what a suffix means and what a failure to read one reports stand in one place. A device that refuses the sound reports through ``on_error`` whenever the refusal - comes, at once for a file or at the landing for a document. + comes, at once for a file or at the landing for a document, and so does a document's render + that fails. """ def __init__( @@ -75,10 +76,18 @@ def _play_reconstruction(self, path: Path, priority: PlaybackPriority) -> None: self.call(self.on_error, exception) return - self._previews.play(reconstruction, partial(self._sound, priority=priority)) + self._previews.play(reconstruction, partial(self._sound, priority=priority), self._report) - def _sound(self, audio: np.ndarray, *, priority: PlaybackPriority) -> None: + def _sound( + self, + audio: np.ndarray, + *, + priority: PlaybackPriority, + ) -> None: try: self._audio_device_manager.play(audio, update=False, priority=priority) except PlaybackError as exception: self.call(self.on_error, exception) + + def _report(self, exception: Exception) -> None: + self.call(self.on_error, exception) diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py index cecf4a872..d6fa76042 100644 --- a/src/sampletones_application/logic/shared/previews.py +++ b/src/sampletones_application/logic/shared/previews.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Callable, Dict, Final, Optional +from types import MappingProxyType +from typing import Callable, Final, Mapping, Optional import numpy as np @@ -12,27 +13,31 @@ 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 +from sampletones_shared.logger import logger Play = Callable[[np.ndarray], None] -NO_RECORDINGS: Final[Dict[int, np.ndarray]] = {} +Failed = Callable[[Exception], None] +NO_RECORDINGS: Final[Mapping[int, np.ndarray]] = MappingProxyType({}) @dataclass(frozen=True) class WantedPreview: - """A document a reader asked to hear whole, and how it sounds once its audio is in hand. + """A document a reader asked to hear whole, how it sounds once its audio is in hand, and who hears of a + render that failed. Attributes: reconstruction: The document to sound. play: What sounds the audio, which owns the device, the priority and the refusal a device answers with. + failed: What reports a render that failed, which owns the way the reader is told. """ reconstruction: Reconstruction play: Play + failed: Failed -class DocumentPreviews(CallbackMixin): +class DocumentPreviews: """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 @@ -45,10 +50,9 @@ class DocumentPreviews(CallbackMixin): way, whose landing then fills the cache alone, 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. A landing sounds the - result's own audio and pursues the next wanted document from there. 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. + result's own audio and pursues the next wanted document from there. A render that fails is + reported to the reader that asked for it, which forgets the preview, so the next wanted + document is pursued and the failed one is asked for again by a new gesture alone. 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 @@ -64,9 +68,7 @@ def __init__( self._renders = renders self._listening = listening self._wanted: Optional[WantedPreview] = None - self._running: Optional[Reconstruction] = None - - self.on_failed: Optional[Callable[[Exception], None]] = None + self._running: Optional[WantedPreview] = None listening.subscribe(self._on_result) @@ -79,14 +81,16 @@ def play( self, reconstruction: Reconstruction, play: Play, + failed: Failed, ) -> 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. + failed: What reports a render that failed. """ - self._wanted = WantedPreview(reconstruction, play) + self._wanted = WantedPreview(reconstruction, play, failed) self._pursue() def cancel(self) -> None: @@ -96,7 +100,7 @@ def cancel(self) -> None: def _pursue(self) -> None: """Sounds the wanted preview from the cache, or asks the worker for it while no job runs. - A preview sounds the document alone, so the job is handed no recording and mixes no original. + A preview sounds the document itself, so its job carries the document alone. """ wanted = self._wanted if wanted is None or self._running is not None: @@ -109,7 +113,7 @@ def _pursue(self) -> None: wanted.play(heard.playing) return - self._running = wanted.reconstruction + self._running = wanted self._listening.start_listening( wanted.reconstruction, hearing=hearing, @@ -133,10 +137,11 @@ def _on_result(self, result: RegenerationResult) -> None: case ServiceSuccess(value=outcome): self._land(outcome) case ServiceError(exception=exception): - if self._wanted is not None and self._wanted.reconstruction is running: + if self._wanted is not None and self._wanted.reconstruction is running.reconstruction: self._wanted = None - self.call(self.on_failed, exception) + logger.error_with_traceback(exception, "A preview's render failed") + running.failed(exception) self._pursue() diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py index 04df68558..9d6294ef9 100644 --- a/src/sampletones_application/logic/shared/renders.py +++ b/src/sampletones_application/logic/shared/renders.py @@ -296,7 +296,10 @@ def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey: return RenderKey(RenderKind.CHANNEL, (RenderPart(source=id(stream), assignment=None, heard=None),), config) @staticmethod - def _original_key(recordings: Sequence[np.ndarray], config: Config) -> RenderKey: + def _original_key( + recordings: Sequence[np.ndarray], + config: Config, + ) -> RenderKey: parts = tuple(RenderPart(source=id(recording), assignment=None, heard=None) for recording in recordings) return RenderKey(RenderKind.ORIGINAL, parts, config) diff --git a/src/sampletones_application/services/regeneration/jobs.py b/src/sampletones_application/services/regeneration/jobs.py index 62ccb1323..923accc05 100644 --- a/src/sampletones_application/services/regeneration/jobs.py +++ b/src/sampletones_application/services/regeneration/jobs.py @@ -20,8 +20,8 @@ class RebuildJob: hearing: What the reader hears, which names the recordings the edit writes and the reading rendered. kept: The renders the caller holds for the document's channels, by channel. - recordings: The recordings loaded for the document, by stem id, which the original of the - recordings heard is mixed from. + recordings: The recordings loaded for the document, by stem id, read-only values the + worker mixes the original of the recordings heard from. """ reconstruction: Reconstruction @@ -40,8 +40,8 @@ class ListeningJob: reconstruction: The document read. hearing: What the reader hears of it. kept: The renders the caller holds for the document's channels, by channel. - recordings: The recordings loaded for the document, by stem id, which the original of the - recordings heard is mixed from. + recordings: The recordings loaded for the document, by stem id, read-only values the + worker mixes the original of the recordings heard from. """ reconstruction: Reconstruction diff --git a/tests/integration/sampletones_application/logic/shared/test_previews.py b/tests/integration/sampletones_application/logic/shared/test_previews.py index 749977ad1..e4016b454 100644 --- a/tests/integration/sampletones_application/logic/shared/test_previews.py +++ b/tests/integration/sampletones_application/logic/shared/test_previews.py @@ -35,16 +35,20 @@ def _settle(previews: DocumentPreviews) -> None: time.sleep(POLL_SECONDS) +def _unexpected(exception: Exception) -> None: + raise AssertionError(f"a preview's render failed: {exception}") + + 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) + previews.play(document, played.append, _unexpected) _settle(previews) assert len(played) == 1 np.testing.assert_array_equal(played[0], rendered_mix(document)) - previews.play(document, played.append) + previews.play(document, played.append, _unexpected) assert len(played) == 2 assert played[1] is played[0] diff --git a/tests/screens/reconstructions/stems/test_stems_card.py b/tests/screens/reconstructions/stems/test_stems_card.py index 11d3dcae1..5c1522b6b 100644 --- a/tests/screens/reconstructions/stems/test_stems_card.py +++ b/tests/screens/reconstructions/stems/test_stems_card.py @@ -7,7 +7,7 @@ from automation.application.startup import Startup from automation.screen import Screen -from automation.steps.reconstructions import expect_open, load_from_the_browser, marked, titled +from automation.steps.reconstructions import expect_open, leave_letting_it_go, load_from_the_browser, marked, titled from automation.views.stems import StemsCard from automation.vocabulary.playback import PAUSE from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED @@ -247,15 +247,6 @@ def its_row_leaves(screen: Screen) -> None: assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} - def leave_letting_it_go(screen: Screen) -> None: - prompt = reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - screen.scenario(hear_the_middle_alone, silence_every_channel_of_it, its_row_leaves, leave_letting_it_go).run() @@ -294,13 +285,4 @@ def drag_pulse_one_to_silence(screen: Screen) -> None: assert instruments.size(ChannelName.PULSE1) == screen.words(NOTHING_EXPORTED).format(bytes=0) assert not instruments.can_export(ChannelName.PULSE1) - def leave_letting_it_go(screen: Screen) -> None: - prompt = reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - screen.scenario(drag_pulse_one_to_silence, leave_letting_it_go).run() diff --git a/tests/unit/sampletones_application/coordinators/playback/test_guard.py b/tests/unit/sampletones_application/coordinators/playback/test_guard.py index 48e71b4ca..3f79fa185 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_guard.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_guard.py @@ -91,6 +91,17 @@ def test_a_transport_command_forgets_it_first( previews.cancel.assert_called_once_with() + def test_stop_forgets_it_too( + self, + guarded_player: GuardedPlayer, + player_logic: MagicMock, + previews: MagicMock, + ) -> None: + guarded_player.stop() + + previews.cancel.assert_called_once_with() + player_logic.stop.assert_called_once_with() + def test_a_guarded_run_forgets_it_first(self, guarded_player: GuardedPlayer, previews: MagicMock) -> None: ran: List[int] = [] diff --git a/tests/unit/sampletones_application/coordinators/playback/test_router.py b/tests/unit/sampletones_application/coordinators/playback/test_router.py index b4b72a0ef..2744a220e 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_router.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_router.py @@ -1,9 +1,11 @@ from dataclasses import dataclass, field from typing import Final, List, Optional, Sequence +from unittest.mock import MagicMock import pytest from sampletones_application.coordinators.playback.router import PlaybackRouter +from sampletones_application.logic.shared.previews import DocumentPreviews from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.language import FakeLanguageManager @@ -77,12 +79,14 @@ def _router( active: Optional[FakeSource], sources: Sequence[FakeSource], device: FakeDevice, + previews: Optional[MagicMock] = None, ) -> PlaybackRouter: return PlaybackRouter( sources=sources, active_source_resolver=lambda: active, audio_device_manager=device, # type: ignore[arg-type] language_manager=FakeLanguageManager(), # type: ignore[arg-type] + previews=previews if previews is not None else MagicMock(spec=DocumentPreviews), ) @@ -160,6 +164,16 @@ def test_stop_silences_a_preview_when_no_source_is_engaged(self) -> None: assert idle.calls == [] assert device.stop_calls == 1 + def test_stop_forgets_a_preview_still_on_its_way(self) -> None: + previews = MagicMock(spec=DocumentPreviews) + router = _router( + active=None, sources=[FakeSource(loaded=True, state=IDLE)], device=FakeDevice(), previews=previews + ) + + router.stop() + + previews.cancel.assert_called_once_with() + class TestTransportShutdown: def test_shutdown_stops_every_source_not_only_the_engaged_one(self) -> None: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 9aca1c2d9..1a9448450 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -2431,16 +2431,3 @@ def test_the_grid_reads_the_mark_from_the_voices_list(self, history_coordinator: history_coordinator._sequencer_voices_panel = MagicMock() assert history_coordinator._marked_voice() is history_coordinator._sequencer_voices_panel.selection - - -class TestAPreviewThatFailsToRender: - """A document's preview whose render fails is told to the reader by the tab that owns the previews.""" - - def test_the_failure_is_shown(self) -> None: - instance = object.__new__(SequencerTabCoordinator) - instance._dialogs = MagicMock() - failure = RuntimeError("rendering failed") - - instance._report_preview_failure(failure) - - instance._dialogs.show_error.assert_called_once_with(failure) 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 6dba91ffe..5d1d1c02b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -545,8 +545,8 @@ def test_a_change_after_the_step_has_run_is_taken( class TestTheBusySpan: - """The line reports what it is busy with as the first step waits or runs, at a hand-over from one kind of - work to the other, and once the last step lands.""" + """The line reports what it is busy with as a job starts, at a hand-over from one kind of work to the other, + and once the last step lands.""" def test_a_rebuild_fills_the_line_until_it_lands( self, @@ -632,9 +632,52 @@ def test_a_listening_carries_them( class TestAChangeWhoseEnvelopesAreOwed: """A change reaching its turn while the envelopes of what is heard are on their way waits behind a listening - of that choice, once: it rebuilds from the envelopes the listening lands, and is let go where it lands none. + of that choice: one already in line, or one the line asks for once per channel. It rebuilds from the + envelopes the listening lands, and is let go where none lands them. """ + def test_a_box_ticked_meanwhile_puts_the_change_behind_the_ticks_listening( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + """The first listening lands envelopes for the choice before the tick, so the change waits for the tick's.""" + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset({SHARED_CHANNEL})) + rewrites.request(ListeningChange()) + + regeneration.finish() + + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert [type(job) for job in regeneration.held] == [RebuildJob] + regeneration.finish() + assert len(outcome.edits) == 1 + assert not rewrites.is_busy + + def test_a_change_joining_the_one_waiting_waits_as_it_did( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A later move of the same channel joins the change waiting, and the one wait covers the joined change.""" + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + monkeypatch.setattr(service_module, "heard_renders", MagicMock(side_effect=RuntimeError("rendering failed"))) + + regeneration.finish() + + assert regeneration.held == () + assert outcome.edits == [] + assert not rewrites.is_busy + def test_the_change_waits_behind_a_listening_and_rebuilds_from_what_it_lands( self, rewrites: ReconstructionRewrites, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 31ed06118..7302ee23e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1027,7 +1027,7 @@ def test_another_document_put_in_front_drops_an_owed_export_and_says_so( panel_logic.redraw_listening() mock_export_service.export_wav.assert_not_called() - def test_a_source_flip_while_a_showing_is_owed_still_loads_the_waveform( + def test_a_box_then_a_source_flip_loads_the_new_hearing( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index e862eed60..cf3bcf367 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -548,6 +548,19 @@ def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None: assert footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes assert [instrument.channel for instrument in footprint.instruments] == [None] + def test_a_samples_render_that_fails_is_reported_as_a_refusal_is(self) -> None: + harness = _harness() + sample = harness.add_sample(sample_reconstruction([ChannelName.PULSE1]), kept=False) + reported: List[Exception] = [] + harness.logic.on_autoplay_error = reported.append + failure = RuntimeError("the worker fell over") + harness.logic.play_voice(sample.id) + + harness.regeneration.fail(failure) + + assert reported == [failure] + assert harness.played() == [] + def test_an_instrument_previews_through_the_pulse_channel(self) -> None: controller, logic, session_manager, audio_device_manager = _logic_with_mocks() instrument = logic.add_new_instrument("lead") diff --git a/tests/unit/sampletones_application/logic/shared/test_file_playback.py b/tests/unit/sampletones_application/logic/shared/test_file_playback.py index 327011ca2..4bd02b883 100644 --- a/tests/unit/sampletones_application/logic/shared/test_file_playback.py +++ b/tests/unit/sampletones_application/logic/shared/test_file_playback.py @@ -152,6 +152,22 @@ def test_the_next_file_replaces_one_on_its_way( (played,) = audio_device_manager.play.call_args.args assert np.array_equal(played, rendered_mix(second)) + def test_a_render_that_fails_reports( + self, + playback: FilePlayback, + audio_device_manager: MagicMock, + regeneration: HeldRegeneration, + tmp_path: Path, + ) -> None: + failure = RuntimeError("the worker fell over") + with patch(RECONSTRUCTION_LOAD, return_value=_document()): + playback.play(_reconstruction_file(tmp_path, "sample")) + + regeneration.fail(failure) + + playback.on_error.assert_called_once_with(failure) + audio_device_manager.play.assert_not_called() + def test_a_device_refusing_at_the_landing_reports( self, playback: FilePlayback, diff --git a/tests/unit/sampletones_application/logic/shared/test_previews.py b/tests/unit/sampletones_application/logic/shared/test_previews.py index 1601fb78b..b72c56ce3 100644 --- a/tests/unit/sampletones_application/logic/shared/test_previews.py +++ b/tests/unit/sampletones_application/logic/shared/test_previews.py @@ -39,6 +39,10 @@ def _document() -> Reconstruction: return sample_reconstruction(PREVIEWED_CHANNELS) +def _unexpected(exception: Exception) -> None: + raise AssertionError(f"a preview's render failed: {exception}") + + 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))) @@ -55,7 +59,7 @@ def test_it_plays_at_once( document = _document() warmed(renders, document, whole_hearing(document), {}) - previews.play(document, played.append) + previews.play(document, played.append, _unexpected) assert _sounded(played, document) assert regeneration.held == () @@ -74,7 +78,7 @@ def test_it_asks_once_and_plays_at_the_landing( ) -> None: document = _document() - previews.play(document, played.append) + previews.play(document, played.append, _unexpected) assert played == [] assert previews.is_busy @@ -97,9 +101,9 @@ def test_a_newer_request_replaces_the_one_waiting( ) -> None: first = _document() second = _document() - previews.play(first, played.append) + previews.play(first, played.append, _unexpected) - previews.play(second, played.append) + previews.play(second, played.append, _unexpected) regeneration.finish() assert played == [] @@ -116,7 +120,7 @@ def test_a_canceled_preview_lands_silently_and_fills_the_cache( played: Played, ) -> None: document = _document() - previews.play(document, played.append) + previews.play(document, played.append, _unexpected) previews.cancel() assert previews.is_busy @@ -135,9 +139,9 @@ def test_a_document_no_longer_wanted_is_kept_while_the_wanted_one_plays_from_the ) -> None: first = _document() second = _document() - previews.play(first, played.append) + previews.play(first, played.append, _unexpected) warmed(renders, second, whole_hearing(second), {}) - previews.play(second, played.append) + previews.play(second, played.append, _unexpected) assert played == [] regeneration.finish() @@ -155,10 +159,9 @@ def test_a_failed_render_is_reported_and_the_next_wanted_document_starts( 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) + previews.play(first, played.append, failures.append) + previews.play(second, played.append, failures.append) regeneration.fail(failure) @@ -174,14 +177,14 @@ def test_a_failed_render_of_the_wanted_document_is_asked_again_by_a_new_gesture_ played: Played, ) -> None: document = _document() - previews.on_failed = lambda exception: None - previews.play(document, played.append) + failures: List[Exception] = [] + previews.play(document, played.append, failures.append) regeneration.fail(RuntimeError("the worker fell over")) assert regeneration.held == () assert not previews.is_busy - previews.play(document, played.append) + previews.play(document, played.append, failures.append) assert len(regeneration.held) == 1 def test_a_result_arriving_with_nothing_on_its_way_raises(self, previews: DocumentPreviews) -> None: @@ -211,7 +214,7 @@ def test_nothing_plays_and_nothing_is_kept( played: Played, ) -> None: document = _document() - previews.play(document, played.append) + previews.play(document, played.append, _unexpected) SingleThreadExecutor.request_shutdown() regeneration.finish() diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 1da929a35..851500b97 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -560,6 +560,23 @@ def test_the_recordings_heard_anywhere_are_mixed_in_record_order(self, taking_tu assert len(outcome.original.recordings) == len(recordings) np.testing.assert_allclose(outcome.original.audio, mix(list(recordings.values()))) + def test_the_recordings_handed_are_read_as_the_values_they_are(self, taking_turns: Reconstruction) -> None: + """The worker reads the recordings the render thread holds, so it names them as they are and writes none.""" + recordings = self._recordings(taking_turns) + for recording in recordings.values(): + recording.setflags(write=False) + before = {stem_id: recording.copy() for stem_id, recording in recordings.items()} + + outcome = _reported( + RegenerationService(), + ListeningJob(taking_turns, _heard_whole(taking_turns), NO_RENDERS, recordings), + ) + + assert outcome.original is not None + assert all(mixed is held for mixed, held in zip(outcome.original.recordings, recordings.values())) + assert all(not recording.flags.writeable for recording in recordings.values()) + assert all(np.array_equal(recordings[stem_id], kept) for stem_id, kept in before.items()) + def test_a_recording_left_out_everywhere_stays_out_of_the_mix(self, taking_turns: Reconstruction) -> None: recordings = self._recordings(taking_turns) hearing = Hearing( From 8aa0752c2613cc5374eecd7d9309d8440a0607c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 08:30:27 +0200 Subject: [PATCH 33/37] Updated: bugs and todos --- docs/development/bugs-and-todos.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 90e27636c..bc2dca559 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -54,8 +54,9 @@ dimension the import starts carrying. ### Features -* In-application guide/tutorial -* Language selector +* MIDI keyboard support. +* In-application guide/tutorial. +* Language selector. * Reading only the bins the refinement asks for, since it transforms every bin the spectrum covers and uses a handful per frame. * Keeping the recordings a stopped folder scan has found, so stopping a long walk keeps the count the reader From 79c0387d7c7e890af9477df4fb9acdebe9997109 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 09:12:28 +0200 Subject: [PATCH 34/37] Fixed: the waveform update contract, its waveform taken by position --- .../logic/reconstruction/reconstruction.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index d3dde48cd..44c98b438 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -131,11 +131,15 @@ def _drawn_either( class WaveformUpdate(Protocol): - """What draws an updated waveform in place, re-fitting its view to the audio's length where asked.""" + """What draws an updated waveform in place, re-fitting its view to the audio's length where asked. + + The waveform is handed over by position, so a drawer may name it as it likes. + """ def __call__( self, waveform: WaveformData, + /, *, refit: bool, ) -> None: ... From 245aa2de172c1a5d75d527c23c95ba8d27443d98 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 09:37:34 +0200 Subject: [PATCH 35/37] Answered: the instruments panel's words while a document's envelopes are on their way --- automation/views/instruments.py | 17 +++ automation/vocabulary/instruments.py | 2 + .../application/reconstruction-edits.md | 5 +- .../logic/reconstruction/editing.py | 16 ++- .../logic/reconstruction/editor.py | 16 ++- .../logic/reconstruction/instruments.py | 70 ++++++----- .../tags/reconstructions.py | 2 +- .../reconstruction/instruments/instruments.py | 20 +++- .../view_model/reconstruction/instruments.py | 9 +- src/sampletones_config/lang/en.yaml | 1 + ...est_words_while_the_sound_is_on_its_way.py | 87 ++++++++++++++ .../logic/reconstruction/test_editor.py | 15 ++- .../logic/reconstruction/test_instruments.py | 112 ++++++++++++++++++ .../sampletones_application/test_startup.py | 24 ++++ .../reconstruction/test_instruments_panel.py | 54 ++++++++- 15 files changed, 406 insertions(+), 44 deletions(-) create mode 100644 tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py diff --git a/automation/views/instruments.py b/automation/views/instruments.py index 0c562758f..85bdfb2ac 100644 --- a/automation/views/instruments.py +++ b/automation/views/instruments.py @@ -22,6 +22,7 @@ from sampletones_application.tags.graphs import SUF_GRAPH, SUF_GRAPH_RAW_DATA from sampletones_application.tags.reconstructions import ( SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, @@ -31,6 +32,7 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey AUDITION_GROUP: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP) +MESSAGE: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE) class Instruments: @@ -151,6 +153,10 @@ def offers_audition(self) -> bool: """Whether the Audition switch stands, which it does while an instrument is open.""" return self._bridge.ask(lambda: read_item(AUDITION_GROUP)).shown + def says(self) -> Optional[str]: + """The words the card shows in place of its tabs, or ``None`` while the tabs stand.""" + return self._bridge.ask(read_words) + def audition(self) -> str: """The generator the Audition switch names.""" return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION))) @@ -175,6 +181,17 @@ def export(self, channel: ChannelName) -> None: self._hand.click(export_button(channel)) +def read_words() -> Optional[str]: + """The words the instruments card shows in place of its tabs, or ``None`` while the tabs stand. + + Runs on the render thread, so a frame recording can take it after every frame. + """ + if not read_item(MESSAGE).shown: + return None + + return str(read_value(MESSAGE)) + + def tab(channel: ChannelName) -> str: """The name of the tab of ``channel``.""" return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel) diff --git a/automation/vocabulary/instruments.py b/automation/vocabulary/instruments.py index e6e1c52db..17dc88cb3 100644 --- a/automation/vocabulary/instruments.py +++ b/automation/vocabulary/instruments.py @@ -1,3 +1,5 @@ from typing import Final TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" +PREPARING: Final[str] = "reconstructions.instruments.message.preparing" +NOTHING_LOADED: Final[str] = "global.dialog.message.reconstruction_no_data" diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 473a5b570..9e3797d88 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -48,7 +48,10 @@ therefore keeps what it held, and the history stays as it was. The instruments panel reads the document's envelopes through the changes still on their way, so the bars, the plots and the byte figures answer for the document once the line empties. A change the line lets go of, -such as a failed rebuild, redraws the panel from the document as it stands. +such as a failed rebuild, redraws the panel from the document as it stands. An opening, a removal, a retune +and an undo leave the envelopes to the worker, so the document has no channels to draw until they land; +the panel says the instruments are being prepared in place of its tabs, and an edit reaching it meanwhile +goes nowhere. ## A step that no longer applies is skipped diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index fb211c06a..7d63612a6 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -28,7 +28,17 @@ class InstrumentEdit: features: Features -EditedVoice = Union[ChannelEnvelopesViewModel, InstrumentEdit] +@dataclass(frozen=True) +class EnvelopesOwed: + """A reconstruction in front of the panel whose envelopes the worker is still rendering. + + An opening, a removal, a retune and an undo send the envelopes to the worker with the sound, so + the document has channels to draw once they land. Until then the panel says they are on their + way, and an edit reaching it goes nowhere. + """ + + +EditedVoice = Union[ChannelEnvelopesViewModel, InstrumentEdit, EnvelopesOwed] class InstrumentEditingProtocol(Protocol): @@ -41,7 +51,9 @@ class InstrumentEditingProtocol(Protocol): """ def edited_instrument(self) -> Optional[EditedVoice]: - """What the panel is editing, or ``None`` while it holds nothing.""" + """What the panel is editing: an instrument, a reconstruction's channels, or a reconstruction whose + channels are on their way from the worker; ``None`` while it holds nothing. + """ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the panel.""" diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 57e9e7515..aa2030a0f 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -5,6 +5,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( EditedVoice, + EnvelopesOwed, InstrumentEdit, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager @@ -61,7 +62,11 @@ def instrument(self) -> Optional[Instrument]: return voice if isinstance(voice, Instrument) else None def edited_instrument(self) -> Optional[EditedVoice]: - """What the instruments panel is editing, or ``None`` while it holds nothing.""" + """What the instruments panel is editing, or ``None`` while it holds nothing. + + An open reconstruction answers with its channels once the worker has exported their + envelopes for what the reader hears, and until then with the word that they are on their way. + """ instrument = self.instrument if instrument is not None: return InstrumentEdit( @@ -70,7 +75,14 @@ def edited_instrument(self) -> Optional[EditedVoice]: features=instrument.instrument_features(), ) - return self._reconstruction_manager.current_features + features = self._reconstruction_manager.current_features + if features is not None: + return features + + if self._reconstruction_manager.reconstruction is not None: + return EnvelopesOwed() + + return None def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the tab, as one history entry. diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 9e49c7200..c450e24ae 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,6 +2,7 @@ from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.logic.reconstruction.editing import ( + EnvelopesOwed, InstrumentEdit, InstrumentEditingProtocol, ) @@ -65,7 +66,7 @@ def update_display(self) -> None: the same voice, so the render is reported once it has been made and they settle on it: an edit to an instrument redraws its waveform here. """ - self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) + self.call(self.on_view_changed, self._build_view_model()) self.call(self.on_feature_data_changed, self._displayed_features()) self.call(self.on_display_refreshed) @@ -102,10 +103,7 @@ def refresh_view(self) -> None: panel draws it empty and the next edit starts from what the document holds. A document rewritten anywhere else is drawn whole through :meth:`update_display`. """ - self.call( - self.on_view_changed, - self._build_view_model(self._measured_channels()), - ) + self.call(self.on_view_changed, self._build_view_model()) self._redraw_silenced() def _redraw_silenced(self) -> None: @@ -133,17 +131,16 @@ def _redraw_silenced(self) -> None: ), ) - def _measured_channels(self) -> Optional[Dict[ChannelName, Features]]: - """The channels of the reconstruction in front of the panel, as its figures measure them. + def _measured_channels( + self, + document: ChannelEnvelopesViewModel, + ) -> Dict[ChannelName, Features]: + """The channels of ``document``, the reconstruction in front of the panel, as its figures measure them. A channel measures as the document will hold it once the changes on their way land. A change silencing every frame of a channel leaves it standing by, so that channel measures standing by from the moment the change is made, with no frame and no figure. """ - document = self._document_envelopes() - if document is None: - return None - return { channel_name: (features if features == document[channel_name] else self._as_regenerated(features)) for channel_name, features in self._pending_changes.drawn(document).channels.items() @@ -183,23 +180,41 @@ def instrument_edit(self) -> Optional[InstrumentEdit]: case _: return None - def _build_view_model( - self, - channels: Optional[Dict[ChannelName, Features]], - ) -> ReconstructionInstrumentsViewModel: - instrument = self.instrument_edit - if instrument is not None: - return self._instrument_view_model(instrument) + def _build_view_model(self) -> ReconstructionInstrumentsViewModel: + """What the panel shows of the voice in front of it. - if channels is None: - return ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - playing_channels=frozenset(), - footprint=None, - ) + A reconstruction is shown by its channels once the worker has exported their envelopes, and + as a document on its way until then, so the panel says a document is open through an + opening, a removal, a retune and an undo. An instrument is shown by its own set. + """ + match self._editor.edited_instrument(): + case InstrumentEdit() as instrument: + return self._instrument_view_model(instrument) + case ChannelEnvelopesViewModel() as document: + return self._reconstruction_view_model(self._measured_channels(document)) + case EnvelopesOwed(): + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + envelopes_owed=True, + playing_channels=frozenset(), + footprint=None, + ) + case None: + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + envelopes_owed=False, + playing_channels=frozenset(), + footprint=None, + ) + def _reconstruction_view_model( + self, + channels: Dict[ChannelName, Features], + ) -> ReconstructionInstrumentsViewModel: + """What the panel shows of a reconstruction: the channels that play and what they cost.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, + envelopes_owed=False, playing_channels=playing_channels(channels), footprint=self._build_footprint(channels), ) @@ -216,6 +231,7 @@ def _instrument_view_model( """ return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, + envelopes_owed=False, playing_channels=playing_channels(self._instrument_channels(instrument)), footprint=VoiceFootprintViewModel.from_instrument(features_footprint(instrument.features)), instrument=InstrumentViewModel(name=instrument.name), @@ -262,7 +278,7 @@ def handle_pitch_value_changed( initial_pitch=value, ), ) - case _: + case InstrumentEdit() | EnvelopesOwed() | None: self.update_display() def handle_envelope_changed( @@ -293,7 +309,7 @@ def handle_envelope_changed( initial_pitch=None, ), ) - case None: + case EnvelopesOwed() | None: self.update_display() def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> None: @@ -310,7 +326,7 @@ def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> drawn = self._pending_changes.drawn(document) self._note_silenced(change.channel_name, change.rebased(drawn[change.channel_name])) self.call(self.on_channel_changed, change) - self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) + self.call(self.on_view_changed, self._build_view_model()) def _note_silenced(self, channel_name: ChannelName, features: Features) -> None: """Remembers whether the latest edit of a channel silences it, which its rebuild then shows.""" diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index a36b22b87..e089d59ab 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -197,7 +197,7 @@ PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel") PRE_RECONSTRUCTION_STEMS = compose_tag("reconstruction", "stems") -SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" +SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE = "message" SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE = "autoscale" diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 17d1bbd9e..bf052863e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -41,7 +41,7 @@ ) from sampletones_application.tags.reconstructions import ( SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, - SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE, + SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, @@ -155,7 +155,7 @@ def __init__( self._export_buttons: Dict[ChannelName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR - self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE) + self.message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE) self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) @@ -187,6 +187,8 @@ def __init__( self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] + self._msg_nothing_loaded = language_manager["global.dialog.message.reconstruction_no_data"] + self._msg_preparing = language_manager["reconstructions.instruments.message.preparing"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) self._lbl_instrument_size = context_label(language_manager, ContextElements.INSTRUMENT_SIZE) self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) @@ -236,9 +238,9 @@ def create_panel(self, parent: str) -> None: def _create_content(self) -> None: dpg.add_text( - tag=self.no_data_message_tag, + tag=self.message_tag, parent=self._body_container, - default_value=self._language_manager["global.dialog.message.reconstruction_no_data"], + default_value=self._msg_nothing_loaded, show=True, ) @@ -505,11 +507,17 @@ def update_view( muted tab label and a withheld export say which channels are there. An instrument is one set every channel reads, so it shows a single tab under its own name, and the audition takes the pitch stepper's place: a row states the note an instrument sounds at, and - what the panel offers instead is the generator to hear it on. + what the panel offers instead is the generator to hear it on. Words stand in place of the + tabs while nothing is open, and while a reconstruction's envelopes are on their way from + the worker they say the instruments are being prepared. """ instrument = view_model.instrument is_open = view_model.is_open - dpg_configure_item(self.no_data_message_tag, show=not is_open) + dpg_set_value( + self.message_tag, + self._msg_preparing if view_model.envelopes_owed else self._msg_nothing_loaded, + ) + dpg_configure_item(self.message_tag, show=not is_open) dpg_configure_item(self.tab_bar_tag, show=is_open) dpg_configure_item(self.sample_size_group_tag, show=is_open) self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index b298605a1..474125e68 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -22,10 +22,13 @@ class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel reads to mark the standing-by tabs and to offer their export. An instrument is one set every - channel reads, so :attr:`instrument` is what the panel renders instead. + channel reads, so :attr:`instrument` is what the panel renders instead. A reconstruction's + envelopes are the worker's to export, so :attr:`envelopes_owed` says the document is open and + its channels are on their way, which the panel states in place of its tabs. """ reconstruction_loaded: bool + envelopes_owed: bool playing_channels: FrozenSet[ChannelName] footprint: Optional[VoiceFootprintViewModel] instrument: Optional[InstrumentViewModel] = None @@ -37,5 +40,5 @@ def edits_an_instrument(self) -> bool: @property def is_open(self) -> bool: - """Whether the panel has a voice in front of it at all.""" - return self.reconstruction_loaded or self.edits_an_instrument + """Whether the panel has a voice to draw: an instrument, or a reconstruction whose envelopes have landed.""" + return self.edits_an_instrument or (self.reconstruction_loaded and not self.envelopes_owed) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8ba535160..b38e20500 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -552,6 +552,7 @@ reconstructions.instruments.message.status_audition: "Click to choose the genera reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Alt + scroll to pan. Right-click for more options." reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature} has {items} items. {kept}." +reconstructions.instruments.message.preparing: "Preparing the instruments..." reconstructions.instruments.template.kept_famitracker: "FamiTracker keeps the first {limit}" reconstructions.instruments.template.kept_bitphase: "a Bitphase project keeps the first {limit}" reconstructions.instruments.template.kept_bitphase_preset: "a Bitphase preset keeps the first {limit}" diff --git a/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py b/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py new file mode 100644 index 000000000..85e4b5fa7 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py @@ -0,0 +1,87 @@ +import operator +from typing import Final, List, Optional, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import leave_letting_it_go, load_from_the_browser +from automation.views.instruments import read_words +from automation.vocabulary.instruments import NOTHING_LOADED, PREPARING +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world + +CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +RECORDING_KEY: Final[str] = "0" + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """The home holds the two-minute reconstruction over three recordings taking turns.""" + return alternating_document_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The application opens with no reconstruction and no project.""" + return Startup(reconstruction=None, project=None) + + +class TestTheWordsWhileTheSoundIsOnItsWay: + """The instruments card says the instruments are being prepared while a document's envelopes are rendered, + and from the moment a document is open it never says none is loaded. + + An opening and a removal send the envelopes to the worker with the sound, so the card has no bars to + draw until they land. The scenario records the card's words after every frame through the opening of + the two-minute document and through the removal of one of its recordings. Each time the words say the + instruments are being prepared, the words saying nothing is loaded never follow them, and the bars + come with the sound. + """ + + def test_the_card_says_what_is_on_its_way_and_then_draws_it(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + stems = reconstructions.stems + preparing = screen.words(PREPARING) + nothing_loaded = screen.words(NOTHING_LOADED) + + def drawn() -> Tuple[ChannelName, ...]: + return tuple(channel for channel in CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME)) + + def the_tabs_stand(screen: Screen) -> None: + screen.expect(instruments.says, operator.not_, description="the tabs standing") + assert screen.expect(drawn, bool, description="the bars drawn") + + def open_it_reading_the_words(screen: Screen) -> None: + assert instruments.says() == nothing_loaded + + with screen.record(read_words) as recording: + load_from_the_browser(screen, LONG_RECONSTRUCTION) + screen.expect( + lambda: reconstructions.shows_open(LONG_RECONSTRUCTION), + bool, + description=f"{LONG_RECONSTRUCTION.name} open", + ) + screen.expect(screen.sound_settled, bool, description="the sound of the opening landed") + + seen: List[Optional[str]] = recording.values() + assert preparing in seen, seen + assert nothing_loaded not in seen[seen.index(preparing) :], seen + the_tabs_stand(screen) + + def remove_one_reading_the_words(screen: Screen) -> None: + stems.remove(RECORDING_KEY) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + + with screen.record(read_words) as recording: + stems.remove_prompt.confirm() + screen.expect(lambda: stems.has_row(RECORDING_KEY), operator.not_, description="the recording gone") + screen.expect(screen.sound_settled, bool, description="the sound of the rest landed") + + seen = recording.values() + assert preparing in seen, seen + assert nothing_loaded not in seen, seen + the_tabs_stand(screen) + + screen.scenario(open_it_reading_the_words, remove_one_reading_the_words, leave_letting_it_go).run() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index c4ecc0b70..e1e75268e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -7,7 +7,7 @@ from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.reconstruction.editing import InstrumentEdit +from sampletones_application.logic.reconstruction.editing import EnvelopesOwed, InstrumentEdit from sampletones_application.logic.reconstruction.editor import InstrumentEditor from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.view_model.reconstruction.envelopes import ( @@ -18,6 +18,7 @@ from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import SUSTAINING_ENVELOPES, new_instrument from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.reconstructions import Reconstruction from tests.suite.history.wiring import wired_history ROOT_PITCH: Final[int] = 55 @@ -43,6 +44,7 @@ def controller() -> ProjectController: @pytest.fixture def reconstruction_manager() -> MagicMock: manager = MagicMock(spec=ReconstructionManager) + manager.reconstruction = None manager.current_features = None return manager @@ -84,6 +86,17 @@ def test_a_loaded_reconstruction_answers_with_its_channels( assert isinstance(edit, ChannelEnvelopesViewModel) assert list(edit.channels) == [ChannelName.PULSE1] + def test_a_reconstruction_whose_envelopes_are_on_their_way_answers_so( + self, + editor: InstrumentEditor, + reconstruction_manager: MagicMock, + ) -> None: + """An opening, a removal, a retune and an undo leave the envelopes to the worker, with the document open.""" + reconstruction_manager.reconstruction = MagicMock(spec=Reconstruction) + reconstruction_manager.current_features = None + + assert isinstance(editor.edited_instrument(), EnvelopesOwed) + def test_an_instrument_answers_with_what_it_states( self, editor: InstrumentEditor, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 4efe8949f..02d0c37e2 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -77,6 +77,7 @@ def history(project_controller: ProjectController) -> HistoryManager: @pytest.fixture def mock_reconstruction_manager() -> MagicMock: + """A stand-in for the manager with a document open, whose envelopes each case states.""" return MagicMock(spec=ReconstructionManager) @@ -126,6 +127,7 @@ def test_no_features_emits_not_loaded_view_model( instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, ) -> None: + mock_reconstruction_manager.reconstruction = None mock_reconstruction_manager.current_features = None received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append @@ -138,6 +140,7 @@ def test_no_features_fires_on_feature_data_changed_with_none( instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, ) -> None: + mock_reconstruction_manager.reconstruction = None mock_reconstruction_manager.current_features = None received: List[Optional[Dict[ChannelName, Features]]] = [] instruments_logic.on_feature_data_changed = received.append @@ -620,6 +623,7 @@ def left_logic( rewrites: ReconstructionRewrites, ) -> ReconstructionInstrumentsLogic: """The panel's logic over an instrument the project has since removed.""" + mock_reconstruction_manager.reconstruction = None mock_reconstruction_manager.current_features = None editor = _editor(mock_reconstruction_manager, project_controller, history) with history.transaction(HistoryAction.ADD_INSTRUMENT): @@ -773,3 +777,111 @@ def test_a_redraw_draws_the_changes_on_their_way( assert envelopes is not None assert envelopes[SHARED_CHANNEL].volume == shared assert envelopes[SOLE_CHANNEL].volume == sole + + +class TestAReconstructionWhoseEnvelopesAreOnTheirWay: + """The panel says a document is open while the worker renders its envelopes, and draws them once they land. + + An opening, a removal, a retune and an undo leave the envelopes owed to the worker, so until they + land the document has no channels to draw. The panel keeps saying so, as a document on its way, + and an edit reaching it meanwhile goes nowhere. + """ + + @pytest.fixture + def owed_logic( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> ReconstructionInstrumentsLogic: + """The panel's logic over an open document whose envelopes the worker has yet to export.""" + mock_reconstruction_manager.reconstruction = reconstruction_factory() + mock_reconstruction_manager.current_features = None + return instruments_logic + + def test_the_display_says_a_document_is_on_its_way( + self, + owed_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + owed_logic.on_view_changed = views.append + + owed_logic.update_display() + + assert len(views) == 1 + assert views[0].reconstruction_loaded + assert views[0].envelopes_owed + assert not views[0].is_open + assert views[0].playing_channels == frozenset() + assert views[0].footprint is None + + def test_the_display_draws_no_envelopes_yet( + self, + owed_logic: ReconstructionInstrumentsLogic, + ) -> None: + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + owed_logic.on_feature_data_changed = drawn.append + + owed_logic.update_display() + + assert drawn == [None] + + def test_a_refresh_says_the_same( + self, + owed_logic: ReconstructionInstrumentsLogic, + ) -> None: + """A retune refreshes the figures of a document whose envelopes it left to the worker.""" + views: List[ReconstructionInstrumentsViewModel] = [] + owed_logic.on_view_changed = views.append + + owed_logic.refresh_view() + + assert len(views) == 1 + assert views[0].reconstruction_loaded + assert views[0].envelopes_owed + + def test_an_envelope_edit_goes_nowhere_and_keeps_the_words( + self, + owed_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + changes: List[ChannelChange] = [] + owed_logic.on_view_changed = views.append + owed_logic.on_channel_changed = changes.append + + owed_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert changes == [] + assert len(views) == 1 + assert views[0].envelopes_owed + + def test_a_pitch_change_goes_nowhere_and_keeps_the_words( + self, + owed_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + changes: List[ChannelChange] = [] + owed_logic.on_view_changed = views.append + owed_logic.on_channel_changed = changes.append + + owed_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) + + assert changes == [] + assert len(views) == 1 + assert views[0].envelopes_owed + + def test_the_landing_draws_the_channels( + self, + owed_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + owed_logic.on_view_changed = views.append + owed_logic.update_display() + + mock_reconstruction_manager.current_features = _heard_features(mock_reconstruction_manager.reconstruction) + owed_logic.update_display() + + assert [view.envelopes_owed for view in views] == [True, False] + assert views[-1].is_open + assert views[-1].playing_channels diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index ae00e590e..62650c41b 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -721,6 +721,30 @@ def test_a_reconstruction_opened_from_a_file_outlasts_a_new_and_a_closed_project held_queue.drain() assert app.reconstruction_manager.filepath == path + def test_an_opening_says_the_envelopes_are_on_their_way_until_they_land( + self, + app: Application, + reconstruction_factory: ReconstructionFactory, + held_queue: HeldQueue, + tmp_path: Path, + ) -> None: + """The panel says a document is open from the opening on, and draws its channels once the worker lands them.""" + views = self._drawn_views(app) + path = tmp_path / "lead.stn" + reconstruction_factory().save(path) + + app.reconstruction_manager.load_reconstruction(path) + + assert views[-1].reconstruction_loaded + assert views[-1].envelopes_owed + assert not views[-1].is_open + + SingleThreadExecutor.join_all() + held_queue.drain() + + assert views[-1].is_open + assert not views[-1].envelopes_owed + class TestAGestureOnTheWholeDocumentWaitsForTheEdits: """A gesture that reads or puts away the open document runs once the edit on its way has landed. diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index d4fbf250c..d3caa53c8 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -89,12 +89,24 @@ NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, + envelopes_owed=False, playing_channels=frozenset(), footprint=None, ) +ENVELOPES_ON_THEIR_WAY: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + envelopes_owed=True, + playing_channels=frozenset(), + footprint=None, +) + +NOTHING_LOADED_KEY: Final[str] = "global.dialog.message.reconstruction_no_data" +PREPARING_KEY: Final[str] = "reconstructions.instruments.message.preparing" + ONE_INSTRUMENT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, + envelopes_owed=False, playing_channels=frozenset((ChannelName.PULSE1,)), footprint=VoiceFootprintViewModel.from_instrument(LARGEST_PULSE), instrument=InstrumentViewModel(name="lead"), @@ -112,6 +124,7 @@ def build_view_model( """A loaded reconstruction playing the given channels, each measured as given.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, + envelopes_owed=False, playing_channels=frozenset(channel_footprints), footprint=VoiceFootprintViewModel.from_footprints(channel_footprints), ) @@ -762,7 +775,46 @@ def test_no_reconstruction_states_no_figures( written: Dict[str, str], ) -> None: panel.update_view(NOT_LOADED) - assert written == {} + assert set(written) == {panel.message_tag} + + +class TestTheWordsInPlaceOfTheTabs: + """The card says what stands in place of its tabs: no document, or one whose envelopes are on their way.""" + + def test_no_reconstruction_says_none_is_loaded( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + written: Dict[str, str], + ) -> None: + panel.update_view(NOT_LOADED) + + assert shown[panel.message_tag] is True + assert shown[panel.tab_bar_tag] is False + assert written[panel.message_tag] == panel._language_manager[NOTHING_LOADED_KEY] + + def test_envelopes_on_their_way_say_the_instruments_are_being_prepared( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + written: Dict[str, str], + ) -> None: + panel.update_view(ENVELOPES_ON_THEIR_WAY) + + assert shown[panel.message_tag] is True + assert shown[panel.tab_bar_tag] is False + assert shown[panel.sample_size_group_tag] is False + assert written[panel.message_tag] == panel._language_manager[PREPARING_KEY] + + def test_a_loaded_reconstruction_shows_its_tabs_instead( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) + + assert shown[panel.message_tag] is False + assert shown[panel.tab_bar_tag] is True class TestTheAuditionSelector: From a3100067bd45565d026e71e20311e69a2deb8188 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 09:37:51 +0200 Subject: [PATCH 36/37] Held: every press of a burst to the double-click time, a late one a slow frame's --- automation/dearpygui/gestures/pointer.py | 26 ++++++++++++++++++++++++ docs/development/application/screens.md | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/automation/dearpygui/gestures/pointer.py b/automation/dearpygui/gestures/pointer.py index 4963f1794..b8a247aaf 100644 --- a/automation/dearpygui/gestures/pointer.py +++ b/automation/dearpygui/gestures/pointer.py @@ -218,6 +218,8 @@ def _press_where_it_stands( ) if running and count > SINGLE_PRESS: self._require_double_click(imgui_button, releases, double_clicks) + if running and count > DOUBLE_PRESS: + self._require_one_burst(imgui_button, releases) def _counts(self, imgui_button: int) -> Tuple[int, int]: """The releases and the double-clicks the witness counted of ``imgui_button`` so far.""" @@ -226,6 +228,30 @@ def _counts(self, imgui_button: int) -> Tuple[int, int]: self._witness.double_clicks(imgui_button), ) + def _require_one_burst( + self, + imgui_button: int, + releases: int, + ) -> None: + """Checks that every press after the double-click landed within Dear ImGui's double-click time of the one + before, which is what makes the presses one burst to a plot. + + A plot takes a press that follows a double-click within that time as one more click of the + burst, and a later one as a click of its own, so a frame drawn slowly between two presses + turns a triple click into a double-click and a click. + + Raises: + SlowFramesError: If a press of the burst came later than that after the one before it. + """ + times = self._bridge.ask(lambda: self._witness.button_release_times(imgui_button))[releases:] + for earlier, later in zip(times[DOUBLE_PRESS - 1 :], times[DOUBLE_PRESS:]): + took = later - earlier + if took >= IMGUI_DOUBLE_CLICK_SECONDS: + raise SlowFramesError( + f"Dear ImGui read a press of the burst as a click of its own: it came {took:.2f} s after " + f"the one before, past the {IMGUI_DOUBLE_CLICK_SECONDS} s double-click time" + ) + def _require_double_click( self, imgui_button: int, diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index f0e330ceb..f1e09d9fc 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -149,7 +149,9 @@ key held past 0.275 s repeats. A click and a key press therefore go down and com ImGui reads the press on one frame and the release on the next at any frame rate. A key stands down for a single frame, too briefly to repeat, and the presses of a double-click land two frames apart. Where two frames take longer than the double-click time, the hand raises `SlowFramesError` and says how far apart the -presses landed. +presses landed. A burst of more presses, such as the triple click a plot plays nothing on, holds every later +press to the same time after the one before it, since a plot takes a press landing later than that as a +click of its own. A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its second press goes down. A scenario holding that press for as long as a person does sees what the answer From badad6c2a91a2a018e74ef919c4f8ac236c43c5e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 11:32:22 +0200 Subject: [PATCH 37/37] Kept: the instruments panel's last envelopes --- automation/views/instruments.py | 30 ++- .../application/reconstruction-edits.md | 23 +- .../coordinators/tabs/reconstruction.py | 1 + .../logic/reconstruction/editing.py | 12 +- .../logic/reconstruction/editor.py | 2 +- .../logic/reconstruction/instruments.py | 47 ++-- .../logic/reconstruction/manager.py | 21 +- .../logic/reconstruction/reconstruction.py | 6 +- .../logic/reconstruction/rewrites/queue.py | 103 ++++++-- .../ui/elements/graphs/bar.py | 12 +- .../ui/elements/pitch_stepper.py | 6 + .../ui/elements/plus_minus_buttons.py | 9 + .../reconstruction/instruments/instruments.py | 31 ++- .../view_model/reconstruction/instruments.py | 41 +++- .../test_eventual_synchronization.py | 187 +++++++++++++++ .../reconstruction/rewrites/test_queue.py | 3 +- ..._the_card_while_the_sound_is_on_its_way.py | 120 ++++++++++ ...est_words_while_the_sound_is_on_its_way.py | 87 ------- .../coordinators/test_reconstruction.py | 4 +- .../reconstruction/rewrites/test_queue.py | 221 +++++++++++++++++- .../logic/reconstruction/test_editor.py | 17 +- .../logic/reconstruction/test_instruments.py | 84 ++++--- .../logic/reconstruction/test_manager.py | 57 +++++ .../reconstruction/test_reconstruction.py | 37 +++ .../sampletones_application/test_startup.py | 9 +- .../ui/elements/graphs/test_bar.py | 32 +++ .../ui/elements/test_pitch_stepper.py | 38 +++ .../ui/elements/test_plus_minus_buttons.py | 26 ++- .../reconstruction/test_instruments_panel.py | 108 ++++++++- 29 files changed, 1166 insertions(+), 208 deletions(-) create mode 100644 tests/integration/reconstruction/test_eventual_synchronization.py create mode 100644 tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py delete mode 100644 tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py diff --git a/automation/views/instruments.py b/automation/views/instruments.py index 85bdfb2ac..f44ca7548 100644 --- a/automation/views/instruments.py +++ b/automation/views/instruments.py @@ -89,13 +89,13 @@ def read() -> Tuple[FeatureKey, ...]: def field(self, channel: ChannelName, feature: FeatureKey) -> str: """The name of the field holding the sequence of ``channel``'s ``feature`` envelope.""" - return compose_tag( - TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, - channel, - feature, - SUF_GRAPH_RAW_DATA, - SUF_TEXT, - ) + return sequence_field(channel, feature) + + def takes_edits(self, channel: ChannelName, feature: FeatureKey) -> bool: + """Whether ``channel``'s ``feature`` field takes a typed sequence, which it holds while the envelopes are on + their way. + """ + return self._bridge.ask(lambda: read_takes_edits(channel, feature)) def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" @@ -181,6 +181,22 @@ def export(self, channel: ChannelName) -> None: self._hand.click(export_button(channel)) +def sequence_field(channel: ChannelName, feature: FeatureKey) -> str: + """The name of the field holding the sequence of ``channel``'s ``feature`` envelope.""" + return compose_tag( + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + channel, + feature, + SUF_GRAPH_RAW_DATA, + SUF_TEXT, + ) + + +def read_takes_edits(channel: ChannelName, feature: FeatureKey) -> bool: + """Whether ``channel``'s ``feature`` field takes a typed sequence. Runs on the render thread.""" + return read_item(sequence_field(channel, feature)).enabled + + def read_words() -> Optional[str]: """The words the instruments card shows in place of its tabs, or ``None`` while the tabs stand. diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 9e3797d88..701ac6179 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -23,6 +23,10 @@ frequency, and a gesture that reads or puts away the whole document. The line ta the order the reader asked for them, and each step is built from the document the step before it left. Two quick edits therefore both land, whichever dimensions or channels they touch. +A step the line takes, or a result it lands, may ask for another step, as an undo redrawing the document +asks for a listening. The ask joins the line and is taken once the step or the landing has returned, so a +gesture never runs inside another one. + ## A step carries what the reader moved A channel change holds the dimensions the reader moved alone. At its turn it is written over the @@ -48,10 +52,12 @@ therefore keeps what it held, and the history stays as it was. The instruments panel reads the document's envelopes through the changes still on their way, so the bars, the plots and the byte figures answer for the document once the line empties. A change the line lets go of, -such as a failed rebuild, redraws the panel from the document as it stands. An opening, a removal, a retune -and an undo leave the envelopes to the worker, so the document has no channels to draw until they land; -the panel says the instruments are being prepared in place of its tabs, and an edit reaching it meanwhile -goes nowhere. +such as a failed rebuild, redraws the panel from the document as it stands. + +A removal, a retune, an undo and a new choice of what is heard leave the envelopes to the worker. The panel +keeps the envelopes it drew until the next ones land, and it takes no edit of them meanwhile, since an edit +would be written against frames the reader no longer hears. A document just opened has none to keep, so the +panel says its instruments are being prepared. ## A step that no longer applies is skipped @@ -94,6 +100,15 @@ is written for what the reader hears once the sound lands. A document put away b that fails, drops the export and tells the reader so. A channel change reaching its turn while the envelopes of what is heard are on their way waits behind a listening of that choice. +## The line catches up with what is heard + +A listening renders the choice standing when it starts, and the reader may move on before it lands. Its +envelopes are kept only where they still answer the reader's choice. Once the line stands idle after a step +or a landing, the open document holds the envelopes of what the reader hears, or the line asks the worker +for them. The panel and the waveform therefore settle on the reader's last choice once the reader stops. +A listening already running for the choice answers it, so no reading is rendered twice. A choice whose +listening failed is asked for again once the reader makes another. + ## The waveform fades while the line is busy The waveform is faded from the moment a step waits or runs until the last one lands, so a reader sees the diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index f41d2c720..faf9f05e9 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -310,6 +310,7 @@ def __init__( self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view self._reconstruction_panel_logic.on_stems_view_changed = self._reconstruction_stems_panel.update_view self._reconstruction_panel_logic.on_ownership_changed = self._reconstruction_plot_panel.update_ownership + self._reconstruction_panel_logic.on_listening_changed = self._reconstruction_instruments_logic.follow_listening self._reconstruction_panel_logic.on_audio_data_changed = self._on_audio_data_changed self._reconstruction_panel_logic.on_waveform_load_changed = self._reconstruction_plot_panel.load_waveform_data self._reconstruction_panel_logic.on_waveform_update_changed = ( diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index 7d63612a6..fb433918c 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -32,11 +32,17 @@ class InstrumentEdit: class EnvelopesOwed: """A reconstruction in front of the panel whose envelopes the worker is still rendering. - An opening, a removal, a retune and an undo send the envelopes to the worker with the sound, so - the document has channels to draw once they land. Until then the panel says they are on their - way, and an edit reaching it goes nowhere. + An opening, an edit, a retune, an undo and a new choice of what is heard send the envelopes to + the worker with the sound, so the document has channels to edit once they land, and an edit + reaching the panel meanwhile goes nowhere. + + Attributes: + opening: Whether no envelopes have landed for the document since it was opened, so the + panel has none of it to keep showing. """ + opening: bool + EditedVoice = Union[ChannelEnvelopesViewModel, InstrumentEdit, EnvelopesOwed] diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index aa2030a0f..3b3a066e5 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -80,7 +80,7 @@ def edited_instrument(self) -> Optional[EditedVoice]: return features if self._reconstruction_manager.reconstruction is not None: - return EnvelopesOwed() + return EnvelopesOwed(opening=not self._reconstruction_manager.has_landed_envelopes) return None diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index c450e24ae..be337eabc 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -11,6 +11,7 @@ ChannelEnvelopesViewModel, ) from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, InstrumentViewModel, ReconstructionInstrumentsViewModel, ) @@ -106,6 +107,14 @@ def refresh_view(self) -> None: self.call(self.on_view_changed, self._build_view_model()) self._redraw_silenced() + def follow_listening(self) -> None: + """Locks the panel while the envelopes of a new choice of what is heard are on their way, and frees it + once the choice is one whose envelopes it draws. + + The bars stay as drawn either way: the envelopes of the new choice redraw them as they land. + """ + self.call(self.on_view_changed, self._build_view_model()) + def _redraw_silenced(self) -> None: """Draws the channels an edit silenced as the document now holds them, once they stand by there.""" envelopes = self._reconstruction_envelopes() @@ -183,29 +192,29 @@ def instrument_edit(self) -> Optional[InstrumentEdit]: def _build_view_model(self) -> ReconstructionInstrumentsViewModel: """What the panel shows of the voice in front of it. - A reconstruction is shown by its channels once the worker has exported their envelopes, and - as a document on its way until then, so the panel says a document is open through an - opening, a removal, a retune and an undo. An instrument is shown by its own set. + A reconstruction is shown by its channels once the worker has exported their envelopes for + what the reader hears. While the next ones are on their way, the panel keeps the ones it + drew, and a document just opened, which has none yet, is shown as being prepared. An + instrument is shown by its own set. """ match self._editor.edited_instrument(): case InstrumentEdit() as instrument: return self._instrument_view_model(instrument) case ChannelEnvelopesViewModel() as document: return self._reconstruction_view_model(self._measured_channels(document)) - case EnvelopesOwed(): - return ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - envelopes_owed=True, - playing_channels=frozenset(), - footprint=None, - ) + case EnvelopesOwed() as owed: + return self._bare_view_model(InstrumentsShowing.PREPARING if owed.opening else InstrumentsShowing.KEPT) case None: - return ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - envelopes_owed=False, - playing_channels=frozenset(), - footprint=None, - ) + return self._bare_view_model(InstrumentsShowing.NOTHING) + + @staticmethod + def _bare_view_model(showing: InstrumentsShowing) -> ReconstructionInstrumentsViewModel: + """A view naming what the panel shows and nothing it measures, which the panel keeps or has none of.""" + return ReconstructionInstrumentsViewModel( + showing=showing, + playing_channels=frozenset(), + footprint=None, + ) def _reconstruction_view_model( self, @@ -213,8 +222,7 @@ def _reconstruction_view_model( ) -> ReconstructionInstrumentsViewModel: """What the panel shows of a reconstruction: the channels that play and what they cost.""" return ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - envelopes_owed=False, + showing=InstrumentsShowing.EDITABLE, playing_channels=playing_channels(channels), footprint=self._build_footprint(channels), ) @@ -230,8 +238,7 @@ def _instrument_view_model( writes one. """ return ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - envelopes_owed=False, + showing=InstrumentsShowing.EDITABLE, playing_channels=playing_channels(self._instrument_channels(instrument)), footprint=VoiceFootprintViewModel.from_instrument(features_footprint(instrument.features)), instrument=InstrumentViewModel(name=instrument.name), diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 3dc1990c3..fcacd1f35 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -130,6 +130,7 @@ def _open_reconstruction( belongs to the document it was made on, and an opened document starts a choice of its own. """ self._listening.release() + self._envelopes = None self._adopt_reconstruction(reconstruction_data, voice_id=voice_id, envelopes=None) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) @@ -174,17 +175,20 @@ def adopt_envelopes( reconstruction: Reconstruction, envelopes: HeardEnvelopes, ) -> bool: - """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document. + """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document heard as the + reader hears it now. A box on the stems card moves which recordings a channel is heard on, and the plot, the figures and an export all read from the same envelopes, so they follow it together once - the worker's reading lands. Envelopes exported for the document and the choice the open - ones already answer change nothing. + the worker's reading lands. A reading started before the reader moved on exports for a + choice they have left, so its envelopes are passed over and the ones answering stay. + Envelopes exported for the document and the choice the open ones already answer change + nothing. Returns: bool: True where the open document's envelopes changed. """ - if self.reconstruction is not reconstruction: + if self.reconstruction is not reconstruction or envelopes.selection != self._listening.selection: return False if self._envelopes is not None and self._envelopes.answers_for(reconstruction, envelopes.selection): @@ -197,6 +201,15 @@ def has_envelopes_for(self, selection: StemSelection) -> bool: """Whether the worker's envelopes for the open document heard as ``selection`` are in place.""" return self._envelopes is not None and self._envelopes.answers_for(self.reconstruction, selection) + @property + def has_landed_envelopes(self) -> bool: + """Whether the worker has landed envelopes for the open document since it was opened. + + An edit, a retune, an undo and a new choice of what is heard keep the ones landed last + until the worker lands the next, so the instruments panel keeps drawing them meanwhile. + """ + return self._envelopes is not None + def save_reconstruction(self, filepath: Optional[Path] = None) -> bool: """Writes the open reconstruction to disk, reporting whether the write happened. diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 44c98b438..f0dad6e63 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -195,6 +195,7 @@ def __init__( self.on_waveform_load_changed: Optional[Callable[[WaveformData], None]] = None self.on_waveform_update_changed: Optional[WaveformUpdate] = None self.on_renders_wanted: Optional[VoidCallback] = None + self.on_listening_changed: Optional[VoidCallback] = None self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None @@ -449,7 +450,9 @@ def _refresh_listening(self) -> None: """Re-answers every reading of the document after the listening choice changed. The stems card and the ribbon answer at once; the waveform, the audio and the envelopes - the instruments panel draws answer once the worker has rendered the new choice. + the instruments panel draws answer once the worker has rendered the new choice. The + choice is reported first, so the instruments panel takes no edit of envelopes read for + another choice. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -460,6 +463,7 @@ def _refresh_listening(self) -> None: self._build_stems_view_model(reconstruction_data), ) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) + self.call(self.on_listening_changed) self._show(reconstruction_data, self._loading()) @property diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 852e327e4..c207cc708 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -31,6 +31,7 @@ from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing from sampletones_core.reconstructions.reconstruction.stems.removal import ( can_remove_stem, without_stem, @@ -69,7 +70,21 @@ class Rebuild(Running): @dataclass(frozen=True) class Reading(Running): - """A reading of what the reader hears, which the worker is rendering.""" + """A reading of what the reader hears, which the worker is rendering. + + Attributes: + hearing: What the reader heard when the reading started, which is what it renders. + """ + + hearing: Hearing + + def answers( + self, + reconstruction: Optional[Reconstruction], + hearing: Hearing, + ) -> bool: + """Whether the reading renders ``reconstruction`` heard as ``hearing``.""" + return self.lands_on(reconstruction) and self.hearing == hearing class ReconstructionRewrites(CallbackMixin): @@ -110,6 +125,8 @@ def __init__( self._waiting: List[Rewrite] = [] self._running: Optional[Running] = None self._awaiting_envelopes: Optional[ChannelName] = None + self._taking = False + self._unreadable: Optional[Reading] = None self._redraw_owed: bool = False self._reported_work: Optional[Work] = None @@ -156,6 +173,7 @@ def request(self, rewrite: Rewrite) -> None: self._waiting.append(rewrite) self._advance() + self._catch_up() self._settle() def drop(self) -> None: @@ -219,8 +237,21 @@ def _enqueue_change(self, change: ChannelChange) -> None: self._waiting.append(change) def _enqueue_listening(self, listening: ListeningChange) -> None: - if not any(isinstance(step, ListeningChange) for step in self._waiting): - self._waiting.append(listening) + """Puts a listening in line where none in line or running renders what the reader hears now. + + A listening waiting reads the choice at its turn, so it answers every choice made before + it; the one running answers only the choice it started with. + """ + if any(isinstance(step, ListeningChange) for step in self._waiting): + return + + if isinstance(self._running, Reading) and self._running.answers( + self._manager.reconstruction, + self._manager.listening.hearing, + ): + return + + self._waiting.append(listening) def _enqueue_rate(self, rate_change: RateChange) -> None: match self._waiting[-1] if self._waiting else None: @@ -230,9 +261,22 @@ def _enqueue_rate(self, rate_change: RateChange) -> None: self._waiting.append(rate_change) def _advance(self) -> None: - """Takes the waiting steps in order until a job runs or the line empties.""" - while self._running is None and self._waiting: - self._take(self._waiting.pop(0)) + """Takes the waiting steps in order until a job runs or the line empties. + + A step taken, or a result landing, may ask for a step of its own, as a gesture redrawing + the document asks for a listening. The ask joins the line and is taken in its turn once + the step or the landing has returned, so a gesture never runs inside a landing or inside + another gesture. + """ + if self._taking: + return + + self._taking = True + try: + while self._running is None and self._waiting: + self._take(self._waiting.pop(0)) + finally: + self._taking = False def _take(self, step: Rewrite) -> None: match step: @@ -316,7 +360,7 @@ def _listen(self) -> None: hearing = self._manager.listening.hearing kept = self._manager.renders.held(reconstruction) - self._running = Reading(base=reconstruction) + self._running = Reading(base=reconstruction, hearing=hearing) self._regeneration.start_listening( reconstruction, hearing=hearing, @@ -370,14 +414,20 @@ def _on_result(self, result: RegenerationResult) -> None: raise RuntimeError("A regeneration result arrived while no job was running") self._running = None - match result: - case ServiceSuccess(value=outcome): - self._land(running, outcome) - case ServiceError(exception=exception): - self.call(self.on_failed, exception) - self._fail(running) + was_taking = self._taking + self._taking = True + try: + match result: + case ServiceSuccess(value=outcome): + self._land(running, outcome) + case ServiceError(exception=exception): + self.call(self.on_failed, exception) + self._fail(running) + finally: + self._taking = was_taking self._advance() + self._catch_up() self._settle() def _land( @@ -420,11 +470,38 @@ def _let_go(self, running: Running) -> None: def _fail(self, running: Running) -> None: """Tells the panel what a failed job leaves it with: a change drawn that will never land, or a sound the open document waited for that will never land. + + A failed listening is remembered, so the line asks the worker again for another choice + alone and a failure that repeats leaves the line idle. """ self._let_go(running) if isinstance(running, Reading) and running.lands_on(self._manager.reconstruction): + self._unreadable = running self.call(self.on_reading_failed) + def _catch_up(self) -> None: + """Asks the worker for the envelopes of what the reader hears, where the line stands idle without them. + + Every gesture and every landing passes here once the line has taken what it could, so an + idle line always holds the open document's envelopes for the reader's choice or is + rendering them: a reader who stops lets the last listening land what they hear. A choice + whose listening failed is left alone until the reader makes another. + """ + reconstruction = self._manager.reconstruction + if self.is_busy or reconstruction is None: + return + + hearing = self._manager.listening.hearing + if self._manager.has_envelopes_for(hearing.selection): + return + + if self._unreadable is not None and self._unreadable.answers(reconstruction, hearing): + return + + self._unreadable = None + self._waiting.append(ListeningChange()) + self._advance() + def _settle(self) -> None: """Pays a redraw owed once the line empties, and reports what the line is busy with as it changes.""" if not self.is_busy and self._redraw_owed: diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index a0a327084..5dfeb80e4 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -108,6 +108,7 @@ def __init__( self.on_bar_point_hovered: Optional[OnBarPointHoveredCallback] = None self._press: LeftPress[HeldStroke] = LeftPress() + self._locked = False _min_x = layout.graph.min_x _max_x = layout.graph.max_x @@ -173,8 +174,17 @@ def _setup_handlers(self) -> None: callback=self._on_release, ) + def set_locked(self, locked: bool) -> None: + """Takes presses on the bars, or holds them while what the bars draw is on its way, ending a press held.""" + self._locked = locked + if locked: + self._press.end() + def _on_press(self, sender: Sender) -> None: - """Starts a press on the plot, which draws the bar it lands on.""" + """Starts a press on the plot, which draws the bar it lands on, where the bars take presses.""" + if self._locked: + return + self._press.begin(HeldStroke()) self._on_mouse_action(sender) diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py index a5901260f..ba0cc7b7e 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -121,6 +121,12 @@ def set_shown(self, shown: bool) -> None: """Shows or hides the stepper, label and buttons together, which is the whole of it.""" dpg_configure_item(self._table_tag, show=shown) + def set_enabled(self, enabled: bool) -> None: + """Takes a typed value and a step of the buttons, or holds both while the value is not the reader's to move.""" + dpg_configure_item(self._input_tag, enabled=enabled) + if self._buttons is not None: + self._buttons.set_enabled(enabled) + def _build(self) -> None: self._clear_existing_items() with dpg.table( diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index 5a7f346aa..329491853 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -100,6 +100,15 @@ def set_decrement_enabled(self, enabled: bool) -> None: if self._decrement_button is not None: self._decrement_button.set_enabled(enabled) + def set_enabled(self, enabled: bool) -> None: + """Takes presses on both buttons, or holds them and ends a hold under way.""" + for button in (self._decrement_button, self._increment_button): + if button is not None: + button.set_enabled(enabled) + + if not enabled: + self._press.end() + def delete(self) -> None: """Removes the buttons and, when hold-repeat is armed, the shared mouse handler.""" self._clear_existing_items() diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index bf052863e..9a74c4a34 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -88,6 +88,7 @@ ChannelEnvelopesViewModel, ) from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, ReconstructionInstrumentsViewModel, ) from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel @@ -508,18 +509,23 @@ def update_view( set every channel reads, so it shows a single tab under its own name, and the audition takes the pitch stepper's place: a row states the note an instrument sounds at, and what the panel offers instead is the generator to hear it on. Words stand in place of the - tabs while nothing is open, and while a reconstruction's envelopes are on their way from - the worker they say the instruments are being prepared. + tabs while nothing is open, and while a document just opened waits for its first envelopes + they say the instruments are being prepared. Envelopes kept while the next ones are on + their way stay drawn as they are, and the panel takes no edit of them. """ instrument = view_model.instrument is_open = view_model.is_open dpg_set_value( self.message_tag, - self._msg_preparing if view_model.envelopes_owed else self._msg_nothing_loaded, + self._msg_preparing if view_model.showing is InstrumentsShowing.PREPARING else self._msg_nothing_loaded, ) dpg_configure_item(self.message_tag, show=not is_open) dpg_configure_item(self.tab_bar_tag, show=is_open) dpg_configure_item(self.sample_size_group_tag, show=is_open) + self._lock(view_model.is_locked) + if view_model.is_locked: + return + self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) self._show_pitch_steppers(shown=instrument is None) self._show_audition_selector(shown=instrument is not None) @@ -538,6 +544,25 @@ def update_view( channel_name in view_model.playing_channels, ) + def _lock(self, locked: bool) -> None: + """Takes no edit of the envelopes drawn while the next ones are on their way, and takes edits again once + they land. + + Every gesture writing an envelope is held: a bar pressed, a sequence typed, a pitch stepped + and an instrument exported. A view that frees the panel marks the exports anew. + """ + for channel_name, plots in self.channel_plots.items(): + for feature_key, plot in plots.items(): + plot.set_locked(locked) + dpg_configure_item(self._get_feature_text_tag(channel_name, feature_key), enabled=not locked) + + for stepper in self._pitch_steppers.values(): + stepper.set_enabled(not locked) + + if locked: + for export_button in self._export_buttons.values(): + export_button.set_enabled(False) + def _show_pitch_steppers(self, *, shown: bool) -> None: """Offers the pitch a channel measures its arpeggio against, where a reader may move it. diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 474125e68..6bec9b022 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import FrozenSet, Optional from pydantic import BaseModel @@ -16,19 +17,33 @@ class InstrumentViewModel(BaseModel, frozen=True): name: str -class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): +class InstrumentsShowing(StrEnum): + """What the instruments panel shows of the voice in front of it. + + A reconstruction's envelopes are the worker's to export. A document just opened has none yet, + so the panel says they are being prepared. An edit, a retune, an undo and a new choice of what + is heard owe the next ones, so the panel keeps the ones it drew and takes no edit until they + land. + """ + + NOTHING = "nothing" + PREPARING = "preparing" + KEPT = "kept" + EDITABLE = "editable" + + +class ReconstructionInstrumentsViewModel(BaseModel, frozen=True, extra="forbid"): """What the instruments panel renders: the voice in front of it, and how it is read. A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel reads to mark the standing-by tabs and to offer their export. An instrument is one set every - channel reads, so :attr:`instrument` is what the panel renders instead. A reconstruction's - envelopes are the worker's to export, so :attr:`envelopes_owed` says the document is open and - its channels are on their way, which the panel states in place of its tabs. + channel reads, so :attr:`instrument` is what the panel renders instead. While the envelopes + are :attr:`InstrumentsShowing.KEPT`, the panel keeps the tabs, the figures and the bars it drew + and reads nothing else of the view. """ - reconstruction_loaded: bool - envelopes_owed: bool + showing: InstrumentsShowing playing_channels: FrozenSet[ChannelName] footprint: Optional[VoiceFootprintViewModel] instrument: Optional[InstrumentViewModel] = None @@ -38,7 +53,17 @@ def edits_an_instrument(self) -> bool: """Whether the panel is showing an instrument rather than a reconstruction's channels.""" return self.instrument is not None + @property + def reconstruction_loaded(self) -> bool: + """Whether a reconstruction stands in front of the panel, its envelopes landed or on their way.""" + return self.instrument is None and self.showing is not InstrumentsShowing.NOTHING + @property def is_open(self) -> bool: - """Whether the panel has a voice to draw: an instrument, or a reconstruction whose envelopes have landed.""" - return self.edits_an_instrument or (self.reconstruction_loaded and not self.envelopes_owed) + """Whether the panel draws envelopes: an instrument's, or a reconstruction's landed or kept.""" + return self.showing in (InstrumentsShowing.KEPT, InstrumentsShowing.EDITABLE) + + @property + def is_locked(self) -> bool: + """Whether the panel keeps what it drew and takes no edit until the worker lands the next envelopes.""" + return self.showing is InstrumentsShowing.KEPT diff --git a/tests/integration/reconstruction/test_eventual_synchronization.py b/tests/integration/reconstruction/test_eventual_synchronization.py new file mode 100644 index 000000000..5c5393f76 --- /dev/null +++ b/tests/integration/reconstruction/test_eventual_synchronization.py @@ -0,0 +1,187 @@ +import random +from typing import Callable, Dict, Final, List, Tuple + +import pytest + +from sampletones_application.application import Application +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.rewrites.steps import StemRemovalRequest +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, + ReconstructionInstrumentsViewModel, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from tests.suite.application import HeldQueue, held_queue +from tests.suite.headless import app +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, taking_turns + +__all__ = ["app", "held_queue", "taking_turns"] + +STEPS: Final[int] = 40 +SEEDS: Final[range] = range(12) +LOUDEST: Final[int] = 15 +LONGEST_DRAG: Final[int] = 4 +STEMS: Final[Tuple[int, ...]] = (STEM_A_ID, STEM_B_ID) +CHANNELS: Final[Tuple[ChannelName, ...]] = (SHARED_CHANNEL, SOLE_CHANNEL) + +Gesture = Callable[[], None] + + +@pytest.fixture +def turns_sample( + app: Application, + held_queue: HeldQueue, + taking_turns: Reconstruction, +) -> Sample: + """The two-recording document added to a new project and opened on the tab, its opening landed.""" + app.project_controller.new() + with app.history.transaction(HistoryAction.ADD_SAMPLE): + sample = app.project_controller.add_sample(taking_turns, "turns") + app._reconstruction_coordinator.open_project_voice(sample.id) + _settle(app, held_queue) + return sample + + +def _land(app: Application, held_queue: HeldQueue) -> None: + """Lets the worker finish what it runs and lands its report, which may start the next step.""" + SingleThreadExecutor.join_all() + held_queue.drain() + + +def _settle(app: Application, held_queue: HeldQueue) -> None: + """Lands every step until the line stands idle.""" + _land(app, held_queue) + while app.reconstruction_rewrites.is_busy: + _land(app, held_queue) + + +def _gestures( + app: Application, + held_queue: HeldQueue, + chance: random.Random, +) -> Dict[str, Gesture]: + """Every gesture the reader can make on the open document, each drawing its own choices.""" + panel = app._reconstructions_tab._reconstruction_panel_logic + instruments = app._reconstructions_tab._reconstruction_instruments_logic + coordinator = app._reconstruction_coordinator + floor = app.history.cursor + + def tick() -> None: + stem = chance.choice(STEMS) + heard = frozenset(channel for channel in CHANNELS if chance.random() < 0.5) + panel.set_stem_channels(stem, heard) + + def boxes() -> None: + panel.set_selected_channels([channel for channel in CHANNELS if chance.random() < 0.5]) + + def solo() -> None: + panel.solo_stem(chance.choice(STEMS)) + + def drag() -> None: + items = tuple(chance.randint(0, LOUDEST) for _ in range(chance.randint(1, LONGEST_DRAG))) + instruments.handle_envelope_changed(chance.choice(CHANNELS), FeatureKey.VOLUME, Envelope[int](items=items)) + + def silence() -> None: + instruments.handle_envelope_changed(chance.choice(CHANNELS), FeatureKey.VOLUME, Envelope[int](items=(0,))) + + def remove() -> None: + coordinator.request_rewrite(StemRemovalRequest(stem_id=chance.choice(STEMS), stem_name="gone")) + + def undo_an_edit() -> None: + """Undoes the latest edit, keeping the sample's own addition, so the document stays open.""" + if app.history.cursor > floor: + app.history.undo() + + def undo() -> None: + coordinator.after_edits(undo_an_edit) + + def land() -> None: + _land(app, held_queue) + + return { + "tick": tick, + "boxes": boxes, + "solo": solo, + "drag": drag, + "silence": silence, + "remove": remove, + "undo": undo, + "land": land, + } + + +def _expect_caught_up( + app: Application, + views: List[ReconstructionInstrumentsViewModel], + walk: List[str], +) -> None: + """The open document's envelopes answer what the reader hears, the waveform owes nothing, and the instruments + panel draws the envelopes, free to edit. + """ + manager = app.reconstruction_manager + panel = app._reconstructions_tab._reconstruction_panel_logic + assert not app.reconstruction_rewrites.is_busy, walk + assert manager.reconstruction is not None, walk + assert manager.current_features is not None, walk + assert panel._owed is None, walk + assert views, walk + assert views[-1].showing is InstrumentsShowing.EDITABLE, walk + + +class TestTheInstrumentsPanelCatchesUpWithEveryWalk: + """The instruments panel keeps the envelopes it drew through every gesture on an open document, and catches + up once the reader stops. + + A seeded walk of ticks, channel boxes, solos, drags, silencings, removals and undos, each + landed or left on its way at random, ends with the line drained. Every view the panel was + handed must draw or keep envelopes, and at the end the envelopes must answer the open + document and the reader's choice, the waveform must owe nothing, and the panel must be free + to edit. A recording ticked away and back before the first listening lands is the gesture that + left the panel waiting for good: that listening landed envelopes for the choice the reader had + left. + """ + + def test_a_recording_ticked_away_and_back_before_the_listening_lands( + self, + app: Application, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + panel = app._reconstructions_tab._reconstruction_panel_logic + views: List[ReconstructionInstrumentsViewModel] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append + heard = app.reconstruction_manager.listening.heard[STEM_B_ID] + + panel.set_stem_channels(STEM_B_ID, frozenset()) + panel.set_stem_channels(STEM_B_ID, heard) + _settle(app, held_queue) + + _expect_caught_up(app, views, ["tick away", "tick back"]) + + @pytest.mark.parametrize("seed", SEEDS) + def test_every_view_draws_or_keeps_the_envelopes( + self, + seed: int, + app: Application, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + chance = random.Random(seed) + gestures = _gestures(app, held_queue, chance) + views: List[ReconstructionInstrumentsViewModel] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append + walk: List[str] = [] + + for _ in range(STEPS): + name = chance.choice(list(gestures)) + walk.append(name) + gestures[name]() + + _settle(app, held_queue) + + assert all(view.is_open for view in views), walk + _expect_caught_up(app, views, walk) 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 c3179e0d5..844f640d3 100644 --- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -140,9 +140,10 @@ def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordina assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None: + """The removal and the rate leave the envelopes owed, so the line reads them before the fade ends, once.""" work = [entry.args[0] for entry in settled._tab.show_work.call_args_list] - assert work == [Work.REBUILDING, None] + assert work == [Work.REBUILDING, Work.READING, None] class TestTheFirstFramesOfTheReadersOwn: diff --git a/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py b/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py new file mode 100644 index 000000000..ca06679b8 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py @@ -0,0 +1,120 @@ +import operator +from typing import Final, List, Optional, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import leave_letting_it_go, load_from_the_browser +from automation.views.instruments import read_takes_edits, read_words +from automation.vocabulary.instruments import NOTHING_LOADED, PREPARING +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world + +CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +REMOVED_KEY: Final[str] = "0" +TICKED_KEY: Final[str] = "1" + +CardReading = Tuple[Optional[str], bool] + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """The home holds the two-minute reconstruction over three recordings taking turns.""" + return alternating_document_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The application opens with no reconstruction and no project.""" + return Startup(reconstruction=None, project=None) + + +class TestTheCardWhileTheSoundIsOnItsWay: + """The instruments card keeps the bars it drew while the sound of a change is on its way, takes no edit of + them meanwhile, and takes edits again once the sound lands. + + A document just opened has no bars yet, so the card says the instruments are being prepared, + and never that nothing is loaded. A removal and a recording ticked away and back at once + each send the sound and the envelopes to the worker. The scenario records the card's words and + whether a sequence field takes edits after every frame through each gesture: the bars stay, + the field is held for a while, and once the sound lands the field takes edits and the bars + stand drawn. + """ + + def test_the_card_keeps_its_bars_and_holds_edits_until_the_sound_lands(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + stems = reconstructions.stems + preparing = screen.words(PREPARING) + nothing_loaded = screen.words(NOTHING_LOADED) + watched: List[ChannelName] = [] + + def drawn() -> Tuple[ChannelName, ...]: + return tuple(channel for channel in CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME)) + + def read_card() -> CardReading: + return (read_words(), read_takes_edits(watched[0], FeatureKey.VOLUME)) + + def free_with_its_bars(screen: Screen) -> None: + screen.expect(screen.sound_settled, bool, description="the sound landed") + screen.expect( + lambda: instruments.takes_edits(watched[0], FeatureKey.VOLUME), + bool, + description="the card taking edits", + ) + assert instruments.says() is None + assert drawn() + + def kept_and_held_meanwhile(readings: List[CardReading]) -> None: + assert all(words is None for words, _ in readings), readings + assert any(not takes_edits for _, takes_edits in readings), readings + + def open_it_reading_the_words(screen: Screen) -> None: + assert instruments.says() == nothing_loaded + + with screen.record(read_words) as recording: + load_from_the_browser(screen, LONG_RECONSTRUCTION) + screen.expect( + lambda: reconstructions.shows_open(LONG_RECONSTRUCTION), + bool, + description=f"{LONG_RECONSTRUCTION.name} open", + ) + screen.expect(screen.sound_settled, bool, description="the sound of the opening landed") + + seen = recording.values() + assert preparing in seen, seen + assert nothing_loaded not in seen[seen.index(preparing) :], seen + screen.expect(drawn, bool, description="the bars drawn") + watched.append(drawn()[0]) + free_with_its_bars(screen) + + def remove_one_keeping_the_bars(screen: Screen) -> None: + stems.remove(REMOVED_KEY) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + + with screen.record(read_card) as recording: + stems.remove_prompt.confirm() + screen.expect(lambda: stems.has_row(REMOVED_KEY), operator.not_, description="the recording gone") + screen.expect(screen.sound_settled, bool, description="the sound of the rest landed") + + kept_and_held_meanwhile(recording.values()) + free_with_its_bars(screen) + + def tick_one_away_and_back_keeping_the_bars(screen: Screen) -> None: + with screen.record(read_card) as recording: + stems.tick(TICKED_KEY) + stems.tick(TICKED_KEY) + screen.expect(lambda: stems.hears(TICKED_KEY), bool, description="the recording heard again") + screen.expect(screen.sound_settled, bool, description="the sound of the choice landed") + + kept_and_held_meanwhile(recording.values()) + free_with_its_bars(screen) + + screen.scenario( + open_it_reading_the_words, + remove_one_keeping_the_bars, + tick_one_away_and_back_keeping_the_bars, + leave_letting_it_go, + ).run() diff --git a/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py b/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py deleted file mode 100644 index 85e4b5fa7..000000000 --- a/tests/screens/reconstructions/instruments/test_words_while_the_sound_is_on_its_way.py +++ /dev/null @@ -1,87 +0,0 @@ -import operator -from typing import Final, List, Optional, Tuple - -import pytest - -from automation.application.startup import Startup -from automation.screen import Screen -from automation.steps.reconstructions import leave_letting_it_go, load_from_the_browser -from automation.views.instruments import read_words -from automation.vocabulary.instruments import NOTHING_LOADED, PREPARING -from automation.worlds.home import World -from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world - -CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) -RECORDING_KEY: Final[str] = "0" - - -@pytest.fixture(name="world") -def world_fixture() -> World: - """The home holds the two-minute reconstruction over three recordings taking turns.""" - return alternating_document_world() - - -@pytest.fixture(name="startup") -def startup_fixture() -> Startup: - """The application opens with no reconstruction and no project.""" - return Startup(reconstruction=None, project=None) - - -class TestTheWordsWhileTheSoundIsOnItsWay: - """The instruments card says the instruments are being prepared while a document's envelopes are rendered, - and from the moment a document is open it never says none is loaded. - - An opening and a removal send the envelopes to the worker with the sound, so the card has no bars to - draw until they land. The scenario records the card's words after every frame through the opening of - the two-minute document and through the removal of one of its recordings. Each time the words say the - instruments are being prepared, the words saying nothing is loaded never follow them, and the bars - come with the sound. - """ - - def test_the_card_says_what_is_on_its_way_and_then_draws_it(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - instruments = reconstructions.instruments - stems = reconstructions.stems - preparing = screen.words(PREPARING) - nothing_loaded = screen.words(NOTHING_LOADED) - - def drawn() -> Tuple[ChannelName, ...]: - return tuple(channel for channel in CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME)) - - def the_tabs_stand(screen: Screen) -> None: - screen.expect(instruments.says, operator.not_, description="the tabs standing") - assert screen.expect(drawn, bool, description="the bars drawn") - - def open_it_reading_the_words(screen: Screen) -> None: - assert instruments.says() == nothing_loaded - - with screen.record(read_words) as recording: - load_from_the_browser(screen, LONG_RECONSTRUCTION) - screen.expect( - lambda: reconstructions.shows_open(LONG_RECONSTRUCTION), - bool, - description=f"{LONG_RECONSTRUCTION.name} open", - ) - screen.expect(screen.sound_settled, bool, description="the sound of the opening landed") - - seen: List[Optional[str]] = recording.values() - assert preparing in seen, seen - assert nothing_loaded not in seen[seen.index(preparing) :], seen - the_tabs_stand(screen) - - def remove_one_reading_the_words(screen: Screen) -> None: - stems.remove(RECORDING_KEY) - screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") - - with screen.record(read_words) as recording: - stems.remove_prompt.confirm() - screen.expect(lambda: stems.has_row(RECORDING_KEY), operator.not_, description="the recording gone") - screen.expect(screen.sound_settled, bool, description="the sound of the rest landed") - - seen = recording.values() - assert preparing in seen, seen - assert nothing_loaded not in seen, seen - the_tabs_stand(screen) - - screen.scenario(open_it_reading_the_words, remove_one_reading_the_words, leave_letting_it_go).run() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index b81097b02..e062bcd4e 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -1938,12 +1938,12 @@ def test_a_removal_leaves_the_envelopes_owed_until_its_listening_lands( tab: MagicMock, turns_sample: Sample, ) -> None: + """The line asks for the listening the removal owes itself, and the envelopes answer once it lands.""" _remove(following_coordinator, STEM_B_ID) - held_queue.drain() assert reconstruction_manager.current_features is None tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + tab.redraw_listening.assert_not_called() - following_coordinator.request_rewrite(ListeningChange()) held_queue.drain() assert reconstruction_manager.current_features is not None 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 5d1d1c02b..33564e055 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -422,6 +422,70 @@ def test_a_waiting_gesture_keeps_its_place( gesture.assert_called_once_with() +class TestAGestureRunsInItsTurn: + """A gesture waiting in line runs once the step before it has returned, never inside it. + + A landing asks for a listening where the screen holds no sound for what it shows, and a + gesture such as an undo redraws the document and asks the same way. Each ask joins the line + and is taken in its turn, so a gesture never runs inside a landing or inside another gesture, + and the history sees each restore whole. + """ + + def test_a_gesture_waits_for_the_landing_that_asks_for_a_listening( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + ran_inside_the_landing: List[int] = [] + held_when_the_gesture_ran: List[int] = [] + gesture = MagicMock(side_effect=lambda: held_when_the_gesture_ran.append(len(regeneration.held))) + adopt = rewrites.on_edit + + def landing(edit: ReconstructionEdit) -> None: + assert adopt is not None + adopt(edit) + rewrites.request(ListeningChange()) + ran_inside_the_landing.append(gesture.call_count) + + rewrites.on_edit = landing + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(AfterEdits(gesture=gesture)) + + regeneration.finish() + + assert ran_inside_the_landing == [0] + gesture.assert_called_once_with() + assert held_when_the_gesture_ran == [0] + assert len(regeneration.held) == 1 + assert isinstance(regeneration.held[0], ListeningJob) + + def test_a_gesture_that_asks_for_a_step_runs_the_next_gesture_after_it( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + second = MagicMock() + ran_inside_the_first: List[int] = [] + + def first_gesture() -> None: + rewrites.request(ListeningChange()) + ran_inside_the_first.append(second.call_count) + + first = MagicMock(side_effect=first_gesture) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(AfterEdits(gesture=first)) + rewrites.request(AfterEdits(gesture=second)) + + regeneration.finish() + + first.assert_called_once_with() + second.assert_called_once_with() + assert ran_inside_the_first == [0] + assert len(regeneration.held) == 1 + assert isinstance(regeneration.held[0], ListeningJob) + + class TestThePanelDrawsWhatTheDocumentWillHold: """The changes on their way are written over the document's envelopes, in the order they were made.""" @@ -494,7 +558,22 @@ def test_nothing_on_its_way_draws_the_document( class TestAStepThatReshapesTheDocument: - """A change drawn while a removal or a whole-document gesture waits is refused, and the panel redrawn once the line empties.""" + """A change drawn while a removal or a whole-document gesture waits is refused, and the panel redrawn once the line empties. + + A removal leaves the envelopes of the document it leaves owed, so the line reads them before it + empties. + """ + + @staticmethod + def _empty_the_line(rewrites: ReconstructionRewrites, regeneration: HeldRegeneration) -> List[type]: + """Lets every held job finish, the ones the landings ask for included, and names the kinds that ran.""" + ran: List[type] = [] + while regeneration.held: + ran.append(type(regeneration.held[0])) + regeneration.finish() + + assert not rewrites.is_busy + return ran @pytest.mark.parametrize("reshaping", ("removal", "gesture")) def test_the_change_is_refused( @@ -506,9 +585,10 @@ def test_the_change_is_refused( rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) rewrites.request(_removal(STEM_B_ID) if reshaping == "removal" else AfterEdits(gesture=MagicMock())) rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) - regeneration.finish() - assert regeneration.held == () + ran = self._empty_the_line(rewrites, regeneration) + + assert ran.count(RebuildJob) == 1 @pytest.mark.parametrize("reshaping", ("removal", "gesture")) def test_the_panel_is_redrawn_once_the_line_empties( @@ -523,7 +603,7 @@ def test_the_panel_is_redrawn_once_the_line_empties( rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) assert outcome.dropped == 0 - regeneration.finish() + self._empty_the_line(rewrites, regeneration) assert outcome.dropped == 1 @@ -590,15 +670,31 @@ def test_steps_taken_at_once_leave_the_line_empty( rewrites: ReconstructionRewrites, outcome: Outcome, ) -> None: - gesture = MagicMock() + first = MagicMock() + second = MagicMock() - rewrites.request(_removal(STEM_B_ID)) - rewrites.request(AfterEdits(gesture=gesture)) + rewrites.request(AfterEdits(gesture=first)) + rewrites.request(AfterEdits(gesture=second)) - gesture.assert_called_once_with() + first.assert_called_once_with() + second.assert_called_once_with() assert outcome.work == [] assert not rewrites.is_busy + def test_a_removal_taken_at_once_leaves_the_line_reading_what_it_owes( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + """A removal leaves the envelopes of the document it leaves owed, so the line reads them before it empties.""" + rewrites.request(_removal(STEM_B_ID)) + + assert outcome.work == [Work.READING] + regeneration.finish() + assert outcome.work == [Work.READING, None] + assert not rewrites.is_busy + class TestTheRecordingsAJobCarries: """A rebuild and a listening take the open document's recordings with them, so the worker mixes the original @@ -910,12 +1006,14 @@ def test_one_listening_waiting_stands_for_every_one_asked( assert [type(job) for job in regeneration.held] == [ListeningJob] - def test_a_listening_asked_while_one_runs_waits_its_turn( + def test_a_listening_asked_while_one_runs_for_a_moved_choice_waits_its_turn( self, rewrites: ReconstructionRewrites, regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, ) -> None: rewrites.request(ListeningChange()) + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) rewrites.request(ListeningChange()) @@ -1000,3 +1098,108 @@ def test_a_listening_fills_the_line( assert outcome.work == [Work.READING, None] assert not rewrites.is_busy + + +class TestTheLineCatchesUpWithWhatIsHeard: + """Once the line stands idle, the open document's envelopes answer what the reader hears. + + A listening reads the choice standing at its turn, and the reader may move on before it + lands. Its envelopes are kept only where they still answer the choice, and an idle line + holding none for it asks the worker once more. A listening that failed is asked again only + for another choice, so a failure leaves the line idle. + """ + + def test_a_listening_landing_after_the_reader_went_back_leaves_the_envelopes_answering( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + """A recording ticked away and back before the first listening lands, as two quick clicks do.""" + listening = reconstruction_manager.listening + heard = listening.heard[STEM_B_ID] + listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(ListeningChange()) + listening.set_channels(STEM_B_ID, heard) + + regeneration.finish() + + assert reconstruction_manager.current_features is not None + assert regeneration.held == () + assert not rewrites.is_busy + + def test_an_idle_line_holding_no_envelopes_for_the_choice_asks_for_them( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + """A choice the screen asked nothing for still reaches the worker once the line takes a step.""" + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + + rewrites.request(AfterEdits(gesture=MagicMock())) + + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert reconstruction_manager.current_features is not None + assert not rewrites.is_busy + + def test_a_listening_asked_while_one_runs_for_the_same_choice_adds_no_job( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(ListeningChange()) + + rewrites.request(ListeningChange()) + regeneration.finish() + + assert regeneration.held == () + assert not rewrites.is_busy + + def test_a_listening_asked_while_one_runs_for_another_choice_follows_it( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + listening = reconstruction_manager.listening + listening.set_channels(STEM_B_ID, frozenset()) + rewrites.request(ListeningChange()) + listening.set_channels(STEM_A_ID, frozenset()) + + rewrites.request(ListeningChange()) + regeneration.finish() + + assert [type(job) for job in regeneration.held] == [ListeningJob] + regeneration.finish() + assert reconstruction_manager.current_features is not None + assert not rewrites.is_busy + + def test_a_failed_listening_is_not_asked_again_for_the_same_choice( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + listening = reconstruction_manager.listening + listening.set_channels(STEM_B_ID, frozenset()) + monkeypatch.setattr(service_module, "heard_renders", MagicMock(side_effect=RuntimeError("rendering failed"))) + rewrites.request(ListeningChange()) + + regeneration.finish() + rewrites.request(AfterEdits(gesture=MagicMock())) + + assert len(outcome.failures) == 1 + assert regeneration.held == () + assert not rewrites.is_busy + + monkeypatch.undo() + listening.set_channels(STEM_A_ID, frozenset()) + rewrites.request(AfterEdits(gesture=MagicMock())) + + assert [type(job) for job in regeneration.held] == [ListeningJob] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index e1e75268e..dc1b649cd 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -91,11 +91,24 @@ def test_a_reconstruction_whose_envelopes_are_on_their_way_answers_so( editor: InstrumentEditor, reconstruction_manager: MagicMock, ) -> None: - """An opening, a removal, a retune and an undo leave the envelopes to the worker, with the document open.""" + """An edit, a retune, an undo and a new choice of what is heard leave the envelopes to the worker.""" reconstruction_manager.reconstruction = MagicMock(spec=Reconstruction) reconstruction_manager.current_features = None + reconstruction_manager.has_landed_envelopes = True - assert isinstance(editor.edited_instrument(), EnvelopesOwed) + assert editor.edited_instrument() == EnvelopesOwed(opening=False) + + def test_a_reconstruction_just_opened_answers_with_the_opening( + self, + editor: InstrumentEditor, + reconstruction_manager: MagicMock, + ) -> None: + """No envelopes have landed for a document just opened, so the panel has none of it to keep.""" + reconstruction_manager.reconstruction = MagicMock(spec=Reconstruction) + reconstruction_manager.current_features = None + reconstruction_manager.has_landed_envelopes = False + + assert editor.edited_instrument() == EnvelopesOwed(opening=True) def test_an_instrument_answers_with_what_it_states( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 02d0c37e2..dc3ad1f21 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -19,6 +19,7 @@ ChannelEnvelopesViewModel, ) from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, ReconstructionInstrumentsViewModel, ) from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -780,11 +781,11 @@ def test_a_redraw_draws_the_changes_on_their_way( class TestAReconstructionWhoseEnvelopesAreOnTheirWay: - """The panel says a document is open while the worker renders its envelopes, and draws them once they land. + """The panel keeps the envelopes it drew while the worker renders the next ones, and takes no edit of them. - An opening, a removal, a retune and an undo leave the envelopes owed to the worker, so until they - land the document has no channels to draw. The panel keeps saying so, as a document on its way, - and an edit reaching it meanwhile goes nowhere. + An edit, a retune, an undo and a new choice of what is heard leave the envelopes owed to the + worker. The panel keeps what it drew, locked, until the next ones land and are drawn. A + document just opened has none to keep, so the panel says they are being prepared. """ @pytest.fixture @@ -794,53 +795,57 @@ def owed_logic( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> ReconstructionInstrumentsLogic: - """The panel's logic over an open document whose envelopes the worker has yet to export.""" + """The panel's logic over an open document whose next envelopes the worker has yet to export.""" mock_reconstruction_manager.reconstruction = reconstruction_factory() mock_reconstruction_manager.current_features = None + mock_reconstruction_manager.has_landed_envelopes = True return instruments_logic - def test_the_display_says_a_document_is_on_its_way( + def test_the_display_keeps_what_it_drew_locked( self, owed_logic: ReconstructionInstrumentsLogic, ) -> None: views: List[ReconstructionInstrumentsViewModel] = [] + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] owed_logic.on_view_changed = views.append + owed_logic.on_feature_data_changed = drawn.append owed_logic.update_display() - assert len(views) == 1 + assert [view.showing for view in views] == [InstrumentsShowing.KEPT] assert views[0].reconstruction_loaded - assert views[0].envelopes_owed - assert not views[0].is_open - assert views[0].playing_channels == frozenset() - assert views[0].footprint is None + assert views[0].is_open + assert views[0].is_locked + assert drawn == [None] - def test_the_display_draws_no_envelopes_yet( + def test_a_refresh_keeps_it_locked( self, owed_logic: ReconstructionInstrumentsLogic, ) -> None: - drawn: List[Optional[ChannelEnvelopesViewModel]] = [] - owed_logic.on_feature_data_changed = drawn.append + """A retune refreshes the figures of a document whose envelopes it left to the worker.""" + views: List[ReconstructionInstrumentsViewModel] = [] + owed_logic.on_view_changed = views.append - owed_logic.update_display() + owed_logic.refresh_view() - assert drawn == [None] + assert [view.showing for view in views] == [InstrumentsShowing.KEPT] - def test_a_refresh_says_the_same( + def test_a_document_just_opened_says_the_instruments_are_being_prepared( self, owed_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, ) -> None: - """A retune refreshes the figures of a document whose envelopes it left to the worker.""" + mock_reconstruction_manager.has_landed_envelopes = False views: List[ReconstructionInstrumentsViewModel] = [] owed_logic.on_view_changed = views.append - owed_logic.refresh_view() + owed_logic.update_display() - assert len(views) == 1 + assert [view.showing for view in views] == [InstrumentsShowing.PREPARING] assert views[0].reconstruction_loaded - assert views[0].envelopes_owed + assert not views[0].is_open - def test_an_envelope_edit_goes_nowhere_and_keeps_the_words( + def test_an_envelope_edit_goes_nowhere_and_keeps_the_lock( self, owed_logic: ReconstructionInstrumentsLogic, ) -> None: @@ -852,10 +857,9 @@ def test_an_envelope_edit_goes_nowhere_and_keeps_the_words( owed_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) assert changes == [] - assert len(views) == 1 - assert views[0].envelopes_owed + assert [view.showing for view in views] == [InstrumentsShowing.KEPT] - def test_a_pitch_change_goes_nowhere_and_keeps_the_words( + def test_a_pitch_change_goes_nowhere_and_keeps_the_lock( self, owed_logic: ReconstructionInstrumentsLogic, ) -> None: @@ -867,21 +871,41 @@ def test_a_pitch_change_goes_nowhere_and_keeps_the_words( owed_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) assert changes == [] - assert len(views) == 1 - assert views[0].envelopes_owed + assert [view.showing for view in views] == [InstrumentsShowing.KEPT] - def test_the_landing_draws_the_channels( + def test_the_landing_draws_the_channels_and_frees_the_panel( self, owed_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, ) -> None: views: List[ReconstructionInstrumentsViewModel] = [] + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] owed_logic.on_view_changed = views.append + owed_logic.on_feature_data_changed = drawn.append owed_logic.update_display() mock_reconstruction_manager.current_features = _heard_features(mock_reconstruction_manager.reconstruction) owed_logic.update_display() - assert [view.envelopes_owed for view in views] == [True, False] - assert views[-1].is_open + assert [view.showing for view in views] == [InstrumentsShowing.KEPT, InstrumentsShowing.EDITABLE] + assert not views[-1].is_locked assert views[-1].playing_channels + assert drawn[-1] is not None + + def test_a_new_choice_locks_the_panel_and_a_choice_it_draws_frees_it( + self, + owed_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + """A box ticked away and back: the bars stay as drawn throughout, so only the lock follows the choice.""" + views: List[ReconstructionInstrumentsViewModel] = [] + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + owed_logic.on_view_changed = views.append + owed_logic.on_feature_data_changed = drawn.append + + owed_logic.follow_listening() + mock_reconstruction_manager.current_features = _heard_features(mock_reconstruction_manager.reconstruction) + owed_logic.follow_listening() + + assert [view.showing for view in views] == [InstrumentsShowing.KEPT, InstrumentsShowing.EDITABLE] + assert drawn == [] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 3fc78f640..fbf306c37 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -908,6 +908,63 @@ def test_a_reading_for_another_document_is_passed_over( assert reconstruction_manager.has_envelopes_for(selection) assert reconstruction_manager.current_features is installed + def test_a_reading_for_a_choice_the_reader_has_left_is_passed_over( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A reading started before the reader ticked a box back lands envelopes for the choice they left.""" + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + selection = reconstruction_manager.listening.selection + reconstruction_manager.adopt_envelopes(reconstruction, exported(reconstruction, selection)) + installed = reconstruction_manager.current_features + heard = reconstruction_manager.listening.heard + _mute(reconstruction_manager) + left = exported(reconstruction, reconstruction_manager.listening.selection) + for stem_id, channels in heard.items(): + reconstruction_manager.listening.set_channels(stem_id, channels) + + changed = reconstruction_manager.adopt_envelopes(reconstruction, left) + + assert not changed + assert reconstruction_manager.current_features is installed + + def test_an_opening_starts_with_no_envelopes_landed( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.adopt_envelopes( + reconstruction, exported(reconstruction, reconstruction_manager.listening.selection) + ) + assert reconstruction_manager.has_landed_envelopes + + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), name="Other", voice_id=SAMPLE_VOICE_ID + ) + + assert not reconstruction_manager.has_landed_envelopes + + def test_an_edit_keeps_the_envelopes_landed_last( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A removal, a retune and an undo leave the envelopes owed, and the ones landed last stand meanwhile.""" + reconstruction = reconstruction_factory() + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + reconstruction_manager.adopt_envelopes( + reconstruction, exported(reconstruction, reconstruction_manager.listening.selection) + ) + + reconstruction_manager.apply_edited(reconstruction_factory(), None) + + assert reconstruction_manager.current_features is None + assert reconstruction_manager.has_landed_envelopes + def test_an_edit_arriving_with_envelopes_installs_them( self, reconstruction_manager: ReconstructionManager, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 7302ee23e..db742faf4 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -895,6 +895,43 @@ def test_a_gesture_asks_once_and_draws_nothing_yet( drawn.assert_not_called() played.assert_not_called() + @pytest.mark.parametrize("gesture", ("tick", "solo")) + def test_a_new_choice_of_recordings_is_reported_before_it_is_asked_for( + self, + gesture: str, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + recorded_data: ReconstructionData, + ) -> None: + """The instruments panel locks before the worker is asked for the envelopes of the new choice.""" + _open(mock_reconstruction_manager, recorded_data) + order: List[str] = [] + panel_logic.on_listening_changed = lambda: order.append("reported") + panel_logic.on_renders_wanted = lambda: order.append("asked") + + if gesture == "tick": + panel_logic.set_stem_channels(1, frozenset()) + else: + panel_logic.solo_stem(0) + + assert order == ["reported", "asked"] + + def test_a_channel_box_changes_no_choice_of_recordings( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + recorded_data: ReconstructionData, + ) -> None: + """The envelopes answer the recordings heard, which the boxes above the waveform leave as they are.""" + _open(mock_reconstruction_manager, recorded_data) + reported = MagicMock() + panel_logic.on_listening_changed = reported + panel_logic.on_renders_wanted = MagicMock() + + panel_logic.set_selected_channels([]) + + reported.assert_not_called() + def test_the_showing_happens_once_the_sound_lands( self, panel_logic: ReconstructionPanelLogic, diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 62650c41b..34d4ac39f 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -64,7 +64,10 @@ from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel from sampletones_application.view_model.reconstruction.envelopes import ChannelEnvelopesViewModel -from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel +from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, + ReconstructionInstrumentsViewModel, +) from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.audio import CurrentDevice from sampletones_core.constants.algorithm import UNIT_DRIVE @@ -736,14 +739,14 @@ def test_an_opening_says_the_envelopes_are_on_their_way_until_they_land( app.reconstruction_manager.load_reconstruction(path) assert views[-1].reconstruction_loaded - assert views[-1].envelopes_owed + assert views[-1].showing is InstrumentsShowing.PREPARING assert not views[-1].is_open SingleThreadExecutor.join_all() held_queue.drain() + assert views[-1].showing is InstrumentsShowing.EDITABLE assert views[-1].is_open - assert not views[-1].envelopes_owed class TestAGestureOnTheWholeDocumentWaitsForTheEdits: diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py index 54b2dfa7a..e7cd2f0b1 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py @@ -43,6 +43,7 @@ def _graph() -> GUIBarGraph: ) } graph._press = LeftPress() + graph._locked = False graph.on_bar_point_clicked = None graph.on_bar_point_hovered = None return graph @@ -112,6 +113,37 @@ def _with_bar(index: int, value: float) -> List[int]: return expected +class TestBarsHeldWhileTheirValuesAreOnTheirWay: + """A locked plot takes no press, ends one held, and takes presses again once freed.""" + + def test_a_press_on_a_locked_plot_writes_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + graph.set_locked(True) + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == list(BAR_VALUES) + + def test_locking_ends_a_press_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + _press(graph, monkeypatch, (0.5, PRESSED_VALUE)) + + graph.set_locked(True) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert graph.layers[LAYER_NAME].y_data[last_bar] == BAR_VALUES[last_bar] + + def test_a_freed_plot_takes_a_press_again(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + graph.set_locked(True) + graph.set_locked(False) + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + class TestPressingTheBars: """A press on the grid the bars stand on writes the value it lands at.""" diff --git a/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py b/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py index db9b27f84..9d8879c59 100644 --- a/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py +++ b/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py @@ -1,10 +1,12 @@ from typing import Any, Dict, List, Tuple +from unittest.mock import MagicMock import pytest from sampletones_application.layout.general.pitch_stepper import PitchStepperLayout from sampletones_application.ui.elements import pitch_stepper as pitch_stepper_module from sampletones_application.ui.elements.pitch_stepper import GUIPitchStepper +from sampletones_application.ui.elements.plus_minus_buttons import GUIPlusMinusButtons from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH from sampletones_core.utils.pitch_kind import ( @@ -82,6 +84,42 @@ def _listen(stepper: GUIPitchStepper) -> List[int]: return reported +class TestHoldingTheStepper: + """A stepper not the reader's to move takes neither a typed value nor a step of its buttons.""" + + def test_disabling_holds_the_input_and_the_buttons(self, monkeypatch: pytest.MonkeyPatch) -> None: + configured: Dict[Any, Dict[str, Any]] = {} + monkeypatch.setattr( + pitch_stepper_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.setdefault(tag, {}).update(kwargs), + ) + stepper = _stepper(kind=PITCH_VALUE_KIND, value=60) + buttons = MagicMock(spec=GUIPlusMinusButtons) + stepper._buttons = buttons + + stepper.set_enabled(False) + + assert configured[stepper._input_tag]["enabled"] is False + buttons.set_enabled.assert_called_once_with(False) + + def test_enabling_frees_them(self, monkeypatch: pytest.MonkeyPatch) -> None: + configured: Dict[Any, Dict[str, Any]] = {} + monkeypatch.setattr( + pitch_stepper_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.setdefault(tag, {}).update(kwargs), + ) + stepper = _stepper(kind=PITCH_VALUE_KIND, value=60) + buttons = MagicMock(spec=GUIPlusMinusButtons) + stepper._buttons = buttons + + stepper.set_enabled(True) + + assert configured[stepper._input_tag]["enabled"] is True + buttons.set_enabled.assert_called_once_with(True) + + class TestSetValue: def test_clamps_into_range(self, recorder: _ValueRecorder) -> None: stepper = _stepper(kind=PITCH_VALUE_KIND, value=60) diff --git a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py index 967d39e26..106873b50 100644 --- a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py +++ b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, List, Optional +from typing import Callable, List, Optional, cast from unittest.mock import MagicMock import dearpygui.dearpygui as dpg @@ -88,6 +88,30 @@ def test_step_down_calls_decrement(self, buttons: GUIPlusMinusButtons) -> None: assert calls == ["decrement"] +class TestHoldingBothButtons: + """A pair not the reader's to press holds both buttons and ends a hold under way.""" + + def test_disabling_holds_both_buttons_and_ends_the_hold( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() + + buttons.set_enabled(False) + + cast(MagicMock, buttons._increment_button).set_enabled.assert_called_once_with(False) + cast(MagicMock, buttons._decrement_button).set_enabled.assert_called_once_with(False) + assert _frame(buttons, 2 * FIRST_REPEAT) is None + + def test_enabling_frees_both_buttons(self, buttons: GUIPlusMinusButtons) -> None: + buttons.set_enabled(True) + + cast(MagicMock, buttons._increment_button).set_enabled.assert_called_once_with(True) + cast(MagicMock, buttons._decrement_button).set_enabled.assert_called_once_with(True) + + class TestAHoldBelongsToTheButtonItWentDownOn: """A press on a button repeats while held over it, and a press carried in from elsewhere steps nothing.""" diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index d3caa53c8..ea428ca42 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -38,6 +38,7 @@ from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentsShowing, InstrumentViewModel, ReconstructionInstrumentsViewModel, ) @@ -88,15 +89,19 @@ ) NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - envelopes_owed=False, + showing=InstrumentsShowing.NOTHING, playing_channels=frozenset(), footprint=None, ) ENVELOPES_ON_THEIR_WAY: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - envelopes_owed=True, + showing=InstrumentsShowing.PREPARING, + playing_channels=frozenset(), + footprint=None, +) + +ENVELOPES_KEPT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( + showing=InstrumentsShowing.KEPT, playing_channels=frozenset(), footprint=None, ) @@ -105,8 +110,7 @@ PREPARING_KEY: Final[str] = "reconstructions.instruments.message.preparing" ONE_INSTRUMENT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - envelopes_owed=False, + showing=InstrumentsShowing.EDITABLE, playing_channels=frozenset((ChannelName.PULSE1,)), footprint=VoiceFootprintViewModel.from_instrument(LARGEST_PULSE), instrument=InstrumentViewModel(name="lead"), @@ -123,8 +127,7 @@ def build_view_model( ) -> ReconstructionInstrumentsViewModel: """A loaded reconstruction playing the given channels, each measured as given.""" return ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - envelopes_owed=False, + showing=InstrumentsShowing.EDITABLE, playing_channels=frozenset(channel_footprints), footprint=VoiceFootprintViewModel.from_footprints(channel_footprints), ) @@ -778,6 +781,95 @@ def test_no_reconstruction_states_no_figures( assert set(written) == {panel.message_tag} +class TestTheEnvelopesKeptWhileTheNextAreOnTheirWay: + """The panel keeps its tabs, figures and bars while the next envelopes are on their way, and takes no edit. + + The bars, the sequence fields, the pitch steppers and the exports are held, and a view freeing + the panel takes edits again and marks the exports anew. + """ + + @pytest.fixture + def enabled(self, monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]: + """Records which items the panel enables, beside what it shows.""" + flags: Dict[str, bool] = {} + shown_before = instruments_module.dpg_configure_item + + def configure(tag: str, **kwargs: object) -> None: + shown_before(tag, **kwargs) + state = kwargs.get("enabled") + if isinstance(state, bool): + flags[tag] = state + + monkeypatch.setattr(instruments_module, "dpg_configure_item", configure) + return flags + + @staticmethod + def _held_parts(panel: GUIReconstructionInstrumentsPanel) -> Dict[str, MagicMock]: + """Stands in for every bar plot, pitch stepper and export button the panel holds, keyed by a name.""" + parts: Dict[str, MagicMock] = {} + for channel_name in ChannelName.items(): + stepper = MagicMock() + panel._pitch_steppers[channel_name] = stepper + parts[f"stepper.{channel_name}"] = stepper + button = MagicMock(spec=GUIButton) + panel._export_buttons[channel_name] = button + parts[f"export.{channel_name}"] = button + panel.channel_plots[channel_name] = {} + for feature_key in panel._generator_features(channel_name): + plot = MagicMock() + panel.channel_plots[channel_name][feature_key] = plot + parts[f"plot.{channel_name}.{feature_key}"] = plot + + return parts + + def test_kept_envelopes_keep_the_tabs_and_hold_every_edit( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + written: Dict[str, str], + enabled: Dict[str, bool], + ) -> None: + parts = self._held_parts(panel) + + panel.update_view(ENVELOPES_KEPT) + + assert shown[panel.message_tag] is False + assert shown[panel.tab_bar_tag] is True + assert panel.sample_size_tag not in written + for name, part in parts.items(): + if name.startswith("plot."): + part.set_locked.assert_called_once_with(True) + else: + part.set_enabled.assert_called_once_with(False) + fields = [ + panel._get_feature_text_tag(channel_name, feature_key) + for channel_name in ChannelName.items() + for feature_key in panel._generator_features(channel_name) + ] + assert all(enabled[field] is False for field in fields) + + def test_a_landed_view_frees_every_edit_and_marks_the_exports_anew( + self, + panel: GUIReconstructionInstrumentsPanel, + enabled: Dict[str, bool], + ) -> None: + parts = self._held_parts(panel) + panel.update_view(ENVELOPES_KEPT) + for part in parts.values(): + part.reset_mock() + + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) + + for name, part in parts.items(): + if name.startswith("plot."): + part.set_locked.assert_called_once_with(False) + elif name.startswith("stepper."): + part.set_enabled.assert_called_once_with(True) + cast(MagicMock, parts[f"export.{ChannelName.PULSE1}"]).set_enabled.assert_called_with(True) + cast(MagicMock, parts[f"export.{ChannelName.PULSE2}"]).set_enabled.assert_called_with(False) + assert all(state is True for state in enabled.values()) + + class TestTheWordsInPlaceOfTheTabs: """The card says what stands in place of its tabs: no document, or one whose envelopes are on their way."""