From 167383bb4a39475d3ebf467b984495e7d832e7d6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 15:16:10 +0200 Subject: [PATCH 1/2] Named: what the waveform's fade waits for --- automation/screen.py | 4 ++ automation/vocabulary/graphs.py | 1 + .../application/reconstruction-edits.md | 6 ++- .../coordinators/reconstruction.py | 14 ++--- .../coordinators/tabs/reconstruction.py | 5 +- .../logic/reconstruction/rewrites/queue.py | 42 +++++++++++---- .../ui/elements/graphs/waveform.py | 42 ++++++++++----- .../ui/panels/reconstruction/plot.py | 5 +- .../view_model/reconstruction/work.py | 13 +++++ src/sampletones_config/lang/en.yaml | 1 + .../reconstruction/rewrites/test_queue.py | 5 +- .../stems/test_long_document_boxes.py | 29 +++++++++++ .../coordinators/test_reconstruction.py | 5 +- .../reconstruction/rewrites/test_queue.py | 41 ++++++++++++--- .../ui/elements/graphs/test_waveform.py | 52 ++++++++++++++----- 15 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 src/sampletones_application/view_model/reconstruction/work.py diff --git a/automation/screen.py b/automation/screen.py index 0b392a506..850b48345 100644 --- a/automation/screen.py +++ b/automation/screen.py @@ -192,6 +192,10 @@ def status(self) -> str: """What the status bar along the bottom of the window says.""" return self.bridge.ask(lambda: read_label(STATUS_BAR)) + def status_drawn(self) -> str: + """What the status bar says, read on the render thread itself, which a recording takes after every frame.""" + return read_label(STATUS_BAR) + def shows_text(self, words: str) -> bool: """Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open.""" return self.bridge.ask(lambda: read_visible_text(words)) diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py index 787339f61..e56189bb1 100644 --- a/automation/vocabulary/graphs.py +++ b/automation/vocabulary/graphs.py @@ -1,4 +1,5 @@ from typing import Final WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating" +WAVEFORM_READING: Final[str] = "global.graph.message.waveform_reading" WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 6ed59173d..0dc15f199 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -91,4 +91,8 @@ cache already answers is shown at once. ## The waveform fades while the line is busy The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the -document is being rewritten for the whole span, however many steps it takes. +document is being rewritten for the whole span, however many steps it takes. The status bar names what +the fade waits for: an edit on its way reads "Regenerating reconstruction...", and a change to what is +heard, or a document just opened, reads "Preparing the sound...". The line reports the kind of work it +is busy with, so a hand-over from an edit to a reading of the sound changes the words under the same +fade. diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index dce87200c..2210c31a5 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -38,6 +38,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample @@ -102,7 +103,7 @@ def __init__( self._rewrites.on_heard_rendered = self._show_listening self._rewrites.on_dropped = self._redraw_open_document self._rewrites.on_failed = self._report_rebuild_failure - self._rewrites.on_busy_changed = self._set_reconstruction_dimmed + self._rewrites.on_work_changed = self._show_work self._reconstruction_manager.set_callbacks( on_reconstruction_loaded=self.on_reconstruction_loaded, on_reconstruction_closed=self._on_closed, @@ -579,16 +580,17 @@ def _report_rebuild_failure(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) - def _set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Fades the reconstruction waveform while the open document is being rewritten. + def _show_work(self, work: Optional[Work]) -> None: + """Fades the reconstruction waveform under the words naming what the line is busy with. - The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform - faded until the last step lands, and it restores once nothing runs or waits. + The fade follows the rewrites' busy span, so a continuous edit stream keeps the waveform + faded until the last step lands, and it restores once nothing runs or waits. The words + follow the work, so a hand-over from an edit to a reading of the sound changes them. """ if self._reconstructions_tab is None: return - self._reconstructions_tab.set_reconstruction_dimmed(dimmed) + self._reconstructions_tab.show_work(work) def _on_state_changed(self) -> None: self._on_session_state_changed_callback() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 72ba64f23..36be1efcc 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -104,6 +104,7 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.audio import AudioDeviceManager @@ -822,8 +823,8 @@ def redraw_listening(self, *, envelopes_changed: bool) -> None: if envelopes_changed: self._reconstruction_instruments_logic.update_display() - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) + def show_work(self, work: Optional[Work]) -> None: + self._reconstruction_plot_panel.show_work(work) def toggle_channel(self, channel: ChannelName) -> None: """Switches one channel's slice in and out of the waveform and of what plays.""" diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py index 77e51870c..edd5bf0a3 100644 --- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -28,6 +28,7 @@ from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.removal import ( can_remove_stem, @@ -96,7 +97,8 @@ class ReconstructionRewrites(CallbackMixin): lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a job's failure, and - ``on_busy_changed`` as the line fills and empties. + ``on_work_changed`` as the line fills, hands over from one kind of work to the other, and + empties. """ def __init__( @@ -109,20 +111,42 @@ def __init__( self._waiting: List[Rewrite] = [] self._running: Optional[Running] = None self._redraw_owed: bool = False - self._reported_busy: bool = False + self._reported_work: Optional[Work] = None self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None self.on_heard_rendered: Optional[Callable[[bool], None]] = None self.on_dropped: Optional[VoidCallback] = None self.on_failed: Optional[Callable[[Exception], None]] = None - self.on_busy_changed: Optional[Callable[[bool], None]] = None + self.on_work_changed: Optional[Callable[[Optional[Work]], None]] = None regeneration.subscribe(self._on_result) + @property + def work(self) -> Optional[Work]: + """What the line is busy with, which the waveform's fade names, or ``None`` while it stands empty. + + The running job says what it does. While steps wait with none running, the first of them + says what comes: a change to what is heard has the sound read, and every other step + rewrites the document. + """ + match self._running: + case Rebuild(): + return Work.REBUILDING + case Listening(): + return Work.READING + + match self._waiting[0] if self._waiting else None: + case None: + return None + case ListeningChange(): + return Work.READING + case _: + return Work.REBUILDING + @property def is_busy(self) -> bool: """Whether a job runs or a step waits, which is the span the open document's sound is on its way.""" - return self._running is not None or bool(self._waiting) + return self.work is not None def request(self, rewrite: Rewrite) -> None: """Puts a step at the end of the line, and takes it at once where nothing runs before it. @@ -365,12 +389,12 @@ def _let_go(self, running: Running) -> None: self.call(self.on_dropped) def _settle(self) -> None: - """Pays a redraw owed once the line empties, and reports the line filling or emptying.""" + """Pays a redraw owed once the line empties, and reports what the line is busy with as it changes.""" if not self.is_busy and self._redraw_owed: self._redraw_owed = False self.call(self.on_dropped) - busy = self.is_busy - if busy != self._reported_busy: - self._reported_busy = busy - self.call(self.on_busy_changed, busy) + work = self.work + if work != self._reported_work: + self._reported_work = work + self.call(self.on_work_changed, work) diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index a42357c87..1781381c0 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -47,6 +47,7 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.audio import START_OF_AUDIO from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -94,9 +95,10 @@ def __init__( self._lbl_waveform_original = language_manager["global.graph.label.waveform_original"] self._lbl_waveform_reconstruction = language_manager["global.graph.label.waveform_reconstruction"] self._msg_regenerating = language_manager["global.graph.message.waveform_regenerating"] + self._msg_reading = language_manager["global.graph.message.waveform_reading"] self.reconstruction_autoscale = True - self._reconstruction_dimmed: bool = False + self._work: Optional[Work] = None self._top_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self.position_indicator_tag = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) @@ -359,8 +361,8 @@ def _on_hover(self, sender: Sender, app_data: Any, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks) self._status_bar.set( - self._msg_regenerating - if self._reconstruction_dimmed + self._work_words(self._work) + if self._work is not None else self._language_manager[ ( "global.graph.message.waveform_playable_navigation" @@ -437,7 +439,7 @@ def load_voice_waveform( name: The name the series is labeled by. color: The color the line is drawn in. """ - self._reconstruction_dimmed = False + self._work = None self._sample_rate = 0 self.clear_layers() self.add_layer(self._array_layer(audio, name=name, color=color)) @@ -499,19 +501,23 @@ def load_waveform_data(self, waveform_data: WaveformData) -> None: self._restate_clock_ticks(*self.x_range) - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Grays the reconstruction line while its audio is being regenerated, restoring it when done. + @property + def _reconstruction_dimmed(self) -> bool: + return self._work is not None + + def show_work(self, work: Optional[Work]) -> None: + """Grays the reconstruction line under the words naming ``work``, and restores it once nothing is on its way. Only the reconstruction series is grayed; the original-audio series and the axes keep full - strength, so the fade reads as "this waveform is being recomputed", and the status bar shows a - regenerating hint for the same span. The state is remembered so an async data update arriving - mid-regeneration redraws the reconstruction still grayed. + strength, so the fade reads as "this waveform is being recomputed", and the status bar names + what it waits for: the document being rebuilt, or its sound being prepared. The work is + remembered so an async data update arriving mid-way redraws the reconstruction still grayed. """ - if self._reconstruction_dimmed == dimmed: + if self._work == work: return - self._reconstruction_dimmed = dimmed - self._status_bar.set(self._msg_regenerating if dimmed else "") + self._work = work + self._status_bar.set(self._work_words(work)) layer = self.layers.get(self._lbl_waveform_reconstruction) if layer is None: @@ -521,6 +527,16 @@ def set_reconstruction_dimmed(self, dimmed: bool) -> None: if dpg.does_item_exist(series_tag): self._bind_series_theme(series_tag, layer) + def _work_words(self, work: Optional[Work]) -> str: + """The status line naming ``work``, and an empty one where nothing is on its way.""" + match work: + case Work.REBUILDING: + return self._msg_regenerating + case Work.READING: + return self._msg_reading + case None: + return "" + def reconstruction_layer(self, data: np.ndarray) -> ArrayLayer: return self._array_layer( data, @@ -611,7 +627,7 @@ def _delete_series(self) -> None: def clear(self) -> None: """Empties the plot down to the marks it keeps for whatever it draws next: the position indicator and the overlay rectangle, both of which live among the axis's children.""" - self._reconstruction_dimmed = False + self._work = None self._sample_rate = 0 self._release_clock_ticks() self.clear_layers() diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index 09cb0ba97..46c40eee2 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -31,6 +31,7 @@ from sampletones_application.view_model.reconstruction.waveform import ( InstrumentWaveformViewModel, ) +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -161,8 +162,8 @@ def clear_waveform(self) -> None: def set_waveform_top_source(self, audio_source: AudioSourceType) -> None: self.waveform_display.set_top_source(audio_source) - def set_reconstruction_dimmed(self, dimmed: bool) -> None: - self.waveform_display.set_reconstruction_dimmed(dimmed) + def show_work(self, work: Optional[Work]) -> None: + self.waveform_display.show_work(work) def set_overlay(self, index: Optional[int]) -> None: if self._frame_length is None: diff --git a/src/sampletones_application/view_model/reconstruction/work.py b/src/sampletones_application/view_model/reconstruction/work.py new file mode 100644 index 000000000..289c851b0 --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/work.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class Work(StrEnum): + """What the open document's line is busy with, which the waveform's fade names. + + An edit on its way rebuilds the document, and a change to what is heard, or a document just + opened, has its sound rendered. The reader sees one fade for both and reads from its words + which of the two the waveform waits for. + """ + + REBUILDING = "rebuilding" + READING = "reading" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index cff2df205..c9d9e606b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -332,6 +332,7 @@ global.graph.message.spectrum_navigation: "Right-click for more options." global.graph.message.waveform_navigation: "Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." global.graph.message.waveform_playable_navigation: "Click to play from a point. Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." global.graph.message.waveform_regenerating: "Regenerating reconstruction..." +global.graph.message.waveform_reading: "Preparing the sound..." # ============================================================================= # Main tab — Explorer diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py index a3ce31308..937794088 100644 --- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -17,6 +17,7 @@ ) from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -135,6 +136,6 @@ def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordina assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None: - dims = [entry.args[0] for entry in settled._tab.set_reconstruction_dimmed.call_args_list] + work = [entry.args[0] for entry in settled._tab.show_work.call_args_list] - assert dims == [True, False] + assert work == [Work.REBUILDING, None] diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py index 203d8d32e..ebf37bcd1 100644 --- a/tests/screens/reconstructions/stems/test_long_document_boxes.py +++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py @@ -7,6 +7,7 @@ from automation.screen import Screen from automation.steps.exports import leaving_asks_nothing from automation.steps.reconstructions import expect_open, reconstruction_shade, wait_for_the_fade_to_end +from automation.vocabulary.graphs import WAVEFORM_READING from automation.worlds.home import World from sampletones_core.constants.enums import ChannelName from tests.suite.screens.readings import expect_no_long_hold, frame_readings, keep_readings, longest_gap_after_each @@ -81,3 +82,31 @@ def keep_the_readings(screen: Screen) -> None: expect_no_long_hold(frames) screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run() + + +class TestWhatTheFadeSays: + """The status bar names what the fade waits for after a tick: the sound being prepared. + + The words stand for the few frames the sound takes, so they are read after every frame through + the tick. The words on opening are pinned where the line reports a listening and where the + waveform shows them, since the browser's own hover text holds the status bar while the pointer + rests on the row that opened the document. + """ + + def test_a_tick_reads_as_the_sound_being_prepared(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + reading = screen.words(WAVEFORM_READING) + unfaded: List[Optional[str]] = [] + + def open_it(screen: Screen) -> None: + expect_open(screen, LONG_RECONSTRUCTION) + unfaded.append(reconstruction_shade(screen)) + + def tick_a_box(screen: Screen) -> None: + with screen.record(screen.status_drawn) as recording: + reconstructions.tick_channel(TOGGLED_CHANNEL) + wait_for_the_fade_to_end(screen, unfaded[0]) + + assert reading in recording.values(), f"the status bar never read {reading!r} through the tick" + + screen.scenario(open_it, tick_a_box, leaving_asks_nothing).run() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 6d1807128..b5e8cd395 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -33,6 +33,7 @@ from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED from sampletones_application.utils.callbacks.gates import Gate, pass_gates from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP @@ -1911,11 +1912,11 @@ def test_the_waveform_stays_faded_until_every_step_has_landed( ) -> None: _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) - assert tab.set_reconstruction_dimmed.call_args_list == [call(True)] + assert tab.show_work.call_args_list == [call(Work.REBUILDING)] held_queue.drain() - assert tab.set_reconstruction_dimmed.call_args_list == [call(True), call(False)] + assert tab.show_work.call_args_list == [call(Work.REBUILDING), call(None)] def test_a_drag_collapses_into_the_place_it_ended( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py index 9b14de8d7..1e9a909c6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -20,6 +20,7 @@ StemRemovalRequest, ) from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob +from sampletones_application.view_model.reconstruction.work import Work from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction @@ -68,7 +69,7 @@ class Outcome: heard: List[bool] = field(default_factory=list) dropped: int = 0 failures: List[Exception] = field(default_factory=list) - busy: List[bool] = field(default_factory=list) + work: List[Optional[Work]] = field(default_factory=list) @pytest.fixture @@ -106,7 +107,7 @@ def heard(envelopes_changed: bool) -> None: rewrites.on_heard_rendered = heard rewrites.on_dropped = drop rewrites.on_failed = outcome.failures.append - rewrites.on_busy_changed = outcome.busy.append + rewrites.on_work_changed = outcome.work.append return rewrites @@ -522,7 +523,8 @@ def test_a_change_after_the_step_has_run_is_taken( class TestTheBusySpan: - """The line reports filling as the first step waits or runs, and emptying once the last one lands.""" + """The line reports what it is busy with as the first step waits or runs, at a hand-over from one kind of + work to the other, and once the last step lands.""" def test_a_rebuild_fills_the_line_until_it_lands( self, @@ -533,12 +535,32 @@ def test_a_rebuild_fills_the_line_until_it_lands( rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) assert rewrites.is_busy + assert rewrites.work is Work.REBUILDING regeneration.finish() - assert outcome.busy == [True] + assert outcome.work == [Work.REBUILDING] regeneration.finish() - assert outcome.busy == [True, False] + assert outcome.work == [Work.REBUILDING, None] + assert not rewrites.is_busy + assert rewrites.work is None + + def test_a_listening_waiting_behind_a_rebuild_is_named_at_the_hand_over( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(ListeningChange()) + assert outcome.work == [Work.REBUILDING] + + regeneration.finish() + + assert outcome.work == [Work.REBUILDING, Work.READING] + assert rewrites.is_busy + regeneration.finish() + assert outcome.work == [Work.REBUILDING, Work.READING, None] assert not rewrites.is_busy def test_steps_taken_at_once_leave_the_line_empty( @@ -552,7 +574,8 @@ def test_steps_taken_at_once_leave_the_line_empty( rewrites.request(AfterEdits(gesture=gesture)) gesture.assert_called_once_with() - assert outcome.busy == [] + assert outcome.work == [] + assert not rewrites.is_busy class TestTheRendersALandingKeeps: @@ -768,8 +791,10 @@ def test_a_listening_fills_the_line( outcome: Outcome, ) -> None: rewrites.request(ListeningChange()) - assert outcome.busy == [True] + assert outcome.work == [Work.READING] + assert rewrites.is_busy regeneration.finish() - assert outcome.busy == [True, False] + assert outcome.work == [Work.READING, None] + assert not rewrites.is_busy diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index 2f74968cf..686e6d290 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from typing import Any, Dict, List, Tuple, Union +from typing import Any, Dict, Final, List, Tuple, Union from unittest.mock import MagicMock import numpy as np @@ -9,8 +9,12 @@ from sampletones_application.ui.elements.graphs.layers.decimation import Decimations from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.reconstruction.work import Work from sampletones_application.view_model.shared.waveform_data import WaveformData +REGENERATING: Final[str] = "Regenerating reconstruction..." +READING: Final[str] = "Preparing the sound..." + VOICE_SAMPLES = 64 @@ -108,10 +112,11 @@ def _graph() -> GUIWaveformGraph: graph.position_indicator_tag = "indicator" graph.overlay_rectangle_tag = "overlay" graph.layers = {} - graph._reconstruction_dimmed = False + graph._work = None graph._lbl_waveform_reconstruction = "Reconstruction" graph._status_bar = MagicMock() - graph._msg_regenerating = "Regenerating reconstruction..." + graph._msg_regenerating = REGENERATING + graph._msg_reading = READING graph.tag = "waveform" graph._series_themes = {} return graph @@ -293,7 +298,7 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None: def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None: graph = _graph() _with_layout(graph, opacity=0.4) - graph._reconstruction_dimmed = True + graph._work = Work.REBUILDING layer = _Layer("Reconstruction") faded = graph._series_color(layer, graph._series_shade(layer)) @@ -304,7 +309,7 @@ def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None: def test_series_color_leaves_other_layers_opaque_when_dimmed(self) -> None: graph = _graph() _with_layout(graph) - graph._reconstruction_dimmed = True + graph._work = Work.REBUILDING layer = _Layer("Sample Name") assert graph._series_color(layer, graph._series_shade(layer)) == layer.color @@ -326,33 +331,54 @@ def test_set_dimmed_rebinds_the_reconstruction_series_once( lambda tag, theme: binds.append(theme), ) - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert graph._reconstruction_dimmed is True assert len(binds) == 1 - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert len(binds) == 1 def test_set_dimmed_without_reconstruction_layer_is_a_noop(self) -> None: graph = _graph() - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) assert graph._reconstruction_dimmed is True - def test_set_dimmed_shows_then_clears_the_status_message(self) -> None: + @pytest.mark.parametrize( + ("work", "words"), + [ + pytest.param(Work.REBUILDING, REGENERATING, id="an edit on its way"), + pytest.param(Work.READING, READING, id="the sound being prepared"), + ], + ) + def test_showing_work_names_it_then_clears_the_status_message( + self, + work: Work, + words: str, + ) -> None: graph = _graph() - graph.set_reconstruction_dimmed(True) - graph._status_bar.set.assert_called_with("Regenerating reconstruction...") + graph.show_work(work) + graph._status_bar.set.assert_called_with(words) - graph.set_reconstruction_dimmed(False) + graph.show_work(None) graph._status_bar.set.assert_called_with("") + assert graph._reconstruction_dimmed is False + + def test_a_hand_over_changes_the_words_under_the_same_fade(self) -> None: + graph = _graph() + + graph.show_work(Work.REBUILDING) + graph.show_work(Work.READING) + + graph._status_bar.set.assert_called_with(READING) + assert graph._reconstruction_dimmed is True def test_new_data_keeps_the_reconstruction_dimmed(self, monkeypatch: pytest.MonkeyPatch) -> None: """An edit landing while later ones are still on their way redraws the waveform still faded.""" graph = _graph() - graph.set_reconstruction_dimmed(True) + graph.show_work(Work.REBUILDING) monkeypatch.setattr(graph, "clear_layers", MagicMock()) monkeypatch.setattr(graph, "_restate_clock_ticks", MagicMock()) monkeypatch.setattr(graph, "_display_layers", MagicMock(return_value=[])) From 3238d66c24c553f17b79fc9d957fb0a3b47bffd1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 11 Oct 2026 15:20:42 +0200 Subject: [PATCH 2/2] Owed: a WAV export to the worker where the cache keeps no sound for the hearing --- .../application/reconstruction-edits.md | 4 +- docs/development/bugs-and-todos.md | 5 +- .../logic/reconstruction/data.py | 18 ----- .../logic/reconstruction/reconstruction.py | 51 +++++++++--- .../logic/reconstruction/test_data.py | 33 +------- .../reconstruction/test_reconstruction.py | 79 +++++++++++++++++-- 6 files changed, 121 insertions(+), 69 deletions(-) diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md index 0dc15f199..8b4a29274 100644 --- a/docs/development/application/reconstruction-edits.md +++ b/docs/development/application/reconstruction-edits.md @@ -86,7 +86,9 @@ moves the choice the listening makes on the new record, so its landing reads the listening waiting answers every choice made before it is taken, and a listening asked for during a rebuild reads the rebuilt document. A freshly opened document is shown the same way, so its channels render on the worker too. The showing the panel owes happens once the sound lands, and a hearing the -cache already answers is shown at once. +cache already answers is shown at once. A WAV export is a reading of what plays like a showing: it +writes the kept sound at once, and where the cache keeps none it is owed beside the showing and written +once the worker's reading lands, for what the reader hears then. ## The waveform fades while the line is busy diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 896084c8a..ac28e14a8 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -89,11 +89,10 @@ dimension the import starts carrying. entry down to the frames the edit changed ([undo](application/undo.md)). * What the Reconstructions tab still computes on the render thread. The regeneration worker renders, mixes and exports what the reader hears, and the landing installs it ([Editing the open - reconstruction](application/reconstruction-edits.md)); three readings stay on the render thread. The + reconstruction](application/reconstruction-edits.md)); two readings stay on the render thread. The sequencer's preview of a sample mixes the sample cold on its first read, since it reads the render cache directly. The original mix of the recordings heard is mixed again on the render thread whenever a - recording is left out, which is a few milliseconds per recording on a two-minute document. A WAV export - of a hearing the cache keeps no sound for renders it on the spot, where the reader waits for the file. + recording is left out, which is a few milliseconds per recording on a two-minute document. * Improve performance of the browser's favorite scan of the entire tree per click * The `fft` and `logfft` analysis floors. Their window spans two cycles of the pulse's lowest note, so the triangle's lowest octave reaches them by its harmonics alone. Covering it doubles their window and diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index e7c7ed91b..5a2332ca3 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -15,7 +15,6 @@ from sampletones_core.reconstructions.reconstruction.stems.hearing import ( HeardRenders, Hearing, - heard_renders, ) from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger @@ -240,23 +239,6 @@ def waveform_data( return self._waveform_data(self.original_mix_for(hearing.selection), heard) - def audio_for( - self, - renders: RenderCache, - hearing: Hearing, - ) -> np.ndarray: - """What ``hearing`` plays of the document, rendered here where the cache keeps none of it. - - An export writes what plays and runs where the reader waits for it, so it reads what is - kept and renders the rest on the spot. - """ - heard = renders.reading(self.reconstruction, hearing) - if heard is not None: - return heard.playing - - channels = renders.channels(self.reconstruction) - return heard_renders(self.reconstruction, hearing, channels, renders.mix(self.reconstruction)).playing - def _waveform_data( self, original_audio: Optional[np.ndarray], diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 2653558e4..a3f4298a0 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -134,6 +134,7 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._owed: Optional[OwedShowing] = None + self._export_owed: Optional[Path] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -195,13 +196,16 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None: ) def redraw_listening(self) -> None: - """Shows what the reader hears once its sound is kept, where a showing waited for it.""" - owed = self._owed + """Shows what the reader hears once its sound is kept, where a showing or an export waited for it.""" reconstruction_data = self._reconstruction_data - if owed is None or not reconstruction_data: + if not reconstruction_data: return - self._show(reconstruction_data, owed) + if self._owed is not None: + self._show(reconstruction_data, self._owed) + + if self._export_owed is not None: + self._export(reconstruction_data, self._export_owed) def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None: """Draws and plays what the reader hears, or asks for its sound where the screen holds none yet. @@ -264,6 +268,10 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION self._owed = None + if self._export_owed is not None: + logger.info(f"The export to {self._export_owed} waited for the sound of a document put away") + self._export_owed = None + self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) self.call( @@ -702,13 +710,38 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: logger.warning("No reconstruction data available for WAV export") return - audio_snapshot = reconstruction_data.audio_for( - self._reconstruction_manager.renders, - self._listening.hearing, - ) + self._export(reconstruction_data, filepath) + + def _export( + self, + reconstruction_data: ReconstructionData, + filepath: Path, + ) -> None: + """Writes what the reader hears to ``filepath``, or asks for its sound where the screen holds none yet. + + An export is a reading of what plays like a showing, so it reads the kept sound and computes + no sample on the render thread. An export the cache cannot answer is owed beside a showing: + the worker is asked, and the file is written once the sound lands, for what the reader hears + then. A document put away before that takes the export with it. + """ + waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing) + if waveform is None: + self._export_owed = filepath + self.call(self.on_renders_wanted) + return + + self._export_owed = None + self._write_wav(reconstruction_data, waveform.playing, filepath) + + def _write_wav( + self, + reconstruction_data: ReconstructionData, + playing: np.ndarray, + filepath: Path, + ) -> None: sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) - self._export_service.export_wav(filepath, sample_rate, audio_snapshot) + self._export_service.export_wav(filepath, sample_rate, playing) def handle_locate_original_audio(self) -> None: if not self._reconstruction_manager.source_paths: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 0922401e6..f80730130 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -363,11 +363,11 @@ def test_partials_keep_only_the_selected_stems_frames( expected = rendered_mix(data.reconstruction).copy() expected[frame_length:] = 0 - partials = data.audio_for(renders, _hearing(0)) + partials = _kept(renders, data, _hearing(0)).playing np.testing.assert_allclose(partials, expected) np.testing.assert_allclose( - data.audio_for(renders, _hearing(0, 1)), + _kept(renders, data, _hearing(0, 1)).playing, rendered_mix(data.reconstruction), ) @@ -431,7 +431,7 @@ def test_a_single_source_with_no_selection_is_silence( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") np.testing.assert_array_equal( - data.audio_for(renders, _hearing()), + _kept(renders, data, _hearing()).playing, np.zeros_like(rendered_mix(data.reconstruction)), ) assert data.original_audio is not None @@ -675,30 +675,3 @@ def test_a_recording_left_out_reads_what_the_worker_kept( assert waveform_data.approximation is not renders.mix(reconstruction) np.testing.assert_array_equal(waveform_data.approximation, np.zeros_like(renders.mix(reconstruction))) np.testing.assert_array_equal(waveform_data.playing, waveform_data.approximation) - - -class TestTheAudioAnExportWrites: - """An export writes what plays, read from what is kept and rendered on the spot where nothing is.""" - - def test_what_is_kept_is_handed_back( - self, - reconstruction_factory: Callable[[], Reconstruction], - renders: RenderCache, - ) -> None: - data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") - kept = _kept(renders, data, _hearing(0)) - - assert data.audio_for(renders, _hearing(0)) is kept.playing - - def test_what_is_not_kept_is_rendered( - self, - reconstruction_factory: Callable[[], Reconstruction], - renders: RenderCache, - ) -> None: - data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") - - np.testing.assert_array_equal(data.audio_for(renders, _hearing(0)), rendered_mix(data.reconstruction)) - np.testing.assert_array_equal( - data.audio_for(renders, _hearing(0, channels_on=frozenset())), - np.zeros_like(rendered_mix(data.reconstruction)), - ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 8d708e176..d16635303 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -35,9 +35,10 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.instructions import PulseInstruction, TriangleInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing +from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -112,6 +113,14 @@ def _heard_whole(reconstruction_data: ReconstructionData) -> Hearing: ) +def _playing(reconstruction_data: ReconstructionData, hearing: Hearing) -> np.ndarray: + """What ``hearing`` plays of the document, rendered afresh by the core as the oracle.""" + reconstruction = reconstruction_data.reconstruction + return heard_renders( + reconstruction, hearing, rendered_channels(reconstruction), rendered_mix(reconstruction) + ).playing + + def _open(manager: MagicMock, reconstruction_data: ReconstructionData) -> None: """Puts a document in front of the panel the way the manager's own adoption does, its sound kept. @@ -805,6 +814,7 @@ class Gesture(BaseRegularTestCase): Gesture(label="stem", act=lambda panel_logic: panel_logic.set_stem_channels(0, frozenset())), Gesture(label="solo", act=lambda panel_logic: panel_logic.solo_stem(0)), Gesture(label="source", act=lambda panel_logic: panel_logic.set_audio_source(AudioSourceType.RECONSTRUCTION)), + Gesture(label="export", act=lambda panel_logic: panel_logic.handle_export_wav_confirmed(Path("output.wav"))), ) @pytest.mark.parametrize("gesture", gestures, ids=lambda gesture: gesture.label) @@ -906,6 +916,51 @@ def test_nothing_owed_shows_nothing( drawn.assert_not_called() + def test_an_export_owed_is_written_once_the_sound_lands( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + asked = MagicMock() + panel_logic.on_renders_wanted = asked + mock_reconstruction_manager.listening.set_channels_on(frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + asked.assert_called_once() + mock_export_service.export_wav.assert_not_called() + _hear(mock_reconstruction_manager) + + panel_logic.redraw_listening() + + mock_export_service.export_wav.assert_called_once() + kept = loaded_data.waveform_data( + mock_reconstruction_manager.renders, mock_reconstruction_manager.listening.hearing + ) + assert kept is not None + assert mock_export_service.export_wav.call_args.args[2] is kept.playing + + def test_a_close_forgets_an_owed_export( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + panel_logic.on_renders_wanted = MagicMock() + mock_reconstruction_manager.listening.set_channels_on(frozenset()) + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + + panel_logic.close_reconstruction() + + _open(mock_reconstruction_manager, loaded_data) + panel_logic.redraw_listening() + mock_export_service.export_wav.assert_not_called() + def test_an_edit_owed_a_showing_updates_the_waveform_in_place( self, panel_logic: ReconstructionPanelLogic, @@ -1243,6 +1298,11 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate( mock_export_service.export_wav.assert_called_once() call_args = mock_export_service.export_wav.call_args assert call_args.args[1] == loaded_data.reconstruction.config.sample_rate + kept = loaded_data.waveform_data( + mock_reconstruction_manager.renders, mock_reconstruction_manager.listening.hearing + ) + assert kept is not None + assert call_args.args[2] is kept.playing class TestReconstructionPanelLogicComputeAudio: @@ -1395,7 +1455,6 @@ def test_silencing_a_recording_filters_waveform_and_playback( panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, stems_data: ReconstructionData, - renders: RenderCache, ) -> None: _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() @@ -1408,13 +1467,13 @@ def test_silencing_a_recording_filters_waveform_and_playback( panel_logic.set_stem_channels(0, frozenset()) panel_logic.redraw_listening() - expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) + expected = _playing(stems_data, mock_reconstruction_manager.listening.hearing) assert len(waveform_updates) == 1 np.testing.assert_allclose(waveform_updates[0].playing, expected) assert len(audio_updates) == 1 assert audio_updates[0] is not None np.testing.assert_allclose(audio_updates[0].sample, expected) - assert np.any(expected != stems_data.audio_for(renders, _heard_whole(stems_data))) + assert np.any(expected != _playing(stems_data, _heard_whole(stems_data))) def test_a_channel_switched_off_for_everything_mutes_its_column( self, @@ -1434,25 +1493,29 @@ def test_a_channel_switched_off_for_everything_mutes_its_column( assert stems_views[0].stems.muted_channels == frozenset({ChannelName.PULSE1}) assert rows["0"].channels == frozenset({ChannelName.PULSE1}) - def test_export_wav_uses_the_stems_filter( + def test_export_wav_writes_the_kept_sound_of_the_recordings_heard( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, stems_data: ReconstructionData, tmp_path: Path, - renders: RenderCache, ) -> None: _open(mock_reconstruction_manager, stems_data) panel_logic.display_reconstruction() + panel_logic.on_renders_wanted = lambda: _hear(mock_reconstruction_manager) panel_logic.set_stem_channels(0, frozenset()) + panel_logic.redraw_listening() panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") mock_export_service.export_wav.assert_called_once() exported_audio = mock_export_service.export_wav.call_args.args[2] - expected = stems_data.audio_for(renders, mock_reconstruction_manager.listening.hearing) - np.testing.assert_allclose(exported_audio, expected) + hearing = mock_reconstruction_manager.listening.hearing + kept = stems_data.waveform_data(mock_reconstruction_manager.renders, hearing) + assert kept is not None + assert exported_audio is kept.playing + np.testing.assert_allclose(exported_audio, _playing(stems_data, hearing)) class TestWhatTheEnvelopesShow: