From a0c6a60c71b5d2fc6d2891040295220555e5e810 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 19:37:18 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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(