diff --git a/docs/development/application/browser.md b/docs/development/application/browser.md index 1452d05e9..5616a96d3 100644 --- a/docs/development/application/browser.md +++ b/docs/development/application/browser.md @@ -30,7 +30,7 @@ Which row carries the configuration follows the branch. In the configuration bra ## The shaping rules * **Prune.** A heading the browser wrote that gathers nothing leaves, deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk. -* **Collapse.** A heading above a single row folds into that row, which takes the joined name and rises into its place. The surviving row keeps its node type, path, configuration and children, so its click behavior, theme, context menu and favorite star carry over. A fold that would repeat a name already beside it stays open, and the branch roots stay in place. With a single configuration present, the configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a second configuration arrives. +* **Collapse.** A heading above a single row folds into that row, which takes the joined name and rises into its place. The surviving row keeps its node type, path, configuration and children, so its click behavior, theme, context menu and favorite star carry over. A fold that would repeat a name already beside it stays open, and the branch roots stay in place. With a single configuration present, the configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a second configuration arrives. A heading naming something somebody chose — an audio file, a source folder — hands the row a name that is no longer the configuration's own text, and the row records that it gathered a plain name. A reader of configuration text meets it as the plain name it has become, which is why such a row reads in the default font while a chain of configuration headings stays in the fixed-width one. * **Order.** Containers come ahead of leaves, then rows follow a natural sort over the label, so a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every label is final. The branches directly under the container root keep the order the builder gives them. * **Unique sibling labels.** Where siblings would read alike, every member of that label takes its short configuration hash. One rule serves the channel directories under a transformation group, the nested configuration directories, and the variants under a sample. @@ -42,6 +42,8 @@ Which row carries the configuration follows the branch. In the configuration bra **What a row answers follows its kind.** A reconstruction plays on a click, opens on a double click, and offers its path items, the tab's own actions and the favorite mark. A directory offers its path items and the favorite mark. A group or a sample represents no path, so its menu reads the subtree: how many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows it by, and, on a sample, the audio its reconstructions were made from, answered through any one of them. +**A row naming a reconstruction says what made it and what it is made of.** The configuration comes from a directory name. A row carries it where the configuration is what distinguishes that row, and reads it from the directory above otherwise, so both branches say the same thing about one file. The recordings come from the document, which is the only place that knows them. A row asks for them as the pointer reaches it, and the answer is kept against the moment the file was last written. One reading answers for both tabs. A pointer crossing many rows leaves the reading of the row it comes to rest on, and an answer landing after the details were built rebuilds them where they stand. Each recording is listed under the color its place on the record gives it, the color [the stems card](stems.md) paints it in. A document naming one recording says what the row already says, so the list is left to the documents holding several. + **Favorites are paths.** A row is a favorite when its path is in the session's set, and it reads as part of a favorite folder when any of its parents is. A reconstruction therefore reads as part of a favorite folder wherever a view puts it, including the sample branch, whose headings carry no path. One path reaches the panel as several rows, so `application.py` resolves the toggled path into every row representing it and hands them to both tabs. Each row repaints with the ancestry its own path carries. ## Filtering diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index c2aa7372e..56351adf8 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -64,6 +64,8 @@ Each verb is an action, so the combination it answers to is the shipped scheme's The left button carries three gestures: a click seeks, a drag pans the view, and a double-click fits it to the audio. `PlotClickGesture` (`ui/elements/graphs/gesture.py`) settles which one a press was, so the playhead and the view each answer only the gesture meant for them. +The wheel carries the view too. Scrolling zooms x, and Shift with the wheel zooms y. Alt with the wheel pans x, and Alt and Shift pan y, by `pan_factor` of the span in view per notch, stopping at the bounds the graph constrains its axes to. `PlotWheelPan` (`ui/elements/graphs/pan.py`) makes those moves. `GUIGraph` locks both axes on every hover frame Alt is held, so the plot's own zoom leaves the wheel to the pan, and a lane linked to the waveform holds the same lock. The spectrum, whose view is fixed to its band, does not register the pan. + Because the target prefers the active tab's own source, Play/Pause acts on what the user is looking at whenever that screen can play something. On a screen that plays nothing of its own, it reaches the source already sounding. Those screens are the Main tab, an empty Reconstruction or Instructions tab, and the Sequencer before a project is open. So it resumes a paused reconstruction from the Main tab, and starts the song on the Sequencer with a project open. ## What the surfaces show diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index 93cd594c5..f9fa802cd 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -14,7 +14,7 @@ Opening the document loads the recorded stems through `load_stems`, the same cal The document's name follows the naming rules in `sampletones_core.reconstructions.naming`, applied to the recorded paths in order. A single source names the document after the file's stem. Several stems that share one directory name it after that directory. Paths that share no directory fall back to the `.stn` filename. -The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a click. The Audio source panel keeps the reconstruction's own file and the choice between the two waveforms. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. +The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes through `set_nes_frequency` and a project sample leaves to the project. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. ## The stems card @@ -31,7 +31,8 @@ The reconstruction tab's Stems card turns the recorded assignment into a listene 5. **The selection follows the open document.** The card lives with the reconstruction it describes. Opening a document seeds the rows and the ticked boxes, a regenerated reconstruction keeps what the reader chose and ticks the channels a stem newly reaches, and closing the document empties the card. 6. **Listening choices stay out of the document.** The ticked set is session state, like every choice that shapes what is heard (see [Playback](playback.md)). Saving the reconstruction records the assignment and not the selection. The banding works the same way: collapsing the levels changes how the card draws and never what it describes. 7. **Removing a recording edits the document.** Where a box steers listening, the remove button rewrites what is described. The change is asked about first and recorded in the project history, and [Editing a stems reconstruction](#editing-a-stems-reconstruction) says what it releases. A reconstruction holds at least one recording, so the last row standing keeps its button disabled. -8. **The ribbon shows what the record holds.** Under the waveform, a lane per channel in play divides into the stretches one recording holds throughout, each painted in that recording's color. A recording takes its color from the place it holds on the record, so one recording reads alike wherever it is drawn. The lanes appear only where more than one owner is in play, either a recording the record names or the row the frames a reader wrote gather under, since a document with a single owner has nothing to tell apart. A stretch reads solid where the reader hears the recording holding it, faded where the reader left it out, and in the surface's own ground where the frames rest. It names its owner whether or not it is listened to. The instruments panel paints the same stretches in a band beneath each dimension's bars. +8. **The ribbon shows what the record holds.** Under the waveform, a lane per channel in play divides into the stretches one recording holds throughout, each painted in that recording's color. A recording takes its color from the place it holds on the record, so one recording reads alike wherever it is drawn — on this card, under the waveform, beneath an instrument's bars, and in the list [the browser](browser.md) shows on hovering a reconstruction. A lane appears for every channel in play, one unbroken stretch where a single owner holds it throughout, so a channel's lane answers whether it sounds and by whom independently of every other channel's. A stretch reads solid where the reader hears the recording holding it, faded where the reader left it out, and in the surface's own ground where the frames rest. It names its owner whether or not it is listened to. The instruments panel paints the same stretches in a band beneath each dimension's bars, where a dimension answering to a single owner has nothing to tell apart and draws no band. +9. **The menu makes the same choices as the boxes.** A right-click on a row offers Mute or Unmute, Solo or Unsolo, and the file items every listing offers. Mute and Unmute set the recording's choice across every channel it offers. Solo hears one recording on every channel it offers and silences the others, and it remembers the choice it replaced, so Unsolo returns to it whichever recordings were soloed in between. A choice made by hand, or a regenerated document, ends that memory, and Unsolo then hears every recording whole. The frames a reader wrote have no file, so their row offers no file items. Both choices stay session state, like the boxes. ### What the code guarantees diff --git a/docs/development/application/vocabularies.md b/docs/development/application/vocabularies.md index 57fb0b482..701ff0056 100644 --- a/docs/development/application/vocabularies.md +++ b/docs/development/application/vocabularies.md @@ -38,9 +38,10 @@ and exempt. ### Text resolves where it is displayed A class that reads text holds the manager as `self._language_manager`, assigned in its own `__init__`, and -looks each string up at the point of use, so a language change takes effect on the next read. Where the -same text is read at more than one site in a class, one named binding serves them all and the reads stay in -step. +looks each string up at the point of use, so a language change takes effect on the next read. A class that +inherits the manager declares it in its body instead, `_language_manager: LanguageManager`, which states +the same thing to a reader and to the hook. Where the same text is read at more than one site in a class, +one named binding serves them all and the reads stay in step. ### The forms a lookup takes diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index bd65137c5..c26a21ca3 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -258,6 +258,10 @@ is the same cell you would see in the tracker. | Speed | 1–255 | the groove's tick counts, bounded to that range | | DPCM channel | present | rests, apart from the groove trigger each pattern's first row carries | +A row that names a voice on a channel the voice has no instrument for plays nothing in the song, so the +exporter writes a note cut on it and reports the row by its frame, channel and row. The project export +dialog lists those rows. + Tables and instruments are numbered together, and each slice takes one of each. The table column is therefore what a wide document reaches first, and the exporter raises an error instead of writing a document whose later voices cannot be named. A song whose rows vary spends one of those ids on its groove, diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 54530d83e..ac3498cc5 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -322,6 +322,10 @@ that ends its note. | Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | | DPCM samples | 64 | not modeled | always empty | +A row that names a voice on a channel the voice has no instrument for plays nothing in the song, so the +exporter writes a note cut on it and reports the row by its frame, channel and row. The project export +dialog lists those rows. + The exporter also reserves an empty pattern index per channel (`max used index + 1`) for order slots the song leaves unset. A channel that already fills indices up to 127 leaves no room for it, and the exporter reports this instead of writing a corrupt order. diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 4ca27b4a5..077afbf23 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -19,18 +19,23 @@ If the reconstruction you have open has unsaved changes, opening another one ask first. The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can -compare the two. The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the -same checkboxes. +compare the two. Its **NES frequency** field retimes the reconstruction: type a new rate and press +`Enter`. The field is locked for a reconstruction that belongs to a project, which follows the project's +rate. + +The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the same checkboxes. Click the waveform to play from that point. While playback is paused, a click moves the playback -position. Drag the waveform to move the view, and double-click to fit the view. +position. Drag the waveform to move the view, and double-click to fit the view. Scroll to zoom, hold **Alt** and +scroll to move the view sideways, or hold **Alt** and **Shift** and scroll to move it up and down. ## Hearing what each recording contributed The **Stems** card lists the recordings a reconstruction was built from, grouped by the [level](converting.md#one-reconstruction-each-or-one-mix-from-all) each one was given. A row has a checkbox for each channel the recording used, and the checkbox at the front switches all of them. -Double-click a row to show the recording in your file browser. +Double-click a row to show the recording in your file browser. Right-click a row to **Mute** the +recording, hear it alone with **Solo**, or copy its name or path. **Unsolo** brings back the mix you had. Uncheck a box to hear the reconstruction without that recording on that channel. The waveform, the playback, the original audio and a WAV export all follow the checkboxes, so you can hear what each diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 6c1f5d2b2..2c13caa76 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -240,6 +240,10 @@ rows. - **Bitphase project...** saves a `.btp` file. - **NSF program...** saves an `.nsf` file, which the NES or an NSF player plays directly. +A voice plays on the channels its instruments cover. Where a row names a voice on another channel, the +FamiTracker and Bitphase files hold a note cut on that row, which is how the song plays it. The dialog +that announces the export lists those rows by frame, channel and row. + An NSF program has room for 32 KB, so the app tells you when a song is too long. See [NSF export](../formats/nsf.md) and [song compression](../concepts/compression.md). diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 4befcb549..113bd1df8 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, Final, Optional +from typing import Any, Dict, Final, Optional, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -638,6 +638,8 @@ def __init__( sequencer_tab=self._sequencer_tab, instructions_tab=self._instructions_tab, ) + self.browser_manager.on_recordings_read = self._show_reconstruction_recordings + self._setup_gui() self._restore_current_items( library_path=library_path, @@ -1105,6 +1107,15 @@ def _refresh_browsers(self) -> None: self._reconstructions_tab.refresh_browser() self._sequencer_tab.refresh_browser() + def _show_reconstruction_recordings(self, path: Path, names: Tuple[str, ...]) -> None: + """Hands a document's recordings to both browsers, whichever of them asked for the reading. + + The two browsers render one tree and read one set of documents, so a reading answers for + each of them and the row it belongs to is the one that shows it. + """ + self._reconstructions_tab.show_browser_recordings(path, names) + self._sequencer_tab.show_browser_recordings(path, names) + def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: """Repaints the toggled path in both browsers, whichever tab the star was clicked in. @@ -1289,7 +1300,7 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: self.reconstruction_manager.apply_edited( retuned.reconstruction, ) - self._reconstructions_tab.update_reconstruction() + self._reconstructions_tab.update_reconstruction(refit_waveform=True) def _open_project_properties(self) -> None: """Opens the properties dialog seeded with the current project's info. diff --git a/src/sampletones_application/categories/skipped.py b/src/sampletones_application/categories/skipped.py new file mode 100644 index 000000000..233ee56c3 --- /dev/null +++ b/src/sampletones_application/categories/skipped.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Optional, Self, Tuple + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_core.structures import IdentifiedCollection +from sampletones_core.utils.display import display_id, display_voice_label + +MAX_REPORTED_ROWS: Final[int] = 12 +ROW_BULLET: Final[str] = " - " + + +@dataclass(frozen=True) +class SkippedRowMessages: + """The words the rows an export left silent are reported in. + + A row naming a voice on a channel the voice has no instrument for plays nothing in the song, + so the tracker formats write a note cut there. The report names each row where the reader + finds it in the tracker, and closes on how many more the list leaves out. + + Attributes: + heading: The line introducing the rows. + row: The template one row is printed with. + more: The template naming the rows past the ones listed. + channels: The name each channel is printed under. + """ + + heading: str + row: str + more: str + channels: Dict[ChannelName, str] + + @classmethod + def build(cls, language_manager: LanguageManager) -> Self: + """Resolves every word the report prints. + + Args: + language_manager: The catalog the words are read from. + + Returns: + Self: The bundle the export result handler reads. + """ + return cls( + heading=language_manager["global.dialog.message.export_skipped_rows"], + row=language_manager["global.dialog.template.export_skipped_row"], + more=language_manager["global.dialog.template.export_skipped_rows_more"], + channels={channel: channel_label(language_manager, channel) for channel in ChannelName.items()}, + ) + + def notice( + self, + skipped_rows: Tuple[SkippedRow, ...], + voices: IdentifiedCollection[VoiceUnion], + ) -> Optional[str]: + """Phrases the rows an export wrote as a note cut. + + Args: + skipped_rows: The rows the export left silent, in the order the song plays them. + voices: The project's voices, which the rows name their voice through. + + Returns: + Optional[str]: The lines the export dialog appends, and ``None`` where no row was + left silent. + """ + if not skipped_rows: + return None + + listed = skipped_rows[:MAX_REPORTED_ROWS] + lines = [self.heading, *(f"{ROW_BULLET}{self._row(skipped, voices)}" for skipped in listed)] + remaining = len(skipped_rows) - len(listed) + if remaining > 0: + lines.append(self.more.format(count=remaining)) + + return "\n".join(lines) + + def _row( + self, + skipped: SkippedRow, + voices: IdentifiedCollection[VoiceUnion], + ) -> str: + return self.row.format( + frame=display_id(skipped.order_position), + channel=self.channels[skipped.channel], + row=display_id(skipped.row_index), + voice=self._voice(skipped.voice_id, voices), + ) + + @staticmethod + def _voice(voice_id: str, voices: IdentifiedCollection[VoiceUnion]) -> str: + """The voice as the voices list prints it, and blank where the project no longer holds it.""" + voice = voices.get(voice_id) + if voice is None: + return display_id(None) + + return display_voice_label(voices.get_index(voice_id), voice.name) diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 21f0c3f9f..775b569b1 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -11,6 +11,7 @@ from sampletones_application.categories.exports import EXPORT_PROJECT_ELEMENTS from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.skipped import SkippedRowMessages from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.logic.project.controller import ProjectController @@ -34,6 +35,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.frame import FrameCallbackManager +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.scope import ExportScope @@ -85,6 +87,7 @@ def __init__( self._format_setups = format_setups self._dialogs = dialogs self._language_manager = language_manager + self._skipped_row_messages = SkippedRowMessages.build(language_manager) self._on_tab_switch = on_tab_switch self._project_manager.session.on_state_changed = on_session_state_changed @@ -317,12 +320,13 @@ def _on_export_result(self, result: ExportResult) -> None: case ExportSuccess( kind=ExportKind.PROJECT, export_format=ExportFormat() as export_format, + skipped_rows=skipped_rows, ): self._present( partial( self._dialogs.show_info, TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message), + self._exported_message(export_format, skipped_rows), self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), ) ) @@ -339,6 +343,19 @@ def _on_export_result(self, result: ExportResult) -> None: ) ) + def _exported_message( + self, + export_format: ExportFormat, + skipped_rows: Tuple[SkippedRow, ...], + ) -> str: + """The report of a written project, followed by the rows the format wrote as a note cut.""" + message = self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message) + notice = self._skipped_row_messages.notice(skipped_rows, self._project_manager.current.voices) + if notice is None: + return message + + return f"{message}\n\n{notice}" + def _present(self, raise_dialog: VoidCallback) -> None: """Raises ``raise_dialog`` once the frame the export window left the screen in has finished.""" FrameCallbackManager.set_frame_callback(raise_dialog) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index e09e3a51c..7924638a7 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,6 +1,6 @@ from functools import partial from pathlib import Path -from typing import Callable, Dict, FrozenSet, Mapping, Optional, Sequence +from typing import Callable, Dict, FrozenSet, Mapping, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -202,10 +202,12 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, + stem_colors=layout.stem_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) + self._browser_panel.on_recordings_requested = self._on_browser_recordings_requested self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility @@ -284,8 +286,10 @@ def __init__( self._browser_panel.on_directory_remove_requested = self._request_remove_directory self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source + self._reconstruction_audio_panel.on_nes_frequency_changed = self._reconstruction_panel_logic.set_nes_frequency self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels + self._reconstruction_stems_panel.on_stem_solo_requested = self._reconstruction_panel_logic.solo_stem self._reconstruction_stems_panel.on_stem_remove_requested = self._request_remove_stem self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate @@ -581,6 +585,16 @@ def _on_browser_favorites_filter_changed( """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + def _on_browser_recordings_requested(self, path: Path) -> None: + """Hands the browser what a document names, where it has already been read.""" + names = self._browser_logic.recordings(path) + if names is not None: + self._browser_panel.update_recordings(path, names) + + def show_browser_recordings(self, path: Path, names: Tuple[str, ...]) -> None: + """Hands the browser a reading that landed after the row asked for it.""" + self._browser_panel.update_recordings(path, names) + def _on_instruments_collapse_changed( self, card_tag: str, @@ -756,8 +770,8 @@ def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: """The recordings the reader hears on one channel, which is what an edit there reaches.""" return self._reconstruction_panel_logic.heard_on(channel_name) - def update_reconstruction(self) -> None: - self._reconstruction_panel_logic.update_reconstruction() + def update_reconstruction(self, *, refit_waveform: bool = False) -> None: + self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.refresh_view() def set_reconstruction_dimmed(self, dimmed: bool) -> None: diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 4b831e9bf..7393ee827 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -147,6 +147,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, + stem_colors=layout.stem_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL), @@ -578,6 +579,7 @@ def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.replace_in_sequencer_label = self._reconstructions.replace_target_label self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree + self._sequencer_browser_panel.on_recordings_requested = self._on_browser_recordings_requested self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility @@ -786,6 +788,16 @@ def _on_player_position_changed( def _on_preview_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: 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.""" + names = self._sequencer_browser_logic.recordings(path) + if names is not None: + self._sequencer_browser_panel.update_recordings(path, names) + + def show_browser_recordings(self, path: Path, names: Tuple[str, ...]) -> None: + """Hands the browser a reading that landed after the row asked for it.""" + self._sequencer_browser_panel.update_recordings(path, names) + def _is_project_open(self) -> bool: return self._project_controller.is_open diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 9a8879d7a..8e3b7cdc0 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -4,7 +4,6 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): master_column_width: int channel_column_width: int - channel_solo_width: int channel_box_width: int remove_button_width: int swatch_size: int diff --git a/src/sampletones_application/layout/glyphs/common.py b/src/sampletones_application/layout/glyphs/common.py index 10fddf7b9..65db42ebf 100644 --- a/src/sampletones_application/layout/glyphs/common.py +++ b/src/sampletones_application/layout/glyphs/common.py @@ -3,6 +3,7 @@ class CommonGlyphs(BaseModel, extra="forbid", frozen=True): tick: str + swatch: str favorite: str expanded: str collapsed: str diff --git a/src/sampletones_application/layout/glyphs/header.py b/src/sampletones_application/layout/glyphs/header.py index a08092c03..5346cdf40 100644 --- a/src/sampletones_application/layout/glyphs/header.py +++ b/src/sampletones_application/layout/glyphs/header.py @@ -13,6 +13,7 @@ class HeaderGlyphs(BaseModel, extra="forbid", frozen=True): details: str parameters: str source: str + stems: str instruments: str voices: str tracker: str diff --git a/src/sampletones_application/layout/graphs/waveform.py b/src/sampletones_application/layout/graphs/waveform.py index 6c441d34f..427823d08 100644 --- a/src/sampletones_application/layout/graphs/waveform.py +++ b/src/sampletones_application/layout/graphs/waveform.py @@ -4,5 +4,6 @@ class WaveformLayout(BaseModel, extra="forbid", frozen=True): reconstruction_dim_opacity: float zoom_factor: float + pan_factor: float max_display_points: int click_travel: float diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 144dd91ea..37f5a9a77 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -181,7 +181,6 @@ def _row(placement: _Placement) -> StemRowViewModel: held=_held(placement) if key.names_folder else (), channels=channels, partial_channels=partial, - bends=frozenset(), offered_channels=ALL_CHANNELS, available=placement.path.is_dir() if key.names_folder else placement.path.is_file(), level=placement.level, @@ -207,7 +206,6 @@ def _held(placement: _Placement) -> Tuple[StemRowViewModel, ...]: held=(), channels=frozenset(CHANNEL_SLOT.read(recording.settings)), partial_channels=frozenset(), - bends=frozenset(), offered_channels=ALL_CHANNELS, available=recording.path.is_file(), level=placement.level, diff --git a/src/sampletones_application/logic/reconstruction/browser/logic.py b/src/sampletones_application/logic/reconstruction/browser/logic.py index 4b512b023..e2fdaba07 100644 --- a/src/sampletones_application/logic/reconstruction/browser/logic.py +++ b/src/sampletones_application/logic/reconstruction/browser/logic.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional, Tuple from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.reconstruction.browser.manager import BrowserManager @@ -23,6 +24,10 @@ def refresh_tree(self) -> None: reconstructions_directory = self._config_manager.get_reconstructions_directory() self._browser_manager.set_reconstructions_directory(reconstructions_directory) + def recordings(self, path: Path) -> Optional[Tuple[str, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._browser_manager.recordings(path) + def remove_path(self, path: Path) -> None: remove_path(path) self.refresh_tree() diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index 4fd3deef9..b12077137 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -1,8 +1,12 @@ from pathlib import Path -from typing import List, Tuple +from typing import List, Optional, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.stems import ( + ReconstructionStemsReader, + RecordingsCallback, +) from sampletones_application.logic.reconstruction.browser.tree.collapse import ( collapse_single_child_containers, ) @@ -23,14 +27,19 @@ scan_reconstructions, ) from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from sampletones_shared.utils.callbacks import CallbackMixin -class BrowserManager: +class BrowserManager(CallbackMixin): """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that one reading, shapes what came out — empty headings pruned, lone headings folded into the row they lead to, siblings ordered — and publishes the result as the tree both browser tabs render. + + What each reconstruction holds is read from the documents themselves, one at a time and kept, + and announced through ``on_recordings_read`` for whoever asked before the reading landed. Both + tabs render one tree, so one reading answers for both. """ def __init__( @@ -42,9 +51,12 @@ def __init__( self._language_manager = language_manager self.config_manager = config_manager self.reconstructions_directory = config_manager.get_reconstructions_directory() + self.on_recordings_read: Optional[RecordingsCallback] = None self.tree = Tree() self._scan = ReconstructionScan(entries=()) + self._stems_reader = ReconstructionStemsReader() + self._stems_reader.on_recordings_read = self._announce_recordings def set_reconstructions_directory(self, directory: Path) -> None: self.reconstructions_directory = directory @@ -80,6 +92,13 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: order_children(container_root) return container_root + def recordings(self, path: Path) -> Optional[Tuple[str, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._stems_reader.recordings(path) + + def _announce_recordings(self, path: Path, names: Tuple[str, ...]) -> None: + self.call(self.on_recordings_read, path, names) + def get_all_reconstruction_files(self) -> List[Path]: return sorted({entry.path for entry in self._scan.reconstructions}) diff --git a/src/sampletones_application/logic/reconstruction/browser/stems.py b/src/sampletones_application/logic/reconstruction/browser/stems.py new file mode 100644 index 000000000..41bee7843 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/stems.py @@ -0,0 +1,99 @@ +import threading +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +from sampletones_application.logic.reconstruction.ownership import recording_names +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.logger import logger +from sampletones_shared.utils.callbacks import CallbackMixin + +RecordingsCallback = Callable[[Path, Tuple[str, ...]], None] + + +@dataclass(frozen=True) +class _ReadRecordings: + """What one reading of a document found, held against the state of the file it was read from.""" + + modified_at: Optional[float] + names: Tuple[str, ...] + + +class ReconstructionStemsReader(CallbackMixin): + """Answers which recordings a reconstruction on disk is made of, reading each document once. + + A row names a file, and only the file says what it holds, so the answer costs a read. The read + runs on a worker and the answer is kept against the moment the file was last written, which + makes a row asked about again answer at once and a regenerated document read afresh. A pointer + crossing many rows leaves the read of the row it comes to rest on, since a request replaces the + one still waiting. + + ``on_recordings_read`` announces an answer on the render thread, for the questions the reading + outlived. A document that cannot be read answers with nothing, which leaves whoever asked with + nothing to show. + """ + + def __init__(self) -> None: + self.on_recordings_read: Optional[RecordingsCallback] = None + + self._executor = LatestWinsExecutor() + self._lock = threading.Lock() + self._read: Dict[Path, _ReadRecordings] = {} + + def recordings(self, path: Path) -> Optional[Tuple[str, ...]]: + """The recordings the document names, in record order, and ``None`` while it stands unread. + + Asking for an unread document starts the reading that answers it, so one call both says + what is known and sets about learning the rest. + + Args: + path: The reconstruction file the row names. + + Returns: + Optional[Tuple[str, ...]]: The recordings, or None until the reading lands. + """ + modified_at = self._modified_at(path) + with self._lock: + read = self._read.get(path) + + if read is not None and read.modified_at == modified_at: + return read.names + + self._executor.submit(partial(self._read_document, path, modified_at)) + return None + + def _read_document(self, path: Path, modified_at: Optional[float]) -> None: + names = self._recordings_of(path) + with self._lock: + self._read[path] = _ReadRecordings( + modified_at=modified_at, + names=names, + ) + + CallbackQueue.add(self._announce, path, names) + + def _announce(self, path: Path, names: Tuple[str, ...]) -> None: + self.call(self.on_recordings_read, path, names) + + @staticmethod + def _recordings_of(path: Path) -> Tuple[str, ...]: + """The recordings the document at ``path`` names, and nothing where it cannot be read.""" + try: + stems_data = Reconstruction.read_stems_data(path) + except (OSError, SampleToNESError) as exception: + logger.debug(f'Failed to read the recordings of "{path}": {exception}') + return () + + return recording_names(stems_data) + + @staticmethod + def _modified_at(path: Path) -> Optional[float]: + """When the file was last written, and nothing where the disk does not answer for it.""" + try: + return path.stat().st_mtime + except OSError: + return None diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py index 15bcebd62..24b0ae067 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -18,6 +18,11 @@ def collapse_single_child_containers(node: TreeNode) -> None: click behavior, theme, context menu and favorite star carry over from before the fold. The two branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk holds stays a folder of its own, since the configuration branch mirrors the disk. + + A heading naming something somebody chose — an audio file, a source folder — hands the row a name + that is no longer the configuration's own text, and the row records that it gathered a plain name. + A reader of configuration text, such as the font a row is drawn in, meets it as the plain name it + has become. """ for child in list(node.children): collapse_single_child_containers(child) @@ -58,6 +63,7 @@ def _siblings_hold( def _fold_into_child(node: TreeNode) -> None: child = node.children[0] child.name = _joined_name(node, child) + child.gathered_plain_name = child.gathered_plain_name or not node.states_configuration child.parent = node.parent node.parent = None diff --git a/src/sampletones_application/logic/reconstruction/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py index a0e712f59..d90a5f3e7 100644 --- a/src/sampletones_application/logic/reconstruction/envelopes.py +++ b/src/sampletones_application/logic/reconstruction/envelopes.py @@ -1,6 +1,9 @@ from typing import Dict -from sampletones_application.logic.reconstruction.ownership import ownership_lanes +from sampletones_application.logic.reconstruction.ownership import ( + ownership_lanes, + tells_owners_apart, +) from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) @@ -50,9 +53,13 @@ def _lanes( Every channel the panel plots takes a lane, whatever the reader has switched on beneath the waveform: the channel boxes answer for the waveform, the stems card's muted tint and an export's scope. A lane reaches the last frame the channel's readings describe, so it stands - over the bars drawn from them and no further. + over the bars drawn from them and no further. A dimension's bars are one reading of one + channel, so a document answering to a single owner has nothing beneath them to tell apart. """ stems_data = reconstruction.stems_data + if not tells_owners_apart(stems_data): + return {} + owned = stems_data.assignments_by_channel assignments = { channel_name: owned[channel_name][: features.frame_count] diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py index 8076f6f1b..15e0620a1 100644 --- a/src/sampletones_application/logic/reconstruction/listening.py +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -1,4 +1,4 @@ -from typing import Dict, FrozenSet, Set +from typing import Dict, FrozenSet, Optional, Set from sampletones_core.constants.algorithm import AUTHORED_STEM_ID from sampletones_core.constants.enums import ChannelName @@ -17,11 +17,15 @@ class StemListening: The choice is the reader's, so it travels across an edit rather than being remade: a channel a recording keeps holding frames on keeps whatever was chosen for it, and one the recording reaches for the first time joins heard. + + Soloing a recording silences every other one and remembers the choice it replaced, so soloing + it again returns to that choice. A choice made by hand afterwards stands as the new state. """ def __init__(self) -> None: self._offered: Dict[int, FrozenSet[ChannelName]] = {} self._heard: Dict[int, FrozenSet[ChannelName]] = {} + self._heard_before_solo: Optional[Dict[int, FrozenSet[ChannelName]]] = None @property def offered(self) -> Dict[int, FrozenSet[ChannelName]]: @@ -67,15 +71,51 @@ def adopt(self, stems_data: StemsData) -> None: for stem_id, channels in offered.items() } self._offered = offered + self._heard_before_solo = None def set_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None: """Takes the channels one recording is heard on, as a box on its row leaves them.""" self._heard[stem_id] = channels + self._heard_before_solo = None + + def solo(self, stem_id: int) -> None: + """Hears one recording on every channel it offers and silences the others. + + The choice in force when the first solo starts is remembered, so soloing the recording + again returns to it, whichever recordings were soloed in between. Where nothing is + remembered, every recording is heard whole. A recording offering no channel has nothing + to hear alone. + """ + if not self._offered.get(stem_id): + return + + if self._is_alone(stem_id): + restored = self._heard_before_solo if self._heard_before_solo is not None else dict(self._offered) + self._heard_before_solo = None + self._heard = restored + return + + if not any(self._is_alone(other_id) for other_id in self._offered): + self._heard_before_solo = dict(self._heard) + + self._heard = { + offered_id: channels if offered_id == stem_id else frozenset() + for offered_id, channels in self._offered.items() + } def release(self) -> None: """Lets go of the choice, which is what closing the reconstruction it describes does.""" self._offered = {} self._heard = {} + self._heard_before_solo = None + + def _is_alone(self, stem_id: int) -> bool: + """Whether the recording is heard on every channel it offers while no other is heard at all.""" + offered = self._offered.get(stem_id, frozenset()) + if not offered or self._heard.get(stem_id, frozenset()) != offered: + return False + + return all(not channels for other_id, channels in self._heard.items() if other_id != stem_id) def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]: diff --git a/src/sampletones_application/logic/reconstruction/ownership.py b/src/sampletones_application/logic/reconstruction/ownership.py index 5d20ddac3..299aa26ab 100644 --- a/src/sampletones_application/logic/reconstruction/ownership.py +++ b/src/sampletones_application/logic/reconstruction/ownership.py @@ -1,4 +1,4 @@ -from typing import AbstractSet, Callable, Dict, Final, Mapping, Sequence +from typing import AbstractSet, Callable, Dict, Final, Mapping, Sequence, Tuple from sampletones_application.logic.reconstruction.listening import offered_channels from sampletones_application.view_model.shared.ownership import ( @@ -30,6 +30,28 @@ def record_positions(stems_data: StemsData) -> Dict[int, int]: return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} +def recording_names(stems_data: StemsData) -> Tuple[str, ...]: + """The recordings a document names, in the order the record paints them. + + The name standing in a given place is the recording standing at that place on the record, which + is where every surface reads its color from, so a list of these names and a column of swatches + beside them agree. A record whose recordings and entries fail to answer for each other names + nothing, a name worth reading being one that sits where its color does. + + Args: + stems_data: The record the document carries. + + Returns: + Tuple[str, ...]: One name per recording, in record order. + """ + positions = record_positions(stems_data) + sources = stems_data.sources_by_id + if sources.keys() != positions.keys(): + return () + + return tuple(sources[stem_id].name for stem_id in sorted(positions, key=lambda stem_id: positions[stem_id])) + + def tells_owners_apart(stems_data: StemsData) -> bool: """Whether the document holds owners a color has something to tell apart. @@ -56,7 +78,12 @@ def ownership_lanes( Each surface states its own reading as the assignments it passes — which channels stand, and how far each lane runs — so the rule dividing a channel into stretches is written once and the ribbon under the waveform and the band under an instrument's bars agree by construction. - A document answering to a single owner has nothing to tell apart and offers no lane. + Whether a lane is worth drawing at all is the caller's own question: a surface with room for + only one reading, such as an instrument's bars, asks ``tells_owners_apart`` first and skips + this call where a document answering to a single owner has nothing to tell apart; a surface + reading every channel of a document at once, such as the waveform's ribbon, has a plainer + question to answer instead — whether a channel plays — and takes a lane for every one that + does. Args: stems_data: The record the document carries. @@ -66,9 +93,6 @@ def ownership_lanes( Returns: Dict[ChannelName, OwnershipLaneViewModel]: One lane per channel given, in that order. """ - if not tells_owners_apart(stems_data): - return {} - positions = record_positions(stems_data) return { channel_name: _ownership_lane(channel_name, stem_ids, positions, heard_on(channel_name)) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 22816f129..ab96d415a 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -162,7 +162,13 @@ def display_reconstruction(self) -> None: self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) self._emit_audio_data() - def update_reconstruction(self) -> None: + 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. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -178,6 +184,7 @@ def update_reconstruction(self) -> None: self.on_waveform_update_changed, reconstruction_data.waveform_data(self._stem_selection), self._selected_channels, + refit=refit_waveform, ) self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) if self._current_audio_source != AudioSourceType.ORIGINAL: @@ -249,6 +256,28 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: self._emit_audio_data() self.call(self.on_waveform_source_changed, audio_source) + def set_nes_frequency(self, nes_frequency: int) -> None: + """Retunes the open reconstruction to ``nes_frequency`` and re-answers every reading of it. + + The instructions carry over and the audio is re-timed to the new frame length, so the + waveform, the playback and an export follow the new rate. The waveform's view re-fits to + the retuned length, taking the longer of it and the original audio where the document + keeps one, since the old view answers to a length the audio no longer has. The change is + an unsaved edit of the document, like any other. + """ + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + return + + reconstruction = reconstruction_data.reconstruction + retuned = reconstruction.with_nes_frequency(nes_frequency) + if retuned is reconstruction: + return + + self._reconstruction_manager.apply_edited(retuned) + self.update_reconstruction(refit_waveform=True) + self._reconstruction_manager.mark_updated() + def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" self._selected_channels = channels @@ -279,6 +308,19 @@ def set_stem_channels( what the instruments panel draws while the document stands as it is. """ self._listening.set_channels(stem_id, channels) + self._refresh_listening() + + def solo_stem(self, stem_id: int) -> None: + """Hears one recording alone, or returns to the choice it replaced when it already is. + + Like the boxes on its row, the solo is listening state, so it re-answers what plays and + what the waveform and the instruments panel show while the document stands as it is. + """ + self._listening.solo(stem_id) + self._refresh_listening() + + def _refresh_listening(self) -> None: + """Re-answers every reading of the document after the listening choice changed.""" self._reconstruction_manager.refresh_features() reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -313,11 +355,12 @@ def _build_ownership_ribbon( """The recordings behind each stretch of what the document plays. A lane stands for every channel the document plays, and paints the stretches the - recordings heard there hold, so the ribbon reads as the waveform above it sounds. A - channel the reader has switched off keeps its lane and stands empty, since the lanes - answer for the document while what fills them answers for the listening: the rows beneath - the waveform hold still while a reader picks their way through it. A document answering to - one owner alone has nothing to tell apart, so it offers no lanes. + recordings heard there hold, so the ribbon reads as the waveform above it sounds — one + recording throughout paints one unbroken stretch, which still answers whether a channel + is sounding and by whom. A channel the reader has switched off keeps its lane and stands + empty, since the lanes answer for the document while what fills them answers for the + listening: the rows beneath the waveform hold still while a reader picks their way + through it. """ stems_data = reconstruction_data.reconstruction.stems_data owned = stems_data.assignments_by_channel @@ -360,13 +403,11 @@ def _build_stems_view_model( stems=EMPTY_STEMS_LIST, ) - entries = stems_data.config.entries_by_id positions = record_positions(stems_data) levels = self._levels_with_edits(stems_data) rows = tuple( self._stem_row( stems_data, - entries[stem_id].settings.bend_set if stem_id in entries else frozenset(), positions.get(stem_id), stem_id, level_index, @@ -410,7 +451,6 @@ def _levels_with_edits(self, stems_data: StemsData) -> List[List[int]]: def _stem_row( self, stems_data: StemsData, - bends: FrozenSet[ChannelName], record_position: Optional[int], stem_id: int, level: int, @@ -428,7 +468,6 @@ def _stem_row( held=(), channels=self._listening.heard.get(stem_id, frozenset()), partial_channels=frozenset(), - bends=bends, offered_channels=self._listening.offered.get(stem_id, frozenset()), available=source is not None and source.path is not None and source.path.is_file(), level=level, diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py index f123000de..a1afb142f 100644 --- a/src/sampletones_application/logic/sequencer/browser.py +++ b/src/sampletones_application/logic/sequencer/browser.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional, Tuple from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.project.controller import ProjectController @@ -28,6 +29,10 @@ def refresh_tree(self) -> None: reconstructions_directory = self._config_manager.get_reconstructions_directory() self._browser_manager.set_reconstructions_directory(reconstructions_directory) + def recordings(self, path: Path) -> Optional[Tuple[str, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._browser_manager.recordings(path) + def load_reconstruction(self, path: Path) -> Reconstruction: """Loads a reconstruction file for inspection before adding. diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py index cfe762eeb..3cb6da566 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.py @@ -6,6 +6,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.colors.feature import FeatureColors +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.layout.general.inputs import InputsLayout from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout from sampletones_application.layout.tabs.sequencer import SequencerLayout @@ -35,6 +36,7 @@ class SequencerTabParameters: plus_minus: PlusMinusButtonsLayout feature_colors: FeatureColors channel_colors: ChannelColors + stem_colors: StemColors tree_colors: TreeColors muted_color: BaseColor scheduling: SchedulingBehavior @@ -53,6 +55,7 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters: plus_minus=general.plus_minus_buttons, feature_colors=general.colors.features, channel_colors=general.colors.channels, + stem_colors=general.colors.stems, tree_colors=TreeColors.create( general.colors, accent=general.colors.headers.reconstruction, diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index a837f3c4b..fcebdc019 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -14,6 +14,7 @@ from sampletones_application.services.result import ServiceCanceled, ServiceStarted from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat @@ -134,7 +135,7 @@ def _written_wave( ) -> ExportArtifact: """Writes the samples straight out, which takes a moment and reports no stages.""" write_wave(filepath, sample_rate, audio) - return ExportArtifact(paths=(filepath,), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=(filepath,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) def _submit( self, @@ -207,6 +208,7 @@ def _report_written( filepath=artifact.paths[0] if artifact.paths else destination, export_format=export_format, truncation=artifact.truncation, + skipped_rows=artifact.skipped_rows, ) ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index aeb016250..a89e99d12 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -1,8 +1,9 @@ from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from sampletones_application.services.export.kind import ExportKind +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat @@ -17,9 +18,12 @@ class ExportSuccess: export_format: The format the run wrote, and ``None`` for an audio export. truncation: What the target format's item limit left out, and ``None`` when the export carries every frame. + skipped_rows: The rows of the song written as a note cut, because the voice they name + has no instrument on their channel. """ kind: ExportKind filepath: Path export_format: Optional[ExportFormat] truncation: Optional[EnvelopeTruncation] + skipped_rows: Tuple[SkippedRow, ...] diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 4be57c044..71322d010 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -170,6 +170,12 @@ Widget.THEME, "focused_button", ) +TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "danger_button_focused", +) TAG_GLOBAL_THEME_SECTION_HEADER = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -861,7 +867,6 @@ SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback") SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement") SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment") -SUF_BENDS = "bends" SUF_CHANNELS = "channels" SUF_GROUP = "group" SUF_HEADING = "heading" diff --git a/src/sampletones_application/tags/graphs.py b/src/sampletones_application/tags/graphs.py index 701c518a9..84e0d0246 100644 --- a/src/sampletones_application/tags/graphs.py +++ b/src/sampletones_application/tags/graphs.py @@ -40,3 +40,4 @@ SUF_BAR_PLOT_ZERO_LINE = "zero_line" SUF_BAR_PLOT_HOVER_BAR = "hover_bar" SUF_HANDLER_MOUSE = compose_tag("handler", "mouse") +SUF_HANDLER_WHEEL = compose_tag("handler", "wheel") diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 003e8ef06..6f27937d8 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -104,12 +104,30 @@ Widget.PATH, "reconstruction_file", ) -TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY = TagName( +TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, - Widget.TEXT, + Widget.INPUT, + "nes_frequency", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.GROUP, "nes_frequency", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TOOLTIP, + "nes_frequency", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TOOLTIP, + "nes_frequency_locked", +) TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index cd6ef5f14..26b716d23 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -24,6 +24,7 @@ dpg_delete_item, dpg_is_item_hovered, ) +from sampletones_application.utils.gui.keyboard.modifiers import CTRL_ALT_SHIFT, capture_modifiers from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor @@ -101,6 +102,7 @@ def __init__( (_min_x, _max_x), _y_range, layout.waveform.zoom_factor, + layout.waveform.pan_factor, ) def _create_content(self) -> None: @@ -328,7 +330,7 @@ def _on_mouse_action(self, _sender: Sender) -> None: previous_stroke = self._draw_stroke self._draw_stroke = None - if dpg.is_key_down(dpg.mvKey_LControl) or dpg.is_key_down(dpg.mvKey_LShift) or dpg.is_key_down(dpg.mvKey_LAlt): + if capture_modifiers() & CTRL_ALT_SHIFT: return if not dpg_is_item_hovered(self.plot_tag) or self.current_data is None: diff --git a/src/sampletones_application/ui/elements/graphs/graph.py b/src/sampletones_application/ui/elements/graphs/graph.py index 94e628363..12cf03230 100644 --- a/src/sampletones_application/ui/elements/graphs/graph.py +++ b/src/sampletones_application/ui/elements/graphs/graph.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Dict, Generic, Optional, Tuple +from typing import Any, ClassVar, Dict, Generic, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -15,13 +15,17 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.graphs.layers.type import LayerT +from sampletones_application.ui.elements.graphs.pan import PlotWheelPan from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_shared.types.application import Sender class GUIGraph(GUIPanel, ABC, Generic[LayerT]): + pans_with_wheel: ClassVar[bool] = True + def __init__( self, tag: str, @@ -32,6 +36,7 @@ def __init__( x_range: Tuple[float, float], y_range: Tuple[float, float], zoom_factor: float, + pan_factor: float, ): self.label = label self.plot_tag = compose_tag(tag, SUF_GRAPH_PLOT) @@ -42,6 +47,7 @@ def __init__( self.event_handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY) self.zoom_factor = zoom_factor + self.pan_factor = pan_factor self._x_range: Tuple[float, float] = x_range self._y_range: Tuple[float, float] = y_range self._default_x_range = self._x_range @@ -74,6 +80,25 @@ def _setup_handlers(self) -> None: with dpg.item_handler_registry(tag=self.event_handler_tag): dpg.add_item_hover_handler(callback=self._on_hover) + if self.pans_with_wheel: + PlotWheelPan( + tag=self.tag, + plot_tags=self._pannable_plot_tags(), + x_axis_tag=self.x_axis_tag, + y_axis_tag=self.y_axis_tag, + pan_factor=self.pan_factor, + x_bounds=lambda: self.x_range, + y_bounds=lambda: self.y_range, + on_panned=self._on_panned, + ).create() + + def _pannable_plot_tags(self) -> Sequence[str]: + """The plots the wheel pans the graph from, which are the plot and any linked to it.""" + return (self.plot_tag,) + + def _on_panned(self) -> None: + self._release_axes_limits() + def _bind_event_handler(self) -> None: dpg.bind_item_handler_registry(self.plot_tag, self.event_handler_tag) @@ -86,9 +111,13 @@ def _on_hover( _app_data: int, _user_data: Any, ) -> None: - shift = dpg.is_key_down(dpg.mvKey_LShift) - dpg.configure_item(self.x_axis_tag, lock_min=shift, lock_max=shift) - dpg.configure_item(self.y_axis_tag, lock_min=not shift, lock_max=not shift) + modifiers = capture_modifiers() + panning = Modifier.ALT in modifiers + shift = Modifier.SHIFT in modifiers + lock_x = panning or shift + lock_y = panning or not shift + dpg.configure_item(self.x_axis_tag, lock_min=lock_x, lock_max=lock_x) + dpg.configure_item(self.y_axis_tag, lock_min=lock_y, lock_max=lock_y) self._release_axes_limits() def add_layer(self, layer: LayerT) -> None: diff --git a/src/sampletones_application/ui/elements/graphs/pan.py b/src/sampletones_application/ui/elements/graphs/pan.py new file mode 100644 index 000000000..38cf30cf9 --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/pan.py @@ -0,0 +1,83 @@ +from typing import Callable, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_HANDLER_WHEEL +from sampletones_application.utils.gui.dpg import dpg_is_item_hovered +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +AxisLimits = Tuple[float, float] + + +class PlotWheelPan: + """Pans a plot with the mouse wheel while Alt is held. + + Alt with the wheel moves the view along x, and Alt and Shift together move it along y, by + ``pan_factor`` of the span in view for each notch. Scrolling down moves x toward larger values, + the way a timeline advances, and scrolling up moves y toward larger values. A move stops at the + bounds the graph constrains its axes to. The wheel acts while the pointer is over the plot or + over any plot linked to it, such as the lanes standing beneath a waveform. + + The plot's own wheel zoom stays out of the way because the graph locks its axes while Alt is + held, so the wheel reaches the view through this class alone. + """ + + def __init__( + self, + *, + tag: str, + plot_tags: Sequence[str], + x_axis_tag: str, + y_axis_tag: str, + pan_factor: float, + x_bounds: Callable[[], AxisLimits], + y_bounds: Callable[[], AxisLimits], + on_panned: VoidCallback, + ) -> None: + self._registry_tag = compose_tag(tag, SUF_HANDLER_WHEEL) + self._plot_tags = tuple(plot_tags) + self._x_axis_tag = x_axis_tag + self._y_axis_tag = y_axis_tag + self._pan_factor = pan_factor + self._x_bounds = x_bounds + self._y_bounds = y_bounds + self._on_panned = on_panned + + def create(self) -> None: + """Registers the wheel handler, which reads the pointer and the modifiers as each notch lands.""" + with dpg.handler_registry(tag=self._registry_tag): + dpg.add_mouse_wheel_handler(callback=self._on_wheel) + + def _on_wheel(self, _sender: Sender, wheel: float) -> None: + modifiers = capture_modifiers() + if Modifier.ALT not in modifiers or not self._hovered(): + return + + if Modifier.SHIFT in modifiers: + self._pan(self._y_axis_tag, self._y_bounds(), wheel) + else: + self._pan(self._x_axis_tag, self._x_bounds(), -wheel) + + self._on_panned() + + def _hovered(self) -> bool: + return any(dpg_is_item_hovered(plot_tag) for plot_tag in self._plot_tags) + + def _pan( + self, + axis_tag: str, + bounds: AxisLimits, + notches: float, + ) -> None: + """Moves an axis by ``notches`` steps of the span in view, within ``bounds``.""" + low, high = dpg.get_axis_limits(axis_tag) + span = high - low + bound_low, bound_high = bounds + if span >= bound_high - bound_low: + return + + start = min(max(low + notches * self._pan_factor * span, bound_low), bound_high - span) + dpg.set_axis_limits(axis_tag, start, start + span) diff --git a/src/sampletones_application/ui/elements/graphs/ribbon.py b/src/sampletones_application/ui/elements/graphs/ribbon.py index a10534a81..0f54f56be 100644 --- a/src/sampletones_application/ui/elements/graphs/ribbon.py +++ b/src/sampletones_application/ui/elements/graphs/ribbon.py @@ -19,8 +19,9 @@ class GUIOwnershipRibbon: Each lane stands for one channel in play and runs the length of the document beneath the waveform, painted in the color of the recording holding each stretch. A resting stretch shows the ground the lane is laid on, so a reader tells at a glance which recording carried a - passage and where nothing was played. The lanes are drawn only while more than one recording - is in play, since a document answering to a single recording has nothing to distinguish. + passage and where nothing was played. A lane is drawn for every channel the document plays, + whether the reconstruction answers to one recording or several, since even a single recording + throughout is worth telling from a channel standing by. A lane is painted into the row the waveform keeps for its channel, which is what holds the two to one span: they begin and end together however wide the amplitude labels beside them diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index 5bfa649e9..d184ad48c 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -25,6 +25,8 @@ class GUISpectrumGraph(GUIGraph[SpectrumLayer]): + pans_with_wheel = False + tag: str parent: str width: int @@ -58,6 +60,7 @@ def __init__( x_range=(layout.graph.min_x, layout.graph.max_x), y_range=(MIN_FREQUENCY, DEFAULT_SAMPLE_RATE / 2), zoom_factor=layout.waveform.zoom_factor, + pan_factor=layout.waveform.pan_factor, ) def _create_content(self) -> None: diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 9b585f8fd..e61ddaf73 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Tuple, Union +from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Sequence, Tuple, Union import dearpygui.dearpygui as dpg import numpy as np @@ -9,6 +9,7 @@ from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.graphs import ( SUF_GRAPH_PLOT, SUF_GRAPH_SUBPLOTS, @@ -40,6 +41,7 @@ dpg_delete_item, ) from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor @@ -98,6 +100,7 @@ def __init__( self.position_indicator_tag = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) self.overlay_rectangle_tag = compose_tag(tag, SUF_WAVEFORM_OVERLAY) self.mouse_handler_tag = compose_tag(tag, SUF_HANDLER_MOUSE) + self.lane_handler_tag = compose_tag(tag, SUF_RIBBON_LANE, SUF_HANDLER_REGISTRY) self.on_position_clicked: Optional[Callable[[int], None]] = None self._click = PlotClickGesture( @@ -140,6 +143,7 @@ def __init__( (_min_x, _max_x), (_min_y, _max_y), layout.waveform.zoom_factor, + layout.waveform.pan_factor, ) def _create_content(self) -> None: @@ -235,12 +239,21 @@ def _create_lane_rows(self) -> None: ) dpg.set_axis_ticks( self.lane_y_axis_tags[channel_name], - ((channel_letter(self._language_manager, channel_name), LANE_LETTER_POSITION),), + self._lane_letter_ticks(channel_name), ) FontRegistry.bind_to_item(plot_tag, Font.REGULAR_TINY) self._bind_lane_theme(channel_name, plot_tag) + self._bind_lane_hover() + + def _bind_lane_hover(self) -> None: + with dpg.item_handler_registry(tag=self.lane_handler_tag): + dpg.add_item_hover_handler(callback=self._on_lane_hover) + + for plot_tag in self.lane_plot_tags.values(): + dpg.bind_item_handler_registry(plot_tag, self.lane_handler_tag) + def _bind_lane_theme(self, channel_name: ChannelName, plot_tag: str) -> None: """Prints a lane's letter in the color the channel is drawn in everywhere else.""" theme_tag = compose_tag(plot_tag, SUF_GRAPH_THEME) @@ -264,10 +277,25 @@ def set_height(self, height: int) -> None: self._resize_rows() def set_lane_heights(self, heights: Mapping[ChannelName, int]) -> None: - """Gives each channel's row the height it needs, closing the ones with nothing to show.""" + """Gives each channel's row the height it needs, closing the ones with nothing to show. + + A closed row carries a sliver of height rather than none, since the grid gives every row + some room to stay laid out in, so a channel's letter is cleared from its axis rather than + left to crowd that sliver. + """ self._lane_heights = {channel_name: heights.get(channel_name, 0) for channel_name in ChannelName.items()} + for channel_name, height in self._lane_heights.items(): + dpg.set_axis_ticks( + self.lane_y_axis_tags[channel_name], + self._lane_letter_ticks(channel_name) if height else (), + ) + self._resize_rows() + def _lane_letter_ticks(self, channel_name: ChannelName) -> Tuple[Tuple[str, float], ...]: + """The one tick standing for a lane's letter, at the position it prints centered from.""" + return ((channel_letter(self._language_manager, channel_name), LANE_LETTER_POSITION),) + def _resize_rows(self) -> None: """Hands the grid the room the waveform and the rows beneath it take together.""" lanes = [ @@ -311,6 +339,20 @@ def clear_layers(self) -> None: def set_overlay_range(self, start: float = 0.0, end: float = 0.0) -> None: self._set_overlay_rectangle(x_start=start, x_end=end) + def _pannable_plot_tags(self) -> Sequence[str]: + return (self.plot_tag, *self.lane_plot_tags.values()) + + def _on_panned(self) -> None: + super()._on_panned() + FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks) + + def _on_lane_hover(self) -> None: + """Holds the lanes' own wheel zoom back while Alt is held, so the wheel pans them instead.""" + locked = Modifier.ALT in capture_modifiers() + for channel_name, plot_tag in self.lane_plot_tags.items(): + for axis_tag in (compose_tag(plot_tag, SUF_GRAPH_X_AXIS), self.lane_y_axis_tags[channel_name]): + dpg.configure_item(axis_tag, lock_min=locked, lock_max=locked) + 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) @@ -455,7 +497,14 @@ def update_waveform_data( self, waveform_data: WaveformData, selected_channels: Optional[List[ChannelName]] = None, + *, + refit: bool = False, ) -> None: + """Redraws the loaded waveform from fresh data, keeping the view the reader left it at. + + ``refit`` names the update a retune is: the audio's own length changed, so the view is + re-fitted to the new span rather than held at a position the old one no longer answers to. + """ if not isinstance(self.current_data, WaveformData): return @@ -463,6 +512,9 @@ def update_waveform_data( for layer in self._display_layers(waveform_data, selected_channels): self.layers[layer.name] = layer + if refit: + self._update_ranges() + self._update_display() def load_waveform_data( diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 1e9563cb1..86812c6a2 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -228,7 +228,6 @@ def columns(self, view_model: StemsListViewModel) -> StemsColumns: channels=view_model.channels_in_play, master=self._offer.master_box, removable=self._offer.removal, - bends=self._offer.bends, swatch=self._offer.swatch, folders=view_model.holds_folders, ) diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index b748f0900..4888238d1 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -1,18 +1,16 @@ from dataclasses import dataclass -from typing import Final, Tuple +from typing import Final, Self, Tuple import dearpygui.dearpygui as dpg from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.constants.enums import ChannelName NO_RESERVE: Final[int] = 0 NO_INDENT: Final[int] = 0 COLUMN_BORDER: Final[int] = 1 -ONE_SLOT: Final[int] = 1 -TWO_SLOTS: Final[int] = 2 @dataclass(frozen=True) @@ -26,8 +24,7 @@ class StemsColumns: asks it. The name column is the one that stretches, so a wider card spends its room on the recordings - rather than on the boxes beside them. A channel column holds one box, or two where ``bends`` - states that a cell carries the bend on its channel, and takes the width that fits. + rather than on the boxes beside them. A channel column holds one box. ``swatch`` states that a row leads with the color its recording is drawn in, which takes a narrow column of its own between the box beside a row and the name it carries. @@ -43,14 +40,25 @@ class StemsColumns: channels: Tuple[ChannelName, ...] master: bool removable: bool - bends: bool swatch: bool folders: bool + @classmethod + def empty(cls, layout: StemsListLayout) -> Self: + """The grid before a list has drawn anything: no channel, and nothing beside the name.""" + return cls( + layout=layout, + channels=(), + master=False, + removable=False, + swatch=False, + folders=False, + ) + @property def channel_width(self) -> int: - """The room one channel's column takes, which the boxes standing in it decide.""" - return self.layout.channel_column_width if self.bends else self.layout.channel_solo_width + """The room one channel's column takes, which the box standing in it decides.""" + return self.layout.channel_column_width @property def reserve(self) -> int: @@ -99,28 +107,15 @@ def declare(self) -> None: if self.reserved: dpg.add_table_column(width_fixed=True, init_width_or_weight=self.reserve_width) - def slots(self, channel_name: ChannelName) -> int: - """How many boxes one channel's cell holds: the channel it takes, and the bend on it. - - A bend moves a note by a fraction of the divider its channel loads, so a channel whose - periods stand at fixed distances holds the first slot alone. - """ - if self.bends and channel_name in TONE_CHANNELS: - return TWO_SLOTS - - return ONE_SLOT - @property def master_indent(self) -> int: """How far the box beside a row sits in, so it stands in the middle of its own column.""" return self._centered(self.layout.channel_box_width, within=self.layout.master_column_width) - def box_indent(self, channel_name: ChannelName) -> int: - """How far a channel's boxes sit in, so they stand in the middle of their own column.""" - return self._centered( - self.slots(channel_name) * self.layout.channel_box_width, - within=self.channel_width, - ) + @property + def box_indent(self) -> int: + """How far a channel's box sits in, so it stands in the middle of its own column.""" + return self._centered(self.layout.channel_box_width, within=self.channel_width) def marker_indent(self, glyph: str, font: Font) -> int: """How far a row carrying no marker sits in, so its name opens where a marker's glyph does. diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 655c16d15..2c8657887 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -44,15 +44,7 @@ def __init__( self._rows = rows self._regions: Dict[str, WindowedRegion] = {} self._resting: Dict[str, float] = {} - self._columns = StemsColumns( - layout=layout, - channels=(), - master=False, - removable=False, - bends=False, - swatch=False, - folders=False, - ) + self._columns = StemsColumns.empty(layout) def reads(self, columns: StemsColumns) -> None: """Takes up the grid the list is drawing, which a folder's own tables stand in too.""" diff --git a/src/sampletones_application/ui/elements/stems/heading.py b/src/sampletones_application/ui/elements/stems/heading.py index f982e9188..dc1bc347b 100644 --- a/src/sampletones_application/ui/elements/stems/heading.py +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -10,25 +10,21 @@ SUF_HEADING, SUF_TABLE, SUF_TEXT, - SUF_TOOLTIP, TAG_GLOBAL_THEME_CHANNEL_MUTED, - TAG_GLOBAL_THEME_STEMS_SLOT_LABEL, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.stems.columns import StemsColumns from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.tooltip import show_tooltip -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.constants.enums import ChannelName class StemsHeading: """The channels a stems grid stands under, named once above the rows. - Naming a channel here leaves each cell below free for the boxes it holds, and the cell reads as - one channel because the name spans it. Where a cell holds two boxes, a second line names them: - the channel the recording takes, and the bend on it. + Naming a channel here leaves each cell below free for the box it holds, and the cell reads as + one channel because the name spans it. """ def __init__( @@ -37,24 +33,10 @@ def __init__( prefix: str, layout: StemsListLayout, language_manager: LanguageManager, - bends: bool, ) -> None: self._prefix = prefix - self._layout = layout self._language_manager = language_manager - self._bends = bends - self._lbl_on = language_manager["global.stems.label.channel_on"] - self._lbl_bend = language_manager["global.stems.label.channel_bend"] - self._msg_bend = language_manager["global.stems.message.bend_tooltip"] - self._columns = StemsColumns( - layout=layout, - channels=(), - master=False, - removable=False, - bends=bends, - swatch=False, - folders=False, - ) + self._columns = StemsColumns.empty(layout) @property def table(self) -> str: @@ -82,8 +64,6 @@ def create(self, parent: str, columns: StemsColumns) -> None: ): columns.declare() self._create_names() - if self._bends: - self._create_slots() def render(self, muted_channels: FrozenSet[ChannelName]) -> None: """Tone each channel's name the way its boxes are toned, so a column reads as one.""" @@ -106,29 +86,3 @@ def _create_names(self) -> None: FontRegistry.bind_to_item(name, Font.BOLD_SMALL) self._columns.open_trailing_cell() - - def _create_slots(self) -> None: - """Label the slots a cell holds, each standing over the box it names.""" - with dpg.table_row(): - self._columns.open_leading_cells() - for channel_name in self._columns.channels: - self._create_channel_slots(channel_name) - - self._columns.open_trailing_cell() - - def _create_channel_slots(self, channel_name: ChannelName) -> None: - with dpg.group(horizontal=True, indent=self._columns.box_indent(channel_name)): - self._create_slot_label(self._lbl_on) - if channel_name in TONE_CHANNELS: - bend = self._create_slot_label(self._lbl_bend) - show_tooltip(bend, self._msg_bend, tag=compose_tag(self.name(channel_name), SUF_TOOLTIP)) - - def _create_slot_label(self, label: str) -> int: - """One slot's name, held to the width of the box it stands over so the two line up.""" - with dpg.group(): - text = dpg.add_text(label) - FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) - ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_SLOT_LABEL).bind_to_item(text) - dpg.add_spacer(width=self._layout.channel_box_width, height=0) - - return int(text) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index ee47717fd..fce0ddead 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -114,7 +114,6 @@ def __init__( prefix=prefix, layout=layout, language_manager=language_manager, - bends=offer.bends, ) self._bands = LevelBands( self._tags, @@ -310,6 +309,15 @@ def lets_a_row_go(self) -> bool: """ return self._view.live and self._rows.releasable(self._view) + @property + def holds_several_rows(self) -> bool: + """Whether more than one row stands in the list, which is what a solo silences the rest of.""" + return self._view.row_count > 1 + + def soloed(self, key: str) -> bool: + """Whether the row is the one recording heard, which is what a menu names its solo by.""" + return self._view.soloed(key) + def stands_open(self, key: str) -> bool: """Whether the folder's recordings are in view, which is what a menu names its move by.""" return self._open_folders.stands_open(key) diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index 4c33cfd84..8c1f00eab 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -13,10 +13,6 @@ class StemsListOffer: each owner honors. A list states that here, once, so a drawing step reads one declaration rather than asking a flag of its own. - ``bends`` states that a channel's cell carries the bend on it beside the channel itself, which - a list recording what a finished conversion took draws and a list setting a run up leaves to - the settings card. - ``picking`` states that the box beside a row picks the row for a mix rather than answering for its channels, which is the reading a list asking which recordings to mix draws. @@ -28,7 +24,6 @@ class StemsListOffer: removal: bool keeps_last_row: bool dragging: bool - bends: bool picking: bool swatch: bool @@ -47,7 +42,6 @@ def drags(self, view_model: StemsListViewModel) -> bool: removal=True, keeps_last_row=False, dragging=True, - bends=False, picking=False, swatch=False, ) @@ -57,7 +51,6 @@ def drags(self, view_model: StemsListViewModel) -> bool: removal=True, keeps_last_row=True, dragging=False, - bends=True, picking=False, swatch=True, ) @@ -67,7 +60,6 @@ def drags(self, view_model: StemsListViewModel) -> bool: removal=False, keeps_last_row=False, dragging=False, - bends=False, picking=True, swatch=False, ) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index d9e6b01a5..da4dedbe1 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -370,37 +370,16 @@ def _create_channel( return checkbox_tag = self._tags.channel(row.key, channel_name) - with dpg.group(horizontal=True, indent=columns.box_indent(channel_name)): + with dpg.group(horizontal=True, indent=columns.box_indent): dpg.add_checkbox( tag=checkbox_tag, default_value=row.agreement_on(channel_name).reads_held, user_data=(row.key, channel_name), callback=self._gestures.on_channel_box, ) - self._create_bend(row, channel_name, columns) self._gestures.bind(checkbox_tag, SUF_CHANNELS) - def _create_bend( - self, - row: StemRowViewModel, - channel_name: ChannelName, - columns: StemsColumns, - ) -> None: - """The box stating the bend the recording carried on this channel, where the list draws one. - - A list describing a conversion that has already run states what it took, so the box reports - rather than asks: the choice was made when the reconstruction was written. - """ - if not columns.bends or channel_name not in row.bendable_channels: - return - - dpg.add_checkbox( - tag=self._tags.bend(row.key, channel_name), - default_value=channel_name in row.bends, - enabled=False, - ) - def _create_remove(self, row: StemRowViewModel) -> None: remove = dpg.add_button( label=self._lbl_remove, diff --git a/src/sampletones_application/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py index 95a6cee7e..c5eb688d3 100644 --- a/src/sampletones_application/ui/elements/stems/tags.py +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -2,7 +2,6 @@ from sampletones_application.tags.compose import compose_tag, identity_part from sampletones_application.tags.general import ( - SUF_BENDS, SUF_CHANNELS, SUF_CHECKBOX, SUF_FOLDER, @@ -100,7 +99,3 @@ def level(self, level_index: int, suffix: str) -> str: def channel(self, key: str, channel_name: ChannelName) -> str: """The tag the box giving ``key`` a channel carries.""" return self.row(key, compose_tag(SUF_CHANNELS, channel_name, SUF_CHECKBOX)) - - def bend(self, key: str, channel_name: ChannelName) -> str: - """The tag the box stating the bend ``key`` took on a channel carries.""" - return self.row(key, compose_tag(SUF_BENDS, channel_name, SUF_CHECKBOX)) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 401520974..4c135937e 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -76,6 +76,7 @@ ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import ( + DetailSwatch, create_detail_tooltip, populate_detail_tooltip, ) @@ -94,7 +95,6 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( - ConfigGroupNode, ConfigNode, FileSystemNode, LibraryNode, @@ -176,6 +176,7 @@ def __init__( self._lbl_detail_window_size = language_manager["global.context.label.detail_window_size"] self._lbl_detail_channels = language_manager["global.context.label.detail_channels"] self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"] + self._lbl_detail_stems = language_manager["global.context.label.detail_stems"] self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None self.on_add_to_sequencer: Optional[PathCallback] = None @@ -525,23 +526,63 @@ def _create_detail_tooltip(self, tree_window_tag: str) -> None: def _update_detail_tooltip(self, user_data: Any) -> None: """Reveals the detail tooltip for a hovered node that carries details, hiding it otherwise. - The reveal is gated on a change of owning node, so the tooltip content is rebuilt once per - node. + A hover is reported for every frame the pointer rests on a row, and the row it belongs to is + read first, so the content is built once per node and a row asked about a document asks for + it once. """ if not isinstance(user_data, tuple): return node, node_tag = user_data + if self._detail_tooltip_owner_tag == node_tag: + return + + self._show_detail_tooltip(node, node_tag) + + def _show_detail_tooltip(self, node: TreeNode, node_tag: str) -> None: + """Fills the tooltip with what the node answers, and hides it where the node answers nothing.""" detail_items = self._node_detail_items(node) - if detail_items: - if self._detail_tooltip_owner_tag != node_tag: - populate_detail_tooltip(self._detail_tooltip_tag, detail_items) - self._detail_tooltip_owner_tag = node_tag - dpg_configure_item(self._detail_tooltip_tag, show=True) + detail_recordings = self._node_detail_recordings(node) + if not detail_items and not detail_recordings: + self._hide_detail_tooltip() + return + populate_detail_tooltip( + self._detail_tooltip_tag, + detail_items, + swatch_glyph=self._glyphs.common.swatch, + swatch_label=self._lbl_detail_stems, + swatches=detail_recordings, + ) + self._detail_tooltip_owner_tag = node_tag + dpg_configure_item(self._detail_tooltip_tag, show=True) + + def refresh_detail_tooltip(self) -> None: + """Rebuilds the tooltip where it stands, which an answer arriving after it was built asks for. + + The rebuild belongs to the row the tooltip is showing, so it stands still once the pointer + has moved on. + """ + owner_tag = self._detail_tooltip_owner_tag + if owner_tag is None: return - self._hide_detail_tooltip() + node = self._node_at(owner_tag) + if node is not None: + self._show_detail_tooltip(node, owner_tag) + + @staticmethod + def _node_at(node_tag: str) -> Optional[TreeNode]: + """The node a row was created for, read back from the widget the row is.""" + if not dpg.does_item_exist(node_tag): + return None + + user_data = dpg.get_item_user_data(node_tag) + if not isinstance(user_data, tuple): + return None + + node, _ = user_data + return node if isinstance(node, TreeNode) else None def _hide_detail_tooltip(self) -> None: if self._detail_tooltip_owner_tag is None: @@ -764,10 +805,11 @@ def _resolve_node_name_font(self, node: TreeNode) -> Font: carries: the fields a reconstruction directory encodes, the stretch of them a heading gathers several directories under, the name a library goes by. A panel that sets ``_MONOSPACE_CONFIG_NODES`` renders every one of those rows in the fixed-width font, so a - column of them reads field under field and a heading reads as the row below it does. Every - other row keeps the panel's ``_NAME_FONT``. + column of them reads field under field and a heading reads as the row below it does. A row + whose name has gathered a name somebody chose reads as that plain name, and every other row + keeps the panel's ``_NAME_FONT``. """ - if self._MONOSPACE_CONFIG_NODES and isinstance(node, (ConfigNode, ConfigGroupNode, LibraryNode)): + if self._MONOSPACE_CONFIG_NODES and node.states_configuration: return self._CONFIG_FONT return self._NAME_FONT @@ -795,6 +837,10 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: return [] + def _node_detail_recordings(self, node: TreeNode) -> Tuple[DetailSwatch, ...]: # pylint: disable=unused-argument + """The recordings a row's document is made of, which a browser reading documents answers.""" + return () + def _library_detail_items( self, key: InstructionLibraryKey, diff --git a/src/sampletones_application/ui/panels/dialogs/export.py b/src/sampletones_application/ui/panels/dialogs/export.py index 8f0256782..44b1ee513 100644 --- a/src/sampletones_application/ui/panels/dialogs/export.py +++ b/src/sampletones_application/ui/panels/dialogs/export.py @@ -6,6 +6,10 @@ from sampletones_application.layout.general.colors.text import TextColors from sampletones_application.layout.settings import SettingsLayout from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, +) from sampletones_application.tags.settings import ( TAG_SETTINGS_EXPORT_BUTTON_CANCEL, TAG_SETTINGS_EXPORT_GROUP_MEASURED, @@ -21,6 +25,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.layout.centered import centered from sampletones_application.ui.elements.seeded import GUISeededDialogWindow +from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.keyboard import KeyRouter @@ -91,6 +96,8 @@ def create_window(self) -> None: FocusStop.button( TAG_SETTINGS_EXPORT_BUTTON_CANCEL, self._request_cancel, + base_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON, + focused_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, ) ], on_escape=self._request_cancel, @@ -156,6 +163,7 @@ def _create_cancel(self) -> None: label=self._language_manager["global.dialog.label.cancel"], callback=self._request_cancel, width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), ) def _render(self) -> None: diff --git a/src/sampletones_application/ui/panels/dialogs/nsf.py b/src/sampletones_application/ui/panels/dialogs/nsf.py index 1ea7a713d..99eb69336 100644 --- a/src/sampletones_application/ui/panels/dialogs/nsf.py +++ b/src/sampletones_application/ui/panels/dialogs/nsf.py @@ -44,7 +44,11 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop -from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item, dpg_set_value +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_item, + dpg_set_value, +) from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.keyboard.focus.tree import is_item_active from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color @@ -314,8 +318,14 @@ def _create_compression_section(self) -> None: indent=self._layout.label_width, wrap=0, ) - FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, Font.REGULAR_SMALL) - dpg_set_palette_color(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, self._text_colors.disabled) + FontRegistry.bind_to_item( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + Font.REGULAR_SMALL, + ) + dpg_set_palette_color( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + self._text_colors.disabled, + ) def _create_destination(self) -> None: """Lays out the file the export writes, with the browse button leading the path it stands at.""" @@ -323,7 +333,10 @@ def _create_destination(self) -> None: self._language_manager["settings.nsf.label.destination"], self._layout.label_width, ): - dpg.add_group(horizontal=True, tag=TAG_SETTINGS_NSF_GROUP_DESTINATION) + dpg.add_group( + horizontal=True, + tag=TAG_SETTINGS_NSF_GROUP_DESTINATION, + ) GUIButton( tag=TAG_SETTINGS_NSF_BUTTON_BROWSE, @@ -375,7 +388,10 @@ def _render(self) -> None: if self._destination_text is not None: self._destination_text.set_path(view_model.destination) - dpg_configure_item(TAG_SETTINGS_NSF_BUTTON_EXPORT, enabled=view_model.export_enabled) + dpg_configure_item( + TAG_SETTINGS_NSF_BUTTON_EXPORT, + enabled=view_model.export_enabled, + ) def _render_fields(self, view_model: NSFExportViewModel) -> None: for field in self._fields: @@ -389,24 +405,57 @@ def _render_channels(self, view_model: NSFExportViewModel) -> None: dpg_configure_item(tag, enabled=view_model.channel_offered(channel)) dpg_set_value(tag, view_model.channel_sounded(channel)) - dpg_configure_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, show=not view_model.export_enabled) + dpg_configure_item( + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + show=not view_model.export_enabled, + ) def _render_repeat(self, view_model: NSFExportViewModel) -> None: self._repeats_by_label = {self._repeat_labels[repeat]: repeat for repeat in view_model.repeats} - dpg_configure_item(TAG_SETTINGS_NSF_COMBO_REPEAT, items=list(self._repeats_by_label)) - dpg_set_value(TAG_SETTINGS_NSF_COMBO_REPEAT, self._repeat_labels[view_model.choices.repeat]) - dpg_configure_item(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, show=view_model.loop_frame_visible) - dpg_configure_item(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, enabled=view_model.loop_frame_visible) - self._settle_input(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, view_model.loop_frame_label(self._fmt_frame)) - dpg_set_value(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, view_model.frame_count_label(self._fmt_frame_count)) - dpg_set_value(TAG_SETTINGS_NSF_TEXT_LENGTH, view_model.length_label(self._fmt_length)) + dpg_configure_item( + TAG_SETTINGS_NSF_COMBO_REPEAT, + items=list(self._repeats_by_label), + ) + dpg_set_value( + TAG_SETTINGS_NSF_COMBO_REPEAT, + self._repeat_labels[view_model.choices.repeat], + ) + dpg_configure_item( + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + show=view_model.loop_frame_visible, + ) + dpg_configure_item( + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + enabled=view_model.loop_frame_visible, + ) + self._settle_input( + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + view_model.loop_frame_label(self._fmt_frame), + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + view_model.frame_count_label(self._fmt_frame_count), + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_LENGTH, + view_model.length_label(self._fmt_length), + ) def _render_scheme(self, view_model: NSFExportViewModel) -> None: scheme = view_model.choices.scheme self._schemes_by_label = {self._scheme_labels[offered]: offered for offered in view_model.schemes} - dpg_configure_item(TAG_SETTINGS_NSF_COMBO_SCHEME, items=list(self._schemes_by_label)) - dpg_set_value(TAG_SETTINGS_NSF_COMBO_SCHEME, self._scheme_labels[scheme]) - dpg_set_value(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, self._scheme_descriptions[scheme]) + dpg_configure_item( + TAG_SETTINGS_NSF_COMBO_SCHEME, + items=list(self._schemes_by_label), + ) + dpg_set_value( + TAG_SETTINGS_NSF_COMBO_SCHEME, + self._scheme_labels[scheme], + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + self._scheme_descriptions[scheme], + ) @staticmethod def _settle_input(tag: str, text: str) -> None: @@ -418,10 +467,20 @@ def _settle_input(tag: str, text: str) -> None: if dpg.does_item_exist(tag) and not is_item_active(tag): dpg_set_value(tag, text) - def _on_text_changed(self, _sender: Sender, app_data: str, field: HeaderField) -> None: + def _on_text_changed( + self, + _sender: Sender, + app_data: str, + field: HeaderField, + ) -> None: self._emit(field.edit(self._choices(), app_data)) - def _on_channel_changed(self, _sender: Sender, app_data: bool, channel: ChannelName) -> None: + def _on_channel_changed( + self, + _sender: Sender, + app_data: bool, + channel: ChannelName, + ) -> None: view_model = self.view_model self._emit(view_model.choices.with_channel(channel, bool(app_data), view_model.offer)) @@ -441,7 +500,9 @@ def _on_loop_frame_changed(self, _sender: Sender, app_data: str) -> None: def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None: view_model = self.view_model - self._emit(view_model.choices.with_scheme(self._schemes_by_label[app_data], view_model.offer)) + self._emit( + view_model.choices.with_scheme(self._schemes_by_label[app_data], view_model.offer), + ) def _emit(self, choices: NSFExportChoices) -> None: self.call(self.on_choices_changed, choices) diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py index d1f91603f..3ee79680e 100644 --- a/src/sampletones_application/ui/panels/dialogs/render.py +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -5,6 +5,10 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, +) from sampletones_application.tags.settings import ( TAG_SETTINGS_RENDER_BUTTON_BROWSE, TAG_SETTINGS_RENDER_BUTTON_CANCEL, @@ -33,6 +37,7 @@ from sampletones_application.ui.elements.path import GUIDestinationPathText from sampletones_application.ui.elements.seeded import GUISeededDialogWindow from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value @@ -138,7 +143,12 @@ def create_window(self) -> None: FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_BROWSE, self._request_destination), FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CLOSE, self._request_close), FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_START, self._request_render), - FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CANCEL, self._request_cancel), + FocusStop.button( + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + self._request_cancel, + base_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON, + focused_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, + ), ], on_escape=self._request_close, ) @@ -285,6 +295,7 @@ def _create_progress(self) -> None: label=self._language_manager["global.dialog.label.cancel"], callback=self._request_cancel, width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), ) def _render(self) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 3fd18c9f9..bdc30650e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -4,12 +4,17 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, - TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -17,6 +22,8 @@ from sampletones_application.ui.elements.path import GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip, set_tooltip_visible, show_tooltip +from sampletones_application.utils.gui.widgets import clamp_widget_value from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, @@ -27,17 +34,18 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) -from sampletones_core.configs.display import format_nes_frequency from sampletones_core.constants.enums import AudioSourceType +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from sampletones_shared.types.application import Sender class GUIReconstructionAudioPanel(GUIPanel): """Where the reconstruction came from and which of the two waveforms plays. - The card names the reconstruction's own file and the engine rate it runs at, and offers + The card names the reconstruction's own file and takes the engine rate it runs at, and offers the choice between the reconstruction and the audio it was built from. The recordings - behind that audio are named by the stems card, one row each. + behind that audio are named by the stems card, one row each. A reconstruction that is a + sample of a project follows the project's rate, so its field is locked. """ def __init__( @@ -53,10 +61,15 @@ def __init__( self._status_bar = status_bar self._path_colors = path_colors self._path_status_color = path_status_color + self._nes_frequency_handler_tag = compose_tag( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + SUF_HANDLER_REGISTRY, + ) self._reconstruction_file_path: GUIPathText self.on_audio_source_changed: Optional[Callable[[AudioSourceType], None]] = None + self.on_nes_frequency_changed: Optional[Callable[[int], None]] = None self._load_path_text(language_manager) self._lbl_original_audio_radio = language_manager["reconstructions.reconstruction.label.original_audio_radio"] @@ -84,14 +97,14 @@ def create_panel(self, parent: str) -> None: self._create_audio_source_radio_buttons() dpg.add_separator() self._create_path_display() - self._create_frequency_display() + self._create_frequency_input() def update_view(self, view_model: ReconstructionViewModel) -> None: self._render_path( self._reconstruction_file_path, view_model.reconstruction_file, ) - self._render_frequency(view_model.nes_frequency) + self._render_frequency(view_model) dpg_configure_item( TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, enabled=view_model.audio_source_enabled, @@ -137,20 +150,76 @@ def _create_path_display(self) -> None: ) self._reconstruction_file_path.set_status("", self._path_status_color) - def _create_frequency_display(self) -> None: - """Draws the engine rate as a readout beside its label, monospaced as a figure.""" - with dpg.group(horizontal=True, parent=self._body_container): + def _create_frequency_input(self) -> None: + """Draws the engine rate as a field beside its label, monospaced as a figure. + + The rate is committed once editing finishes, since a new rate re-times the whole + reconstruction and a change per keystroke would redo that work for every digit. A locked + field shows no tooltip of its own, so the group around it explains why it is locked. + """ + with dpg.group( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + horizontal=True, + parent=self._body_container, + ): label = dpg.add_text(self._language_manager["reconstructions.reconstruction.label.nes_frequency_label"]) - dpg.add_text("", tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) + dpg.add_input_int( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + min_value=MIN_NES_FREQUENCY, + max_value=MAX_NES_FREQUENCY, + min_clamped=True, + max_clamped=True, + width=-1, + enabled=False, + show=False, + ) FontRegistry.bind_to_item(label, Font.REGULAR_SMALL) - FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, Font.MONO) + FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, Font.MONO) + with dpg.item_handler_registry(tag=self._nes_frequency_handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._on_nes_frequency_input) + + dpg.bind_item_handler_registry( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + self._nes_frequency_handler_tag, + ) + show_tooltip( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + self._language_manager["main.config.tooltip.tooltip_nes_frequency"], + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + ) + attach_disabled_tooltip( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_locked"], + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, + ) + + def _render_frequency(self, view_model: ReconstructionViewModel) -> None: + """States the rate a loaded reconstruction runs at, and stands blank for an empty tab. + + A field being typed into keeps what the reader has typed until the edit is committed. + """ + nes_frequency = view_model.nes_frequency + dpg_configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + show=nes_frequency is not None, + enabled=view_model.nes_frequency_editable, + ) + set_tooltip_visible( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + view_model.nes_frequency_editable, + ) + set_tooltip_visible( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, + view_model.show_nes_frequency_hint, + ) + if nes_frequency is not None and not dpg.is_item_active(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY): + dpg_set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, nes_frequency) - def _render_frequency(self, nes_frequency: Optional[int]) -> None: - """States the rate a loaded reconstruction runs at, and stands blank for an empty tab.""" - dpg_set_value( - TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, - format_nes_frequency(nes_frequency) if nes_frequency is not None else "", + def _on_nes_frequency_input(self, _sender: Sender, _app_data: int) -> None: + self.call( + self.on_nes_frequency_changed, + int(clamp_widget_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)), ) def _create_audio_source_radio_buttons(self) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 8f618d03e..244de4c36 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -7,6 +7,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, @@ -48,6 +49,7 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + stem_colors: StemColors, initial_collapsed: bool, initial_favorites_only: bool, initial_expanded_rows: AbstractSet[str], @@ -61,6 +63,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + stem_colors=stem_colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index af75c9ad7..fd4c161d8 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -153,8 +153,10 @@ def update_waveform_data( self, waveform_data: WaveformData, generators: List[ChannelName], + *, + refit: bool = False, ) -> None: - self.waveform_display.update_waveform_data(waveform_data, generators) + self.waveform_display.update_waveform_data(waveform_data, generators, refit=refit) def clear_waveform(self) -> None: self._frame_length = None diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 4b4fd0c8d..dbf631698 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -19,6 +19,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.list import GUIStemsList from sampletones_application.ui.elements.stems.offer import RECORDED_ASSIGNMENT +from sampletones_application.ui.panels.reconstruction.stems_menu import StemsMenu from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.stems import ( @@ -41,8 +42,9 @@ class GUIReconstructionStemsPanel(GUIPanel): reconstruction shows its boxes muted while they stay as clickable as any other, so the reader's per-recording choice keeps standing. Each row leads with the color its recording is painted in, so a stretch of the ribbon under the waveform answers to a name here at a glance. - A double-click on a row shows the recording where it sits on disk, and the button beside it - asks to take the recording out of the reconstruction for good. The list holds on to its last + A double-click on a row shows the recording where it sits on disk, a right-click offers to + mute or solo it beside the file items, and the button beside it asks to take the recording + out of the reconstruction for good. The list holds on to its last row, so one recording always stands. """ @@ -77,8 +79,13 @@ def __init__( status_bar=status_bar, offer=RECORDED_ASSIGNMENT, ) + self._menu = StemsMenu( + stems_list=self._stems_list, + language_manager=language_manager, + ) self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None + self.on_stem_solo_requested: Optional[Callable[[int], None]] = None self.on_stem_remove_requested: Optional[Callable[[int], None]] = None super().__init__(tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS) @@ -96,7 +103,7 @@ def create_panel(self, parent: str) -> None: with self._collapsible_card( parent, self._lbl_stems, - glyph=self._glyphs.headers.source, + glyph=self._glyphs.headers.stems, width=0, no_scrollbar=True, ): @@ -118,6 +125,11 @@ def create_panel(self, parent: str) -> None: self._stems_list.on_channels_changed = self._on_channels_changed self._stems_list.on_row_revealed = self._on_row_revealed self._stems_list.on_remove_requested = self._on_remove_requested + self._stems_list.on_menu_requested = self._menu.show + self._menu.on_channels_changed = lambda stem_id, channels: self.call( + self.on_stem_channels_changed, stem_id, channels + ) + self._menu.on_solo_requested = lambda stem_id: self.call(self.on_stem_solo_requested, stem_id) def update_view(self, view_model: ReconstructionStemsViewModel) -> None: self._view_model = view_model diff --git a/src/sampletones_application/ui/panels/reconstruction/stems_menu.py b/src/sampletones_application/ui/panels/reconstruction/stems_menu.py new file mode 100644 index 000000000..6222b20a9 --- /dev/null +++ b/src/sampletones_application/ui/panels/reconstruction/stems_menu.py @@ -0,0 +1,82 @@ +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import ( + add_path_menu_items, + context_menu, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.utils.callbacks import CallbackMixin + + +class StemsMenu(CallbackMixin): + """What a right-click on a recording of the open reconstruction offers. + + The recordings are heard the way the boxes on their rows leave them, so the menu names the + same choices in one place: switching the recording off or back on, and hearing it alone. A + recording standing on disk adds the file items every file listing offers. + """ + + def __init__( + self, + *, + stems_list: GUIStemsList, + language_manager: LanguageManager, + ) -> None: + self._stems_list = stems_list + self._language_manager = language_manager + self._lbl_mute = language_manager["reconstructions.reconstruction.label.stem_mute"] + self._lbl_unmute = language_manager["reconstructions.reconstruction.label.stem_unmute"] + self._lbl_solo = language_manager["reconstructions.reconstruction.label.stem_solo"] + self._lbl_unsolo = language_manager["reconstructions.reconstruction.label.stem_unsolo"] + + self.on_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None + self.on_solo_requested: Optional[Callable[[int], None]] = None + + def show(self, key: str) -> None: + """Offer what the row a gesture landed on can do.""" + row = self._stems_list.row(key) + if row is None: + return + + stem_id = int(key) + with context_menu(): + self._header(row.name) + self._create_mute(row, stem_id) + self._create_solo(row, stem_id) + if row.path is not None: + add_path_menu_items(self._language_manager, row.path) + + def _create_mute(self, row: StemRowViewModel, stem_id: int) -> None: + """The item switching the recording off, or back on across every channel it offers.""" + heard = row.takes_part + dpg.add_menu_item( + label=self._lbl_mute if heard else self._lbl_unmute, + enabled=row.offers_channels, + callback=lambda: self.call( + self.on_channels_changed, + stem_id, + frozenset() if heard else row.offered_channels, + ), + ) + + def _create_solo(self, row: StemRowViewModel, stem_id: int) -> None: + """The item hearing the recording alone, or returning to the mix it replaced.""" + dpg.add_menu_item( + label=self._lbl_unsolo if self._stems_list.soloed(row.key) else self._lbl_solo, + enabled=row.offers_channels and self._stems_list.holds_several_rows, + callback=lambda: self.call(self.on_solo_requested, stem_id), + ) + + @staticmethod + def _header(name: str) -> None: + """What the menu names above its items: the recording the gesture landed on.""" + header = dpg.add_text(name) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 9bfe14e39..306509b00 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,9 +1,4 @@ -from typing import AbstractSet - from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.behavior.scheduling.scheduling import ( - SchedulingBehavior, -) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -12,19 +7,17 @@ TAG_SEQUENCER_BROWSER_TREE, TAG_SEQUENCER_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) -from sampletones_core.structures.tree import FileSystemNode, Tree +from sampletones_core.structures.tree import FileSystemNode class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): """The Sequencer tab's browser, whose reconstructions become the song's samples.""" + _language_manager: LanguageManager _tags: FileBrowserTags = FileBrowserTags( panel=TAG_SEQUENCER_BROWSER_PANEL, tree=TAG_SEQUENCER_BROWSER_TREE, @@ -34,33 +27,6 @@ class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, ) - def __init__( - self, - tree: Tree, - tree_logic: TreeLogicProtocol, - *, - scheduling: SchedulingBehavior, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - colors: TreeColors, - initial_collapsed: bool, - initial_favorites_only: bool, - initial_expanded_rows: AbstractSet[str], - ) -> None: - self._language_manager = language_manager - - super().__init__( - tree=tree, - tree_logic=tree_logic, - scheduling=scheduling, - language_manager=language_manager, - status_bar=status_bar, - colors=colors, - initial_collapsed=initial_collapsed, - initial_favorites_only=initial_favorites_only, - initial_expanded_rows=initial_expanded_rows, - ) - @property def refresh_button_label(self) -> str: return self._language_manager["sequencer.browser.label.refresh_button"] diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 6d7acceeb..53ccbe23a 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -1,5 +1,6 @@ from abc import abstractmethod -from typing import AbstractSet, Any, Optional, Tuple +from pathlib import Path +from typing import AbstractSet, Any, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -7,6 +8,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, @@ -18,7 +20,9 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.utils.gui.tooltip import DetailSwatch from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, NodeType, Tree, @@ -27,7 +31,9 @@ traverse, ) from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.types.callback import PathCallback, VoidCallback + +SEVERAL_RECORDINGS: Final[int] = 2 class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): @@ -41,6 +47,10 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild — a refresh, a change of mode — brings the rows back standing as they were left, and so does the next run of the application. + + A row naming a reconstruction asks through ``on_recordings_requested`` what that document is made + of, and lists what comes back in the hover details, each recording marked in the color its place + on the record gives it everywhere else. """ _MONOSPACE_CONFIG_NODES: bool = True @@ -56,12 +66,17 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + stem_colors: StemColors, initial_collapsed: bool, initial_favorites_only: bool, initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager + self._stem_colors = stem_colors + self._detail_document: Optional[Path] = None + self._detail_recordings: Tuple[str, ...] = () self.on_refresh_tree: Optional[VoidCallback] = None + self.on_recordings_requested: Optional[PathCallback] = None super().__init__( tree=tree, @@ -111,6 +126,84 @@ def _setup_handlers(self) -> None: super()._setup_handlers() + def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: + """What a row states about the configuration behind it. + + A reconstruction file reads its fields from the configuration directory holding it, since a + configuration states them in a directory name. The same file listed under the audio it + mirrors carries those fields on its own row, so both branches say the same thing about it. + """ + match node: + case ConfigNode(): + return super()._node_detail_items(node) + case FileSystemNode(node_type=NodeType.FILE): + return self._enclosing_configuration_items(node) + + return super()._node_detail_items(node) + + def _enclosing_configuration_items(self, node: FileSystemNode) -> List[Tuple[str, str]]: + """The fields of the nearest configuration directory above ``node``, where one stands there.""" + configuration = next( + (ancestor.config for ancestor in reversed(node.ancestors) if isinstance(ancestor, ConfigNode)), + None, + ) + if configuration is None: + return [] + + return self._reconstruction_detail_items(configuration) + + def _node_detail_recordings(self, node: TreeNode) -> Tuple[DetailSwatch, ...]: + """The recordings the hovered document is made of, asked for as the pointer reaches its row. + + The document holds the answer, so the row asks for it and lists what has come back. A + document standing for one recording says what the row already says, which leaves the list + to the documents holding several. + """ + path = self._document_path(node) + if path is None: + self._forget_hovered_document() + return () + + if path != self._detail_document: + self._forget_hovered_document() + self._detail_document = path + self.call(self.on_recordings_requested, path) + + return self._recording_swatches(self._detail_recordings) + + def update_recordings(self, path: Path, names: Tuple[str, ...]) -> None: + """Takes what a document names and redraws the details, where they stand on that document.""" + if path != self._detail_document: + return + + self._detail_recordings = names + self.refresh_detail_tooltip() + + def _forget_hovered_document(self) -> None: + self._detail_document = None + self._detail_recordings = () + + def _recording_swatches(self, names: Tuple[str, ...]) -> Tuple[DetailSwatch, ...]: + """One mark per recording, colored by the place it holds on the record.""" + if len(names) < SEVERAL_RECORDINGS: + return () + + return tuple( + DetailSwatch( + name=name, + color=self._stem_colors.for_position(position), + ) + for position, name in enumerate(names) + ) + + @staticmethod + def _document_path(node: TreeNode) -> Optional[Path]: + """Where the reconstruction a row names lives, for the rows that name one.""" + if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE: + return node.filepath + + return None + def _has_relevant_content(self, node: TreeNode) -> bool: if node.node_type == NodeType.FILE: return True diff --git a/src/sampletones_application/utils/gui/dialog_navigation/ring.py b/src/sampletones_application/utils/gui/dialog_navigation/ring.py index afa3861be..c466d41b5 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/ring.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/ring.py @@ -2,7 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.tags.general import TAG_GLOBAL_THEME_FOCUSED_BUTTON from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dialog_navigation.stop import FocusStop @@ -14,8 +13,10 @@ class FocusRing: user is actively editing, which keeps the cycle correct whether or not a focused button reports its focus, and lets the field being typed into keep Enter for itself. - As focus moves onto a button the ring paints it with the focus outline theme and rebinds the - button's own theme once focus leaves, so the button the user is on carries a visible accent + As focus moves onto a button the ring paints it with the stop's own focused theme and rebinds + it to the stop's base theme once focus leaves, so the button the user is on carries a visible + accent border. A bound theme replaces a button's colors outright, so a button styled beyond + the default names both themes, and the focused one carries its own colors forward under the border. Fields show their own edit caret, so the ring leaves their appearance to DearPyGui. """ @@ -58,10 +59,10 @@ def _focus(self, index: int) -> None: def _apply_focus_outline(self, index: int) -> None: stop = self._stops[index] - if stop.base_theme_tag is None: + if stop.base_theme_tag is None or stop.focused_theme_tag is None: return - ThemeRegistry.get(TAG_GLOBAL_THEME_FOCUSED_BUTTON).bind_to_item(stop.focus_tag) + ThemeRegistry.get(stop.focused_theme_tag).bind_to_item(stop.focus_tag) def _restore_focus_outline(self, index: int) -> None: stop = self._stops[index] diff --git a/src/sampletones_application/utils/gui/dialog_navigation/stop.py b/src/sampletones_application/utils/gui/dialog_navigation/stop.py index 53eb51ec5..011f8a073 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/stop.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/stop.py @@ -4,7 +4,11 @@ from typing import Optional from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_BUTTON, TAG_GLOBAL_THEME_DEFAULT +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FOCUSED_BUTTON, +) from sampletones_shared.types.callback import VoidCallback @@ -17,14 +21,17 @@ class FocusStop: combos leave ``activate`` empty so Enter stays with the control (Enter in the multiline comment inserts a line break, and a text-input dialog keeps its own submit-on-Enter). ``base_theme_tag`` names the theme a button wears while unfocused; the ring rebinds it once - the focus outline moves on. Fields leave it empty, marking them as stops the ring styles by - their own edit caret. + the focus outline moves on. ``focused_theme_tag`` names the theme it wears while focused, + which carries the base theme's own colors forward under the accent border, since a bound + theme replaces a button's colors outright rather than layering over another bind. Fields + leave both empty, marking them as stops the ring styles by their own edit caret. """ focus_tag: str enabled_tag: str activate: Optional[VoidCallback] base_theme_tag: Optional[str] = None + focused_theme_tag: Optional[str] = None @classmethod def button( @@ -33,17 +40,22 @@ def button( activate: VoidCallback, *, base_theme_tag: str = TAG_GLOBAL_THEME_DEFAULT, + focused_theme_tag: str = TAG_GLOBAL_THEME_FOCUSED_BUTTON, ) -> FocusStop: """Builds a stop for a :class:`GUIButton`, focusing its inner button item. ``base_theme_tag`` is the button's own theme, which the ring rebinds when the focus - outline leaves the button. + outline leaves the button. A button carrying a theme of its own beyond the default, + such as a danger button, passes the matching ``focused_theme_tag`` too, which is that + same theme with the accent border added, so the button keeps its own color while it + holds focus rather than reading as an unstyled default button. """ return cls( focus_tag=compose_tag(tag, SUF_BUTTON), enabled_tag=tag, activate=activate, base_theme_tag=base_theme_tag, + focused_theme_tag=focused_theme_tag, ) @classmethod diff --git a/src/sampletones_application/utils/gui/tooltip.py b/src/sampletones_application/utils/gui/tooltip.py index ddd051393..0bbb54886 100644 --- a/src/sampletones_application/utils/gui/tooltip.py +++ b/src/sampletones_application/utils/gui/tooltip.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Tuple +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -9,10 +10,20 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.application import Sender from sampletones_shared.types.data import SerializedData +@dataclass(frozen=True) +class DetailSwatch: + """One thing a hover lists, named and marked in the color that thing is drawn in elsewhere.""" + + name: str + color: BaseColor + + def show_tooltip( parent: Sender, message: str, @@ -79,13 +90,36 @@ def create_detail_tooltip(parent: str, *, tag: str) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tag) -def populate_detail_tooltip(tag: str, items: List[Tuple[str, str]]) -> None: - """Replaces the tooltip's content with ``label``/``value`` pairs in two aligned columns. +def populate_detail_tooltip( + tag: str, + items: List[Tuple[str, str]], + *, + swatch_glyph: str, + swatch_label: str, + swatches: Sequence[DetailSwatch], +) -> None: + """Replaces the tooltip's content with ``label``/``value`` pairs and the list that follows them. ``mvTable_SizingFixedFit`` sizes each column to its own content, so the label column tracks its own text width independently of the value column. A compact theme tightens the row padding. + The list below the pairs reads under ``swatch_label``, one row per thing, each marked with + ``swatch_glyph`` in that thing's own color. A tooltip given nothing to list shows the pairs + alone. """ dpg.delete_item(tag, children_only=True) + if items: + _add_detail_table(tag, items) + + if not swatches: + return + + if items: + dpg.add_separator(parent=tag) + + _add_swatch_list(tag, swatch_glyph, swatch_label, swatches) + + +def _add_detail_table(tag: str, items: List[Tuple[str, str]]) -> None: with dpg.table( parent=tag, header_row=False, @@ -104,3 +138,21 @@ def populate_detail_tooltip(tag: str, items: List[Tuple[str, str]]) -> None: FontRegistry.bind_to_item(label_text, Font.REGULAR_SMALL) value_text = dpg.add_text(value) FontRegistry.bind_to_item(value_text, Font.MONO_SMALL) + + +def _add_swatch_list( + tag: str, + glyph: str, + label: str, + swatches: Sequence[DetailSwatch], +) -> None: + """Lists each thing under its heading, the mark ahead of the name reading in the thing's color.""" + heading = dpg.add_text(label, parent=tag) + FontRegistry.bind_to_item(heading, Font.REGULAR_SMALL) + for swatch in swatches: + with dpg.group(horizontal=True, parent=tag): + mark = dpg.add_text(glyph) + dpg_set_palette_color(mark, swatch.color) + FontRegistry.bind_to_item(mark, Font.ICON) + name = dpg.add_text(swatch.name) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 1279d3496..880906c1b 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -20,7 +20,8 @@ class ReconstructionViewModel(BaseModel, frozen=True): switched on, so a channel switched off by hand stays off across an edit. :attr:`nes_frequency` is the engine rate the open reconstruction runs at, and ``None`` - while the tab holds no document. + while the tab holds no document. A document living on disk takes a new rate from the tab, + while one detached from its file is a project sample and follows the project's rate. """ reconstruction_loaded: bool @@ -36,6 +37,18 @@ def audio_source_enabled(self) -> bool: until then playback stays on the reconstruction.""" return self.original_audio.state in PLAYABLE_PATH_STATES + @property + def nes_frequency_editable(self) -> bool: + """The rate can be changed while a loaded document still has a file it is saved to.""" + return ( + self.reconstruction_loaded and self.reconstruction_file.state is not ReconstructionPathState.NOT_APPLICABLE + ) + + @property + def show_nes_frequency_hint(self) -> bool: + """The hint explains the locked rate, so it appears exactly when a loaded document follows the project's.""" + return self.reconstruction_loaded and not self.nes_frequency_editable + @property def locate_audio_enabled(self) -> bool: """Locating needs a recorded path to point the file explorer at; the file diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index ef79bae77..75c3639a6 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -6,7 +6,7 @@ from sampletones_application.constants.sources import SourceKind from sampletones_application.view_model.shared.agreement import Agreement -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.constants.enums import ChannelName class StemRowViewModel(BaseModel, frozen=True): @@ -27,9 +27,6 @@ class StemRowViewModel(BaseModel, frozen=True): reader reaches by opening it. They stand where the folder stands, so a recording answers for itself while the folder answers for them all. - ``bends`` names the channels whose notes the recording carries to the pitch it sounds, which a - list recording what a finished conversion took draws beside the channel itself. - ``name`` is what the row reads as and ``path`` where its source lives, which a row standing for a recording a document was detached from carries no more. Gathering a file names the row after it; a recorded assignment names it after the recording the document remembers. @@ -46,7 +43,6 @@ class StemRowViewModel(BaseModel, frozen=True): held: Tuple["StemRowViewModel", ...] channels: FrozenSet[ChannelName] partial_channels: FrozenSet[ChannelName] - bends: FrozenSet[ChannelName] offered_channels: FrozenSet[ChannelName] available: bool level: int @@ -69,15 +65,6 @@ def recordings(self) -> Tuple["StemRowViewModel", ...]: """ return self.held or (self,) - @property - def bendable_channels(self) -> FrozenSet[ChannelName]: - """The channels the row draws a bend box for: the offered ones that load a divider. - - A bend moves a note by a fraction of the divider its channel loads, so the channels - holding one are where the choice reaches something. - """ - return self.offered_channels & TONE_CHANNELS - @property def stands_for_a_folder(self) -> bool: """The row is a folder, standing for every recording gathered below it.""" @@ -193,6 +180,21 @@ def row(self, key: str) -> Optional[StemRowViewModel]: """The row a gesture named, where the view still holds one.""" return self._by_key.get(key) + def soloed(self, key: str) -> bool: + """Whether the row is the one recording heard, on every channel it offers. + + A list holding a single recording has no other to silence, so none of its rows stands + soloed. + """ + row = self.row(key) + if row is None or self.row_count < 2 or not row.offers_channels: + return False + + if row.channels != row.offered_channels: + return False + + return not any(other.takes_part for other in self.rows if other.key != key) + def rows_on(self, level_index: int) -> Tuple[StemRowViewModel, ...]: """The rows one band holds, in the order they stand.""" return tuple(row for row in self.rows if row.level == level_index) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 4766db971..3ae6f52b9 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -81,6 +81,9 @@ global.dialog.message.bitphase_project_exported_successfully: "Bitphase project global.dialog.message.bitphase_project_export_failed: "Failed to export Bitphase project." global.dialog.message.nsf_project_exported_successfully: "NSF program exported successfully." global.dialog.message.nsf_project_export_failed: "Failed to export NSF program." +global.dialog.message.export_skipped_rows: "Rows whose voice has no instrument on that row's channel were written as note cuts instead:" +global.dialog.template.export_skipped_row: "Frame {frame}, {channel}, row {row}: {voice}" +global.dialog.template.export_skipped_rows_more: "and {count} more" global.dialog.message.new_unsaved_project: "The current project has unsaved changes. Do you want to save it before starting a new one?" global.dialog.message.open_unsaved_project: "The current project has unsaved changes. Do you want to save it before opening another?" global.dialog.message.close_unsaved_project: "The current project has unsaved changes. Do you want to save it before closing?" @@ -98,7 +101,7 @@ global.dialog.message.configuration_recovery_path_prefix: "You can edit the conf global.dialog.message.audio_playback_error: "Audio playback error" global.dialog.message.no_project_open: "No project is open. Create or open a project before adding a reconstruction." global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" -global.dialog.message.change_nes_frequency: "Changing the NES frequency leaves the loaded reconstructions out of sync with the project rate for editing in the Reconstructions tab. Song playback already follows the new rate. Retune all samples to match?" +global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?" global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?" global.dialog.message.operation_in_progress: "An operation is in progress. Please wait until the running operation finishes." global.dialog.message.about_description: "An application for approximating audio samples with the NES 2A03 oscillators and exporting them as FamiTracker instruments/modules." @@ -177,6 +180,7 @@ global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" global.context.label.detail_configuration: "Configuration" global.context.label.detail_reconstructions: "Reconstructions" +global.context.label.detail_stems: "Stems" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" global.context.template.size_bytes: "{bytes} B" @@ -285,7 +289,7 @@ global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the rec global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level." global.stems.message.inert_tooltip: "Tick a channel to use this recording." global.stems.message.missing_tooltip: "This recording is missing from disk." -global.stems.message.unoffered_tooltip: "This recording holds no frames." +global.stems.message.unoffered_tooltip: "This recording has no frames." global.stems.message.edits_tooltip: "The frames you wrote by hand. Removing a recording leaves them alone." global.stems.message.status_row_drag: "Drag {name} onto another row or a gap to move it, or right-click for more actions." global.stems.message.status_row_play: "Double-click {name} to hear it." @@ -317,8 +321,8 @@ global.graph.label.spectrum_frequency_axis: "Frequency" global.graph.label.spectrum_name: "Spectrum" global.graph.label.bar_display: "Bar plot display" 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. 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. 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..." # ============================================================================= @@ -363,7 +367,7 @@ main.source.label.drive: "drive" main.source.label.drive_mixed: "mixed" main.source.label.channel_cap: "Channels at once" main.source.tooltip.tooltip_new_recordings: "Every recording you add starts with these settings.\nPick a row in the Converter to change one recording or folder." -main.source.tooltip.tooltip_drive: "How hard this channel is driven while a recording is converted.\nAt 1.00 the level is calibrated; higher values reach for louder instructions, adding a distortion-like effect." +main.source.tooltip.tooltip_drive: "How hard this channel is driven while a recording is converted.\nAt 1.00 the level is calibrated. Higher values reach for louder instructions, adding a distortion-like effect." main.source.tooltip.tooltip_channel_cap: "How many of its channels a recording may sound in one frame,\nup to the channels it uses." main.source.message.status_channel_cap: "Sound at most {count} channels in one frame." @@ -424,7 +428,7 @@ main.converter.label.discard_stems_button: "Replace it" main.converter.label.keep_stems_button: "Keep the list" main.converter.label.add_stems_button: "Add" main.converter.label.overwrite_target_button: "Convert anyway" -main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round; strict fills a level before the next one picks." +main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round. Strict fills a level before the next one picks." main.converter.message.stems_empty_hint: "Double-click a recording in the browser to add it. Add a folder to convert everything inside it." main.converter.message.discard_stems_prompt: "This replaces the recordings already in the converter. Continue?" main.converter.message.overwrite_target_prompt: "A reconstruction with this name already exists. Converting replaces it." @@ -491,12 +495,13 @@ reconstructions.reconstruction.label.audio_source_label: "Source" reconstructions.reconstruction.label.autoscale_checkbox: "Autoscale" reconstructions.reconstruction.label.reconstruction_file_label: "Reconstruction file:" reconstructions.reconstruction.label.nes_frequency_label: "NES frequency:" +reconstructions.reconstruction.tooltip.nes_frequency_locked: "This reconstruction is a sample of the project, which sets its NES frequency." reconstructions.reconstruction.label.path_not_found: "not found" reconstructions.reconstruction.label.path_not_applicable: "N/A" reconstructions.reconstruction.label.original_audio_radio: "Original audio" reconstructions.reconstruction.label.reconstruction_radio: "Reconstruction" reconstructions.reconstruction.label.waveform_label: "Waveform" -reconstructions.reconstruction.tooltip.autoscale_tooltip: "Include reconstruction's amplification coefficient and auto-scale waveforms accordingly." +reconstructions.reconstruction.tooltip.autoscale_tooltip: "Scale the waveforms by the reconstruction's own amplification coefficient, so they can be compared directly." 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." @@ -504,6 +509,10 @@ reconstructions.reconstruction.label.stems: "Stems" reconstructions.reconstruction.label.stems_empty: "This reconstruction has no sources" reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin" reconstructions.reconstruction.label.stems_mode_strict: "Strict" +reconstructions.reconstruction.label.stem_mute: "Mute" +reconstructions.reconstruction.label.stem_unmute: "Unmute" +reconstructions.reconstruction.label.stem_solo: "Solo" +reconstructions.reconstruction.label.stem_unsolo: "Unsolo" reconstructions.reconstruction.template.stems_setup: "Mode: {mode}" reconstructions.reconstruction.label.collapse_levels: "Collapse levels" reconstructions.reconstruction.message.collapse_levels_tooltip: "Draw every recording in one table." @@ -528,13 +537,13 @@ reconstructions.instruments.label.audition: "Audition: " reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)." reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." reconstructions.instruments.message.status_audition: "Click to choose the generator the note keys sound this instrument on." -reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." +reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Alt + scroll to pan. Right-click for more options." reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." reconstructions.instruments.message.status_channel_toggle: "Click to turn {on_or_off} {channel_name}." reconstructions.instruments.message.status_channel_not_available: "{channel_name} is not available." -reconstructions.instruments.message.status_export_instrument: "Writes the {channel} channel's instrument, for the tracker the chosen extension names." +reconstructions.instruments.message.status_export_instrument: "Writes the {channel} channel's instrument, in the format the chosen file extension selects." reconstructions.instruments.message.export_instrument_success: "Instrument saved successfully." reconstructions.instruments.message.export_instruments_success: "Reconstruction instruments saved successfully." reconstructions.instruments.message.export_instrument_truncated: "The envelope was truncated from {source_frames} to {frames} frames." @@ -657,10 +666,10 @@ sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" sequencer.voices.label.context_instrument_from: "New instrument from" sequencer.voices.label.context_export_instrument: "Export instrument..." -sequencer.voices.label.omission_cumulative_bend: "a bend the tracker piles up, since its arpeggio stops pinning the note" +sequencer.voices.label.omission_cumulative_bend: "a bend outrunning its arpeggio" sequencer.voices.label.omission_release_point: "a release point" sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode" -sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel" +sequencer.voices.tooltip.new_instrument: "Add an instrument playable on any channel" sequencer.voices.tooltip.kind_sample: "Sample" sequencer.voices.tooltip.kind_instrument: "Instrument" sequencer.voices.message.instrument_not_found: "The instrument file could not be found." @@ -671,7 +680,7 @@ sequencer.voices.template.instrument_name: "Instrument {position}" sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes} as FamiTracker instruments." sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes} as a FamiTracker instrument." sequencer.voices.template.status_channel_separator: ", " -sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also holds, on the instrument's own terms:" +sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also states:" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index d9a327b06..359ac5778 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,6 +1,5 @@ master_column_width: 44 -channel_column_width: 78 -channel_solo_width: 62 +channel_column_width: 62 channel_box_width: 29 remove_button_width: 30 swatch_size: 14 diff --git a/src/sampletones_config/layout/glyphs.yaml b/src/sampletones_config/layout/glyphs.yaml index d1f26de16..74acd8dc9 100644 --- a/src/sampletones_config/layout/glyphs.yaml +++ b/src/sampletones_config/layout/glyphs.yaml @@ -1,5 +1,6 @@ common: tick: "▍" + swatch: "■" favorite: "★" expanded: "▾" collapsed: "▸" @@ -22,6 +23,7 @@ headers: details: "✎" parameters: "▦" source: "♪" + stems: "☰" instruments: "♬" voices: "♫" tracker: "▦" diff --git a/src/sampletones_config/layout/graphs/waveform.yaml b/src/sampletones_config/layout/graphs/waveform.yaml index dbea4fd5b..155e3c33b 100644 --- a/src/sampletones_config/layout/graphs/waveform.yaml +++ b/src/sampletones_config/layout/graphs/waveform.yaml @@ -1,4 +1,5 @@ reconstruction_dim_opacity: 0.5 zoom_factor: 0.2 +pan_factor: 0.1 max_display_points: 4096 click_travel: 4 diff --git a/src/sampletones_config/theme/button/danger_focused.yaml b/src/sampletones_config/theme/button/danger_focused.yaml new file mode 100644 index 000000000..c2c6f8853 --- /dev/null +++ b/src/sampletones_config/theme/button/danger_focused.yaml @@ -0,0 +1,13 @@ +name: danger_button_focused +tag: global.theme.danger_button_focused +extends: danger_button + +components: + - item_type: Button + entries: + - type: color + key: Border + value: .accent + - type: style + key: FrameBorderSize + x: 1 diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index b489e0626..b31e259fc 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -74,8 +74,17 @@ def deserialize( validation: Optional[Callback] = None, fast: bool = True, ) -> Self: - data = msgpack.unpackb(buffer, raw=False) - return cls.deserialize_inner(data, validation, fast=fast) + return cls.deserialize_inner(cls.unpack(buffer), validation, fast=fast) + + @staticmethod + def unpack(buffer: bytes) -> SerializedData: + """The fields a serialized payload states, before any of them is read into a model. + + A reader after one field of a large document unpacks the payload and reads that field + alone, which costs a fraction of building every model the document describes. + """ + unpacked: SerializedData = msgpack.unpackb(buffer, raw=False) + return unpacked def save(self, path: Pathlike) -> None: save_binary(path, compress_document(self.serialize())) diff --git a/src/sampletones_core/exporters/skipped.py b/src/sampletones_core/exporters/skipped.py new file mode 100644 index 000000000..774f669fb --- /dev/null +++ b/src/sampletones_core/exporters/skipped.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Container, Final, Generic, List, Tuple, TypeVar + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn + +DocumentT = TypeVar("DocumentT") + + +@dataclass(frozen=True) +class SkippedRow: + """A note-on the target format has no instrument for, which the export wrote as a note cut. + + A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays + nothing in the song either. The row is named by where the reader finds it in the tracker. + + Attributes: + voice_id: The voice the row names. + channel: The channel the row stands on. + order_position: The frame of the order the row stands in. + row_index: The row's position within its pattern. + """ + + voice_id: str + channel: ChannelName + order_position: int + row_index: int + + +NO_SKIPPED_ROWS: Final[Tuple[SkippedRow, ...]] = () + + +@dataclass(frozen=True) +class BuiltDocument(Generic[DocumentT]): + """A format's document with the rows its build left silent. + + Attributes: + document: What the format serializes. + skipped_rows: The rows written as a note cut for lack of an instrument. + """ + + document: DocumentT + skipped_rows: Tuple[SkippedRow, ...] + + +def find_skipped_rows( + song: Song, + instruments: Container[Tuple[str, ChannelName]], +) -> Tuple[SkippedRow, ...]: + """Finds every row of the song naming a voice on a channel it has no instrument for. + + Rows are listed in the order the song plays them, frame by frame and channel by channel, so a + pattern the order plays twice reports each of its rows once for every frame it stands in. + + Args: + song: The arrangement being exported. + instruments: The ``(voice id, channel)`` pairs the export holds an instrument for. + + Returns: + Tuple[SkippedRow, ...]: The rows that name a voice without an instrument. + """ + ordered = {channel: song.ordered_patterns(channel) for channel in ChannelName.items()} + skipped: List[SkippedRow] = [] + for position in range(song.order_length()): + for channel in ChannelName.items(): + pattern = ordered[channel][position] + if pattern is None: + continue + + for row_index, row in enumerate(pattern.rows[: song.rows_per_pattern]): + match row.command: + case NoteOn() as reference if (reference.voice_id, channel) not in instruments: + skipped.append( + SkippedRow( + voice_id=reference.voice_id, + channel=channel, + order_position=position, + row_index=row_index, + ) + ) + + return tuple(skipped) diff --git a/src/sampletones_core/exports/artifact.py b/src/sampletones_core/exports/artifact.py index 9d99a04bb..ccb12fd15 100644 --- a/src/sampletones_core/exports/artifact.py +++ b/src/sampletones_core/exports/artifact.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Optional, Tuple +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation @@ -13,7 +14,10 @@ class ExportArtifact: paths: Every file the run wrote, in write order. truncation: What the target format's item limit left out, and ``None`` when every instrument carries its whole envelope. + skipped_rows: The rows of a song written as a note cut, because the voice they name + has no instrument on their channel. """ paths: Tuple[Path, ...] truncation: Optional[EnvelopeTruncation] + skipped_rows: Tuple[SkippedRow, ...] diff --git a/src/sampletones_core/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py index 473f6509e..0928f658e 100644 --- a/src/sampletones_core/exports/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Final, FrozenSet, List +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.progress import ExportReporter, announce @@ -13,8 +14,8 @@ from sampletones_core.exports.stage import ExportStage from sampletones_core.formats.bitphase.btp import write_btp from sampletones_core.formats.bitphase.builder import ( + build_bitphase, instrument_to_bitphase, - project_to_bitphase, sample_to_bitphase, ) from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset @@ -60,7 +61,7 @@ def write_instrument( write_btp(destination, instrument_to_bitphase(request)) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) def write_sample( self, @@ -72,7 +73,7 @@ def write_sample( write_btp(destination, sample_to_bitphase(request)) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) def write_project( self, @@ -81,10 +82,15 @@ def write_project( report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) - write_btp(destination, project_to_bitphase(request.project)) + built = build_bitphase(request.project) + write_btp(destination, built.document) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + return ExportArtifact( + paths=(destination,), + truncation=WHOLE_ENVELOPE, + skipped_rows=built.skipped_rows, + ) class BitphasePresetBackend: @@ -117,7 +123,7 @@ def write_instrument( write_preset(destination, instrument_to_preset(request)) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) def write_sample( self, @@ -141,7 +147,7 @@ def write_sample( paths.extend(self.write_instrument(filepath, instrument, silent_reporter).paths) announce(report, ExportStage.WRITING, index, written) - return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) def write_project( self, diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 9346e2365..b182bb2a3 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Final, FrozenSet, List, Optional +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat @@ -66,6 +67,7 @@ def write_instrument( return ExportArtifact( paths=(destination,), truncation=features_truncation(request.features), + skipped_rows=NO_SKIPPED_ROWS, ) def write_sample( @@ -96,6 +98,7 @@ def write_sample( return ExportArtifact( paths=tuple(paths), truncation=EnvelopeTruncation.summarize(truncations), + skipped_rows=NO_SKIPPED_ROWS, ) def write_project( @@ -105,7 +108,7 @@ def write_project( report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) - write_ftm(destination, request.project) + skipped_rows = write_ftm(destination, request.project) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=None) + return ExportArtifact(paths=(destination,), truncation=None, skipped_rows=skipped_rows) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index dc5a5fa3c..ffae6e87d 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -4,6 +4,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME +from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.formats.bitphase.envelopes import ( @@ -352,14 +353,6 @@ def _build_voice_table( return voices, by_reference -def _resolve_slice_voice(reference: NoteOn, channel: ChannelName, voices: SliceVoiceTable) -> SliceVoice: - voice = voices.get((reference.voice_id, channel)) - if voice is None: - raise ValueError(f"Row references voice '{reference.voice_id}' on channel '{channel}' with no instrument") - - return voice - - def _volume_column(volume: Optional[int]) -> int: """Writes a tracker line's volume column as the value Bitphase reads it as. @@ -384,26 +377,30 @@ def _row_cell( ) -> BitphaseRow: """Converts one tracker line to the Bitphase row that plays it. - Raises: - ValueError: If the line references a voice that has no instrument on this channel. + A note-on naming a voice with no instrument on this channel plays nothing in the song, so it + becomes the note cut that silences the channel. """ volume = _volume_column(row.volume) cell = BitphaseRow(volume=volume) + note_cut = BitphaseRow( + note=NoteCell(name=int(NoteName.OFF)), + volume=volume, + ) match row.command: case NoteOff(): - cell = BitphaseRow( - note=NoteCell(name=int(NoteName.OFF)), - volume=volume, - ) + cell = note_cut case NoteOn() as reference: - voice = _resolve_slice_voice(reference, channel_generator, voices) - pitch = voice.initial_pitch + (row.transpose or 0) - cell = _trigger_row( - voice, - _note_cell(channel_generator, pitch), - volume, - ) + voice = voices.get((reference.voice_id, channel_generator)) + if voice is None: + cell = note_cut + else: + pitch = voice.initial_pitch + (row.transpose or 0) + cell = _trigger_row( + voice, + _note_cell(channel_generator, pitch), + volume, + ) case None: pass @@ -548,20 +545,26 @@ def _project_patterns( def project_to_bitphase(project: Project) -> BitphaseProject: - """Maps a project's samples, song and tempo onto the Bitphase document IR. + """Maps a project's samples, song and tempo onto the Bitphase document IR.""" + return build_bitphase(project).document + + +def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: + """Maps a project onto the Bitphase document IR and lists the rows it had to leave silent. The song carries the project's tempo as a groove, which is the initial speed on its own - where every row lasts alike and a table the patterns trigger where the rows differ. + where every row lasts alike and a table the patterns trigger where the rows differ. A row + naming a voice on a channel the voice has no instrument for plays nothing in the song, so the + document holds a note cut there and the row is listed beside it. Args: project: The project to write. Returns: - BitphaseProject: The document to serialize. + BuiltDocument[BitphaseProject]: The document to serialize and the rows left silent. Raises: - ValueError: If the project holds more than Bitphase has room for, or a row - references a sample slice that has no instrument. + ValueError: If the project holds more than Bitphase has room for. """ groove = _project_groove(project) voices, by_reference = _build_voice_table( @@ -580,18 +583,21 @@ def project_to_bitphase(project: Project) -> BitphaseProject: settings = project.settings info = project.info - return BitphaseProject( - name=info.title, - author=info.author, - songs=( - _build_song( - patterns, - speed=groove.ticks[GROOVE_TRIGGER_ROW], - nes_frequency=settings.nes_frequency, - pattern_length=project.song.rows_per_pattern, + return BuiltDocument( + document=BitphaseProject( + name=info.title, + author=info.author, + songs=( + _build_song( + patterns, + speed=groove.ticks[GROOVE_TRIGGER_ROW], + nes_frequency=settings.nes_frequency, + pattern_length=project.song.rows_per_pattern, + ), ), + pattern_order=tuple(pattern.id for pattern in patterns), + tables=_document_tables(voices, groove_table), + instruments=tuple(voice.instrument for voice in voices), ), - pattern_order=tuple(pattern.id for pattern in patterns), - tables=_document_tables(voices, groove_table), - instruments=tuple(voice.instrument for voice in voices), + skipped_rows=find_skipped_rows(project.song, by_reference), ) diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 9a1396768..f3eab165e 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -2,6 +2,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows from sampletones_core.exporters.slices import ( InstrumentSlot, InstrumentTable, @@ -138,6 +139,11 @@ def _row_cell( channel_generator: ChannelName, slots: InstrumentTable, ) -> Optional[RowCell]: + """Converts one tracker line to the cell that plays it, and ``None`` where the line is empty. + + A note-on naming a voice with no instrument on this channel plays nothing in the song, so it + becomes the note cut that silences the channel. + """ note = EMPTY_NOTE octave = MIN_OCTAVE instrument = EMPTY_INSTRUMENT @@ -149,16 +155,14 @@ def _row_cell( case NoteOn() as reference: slot = slots.get((reference.voice_id, channel_generator)) if slot is None: - raise ValueError( - f"Row references voice '{reference.voice_id}' on channel " - f"'{channel_generator}' with no instrument" + note = int(NoteValue.HALT) + else: + instrument = slot.index + note, octave = _note_and_octave( + row.transpose or 0, + channel_generator, + slot, ) - instrument = slot.index - note, octave = _note_and_octave( - row.transpose or 0, - channel_generator, - slot, - ) case None: pass @@ -252,6 +256,18 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]: def project_to_module(project: Project) -> FamiTrackerModule: """Maps a project's samples and song onto the FamiTracker module IR.""" + return build_module(project).document + + +def build_module(project: Project) -> BuiltDocument[FamiTrackerModule]: + """Maps a project onto the FamiTracker module IR and lists the rows it had to leave silent. + + A row naming a voice on a channel the voice has no instrument for plays nothing in the song, + so the module holds a note cut there and the row is listed beside it. + + Raises: + ValueError: If the project holds more than FamiTracker has room for. + """ instruments, slots = build_instrument_table(project) song = project.song settings = project.settings @@ -294,10 +310,13 @@ def project_to_module(project: Project) -> FamiTrackerModule: effect_columns={channel_id: DEFAULT_EFFECT_COLUMNS for channel_id in ChannelId}, ) - return FamiTrackerModule( - parameters=parameters, - information=information, - instruments=tuple(instruments), - track=track, - comment=info.comment, + return BuiltDocument( + document=FamiTrackerModule( + parameters=parameters, + information=information, + instruments=tuple(instruments), + track=track, + comment=info.comment, + ), + skipped_rows=find_skipped_rows(song, slots), ) diff --git a/src/sampletones_core/formats/famitracker/export.py b/src/sampletones_core/formats/famitracker/export.py index 16a18538a..88fe2e612 100644 --- a/src/sampletones_core/formats/famitracker/export.py +++ b/src/sampletones_core/formats/famitracker/export.py @@ -1,11 +1,20 @@ -from sampletones_core.formats.famitracker.builder import project_to_module +from typing import Tuple + +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.formats.famitracker.builder import build_module from sampletones_core.formats.famitracker.module import module_to_ftm_bytes from sampletones_core.project.project import Project from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import save_binary -def write_ftm(filepath: Pathlike, project: Project) -> None: - """Exports a project to a FamiTracker ``.ftm`` module file.""" - module = project_to_module(project) - save_binary(filepath, module_to_ftm_bytes(module)) +def write_ftm(filepath: Pathlike, project: Project) -> Tuple[SkippedRow, ...]: + """Exports a project to a FamiTracker ``.ftm`` module file. + + Returns: + Tuple[SkippedRow, ...]: The rows written as a note cut because their voice has no + instrument on the channel. + """ + built = build_module(project) + save_binary(filepath, module_to_ftm_bytes(built.document)) + return built.skipped_rows diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index e7f33f932..b6687d4d5 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -1,6 +1,7 @@ from __future__ import annotations import struct +from contextlib import contextmanager from functools import cached_property from pathlib import Path from typing import ( @@ -9,6 +10,7 @@ Final, FrozenSet, Iterable, + Iterator, List, Mapping, Optional, @@ -444,6 +446,23 @@ def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: fast=fast, ) + @classmethod + def read_stems_data(cls, path: Pathlike) -> StemsData: + """The recordings a document names and the setup they were converted under, read on their own. + + A listing that names many reconstructions asks each one what it is made of, which the whole + document costs far more to answer than the one field holding it. The payload travels through + the upgrade and the ``deserialize_inner`` a full load reads it with, so this answer and the + loaded document's describe the same recordings. + + Raises: + InvalidReconstructionValuesError: If the payload states values a stems record rejects. + UnhandledReconstructionError: If reading it fails for any other reason. + """ + with cls._reading(path): + document = cls.unpack(upgrade_binary(ObjectKind.RECONSTRUCTION, decompress_document(load_binary(path)))) + return StemsData.deserialize_inner(document["stems_data"]) + @classmethod def deserialize_data( cls, @@ -452,9 +471,16 @@ def deserialize_data( validation: Optional[Callback] = None, fast: bool = True, ) -> Reconstruction: - try: + with cls._reading(source): binary = upgrade_binary(ObjectKind.RECONSTRUCTION, decompress_document(binary)) return cls.deserialize(binary, validation=validation, fast=fast) + + @staticmethod + @contextmanager + def _reading(source: Pathlike) -> Iterator[None]: + """States what a malformed document raises, so every reader of one answers the same errors.""" + try: + yield except (ValidationError, TypeError, ValueError, struct.error, IndexError) as exception: raise InvalidReconstructionValuesError( f'Failed to deserialize ReconstructionData from "{source}" due to validation error: {exception}', diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index bc153a4de..c5870c145 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, TypeVar from anytree import Node @@ -11,6 +11,8 @@ from .type import NodeType +TreeNodeT = TypeVar("TreeNodeT", bound="TreeNode") + class TreeNode(Node): """A node of one of the application's trees, and the base every node kind derives from. @@ -19,6 +21,11 @@ class TreeNode(Node): Every tree here is built from this class alone, which is what these declarations state: a relative of a node is a node of ours, and the checker holds each reader to the attributes the node kind it reached actually carries. + + ``gathered_plain_name`` says that the node's name has taken in a name somebody chose — a + folder's, an audio file's — which a tree hands a row when it folds a heading into it. The + classes naming a configuration read it through :attr:`states_configuration`, so a reader of + configuration text meets the row as the plain name it has become. """ name: str @@ -37,9 +44,20 @@ def __init__( ) -> None: super().__init__(name, parent=parent) self.node_type = node_type + self.gathered_plain_name = False + + @property + def states_configuration(self) -> bool: + """Whether the node's name is the machine text a reconstruction configuration carries.""" + return False def copy(self, parent: Optional[TreeNode] = None) -> TreeNode: - return TreeNode(self.name, node_type=self.node_type, parent=parent) + return self._carrying(TreeNode(self.name, node_type=self.node_type, parent=parent)) + + def _carrying(self, node: TreeNodeT) -> TreeNodeT: + """The fresh node, given what its name has come to hold beside the fields it was built with.""" + node.gathered_plain_name = self.gathered_plain_name + return node class FileSystemNode(TreeNode): @@ -54,11 +72,13 @@ def __init__( self.filepath = filepath def copy(self, parent: Optional[TreeNode] = None) -> FileSystemNode: - return FileSystemNode( - self.name, - filepath=self.filepath, - node_type=self.node_type, - parent=parent, + return self._carrying( + FileSystemNode( + self.name, + filepath=self.filepath, + node_type=self.node_type, + parent=parent, + ) ) @@ -87,13 +107,19 @@ def __init__( ) self.config = config + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name + def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode: - return ConfigNode( - self.name, - node_type=self.node_type, - filepath=self.filepath, - config=self.config, - parent=parent, + return self._carrying( + ConfigNode( + self.name, + node_type=self.node_type, + filepath=self.filepath, + config=self.config, + parent=parent, + ) ) @@ -106,8 +132,12 @@ class ConfigGroupNode(TreeNode): tooltip, a font — reach it the way it reaches the configuration row below. """ + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name + def copy(self, parent: Optional[TreeNode] = None) -> ConfigGroupNode: - return ConfigGroupNode(self.name, node_type=self.node_type, parent=parent) + return self._carrying(ConfigGroupNode(self.name, node_type=self.node_type, parent=parent)) class LibraryNode(TreeNode): @@ -126,13 +156,19 @@ def __init__( self.library_key = library_key self.outdated = outdated + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name + def copy(self, parent: Optional[TreeNode] = None) -> LibraryNode: - return LibraryNode( - self.name, - node_type=self.node_type, - library_key=self.library_key, - outdated=self.outdated, - parent=parent, + return self._carrying( + LibraryNode( + self.name, + node_type=self.node_type, + library_key=self.library_key, + outdated=self.outdated, + parent=parent, + ) ) @@ -148,9 +184,11 @@ def __init__( self.generator_name = generator_name def copy(self, parent: Optional[TreeNode] = None) -> GeneratorNode: - return GeneratorNode( - self.name, - node_type=self.node_type, - generator_name=self.generator_name, - parent=parent, + return self._carrying( + GeneratorNode( + self.name, + node_type=self.node_type, + generator_name=self.generator_name, + parent=parent, + ) ) diff --git a/src/sampletones_player/export/writer.py b/src/sampletones_player/export/writer.py index a9987ba90..3b7d39290 100644 --- a/src/sampletones_player/export/writer.py +++ b/src/sampletones_player/export/writer.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Final, Self +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.progress import ExportReporter, announce from sampletones_core.exports.request import SampleExport @@ -131,4 +132,4 @@ def _write( announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) write_nsf(destination, song, program.information, self._image) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) diff --git a/src/sampletones_tools/checks/source/bindings/scopes.py b/src/sampletones_tools/checks/source/bindings/scopes.py index 95d3af019..6a85bbff9 100644 --- a/src/sampletones_tools/checks/source/bindings/scopes.py +++ b/src/sampletones_tools/checks/source/bindings/scopes.py @@ -1,6 +1,6 @@ import ast from dataclasses import dataclass -from typing import Dict, List, NamedTuple, Optional +from typing import Dict, Final, List, NamedTuple, Optional from sampletones_tools.checks.source.bindings.containers import ( ItemTypes, @@ -14,9 +14,18 @@ LoopStatement, Statement, TypeStatement, + annotated_statement, read_statement, ) -from sampletones_tools.checks.source.nodes import expression_spelling, is_attribute_spelling, nested_scopes, own_nodes +from sampletones_tools.checks.source.nodes import ( + class_attribute_annotations, + expression_spelling, + is_attribute_spelling, + nested_scopes, + own_nodes, +) + +MODULE_SCOPE: Final[int] = 0 @dataclass(frozen=True) @@ -71,6 +80,19 @@ def read(self, node: ast.AST, parent: Optional[int]) -> None: for nested in nested_scopes(node): self.read(nested, scope) + def read_class_attributes(self, tree: ast.AST) -> None: + """Records what each class body says its instances carry, under the spelling `self` reaches. + + An attribute chain belongs to the module, so an annotation written in a class body states + the type for every method of that class, which is how a class inheriting an attribute + declares what it holds. + + Args: + tree: Parsed module to read. + """ + for spelling, annotation in class_attribute_annotations(tree): + self._collect(MODULE_SCOPE, annotated_statement(spelling, annotation)) + def scopes(self) -> List[Scope]: """The scopes read so far, each holding the types visible inside it.""" self._bind_loops() @@ -158,8 +180,10 @@ def module_scopes( A parameter annotation, an annotated assignment, and an assignment from a direct construction each state a type. An assignment of one spelling to another carries that type along, so an - object taken as a parameter and kept as `self._manager` is known under both spellings. A `for` - or comprehension target takes the item type of the container it walks. + object taken as a parameter and kept as `self._manager` is known under both spellings. An + attribute a class body annotates is known the same way, which is how a class that inherits one + states what it holds. A `for` or comprehension target takes the item type of the container it + walks. Args: tree: Parsed module to read. @@ -173,4 +197,5 @@ def module_scopes( """ reader = _ScopeReader({**imported_item_types, **container_item_types(tree)}) reader.read(tree, None) + reader.read_class_attributes(tree) return reader.scopes() diff --git a/src/sampletones_tools/checks/source/bindings/statements.py b/src/sampletones_tools/checks/source/bindings/statements.py index 7d4d55182..2d68a73d9 100644 --- a/src/sampletones_tools/checks/source/bindings/statements.py +++ b/src/sampletones_tools/checks/source/bindings/statements.py @@ -45,7 +45,7 @@ def read_statement(node: ast.AST) -> Optional[Statement]: """ match node: case ast.arg(arg=name, annotation=ast.expr() as annotation): - return _annotated(name, annotation) + return annotated_statement(name, annotation) case ast.AnnAssign(target=target, annotation=annotation): return _annotated_target(target, annotation) case ast.Assign(targets=[target], value=value): @@ -59,7 +59,16 @@ def read_statement(node: ast.AST) -> Optional[Statement]: return None -def _annotated(spelling: str, annotation: ast.expr) -> Optional[TypeStatement]: +def annotated_statement(spelling: str, annotation: ast.expr) -> Optional[TypeStatement]: + """What an annotation standing on ``spelling`` states about it. + + Args: + spelling: Spelling the annotation stands on, as `expression_spelling` writes it. + annotation: Annotation to read. + + Returns: + Optional[TypeStatement]: The type, or `None` where the annotation names none. + """ type_name = annotation_type_name(annotation) if type_name is None: return None @@ -75,7 +84,7 @@ def _annotated_target(target: ast.expr, annotation: ast.expr) -> Optional[TypeSt if spelling is None: return None - return _annotated(spelling, annotation) + return annotated_statement(spelling, annotation) def _assigned(target: ast.expr, value: ast.expr) -> Optional[Statement]: diff --git a/src/sampletones_tools/checks/source/nodes.py b/src/sampletones_tools/checks/source/nodes.py index 1d024ea2b..6c75dc7a0 100644 --- a/src/sampletones_tools/checks/source/nodes.py +++ b/src/sampletones_tools/checks/source/nodes.py @@ -6,6 +6,7 @@ SCOPE_NODES: Final[Tuple[Type[ast.AST], ...]] = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) ATTRIBUTE_SEPARATOR: Final[str] = "." +INSTANCE_SPELLING: Final[str] = "self" def own_nodes(scope: ast.AST) -> Iterator[ast.AST]: @@ -42,6 +43,29 @@ def nested_scopes(scope: ast.AST) -> Iterator[ast.AST]: yield from nested_scopes(child) +def class_attribute_annotations(tree: ast.AST) -> Iterator[Tuple[str, ast.expr]]: + """Every attribute a class body annotates, spelled the way an instance reaches it. + + A class states the attributes it carries in its own body, which is how a class inheriting one + declares what it holds without assigning it again. Reading those annotations lets a method of + that class be understood through `self`. + + Args: + tree: Parsed module to read. + + Yields: + Tuple[str, ast.expr]: The spelling, such as `self._manager`, and the annotation on it. + """ + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + + for statement in node.body: + match statement: + case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation): + yield f"{INSTANCE_SPELLING}{ATTRIBUTE_SEPARATOR}{name}", annotation + + def slice_elements(subscript: ast.Subscript) -> Tuple[ast.expr, ...]: """The expressions a subscript holds between its brackets, one per comma-separated part.""" if isinstance(subscript.slice, ast.Tuple): diff --git a/tests/suite/silent_rows.py b/tests/suite/silent_rows.py new file mode 100644 index 000000000..a6478c087 --- /dev/null +++ b/tests/suite/silent_rows.py @@ -0,0 +1,31 @@ +from typing import Final, List + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn + +MISSING_VOICE_ID: Final[str] = "a-voice-no-project-holds" +SILENT_ROW: Final[int] = 5 +SILENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def name_a_missing_voice(project: Project) -> SkippedRow: + """Puts a note-on on the first frame naming a voice that has no instrument on its channel. + + Returns: + SkippedRow: The row an export reports for it. + """ + rows: List[Row] = [Row() for _ in range(project.song.rows_per_pattern)] + rows[SILENT_ROW] = Row(command=NoteOn(voice_id=MISSING_VOICE_ID)) + project.song.channels[SILENT_CHANNEL] = Channel(name=SILENT_CHANNEL, patterns={0: Pattern(rows=rows)}) + project.song.order[0][SILENT_CHANNEL] = 0 + return SkippedRow( + voice_id=MISSING_VOICE_ID, + channel=SILENT_CHANNEL, + order_position=0, + row_index=SILENT_ROW, + ) diff --git a/tests/unit/sampletones_application/categories/test_skipped.py b/tests/unit/sampletones_application/categories/test_skipped.py new file mode 100644 index 000000000..aee8cec9d --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_skipped.py @@ -0,0 +1,118 @@ +from typing import Final, List + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.skipped import MAX_REPORTED_ROWS, SkippedRowMessages +from sampletones_application.paths import LANG_EN +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.project.voices.sample import Sample +from sampletones_core.structures import IdentifiedCollection +from tests.suite.sequencer import sample_reconstruction + +ROWS_LISTED: Final[int] = MAX_REPORTED_ROWS + + +def _skipped(voice_id: str, *, order_position: int = 3, row_index: int = 26) -> SkippedRow: + return SkippedRow( + voice_id=voice_id, + channel=ChannelName.PULSE1, + order_position=order_position, + row_index=row_index, + ) + + +@pytest.fixture(name="messages") +def messages_fixture() -> SkippedRowMessages: + return SkippedRowMessages.build(LanguageManager(LANG_EN)) + + +@pytest.fixture(name="voices") +def voices_fixture() -> IdentifiedCollection[Sample]: + voices: IdentifiedCollection[Sample] = IdentifiedCollection() + for name in ("Lead", "Bass"): + voices.append(Sample(name=name, reconstruction=sample_reconstruction([ChannelName.TRIANGLE]))) + + return voices + + +class TestTheReportOfRowsLeftSilent: + def test_an_export_that_left_no_row_silent_has_nothing_to_report( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + assert messages.notice((), voices) is None + + def test_a_row_is_named_by_its_frame_channel_and_row_in_hexadecimal( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + bass = voices[1] + + notice = messages.notice((_skipped(bass.id),), voices) + + assert notice is not None + assert "Frame 03, Pulse 1, row 1A: " in notice + + def test_the_voice_reads_as_the_voices_list_prints_it( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + bass = voices[1] + + notice = messages.notice((_skipped(bass.id),), voices) + + assert notice is not None + assert notice.endswith("01: Bass") + + def test_a_voice_the_project_no_longer_holds_reads_blank( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + notice = messages.notice((_skipped("a-voice-no-project-holds"),), voices) + + assert notice is not None + assert notice.endswith(": ..") + + def test_the_report_opens_on_its_heading( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + notice = messages.notice((_skipped(voices[0].id),), voices) + + assert notice is not None + assert notice.splitlines()[0] == messages.heading + + def test_a_short_list_is_printed_whole( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + rows = tuple(_skipped(voices[0].id, row_index=index) for index in range(ROWS_LISTED)) + + notice = messages.notice(rows, voices) + + assert notice is not None + assert len(notice.splitlines()) == 1 + ROWS_LISTED + assert "more" not in notice + + def test_a_long_list_is_cut_and_says_how_many_it_leaves_out( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + left_out = 5 + rows = tuple(_skipped(voices[0].id, row_index=index) for index in range(ROWS_LISTED + left_out)) + + notice = messages.notice(rows, voices) + + assert notice is not None + lines: List[str] = notice.splitlines() + assert len(lines) == 1 + ROWS_LISTED + 1 + assert lines[-1] == f"and {left_out} more" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 218ab024f..0fc1b4d27 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -14,6 +14,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat from sampletones_shared.exceptions import ( @@ -331,6 +332,7 @@ def test_a_complete_instrument_export_shows_the_success_message( filepath=Path("lead.fti"), export_format=ExportFormat.FAMITRACKER, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -346,6 +348,7 @@ def test_a_shortened_instrument_export_names_both_frame_counts( filepath=Path("lead.fti"), export_format=ExportFormat.FAMITRACKER, truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1), + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -368,6 +371,7 @@ def test_a_shortened_reconstruction_export_counts_the_instruments( source_frames=410, instruments=3, ), + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -385,7 +389,34 @@ def test_a_wav_export_shows_its_own_message( filepath=Path("track.wav"), export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success + + +class TestUpdateReconstructionRefitsTheWaveformOnRequest: + """A retune moves the audio's own length, so the caller that knows this asks the waveform to + re-fit; an ordinary edit leaves the reader's view where it was, as it always has.""" + + @staticmethod + def _coordinator() -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = MagicMock() + return instance + + def test_a_retune_is_forwarded_to_the_panel_logic(self) -> None: + coordinator = self._coordinator() + + coordinator.update_reconstruction(refit_waveform=True) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_an_ordinary_call_asks_for_no_refit(self) -> None: + coordinator = self._coordinator() + + coordinator.update_reconstruction() + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=False) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 94e244027..6956352e8 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -1,12 +1,21 @@ from dataclasses import dataclass from pathlib import Path +from typing import Callable from unittest.mock import MagicMock import pytest +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.skipped import MAX_REPORTED_ROWS from sampletones_application.coordinators import project as project_module from sampletones_application.coordinators.project import ProjectCoordinator +from sampletones_application.paths import LANG_EN +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.success import ExportSuccess +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow from sampletones_core.exports.format import ExportFormat +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings from sampletones_shared.exceptions import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -18,6 +27,7 @@ from sampletones_shared.paths.extensions import EXT_FILE_MODULE from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL @pytest.fixture @@ -223,3 +233,86 @@ def test_a_closed_project_opens_nothing( setup.open_project.assert_not_called() save_dialog.assert_not_called() + + +class TestAWrittenProjectReportsTheRowsLeftSilent: + """A project holding rows that name a voice with no instrument still exports, and the dialog + announcing it lists those rows where the reader finds them in the tracker.""" + + @staticmethod + def _success(skipped_rows: tuple[SkippedRow, ...]) -> ExportSuccess: + return ExportSuccess( + kind=ExportKind.PROJECT, + filepath=Path("song.ftm"), + export_format=ExportFormat.FAMITRACKER, + truncation=None, + skipped_rows=skipped_rows, + ) + + @staticmethod + def _row(index: int) -> SkippedRow: + return SkippedRow( + voice_id=MISSING_VOICE_ID, + channel=SILENT_CHANNEL, + order_position=3, + row_index=index, + ) + + @pytest.fixture(name="coordinator") + def coordinator_fixture(self, monkeypatch: pytest.MonkeyPatch) -> ProjectCoordinator: + monkeypatch.setattr( + project_module.FrameCallbackManager, + "set_frame_callback", + lambda callback: callback(), + ) + project_manager = MagicMock() + project_manager.current = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + return ProjectCoordinator( + MagicMock(), + project_manager, + MagicMock(), + MagicMock(), + export_backends={}, + format_setups={}, + dialogs=MagicMock(), + language_manager=LanguageManager(LANG_EN), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + @staticmethod + def _announced(coordinator: ProjectCoordinator) -> str: + message: str = coordinator._dialogs.show_info.call_args.args[1] + return message + + def test_a_project_with_an_instrument_for_every_row_announces_the_export_alone( + self, + coordinator: ProjectCoordinator, + ) -> None: + coordinator._on_export_result(self._success(NO_SKIPPED_ROWS)) + + assert ( + self._announced(coordinator) + == LanguageManager(LANG_EN)["global.dialog.message.project_exported_successfully"] + ) + + def test_the_rows_follow_the_announcement( + self, + coordinator: ProjectCoordinator, + ) -> None: + coordinator._on_export_result(self._success((self._row(26),))) + + lines = self._announced(coordinator).splitlines() + assert lines[0] == "FamiTracker module exported successfully." + assert lines[-1].endswith("Frame 03, Pulse 1, row 1A: ..") + + def test_a_long_list_is_cut_where_the_dialog_can_hold_it( + self, + coordinator: ProjectCoordinator, + ) -> None: + left_out = 4 + rows = tuple(self._row(index) for index in range(MAX_REPORTED_ROWS + left_out)) + + coordinator._on_export_result(self._success(rows)) + + assert self._announced(coordinator).endswith(f"and {left_out} more") diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index e71ada124..6d0552e62 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -14,6 +14,7 @@ ExportPhase, SongExportViewModel, ) +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exports.stage import ExportStage WALKING_LABEL: Final[str] = "Playing the song out" @@ -96,6 +97,7 @@ def finished() -> ExportSuccess: filepath=Path("song.nsf"), export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 64e8fcfa7..863255069 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -17,10 +17,19 @@ ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_core.structures.tree import ( + ConfigGroupNode, + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.application import synchronous_executor from tests.suite.language import FakeLanguageManager +__all__ = ["synchronous_executor"] + HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" HASH_B: Final[str] = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -109,6 +118,22 @@ def directory_node(name: str, parent: TreeNode) -> FileSystemNode: ) +def config_group_node(name: str, parent: TreeNode) -> ConfigGroupNode: + """A heading naming a stretch of the configuration text the rows below it carry.""" + return ConfigGroupNode(name, node_type=NodeType.GROUP, parent=parent) + + +def config_variant_node(name: str, parent: TreeNode) -> ConfigNode: + """A reconstruction row labeled by the configuration that produced it, as the sample branch builds it.""" + return ConfigNode( + name, + node_type=NodeType.FILE, + filepath=(RECONSTRUCTIONS / name).with_suffix(EXT_FILE_RECONSTRUCTION), + config=config_fields(), + parent=parent, + ) + + def file_node(name: str, parent: TreeNode) -> FileSystemNode: return FileSystemNode( name, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py index 80c0c9b97..4643fc547 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py @@ -5,6 +5,8 @@ from .conftest import ( child_names, + config_group_node, + config_variant_node, container_root, directory_node, file_node, @@ -68,6 +70,51 @@ def test_a_folded_group_keeps_the_children_it_led_to(self) -> None: assert child_names(directory) == ["song"] +class TestWhatAFoldedNameStates: + def test_a_sample_leaves_the_variant_reading_as_a_plain_name(self) -> None: + root = container_root() + branch = group_node("branch", root) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", branch)) + + collapse_single_child_containers(root) + + assert not variant.states_configuration + + def test_a_chain_of_configuration_headings_still_states_a_configuration(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = config_group_node("44.1 kHz·30 Hz", branch) + variant = config_variant_node("PTN", config_group_node("FFT·γ0", frequencies)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert variant.states_configuration + + def test_a_source_folder_above_a_folded_variant_leaves_it_plain(self) -> None: + """Deepest first, the sample folds in and then the folder does, and one plain name is enough.""" + root = container_root() + branch = group_node("branch", root) + folder = group_node("Amen Breaks", branch) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", folder)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["Amen Breaks·cw_amen02_165·44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert not variant.states_configuration + + def test_a_variant_no_heading_folded_into_states_its_configuration(self) -> None: + root = container_root() + branch = group_node("branch", root) + sample = sample_node("cw_amen02_165", branch) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample) + config_variant_node("44.1 kHz·60 Hz·FFT·γ0·PTN", sample) + + collapse_single_child_containers(root) + + assert variant.states_configuration + + class TestHeadingsThatStay: def test_a_group_gathering_several_rows_stays(self) -> None: root = container_root() diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_stems_reader.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_stems_reader.py new file mode 100644 index 000000000..e0141eb77 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_stems_reader.py @@ -0,0 +1,140 @@ +import os +from pathlib import Path +from typing import Final, List, Sequence, Tuple + +import pytest + +from sampletones_application.logic.reconstruction.browser.stems import ( + ReconstructionStemsReader, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.sequencer import sample_reconstruction +from tests.suite.stems import recorded_from + +RECORDINGS: Final[List[str]] = ["Lead Vocals", "Drums", "Bass"] +REWRITTEN: Final[List[str]] = ["Guitar", "Organ", "Bass"] +LATER: Final[float] = 120.0 + + +def write_document(directory: Path, names: Sequence[str]) -> Path: + """Writes a reconstruction recording one named audio file per name, and answers where it went.""" + reconstruction = recorded_from( + sample_reconstruction([ChannelName.PULSE1]), + [directory / f"{name}.wav" for name in names], + ) + path = (directory / "song").with_suffix(EXT_FILE_RECONSTRUCTION) + reconstruction.save(path) + return path + + +def touched_later(path: Path) -> None: + """Writes the file's clock forward, so a rewrite reads as one however fast it followed.""" + written = path.stat().st_mtime + LATER + os.utime(path, (written, written)) + + +class Announcements: + def __init__(self) -> None: + self.read: List[Tuple[Path, Tuple[str, ...]]] = [] + + def __call__(self, path: Path, names: Tuple[str, ...]) -> None: + self.read.append((path, names)) + + +@pytest.fixture +def announcements() -> Announcements: + return Announcements() + + +@pytest.fixture +def reader(announcements: Announcements) -> ReconstructionStemsReader: + instance = ReconstructionStemsReader() + instance.on_recordings_read = announcements + return instance + + +class TestReadingADocument: + def test_an_unread_document_answers_nothing( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + assert reader.recordings(write_document(tmp_path, RECORDINGS)) is None + + def test_the_reading_names_the_recordings_in_record_order( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + + reader.recordings(path) + + assert reader.recordings(path) == tuple(RECORDINGS) + + def test_the_reading_is_announced_once_it_lands( + self, + reader: ReconstructionStemsReader, + announcements: Announcements, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + + reader.recordings(path) + + assert announcements.read == [(path, tuple(RECORDINGS))] + + +class TestWhatAReadingOutlives: + def test_a_rewritten_document_is_read_again( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + reader.recordings(path) + + write_document(tmp_path, REWRITTEN) + touched_later(path) + + assert reader.recordings(path) is None + assert reader.recordings(path) == tuple(REWRITTEN) + + def test_a_document_read_once_is_answered_without_reading_it_again( + self, + reader: ReconstructionStemsReader, + announcements: Announcements, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + reader.recordings(path) + + reader.recordings(path) + + assert len(announcements.read) == 1 + + +class TestADocumentThatCannotBeRead: + def test_an_unreadable_document_answers_nothing_to_show( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = (tmp_path / "broken").with_suffix(EXT_FILE_RECONSTRUCTION) + path.write_bytes(b"not a document") + + reader.recordings(path) + + assert reader.recordings(path) == () + + def test_a_document_the_disk_does_not_hold_answers_nothing_to_show( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = (tmp_path / "absent").with_suffix(EXT_FILE_RECONSTRUCTION) + + reader.recordings(path) + + assert reader.recordings(path) == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py index baaa8eff2..b64220a6d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py @@ -128,3 +128,80 @@ def test_a_released_choice_hears_the_next_document_whole(self, listening: StemLi listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) assert listening.heard == listening.offered + + +class TestSoloingARecording: + """A solo hears one recording alone and remembers the choice it replaced.""" + + @staticmethod + def _held() -> StemsData: + return _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, STEM_B]), + ) + + def test_the_recording_is_heard_on_every_channel_it_offers_and_no_other_is_heard( + self, + listening: StemListening, + ) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) + + listening.solo(STEM_A) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert listening.heard[STEM_B] == frozenset() + + def test_a_second_solo_returns_to_the_choice_it_replaced(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) + before = listening.heard + + listening.solo(STEM_A) + listening.solo(STEM_A) + + assert listening.heard == before + + def test_soloing_another_recording_keeps_the_choice_the_first_solo_replaced( + self, + listening: StemListening, + ) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) + before = listening.heard + + listening.solo(STEM_A) + listening.solo(STEM_B) + listening.solo(STEM_B) + + assert listening.heard == before + + def test_a_solo_with_nothing_remembered_hears_every_recording_whole(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.solo(STEM_A) + listening.adopt(self._held()) + + listening.solo(STEM_A) + + assert listening.heard == listening.offered + + def test_a_choice_made_by_hand_stands_as_the_new_state(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.solo(STEM_A) + + listening.set_channels(STEM_B, frozenset({ChannelName.PULSE1})) + listening.solo(STEM_A) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert listening.heard[STEM_B] == frozenset() + + def test_a_recording_offering_nothing_leaves_the_others_heard( + self, + listening: StemListening, + ) -> None: + listening.adopt(_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A]))) + + listening.solo(STEM_B) + listening.solo(STEM_B) + + assert listening.heard == listening.offered diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py index 67e02bbcb..61a05ecc9 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py @@ -1,6 +1,9 @@ from typing import Dict, Final, List, Sequence, Tuple -from sampletones_application.logic.reconstruction.ownership import ownership_lanes +from sampletones_application.logic.reconstruction.ownership import ( + ownership_lanes, + tells_owners_apart, +) from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, bending_channels from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -16,17 +19,17 @@ STEM_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] -def _stems_data(stem_ids: Sequence[int]) -> StemsData: - """A record of two recordings, the first channel holding ``stem_ids`` frame by frame.""" +def _stems_data(stem_ids: Sequence[int], *, owners: Sequence[int] = (STEM_A, STEM_B)) -> StemsData: + """A record of the given recordings, the first channel holding ``stem_ids`` frame by frame.""" entries = [ StemEntry( id=stem_id, settings=StemSettings(channels=STEM_CHANNELS, bends=bending_channels(STEM_CHANNELS)), ) - for stem_id in (STEM_A, STEM_B) + for stem_id in owners ] return StemsData( - config=StemsConfig(entries=entries, hierarchy=StemsHierarchy(levels=[[STEM_A, STEM_B]])), + config=StemsConfig(entries=entries, hierarchy=StemsHierarchy(levels=[list(owners)])), assignments=[ChannelAssignment(channel_name=CHANNEL, stem_ids=list(stem_ids))], ) @@ -56,3 +59,24 @@ def test_a_channel_resting_throughout_carries_no_stretch(self) -> None: def test_a_trailing_rest_leaves_the_recordings_where_they_stand(self) -> None: assert _lane_runs([STEM_A, STEM_A, RESTING_STEM_ID]) == ((0, 2, STEM_A),) + + +class TestALaneStandsWhateverTheDocumentHoldsToTellApart: + """Whether a lane is worth drawing is left to the caller, so a document answering to a single + owner still divides into the one stretch that owner holds.""" + + def test_a_single_owner_still_takes_a_lane(self) -> None: + stems_data = _stems_data([STEM_A, STEM_A], owners=(STEM_A,)) + assignments: Dict[ChannelName, List[int]] = {CHANNEL: [STEM_A, STEM_A]} + + lanes = ownership_lanes(stems_data, assignments, lambda _channel: {STEM_A}) + + assert [(run.start_frame, run.end_frame, run.stem_id) for run in lanes[CHANNEL].runs] == [(0, 2, STEM_A)] + + +class TestWhetherTheDocumentHasOwnersToTellApart: + def test_one_owner_has_nothing_to_tell_apart(self) -> None: + assert not tells_owners_apart(_stems_data([STEM_A], owners=(STEM_A,))) + + def test_two_owners_tell_apart(self) -> None: + assert tells_owners_apart(_stems_data([STEM_A, STEM_B])) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index a5f109285..103ce2ee5 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, Final, List +from typing import Callable, Dict, Final, List, Optional from unittest.mock import MagicMock import numpy as np @@ -23,7 +23,9 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel +from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.algorithm import AUTHORED_STEM_ID @@ -493,6 +495,117 @@ def test_a_closed_tab_states_no_rate( assert received[0].nes_frequency is None +class TestReconstructionPanelLogicRetuning: + """A reader changing the rate re-times the open document and every reading of it.""" + + @staticmethod + def _rebinding(manager: MagicMock) -> None: + """Lets the stand-in manager rebind the open document the way the real one does.""" + manager.apply_edited.side_effect = lambda reconstruction: setattr( + manager, + "current_reconstruction", + manager.current_reconstruction.with_reconstruction(reconstruction), + ) + + def test_the_document_is_rebound_to_a_reconstruction_at_the_new_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._rebinding(mock_reconstruction_manager) + _open(mock_reconstruction_manager, loaded_data) + + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + retuned = mock_reconstruction_manager.apply_edited.call_args.args[0] + assert retuned.config.nes_frequency == PAL_FREQUENCY + + def test_the_change_stands_as_an_unsaved_edit( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._rebinding(mock_reconstruction_manager) + _open(mock_reconstruction_manager, loaded_data) + + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + mock_reconstruction_manager.mark_updated.assert_called_once_with() + + def test_the_view_states_the_new_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._rebinding(mock_reconstruction_manager) + _open(mock_reconstruction_manager, loaded_data) + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + assert received[-1].nes_frequency == PAL_FREQUENCY + + def test_the_waveform_and_the_audio_follow_the_new_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._rebinding(mock_reconstruction_manager) + _open(mock_reconstruction_manager, loaded_data) + waveforms: List[WaveformData] = [] + audio: List[Optional[AudioData]] = [] + panel_logic.on_waveform_update_changed = lambda waveform, _channels, **_kwargs: waveforms.append(waveform) + panel_logic.on_audio_data_changed = audio.append + + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + assert len(waveforms) == 1 + assert len(audio) == 1 + + def test_the_waveform_view_re_fits_to_the_retuned_length( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """A retune moves the audio's own length, so the old view no longer answers to it.""" + self._rebinding(mock_reconstruction_manager) + _open(mock_reconstruction_manager, loaded_data) + refits: List[bool] = [] + panel_logic.on_waveform_update_changed = lambda _waveform, _channels, *, refit: refits.append(refit) + + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + assert refits == [True] + + def test_the_rate_the_document_already_runs_at_changes_nothing( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + + panel_logic.set_nes_frequency(loaded_data.config.nes_frequency) + + mock_reconstruction_manager.apply_edited.assert_not_called() + mock_reconstruction_manager.mark_updated.assert_not_called() + + def test_a_tab_holding_no_document_changes_nothing( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + panel_logic.set_nes_frequency(PAL_FREQUENCY) + + mock_reconstruction_manager.apply_edited.assert_not_called() + + class TestReconstructionPanelLogicClose: def test_close_fires_on_waveform_cleared( self, @@ -1258,6 +1371,51 @@ def test_the_reader_is_told_the_envelopes_moved( assert len(reported) == 1 + def test_a_solo_leaves_the_envelopes_of_the_recording_alone( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count + + panel_logic.solo_stem(0) + + assert mock_reconstruction_manager.listening.heard[1] == frozenset() + assert 0 < mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count < whole + + def test_a_second_solo_returns_to_the_whole_mix( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count + + panel_logic.solo_stem(0) + panel_logic.solo_stem(0) + + assert mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count == whole + + def test_a_solo_tells_the_reader_the_envelopes_moved_once( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + reported: List[None] = [] + panel_logic.on_heard_changed = lambda: reported.append(None) + + panel_logic.solo_stem(0) + + assert len(reported) == 1 + class TestTheLanesTheRibbonStandsOn: """The lanes answer for the channels the document plays, and what fills them for the listening.""" @@ -1377,18 +1535,23 @@ def test_a_recording_switched_off_keeps_its_stretch_under_its_own_name( pulse_lane = next(lane for lane in received[-1].lanes if lane.channel_name == ChannelName.PULSE1) assert [(run.stem_id, run.heard) for run in pulse_lane.runs] == [(0, False)] - def test_a_document_answering_to_one_recording_offers_no_lanes( + def test_a_document_answering_to_one_recording_still_offers_a_lane( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: + """A single recording throughout still answers whether a channel is sounding and by whom, + so its lane stands the way every other channel's does.""" _open(mock_reconstruction_manager, loaded_data) received = self._ribbons(panel_logic) panel_logic.display_reconstruction() - assert not received[-1].is_drawn + ribbon = received[-1] + assert ribbon.is_drawn + assert [lane.channel_name for lane in ribbon.lanes] == [ChannelName.PULSE1] + assert ribbon.lanes[0].runs class TestTheScopeAnEditWritesIn: diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index f4222ef4a..3e9a8ba63 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -6,6 +6,7 @@ from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat @@ -18,6 +19,7 @@ def test_stores_kind_and_filepath(self) -> None: filepath=filepath, export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) assert success.kind == ExportKind.WAV assert success.filepath == filepath @@ -30,6 +32,7 @@ def test_stores_the_tracker_format(self) -> None: filepath=Path("/x"), export_format=ExportFormat.BITPHASE, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) assert success.export_format == ExportFormat.BITPHASE @@ -44,6 +47,7 @@ def test_stores_the_truncation(self) -> None: filepath=Path("/x"), export_format=ExportFormat.FAMITRACKER, truncation=truncation, + skipped_rows=NO_SKIPPED_ROWS, ) assert success.truncation == truncation @@ -53,6 +57,7 @@ def test_frozen(self) -> None: filepath=Path("/x"), export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) with pytest.raises(FrozenInstanceError): success.kind = ExportKind.INSTRUMENT # type: ignore[misc] @@ -64,22 +69,26 @@ def test_equality(self) -> None: filepath=path, export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) == ExportSuccess( kind=ExportKind.WAV, filepath=path, export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) assert ExportSuccess( kind=ExportKind.WAV, filepath=path, export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: @@ -89,11 +98,13 @@ def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: filepath=path, export_format=ExportFormat.FAMITRACKER, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, export_format=ExportFormat.BITPHASE, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index d2e1bf8da..5efe54824 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -16,6 +16,7 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat @@ -56,9 +57,11 @@ def __init__( self, truncation: Optional[EnvelopeTruncation] = None, exception: Optional[Exception] = None, + skipped_rows: Tuple[SkippedRow, ...] = NO_SKIPPED_ROWS, ) -> None: self.truncation = truncation self.exception = exception + self.skipped_rows = skipped_rows self.calls: List[Tuple[str, Path, Any]] = [] self.on_write: Optional[Callable[[], None]] = None @@ -113,7 +116,7 @@ def _write( raise self.exception announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - return ExportArtifact(paths=(destination,), truncation=self.truncation) + return ExportArtifact(paths=(destination,), truncation=self.truncation, skipped_rows=self.skipped_rows) def build_instrument(name: str = "Lead") -> InstrumentExport: @@ -481,6 +484,43 @@ def test_a_wav_export_reports_no_truncation( assert outcome(results).truncation is None +class TestExportSkippedRowsReporting: + SKIPPED = ( + SkippedRow( + voice_id="voice", + channel=ChannelName.PULSE1, + order_position=3, + row_index=26, + ), + ) + + def test_a_project_carries_the_rows_the_backend_left_silent( + self, + service, + tmp_path, + ) -> None: + export_service, results = service + + export_service.export_project( + tmp_path / "song.ftm", + StubBackend(skipped_rows=self.SKIPPED), + build_project(), + ) + + assert outcome(results).skipped_rows == self.SKIPPED + + def test_a_project_the_backend_wrote_whole_reports_no_rows( + self, + service, + tmp_path, + ) -> None: + export_service, results = service + + export_service.export_project(tmp_path / "song.ftm", StubBackend(), build_project()) + + assert outcome(results).skipped_rows == () + + class TestExportServiceConcurrency: def test_second_export_while_first_running_is_rejected( self, @@ -560,7 +600,7 @@ def write_instrument( self._service.cancel() announce(report, ExportStage.COMPRESSING, ONE_FILE, None) self.stages.append(ExportStage.COMPRESSING) - return ExportArtifact(paths=(destination,), truncation=None) + return ExportArtifact(paths=(destination,), truncation=None, skipped_rows=NO_SKIPPED_ROWS) def write_sample( self, diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 5d2cf4377..2a6893301 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -71,7 +71,7 @@ def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: app._apply_retuned_sample(retuned) app.reconstruction_manager.apply_edited.assert_called_once_with(retuned.reconstruction) - app._reconstructions_tab.update_reconstruction.assert_called_once() + app._reconstructions_tab.update_reconstruction.assert_called_once_with(refit_waveform=True) def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: sample = _sample_double() diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py index aa0c52709..ae7649bf9 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -5,6 +5,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.result import ServiceProgress, ServiceStarted +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exports.stage import ExportStage NOTHING_MEASURED: int = 0 @@ -87,6 +88,7 @@ def test_a_finished_export_refreshes_the_busy_state(self) -> None: filepath=Path("song.nsf"), export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) ) application._update_menu.assert_called_once_with() diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py index 2ffdc02d3..e722d9f0d 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py @@ -1,8 +1,13 @@ -from typing import Final +from typing import Dict, Final, Tuple from unittest.mock import patch +import pytest + from sampletones_application.ui.elements.graphs import graph as graph_module +from sampletones_application.ui.elements.graphs.graph import GUIGraph +from sampletones_application.ui.elements.graphs.spectrum import GUISpectrumGraph from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph +from sampletones_application.utils.gui.keyboard.modifiers import ALT, NO_MODIFIERS, SHIFT, ModifierSet MODULE: Final[str] = "sampletones_application.ui.elements.graphs.graph" X_AXIS: Final[str] = "graph.x" @@ -43,3 +48,53 @@ def test_the_release_is_scheduled_for_the_following_frame(self) -> None: frame.set_frame_callback.call_args.args[0]() assert [call.args[0] for call in set_auto.call_args_list] == [X_AXIS, Y_AXIS] + + +class TestWhichAxesTheHoverLocks: + """The wheel zooms the axis left free, so what the hover locks decides what a wheel does.""" + + @staticmethod + def _locks(modifiers: ModifierSet) -> Dict[str, Tuple[bool, bool]]: + graph = _graph() + locks: Dict[str, Tuple[bool, bool]] = {} + with ( + patch(f"{MODULE}.FrameCallbackManager"), + patch(f"{MODULE}.capture_modifiers", return_value=modifiers), + patch.object( + graph_module.dpg, + "configure_item", + side_effect=lambda axis, lock_min, lock_max: locks.update({axis: (lock_min, lock_max)}), + ), + ): + GUIGraph._on_hover(graph, 0, 0, None) + + return locks + + @pytest.mark.parametrize( + ("modifiers", "x_locked", "y_locked"), + [ + (NO_MODIFIERS, False, True), + (SHIFT, True, False), + (ALT, True, True), + (ALT | SHIFT, True, True), + ], + ids=["bare wheel zooms x", "shift zooms y", "alt pans x", "alt and shift pan y"], + ) + def test_the_hover_locks_every_axis_the_wheel_leaves_alone( + self, + modifiers: ModifierSet, + x_locked: bool, + y_locked: bool, + ) -> None: + locks = self._locks(modifiers) + + assert locks[X_AXIS] == (x_locked, x_locked) + assert locks[Y_AXIS] == (y_locked, y_locked) + + +class TestWhichGraphsPan: + def test_the_waveform_pans_with_the_wheel(self) -> None: + assert GUIWaveformGraph.pans_with_wheel + + def test_the_spectrum_keeps_its_view(self) -> None: + assert not GUISpectrumGraph.pans_with_wheel diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py b/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py new file mode 100644 index 000000000..b933dd0e3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py @@ -0,0 +1,221 @@ +from typing import Dict, FrozenSet, List, Tuple +from unittest.mock import patch + +import pytest + +from sampletones_application.layout.graphs import GraphsLayout +from sampletones_application.ui.elements.graphs import pan as pan_module +from sampletones_application.ui.elements.graphs.pan import PlotWheelPan +from sampletones_application.utils.gui.keyboard.modifiers import ( + ALT, + CTRL_ALT_SHIFT, + NO_MODIFIERS, + SHIFT, + ModifierSet, +) + +PLOT: str = "graph.plot" +LANE: str = "graph.lane" +X_AXIS: str = "graph.x" +Y_AXIS: str = "graph.y" +X_BOUNDS: Tuple[float, float] = (0.0, 1000.0) +Y_BOUNDS: Tuple[float, float] = (-1.0, 1.0) +ALT_SHIFT: ModifierSet = ALT | SHIFT + + +class Scene: + """A plot standing at some limits, with the wheel handler its pan registered.""" + + def __init__( + self, + pan_factor: float, + *, + limits: Dict[str, Tuple[float, float]], + ) -> None: + self.limits = dict(limits) + self.hovered: FrozenSet[str] = frozenset({PLOT}) + self.modifiers: ModifierSet = NO_MODIFIERS + self.panned: List[None] = [] + self.pan = PlotWheelPan( + tag="graph", + plot_tags=(PLOT, LANE), + x_axis_tag=X_AXIS, + y_axis_tag=Y_AXIS, + pan_factor=pan_factor, + x_bounds=lambda: X_BOUNDS, + y_bounds=lambda: Y_BOUNDS, + on_panned=lambda: self.panned.append(None), + ) + + def scroll(self, wheel: float) -> None: + with ( + patch.object(pan_module, "capture_modifiers", return_value=self.modifiers), + patch.object(pan_module, "dpg_is_item_hovered", side_effect=lambda tag: tag in self.hovered), + patch.object(pan_module.dpg, "get_axis_limits", side_effect=lambda axis: self.limits[axis]), + patch.object(pan_module.dpg, "set_axis_limits", side_effect=self._set_limits), + ): + self.pan._on_wheel(0, wheel) + + def _set_limits(self, axis: str, low: float, high: float) -> None: + self.limits[axis] = (low, high) + + +@pytest.fixture +def scene(graphs_layout: GraphsLayout) -> Scene: + return Scene( + graphs_layout.waveform.pan_factor, + limits={X_AXIS: (400.0, 500.0), Y_AXIS: (-0.5, 0.5)}, + ) + + +class TestAltWithTheWheelPansAlongX: + def test_scrolling_down_moves_the_view_toward_larger_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + step = graphs_layout.waveform.pan_factor * 100.0 + assert scene.limits[X_AXIS] == pytest.approx((400.0 + step, 500.0 + step)) + + def test_scrolling_up_moves_the_view_toward_smaller_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT + + scene.scroll(1.0) + + step = graphs_layout.waveform.pan_factor * 100.0 + assert scene.limits[X_AXIS] == pytest.approx((400.0 - step, 500.0 - step)) + + def test_a_move_keeps_the_span_in_view(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-3.0) + + low, high = scene.limits[X_AXIS] + assert high - low == pytest.approx(100.0) + + def test_the_y_axis_stays_where_it_was(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + assert scene.limits[Y_AXIS] == (-0.5, 0.5) + + def test_the_graph_is_told_once_a_move_is_made(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + assert len(scene.panned) == 1 + + +class TestAltAndShiftWithTheWheelPanAlongY: + def test_scrolling_up_moves_the_view_toward_larger_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT_SHIFT + + scene.scroll(1.0) + + step = graphs_layout.waveform.pan_factor * 1.0 + assert scene.limits[Y_AXIS] == pytest.approx((-0.5 + step, 0.5 + step)) + + def test_the_x_axis_stays_where_it_was(self, scene: Scene) -> None: + scene.modifiers = ALT_SHIFT + + scene.scroll(1.0) + + assert scene.limits[X_AXIS] == (400.0, 500.0) + + +class TestWhereTheWheelDoesNothing: + @pytest.mark.parametrize( + "modifiers", + [NO_MODIFIERS, SHIFT, CTRL_ALT_SHIFT - ALT], + ids=["bare", "shift", "no alt"], + ) + def test_a_wheel_without_alt_leaves_the_view_to_the_plot( + self, + scene: Scene, + modifiers: ModifierSet, + ) -> None: + scene.modifiers = modifiers + + scene.scroll(-1.0) + + assert scene.limits == {X_AXIS: (400.0, 500.0), Y_AXIS: (-0.5, 0.5)} + assert not scene.panned + + def test_a_wheel_away_from_the_plot_leaves_the_view_alone(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.hovered = frozenset() + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS] == (400.0, 500.0) + assert not scene.panned + + def test_a_view_showing_the_whole_range_has_nowhere_to_go(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.limits[X_AXIS] = X_BOUNDS + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS] == X_BOUNDS + + +class TestPlotsLinkedToTheGraph: + def test_the_wheel_over_a_linked_plot_pans_the_shared_axis(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.hovered = frozenset({LANE}) + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS][0] > 400.0 + + +class TestTheBoundsAMoveStopsAt: + @pytest.mark.parametrize( + ("limits", "wheel", "expected"), + [ + ((990.0, 1000.0), -50.0, (990.0, 1000.0)), + ((0.0, 10.0), 50.0, (0.0, 10.0)), + ((980.0, 990.0), -50.0, (990.0, 1000.0)), + ((10.0, 20.0), 50.0, (0.0, 10.0)), + ], + ids=["at the top", "at the bottom", "reaching the top", "reaching the bottom"], + ) + def test_the_view_stops_at_the_edge_of_the_range( + self, + scene: Scene, + limits: Tuple[float, float], + wheel: float, + expected: Tuple[float, float], + ) -> None: + scene.modifiers = ALT + scene.limits[X_AXIS] = limits + + scene.scroll(wheel) + + assert scene.limits[X_AXIS] == pytest.approx(expected) + + +class TestRegisteringTheHandler: + def test_the_wheel_handler_is_registered_under_the_graph(self, scene: Scene) -> None: + with ( + patch.object(pan_module.dpg, "handler_registry") as registry, + patch.object(pan_module.dpg, "add_mouse_wheel_handler") as handler, + ): + scene.pan.create() + + registry.assert_called_once() + handler.assert_called_once_with(callback=scene.pan._on_wheel) 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 d1b196e59..debca4edc 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -8,6 +8,7 @@ from sampletones_application.ui.elements.graphs import waveform as waveform_module from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.shared.waveform_data import WaveformData VOICE_SAMPLES = 64 @@ -150,6 +151,69 @@ def test_preserves_position_indicator_across_updates(self, fake_dpg: _FakeDPG) - assert fake_dpg.alias_to_id["overlay"] not in fake_dpg.deleted +class TestWaveformDataUpdateRefit: + """A retune moves the audio's own length, so its update re-fits the view; an ordinary edit, + which changes nothing about the length, leaves the reader's view where it was.""" + + @staticmethod + def _waveform_data() -> WaveformData: + approximation = np.zeros(VOICE_SAMPLES, dtype=np.float32) + return WaveformData( + original_audio=None, + approximation=approximation, + approximations={}, + coefficient=1.0, + frame_length=1, + sample_rate=44100, + ) + + def test_a_refit_update_recomputes_the_axis_ranges( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + graph.current_data = self._waveform_data() + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data(), refit=True) + + ranges.assert_called_once_with() + + def test_an_ordinary_update_leaves_the_ranges_alone( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + graph.current_data = self._waveform_data() + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data()) + + ranges.assert_not_called() + + def test_a_graph_not_yet_holding_waveform_data_takes_no_update( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A voice's own waveform (`current_data` unset) has no length to lose, so a retune + elsewhere reaching this graph by mistake is a no-op rather than a crash.""" + graph = _graph() + graph.current_data = None + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data(), refit=True) + + ranges.assert_not_called() + + class TestWaveformReconstructionDim: def test_series_color_is_untouched_when_not_dimmed(self) -> None: graph = _graph() diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py index 2c4f4e184..01f9f5490 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py @@ -1,9 +1,10 @@ -from typing import Generator +from typing import Generator, List, Tuple from unittest.mock import MagicMock import dearpygui.dearpygui as dpg import pytest +from sampletones_application.categories.context import channel_letter from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.loader import load_layout_config @@ -16,12 +17,15 @@ ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.graphs import waveform as waveform_module from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource from sampletones_core.constants.enums import ChannelName +LANGUAGE_MANAGER = LanguageManager(LANG_EN) + @pytest.fixture(name="layout") def layout_fixture() -> LayoutConfig: @@ -44,7 +48,7 @@ def graph_fixture(layout: LayoutConfig) -> Generator[GUIWaveformGraph, None, Non parent="root", layout=layout.graphs, channel_colors=layout.general.colors.channels, - language_manager=LanguageManager(LANG_EN), + language_manager=LANGUAGE_MANAGER, status_bar=MagicMock(), ) finally: @@ -96,3 +100,48 @@ def test_every_lane_takes_the_tiny_face(self, graph: GUIWaveformGraph) -> None: def test_the_letter_stands_shorter_than_the_lane_it_marks(self, layout: LayoutConfig) -> None: """A letter taller than its lane would run into the lane above, so the face stays under it.""" assert FontRegistry.get_size(Font.REGULAR_TINY) <= layout.graphs.ribbon.lane_height + + +class TestTheLetterFollowsWhetherTheLaneStands: + """A closed lane keeps a sliver of height, so its letter is cleared rather than left to crowd it.""" + + @staticmethod + def _ticks(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, Tuple[Tuple[str, float], ...]]]: + calls: List[Tuple[str, Tuple[Tuple[str, float], ...]]] = [] + monkeypatch.setattr(waveform_module.dpg, "set_axis_ticks", lambda tag, ticks: calls.append((tag, ticks))) + return calls + + def test_a_channel_gaining_a_lane_is_marked_with_its_letter( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({ChannelName.PULSE1: 11}) + + letter = channel_letter(LANGUAGE_MANAGER, ChannelName.PULSE1) + assert (graph.lane_y_axis_tags[ChannelName.PULSE1], ((letter, 0.5),)) in calls + + def test_a_channel_without_a_lane_is_cleared( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({ChannelName.PULSE1: 11}) + + assert (graph.lane_y_axis_tags[ChannelName.PULSE2], ()) in calls + + def test_a_lane_closed_after_standing_open_loses_its_letter( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph.set_lane_heights({ChannelName.PULSE1: 11}) + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({}) + + assert (graph.lane_y_axis_tags[ChannelName.PULSE1], ()) in calls diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py index 73fa3f750..6daab48db 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py @@ -65,6 +65,7 @@ def rendered_strip_padding(monkeypatch: pytest.MonkeyPatch) -> None: def _glyphs() -> Glyphs: common = CommonGlyphs( tick=".", + swatch="#", favorite="*", expanded=_EXPANDED_GLYPH, collapsed=_COLLAPSED_GLYPH, diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py index 3951fef96..c7f14e62f 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -49,7 +49,6 @@ def columns( *, folders: bool, master: bool = False, - bends: bool = False, swatch: bool = False, ) -> StemsColumns: """The grid a list of gathered recordings declares.""" @@ -58,7 +57,6 @@ def columns( channels=CHANNELS, master=master, removable=True, - bends=bends, swatch=swatch, folders=folders, ) @@ -122,20 +120,6 @@ def test_the_name_stands_over_the_middle_of_its_column( with measured(LABEL_SIZE): indent = columns(layout_config, folders=True).name_indent(LABEL, Font.BOLD_SMALL) - assert indent == (stems.channel_solo_width - int(LABEL_WIDTH)) // 2 - - def test_a_name_over_a_column_carrying_bends_stands_in_the_wider_room( - self, - dpg_context: None, - layout_config: LayoutConfig, - ) -> None: - """A cell holding the channel and the bend on it takes a column of its own width, and the - name above it is centered in that.""" - stems = layout_config.general.stems - - with measured(LABEL_SIZE): - indent = columns(layout_config, folders=True, bends=True).name_indent(LABEL, Font.BOLD_SMALL) - assert indent == (stems.channel_column_width - int(LABEL_WIDTH)) // 2 def test_a_name_no_frame_has_measured_yet_opens_at_the_edge( @@ -157,32 +141,9 @@ def test_a_channel_box_is_centered_in_its_column( self, layout_config: LayoutConfig, ) -> None: - """One box stands in a column the width the layout gives a channel standing on its own.""" - stems = layout_config.general.stems - - indent = columns(layout_config, folders=True).box_indent(ChannelName.PULSE1) - - assert indent == (stems.channel_solo_width - stems.channel_box_width) // 2 - - def test_a_channel_carrying_its_bend_centers_both_boxes_together( - self, - layout_config: LayoutConfig, - ) -> None: - """A tone channel's cell holds the channel and the bend on it, in a column of its own width.""" - stems = layout_config.general.stems - - indent = columns(layout_config, folders=True, bends=True).box_indent(ChannelName.PULSE1) - - assert indent == (stems.channel_column_width - 2 * stems.channel_box_width) // 2 - - def test_a_channel_taking_no_bend_keeps_the_one_box( - self, - layout_config: LayoutConfig, - ) -> None: - """A bend moves a note within its divider, so noise holds the first slot alone.""" stems = layout_config.general.stems - indent = columns(layout_config, folders=True, bends=True).box_indent(ChannelName.NOISE) + indent = columns(layout_config, folders=True).box_indent assert indent == (stems.channel_column_width - stems.channel_box_width) // 2 @@ -209,7 +170,6 @@ def test_a_box_wider_than_its_column_opens_at_the_edge( channels=CHANNELS, master=True, removable=True, - bends=False, swatch=False, folders=True, ).master_indent @@ -293,7 +253,7 @@ def test_each_channel_takes_the_width_its_boxes_ask_for( layout_config: LayoutConfig, ) -> None: stems = layout_config.general.stems - grid = columns(layout_config, folders=True, bends=True) + grid = columns(layout_config, folders=True) declared = self._declared(grid) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 753e963b5..add4c0d78 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -112,7 +112,6 @@ def recording(path: Path, *, channels: FrozenSet[ChannelName] = frozenset(CHANNE held=(), channels=channels, partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset(CHANNELS), available=True, level=0, @@ -133,7 +132,6 @@ def folder(name: str, *, holds: int) -> StemRowViewModel: held=tuple(recording(root / f"take_{index}.wav") for index in range(holds)), channels=frozenset(CHANNELS), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset(CHANNELS), available=True, level=0, @@ -694,7 +692,6 @@ def _grid(layout_config: LayoutConfig) -> StemsColumns: channels=CHANNELS, master=GATHERED_SOURCES.master_box, removable=GATHERED_SOURCES.removal, - bends=GATHERED_SOURCES.bends, swatch=GATHERED_SOURCES.swatch, folders=True, ) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index a54f55be6..7a03229e1 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -96,7 +96,6 @@ def build( removal: bool = True, keeps_last_row: bool = False, master_box: bool = False, - bends: bool = False, picking: bool = False, swatch: bool = False, ) -> GUIStemsList: @@ -113,7 +112,6 @@ def build( removal=removal, keeps_last_row=keeps_last_row, dragging=dragging, - bends=bends, picking=picking, swatch=swatch, ), @@ -142,7 +140,6 @@ def row( kind=kind, held=(), partial_channels=frozenset(), - bends=frozenset(), key=str(path), name=path.stem, path=path, @@ -234,7 +231,6 @@ def folder_row( held=tuple(row(f"{name}/held_{index}") for index in range(holds)), channels=channels, partial_channels=partial_channels, - bends=frozenset(), offered_channels=frozenset(CHANNELS), available=True, level=0, @@ -774,7 +770,6 @@ def test_a_master_box_opens_where_the_grid_puts_it(self, dpg_context: None, layo channels=CHANNELS, master=True, removable=True, - bends=False, swatch=False, folders=False, ).master_indent diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py index 2e1b5a8ba..aae25c60a 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py @@ -42,7 +42,6 @@ def offer(*, dragging: bool = False) -> StemsListOffer: removal=True, keeps_last_row=False, dragging=dragging, - bends=False, picking=False, swatch=False, ) @@ -64,7 +63,6 @@ def recording( held=(), channels=channels, partial_channels=frozenset(), - bends=frozenset(), offered_channels=offered_channels, available=available, level=0, @@ -85,7 +83,6 @@ def folder(name: str = FOLDER_NAME, *, holds: int = HOLDS) -> StemRowViewModel: held=tuple(recording(f"{name}/take_{index}") for index in range(holds)), channels=frozenset(CHANNELS), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset(CHANNELS), available=True, level=0, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index e13796fac..27f688b1e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -1,10 +1,12 @@ from pathlib import Path -from typing import Final, List +from typing import Final, List, Tuple import pytest +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_core.configs import Config from sampletones_core.configs.display import format_frequencies, format_sample_rate, short_hash from sampletones_core.constants.enums import DEFAULT_CHANNELS @@ -17,6 +19,7 @@ ) from sampletones_core.structures.tree.type import NodeType from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from sampletones_shared.types.application import ColorRGBA from tests.suite.language import FakeLanguageManager CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) @@ -31,19 +34,47 @@ "window_size", "channels", "configuration", + "stems", ] +RECORDING_COLORS: Final[Tuple[ColorRGBA, ...]] = ( + (200, 80, 40, 255), + (80, 160, 220, 255), +) +AUTHORED_COLOR: Final[ColorRGBA] = (180, 140, 240, 255) +REST_COLOR: Final[ColorRGBA] = (40, 40, 48, 255) +LEFT_OUT_FRACTION: Final[float] = 0.4 + +RECORDINGS: Final[Tuple[str, ...]] = ("Drums", "Bass") +ONE_RECORDING: Final[Tuple[str, ...]] = ("Neurostem",) + + +@pytest.fixture +def stem_colors() -> StemColors: + return StemColors( + recordings=tuple(LiteralColor(value) for value in RECORDING_COLORS), + authored=LiteralColor(AUTHORED_COLOR), + rest=LiteralColor(REST_COLOR), + left_out_fraction=LEFT_OUT_FRACTION, + ) + @pytest.fixture -def panel() -> GUISequencerBrowserPanel: +def panel(stem_colors: StemColors) -> GUISequencerBrowserPanel: """Builds a browser panel without its DearPyGui-dependent constructor. - Resolving a node's detail items reads only the language-resolved detail labels, so the pieces + Resolving a node's detail items reads only the language-resolved detail labels, the colors a + recording is known by and what the panel holds about the row under the pointer, so the pieces the constructor would build around a running GUI context are unnecessary here. A concrete browser stands in for the base because the configuration font is a browser-level opt-in. """ instance = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) instance._language_manager = FakeLanguageManager() + instance._stem_colors = stem_colors + instance._detail_document = None + instance._detail_recordings = () + instance._detail_tooltip_owner_tag = None + instance.on_recordings_requested = None for label in DETAIL_LABELS: setattr(instance, f"_lbl_detail_{label}", label) @@ -83,6 +114,16 @@ def config_variant_node() -> ConfigNode: ) +def reconstruction_file_node(parent: TreeNode) -> FileSystemNode: + """A reconstruction as the configuration branch lists it: a file under the folder holding it.""" + return FileSystemNode( + "song", + node_type=NodeType.FILE, + filepath=RECONSTRUCTION_PATH, + parent=parent, + ) + + class TestConfigDetailItems: def test_config_directory_states_its_configuration( self, @@ -116,6 +157,93 @@ def test_group_states_nothing( assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] +class TestAReconstructionFileRow: + def test_it_states_the_configuration_of_the_directory_holding_it( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A configuration states its fields in a directory name, which the file inherits by sitting there.""" + directory = config_directory_node() + + items = panel._node_detail_items(reconstruction_file_node(directory)) + + assert items == panel._node_detail_items(directory) + + def test_a_file_outside_every_configuration_directory_states_none( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(reconstruction_file_node(plain_directory_node())) == [] + + +class TestTheRecordingsARowLists: + def test_a_row_naming_a_document_asks_what_it_holds( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + asked: List[Path] = [] + panel.on_recordings_requested = asked.append + + panel._node_detail_recordings(config_variant_node()) + + assert asked == [RECONSTRUCTION_PATH] + + def test_a_row_naming_no_document_asks_for_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + asked: List[Path] = [] + panel.on_recordings_requested = asked.append + + assert panel._node_detail_recordings(config_directory_node()) == () + assert asked == [] + + def test_the_recordings_read_in_record_order( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, RECORDINGS) + + swatches = panel._node_detail_recordings(config_variant_node()) + + assert tuple(swatch.name for swatch in swatches) == RECORDINGS + + def test_each_recording_takes_the_color_of_the_place_it_holds( + self, + panel: GUISequencerBrowserPanel, + stem_colors: StemColors, + ) -> None: + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, RECORDINGS) + + swatches = panel._node_detail_recordings(config_variant_node()) + + assert [swatch.color.rgba for swatch in swatches] == [ + stem_colors.for_position(position).rgba for position in range(len(RECORDINGS)) + ] + + def test_a_document_naming_one_recording_lists_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """One recording says what the row already says, so the list is left to the documents holding several.""" + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, ONE_RECORDING) + + assert panel._node_detail_recordings(config_variant_node()) == () + + def test_a_reading_of_another_document_is_left_where_it_is( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + panel._node_detail_recordings(config_variant_node()) + + panel.update_recordings(CONFIG_DIRECTORY / f"other{EXT_FILE_RECONSTRUCTION}", RECORDINGS) + + assert panel._node_detail_recordings(config_variant_node()) == () + + class TestConfigurationFont: """Every row whose label is configuration text reads in one font, whatever kind of row it is.""" @@ -149,3 +277,13 @@ def test_heading_the_disk_names_reads_in_the_name_font( panel: GUISequencerBrowserPanel, ) -> None: assert panel._resolve_node_name_font(TreeNode("Amen Breaks", NodeType.GROUP)) == Font.REGULAR_SMALL + + def test_row_gathering_a_plain_name_reads_in_the_name_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A sample folded into its one variant hands the row an audio's name, which reads as such.""" + variant = config_variant_node() + variant.gathered_plain_name = True + + assert panel._resolve_node_name_font(variant) == Font.REGULAR_SMALL diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index aa252a427..0c400037b 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -7,7 +7,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.paths import LANG_EN from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.general import SUF_BUTTON, TAG_GLOBAL_THEME_DANGER_BUTTON from sampletones_application.tags.settings import ( TAG_SETTINGS_EXPORT_BUTTON_CANCEL, TAG_SETTINGS_EXPORT_GROUP_MEASURED, @@ -133,6 +133,13 @@ def test_an_export_already_stopping_offers_no_further_stop(self, window: GUIExpo render(window, phase=ExportPhase.CANCELING) assert not dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] + def test_cancel_carries_the_tone_of_an_action_that_undoes_the_run(self, window: GUIExportWindow) -> None: + render(window, phase=ExportPhase.EXPORTING) + + theme = dpg.get_item_theme(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + def test_pressing_cancel_asks_the_run_to_stop(self, window: GUIExportWindow) -> None: asked: List[bool] = [] window.on_cancel = lambda: asked.append(True) diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py index b461e12e5..dc239c616 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py @@ -8,7 +8,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.paths import LANG_EN from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.general import SUF_BUTTON, TAG_GLOBAL_THEME_DANGER_BUTTON from sampletones_application.tags.settings import ( TAG_SETTINGS_RENDER_BUTTON_BROWSE, TAG_SETTINGS_RENDER_BUTTON_CANCEL, @@ -283,6 +283,13 @@ def test_the_stop_button_stops_a_running_render(self, window: GUIRenderWindow) - assert stopped + def test_the_stop_button_carries_the_tone_of_an_action_that_undoes_the_run(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.RENDERING) + + theme = dpg.get_item_theme(compose_tag(TAG_SETTINGS_RENDER_BUTTON_CANCEL, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + def test_leaving_the_setup_closes_the_dialog(self, window: GUIRenderWindow) -> None: closed: List[None] = [] stopped: List[None] = [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py index d840933f3..b59bd6ec2 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -60,7 +60,6 @@ def recording_row(path: Path) -> StemRowViewModel: held=(), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=0, @@ -81,7 +80,6 @@ def folder_row(root: Path, held: Sequence[Path]) -> StemRowViewModel: held=tuple(recording_row(path) for path in held), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=0, diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 964cff257..73b85a47c 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -111,7 +111,6 @@ def row( held=(), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=level, @@ -133,7 +132,6 @@ def folder(name: str, *, holds: int) -> StemRowViewModel: held=tuple(row(f"{name}/take_{index}") for index in range(holds)), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), - bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=0, diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py index d1ef2d3f8..ec1117749 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py @@ -1,4 +1,4 @@ -from typing import Iterator, Optional +from typing import Iterator, List, Optional import dearpygui.dearpygui as dpg import pytest @@ -13,8 +13,12 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.reconstructions import ( - TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -36,8 +40,7 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) -from sampletones_core.configs.display import format_nes_frequency -from sampletones_shared.constants.nes import PAL_FREQUENCY +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, NTSC_FREQUENCY, PAL_FREQUENCY ROOT_TAG = "test_root" @@ -85,20 +88,35 @@ def rendered_panel(panel: GUIReconstructionAudioPanel) -> GUIReconstructionAudio return panel -def _view_model(nes_frequency: Optional[int]) -> ReconstructionViewModel: +def _view_model( + nes_frequency: Optional[int], + *, + file_state: ReconstructionPathState = ReconstructionPathState.AVAILABLE, +) -> ReconstructionViewModel: empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()) + loaded = nes_frequency is not None return ReconstructionViewModel( - reconstruction_loaded=nes_frequency is not None, + reconstruction_loaded=loaded, playing_channels=frozenset(), selected_channels=frozenset(), - reconstruction_file=empty_path, + reconstruction_file=(ReconstructionPathViewModel(state=file_state, paths=()) if loaded else empty_path), original_audio=empty_path, nes_frequency=nes_frequency, ) -class TestEngineRateReadout: - """The rate the card states for the open reconstruction.""" +def _commit(rendered_panel: GUIReconstructionAudioPanel, value: int) -> None: + """Types a value into the field and finishes the edit the way DearPyGui reports it.""" + dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, value) + handler = dpg.get_item_children( + compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, SUF_HANDLER_REGISTRY), + 1, + )[0] + dpg.get_item_callback(handler)(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, value) + + +class TestEngineRateField: + """The rate the card states for the open reconstruction, which a reader can change.""" def test_a_loaded_reconstruction_states_its_rate( self, @@ -106,23 +124,77 @@ def test_a_loaded_reconstruction_states_its_rate( ) -> None: rendered_panel.update_view(_view_model(PAL_FREQUENCY)) - assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == format_nes_frequency( - PAL_FREQUENCY, - ) + assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) == PAL_FREQUENCY - def test_an_empty_tab_leaves_the_readout_blank( + def test_an_empty_tab_shows_no_field( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: rendered_panel.update_view(_view_model(PAL_FREQUENCY)) rendered_panel.update_view(_view_model(None)) - assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == "" + assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) def test_the_rate_reads_as_a_monospaced_figure( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: - readout = dpg.get_item_info(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) + field = dpg.get_item_info(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) + + assert field["font"] == FontRegistry.get_tag(Font.MONO) + + def test_a_document_with_a_file_takes_a_new_rate( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY)) + + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + + def test_a_document_detached_from_its_file_locks_the_field_and_says_why( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, file_state=ReconstructionPathState.NOT_APPLICABLE)) + + assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + + def test_the_lock_lifts_when_a_document_with_a_file_opens( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, file_state=ReconstructionPathState.NOT_APPLICABLE)) + rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + + def test_a_finished_edit_reports_the_rate_typed( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + reported: List[int] = [] + rendered_panel.on_nes_frequency_changed = reported.append + rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + + _commit(rendered_panel, PAL_FREQUENCY) + + assert reported == [PAL_FREQUENCY] + + def test_a_rate_out_of_range_is_reported_at_the_nearest_bound( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + reported: List[int] = [] + rendered_panel.on_nes_frequency_changed = reported.append + rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + + _commit(rendered_panel, MAX_NES_FREQUENCY + 1) - assert readout["font"] == FontRegistry.get_tag(Font.MONO) + assert reported == [MAX_NES_FREQUENCY] diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py index 2bb48ba5e..b87f7db7f 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import FrozenSet, Iterator, List, Tuple +from typing import Any, Dict, FrozenSet, Iterator, List, Tuple import dearpygui.dearpygui as dpg import pytest @@ -24,6 +24,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.reconstruction import stems_menu as menu_module from sampletones_application.ui.panels.reconstruction.stems import ( GUIReconstructionStemsPanel, ) @@ -39,8 +40,11 @@ StemsListViewModel, ) from sampletones_core.constants.enums import ChannelName, HierarchyMode +from tests.suite.gestures import CLICKED, click_row_name ROOT_TAG = "test_root" +SEPARATOR = "separator" +LANGUAGE_MANAGER = LanguageManager(LANG_EN) CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.NOISE) @@ -89,7 +93,6 @@ def _row( *, name: str, channels: FrozenSet[ChannelName] = frozenset(CHANNELS), - bends: FrozenSet[ChannelName] = frozenset(), offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), level: int = 0, position: int = 0, @@ -100,7 +103,6 @@ def _row( kind=SourceKind.RECORDING, held=(), partial_channels=frozenset(), - bends=bends, key=str(stem_id), name=name, path=Path(f"/audio/{name}.wav"), @@ -292,6 +294,22 @@ def test_a_reader_who_collapsed_the_levels_keeps_them_collapsed_across_an_edit( assert dpg.does_item_exist(panel.stems_list.tags.table) +class TestStemsPanelHeader: + def test_the_card_leads_with_the_glyph_of_stems( + self, + panel: GUIReconstructionStemsPanel, + layout_config: LayoutConfig, + ) -> None: + render(panel) + + texts = [ + dpg.get_value(item) for item in dpg.get_all_items() if dpg.get_item_type(item) == "mvAppItemType::mvText" + ] + + assert layout_config.glyphs.headers.stems in texts + assert layout_config.glyphs.headers.stems != layout_config.glyphs.headers.source + + class TestStemsPanelStates: def test_the_setup_line_states_the_mode(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) @@ -314,57 +332,142 @@ def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source( assert not dpg.is_item_shown(panel.stems_list.tag) -class TestTheBendARecordingTook: - """A finished reconstruction records the bend each recording carried, which the list states. +@pytest.fixture +def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, in the order a reader meets them, the rules between them included.""" + items: List[Dict[str, Any]] = [] + monkeypatch.setattr(menu_module.dpg, "add_menu_item", lambda **kwargs: items.append(kwargs) or 0) + monkeypatch.setattr( + menu_module.dpg, + "add_separator", + lambda **_kwargs: items.append({"label": SEPARATOR}) or 0, + ) + return items + + +def right_click(panel: GUIReconstructionStemsPanel, stem_id: int) -> None: + click_row_name(panel.stems_list.tags, str(stem_id), kind=CLICKED, button=dpg.mvMouseButton_Right) + + +def item(registered: List[Dict[str, Any]], key: str) -> Dict[str, Any]: + label = LANGUAGE_MANAGER[key] + return next(entry for entry in registered if entry["label"] == label) - The choice was made when the reconstruction was written, so the box reports it rather than - offering it. - """ - @staticmethod - def _bend_tag(panel: GUIReconstructionStemsPanel, stem_id: int, channel_name: ChannelName) -> str: - return panel.stems_list.tags.bend(str(stem_id), channel_name) +class TestTheMenuARightClickPutsUp: + """A right-click on a recording offers to mute or solo it, beside the file items.""" - def test_a_tone_channel_carries_a_box_beside_its_own( + def test_a_right_click_offers_what_a_recording_can_be_told( self, panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], ) -> None: render(panel) - panel.update_view(_view_model(_row(1, name="bass"))) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 0) - assert dpg.does_item_exist(self._bend_tag(panel, 1, ChannelName.PULSE1)) + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_mute"] in labels + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"] in labels + assert LANGUAGE_MANAGER["global.context.label.open_in_explorer"] in labels - def test_it_reads_the_bend_the_recording_took( + def test_a_row_heard_offers_to_mute_and_reports_what_it_keeps( self, panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], ) -> None: + reported: List[Tuple[int, FrozenSet[ChannelName]]] = [] + panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels)) render(panel) - panel.update_view(_view_model(_row(1, name="bass", bends=frozenset({ChannelName.PULSE1})))) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 0) + item(registered, "reconstructions.reconstruction.label.stem_mute")["callback"]() - assert dpg.get_value(self._bend_tag(panel, 1, ChannelName.PULSE1)) is True + assert reported == [(0, frozenset())] - def test_a_channel_it_did_not_bend_reads_clear( + def test_a_row_muted_offers_to_unmute_across_every_channel_it_offers( self, panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], ) -> None: + reported: List[Tuple[int, FrozenSet[ChannelName]]] = [] + panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels)) render(panel) - panel.update_view(_view_model(_row(1, name="bass"))) + panel.update_view(_view_model(_row(0, name="kick", channels=frozenset()), _row(1, name="snare"))) + + right_click(panel, 0) + item(registered, "reconstructions.reconstruction.label.stem_unmute")["callback"]() - assert dpg.get_value(self._bend_tag(panel, 1, ChannelName.PULSE1)) is False + assert reported == [(0, frozenset(CHANNELS))] - def test_the_box_states_rather_than_asks(self, panel: GUIReconstructionStemsPanel) -> None: + def test_solo_reports_the_recording_it_landed_on( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + soloed: List[int] = [] + panel.on_stem_solo_requested = soloed.append render(panel) - panel.update_view(_view_model(_row(1, name="bass"))) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 1) + item(registered, "reconstructions.reconstruction.label.stem_solo")["callback"]() + + assert soloed == [1] + + def test_a_recording_heard_alone_offers_to_unsolo( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare", channels=frozenset()))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_unsolo"] in labels + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"] not in labels + + def test_a_lone_recording_has_none_to_solo_against( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"))) + + right_click(panel, 0) - tag = self._bend_tag(panel, 1, ChannelName.PULSE1) - assert dpg.get_item_configuration(tag)["enabled"] is False + assert item(registered, "reconstructions.reconstruction.label.stem_solo")["enabled"] is False - def test_a_channel_loading_no_divider_carries_none( + def test_a_row_standing_for_the_readers_frames_offers_no_file_items( self, panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], ) -> None: - """A bend moves a note by a fraction of a divider, so the noise channel has nothing to bend.""" + edits = _row(0, name="edits").model_copy(update={"kind": SourceKind.EDITS, "path": None}) render(panel) - panel.update_view(_view_model(_row(1, name="bass"))) + panel.update_view(_view_model(edits, _row(1, name="snare"))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_mute"] in labels + assert LANGUAGE_MANAGER["global.context.label.open_in_explorer"] not in labels + + def test_a_rule_divides_what_a_recording_is_told_from_its_file( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 0) - assert not dpg.does_item_exist(self._bend_tag(panel, 1, ChannelName.NOISE)) + labels = [entry["label"] for entry in registered] + solo = labels.index(LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"]) + assert labels[solo + 1] == SEPARATOR diff --git a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py index 11738fc7a..136544633 100644 --- a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py +++ b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py @@ -118,6 +118,34 @@ def test_leaves_field_stops_without_an_outline(self) -> None: registry.get.assert_not_called() + def test_a_button_styled_beyond_the_default_keeps_its_own_look_while_focused(self) -> None: + """A themed button, such as a danger one, names its own focused variant, so the accent + border a stop takes on carries that button's colors forward rather than the plain + default look every other stop shares.""" + stops = [ + FocusStop.field("title"), + FocusStop.button( + "cancel", + MagicMock(), + base_theme_tag="global.theme.danger_button", + focused_theme_tag="global.theme.danger_button_focused", + ), + ] + ring = FocusRing(stops, initial_index=0) + + themes: Dict[str, MagicMock] = defaultdict(MagicMock) + registry = MagicMock() + registry.get.side_effect = lambda tag: themes[tag] + + with patch(f"{MODULE}.dpg", _dpg()), patch(f"{MODULE}.ThemeRegistry", registry): + ring.focus_initial() + ring.cycle(1) + ring.cycle(1) + + themes["global.theme.danger_button_focused"].bind_to_item.assert_any_call("cancel.button") + themes["global.theme.danger_button"].bind_to_item.assert_any_call("cancel.button") + themes[TAG_GLOBAL_THEME_FOCUSED_BUTTON].bind_to_item.assert_not_called() + class TestActivateFocused: def test_activates_the_focused_button(self) -> None: diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 2aafe0560..583deac89 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -33,7 +33,6 @@ def _row( kind=SourceKind.RECORDING, held=(), partial_channels=frozenset(), - bends=frozenset(), key=str(path), name=path.stem, path=path, diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 0a12c4b71..736f51dd4 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -112,6 +112,44 @@ def test_enablement_follows_original_audio_state( assert view_model.show_locate_audio_hint is case.show_locate_audio_hint +class TestReconstructionViewModelNesFrequency: + """The rate is the tab's to change while the document has a file, and the project's once detached.""" + + @staticmethod + def _view_model(*, loaded: bool, file_state: ReconstructionPathState) -> ReconstructionViewModel: + return ReconstructionViewModel( + reconstruction_loaded=loaded, + playing_channels=frozenset(), + selected_channels=frozenset(), + reconstruction_file=ReconstructionPathViewModel(state=file_state, paths=()), + original_audio=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), + nes_frequency=None, + ) + + @pytest.mark.parametrize( + "file_state", + [ReconstructionPathState.AVAILABLE, ReconstructionPathState.NOT_FOUND], + ids=lambda state: state.name.lower(), + ) + def test_a_document_with_a_file_takes_a_new_rate(self, file_state: ReconstructionPathState) -> None: + view_model = self._view_model(loaded=True, file_state=file_state) + + assert view_model.nes_frequency_editable + assert not view_model.show_nes_frequency_hint + + def test_a_document_detached_from_its_file_follows_the_project(self) -> None: + view_model = self._view_model(loaded=True, file_state=ReconstructionPathState.NOT_APPLICABLE) + + assert not view_model.nes_frequency_editable + assert view_model.show_nes_frequency_hint + + def test_an_empty_tab_takes_and_explains_nothing(self) -> None: + view_model = self._view_model(loaded=False, file_state=ReconstructionPathState.EMPTY) + + assert not view_model.nes_frequency_editable + assert not view_model.show_nes_frequency_hint + + class TestReconstructionPathViewModelPath: def test_single_path_is_the_location(self) -> None: view_model = ReconstructionPathViewModel( diff --git a/tests/unit/sampletones_application/view_model/shared/test_stems.py b/tests/unit/sampletones_application/view_model/shared/test_stems.py new file mode 100644 index 000000000..2317a197b --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_stems.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import FrozenSet + +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.stems import StemRowViewModel, StemsListViewModel +from sampletones_core.constants.enums import ChannelName + +OFFERED: FrozenSet[ChannelName] = frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + +def _row(name: str, *, heard: FrozenSet[ChannelName], offered: FrozenSet[ChannelName] = OFFERED) -> StemRowViewModel: + return StemRowViewModel( + key=name, + kind=SourceKind.RECORDING, + name=name, + path=Path(f"/audio/{name}.wav"), + held=(), + channels=heard, + partial_channels=frozenset(), + offered_channels=offered, + available=True, + level=0, + position=0, + record_position=0, + level_size=1, + level_count=1, + ) + + +def _view(*rows: StemRowViewModel) -> StemsListViewModel: + return StemsListViewModel.empty().model_copy(update={"rows": rows}) + + +class TestARowHeardAlone: + def test_the_only_row_holding_every_channel_it_offers_stands_soloed(self) -> None: + view = _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset())) + + assert view.soloed("kick") + assert not view.soloed("snare") + + def test_a_row_heard_on_part_of_what_it_offers_is_not_soloed(self) -> None: + view = _view(_row("kick", heard=frozenset({ChannelName.PULSE1})), _row("snare", heard=frozenset())) + + assert not view.soloed("kick") + + def test_a_row_heard_beside_another_is_not_soloed(self) -> None: + view = _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset({ChannelName.NOISE}))) + + assert not view.soloed("kick") + + def test_a_list_holding_a_single_row_has_none_to_silence(self) -> None: + view = _view(_row("kick", heard=OFFERED)) + + assert not view.soloed("kick") + + def test_a_row_offering_no_channel_is_not_soloed(self) -> None: + view = _view( + _row("kick", heard=frozenset(), offered=frozenset()), + _row("snare", heard=frozenset()), + ) + + assert not view.soloed("kick") + + def test_a_key_the_list_does_not_hold_is_not_soloed(self) -> None: + assert not _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset())).soloed("hat") diff --git a/tests/unit/sampletones_core/exporters/test_skipped.py b/tests/unit/sampletones_core/exporters/test_skipped.py new file mode 100644 index 000000000..761485ee3 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_skipped.py @@ -0,0 +1,99 @@ +from typing import Dict, List, Optional, Set, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow, find_skipped_rows +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn + +ROWS_PER_PATTERN = 4 +KNOWN = "known" +UNKNOWN = "unknown" + + +def _song( + channels: Dict[ChannelName, Dict[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Song: + pools = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in channels.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + return Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=pools) + + +def _rows(**commands: NoteOn) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for position, command in commands.items(): + rows[int(position.removeprefix("row_"))] = Row(command=command) + + return rows + + +INSTRUMENTS: Set[Tuple[str, ChannelName]] = {(KNOWN, ChannelName.PULSE1)} + + +class TestFindingTheRowsLeftSilent: + def test_a_note_on_naming_a_voice_without_an_instrument_on_its_channel_is_found(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_1=NoteOn(voice_id=KNOWN))}}, + [{ChannelName.PULSE2: 0}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == ( + SkippedRow(voice_id=KNOWN, channel=ChannelName.PULSE2, order_position=0, row_index=1), + ) + + def test_a_note_on_with_an_instrument_on_its_channel_is_left_alone(self) -> None: + song = _song( + {ChannelName.PULSE1: {0: _rows(row_1=NoteOn(voice_id=KNOWN))}}, + [{ChannelName.PULSE1: 0}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_a_note_off_is_left_alone(self) -> None: + rows = _rows() + rows[2] = Row(command=NoteOff()) + song = _song({ChannelName.PULSE2: {0: rows}}, [{ChannelName.PULSE2: 0}]) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_a_pattern_the_order_plays_twice_is_found_in_each_frame(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}}, + [{ChannelName.PULSE2: 0}, {ChannelName.PULSE2: None}, {ChannelName.PULSE2: 0}], + ) + + assert [row.order_position for row in find_skipped_rows(song, INSTRUMENTS)] == [0, 2] + + def test_a_pattern_no_frame_plays_is_not_found(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}}, + [{ChannelName.PULSE2: None}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_the_rows_come_in_the_order_the_song_plays_them(self) -> None: + song = _song( + { + ChannelName.PULSE2: {0: _rows(row_3=NoteOn(voice_id=UNKNOWN))}, + ChannelName.TRIANGLE: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}, + }, + [{ChannelName.PULSE2: 0, ChannelName.TRIANGLE: 0}, {ChannelName.PULSE2: 0}], + ) + + found = [(row.order_position, row.channel, row.row_index) for row in find_skipped_rows(song, INSTRUMENTS)] + + assert found == [ + (0, ChannelName.PULSE2, 3), + (0, ChannelName.TRIANGLE, 0), + (1, ChannelName.PULSE2, 3), + ] diff --git a/tests/unit/sampletones_core/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py index a054f116a..f5724c186 100644 --- a/tests/unit/sampletones_core/exports/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -26,6 +26,7 @@ from sampletones_core.project.settings import ProjectSettings from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from tests.suite.silent_rows import name_a_missing_voice NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 @@ -181,6 +182,30 @@ def test_the_document_takes_the_project_title( assert read_document(destination)["name"] == PROJECT_TITLE + def test_a_row_with_no_instrument_is_reported_beside_the_written_file( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + silent = name_a_missing_voice(project) + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert destination.exists() + assert artifact.skipped_rows == (silent,) + + def test_a_project_with_an_instrument_for_every_row_reports_none( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_BITPHASE}", ProjectExport(project=project)) + + assert artifact.skipped_rows == () + class TestThePresetBackend: def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index 645831344..db280df0f 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -10,17 +10,20 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend from sampletones_core.exports.progress import ExportProgress -from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.request import InstrumentExport, ProjectExport, SampleExport from sampletones_core.exports.scope import ExportScope from sampletones_core.exports.stage import ExportStage from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from tests.suite.progress import RecordingReporter +from tests.suite.silent_rows import name_a_missing_voice NES_FREQUENCY: Final[int] = 60 ENVELOPE_FRAMES: Final[int] = 4 @@ -177,6 +180,35 @@ def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, t assert artifact.truncation is None +class TestWriteProject: + @staticmethod + def _project() -> Project: + return Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + + def test_a_row_with_no_instrument_is_reported_beside_the_written_file( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_MODULE}" + project = self._project() + silent = name_a_missing_voice(project) + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert destination.exists() + assert artifact.skipped_rows == (silent,) + + def test_a_project_with_an_instrument_for_every_row_reports_none( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_MODULE}", ProjectExport(project=self._project())) + + assert artifact.skipped_rows == () + + class TestWhatABatchSaysAboutItself: """A reconstruction lands as a file per slice, so the run counts them as it writes them.""" diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index e68b28d3c..7cd2c8e88 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -10,7 +10,8 @@ ChannelName, ) from sampletones_core.constants.general import SILENT_VOLUME -from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.formats.bitphase.builder import build_bitphase, project_to_bitphase from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.notes import ( @@ -318,15 +319,51 @@ def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: assert all(row.effects == (None,) for channel in channels for row in channel.rows) -class TestAnUnbuildableRow: - def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: +class TestARowWithNoInstrumentOnItsChannel: + """A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays + nothing in the song. The export writes a note cut there and reports the row.""" + + @staticmethod + def _with_lead_on_pulse2(source: Project, lead: Sample) -> None: rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] - rows[TRIGGER_ROW] = Row(command=NoteOn(voice_id=lead.id)) + rows[TRIGGER_ROW] = Row(command=NoteOn(voice_id=lead.id), volume=ROW_VOLUME) source.song.channels[ChannelName.PULSE2] = Channel( name=ChannelName.PULSE2, patterns={0: Pattern(rows=rows)}, ) source.song.order[0][ChannelName.PULSE2] = 0 - with pytest.raises(ValueError, match="with no instrument"): - project_to_bitphase(source) + def test_the_row_is_written_as_a_note_cut(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + + document = project_to_bitphase(source) + + cut = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE2)].rows[TRIGGER_ROW] + assert cut.note is not None + assert cut.note.name == int(NoteName.OFF) + assert cut.volume == ROW_VOLUME + + def test_the_row_is_reported_where_the_tracker_shows_it(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + + skipped = build_bitphase(source).skipped_rows + + assert skipped == ( + SkippedRow( + voice_id=lead.id, + channel=ChannelName.PULSE2, + order_position=0, + row_index=TRIGGER_ROW, + ), + ) + + def test_a_pattern_the_order_plays_twice_reports_each_frame(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + source.song.order[1][ChannelName.PULSE2] = 0 + + skipped = build_bitphase(source).skipped_rows + + assert [row.order_position for row in skipped] == [0, 1] + + def test_a_project_naming_only_voices_with_instruments_reports_nothing(self, source: Project) -> None: + assert build_bitphase(source).skipped_rows == () diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index aea4158a4..cab64ca40 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -2,8 +2,10 @@ import pytest from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.formats.famitracker.builder import ( build_instrument_table, + build_module, project_to_module, ) from sampletones_core.formats.famitracker.specification.channels import ( @@ -27,7 +29,11 @@ SequenceKind, ) from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction @@ -177,6 +183,67 @@ def test_noise_row_uses_period_note(self, project_fixture: ProjectFixture) -> No assert first.octave == 0 +class TestARowWithNoInstrumentOnItsChannel: + """A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays + nothing in the song. The export writes a note cut there and reports the row.""" + + DRUM_ROW = 3 + + def _with_drum_on_pulse2(self, project_fixture: ProjectFixture) -> None: + rows = [Row() for _ in range(8)] + rows[self.DRUM_ROW] = Row(command=NoteOn(voice_id=project_fixture.drum.id), volume=9) + song = project_fixture.project.song + song.channels[ChannelName.PULSE2] = Channel(name=ChannelName.PULSE2, patterns={0: Pattern(rows=rows)}) + song.order[0][ChannelName.PULSE2] = 0 + + def test_the_row_is_written_as_a_note_cut(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + + module = project_to_module(project_fixture.project) + + pattern = next( + pattern for pattern in module.track.patterns if pattern.channel == ChannelId.SQUARE2 and pattern.index == 0 + ) + cut = next(row for row in pattern.rows if row.row_number == self.DRUM_ROW) + assert cut.note == int(NoteValue.HALT) + assert cut.instrument == EMPTY_INSTRUMENT + assert cut.volume == 9 + + def test_the_row_is_reported_where_the_tracker_shows_it(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + + skipped = build_module(project_fixture.project).skipped_rows + + assert skipped == ( + SkippedRow( + voice_id=project_fixture.drum.id, + channel=ChannelName.PULSE2, + order_position=0, + row_index=self.DRUM_ROW, + ), + ) + + def test_a_pattern_the_order_plays_twice_reports_each_frame(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + project_fixture.project.song.order[1][ChannelName.PULSE2] = 0 + + skipped = build_module(project_fixture.project).skipped_rows + + assert [row.order_position for row in skipped] == [0, 1] + + def test_a_pattern_no_frame_plays_reports_nothing(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + project_fixture.project.song.order[0][ChannelName.PULSE2] = None + + assert build_module(project_fixture.project).skipped_rows == () + + def test_a_project_naming_only_voices_with_instruments_reports_nothing( + self, + project_fixture: ProjectFixture, + ) -> None: + assert build_module(project_fixture.project).skipped_rows == () + + class TestProjectToModuleOrder: def test_order_has_one_entry_per_channel(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py index c08d2b35a..b1dc55ef3 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py @@ -16,6 +16,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 sampletones_shared.exceptions import SampleToNESError LEAD: Final[int] = 0 BASS: Final[int] = 1 @@ -114,6 +115,21 @@ def test_it_carries_its_names_through_a_round_trip(self, tmp_path: Path) -> None assert restored.audio_filepath == () +class TestReadingTheRecordingsOnTheirOwn: + def test_the_record_reads_as_the_whole_document_states_it(self, tmp_path: Path) -> None: + path = tmp_path / "stems.stn" + _reconstruction().save(path) + + assert Reconstruction.read_stems_data(path) == Reconstruction.load(path).stems_data + + def test_a_document_stating_nothing_readable_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "broken.stn" + path.write_bytes(b"not a document") + + with pytest.raises(SampleToNESError): + Reconstruction.read_stems_data(path) + + class TestALocationOneRecordingHasLost: def test_the_whole_set_reads_as_unlocated(self) -> None: """One unreadable recording costs the whole original, so a partial set offers none.""" diff --git a/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py b/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py index f9b7c6a82..901e85ff2 100644 --- a/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py +++ b/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py @@ -37,6 +37,15 @@ def _names(self) -> None: print(name) """ +DECLARED_ATTRIBUTE_SOURCE: Final[str] = """ +class Browser(Panel): + _language_manager: LanguageManager + _plain = 1 + + def _menu_label(self, element: MenuElements) -> str: + return self._language_manager[element] +""" + IMPORTED_SOURCE: Final[str] = """ def create() -> None: for export_format, element in EXPORT_FILTERS.items(): @@ -133,6 +142,19 @@ def test_the_spellings_of_a_type_leave_other_names_aside(self) -> None: assert "element" not in panel_environment("_menu_label").spellings_of("LanguageManager") +class TestAttributesAClassBodyDeclares: + def test_a_declared_attribute_states_its_type_to_every_method(self) -> None: + """A class inheriting an attribute declares what it holds rather than assigning it again.""" + environment = environment_of(DECLARED_ATTRIBUTE_SOURCE, "_menu_label", {}) + + assert environment.type_of("self._language_manager") == "LanguageManager" + + def test_an_attribute_the_body_only_assigns_states_nothing(self) -> None: + environment = environment_of(DECLARED_ATTRIBUTE_SOURCE, "_menu_label", {}) + + assert environment.type_of("self._plain") is None + + class TestLoopTargets: def test_walking_items_states_the_key_and_the_value_type(self) -> None: environment = panel_environment("_filters")