From e7c91246e3bd3c2b14bc59f0983306d0fb54d76b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 23:05:04 +0200 Subject: [PATCH 01/20] Implemented: shared tracker backend --- src/sampletones_application/application.py | 9 +- .../coordinators/project.py | 5 +- .../coordinators/tabs/reconstruction.py | 13 +- .../logic/project/controller.py | 8 +- .../logic/reconstruction/reconstruction.py | 67 ++++-- .../services/__init__.py | 2 - .../services/export/service.py | 91 ++++---- .../services/export/success.py | 6 +- .../services/export/truncation.py | 45 ---- src/sampletones_core/exporters/feature.py | 45 ---- src/sampletones_core/exporters/lengths.py | 66 ++++++ src/sampletones_core/exporters/truncation.py | 60 +++++ .../famitracker/sequences/features.py | 9 +- .../famitracker/sequences/lengths.py | 65 ------ .../famitracker/sequences/truncation.py | 35 --- .../famitracker/specification/instruments.py | 2 + src/sampletones_core/paths.py | 1 + src/sampletones_core/trackers/__init__.py | 0 src/sampletones_core/trackers/artifact.py | 19 ++ src/sampletones_core/trackers/backend.py | 105 +++++++++ src/sampletones_core/trackers/famitracker.py | 100 ++++++++ src/sampletones_core/trackers/format.py | 7 + src/sampletones_core/trackers/registry.py | 18 ++ src/sampletones_core/trackers/request.py | 47 ++++ src/sampletones_core/trackers/scope.py | 24 ++ .../services/test_export.py | 70 ++++-- .../coordinators/tabs/test_reconstruction.py | 8 +- .../coordinators/test_project.py | 1 + .../reconstruction/test_reconstruction.py | 21 +- .../services/export/test_result.py | 4 +- .../services/export/test_service.py | 220 ++++++++++-------- .../services/export/test_truncation.py | 20 -- .../exporters/test_feature.py | 19 -- .../exporters/test_lengths.py | 83 +++++++ .../exporters/test_truncation.py | 42 ++++ .../famitracker/sequences/test_lengths.py | 76 ------ .../famitracker/sequences/test_truncation.py | 18 -- .../sampletones_core/trackers/__init__.py | 0 .../trackers/test_famitracker.py | 171 ++++++++++++++ 39 files changed, 1074 insertions(+), 528 deletions(-) delete mode 100644 src/sampletones_application/services/export/truncation.py create mode 100644 src/sampletones_core/exporters/lengths.py create mode 100644 src/sampletones_core/exporters/truncation.py delete mode 100644 src/sampletones_core/famitracker/sequences/lengths.py delete mode 100644 src/sampletones_core/famitracker/sequences/truncation.py create mode 100644 src/sampletones_core/trackers/__init__.py create mode 100644 src/sampletones_core/trackers/artifact.py create mode 100644 src/sampletones_core/trackers/backend.py create mode 100644 src/sampletones_core/trackers/famitracker.py create mode 100644 src/sampletones_core/trackers/format.py create mode 100644 src/sampletones_core/trackers/registry.py create mode 100644 src/sampletones_core/trackers/request.py create mode 100644 src/sampletones_core/trackers/scope.py delete mode 100644 tests/unit/sampletones_application/services/export/test_truncation.py create mode 100644 tests/unit/sampletones_core/exporters/test_lengths.py create mode 100644 tests/unit/sampletones_core/exporters/test_truncation.py delete mode 100644 tests/unit/sampletones_core/famitracker/sequences/test_lengths.py delete mode 100644 tests/unit/sampletones_core/famitracker/sequences/test_truncation.py create mode 100644 tests/unit/sampletones_core/trackers/__init__.py create mode 100644 tests/unit/sampletones_core/trackers/test_famitracker.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 0a54caf3..1f4da232 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, Final, Optional +from typing import Any, Dict, Final, Optional import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -129,6 +129,9 @@ from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.types.feature import FeatureValue from sampletones_shared.application import ( SAMPLETONES_AUTHOR, @@ -207,6 +210,8 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) + self.tracker_backends: Dict[TrackerFormat, TrackerBackend] = build_tracker_backends() + self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) self.history: HistoryManager = HistoryManager( @@ -267,6 +272,7 @@ def __init__( self.project_controller, self.project_manager, self.session_manager, + export_backend=self.tracker_backends[TrackerFormat.FAMITRACKER], dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -298,6 +304,7 @@ def __init__( reconstruction_manager=self.reconstruction_manager, browser_manager=self.browser_manager, export_service=self.export_service, + export_backend=self.tracker_backends[TrackerFormat.FAMITRACKER], on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, on_reconstruct_file=self._reconstruct_file_dialog, on_reconstruct_directory=self._reconstruct_directory_dialog, diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index e89d59ce..2b2a46ed 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -26,6 +26,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.paths import EXT_FILE_MODULE, EXT_FILE_PROJECT +from sampletones_core.trackers.backend import TrackerBackend from sampletones_shared.constants.project import ( DEFAULT_MODULE_FILENAME, DEFAULT_PROJECT_FILENAME, @@ -57,6 +58,7 @@ def __init__( project_manager: ProjectManager, session_manager: SessionManager, *, + export_backend: TrackerBackend, dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -65,6 +67,7 @@ def __init__( self._project_controller = project_controller self._project_manager = project_manager self._session_manager = session_manager + self._export_backend = export_backend self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch @@ -259,7 +262,7 @@ def _save(self, filepath: Path) -> bool: def _export_module(self, filepath: Path) -> None: try: - self._project_controller.export_module(filepath) + self._project_controller.export_project(filepath, self._export_backend) except (ValueError, OSError) as exception: logger.error_with_traceback( exception, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index a0d2f872..40eef05b 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -41,7 +41,6 @@ from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.tags.general import ( SUF_PANEL_CENTER, SUF_PANEL_LEFT, @@ -85,7 +84,10 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_WAVE +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -111,6 +113,7 @@ def __init__( reconstruction_manager: ReconstructionManager, browser_manager: BrowserManager, export_service: ExportService, + export_backend: TrackerBackend, on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_reconstruct_file: VoidCallback, on_reconstruct_directory: VoidCallback, @@ -126,6 +129,7 @@ def __init__( ) -> None: self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager + self._export_backend = export_backend self._dialogs = dialogs self._original_audio_locator = original_audio_locator @@ -302,6 +306,7 @@ def __init__( session_manager, reconstruction_manager, export_service, + export_backend, ) self._reconstruction_instruments_panel: GUIReconstructionInstrumentsPanel = GUIReconstructionInstrumentsPanel( pitch_stepper_style=layout.pitch_stepper_style, @@ -416,7 +421,7 @@ def _export_message( self, success: str, shortened: str, - truncation: Optional[ExportTruncation], + truncation: Optional[EnvelopeTruncation], ) -> str: """Follows the success line with the frames the FamiTracker sequence limit left out. @@ -455,7 +460,7 @@ def _open_export_instrument_dialog( title=self._ttl_export_instrument, initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_INSTRUMENT], + extensions=[self._export_backend.extension(ExportScope.INSTRUMENT)], filter_name=self._filter_export_instrument, ) self._handle_export_instrument(filepath) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 5c52be87..4238a3fd 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -3,12 +3,13 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE -from sampletones_core.famitracker.export import write_ftm from sampletones_core.project import Project from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.request import ProjectExport from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.callbacks import CallbackMixin @@ -94,8 +95,9 @@ def replace_project(self, project: Project, *, clean: bool) -> None: self._project_manager.install(project, clean=clean) self.call(self.on_project_replaced) - def export_module(self, path: Path) -> None: - write_ftm(path, self.project) + def export_project(self, path: Path, backend: TrackerBackend) -> None: + """Writes the current project in the format ``backend`` produces.""" + backend.write_project(path, ProjectExport(project=self.project)) def mark_updated(self) -> None: self._touch() diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 3c44c792..22318490 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -14,8 +14,9 @@ from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_INSTRUMENT +from sampletones_core.exporters.feature import Features +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -29,11 +30,26 @@ class ExportServiceProtocol(Protocol): the service implementation; the composition root supplies the real service. """ - def export_wav(self, filepath: Path, sample_rate: int, audio: np.ndarray) -> None: ... + def export_wav( + self, + filepath: Path, + sample_rate: int, + audio: np.ndarray, + ) -> None: ... - def export_instrument(self, filepath: Path, instrument_name: str, feature: Features) -> None: ... + def export_instrument( + self, + destination: Path, + backend: TrackerBackend, + request: InstrumentExport, + ) -> None: ... - def export_instruments(self, directory: Path, exports: List[Tuple[Path, str, Features]]) -> None: ... + def export_sample( + self, + destination: Path, + backend: TrackerBackend, + request: SampleExport, + ) -> None: ... class ReconstructionPanelLogic(CallbackMixin): @@ -42,10 +58,12 @@ def __init__( session_manager: SessionManager, reconstruction_manager: ReconstructionManager, export_service: ExportServiceProtocol, + export_backend: TrackerBackend, ) -> None: self._session_manager = session_manager self._reconstruction_manager = reconstruction_manager self._export_service = export_service + self._export_backend = export_backend self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._selected_generators: List[GeneratorName] = [] @@ -191,15 +209,14 @@ def handle_export_instrument_confirmed(self, filepath: Path) -> None: return generator_name = self._pending_generator_name - instrument_name = self._get_instrument_name(generator_name) feature = self._reconstruction_data.feature_data[generator_name] self._pending_generator_name = None self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, - instrument_name, - feature, + self._export_backend, + self._instrument_export(generator_name, feature), ) def handle_export_instruments_confirmed(self, directory: Path) -> None: @@ -208,16 +225,32 @@ def handle_export_instruments_confirmed(self, directory: Path) -> None: logger.warning("No reconstruction data available for instruments export") return - exports = [ - ( - directory / f"{self._get_instrument_name(gen_name)}{EXT_FILE_INSTRUMENT}", - self._get_instrument_name(gen_name), - feature, - ) - for gen_name, feature in reconstruction_data.feature_data.generators.items() - ] + request = SampleExport( + name=reconstruction_data.name, + instruments=tuple( + self._instrument_export(generator_name, feature) + for generator_name, feature in reconstruction_data.feature_data.generators.items() + ), + ) self._session_manager.set_instrument_path(directory.parent) - self._export_service.export_instruments(directory, exports) + self._export_service.export_sample(directory, self._export_backend, request) + + def _instrument_export( + self, + generator_name: GeneratorName, + feature: Features, + ) -> InstrumentExport: + """Names one generator slice and packages it for a tracker backend. + + A reconstruction has no loop flag of its own — that belongs to a sample placed in + a project — so the instrument plays its envelopes once. + """ + return InstrumentExport( + name=self._get_instrument_name(generator_name), + generator=generator_name, + features=feature, + loop=False, + ) def handle_export_wav_confirmed(self, filepath: Path) -> None: reconstruction_data = self._reconstruction_data diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index bde18088..2ce762af 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -5,7 +5,6 @@ from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.services.regeneration import ( RegeneratedInstrument, RegenerationResult, @@ -30,7 +29,6 @@ "ExportResult", "ExportService", "ExportSuccess", - "ExportTruncation", "RegeneratedInstrument", "RegenerationResult", "RegenerationService", diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e35ca182..e02f294e 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -1,5 +1,6 @@ +from functools import partial from pathlib import Path -from typing import List, Optional, Tuple +from typing import Callable import numpy as np @@ -8,15 +9,22 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave -from sampletones_core.exporters import Features -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_shared.logger import logger class ExportService(ServiceBase[ExportResult]): + """Writes exports on a background thread and reports each outcome as a result. + + The tracker backend arrives per call, so the service stays free of any one file + format: it owns the thread boundary and the error boundary, and the backend owns + what lands on disk. + """ + def __init__(self, priority: int = 0) -> None: super().__init__(priority) self._executor = SingleThreadExecutor() @@ -51,56 +59,59 @@ def task() -> None: def export_instrument( self, - filepath: Path, - instrument_name: str, - feature: Features, + destination: Path, + backend: TrackerBackend, + request: InstrumentExport, ) -> None: - def task() -> None: - try: - truncation = feature.save(filepath, instrument_name) - logger.info(f"Exported FamiTracker instrument: {logger.format_path(filepath)}") - self._emit( - ExportSuccess( - kind=ExportKind.INSTRUMENT, - filepath=filepath, - truncation=ExportTruncation.summarize([truncation]), - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export instrument: {filepath}") - self._emit( - ExportError( - kind=ExportKind.INSTRUMENT, - exception=exception, - ) - ) + self._submit( + ExportKind.INSTRUMENT, + destination, + partial(backend.write_instrument, destination, request), + ) - self._executor.execute(task, wait=False) + def export_sample( + self, + destination: Path, + backend: TrackerBackend, + request: SampleExport, + ) -> None: + self._submit( + ExportKind.INSTRUMENTS, + destination, + partial(backend.write_sample, destination, request), + ) - def export_instruments( + def _submit( self, - directory: Path, - exports: List[Tuple[Path, str, Features]], + kind: ExportKind, + destination: Path, + write: Callable[[], ExportArtifact], ) -> None: + """Runs one backend write on the executor and reports what it produced. + + Args: + kind: The artefact the run produces, naming the dialog that reports it. + destination: The file written, or the directory a batch of instruments filled. + write: Calls the backend and returns what it left on disk. + """ + def task() -> None: try: - directory.mkdir(parents=True, exist_ok=True) - truncations: List[Optional[SequenceTruncation]] = [] - for filepath, instrument_name, feature in exports: - truncations.append(feature.save(filepath, instrument_name)) - logger.info(f"Exported FamiTracker instrument: {logger.format_path(filepath)}") + artifact = write() + for path in artifact.paths: + logger.info(f"Exported instrument: {logger.format_path(path)}") self._emit( ExportSuccess( - kind=ExportKind.INSTRUMENTS, - filepath=directory, - truncation=ExportTruncation.summarize(truncations), + kind=kind, + filepath=destination, + truncation=artifact.truncation, ) ) except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export instruments to: {directory}") + logger.error_with_traceback(exception, f"Failed to export to: {destination}") self._emit( ExportError( - kind=ExportKind.INSTRUMENTS, + kind=kind, exception=exception, ) ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index 37439e4c..8165e6db 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -3,7 +3,7 @@ from typing import Optional from sampletones_application.services.export.kind import ExportKind -from sampletones_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation @dataclass(frozen=True) @@ -13,10 +13,10 @@ class ExportSuccess: Attributes: kind: The artefact the run produced. filepath: The file written, or the directory a batch of instruments filled. - truncation: What the FamiTracker sequence limit left out, and ``None`` when + truncation: What the target format's item limit left out, and ``None`` when the export carries every frame. """ kind: ExportKind filepath: Path - truncation: Optional[ExportTruncation] + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_application/services/export/truncation.py b/src/sampletones_application/services/export/truncation.py deleted file mode 100644 index 89db73cb..00000000 --- a/src/sampletones_application/services/export/truncation.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional, Sequence - -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation - - -@dataclass(frozen=True) -class ExportTruncation: - """What the FamiTracker sequence limit left out of the instruments one export wrote. - - Attributes: - frames: The frame count a shortened instrument carries. - source_frames: The longest envelope the export was given. - instruments: How many written instruments were shortened. - """ - - frames: int - source_frames: int - instruments: int - - @classmethod - def summarize( - cls, - truncations: Sequence[Optional[SequenceTruncation]], - ) -> Optional[ExportTruncation]: - """Gathers the per-instrument shortenings of one export into a single report. - - Args: - truncations: One entry per written instrument, ``None`` where it fit whole. - - Returns: - Optional[ExportTruncation]: The summary, and ``None`` when every instrument - carries its whole envelope. - """ - shortened = [truncation for truncation in truncations if truncation is not None] - if not shortened: - return None - - return cls( - frames=min(truncation.frames for truncation in shortened), - source_frames=max(truncation.source_frames for truncation in shortened), - instruments=len(shortened), - ) diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 9cca660d..e3850adb 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,16 +1,11 @@ from __future__ import annotations -from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, cast import numpy as np from pydantic import BaseModel, ConfigDict from sampletones_core.constants.enums import FeatureKey -from sampletones_core.famitracker.fti import write_fti -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation from sampletones_core.types.feature import FeatureMap, FeatureValue @@ -111,43 +106,3 @@ def frame_count(self) -> int: """The frame count the envelopes describe, taken from the longest populated dimension.""" arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) return max((len(array) for array in arrays if array is not None), default=0) - - def save(self, filepath: Path, instrument_name: str) -> Optional[SequenceTruncation]: - """Writes the features to a FamiTracker instrument (``.fti``) file. - - Builds a single 2A03 instrument from the envelopes and serializes it. Envelopes - longer than a FamiTracker sequence holds reach the file as their opening frames, - which the return value reports. - - Args: - filepath: Destination path for the ``.fti`` file. - instrument_name: Name stored in the instrument. - - Returns: - Optional[SequenceTruncation]: The frames the sequence limit left out, and - ``None`` when the file carries every frame. - - Raises: - IOError: If the file cannot be written. - """ - sequences = features_to_instrument_sequences( - volume=self.volume, - arpeggio=self.arpeggio, - pitch=self.pitch, - hi_pitch=self.hi_pitch, - duty_cycle=self.duty_cycle, - loop=False, - ) - instrument = Instrument2A03(index=0, name=instrument_name, sequences=sequences) - try: - write_fti(filepath, instrument) - except ( - FileNotFoundError, - IOError, - OSError, - PermissionError, - IsADirectoryError, - ) as exception: - raise IOError(f"Failed to save features to '{filepath}': {exception}") from exception - - return SequenceTruncation.measure(self.frame_count) diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py new file mode 100644 index 00000000..64f6144d --- /dev/null +++ b/src/sampletones_core/exporters/lengths.py @@ -0,0 +1,66 @@ +from collections.abc import Hashable +from typing import Dict, List, Optional, Tuple, TypeVar + +from sampletones_shared.logger import logger + +EnvelopeKey = TypeVar("EnvelopeKey", bound=Hashable) + + +def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: + """Brings a sequence to a length, repeating its final value when it falls short.""" + return items[:length] + items[-1:] * (length - len(items)) + + +def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: + """Chooses the length every populated dimension of an instrument shares. + + A looping instrument takes the shortest length, which drops the trailing note-off + volume item the loop would otherwise sound once per cycle; a one-shot takes the + longest, so each shorter dimension holds its final value to the end. A ``limit`` + caps the result, so an envelope longer than the target format stores keeps its + opening items and the rest is reported as dropped. + + Args: + lengths: The item counts of the populated dimensions. + loop: Whether the instrument loops while its note is held. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + int: The shared item count, at most ``limit`` where one applies. + """ + length = min(lengths) if loop else max(lengths) + if limit is None or length <= limit: + return length + + logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") + return limit + + +def equalize_lengths( + items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]], + loop: bool, + *, + limit: Optional[int] = None, +) -> Dict[EnvelopeKey, Tuple[int, ...]]: + """Brings every populated dimension of an instrument to one common length. + + A tracker advances each dimension on its own per-tick counter, so dimensions of + unequal length pull apart: a looping instrument's envelopes slip by a tick per + cycle, and a one-shot's shorter dimensions expire while its volume still sounds. + + Args: + items_by_kind: The per-dimension item tuples, empty for a dimension the channel + leaves unused. + loop: Whether the instrument loops while its note is held. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + Dict[EnvelopeKey, Tuple[int, ...]]: The items with every populated dimension at + one length, leaving unused dimensions empty. + """ + lengths = [len(items) for items in items_by_kind.values() if items] + if not lengths: + return items_by_kind + + length = _common_length(lengths, loop, limit) + return {kind: _resize(items, length) if items else items for kind, items in items_by_kind.items()} diff --git a/src/sampletones_core/exporters/truncation.py b/src/sampletones_core/exporters/truncation.py new file mode 100644 index 00000000..726819e5 --- /dev/null +++ b/src/sampletones_core/exporters/truncation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence + + +@dataclass(frozen=True) +class EnvelopeTruncation: + """The frames a target format's item limit leaves out of the instruments one export wrote. + + Attributes: + frames: The frame count a shortened instrument carries. + source_frames: The longest envelope the export was given. + instruments: How many written instruments were shortened. + """ + + frames: int + source_frames: int + instruments: int + + @classmethod + def measure(cls, source_frames: int, limit: Optional[int]) -> Optional[EnvelopeTruncation]: + """Reports what an export of one instrument's envelopes keeps. + + Args: + source_frames: The frame count the envelopes arrived with. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + Optional[EnvelopeTruncation]: The shortening the limit imposes, and ``None`` + when the envelopes fit whole. + """ + if limit is None or source_frames <= limit: + return None + + return cls(frames=limit, source_frames=source_frames, instruments=1) + + @classmethod + def summarize( + cls, + truncations: Sequence[Optional[EnvelopeTruncation]], + ) -> Optional[EnvelopeTruncation]: + """Gathers the per-instrument shortenings of one export into a single report. + + Args: + truncations: One entry per written instrument, ``None`` where it fit whole. + + Returns: + Optional[EnvelopeTruncation]: The summary, and ``None`` when every instrument + carries its whole envelope. + """ + shortened = [truncation for truncation in truncations if truncation is not None] + if not shortened: + return None + + return cls( + frames=min(truncation.frames for truncation in shortened), + source_frames=max(truncation.source_frames for truncation in shortened), + instruments=sum(truncation.instruments for truncation in shortened), + ) diff --git a/src/sampletones_core/famitracker/sequences/features.py b/src/sampletones_core/famitracker/sequences/features.py index 83ff2b70..a93d55fc 100644 --- a/src/sampletones_core/famitracker/sequences/features.py +++ b/src/sampletones_core/famitracker/sequences/features.py @@ -2,10 +2,11 @@ import numpy as np +from sampletones_core.exporters.lengths import equalize_lengths from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.sequences.lengths import equalize_lengths from sampletones_core.famitracker.specification.sequences import ( LOOP_FROM_START, + MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, SequenceKind, ) @@ -43,7 +44,11 @@ def features_to_instrument_sequences( SequenceKind.DUTY: duty_cycle, } - items_by_kind = equalize_lengths({kind: _to_items(array) for kind, array in arrays.items()}, loop) + items_by_kind = equalize_lengths( + {kind: _to_items(array) for kind, array in arrays.items()}, + loop, + limit=MAX_SEQUENCE_ITEMS, + ) sequences: Dict[SequenceKind, InstrumentSequence] = {} for kind, items in items_by_kind.items(): diff --git a/src/sampletones_core/famitracker/sequences/lengths.py b/src/sampletones_core/famitracker/sequences/lengths.py deleted file mode 100644 index 5c96bd88..00000000 --- a/src/sampletones_core/famitracker/sequences/lengths.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Dict, List, Tuple - -from sampletones_core.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, - SequenceKind, -) -from sampletones_shared.logger import logger - - -def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: - """Brings a sequence to a length, repeating its final value when it falls short.""" - return items[:length] + items[-1:] * (length - len(items)) - - -def _common_length(lengths: List[int], loop: bool) -> int: - """Chooses the length every populated sequence of an instrument shares. - - A looping instrument takes the shortest length, which drops the trailing note-off - volume item the loop would otherwise sound once per cycle; a one-shot takes the - longest, so each shorter dimension holds its final value to the end. The result - stays within the item count FamiTracker stores, so an envelope longer than that - keeps its opening items and the rest is reported as dropped. - - Args: - lengths: The item counts of the populated dimensions. - loop: Whether the instrument loops while its note is held. - - Returns: - int: The shared item count, at most ``MAX_SEQUENCE_ITEMS``. - """ - length = min(lengths) if loop else max(lengths) - if length <= MAX_SEQUENCE_ITEMS: - return length - - logger.warning( - f"Instrument envelope of {length} items keeps its first {MAX_SEQUENCE_ITEMS}, " - f"the most FamiTracker stores in a sequence" - ) - return MAX_SEQUENCE_ITEMS - - -def equalize_lengths( - items_by_kind: Dict[SequenceKind, Tuple[int, ...]], - loop: bool, -) -> Dict[SequenceKind, Tuple[int, ...]]: - """Brings every populated sequence of an instrument to one common length. - - FamiTracker advances each sequence on its own per-tick counter, so dimensions of - unequal length pull apart: a looping instrument's envelopes slip by a tick per - cycle, and a one-shot's shorter dimensions expire while its volume still sounds. - - Args: - items_by_kind: The per-kind item tuples, empty for a disabled dimension. - loop: Whether the instrument loops while its note is held. - - Returns: - Dict[SequenceKind, Tuple[int, ...]]: The items with every populated kind at - one length, leaving disabled kinds empty. - """ - lengths = [len(items) for items in items_by_kind.values() if items] - if not lengths: - return items_by_kind - - length = _common_length(lengths, loop) - return {kind: _resize(items, length) if items else items for kind, items in items_by_kind.items()} diff --git a/src/sampletones_core/famitracker/sequences/truncation.py b/src/sampletones_core/famitracker/sequences/truncation.py deleted file mode 100644 index 72c4bf50..00000000 --- a/src/sampletones_core/famitracker/sequences/truncation.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional - -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS - - -@dataclass(frozen=True) -class SequenceTruncation: - """The frames of an envelope the FamiTracker sequence limit leaves out. - - Attributes: - frames: The frame count the exported sequences carry. - source_frames: The frame count the envelopes arrived with. - """ - - frames: int - source_frames: int - - @classmethod - def measure(cls, source_frames: int) -> Optional[SequenceTruncation]: - """Reports what an export of this many frames keeps. - - Args: - source_frames: The frame count the envelopes arrived with. - - Returns: - Optional[SequenceTruncation]: The shortening the limit imposes, and ``None`` - when the envelopes fit whole. - """ - if source_frames <= MAX_SEQUENCE_ITEMS: - return None - - return cls(frames=MAX_SEQUENCE_ITEMS, source_frames=source_frames) diff --git a/src/sampletones_core/famitracker/specification/instruments.py b/src/sampletones_core/famitracker/specification/instruments.py index 4a66037e..b58be64b 100644 --- a/src/sampletones_core/famitracker/specification/instruments.py +++ b/src/sampletones_core/famitracker/specification/instruments.py @@ -5,6 +5,8 @@ INSTRUMENT_TYPE_2A03: Final[int] = 1 MAX_INSTRUMENTS: Final[int] = 64 +STANDALONE_INSTRUMENT_INDEX: Final[int] = 0 + DPCM_KEY_ASSIGNMENTS: Final[int] = NOTE_RANGE * OCTAVE_RANGE DPCM_KEY_BYTES: Final[int] = 3 diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py index 19c3a313..f0564761 100644 --- a/src/sampletones_core/paths.py +++ b/src/sampletones_core/paths.py @@ -28,6 +28,7 @@ EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" EXT_FILE_PROJECT: Final[str] = ".stp" EXT_FILE_MODULE: Final[str] = ".ftm" +EXT_FILE_BITPHASE: Final[str] = ".btp" EXT_FILE_WAVE: Final[str] = ".wav" EXT_FILE_MP3: Final[str] = ".mp3" EXT_FILE_FLAC: Final[str] = ".flac" diff --git a/src/sampletones_core/trackers/__init__.py b/src/sampletones_core/trackers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/trackers/artifact.py b/src/sampletones_core/trackers/artifact.py new file mode 100644 index 00000000..9d99a04b --- /dev/null +++ b/src/sampletones_core/trackers/artifact.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_core.exporters.truncation import EnvelopeTruncation + + +@dataclass(frozen=True) +class ExportArtifact: + """What one export run left on disk. + + Attributes: + 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. + """ + + paths: Tuple[Path, ...] + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/trackers/backend.py new file mode 100644 index 00000000..ffd32dbb --- /dev/null +++ b/src/sampletones_core/trackers/backend.py @@ -0,0 +1,105 @@ +from pathlib import Path +from typing import FrozenSet, Protocol + +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import DestinationKind, ExportScope + + +class TrackerBackend(Protocol): + """Writes the application's work in the file format one tracker reads. + + A backend owns both the byte layout and the shape each :class:`ExportScope` takes on + disk, so a format that reads a whole reconstruction from a single file writes one + where another writes a directory of per-instrument files. Callers ask + :meth:`destination_kind` what to prompt for and hand the answer straight back as the + ``destination``. + """ + + @property + def tracker_format(self) -> TrackerFormat: + """The format this backend writes.""" + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + """The scopes this format can express.""" + + def destination_kind(self, scope: ExportScope) -> DestinationKind: + """Whether ``scope`` is written to a file or into a directory. + + Args: + scope: The scope about to be exported. + + Returns: + DestinationKind: What the caller should prompt the user for. + """ + + def extension(self, scope: ExportScope) -> str: + """The extension the files of ``scope`` carry, leading dot included. + + Args: + scope: The scope about to be exported. + + Returns: + str: The extension of each file the run writes. + """ + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + """Writes one generator slice. + + Args: + destination: The file to write. + request: The slice to write. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + """ + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + """Writes every generator slice of one reconstruction. + + Args: + destination: The file to write, or the directory to fill. + request: The reconstruction's slices. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + """ + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + """Writes a whole composition. + + Args: + destination: The file to write. + request: The project to write. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + ValueError: If the project holds more than the format has room for. + """ diff --git a/src/sampletones_core/trackers/famitracker.py b/src/sampletones_core/trackers/famitracker.py new file mode 100644 index 00000000..99c08cd9 --- /dev/null +++ b/src/sampletones_core/trackers/famitracker.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import FrozenSet, List, Optional + +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.famitracker.export import write_ftm +from sampletones_core.famitracker.fti import write_fti +from sampletones_core.famitracker.model.instrument import Instrument2A03 +from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import DestinationKind, ExportScope + +SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) + + +class FamiTrackerBackend: + """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. + + FamiTracker reads one instrument per ``.fti`` file, so a whole reconstruction lands + as a directory of them, one file per generator slice named after the instrument. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.FAMITRACKER + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def destination_kind(self, scope: ExportScope) -> DestinationKind: + return DestinationKind.DIRECTORY if scope == ExportScope.SAMPLE else DestinationKind.FILE + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_MODULE if scope == ExportScope.PROJECT else EXT_FILE_INSTRUMENT + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + features = request.features + sequences = features_to_instrument_sequences( + volume=features.volume, + arpeggio=features.arpeggio, + pitch=features.pitch, + hi_pitch=features.hi_pitch, + duty_cycle=features.duty_cycle, + loop=request.loop, + ) + instrument = Instrument2A03( + index=STANDALONE_INSTRUMENT_INDEX, + name=request.name, + sequences=sequences, + ) + write_fti(destination, instrument) + + return ExportArtifact( + paths=(destination,), + truncation=EnvelopeTruncation.measure( + features.frame_count, + MAX_SEQUENCE_ITEMS, + ), + ) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + destination.mkdir(parents=True, exist_ok=True) + + paths: List[Path] = [] + truncations: List[Optional[EnvelopeTruncation]] = [] + for instrument in request.instruments: + filepath = destination / f"{instrument.name}{EXT_FILE_INSTRUMENT}" + artifact = self.write_instrument(filepath, instrument) + paths.extend(artifact.paths) + truncations.append(artifact.truncation) + + return ExportArtifact( + paths=tuple(paths), + truncation=EnvelopeTruncation.summarize(truncations), + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + write_ftm(destination, request.project) + return ExportArtifact(paths=(destination,), truncation=None) diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/trackers/format.py new file mode 100644 index 00000000..9cedb468 --- /dev/null +++ b/src/sampletones_core/trackers/format.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class TrackerFormat(StrEnum): + """A file format one tracker reads, and the backend that writes it.""" + + FAMITRACKER = "famitracker" diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py new file mode 100644 index 00000000..4d2d5b2e --- /dev/null +++ b/src/sampletones_core/trackers/registry.py @@ -0,0 +1,18 @@ +from typing import Dict + +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.famitracker import FamiTrackerBackend +from sampletones_core.trackers.format import TrackerFormat + + +def build_tracker_backends() -> Dict[TrackerFormat, TrackerBackend]: + """Builds one backend per tracker format the application can write. + + The composition root calls this once and hands the result to the components that + offer a format choice, so a new format reaches the whole application by joining + this mapping. + + Returns: + Dict[TrackerFormat, TrackerBackend]: Every backend, keyed by the format it writes. + """ + return {TrackerFormat.FAMITRACKER: FamiTrackerBackend()} diff --git a/src/sampletones_core/trackers/request.py b/src/sampletones_core/trackers/request.py new file mode 100644 index 00000000..7dd8443d --- /dev/null +++ b/src/sampletones_core/trackers/request.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.project.project import Project + + +@dataclass(frozen=True) +class InstrumentExport: + """One generator slice of a reconstruction, ready for a backend to write. + + Attributes: + name: Name the written instrument carries. + generator: The NES channel the slice was reconstructed for. + features: The per-dimension envelopes describing the slice. + loop: Whether the instrument repeats its envelopes while its note is held. + """ + + name: str + generator: GeneratorName + features: Features + loop: bool + + +@dataclass(frozen=True) +class SampleExport: + """Every generator slice of one reconstruction. + + Attributes: + name: Name of the reconstruction the slices came from. + instruments: One entry per channel the reconstruction covers. + """ + + name: str + instruments: Tuple[InstrumentExport, ...] + + +@dataclass(frozen=True) +class ProjectExport: + """A whole composition — its samples and the song that arranges them. + + Attributes: + project: The project to write. + """ + + project: Project diff --git a/src/sampletones_core/trackers/scope.py b/src/sampletones_core/trackers/scope.py new file mode 100644 index 00000000..f83b6d0f --- /dev/null +++ b/src/sampletones_core/trackers/scope.py @@ -0,0 +1,24 @@ +from enum import StrEnum + + +class ExportScope(StrEnum): + """How much of the application's work one export run carries. + + A backend decides how each scope materialises on disk, so a format that reads a + whole reconstruction from a single file is free to write one. + """ + + INSTRUMENT = "instrument" + SAMPLE = "sample" + PROJECT = "project" + + +class DestinationKind(StrEnum): + """Whether a scope's destination is a file to write or a directory to fill. + + The coordinator reads this to choose between a save-file and a select-directory + dialog, so the choice follows the backend rather than a branch on format. + """ + + FILE = "file" + DIRECTORY = "directory" diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 37f2583b..4f8d7a50 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -1,12 +1,31 @@ from typing import Any, List import numpy as np +import pytest from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_core.audio import read_wave +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.trackers.famitracker import FamiTrackerBackend +from sampletones_core.trackers.request import InstrumentExport, SampleExport + + +@pytest.fixture(name="backend") +def backend_fixture() -> FamiTrackerBackend: + return FamiTrackerBackend() + + +def instrument_export(name: str, features: Features) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=features, + loop=False, + ) class TestExportWavIntegration: @@ -57,85 +76,88 @@ def test_invalid_sample_rate_emits_export_error(self, tmp_path) -> None: class TestExportInstrumentIntegration: - def test_fti_file_is_created_on_disk(self, tmp_path, pulse_features) -> None: + def test_fti_file_is_created_on_disk(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) filepath = tmp_path / "instrument.fti" - export_service.export_instrument(filepath, "test_instrument", pulse_features) + export_service.export_instrument(filepath, backend, instrument_export("test_instrument", pulse_features)) assert filepath.exists() - def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pulse_features) -> None: + def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) filepath = tmp_path / "instrument.fti" - export_service.export_instrument(filepath, "test_instrument", pulse_features) + export_service.export_instrument(filepath, backend, instrument_export("test_instrument", pulse_features)) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) assert results[0].kind == ExportKind.INSTRUMENT assert results[0].filepath == filepath - def test_directory_path_emits_export_error(self, tmp_path, pulse_features) -> None: + def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_instrument(tmp_path, "test_instrument", pulse_features) + export_service.export_instrument(tmp_path, backend, instrument_export("test_instrument", pulse_features)) assert len(results) == 1 assert isinstance(results[0], ExportError) assert results[0].kind == ExportKind.INSTRUMENT -class TestExportInstrumentsIntegration: - def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features) -> None: +class TestExportSampleIntegration: + def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - exports = [ - (tmp_path / "inst_0.fti", "inst_0", pulse_features), - (tmp_path / "inst_1.fti", "inst_1", pulse_features), - ] - export_service.export_instruments(tmp_path, exports) + request = SampleExport( + name="sample", + instruments=( + instrument_export("inst_0", pulse_features), + instrument_export("inst_1", pulse_features), + ), + ) + export_service.export_sample(tmp_path, backend, request) - for filepath, _, _ in exports: - assert filepath.exists() + assert (tmp_path / "inst_0.fti").exists() + assert (tmp_path / "inst_1.fti").exists() - def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_features) -> None: + def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - exports = [(tmp_path / "inst.fti", "inst", pulse_features)] - export_service.export_instruments(tmp_path, exports) + request = SampleExport(name="sample", instruments=(instrument_export("inst", pulse_features),)) + export_service.export_sample(tmp_path, backend, request) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) assert results[0].kind == ExportKind.INSTRUMENTS assert results[0].filepath == tmp_path - def test_new_directory_is_created(self, tmp_path, pulse_features) -> None: + def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: new_dir = tmp_path / "subdir" export_service = ExportService() export_service.subscribe(lambda _: None) - export_service.export_instruments(new_dir, [(new_dir / "inst.fti", "inst", pulse_features)]) + request = SampleExport(name="sample", instruments=(instrument_export("inst", pulse_features),)) + export_service.export_sample(new_dir, backend, request) assert new_dir.exists() - def test_empty_exports_list_creates_no_files(self, tmp_path) -> None: + def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_instruments(tmp_path, []) + export_service.export_sample(tmp_path, backend, SampleExport(name="sample", instruments=())) - fti_files = list(tmp_path.glob("*.fti")) - assert fti_files == [] + assert list(tmp_path.glob("*.fti")) == [] assert isinstance(results[0], ExportSuccess) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 70ca3118..ef77cf8c 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -11,7 +11,7 @@ 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_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -260,7 +260,7 @@ def test_a_shortened_instrument_export_names_both_frame_counts( ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), - truncation=ExportTruncation(frames=252, source_frames=300, instruments=1), + truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1), ) ) @@ -277,7 +277,7 @@ def test_a_shortened_reconstruction_export_counts_the_instruments( ExportSuccess( kind=ExportKind.INSTRUMENTS, filepath=Path("instruments"), - truncation=ExportTruncation(frames=252, source_frames=410, instruments=3), + truncation=EnvelopeTruncation(frames=252, source_frames=410, instruments=3), ) ) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 1b84c113..b7153ed0 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -23,6 +23,7 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), MagicMock(), + export_backend=MagicMock(), dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 6bf15c98..e9ecd2b2 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from dataclasses import dataclass from pathlib import Path @@ -47,8 +47,19 @@ def panel_logic( session_manager: MagicMock, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, + mock_export_backend: MagicMock, ) -> ReconstructionPanelLogic: - return ReconstructionPanelLogic(session_manager, mock_reconstruction_manager, mock_export_service) + return ReconstructionPanelLogic( + session_manager, + mock_reconstruction_manager, + mock_export_service, + mock_export_backend, + ) + + +@pytest.fixture +def mock_export_backend() -> MagicMock: + return MagicMock() @pytest.fixture @@ -441,9 +452,9 @@ def test_handle_export_instruments_confirmed_with_no_data_is_no_op( tmp_path: Path, ) -> None: panel_logic.handle_export_instruments_confirmed(tmp_path) - mock_export_service.export_instruments.assert_not_called() + mock_export_service.export_sample.assert_not_called() - def test_handle_export_instruments_confirmed_calls_export_instruments( + def test_handle_export_instruments_confirmed_calls_export_sample( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -453,7 +464,7 @@ def test_handle_export_instruments_confirmed_calls_export_instruments( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instruments_confirmed(tmp_path) - mock_export_service.export_instruments.assert_called_once() + mock_export_service.export_sample.assert_called_once() class TestReconstructionPanelLogicExportWav: diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index bce41c0f..1f855aa8 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -6,7 +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_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation class TestExportSuccess: @@ -18,7 +18,7 @@ def test_stores_kind_and_filepath(self) -> None: assert success.truncation is None def test_stores_the_truncation(self) -> None: - truncation = ExportTruncation(frames=252, source_frames=300, instruments=1) + truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1) success = ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=Path("/x"), truncation=truncation) assert success.truncation == truncation diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index fc1dca53..8e89461a 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -1,6 +1,6 @@ from pathlib import Path -from typing import Any, List -from unittest.mock import MagicMock, call, patch +from typing import Any, List, Optional, Tuple +from unittest.mock import patch import numpy as np import pytest @@ -9,7 +9,86 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import DestinationKind, ExportScope + + +class StubBackend: + """Records what the service asked for and returns a prepared artefact. + + The service under test owns the thread boundary and the result contract; what lands + on disk belongs to the real backends and is exercised in their own tests. + """ + + def __init__( + self, + truncation: Optional[EnvelopeTruncation] = None, + exception: Optional[Exception] = None, + ) -> None: + self.truncation = truncation + self.exception = exception + self.calls: List[Tuple[str, Path, Any]] = [] + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.FAMITRACKER + + @property + def supported_scopes(self) -> frozenset: + return frozenset(ExportScope) + + def destination_kind(self, scope: ExportScope) -> DestinationKind: + return DestinationKind.FILE + + def extension(self, scope: ExportScope) -> str: + return ".fti" + + def write_instrument(self, destination: Path, request: InstrumentExport) -> ExportArtifact: + return self._write("instrument", destination, request) + + def write_sample(self, destination: Path, request: SampleExport) -> ExportArtifact: + return self._write("sample", destination, request) + + def write_project(self, destination: Path, request: ProjectExport) -> ExportArtifact: + return self._write("project", destination, request) + + def _write(self, scope: str, destination: Path, request: Any) -> ExportArtifact: + self.calls.append((scope, destination, request)) + if self.exception is not None: + raise self.exception + return ExportArtifact(paths=(destination,), truncation=self.truncation) + + +def build_instrument(name: str = "Lead") -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=Features( + initial_pitch=60, + volume=np.full(8, 15, dtype=int), + arpeggio=np.zeros(8, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=None, + ), + loop=False, + ) + + +def build_sample(count: int = 2) -> SampleExport: + return SampleExport( + name="Kick", + instruments=tuple(build_instrument(f"Kick {index}") for index in range(count)), + ) @pytest.fixture @@ -20,13 +99,6 @@ def service(): return export_service, results -def feature_mock(truncation: Any = None) -> MagicMock: - """A feature whose save reports the frames a FamiTracker sequence left out.""" - feature = MagicMock() - feature.save.return_value = truncation - return feature - - class TestExportWav: def test_success_emits_export_success(self, service, tmp_path) -> None: export_service, results = service @@ -81,9 +153,8 @@ class TestExportInstrument: def test_success_emits_export_success(self, service, tmp_path) -> None: export_service, results = service filepath = tmp_path / "instrument.fti" - feature = feature_mock() - export_service.export_instrument(filepath, "guitar", feature) + export_service.export_instrument(filepath, StubBackend(), build_instrument()) assert len(results) == 1 result = results[0] @@ -91,23 +162,25 @@ def test_success_emits_export_success(self, service, tmp_path) -> None: assert result.kind == ExportKind.INSTRUMENT assert result.filepath == filepath - def test_success_calls_feature_save(self, service, tmp_path) -> None: + def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None: export_service, _ = service filepath = tmp_path / "instrument.fti" - feature = feature_mock() + backend = StubBackend() + request = build_instrument("Guitar") - export_service.export_instrument(filepath, "guitar", feature) + export_service.export_instrument(filepath, backend, request) - feature.save.assert_called_once_with(filepath, "guitar") + assert backend.calls == [("instrument", filepath, request)] def test_error_emits_export_error(self, service, tmp_path) -> None: export_service, results = service - filepath = tmp_path / "instrument.fti" exception = PermissionError("read-only") - feature = feature_mock() - feature.save.side_effect = exception - export_service.export_instrument(filepath, "bass", feature) + export_service.export_instrument( + tmp_path / "instrument.fti", + StubBackend(exception=exception), + build_instrument(), + ) assert len(results) == 1 result = results[0] @@ -117,31 +190,21 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: def test_error_does_not_emit_success(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock() - feature.save.side_effect = OSError("fail") - export_service.export_instrument(tmp_path / "x.fti", "piano", feature) + export_service.export_instrument( + tmp_path / "x.fti", + StubBackend(exception=OSError("fail")), + build_instrument(), + ) assert not any(isinstance(r, ExportSuccess) for r in results) -class TestExportInstruments: - def test_success_calls_save_for_each_export(self, service, tmp_path) -> None: - export_service, _ = service - features = [feature_mock(), feature_mock(), feature_mock()] - exports = [(tmp_path / f"inst_{i}.fti", f"inst_{i}", features[i]) for i in range(3)] - - export_service.export_instruments(tmp_path, exports) - - for feature in features: - feature.save.assert_called_once() - - def test_success_emits_export_success_with_directory(self, service, tmp_path) -> None: +class TestExportSample: + def test_success_emits_export_success_with_the_destination(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock() - exports = [(tmp_path / "inst.fti", "inst", feature)] - export_service.export_instruments(tmp_path, exports) + export_service.export_sample(tmp_path, StubBackend(), build_sample()) assert len(results) == 1 result = results[0] @@ -149,28 +212,20 @@ def test_success_emits_export_success_with_directory(self, service, tmp_path) -> assert result.kind == ExportKind.INSTRUMENTS assert result.filepath == tmp_path - def test_success_creates_directory(self, service, tmp_path) -> None: + def test_the_backend_receives_every_slice_in_one_call(self, service, tmp_path) -> None: export_service, _ = service - new_dir = tmp_path / "subdir" - feature = feature_mock() - exports = [(new_dir / "inst.fti", "inst", feature)] + backend = StubBackend() + request = build_sample(3) - export_service.export_instruments(new_dir, exports) + export_service.export_sample(tmp_path, backend, request) - assert new_dir.exists() + assert backend.calls == [("sample", tmp_path, request)] - def test_error_on_first_save_emits_export_error(self, service, tmp_path) -> None: + def test_error_emits_export_error(self, service, tmp_path) -> None: export_service, results = service exception = OSError("no space") - first_feature = feature_mock() - first_feature.save.side_effect = exception - second_feature = feature_mock() - exports = [ - (tmp_path / "first.fti", "first", first_feature), - (tmp_path / "second.fti", "second", second_feature), - ] - export_service.export_instruments(tmp_path, exports) + export_service.export_sample(tmp_path, StubBackend(exception=exception), build_sample()) assert len(results) == 1 result = results[0] @@ -178,24 +233,10 @@ def test_error_on_first_save_emits_export_error(self, service, tmp_path) -> None assert result.kind == ExportKind.INSTRUMENTS assert result.exception is exception - def test_error_stops_after_first_failure(self, service, tmp_path) -> None: - export_service, _ = service - first_feature = feature_mock() - first_feature.save.side_effect = OSError("fail") - second_feature = feature_mock() - exports = [ - (tmp_path / "first.fti", "first", first_feature), - (tmp_path / "second.fti", "second", second_feature), - ] - - export_service.export_instruments(tmp_path, exports) - - second_feature.save.assert_not_called() - - def test_empty_exports_list_emits_success(self, service, tmp_path) -> None: + def test_a_sample_with_no_slices_emits_success(self, service, tmp_path) -> None: export_service, results = service - export_service.export_instruments(tmp_path, []) + export_service.export_sample(tmp_path, StubBackend(), build_sample(0)) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) @@ -206,44 +247,29 @@ class TestExportTruncationReporting: def test_a_complete_instrument_reports_no_truncation(self, service, tmp_path) -> None: export_service, results = service - export_service.export_instrument(tmp_path / "inst.fti", "inst", feature_mock()) + export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument()) assert results[0].truncation is None - def test_a_shortened_instrument_reports_its_frames(self, service, tmp_path) -> None: + def test_a_shortened_instrument_carries_the_backend_report(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock(SequenceTruncation(frames=252, source_frames=300)) + truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1) - export_service.export_instrument(tmp_path / "inst.fti", "inst", feature) + export_service.export_instrument( + tmp_path / "inst.fti", + StubBackend(truncation=truncation), + build_instrument(), + ) - truncation = results[0].truncation - assert truncation.frames == 252 - assert truncation.source_frames == 300 - assert truncation.instruments == 1 + assert results[0].truncation == truncation - def test_a_complete_reconstruction_reports_no_truncation(self, service, tmp_path) -> None: + def test_a_shortened_sample_carries_the_backend_report(self, service, tmp_path) -> None: export_service, results = service - exports = [(tmp_path / f"inst_{index}.fti", f"inst_{index}", feature_mock()) for index in range(3)] + truncation = EnvelopeTruncation(frames=252, source_frames=410, instruments=2) - export_service.export_instruments(tmp_path, exports) + export_service.export_sample(tmp_path, StubBackend(truncation=truncation), build_sample(3)) - assert results[0].truncation is None - - def test_a_partly_shortened_reconstruction_counts_the_shortened_instruments(self, service, tmp_path) -> None: - export_service, results = service - features = [ - feature_mock(), - feature_mock(SequenceTruncation(frames=252, source_frames=300)), - feature_mock(SequenceTruncation(frames=252, source_frames=410)), - ] - exports = [(tmp_path / f"inst_{index}.fti", f"inst_{index}", feature) for index, feature in enumerate(features)] - - export_service.export_instruments(tmp_path, exports) - - truncation = results[0].truncation - assert truncation.instruments == 2 - assert truncation.frames == 252 - assert truncation.source_frames == 410 + assert results[0].truncation == truncation def test_a_wav_export_reports_no_truncation(self, service, tmp_path) -> None: export_service, results = service diff --git a/tests/unit/sampletones_application/services/export/test_truncation.py b/tests/unit/sampletones_application/services/export/test_truncation.py deleted file mode 100644 index a34314d9..00000000 --- a/tests/unit/sampletones_application/services/export/test_truncation.py +++ /dev/null @@ -1,20 +0,0 @@ -from sampletones_application.services.export.truncation import ExportTruncation -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation - - -class TestExportTruncationSummarize: - def test_a_complete_export_summarizes_to_nothing(self) -> None: - assert ExportTruncation.summarize([None, None]) is None - - def test_an_empty_export_summarizes_to_nothing(self) -> None: - assert ExportTruncation.summarize([]) is None - - def test_the_summary_spans_every_shortened_instrument(self) -> None: - summary = ExportTruncation.summarize( - [ - None, - SequenceTruncation(frames=252, source_frames=300), - SequenceTruncation(frames=252, source_frames=480), - ] - ) - assert summary == ExportTruncation(frames=252, source_frames=480, instruments=2) diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index fac52d2d..192f830e 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -1,11 +1,8 @@ -from pathlib import Path from typing import Optional import numpy as np from sampletones_core.exporters import Features -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: @@ -29,19 +26,3 @@ def test_absent_dimensions_leave_the_count_to_the_others(self) -> None: def test_empty_envelopes_count_no_frames(self) -> None: assert build_features(0).frame_count == 0 - - -class TestSaveReportsTruncation: - def test_an_envelope_within_the_limit_reports_nothing(self, tmp_path: Path) -> None: - features = build_features(MAX_SEQUENCE_ITEMS) - assert features.save(tmp_path / "short.fti", "Short") is None - - def test_an_envelope_beyond_the_limit_reports_both_counts(self, tmp_path: Path) -> None: - features = build_features(300) - truncation = features.save(tmp_path / "long.fti", "Long") - assert truncation == SequenceTruncation(frames=MAX_SEQUENCE_ITEMS, source_frames=300) - - def test_a_shortened_export_still_writes_the_file(self, tmp_path: Path) -> None: - filepath = tmp_path / "long.fti" - build_features(300, duty_cycle_frames=300).save(filepath, "Long") - assert filepath.exists() diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py new file mode 100644 index 00000000..b3c4bb0c --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -0,0 +1,83 @@ +import logging +from typing import Dict, Final, Tuple + +import pytest + +from sampletones_core.exporters.lengths import equalize_lengths + +VOLUME: Final[str] = "volume" +ARPEGGIO: Final[str] = "arpeggio" +DUTY: Final[str] = "duty" + +ITEM_LIMIT: Final[int] = 252 + + +def items_of(length: int) -> Tuple[int, ...]: + return tuple(index % 16 for index in range(length)) + + +def volume_and_arpeggio(length: int) -> Dict[str, Tuple[int, ...]]: + return { + VOLUME: items_of(length), + ARPEGGIO: (0,) * length, + DUTY: (), + } + + +class TestEqualizeLengths: + def test_loop_takes_the_shortest_populated_dimension(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=True) + assert equalized[VOLUME] == (15, 12, 9) + assert equalized[ARPEGGIO] == (0, 2, 4) + + def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=False) + assert equalized[VOLUME] == (15, 12, 9, 0) + assert equalized[ARPEGGIO] == (0, 2, 4, 4) + + def test_empty_dimensions_stay_empty(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, loop=False) + assert equalized[ARPEGGIO] == () + + def test_all_dimensions_empty_stay_empty(self) -> None: + equalized = equalize_lengths({VOLUME: (), ARPEGGIO: (), DUTY: ()}, loop=True) + assert all(items == () for items in equalized.values()) + + +class TestItemLimit: + @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) + def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: + length = ITEM_LIMIT + 48 + + equalized = equalize_lengths(volume_and_arpeggio(length), loop=loop, limit=ITEM_LIMIT) + + assert equalized[VOLUME] == items_of(ITEM_LIMIT) + assert len(equalized[ARPEGGIO]) == ITEM_LIMIT + + def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False, limit=ITEM_LIMIT) + + assert str(ITEM_LIMIT) in caplog.text + + def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT), loop=False, limit=ITEM_LIMIT) + + assert caplog.text == "" + + +class TestUnboundedFormat: + def test_an_absent_limit_keeps_every_item(self) -> None: + length = ITEM_LIMIT + 48 + + equalized = equalize_lengths(volume_and_arpeggio(length), loop=False) + + assert equalized[VOLUME] == items_of(length) + assert len(equalized[ARPEGGIO]) == length + + def test_an_absent_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False) + + assert caplog.text == "" diff --git a/tests/unit/sampletones_core/exporters/test_truncation.py b/tests/unit/sampletones_core/exporters/test_truncation.py new file mode 100644 index 00000000..71803658 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_truncation.py @@ -0,0 +1,42 @@ +from typing import Final + +import pytest + +from sampletones_core.exporters.truncation import EnvelopeTruncation + +ITEM_LIMIT: Final[int] = 252 + + +class TestEnvelopeTruncationMeasure: + @pytest.mark.parametrize( + "source_frames", + [0, 1, ITEM_LIMIT], + ids=["empty", "single", "at_the_limit"], + ) + def test_an_envelope_within_the_limit_reports_nothing(self, source_frames: int) -> None: + assert EnvelopeTruncation.measure(source_frames, ITEM_LIMIT) is None + + def test_an_envelope_beyond_the_limit_reports_both_counts(self) -> None: + truncation = EnvelopeTruncation.measure(300, ITEM_LIMIT) + assert truncation == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=300, instruments=1) + + def test_an_unbounded_format_reports_nothing(self) -> None: + assert EnvelopeTruncation.measure(100_000, None) is None + + +class TestEnvelopeTruncationSummarize: + def test_instruments_that_all_fit_report_nothing(self) -> None: + assert EnvelopeTruncation.summarize([None, None]) is None + + def test_an_empty_export_reports_nothing(self) -> None: + assert EnvelopeTruncation.summarize([]) is None + + def test_the_summary_spans_every_shortened_instrument(self) -> None: + summary = EnvelopeTruncation.summarize( + [ + None, + EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=300, instruments=1), + EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=480, instruments=1), + ] + ) + assert summary == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=480, instruments=2) diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py b/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py deleted file mode 100644 index 23bef4cb..00000000 --- a/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -from typing import Dict, Tuple - -import pytest - -from sampletones_core.famitracker.sequences.lengths import equalize_lengths -from sampletones_core.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, - SequenceKind, -) - - -def items_of(length: int) -> Tuple[int, ...]: - return tuple(index % 16 for index in range(length)) - - -def volume_and_arpeggio(length: int) -> Dict[SequenceKind, Tuple[int, ...]]: - return { - SequenceKind.VOLUME: items_of(length), - SequenceKind.ARPEGGIO: (0,) * length, - SequenceKind.PITCH: (), - SequenceKind.HI_PITCH: (), - SequenceKind.DUTY: (), - } - - -class TestEqualizeLengths: - def test_loop_takes_the_shortest_populated_dimension(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 9, 0), SequenceKind.ARPEGGIO: (0, 2, 4)}, - loop=True, - ) - assert equalized[SequenceKind.VOLUME] == (15, 12, 9) - assert equalized[SequenceKind.ARPEGGIO] == (0, 2, 4) - - def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 9, 0), SequenceKind.ARPEGGIO: (0, 2, 4)}, - loop=False, - ) - assert equalized[SequenceKind.VOLUME] == (15, 12, 9, 0) - assert equalized[SequenceKind.ARPEGGIO] == (0, 2, 4, 4) - - def test_empty_dimensions_stay_empty(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 0), SequenceKind.ARPEGGIO: ()}, - loop=False, - ) - assert equalized[SequenceKind.ARPEGGIO] == () - - def test_all_dimensions_empty_stay_empty(self) -> None: - equalized = equalize_lengths({kind: () for kind in SequenceKind}, loop=True) - assert all(items == () for items in equalized.values()) - - -class TestFamiTrackerItemLimit: - @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) - def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: - length = MAX_SEQUENCE_ITEMS + 48 - - equalized = equalize_lengths(volume_and_arpeggio(length), loop=loop) - - assert equalized[SequenceKind.VOLUME] == items_of(MAX_SEQUENCE_ITEMS) - assert len(equalized[SequenceKind.ARPEGGIO]) == MAX_SEQUENCE_ITEMS - - def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): - equalize_lengths(volume_and_arpeggio(MAX_SEQUENCE_ITEMS + 1), loop=False) - - assert str(MAX_SEQUENCE_ITEMS) in caplog.text - - def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): - equalize_lengths(volume_and_arpeggio(MAX_SEQUENCE_ITEMS), loop=False) - - assert caplog.text == "" diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py b/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py deleted file mode 100644 index ca5fb888..00000000 --- a/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS - - -class TestSequenceTruncationMeasure: - @pytest.mark.parametrize( - "source_frames", - [0, 1, MAX_SEQUENCE_ITEMS], - ids=["empty", "single", "at_the_limit"], - ) - def test_an_envelope_within_the_limit_reports_nothing(self, source_frames: int) -> None: - assert SequenceTruncation.measure(source_frames) is None - - def test_an_envelope_beyond_the_limit_reports_both_counts(self) -> None: - truncation = SequenceTruncation.measure(300) - assert truncation == SequenceTruncation(frames=MAX_SEQUENCE_ITEMS, source_frames=300) diff --git a/tests/unit/sampletones_core/trackers/__init__.py b/tests/unit/sampletones_core/trackers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py new file mode 100644 index 00000000..7d7b1885 --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -0,0 +1,171 @@ +from pathlib import Path +from typing import Optional + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_core.trackers.famitracker import FamiTrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.trackers.scope import DestinationKind, ExportScope + + +def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: + duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + return Features( + initial_pitch=60, + volume=np.full(frames, 15, dtype=int), + arpeggio=np.zeros(frames, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=build_features(frames), + loop=False, + ) + + +@pytest.fixture(name="backend") +def backend_fixture() -> FamiTrackerBackend: + return FamiTrackerBackend() + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None: + assert backend.tracker_format == TrackerFormat.FAMITRACKER + + def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize( + ("scope", "expected"), + [ + (ExportScope.INSTRUMENT, DestinationKind.FILE), + (ExportScope.SAMPLE, DestinationKind.DIRECTORY), + (ExportScope.PROJECT, DestinationKind.FILE), + ], + ) + def test_a_sample_fills_a_directory_while_the_others_write_a_file( + self, + backend: FamiTrackerBackend, + scope: ExportScope, + expected: DestinationKind, + ) -> None: + assert backend.destination_kind(scope) == expected + + @pytest.mark.parametrize( + ("scope", "expected"), + [ + (ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT), + (ExportScope.SAMPLE, EXT_FILE_INSTRUMENT), + (ExportScope.PROJECT, EXT_FILE_MODULE), + ], + ) + def test_instruments_carry_the_instrument_extension_and_a_project_the_module_one( + self, + backend: FamiTrackerBackend, + scope: ExportScope, + expected: str, + ) -> None: + assert backend.extension(scope) == expected + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_INSTRUMENT}" + + artifact = backend.write_instrument(destination, build_instrument("Short", 16)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_an_envelope_within_the_limit_reports_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + artifact = backend.write_instrument( + tmp_path / f"Short{EXT_FILE_INSTRUMENT}", + build_instrument("Short", MAX_SEQUENCE_ITEMS), + ) + assert artifact.truncation is None + + def test_an_envelope_beyond_the_limit_reports_both_counts( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_instrument( + tmp_path / f"Long{EXT_FILE_INSTRUMENT}", + build_instrument("Long", 300), + ) + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=300, + instruments=1, + ) + + def test_a_shortened_export_still_writes_the_file(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Long{EXT_FILE_INSTRUMENT}" + backend.write_instrument(destination, build_instrument("Long", 300)) + assert destination.exists() + + +class TestWriteSample: + def test_each_slice_lands_in_a_file_named_after_its_instrument( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / "Kick" + request = SampleExport( + name="Kick", + instruments=(build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16)), + ) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == ( + destination / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", + destination / f"Kick (noise){EXT_FILE_INSTRUMENT}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / "Kick" + + backend.write_sample(destination, SampleExport(name="Kick", instruments=(build_instrument("Kick", 16),))) + + assert destination.is_dir() + + def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = SampleExport( + name="Kick", + instruments=( + build_instrument("Short", 16), + build_instrument("Long", 300), + build_instrument("Longer", 410), + ), + ) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=410, + instruments=2, + ) + + def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = SampleExport(name="Kick", instruments=(build_instrument("Short", 16),)) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation is None From 606862774f5bf617991786dd3a50472123ed35fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 23:55:49 +0200 Subject: [PATCH 02/20] Added: Bitphase support --- .../logic/reconstruction/reconstruction.py | 10 + src/sampletones_core/bitphase/__init__.py | 0 src/sampletones_core/bitphase/btp.py | 42 ++ src/sampletones_core/bitphase/builder.py | 431 ++++++++++++++++++ src/sampletones_core/bitphase/envelopes.py | 127 ++++++ src/sampletones_core/bitphase/identifiers.py | 20 + .../bitphase/model/__init__.py | 0 src/sampletones_core/bitphase/model/config.py | 10 + .../bitphase/model/instrument.py | 154 +++++++ .../bitphase/model/pattern.py | 110 +++++ .../bitphase/model/project.py | 54 +++ src/sampletones_core/bitphase/model/song.py | 64 +++ src/sampletones_core/bitphase/model/table.py | 41 ++ src/sampletones_core/bitphase/notes.py | 75 +++ src/sampletones_core/bitphase/preset.py | 106 +++++ .../bitphase/specification/__init__.py | 0 .../bitphase/specification/channels.py | 31 ++ .../bitphase/specification/chip.py | 35 ++ .../bitphase/specification/instruments.py | 50 ++ .../bitphase/specification/patterns.py | 43 ++ src/sampletones_core/bitphase/tuning.py | 41 ++ src/sampletones_core/exporters/slices.py | 86 ++++ src/sampletones_core/famitracker/builder.py | 101 ++-- src/sampletones_core/trackers/bitphase.py | 126 +++++ src/sampletones_core/trackers/format.py | 2 + src/sampletones_core/trackers/registry.py | 7 +- src/sampletones_core/trackers/request.py | 4 + .../services/test_export.py | 25 +- .../services/export/test_service.py | 6 +- .../trackers/test_famitracker.py | 30 +- 30 files changed, 1765 insertions(+), 66 deletions(-) create mode 100644 src/sampletones_core/bitphase/__init__.py create mode 100644 src/sampletones_core/bitphase/btp.py create mode 100644 src/sampletones_core/bitphase/builder.py create mode 100644 src/sampletones_core/bitphase/envelopes.py create mode 100644 src/sampletones_core/bitphase/identifiers.py create mode 100644 src/sampletones_core/bitphase/model/__init__.py create mode 100644 src/sampletones_core/bitphase/model/config.py create mode 100644 src/sampletones_core/bitphase/model/instrument.py create mode 100644 src/sampletones_core/bitphase/model/pattern.py create mode 100644 src/sampletones_core/bitphase/model/project.py create mode 100644 src/sampletones_core/bitphase/model/song.py create mode 100644 src/sampletones_core/bitphase/model/table.py create mode 100644 src/sampletones_core/bitphase/notes.py create mode 100644 src/sampletones_core/bitphase/preset.py create mode 100644 src/sampletones_core/bitphase/specification/__init__.py create mode 100644 src/sampletones_core/bitphase/specification/channels.py create mode 100644 src/sampletones_core/bitphase/specification/chip.py create mode 100644 src/sampletones_core/bitphase/specification/instruments.py create mode 100644 src/sampletones_core/bitphase/specification/patterns.py create mode 100644 src/sampletones_core/bitphase/tuning.py create mode 100644 src/sampletones_core/exporters/slices.py create mode 100644 src/sampletones_core/trackers/bitphase.py diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 22318490..2d7ff210 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -231,6 +231,7 @@ def handle_export_instruments_confirmed(self, directory: Path) -> None: self._instrument_export(generator_name, feature) for generator_name, feature in reconstruction_data.feature_data.generators.items() ), + nes_frequency=self._nes_frequency(), ) self._session_manager.set_instrument_path(directory.parent) self._export_service.export_sample(directory, self._export_backend, request) @@ -250,8 +251,17 @@ def _instrument_export( generator=generator_name, features=feature, loop=False, + nes_frequency=self._nes_frequency(), ) + def _nes_frequency(self) -> int: + """The rate the loaded reconstruction's envelopes advance at, in Hz.""" + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + raise AssertionError("Expected reconstruction data to be present") + + return reconstruction_data.config.library.nes_frequency + def handle_export_wav_confirmed(self, filepath: Path) -> None: reconstruction_data = self._reconstruction_data if not reconstruction_data: diff --git a/src/sampletones_core/bitphase/__init__.py b/src/sampletones_core/bitphase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/bitphase/btp.py b/src/sampletones_core/bitphase/btp.py new file mode 100644 index 00000000..9a5b681b --- /dev/null +++ b/src/sampletones_core/bitphase/btp.py @@ -0,0 +1,42 @@ +import gzip +import json +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.bitphase.model.project import BitphaseProject + +JSON_SEPARATORS: Final[Tuple[str, str]] = (",", ":") +FIXED_TIMESTAMP: Final[int] = 0 + + +def project_to_bytes(project: BitphaseProject) -> bytes: + """Serializes a document the way Bitphase reads it back. + + A ``.btp`` is the document's JSON under gzip, written without separator padding + and with a fixed timestamp, so exporting the same document twice yields the same + bytes. + + Args: + project: The document to serialize. + + Returns: + bytes: The file's contents. + """ + payload = json.dumps( + project.model_dump(mode="json", by_alias=True), + separators=JSON_SEPARATORS, + ) + return gzip.compress(payload.encode("utf-8"), mtime=FIXED_TIMESTAMP) + + +def write_btp(destination: Path, project: BitphaseProject) -> None: + """Writes a Bitphase document to disk. + + Args: + destination: The file to write. + project: The document to serialize. + + Raises: + OSError: If the destination cannot be written. + """ + destination.write_bytes(project_to_bytes(project)) diff --git a/src/sampletones_core/bitphase/builder.py b/src/sampletones_core/bitphase/builder.py new file mode 100644 index 00000000..85f38154 --- /dev/null +++ b/src/sampletones_core/bitphase/builder.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Dict, List, Sequence, Tuple + +from sampletones_core.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.bitphase.identifiers import format_instrument_id +from sampletones_core.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.bitphase.model.pattern import ( + BitphaseChannel, + BitphasePattern, + BitphaseRow, + NoteCell, +) +from sampletones_core.bitphase.model.project import BitphaseProject +from sampletones_core.bitphase.model.song import BitphaseSong +from sampletones_core.bitphase.model.table import BitphaseTable +from sampletones_core.bitphase.notes import ( + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX +from sampletones_core.bitphase.specification.chip import ( + CPU_FREQUENCIES, + DEFAULT_A4_TUNING, + DEFAULT_CHIP_VARIANT, +) +from sampletones_core.bitphase.specification.instruments import ( + MAX_INSTRUMENT_ID, + MAX_TABLE_ID, + MIN_INSTRUMENT_ID, + MIN_TABLE_ID, +) +from sampletones_core.bitphase.specification.patterns import ( + FIRST_PATTERN_ID, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_VOLUME_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.bitphase.tuning import generate_tuning_table +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED + +PREVIEW_SPEED = DEFAULT_SPEED +PREVIEW_TRIGGER_ROW = 0 +PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1 +NO_AUTHOR = "" + + +@dataclass(frozen=True) +class Voice: + """One built instrument together with the table and the note that triggers it. + + Attributes: + number: Value a pattern's instrument column carries to play the instrument. + instrument: The per-tick rows the channel takes on. + table: The per-tick semitone contour that moves the note. + generator: The NES channel the slice was reconstructed for. + initial_pitch: Pitch the slice's contour is measured against. + ticks: How many ticks the instrument runs before it loops. + """ + + number: int + instrument: BitphaseInstrument + table: BitphaseTable + generator: GeneratorName + initial_pitch: int + ticks: int + + +VoiceTable = Dict[Tuple[str, GeneratorName], Voice] + + +def _build_voice( + index: int, + name: str, + generator: GeneratorName, + initial_pitch: int, + envelopes: ChannelEnvelopes, +) -> Voice: + """Numbers one generator slice and packages it as an instrument-and-table pair. + + Instruments and tables are numbered alike, so a pattern cell names the same position + in both columns. + + Raises: + ValueError: If the position runs past what a pattern column can name. + """ + number = index + MIN_INSTRUMENT_ID + if number > MAX_INSTRUMENT_ID: + raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments") + + table_id = index + MIN_TABLE_ID + if table_id > MAX_TABLE_ID: + raise ValueError(f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables") + + return Voice( + number=number, + instrument=BitphaseInstrument( + id=format_instrument_id(number), + rows=envelopes.rows, + loop=envelopes.loop, + name=name, + ), + table=BitphaseTable( + id=table_id, + rows=envelopes.table_rows, + loop=envelopes.loop, + name=name, + ), + generator=generator, + initial_pitch=initial_pitch, + ticks=len(envelopes.rows), + ) + + +def _note_cell(channel_generator: GeneratorName, pitch: int) -> NoteCell: + """Resolves a pitch to the note column of the channel the row sits on. + + The noise channel reads its note as a period selector, so its pitch takes the + mapping that reproduces that period; every other channel reads the tuning table. + """ + if channel_generator == GeneratorName.NOISE: + return note_index_to_note_cell(noise_period_to_note_index(pitch)) + + return note_index_to_note_cell(pitch_to_note_index(pitch)) + + +def _trigger_row(voice: Voice, note: NoteCell, volume: int) -> BitphaseRow: + return BitphaseRow( + note=note, + instrument=voice.number, + table=voice.table.id + TABLE_COLUMN_OFFSET, + volume=volume, + ) + + +def _empty_channels(length: int) -> List[List[BitphaseRow]]: + return [[BitphaseRow() for _ in range(length)] for _ in CHANNEL_LABELS] + + +def _to_pattern( + pattern_id: int, + length: int, + channel_rows: Sequence[Sequence[BitphaseRow]], +) -> BitphasePattern: + channels = tuple( + BitphaseChannel(rows=tuple(rows), label=label) + for label, rows in zip( + CHANNEL_LABELS, + channel_rows, + ) + ) + return BitphasePattern(id=pattern_id, length=length, channels=channels) + + +def _build_song( + patterns: Tuple[BitphasePattern, ...], + *, + speed: int, + nes_frequency: int, +) -> BitphaseSong: + chip_frequency = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] + return BitphaseSong( + patterns=patterns, + tuning_table=generate_tuning_table( + chip_frequency, + a4_tuning=DEFAULT_A4_TUNING, + ), + initial_speed=speed, + chip_frequency=chip_frequency, + interrupt_frequency=nes_frequency, + ) + + +def _preview_length(voices: Sequence[Voice]) -> int: + """Sizes the preview pattern so a full line of it covers the longest instrument.""" + rows = math.ceil(max((voice.ticks for voice in voices), default=0) / PREVIEW_SPEED) + return max( + MIN_PATTERN_LENGTH, + min(MAX_PATTERN_LENGTH, max(rows, DEFAULT_ROWS_PER_PATTERN)), + ) + + +def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]: + """Spaces the trigger far enough apart for the longest instrument to play through. + + Every order position past the first plays a resting pattern, so an instrument that + outlasts a single pattern still reaches its end before the trigger comes round again. + """ + ticks = max((voice.ticks for voice in voices), default=0) + positions = max(1, math.ceil(ticks / (length * PREVIEW_SPEED))) + return (FIRST_PATTERN_ID,) + (PREVIEW_REST_PATTERN_ID,) * (positions - 1) + + +def _preview_patterns( + voices: Sequence[Voice], + length: int, + positions: int, +) -> Tuple[BitphasePattern, ...]: + channel_rows = _empty_channels(length) + for voice in voices: + channel = GENERATOR_NAME_TO_CHANNEL_INDEX[voice.generator] + note = _note_cell(voice.generator, voice.initial_pitch) + channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row( + voice, + note, + FULL_VOLUME, + ) + + patterns = [_to_pattern(FIRST_PATTERN_ID, length, channel_rows)] + if positions > 1: + patterns.append( + _to_pattern(PREVIEW_REST_PATTERN_ID, length, _empty_channels(length)), + ) + + return tuple(patterns) + + +def sample_to_bitphase(request: SampleExport) -> BitphaseProject: + """Builds a playable Bitphase document holding one reconstruction's instruments. + + Every generator slice becomes an instrument and the table that carries its pitch + contour, and one pattern triggers each slice on the channel it was reconstructed + for, so opening the document and pressing play sounds the reconstruction. + + Args: + request: The reconstruction's slices. + + Returns: + BitphaseProject: The document to serialize. + + Raises: + ValueError: If the reconstruction holds more slices than Bitphase has room for. + """ + voices = [ + _build_voice( + index, + instrument.name, + instrument.generator, + instrument.features.initial_pitch, + features_to_envelopes( + instrument.features, + instrument.generator, + loop=instrument.loop, + ), + ) + for index, instrument in enumerate(request.instruments) + ] + + length = _preview_length(voices) + order = _preview_order(voices, length) + patterns = _preview_patterns(voices, length, len(order)) + + return BitphaseProject( + name=request.name, + author=NO_AUTHOR, + songs=(_build_song(patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency),), + pattern_order=order, + tables=tuple(voice.table for voice in voices), + instruments=tuple(voice.instrument for voice in voices), + ) + + +def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: + """Builds a playable Bitphase document holding one generator slice. + + Args: + request: The slice to write. + + Returns: + BitphaseProject: The document to serialize. + """ + sample = SampleExport( + name=request.name, + instruments=(request,), + nes_frequency=request.nes_frequency, + ) + return sample_to_bitphase(sample) + + +def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]: + voices: List[Voice] = [] + by_reference: VoiceTable = {} + + for sample_slice in iterate_sample_slices(project): + envelopes = features_to_envelopes( + sample_slice.features, + sample_slice.generator, + loop=sample_slice.sample.loop, + ) + voice = _build_voice( + sample_slice.index, + sample_slice.instrument_name, + sample_slice.generator, + sample_slice.features.initial_pitch, + envelopes, + ) + voices.append(voice) + by_reference[sample_slice.key] = voice + + return voices, by_reference + + +def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: + voice = voices.get((reference.sample_id, reference.generator_name)) + if voice is None: + raise ValueError( + f"Row references sample '{reference.sample_id}' slice " + f"'{reference.generator_name}' that has no instrument" + ) + + return voice + + +def _row_cell( + row: Row, + channel_generator: GeneratorName, + voices: VoiceTable, +) -> BitphaseRow: + """Converts one tracker line to the Bitphase row that plays it. + + Raises: + ValueError: If the line references a sample slice that has no instrument. + """ + volume = row.volume if row.volume is not None else NO_VOLUME_CHANGE + cell = BitphaseRow(volume=volume) + + match row.command: + case NoteOff(): + cell = BitphaseRow( + note=NoteCell(name=int(NoteName.OFF)), + volume=volume, + ) + case Instrument() as reference: + voice = _resolve_voice(reference, voices) + pitch = voice.initial_pitch + (row.transpose or 0) + cell = _trigger_row( + voice, + _note_cell(channel_generator, pitch), + volume, + ) + case None: + pass + + return cell + + +def _channel_rows( + rows: Sequence[Row], + length: int, + generator: GeneratorName, + voices: VoiceTable, +) -> List[BitphaseRow]: + cells = [_row_cell(row, generator, voices) for row in rows[:length]] + cells.extend(BitphaseRow() for _ in range(length - len(cells))) + return cells + + +def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePattern, ...]: + """Flattens the song's per-channel arrangement into whole-pattern order positions. + + A SampleToNES order frame points every channel at its own pattern, where a Bitphase + order position names one pattern that spans all channels, so each frame becomes a + pattern of its own carrying that frame's channels side by side. + """ + song = project.song + length = song.rows_per_pattern + patterns: List[BitphasePattern] = [] + + for position, frame in enumerate(song.order): + channel_rows = _empty_channels(length) + for generator in GeneratorName.items(): + index = frame.get(generator) + if index is None: + continue + + pattern = song.channels[generator].pattern(index) + if pattern is None: + continue + + channel = GENERATOR_NAME_TO_CHANNEL_INDEX[generator] + channel_rows[channel] = _channel_rows( + pattern.rows, + length, + generator, + voices, + ) + + patterns.append(_to_pattern(position, length, channel_rows)) + + return tuple(patterns) + + +def project_to_bitphase(project: Project) -> BitphaseProject: + """Maps a project's samples and song onto the Bitphase document IR. + + Args: + project: The project to write. + + Returns: + BitphaseProject: The document to serialize. + + Raises: + ValueError: If the project holds more than Bitphase has room for, or a row + references a sample slice that has no instrument. + """ + voices, by_reference = _build_voice_table(project) + patterns = _project_patterns(project, by_reference) + settings = project.settings + info = project.info + + return BitphaseProject( + name=info.title, + author=info.author, + songs=(_build_song(patterns, speed=settings.speed, nes_frequency=settings.nes_frequency),), + pattern_order=tuple(pattern.id for pattern in patterns), + tables=tuple(voice.table for voice in voices), + instruments=tuple(voice.instrument for voice in voices), + ) diff --git a/src/sampletones_core/bitphase/envelopes.py b/src/sampletones_core/bitphase/envelopes.py new file mode 100644 index 00000000..aaac4ac4 --- /dev/null +++ b/src/sampletones_core/bitphase/envelopes.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from typing import Dict, Final, Optional, Tuple + +import numpy as np + +from sampletones_core.bitphase.model.instrument import NesInstrumentRow +from sampletones_core.bitphase.notes import noise_arpeggio_to_table_offset +from sampletones_core.bitphase.specification.instruments import ( + FLAT_PULSE_WIDTH, + LOOP_FROM_START, + NO_TABLE_OFFSET, + NOISE_MODE_LONG, + NOISE_MODE_SHORT, + SILENT_VOLUME, +) +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.lengths import equalize_lengths + +SILENT_ROW: Final[NesInstrumentRow] = NesInstrumentRow( + pulse_width=FLAT_PULSE_WIDTH, + volume_or_rate=SILENT_VOLUME, +) + + +@dataclass(frozen=True) +class ChannelEnvelopes: + """One generator slice expressed the way Bitphase plays it back. + + The instrument rows and the table rows advance on their own per-tick counters, so + they share a length and a loop point and stay in step for as long as the note + sounds. + + Attributes: + rows: Instrument rows, one per engine tick. + table_rows: Semitone offsets, one per engine tick. + loop: Row both lists return to once they run off the end. + """ + + rows: Tuple[NesInstrumentRow, ...] + table_rows: Tuple[int, ...] + loop: int + + +def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: + if array is None: + return () + return tuple(int(value) for value in array) + + +def _pulse_width(generator: GeneratorName, duty_cycle: int) -> int: + """Reads a duty-cycle item as the field the channel uses it for. + + A square channel takes it as the duty itself; the noise channel takes any nonzero + value as its short LFSR mode; the triangle channel plays one fixed waveform. + """ + match generator: + case GeneratorName.PULSE1 | GeneratorName.PULSE2: + return duty_cycle + case GeneratorName.NOISE: + return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG + case GeneratorName.TRIANGLE: + return FLAT_PULSE_WIDTH + + +def _table_offset(generator: GeneratorName, arpeggio: int) -> int: + if generator == GeneratorName.NOISE: + return noise_arpeggio_to_table_offset(arpeggio) + + return arpeggio + + +def features_to_envelopes( + features: Features, + generator: GeneratorName, + *, + loop: bool, +) -> ChannelEnvelopes: + """Converts one generator slice's envelopes into Bitphase instrument and table rows. + + Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's + waveform field, and the arpeggio becomes the table contour that moves the note. A + looping slice returns to its first row so it sustains for as long as the note is + held; a one-shot returns to its last row, which the volume envelope already leaves + silent, so it rests there once it has played through. + + Args: + features: The per-dimension envelopes describing the slice. + generator: The NES channel the slice was reconstructed for. + loop: Whether the instrument repeats its envelopes while its note is held. + + Returns: + ChannelEnvelopes: The rows, contour, and loop point describing the slice. + """ + arrays: Dict[FeatureKey, Optional[np.ndarray]] = { + FeatureKey.VOLUME: features.volume, + FeatureKey.ARPEGGIO: features.arpeggio, + FeatureKey.DUTY_CYCLE: features.duty_cycle, + } + items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) + + volumes = items[FeatureKey.VOLUME] + arpeggios = items[FeatureKey.ARPEGGIO] + duty_cycles = items[FeatureKey.DUTY_CYCLE] + + if not volumes: + return ChannelEnvelopes( + rows=(SILENT_ROW,), + table_rows=(NO_TABLE_OFFSET,), + loop=LOOP_FROM_START, + ) + + rows = tuple( + NesInstrumentRow( + pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), + volume_or_rate=volume, + ) + for frame, volume in enumerate(volumes) + ) + contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes) + table_rows = tuple(_table_offset(generator, arpeggio) for arpeggio in contour) + + return ChannelEnvelopes( + rows=rows, + table_rows=table_rows, + loop=LOOP_FROM_START if loop else len(rows) - 1, + ) diff --git a/src/sampletones_core/bitphase/identifiers.py b/src/sampletones_core/bitphase/identifiers.py new file mode 100644 index 00000000..a5caaf9c --- /dev/null +++ b/src/sampletones_core/bitphase/identifiers.py @@ -0,0 +1,20 @@ +from sampletones_core.bitphase.specification.instruments import INSTRUMENT_ID_DIGITS +from sampletones_core.bitphase.specification.patterns import SYMBOL_BASE, SYMBOL_DIGITS + + +def format_instrument_id(number: int) -> str: + """Renders an instrument number as the base-36 text a pattern column matches on. + + Args: + number: Instrument number, at most :data:`MAX_INSTRUMENT_ID`. + + Returns: + str: The number in base 36, padded to the width of the instrument column. + """ + digits = "" + remaining = number + for _ in range(INSTRUMENT_ID_DIGITS): + remaining, digit = divmod(remaining, SYMBOL_BASE) + digits = SYMBOL_DIGITS[digit] + digits + + return digits diff --git a/src/sampletones_core/bitphase/model/__init__.py b/src/sampletones_core/bitphase/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/bitphase/model/config.py b/src/sampletones_core/bitphase/model/config.py new file mode 100644 index 00000000..8546c1ec --- /dev/null +++ b/src/sampletones_core/bitphase/model/config.py @@ -0,0 +1,10 @@ +from typing import Final + +from pydantic import ConfigDict +from pydantic.alias_generators import to_camel + +BITPHASE_MODEL_CONFIG: Final[ConfigDict] = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + frozen=True, +) diff --git a/src/sampletones_core/bitphase/model/instrument.py b/src/sampletones_core/bitphase/model/instrument.py new file mode 100644 index 00000000..54ab7258 --- /dev/null +++ b/src/sampletones_core/bitphase/model/instrument.py @@ -0,0 +1,154 @@ +from typing import Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.bitphase.specification.instruments import ( + ABSOLUTE_TONE, + CONSTANT_VOLUME, + KEEP_PHASE, + LOOP_FROM_START, + MAX_PULSE_WIDTH, + MAX_SOUND_LENGTH, + MAX_SWEEP_RATE, + MAX_SWEEP_SHIFT, + MAX_TONE_ADD, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_SOUND_LENGTH, + MIN_SWEEP_RATE, + MIN_SWEEP_SHIFT, + MIN_TONE_ADD, + MIN_VOLUME_OR_RATE, + NO_SWEEP, + NO_SWEEP_RATE, + NO_SWEEP_SHIFT, + NO_TONE_OFFSET, + SUSTAINED_SOUND_LENGTH, +) + + +class NesInstrumentRow(BaseModel): + """One tick of a Bitphase NES instrument. + + An instrument advances one row per engine tick, so a row carries every register + value the channel takes for that tick. ``pulse_width`` selects the duty on a square + channel and the LFSR mode on the noise channel; ``volume_or_rate`` is a literal + volume while ``envelope`` stays off. The remaining fields hold the settings a + reconstruction leaves alone: the note sustains, the pitch comes from the tuning + table, and the hardware sweep stays disabled. + """ + + model_config = BITPHASE_MODEL_CONFIG + + pulse_width: int = Field( + ..., + ge=MIN_PULSE_WIDTH, + le=MAX_PULSE_WIDTH, + description="Square duty cycle, or the noise channel's LFSR mode.", + ) + volume_or_rate: int = Field( + ..., + ge=MIN_VOLUME_OR_RATE, + le=MAX_VOLUME_OR_RATE, + description="Channel volume while the hardware envelope stays off.", + ) + retrigger: bool = Field( + default=KEEP_PHASE, + description="Restarts the waveform phase this tick.", + ) + sound_length: int = Field( + default=SUSTAINED_SOUND_LENGTH, + ge=MIN_SOUND_LENGTH, + le=MAX_SOUND_LENGTH, + description="Length counter in ticks; zero holds the note for as long as the envelope runs.", + ) + envelope: bool = Field( + default=CONSTANT_VOLUME, + description="Reads volume_or_rate as a decay rate.", + ) + tone_add: int = Field( + default=NO_TONE_OFFSET, + ge=MIN_TONE_ADD, + le=MAX_TONE_ADD, + description="Offset added to the tuning-table period on a square or triangle channel.", + ) + tone_accumulation: bool = Field( + default=ABSOLUTE_TONE, + description="Sums tone_add across ticks.", + ) + sweep: bool = Field( + default=NO_SWEEP, + description="Enables the square channel's sweep unit.", + ) + sweep_rate: int = Field( + default=NO_SWEEP_RATE, + ge=MIN_SWEEP_RATE, + le=MAX_SWEEP_RATE, + ) + sweep_shift: int = Field( + default=NO_SWEEP_SHIFT, + ge=MIN_SWEEP_SHIFT, + le=MAX_SWEEP_SHIFT, + ) + + +class BitphaseInstrument(BaseModel): + """A named row list one pattern cell triggers, held in a project's instrument list. + + ``id`` is the base-36 text a pattern's instrument column matches on, and ``loop`` + is the row playback returns to once it runs off the end. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: str = Field( + ..., + description="Base-36 identifier a pattern row references.", + ) + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip whose row layout the instrument uses.", + ) + rows: Tuple[NesInstrumentRow, ...] = Field( + ..., + description="One row per engine tick.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + name: str = Field( + ..., + description="Name shown in the instrument list.", + ) + + +class BitphaseInstrumentPreset(BaseModel): + """A single instrument as Bitphase's instruments panel loads and saves it. + + The panel writes the loaded rows into the instrument slot the user has selected, + which supplies the id and leaves this file carrying the rows alone. + """ + + model_config = BITPHASE_MODEL_CONFIG + + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip whose row layout the preset uses.", + ) + name: str = Field( + ..., + description="Name the preset offers for the instrument.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + rows: Tuple[NesInstrumentRow, ...] = Field( + ..., + description="One row per engine tick.", + ) diff --git a/src/sampletones_core/bitphase/model/pattern.py b/src/sampletones_core/bitphase/model/pattern.py new file mode 100644 index 00000000..838e0ce5 --- /dev/null +++ b/src/sampletones_core/bitphase/model/pattern.py @@ -0,0 +1,110 @@ +from typing import Dict, Optional, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.bitphase.specification.patterns import ( + EMPTY_OCTAVE, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + NoteName, +) + + +class NoteCell(BaseModel): + """The note column of one pattern row, naming a semitone and its octave.""" + + model_config = BITPHASE_MODEL_CONFIG + + name: int = Field( + default=int(NoteName.NONE), + ge=int(NoteName.NONE), + le=int(NoteName.B), + description="Semitone within the octave, or a non-pitched marker.", + ) + octave: int = Field( + default=EMPTY_OCTAVE, + ge=EMPTY_OCTAVE, + description="Octave the semitone sounds in.", + ) + + +class EffectCell(BaseModel): + """One effect column of a pattern row.""" + + model_config = BITPHASE_MODEL_CONFIG + + effect: int = Field(..., description="Effect identifier.") + delay: int = Field(default=0, description="Ticks the effect waits before it applies.") + parameter: int = Field(default=0, description="Effect argument.") + table_index: Optional[int] = Field( + default=None, + description="Table the effect drives, where it takes one.", + ) + + +class BitphaseRow(BaseModel): + """A single tracker line on one channel. + + Every column beyond the note carries its own "leave as it is" value, so a blank + line keeps whatever the channel already plays. + """ + + model_config = BITPHASE_MODEL_CONFIG + + note: NoteCell = Field(default_factory=NoteCell, description="Note column.") + effects: Tuple[Optional[EffectCell], ...] = Field( + default=(None,), + description="One entry per effect column.", + ) + instrument: int = Field( + default=NO_INSTRUMENT_CHANGE, + ge=NO_INSTRUMENT_CHANGE, + description="Instrument to play from this line on.", + ) + table: int = Field(default=NO_TABLE_CHANGE, description="Table to attach from this line on.") + volume: int = Field( + default=NO_VOLUME_CHANGE, + ge=NO_VOLUME_CHANGE, + le=FULL_VOLUME, + description="Channel volume from this line on.", + ) + + +class BitphaseChannel(BaseModel): + """One channel's lines within a pattern.""" + + model_config = BITPHASE_MODEL_CONFIG + + rows: Tuple[BitphaseRow, ...] = Field(..., description="One row per pattern line.") + label: str = Field(..., description="Name of the channel the lines drive.") + + +class BitphasePattern(BaseModel): + """One block of tracker lines across every channel. + + ``pattern_rows`` holds the columns a chip declares song-wide rather than per + channel; the 2A03 declares none, so Bitphase fills the block itself. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: int = Field(..., ge=0, description="Identifier the pattern order references.") + length: int = Field( + ..., + ge=MIN_PATTERN_LENGTH, + le=MAX_PATTERN_LENGTH, + description="Line count every channel of the pattern shares.", + ) + channels: Tuple[BitphaseChannel, ...] = Field( + ..., + description="One entry per chip channel.", + ) + pattern_rows: Tuple[Dict[str, int], ...] = Field( + default=(), + description="Song-wide columns, one entry per line.", + ) diff --git a/src/sampletones_core/bitphase/model/project.py b/src/sampletones_core/bitphase/model/project.py new file mode 100644 index 00000000..2fdd07bb --- /dev/null +++ b/src/sampletones_core/bitphase/model/project.py @@ -0,0 +1,54 @@ +from typing import Dict, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.bitphase.model.song import BitphaseSong +from sampletones_core.bitphase.model.table import BitphaseTable +from sampletones_core.bitphase.specification.patterns import FIRST_PATTERN_ID + + +class BitphaseProject(BaseModel): + """Everything one Bitphase document holds. + + Instruments and tables are owned by the project rather than by a song, so every + song addresses the same instrument list. ``pattern_order`` names the pattern each + order position plays, and ``loop_point_id`` is the position playback returns to. + """ + + model_config = BITPHASE_MODEL_CONFIG + + name: str = Field( + ..., + description="Title shown for the document.", + ) + author: str = Field( + ..., + description="Author credited for the document.", + ) + songs: Tuple[BitphaseSong, ...] = Field( + ..., + description="Every song the document holds.", + ) + loop_point_id: int = Field( + default=FIRST_PATTERN_ID, + ge=0, + description="Order position playback returns to at the end.", + ) + pattern_order: Tuple[int, ...] = Field( + ..., + description="Pattern id played at each order position.", + ) + tables: Tuple[BitphaseTable, ...] = Field( + ..., + description="Every semitone contour the patterns attach.", + ) + pattern_order_colors: Dict[int, str] = Field( + default_factory=dict, + description="Highlight colour per order position.", + ) + instruments: Tuple[BitphaseInstrument, ...] = Field( + ..., + description="Every instrument the patterns trigger.", + ) diff --git a/src/sampletones_core/bitphase/model/song.py b/src/sampletones_core/bitphase/model/song.py new file mode 100644 index 00000000..1100cb62 --- /dev/null +++ b/src/sampletones_core/bitphase/model/song.py @@ -0,0 +1,64 @@ +from typing import Dict, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.bitphase.model.pattern import BitphasePattern +from sampletones_core.bitphase.specification.chip import ( + CHIP_TYPE_NES, + DEFAULT_A4_TUNING, + DEFAULT_CHIP_VARIANT, + MAX_INITIAL_SPEED, + MIN_INITIAL_SPEED, + ChipVariant, +) + + +class BitphaseSong(BaseModel): + """One arrangement of patterns, along with the chip settings it plays under. + + ``interrupt_frequency`` is the engine tick rate in Hz, so it carries the rate a + reconstruction's envelopes were measured at; ``initial_speed`` is how many of those + ticks each pattern line lasts. + """ + + model_config = BITPHASE_MODEL_CONFIG + + patterns: Tuple[BitphasePattern, ...] = Field( + ..., + description="Every pattern the song holds.", + ) + tuning_table: Tuple[int, ...] = Field( + ..., + description="Channel period for each of the 96 note indices.", + ) + initial_speed: int = Field( + ..., + ge=MIN_INITIAL_SPEED, + le=MAX_INITIAL_SPEED, + description="Engine ticks per pattern line.", + ) + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip the song drives.", + ) + chip_variant: ChipVariant = Field( + default=DEFAULT_CHIP_VARIANT, + description="System whose CPU clock applies.", + ) + chip_frequency: int = Field( + ..., + description="CPU clock in Hz the tuning table was built from.", + ) + interrupt_frequency: int = Field( + ..., + description="Engine tick rate in Hz.", + ) + a4_tuning_hz: float = Field( + default=DEFAULT_A4_TUNING, + description="Concert pitch the tuning table centres on.", + ) + virtual_channel_map: Dict[int, int] = Field( + default_factory=dict, + description="Extra channels folded onto hardware ones.", + ) diff --git a/src/sampletones_core/bitphase/model/table.py b/src/sampletones_core/bitphase/model/table.py new file mode 100644 index 00000000..126be666 --- /dev/null +++ b/src/sampletones_core/bitphase/model/table.py @@ -0,0 +1,41 @@ +from typing import Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.bitphase.specification.instruments import ( + LOOP_FROM_START, + MAX_TABLE_ID, + MIN_TABLE_ID, +) + + +class BitphaseTable(BaseModel): + """A per-tick semitone contour a pattern cell attaches to a channel. + + Playback adds ``rows[position]`` to the channel's note every tick, advancing one + row per tick, so a table carries the pitch movement a reconstruction's arpeggio + envelope describes. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: int = Field( + ..., + ge=MIN_TABLE_ID, + le=MAX_TABLE_ID, + description="Identifier a pattern's table column names.", + ) + rows: Tuple[int, ...] = Field( + ..., + description="Semitone offset applied on each tick.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + name: str = Field( + ..., + description="Name shown in the table list.", + ) diff --git a/src/sampletones_core/bitphase/notes.py b/src/sampletones_core/bitphase/notes.py new file mode 100644 index 00000000..a6f9c911 --- /dev/null +++ b/src/sampletones_core/bitphase/notes.py @@ -0,0 +1,75 @@ +from sampletones_core.bitphase.model.pattern import NoteCell +from sampletones_core.bitphase.specification.patterns import ( + FIRST_OCTAVE, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NOISE_BASE_NOTE_INDEX, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, + NoteName, +) +from sampletones_core.constants.general import NUM_PERIODS + + +def pitch_to_note_index(pitch: int) -> int: + """Converts an absolute pitch to the note index Bitphase tunes from. + + The index is clamped to the span the 96-entry tuning table covers, so an extreme + transposition lands on the nearest playable note. + + Args: + pitch: Absolute pitch, on the same scale the reconstruction records. + + Returns: + int: Index into the tuning table. + """ + index = pitch - NOTE_INDEX_PITCH_OFFSET + return max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, index)) + + +def note_index_to_note_cell(index: int) -> NoteCell: + """Converts a tuning-table index to the note and octave a pattern cell stores. + + Args: + index: Index into the tuning table. + + Returns: + NoteCell: The note column playback resolves back to ``index``. + """ + name = index % NOTE_RANGE + int(NoteName.C) + octave = index // NOTE_RANGE + FIRST_OCTAVE + return NoteCell(name=name, octave=octave) + + +def noise_period_to_note_index(period: int) -> int: + """Converts a noise period index to the note index that selects it. + + Playback reads a noise note as ``15 - (index mod 16)``, so every period repeats once + per sixteen note indices and any of those indices selects it. The base index sits + far enough below the top of the tuning table that a whole cycle of table offsets + stays in range. + + Args: + period: Noise period index the reconstruction chose. + + Returns: + int: Note index whose noise period equals ``period``. + """ + offset = (NUM_PERIODS - 1 - period) % NUM_PERIODS + return NOISE_BASE_NOTE_INDEX + offset + + +def noise_arpeggio_to_table_offset(step: int) -> int: + """Converts a noise arpeggio step to the semitone offset a table row carries. + + A rising noise period is a falling note index, so the step is negated and wrapped + into one period cycle, which keeps every note the table reaches inside the tuning + table. + + Args: + step: Period offset from the reconstruction's initial noise period. + + Returns: + int: Semitone offset that moves the noise period by ``step``. + """ + return (-step) % NUM_PERIODS diff --git a/src/sampletones_core/bitphase/preset.py b/src/sampletones_core/bitphase/preset.py new file mode 100644 index 00000000..594a33c0 --- /dev/null +++ b/src/sampletones_core/bitphase/preset.py @@ -0,0 +1,106 @@ +import json +from pathlib import Path +from typing import Final, Sequence, Tuple + +from sampletones_core.bitphase.envelopes import features_to_envelopes +from sampletones_core.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.bitphase.notes import pitch_to_note_index +from sampletones_core.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY +from sampletones_core.bitphase.specification.instruments import ( + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX +from sampletones_core.bitphase.tuning import generate_tuning_table +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.request import InstrumentExport + +PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( + DEFAULT_CPU_FREQUENCY, + a4_tuning=DEFAULT_A4_TUNING, +) +PRESET_JSON_INDENT: Final[int] = 2 + + +def _tone_offsets( + generator: GeneratorName, + initial_pitch: int, + contour: Sequence[int], +) -> Tuple[int, ...]: + """Expresses a semitone contour as the per-tick period offsets a preset carries. + + A preset holds rows alone, so its pitch movement rides in each row's tone offset. + The offsets are measured against the pitch the slice was reconstructed at, under the + tuning the NTSC system gives at concert pitch, which is what a freshly created + Bitphase document plays. The noise channel takes its period from the note rather + than from a period offset, so its rows hold a flat offset and the note carries the + pitch. + """ + if generator == GeneratorName.NOISE: + return (NO_TONE_OFFSET,) * len(contour) + + base_index = pitch_to_note_index(initial_pitch) + base_period = PRESET_TUNING_TABLE[base_index] + + offsets = [] + for semitones in contour: + index = max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, base_index + semitones)) + offset = PRESET_TUNING_TABLE[index] - base_period + offsets.append(max(MIN_TONE_ADD, min(MAX_TONE_ADD, offset))) + + return tuple(offsets) + + +def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: + """Builds the single-instrument file Bitphase's instruments panel loads. + + Args: + request: The generator slice to write. + + Returns: + BitphaseInstrumentPreset: The instrument to serialize. + """ + envelopes = features_to_envelopes( + request.features, + request.generator, + loop=request.loop, + ) + offsets = _tone_offsets( + request.generator, + request.features.initial_pitch, + envelopes.table_rows, + ) + rows: Tuple[NesInstrumentRow, ...] = tuple( + row.model_copy(update={"tone_add": offset}) + for row, offset in zip( + envelopes.rows, + offsets, + ) + ) + + return BitphaseInstrumentPreset( + name=request.name, + loop=envelopes.loop, + rows=rows, + ) + + +def write_preset(destination: Path, preset: BitphaseInstrumentPreset) -> None: + """Writes a Bitphase instrument preset to disk. + + The file is indented the way Bitphase writes its own, so a preset dropped into the + tracker's preset tree reads like the ones already there. + + Args: + destination: The file to write. + preset: The instrument to serialize. + + Raises: + OSError: If the destination cannot be written. + """ + payload = json.dumps( + preset.model_dump(mode="json", by_alias=True), + indent=PRESET_JSON_INDENT, + ) + destination.write_text(payload, encoding="utf-8") diff --git a/src/sampletones_core/bitphase/specification/__init__.py b/src/sampletones_core/bitphase/specification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/bitphase/specification/channels.py b/src/sampletones_core/bitphase/specification/channels.py new file mode 100644 index 00000000..838a6f55 --- /dev/null +++ b/src/sampletones_core/bitphase/specification/channels.py @@ -0,0 +1,31 @@ +from enum import IntEnum +from typing import Dict, Final, Tuple + +from sampletones_core.constants.enums import GeneratorName + + +class ChannelIndex(IntEnum): + """Position each 2A03 channel takes in a pattern's channel list.""" + + SQUARE1 = 0 + SQUARE2 = 1 + TRIANGLE = 2 + NOISE = 3 + DPCM = 4 + + +CHANNEL_LABELS: Final[Tuple[str, ...]] = ( + "Square 1", + "Square 2", + "Triangle", + "Noise", + "DPCM", +) +CHANNEL_COUNT: Final[int] = len(CHANNEL_LABELS) + +GENERATOR_NAME_TO_CHANNEL_INDEX: Final[Dict[GeneratorName, ChannelIndex]] = { + GeneratorName.PULSE1: ChannelIndex.SQUARE1, + GeneratorName.PULSE2: ChannelIndex.SQUARE2, + GeneratorName.TRIANGLE: ChannelIndex.TRIANGLE, + GeneratorName.NOISE: ChannelIndex.NOISE, +} diff --git a/src/sampletones_core/bitphase/specification/chip.py b/src/sampletones_core/bitphase/specification/chip.py new file mode 100644 index 00000000..24dd1152 --- /dev/null +++ b/src/sampletones_core/bitphase/specification/chip.py @@ -0,0 +1,35 @@ +from enum import StrEnum +from typing import Dict, Final + +from sampletones_core.constants.general import A4_FREQUENCY, APU_CLOCK + +CHIP_TYPE_NES: Final[str] = "nes" + + +class ChipVariant(StrEnum): + """NES system whose CPU clock drives the tuning table.""" + + NTSC = "NTSC" + PAL = "PAL" + DENDY = "Dendy" + + +CPU_FREQUENCIES: Final[Dict[ChipVariant, int]] = { + ChipVariant.NTSC: int(APU_CLOCK), + ChipVariant.PAL: 1_662_607, + ChipVariant.DENDY: 1_773_448, +} + +DEFAULT_CHIP_VARIANT: Final[ChipVariant] = ChipVariant.NTSC +DEFAULT_CPU_FREQUENCY: Final[int] = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] + +TUNING_TABLE_LENGTH: Final[int] = 96 +TUNING_A4_INDEX: Final[int] = 45 +TUNING_PERIOD_DIVISOR: Final[int] = 16 +MIN_TUNING_PERIOD: Final[int] = 1 +MAX_TUNING_PERIOD: Final[int] = 2047 + +DEFAULT_A4_TUNING: Final[float] = A4_FREQUENCY + +MIN_INITIAL_SPEED: Final[int] = 1 +MAX_INITIAL_SPEED: Final[int] = 255 diff --git a/src/sampletones_core/bitphase/specification/instruments.py b/src/sampletones_core/bitphase/specification/instruments.py new file mode 100644 index 00000000..655fa078 --- /dev/null +++ b/src/sampletones_core/bitphase/specification/instruments.py @@ -0,0 +1,50 @@ +from typing import Final + +from sampletones_core.bitphase.specification.patterns import ( + SYMBOL_BASE, + TABLE_COLUMN_OFFSET, +) +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_VOLUME + +INSTRUMENT_ID_DIGITS: Final[int] = 2 +MIN_INSTRUMENT_ID: Final[int] = 1 +MAX_INSTRUMENT_ID: Final[int] = SYMBOL_BASE**INSTRUMENT_ID_DIGITS - 1 + +TABLE_COLUMN_DIGITS: Final[int] = 1 +MAX_TABLE_COLUMN: Final[int] = SYMBOL_BASE**TABLE_COLUMN_DIGITS - 1 +MIN_TABLE_ID: Final[int] = 0 +MAX_TABLE_ID: Final[int] = MAX_TABLE_COLUMN - TABLE_COLUMN_OFFSET + +MIN_PULSE_WIDTH: Final[int] = 0 +MAX_PULSE_WIDTH: Final[int] = MAX_DUTY_CYCLE +FLAT_PULSE_WIDTH: Final[int] = 0 + +MIN_VOLUME_OR_RATE: Final[int] = 0 +MAX_VOLUME_OR_RATE: Final[int] = MAX_VOLUME +SILENT_VOLUME: Final[int] = 0 + +NOISE_MODE_LONG: Final[int] = 0 +NOISE_MODE_SHORT: Final[int] = 1 + +MIN_SOUND_LENGTH: Final[int] = 0 +MAX_SOUND_LENGTH: Final[int] = 511 +SUSTAINED_SOUND_LENGTH: Final[int] = 0 + +MIN_TONE_ADD: Final[int] = -4096 +MAX_TONE_ADD: Final[int] = 4095 +NO_TONE_OFFSET: Final[int] = 0 + +MIN_SWEEP_RATE: Final[int] = 0 +MAX_SWEEP_RATE: Final[int] = 7 +MIN_SWEEP_SHIFT: Final[int] = -7 +MAX_SWEEP_SHIFT: Final[int] = 7 +NO_SWEEP_RATE: Final[int] = 0 +NO_SWEEP_SHIFT: Final[int] = 0 + +CONSTANT_VOLUME: Final[bool] = False +ABSOLUTE_TONE: Final[bool] = False +KEEP_PHASE: Final[bool] = False +NO_SWEEP: Final[bool] = False + +LOOP_FROM_START: Final[int] = 0 +NO_TABLE_OFFSET: Final[int] = 0 diff --git a/src/sampletones_core/bitphase/specification/patterns.py b/src/sampletones_core/bitphase/specification/patterns.py new file mode 100644 index 00000000..29ced951 --- /dev/null +++ b/src/sampletones_core/bitphase/specification/patterns.py @@ -0,0 +1,43 @@ +from enum import IntEnum +from typing import Final + +from sampletones_core.bitphase.specification.chip import TUNING_TABLE_LENGTH + + +class NoteName(IntEnum): + """Reserved values of a pattern cell's note column. + + Pitched notes occupy ``2``..``13`` (C..B); the values below are the non-pitched + markers. + """ + + NONE = 0 + OFF = 1 + C = 2 + B = 13 + + +NOTE_RANGE: Final[int] = 12 +FIRST_OCTAVE: Final[int] = 1 +EMPTY_OCTAVE: Final[int] = 0 + +MIN_NOTE_INDEX: Final[int] = 0 +MAX_NOTE_INDEX: Final[int] = TUNING_TABLE_LENGTH - 1 +NOTE_INDEX_PITCH_OFFSET: Final[int] = 24 +NOISE_BASE_NOTE_INDEX: Final[int] = 48 + +SYMBOL_BASE: Final[int] = 36 +SYMBOL_DIGITS: Final[str] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +NO_INSTRUMENT_CHANGE: Final[int] = 0 +NO_TABLE_CHANGE: Final[int] = 0 +TABLE_OFF: Final[int] = -1 +TABLE_COLUMN_OFFSET: Final[int] = 1 + +NO_VOLUME_CHANGE: Final[int] = 0 +FULL_VOLUME: Final[int] = 15 + +MIN_PATTERN_LENGTH: Final[int] = 1 +MAX_PATTERN_LENGTH: Final[int] = 256 + +FIRST_PATTERN_ID: Final[int] = 0 diff --git a/src/sampletones_core/bitphase/tuning.py b/src/sampletones_core/bitphase/tuning.py new file mode 100644 index 00000000..b7827336 --- /dev/null +++ b/src/sampletones_core/bitphase/tuning.py @@ -0,0 +1,41 @@ +import math +from typing import Tuple + +from sampletones_core.bitphase.specification.chip import ( + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_A4_INDEX, + TUNING_PERIOD_DIVISOR, + TUNING_TABLE_LENGTH, +) +from sampletones_core.bitphase.specification.patterns import NOTE_RANGE + + +def generate_tuning_table( + chip_frequency: int, + *, + a4_tuning: float, + max_period: int = MAX_TUNING_PERIOD, +) -> Tuple[int, ...]: + """Builds the channel period Bitphase plays for each of its 96 note indices. + + Each index is one equal-tempered semitone, measured from the concert pitch that + sits at index 45, and its period is the CPU clock divided by the timer's own + divisor and the note's frequency. Rounding matches the tracker's, so a table built + here equals the one Bitphase derives from the same settings. + + Args: + chip_frequency: CPU clock in Hz. + a4_tuning: Frequency in Hz of the note at the concert-pitch index. + max_period: Longest period the channel timer holds. + + Returns: + Tuple[int, ...]: One period per note index, held within the timer's range. + """ + periods = [] + for index in range(TUNING_TABLE_LENGTH): + frequency = a4_tuning * 2 ** ((index - TUNING_A4_INDEX) / NOTE_RANGE) + period = math.floor(chip_frequency / TUNING_PERIOD_DIVISOR / frequency + 0.5) + periods.append(max(MIN_TUNING_PERIOD, min(max_period, period))) + + return tuple(periods) diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py new file mode 100644 index 00000000..f2933aa1 --- /dev/null +++ b/src/sampletones_core/exporters/slices.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterator, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project + + +@dataclass(frozen=True) +class InstrumentSlot: + """Where a sample's generator slice landed in the instrument table.""" + + index: int + initial_pitch: int + + +InstrumentTable = Dict[Tuple[str, GeneratorName], InstrumentSlot] + + +@dataclass(frozen=True) +class SampleSlice: + """One generator slice of a project sample, numbered for the instrument table. + + Attributes: + index: Position the slice takes in the exported instrument table. + sample: The sample whose reconstruction the slice came from. + generator: The NES channel the slice covers. + features: The per-dimension envelopes describing the slice. + """ + + index: int + sample: Sample + generator: GeneratorName + features: Features + + @property + def instrument_name(self) -> str: + """The exported instrument's name, naming both its sample and its channel.""" + return f"{self.sample.name} {self.generator.capitalized}" + + @property + def key(self) -> Tuple[str, GeneratorName]: + """The identity a pattern row references the slice by.""" + return (self.sample.id, self.generator) + + @property + def slot(self) -> InstrumentSlot: + """The table position and reference pitch a pattern row resolves through.""" + return InstrumentSlot( + index=self.index, + initial_pitch=self.features.initial_pitch, + ) + + +def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: + """Walks every generator slice of every sample in instrument-table order. + + A sample contributes one slice per channel its reconstruction covers, so it yields + one to four. Slices are numbered in sample order, then channel order, which fixes + the instrument numbering every tracker format builds on. Each sample's features are + exported once, so a caller reads a reconstruction's envelopes at a single cost. + + Args: + project: The project whose samples are exported. + + Yields: + SampleSlice: Each slice alongside the index it takes in the instrument table. + """ + index = 0 + for sample in project.samples: + features_by_generator = sample.reconstruction.export() + for generator in GeneratorName.items(): + features = features_by_generator.get(generator) + if features is None: + continue + + yield SampleSlice( + index=index, + sample=sample, + generator=generator, + features=features, + ) + index += 1 diff --git a/src/sampletones_core/famitracker/builder.py b/src/sampletones_core/famitracker/builder.py index 1dcc6d66..ac150c0b 100644 --- a/src/sampletones_core/famitracker/builder.py +++ b/src/sampletones_core/famitracker/builder.py @@ -1,9 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.slices import ( + InstrumentSlot, + InstrumentTable, + iterate_sample_slices, +) from sampletones_core.famitracker.model.instrument import Instrument2A03 from sampletones_core.famitracker.model.module import ( FamiTrackerModule, @@ -54,17 +58,6 @@ from sampletones_core.project.song import Song -@dataclass(frozen=True) -class InstrumentSlot: - """Where a sample's generator slice landed in the instrument table.""" - - index: int - initial_pitch: int - - -InstrumentTable = Dict[Tuple[str, GeneratorName], InstrumentSlot] - - def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]: """Builds one FamiTracker instrument per generator slice of every sample. @@ -75,28 +68,27 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst instruments: List[Instrument2A03] = [] slots: InstrumentTable = {} - for sample in project.samples: - features_by_generator = sample.reconstruction.export() - for generator in GeneratorName.items(): - features = features_by_generator.get(generator) - if features is None: - continue - - index = len(instruments) - if index >= MAX_INSTRUMENTS: - raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") - - sequences = features_to_instrument_sequences( - volume=features.volume, - arpeggio=features.arpeggio, - pitch=features.pitch, - hi_pitch=features.hi_pitch, - duty_cycle=features.duty_cycle, - loop=sample.loop, + for sample_slice in iterate_sample_slices(project): + if sample_slice.index >= MAX_INSTRUMENTS: + raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") + + features = sample_slice.features + sequences = features_to_instrument_sequences( + volume=features.volume, + arpeggio=features.arpeggio, + pitch=features.pitch, + hi_pitch=features.hi_pitch, + duty_cycle=features.duty_cycle, + loop=sample_slice.sample.loop, + ) + instruments.append( + Instrument2A03( + index=sample_slice.index, + name=sample_slice.instrument_name, + sequences=sequences, ) - name = f"{sample.name} {generator.capitalized}" - instruments.append(Instrument2A03(index=index, name=name, sequences=sequences)) - slots[(sample.id, generator)] = InstrumentSlot(index=index, initial_pitch=features.initial_pitch) + ) + slots[sample_slice.key] = sample_slice.slot return instruments, slots @@ -137,7 +129,12 @@ def _row_cell( f"'{reference.generator_name}' that has no instrument" ) instrument = slot.index - note, octave = _note_and_octave(reference, row.transpose or 0, channel_generator, slot) + note, octave = _note_and_octave( + reference, + row.transpose or 0, + channel_generator, + slot, + ) case None: pass @@ -162,7 +159,11 @@ def _has_data(cell: RowCell) -> bool: ) -def _channel_patterns(generator: GeneratorName, channel: Channel, slots: InstrumentTable) -> List[PatternData]: +def _channel_patterns( + generator: GeneratorName, + channel: Channel, + slots: InstrumentTable, +) -> List[PatternData]: channel_id = GENERATOR_NAME_TO_CHANNEL_ID[generator] patterns: List[PatternData] = [] @@ -173,11 +174,25 @@ def _channel_patterns(generator: GeneratorName, channel: Channel, slots: Instrum pattern = channel.patterns[index] rows = tuple( cell - for cell in (_row_cell(row, row_number, generator, slots) for row_number, row in enumerate(pattern.rows)) + for cell in ( + _row_cell( + row, + row_number, + generator, + slots, + ) + for row_number, row in enumerate(pattern.rows) + ) if cell is not None ) if rows: - patterns.append(PatternData(channel=channel_id, index=index, rows=rows)) + patterns.append( + PatternData( + channel=channel_id, + index=index, + rows=rows, + ) + ) return patterns @@ -227,11 +242,17 @@ def project_to_module(project: Project) -> FamiTrackerModule: highlight_second=DEFAULT_HIGHLIGHT_SECOND, speed_split_point=DEFAULT_SPEED_SPLIT_POINT, ) - information = ModuleInformation(title=info.title, author=info.author, copyright=DEFAULT_COPYRIGHT) + information = ModuleInformation( + title=info.title, + author=info.author, + copyright=DEFAULT_COPYRIGHT, + ) patterns: List[PatternData] = [] for generator in GeneratorName.items(): - patterns.extend(_channel_patterns(generator, song.channels[generator], slots)) + patterns.extend( + _channel_patterns(generator, song.channels[generator], slots), + ) track = Track( title=info.title, diff --git a/src/sampletones_core/trackers/bitphase.py b/src/sampletones_core/trackers/bitphase.py new file mode 100644 index 00000000..c2b3e553 --- /dev/null +++ b/src/sampletones_core/trackers/bitphase.py @@ -0,0 +1,126 @@ +from pathlib import Path +from typing import FrozenSet, List + +from sampletones_core.bitphase.btp import write_btp +from sampletones_core.bitphase.builder import ( + instrument_to_bitphase, + project_to_bitphase, + sample_to_bitphase, +) +from sampletones_core.bitphase.preset import instrument_to_preset, write_preset +from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport +from sampletones_core.trackers.scope import DestinationKind, ExportScope + +DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) +PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + +WHOLE_ENVELOPE: None = None + + +class BitphaseBackend: + """Writes Bitphase's ``.btp`` documents. + + A ``.btp`` holds a whole document, so every scope lands in one file: an instrument + and a reconstruction each become a playable document whose pattern triggers the + instruments it carries. Bitphase stores instrument and table rows without a length + limit, so every envelope crosses over whole. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.BITPHASE + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return DOCUMENT_SCOPES + + def destination_kind(self, scope: ExportScope) -> DestinationKind: + return DestinationKind.FILE + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_BITPHASE + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + write_btp(destination, instrument_to_bitphase(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + write_btp(destination, sample_to_bitphase(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + write_btp(destination, project_to_bitphase(request.project)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + +class BitphasePresetBackend: + """Writes the single-instrument ``.json`` files Bitphase's instruments panel loads. + + The panel reads one instrument per file into the slot the user has selected, so a + whole reconstruction lands as a directory of them, one file per generator slice + named after the instrument. A preset carries rows alone, so its pitch contour rides + in each row's tone offset. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.BITPHASE_PRESET + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return PRESET_SCOPES + + def destination_kind(self, scope: ExportScope) -> DestinationKind: + return DestinationKind.DIRECTORY if scope == ExportScope.SAMPLE else DestinationKind.FILE + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_JSON + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + write_preset(destination, instrument_to_preset(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + destination.mkdir(parents=True, exist_ok=True) + + paths: List[Path] = [] + for instrument in request.instruments: + filepath = destination / f"{instrument.name}{EXT_FILE_JSON}" + paths.extend(self.write_instrument(filepath, instrument).paths) + + return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + """Reports that a preset holds one instrument. + + Raises: + ValueError: Always, since a preset file carries a single instrument. + """ + raise ValueError("A Bitphase instrument preset holds one instrument, not a whole project") diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/trackers/format.py index 9cedb468..625f55e5 100644 --- a/src/sampletones_core/trackers/format.py +++ b/src/sampletones_core/trackers/format.py @@ -5,3 +5,5 @@ class TrackerFormat(StrEnum): """A file format one tracker reads, and the backend that writes it.""" FAMITRACKER = "famitracker" + BITPHASE = "bitphase" + BITPHASE_PRESET = "bitphase_preset" diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py index 4d2d5b2e..aae211c7 100644 --- a/src/sampletones_core/trackers/registry.py +++ b/src/sampletones_core/trackers/registry.py @@ -1,6 +1,7 @@ from typing import Dict from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.bitphase import BitphaseBackend, BitphasePresetBackend from sampletones_core.trackers.famitracker import FamiTrackerBackend from sampletones_core.trackers.format import TrackerFormat @@ -15,4 +16,8 @@ def build_tracker_backends() -> Dict[TrackerFormat, TrackerBackend]: Returns: Dict[TrackerFormat, TrackerBackend]: Every backend, keyed by the format it writes. """ - return {TrackerFormat.FAMITRACKER: FamiTrackerBackend()} + return { + TrackerFormat.FAMITRACKER: FamiTrackerBackend(), + TrackerFormat.BITPHASE: BitphaseBackend(), + TrackerFormat.BITPHASE_PRESET: BitphasePresetBackend(), + } diff --git a/src/sampletones_core/trackers/request.py b/src/sampletones_core/trackers/request.py index 7dd8443d..6b75932c 100644 --- a/src/sampletones_core/trackers/request.py +++ b/src/sampletones_core/trackers/request.py @@ -15,12 +15,14 @@ class InstrumentExport: generator: The NES channel the slice was reconstructed for. features: The per-dimension envelopes describing the slice. loop: Whether the instrument repeats its envelopes while its note is held. + nes_frequency: Rate in Hz the envelopes advance at, one item per tick. """ name: str generator: GeneratorName features: Features loop: bool + nes_frequency: int @dataclass(frozen=True) @@ -30,10 +32,12 @@ class SampleExport: Attributes: name: Name of the reconstruction the slices came from. instruments: One entry per channel the reconstruction covers. + nes_frequency: Rate in Hz the envelopes advance at, one item per tick. """ name: str instruments: Tuple[InstrumentExport, ...] + nes_frequency: int @dataclass(frozen=True) diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 4f8d7a50..d60a5414 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -1,4 +1,4 @@ -from typing import Any, List +from typing import Any, Final, List import numpy as np import pytest @@ -13,6 +13,8 @@ from sampletones_core.trackers.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport +NES_FREQUENCY: Final[int] = 60 + @pytest.fixture(name="backend") def backend_fixture() -> FamiTrackerBackend: @@ -25,9 +27,14 @@ def instrument_export(name: str, features: Features) -> InstrumentExport: generator=GeneratorName.PULSE1, features=features, loop=False, + nes_frequency=NES_FREQUENCY, ) +def sample_export(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + + class TestExportWavIntegration: def test_wav_file_is_created_on_disk(self, tmp_path, default_config) -> None: export_service = ExportService() @@ -117,12 +124,10 @@ def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features, backe results: List[Any] = [] export_service.subscribe(results.append) - request = SampleExport( - name="sample", - instruments=( - instrument_export("inst_0", pulse_features), - instrument_export("inst_1", pulse_features), - ), + request = sample_export( + "sample", + instrument_export("inst_0", pulse_features), + instrument_export("inst_1", pulse_features), ) export_service.export_sample(tmp_path, backend, request) @@ -134,7 +139,7 @@ def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_feat results: List[Any] = [] export_service.subscribe(results.append) - request = SampleExport(name="sample", instruments=(instrument_export("inst", pulse_features),)) + request = sample_export("sample", instrument_export("inst", pulse_features)) export_service.export_sample(tmp_path, backend, request) assert len(results) == 1 @@ -147,7 +152,7 @@ def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> No export_service = ExportService() export_service.subscribe(lambda _: None) - request = SampleExport(name="sample", instruments=(instrument_export("inst", pulse_features),)) + request = sample_export("sample", instrument_export("inst", pulse_features)) export_service.export_sample(new_dir, backend, request) assert new_dir.exists() @@ -157,7 +162,7 @@ def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> No results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_sample(tmp_path, backend, SampleExport(name="sample", instruments=())) + export_service.export_sample(tmp_path, backend, sample_export("sample")) assert list(tmp_path.glob("*.fti")) == [] assert isinstance(results[0], ExportSuccess) diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 8e89461a..129b197f 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, List, Optional, Tuple +from typing import Any, Final, List, Optional, Tuple from unittest.mock import patch import numpy as np @@ -21,6 +21,8 @@ ) from sampletones_core.trackers.scope import DestinationKind, ExportScope +NES_FREQUENCY: Final[int] = 60 + class StubBackend: """Records what the service asked for and returns a prepared artefact. @@ -81,6 +83,7 @@ def build_instrument(name: str = "Lead") -> InstrumentExport: duty_cycle=None, ), loop=False, + nes_frequency=NES_FREQUENCY, ) @@ -88,6 +91,7 @@ def build_sample(count: int = 2) -> SampleExport: return SampleExport( name="Kick", instruments=tuple(build_instrument(f"Kick {index}") for index in range(count)), + nes_frequency=NES_FREQUENCY, ) diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 7d7b1885..79ede84e 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Final, Optional import numpy as np import pytest @@ -14,6 +14,8 @@ from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import DestinationKind, ExportScope +NES_FREQUENCY: Final[int] = 60 + def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) @@ -33,9 +35,14 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: generator=GeneratorName.PULSE1, features=build_features(frames), loop=False, + nes_frequency=NES_FREQUENCY, ) +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + + @pytest.fixture(name="backend") def backend_fixture() -> FamiTrackerBackend: return FamiTrackerBackend() @@ -125,10 +132,7 @@ def test_each_slice_lands_in_a_file_named_after_its_instrument( tmp_path: Path, ) -> None: destination = tmp_path / "Kick" - request = SampleExport( - name="Kick", - instruments=(build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16)), - ) + request = build_sample("Kick", build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16)) artifact = backend.write_sample(destination, request) @@ -141,18 +145,16 @@ def test_each_slice_lands_in_a_file_named_after_its_instrument( def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: destination = tmp_path / "nested" / "Kick" - backend.write_sample(destination, SampleExport(name="Kick", instruments=(build_instrument("Kick", 16),))) + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", 16))) assert destination.is_dir() def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - request = SampleExport( - name="Kick", - instruments=( - build_instrument("Short", 16), - build_instrument("Long", 300), - build_instrument("Longer", 410), - ), + request = build_sample( + "Kick", + build_instrument("Short", 16), + build_instrument("Long", 300), + build_instrument("Longer", 410), ) artifact = backend.write_sample(tmp_path / "Kick", request) @@ -164,7 +166,7 @@ def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBacken ) def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - request = SampleExport(name="Kick", instruments=(build_instrument("Short", 16),)) + request = build_sample("Kick", build_instrument("Short", 16)) artifact = backend.write_sample(tmp_path / "Kick", request) From 67158f1375d1dfcd928f3df12d1adc3084370c4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 1 Aug 2026 09:51:07 +0200 Subject: [PATCH 03/20] Wired: Bitphase export --- src/sampletones_application/application.py | 17 +++- .../categories/elements/global_.py | 7 +- .../categories/trackers.py | 51 ++++++++++ .../coordinators/project.py | 99 ++++++++++++------- .../coordinators/tabs/reconstruction.py | 87 +++++++++++----- .../logic/project/controller.py | 8 +- .../logic/reconstruction/pending.py | 20 ++++ .../logic/reconstruction/reconstruction.py | 60 +++++++---- .../services/export/error.py | 4 + .../services/export/kind.py | 3 +- .../services/export/service.py | 37 ++++++- .../services/export/success.py | 3 + src/sampletones_application/shell.py | 12 ++- src/sampletones_application/ui/menu.py | 2 +- .../utils/gui/shortcuts/ids.py | 3 +- src/sampletones_config/lang/en.yaml | 7 +- src/sampletones_shared/constants/project.py | 2 +- .../services/test_export.py | 2 +- .../coordinators/tabs/test_reconstruction.py | 14 ++- .../coordinators/test_project.py | 3 +- .../reconstruction/test_reconstruction.py | 93 ++++++++++++++--- .../services/export/test_result.py | 51 +++++++--- .../services/export/test_service.py | 75 +++++++++++++- 23 files changed, 523 insertions(+), 137 deletions(-) create mode 100644 src/sampletones_application/categories/trackers.py create mode 100644 src/sampletones_application/logic/reconstruction/pending.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 1f4da232..b8dc3347 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Any, Dict, Final, Optional @@ -272,7 +273,8 @@ def __init__( self.project_controller, self.project_manager, self.session_manager, - export_backend=self.tracker_backends[TrackerFormat.FAMITRACKER], + self.export_service, + tracker_backends=self.tracker_backends, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -304,7 +306,7 @@ def __init__( reconstruction_manager=self.reconstruction_manager, browser_manager=self.browser_manager, export_service=self.export_service, - export_backend=self.tracker_backends[TrackerFormat.FAMITRACKER], + tracker_backends=self.tracker_backends, on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, on_reconstruct_file=self._reconstruct_file_dialog, on_reconstruct_directory=self._reconstruct_directory_dialog, @@ -478,7 +480,14 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: save_project=self._project_coordinator.save, save_project_as=self._project_coordinator.save_as_dialog, project_properties=self._open_project_properties, - export_project_module=self._project_coordinator.export_module_dialog, + export_project_famitracker=partial( + self._project_coordinator.export_project_dialog, + TrackerFormat.FAMITRACKER, + ), + export_project_bitphase=partial( + self._project_coordinator.export_project_dialog, + TrackerFormat.BITPHASE, + ), close_project=self._project_coordinator.close_with_confirmation, exit=self._on_close, undo=self._sequencer_tab.undo, @@ -735,7 +744,7 @@ def _export_reconstruction_wav_dialog(self) -> None: def _export_reconstruction_instruments_dialog(self) -> None: if self._reconstruction_coordinator.check_loaded(): - self._reconstructions_tab.request_export_instruments_dialog() + self._reconstructions_tab.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) def _reconstruct_file(self, filepath: Path) -> None: self._main_tab.set_input_path(filepath, convert=True) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 336192c9..793c1ac3 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -157,6 +157,8 @@ class GlobalMessageElements(AbstractElement): PROJECT_SAVE_FAILED = "project_save_failed" PROJECT_EXPORTED_SUCCESSFULLY = "project_exported_successfully" PROJECT_EXPORT_FAILED = "project_export_failed" + BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY = "bitphase_project_exported_successfully" + BITPHASE_PROJECT_EXPORT_FAILED = "bitphase_project_export_failed" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" @@ -198,7 +200,8 @@ class GlobalDialogTitleElements(AbstractElement): SAVE_PROJECT = "save_project" PROJECT_SAVED = "project_saved" EXPORT_MODULE = "export_module" - MODULE_EXPORTED = "module_exported" + EXPORT_BITPHASE_PROJECT = "export_bitphase_project" + PROJECT_EXPORTED = "project_exported" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" @@ -214,6 +217,8 @@ class FileFilterElements(AbstractElement): RECONSTRUCTION = "reconstruction" MODULE = "module" INSTRUMENT = "instrument" + BITPHASE_PROJECT = "bitphase_project" + BITPHASE_PRESET = "bitphase_preset" CONFIG = "config" AUDIO = "audio" WAVE = "wave" diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py new file mode 100644 index 00000000..8d7a7557 --- /dev/null +++ b/src/sampletones_application/categories/trackers.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Dict, Final + +from sampletones_application.categories.elements.global_ import ( + FileFilterElements, + GlobalDialogTitleElements, + GlobalMessageElements, +) +from sampletones_core.trackers.format import TrackerFormat + + +@dataclass(frozen=True) +class TrackerProjectElements: + """Which texts one tracker format's project export reads. + + Every format names its own file kind, so the dialog that picks a destination and the + one that reports the outcome speak in the words of the tracker that reads the file. + + Attributes: + dialog_title: Title of the dialog the destination is picked in. + filter_name: Name of the file filter the dialog offers. + exported_message: Shown when the project reaches its file. + export_failed_message: Shown when the export fails. + """ + + dialog_title: GlobalDialogTitleElements + filter_name: FileFilterElements + exported_message: GlobalMessageElements + export_failed_message: GlobalMessageElements + + +TRACKER_PROJECT_ELEMENTS: Final[Dict[TrackerFormat, TrackerProjectElements]] = { + TrackerFormat.FAMITRACKER: TrackerProjectElements( + dialog_title=GlobalDialogTitleElements.EXPORT_MODULE, + filter_name=FileFilterElements.MODULE, + exported_message=GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY, + export_failed_message=GlobalMessageElements.PROJECT_EXPORT_FAILED, + ), + TrackerFormat.BITPHASE: TrackerProjectElements( + dialog_title=GlobalDialogTitleElements.EXPORT_BITPHASE_PROJECT, + filter_name=FileFilterElements.BITPHASE_PROJECT, + exported_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY, + export_failed_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORT_FAILED, + ), +} + +TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { + TrackerFormat.FAMITRACKER: FileFilterElements.INSTRUMENT, + TrackerFormat.BITPHASE: FileFilterElements.BITPHASE_PROJECT, + TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, +} diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 2b2a46ed..1e459d48 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Dict, Optional from sampletones_application.categories.abstract import AbstractElement from sampletones_application.categories.elements.global_ import ( @@ -10,9 +10,15 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import TRACKER_PROJECT_ELEMENTS from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.services.export.error import ExportError +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.result import ExportResult +from sampletones_application.services.export.service import ExportService +from sampletones_application.services.export.success import ExportSuccess from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_MODULE_EXPORTED, TAG_GLOBAL_DIALOG_PROJECT_OPEN, @@ -25,10 +31,12 @@ ) from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_MODULE, EXT_FILE_PROJECT +from sampletones_core.paths import EXT_FILE_PROJECT from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.constants.project import ( - DEFAULT_MODULE_FILENAME, + DEFAULT_EXPORT_NAME, DEFAULT_PROJECT_FILENAME, ) from sampletones_shared.exceptions import ( @@ -57,8 +65,9 @@ def __init__( project_controller: ProjectController, project_manager: ProjectManager, session_manager: SessionManager, + export_service: ExportService, *, - export_backend: TrackerBackend, + tracker_backends: Dict[TrackerFormat, TrackerBackend], dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -67,12 +76,15 @@ def __init__( self._project_controller = project_controller self._project_manager = project_manager self._session_manager = session_manager - self._export_backend = export_backend + self._export_service = export_service + self._tracker_backends = tracker_backends self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch self._project_manager.session.on_state_changed = on_session_state_changed + export_service.subscribe(self._on_export_result) + @property def project_name(self) -> Optional[str]: name = self._project_manager.session.name @@ -171,25 +183,32 @@ def save_as_dialog(self) -> bool: return self._handle_save_as(filepath) - def _get_project_filename(self) -> str: - return f"{self.project_name}{EXT_FILE_MODULE}" if self.project_name else DEFAULT_MODULE_FILENAME + def _get_project_filename(self, extension: str) -> str: + name = self.project_name or DEFAULT_EXPORT_NAME + return f"{name}{extension}" + + def export_project_dialog(self, tracker_format: TrackerFormat) -> None: + """Prompts for a destination and writes the open project in ``tracker_format``. - def export_module_dialog(self) -> None: + Args: + tracker_format: The tracker the project is written for. + """ if not self._project_controller.is_open: return + backend = self._tracker_backends[tracker_format] + elements = TRACKER_PROJECT_ELEMENTS[tracker_format] + extension = backend.extension(ExportScope.PROJECT) path = self._session_manager.get_project_path() - filename = self._get_project_filename() - directory = get_directory(path) filepath = save_file_dialog( - title=self._title(GlobalDialogTitleElements.EXPORT_MODULE), - initial_directory=directory, - default_filename=filename, - extensions=[EXT_FILE_MODULE], - filter_name=self._filter_name(FileFilterElements.MODULE), + title=self._title(elements.dialog_title), + initial_directory=get_directory(path), + default_filename=self._get_project_filename(extension), + extensions=[extension], + filter_name=self._filter_name(elements.filter_name), ) - self._handle_export_module(filepath) + self._handle_export_project(filepath, tracker_format) def _open_dialog(self) -> None: filepath = open_file_dialog( @@ -212,8 +231,12 @@ def _handle_save_as(self, filepath: Path) -> bool: return self._save(filepath) @ignore_none_path - def _handle_export_module(self, filepath: Path) -> None: - self._export_module(filepath) + def _handle_export_project(self, filepath: Path, tracker_format: TrackerFormat) -> None: + self._export_service.export_project( + filepath, + self._tracker_backends[tracker_format], + self._project_controller.export_request, + ) def _new(self) -> None: self._project_controller.new() @@ -260,25 +283,27 @@ def _save(self, filepath: Path) -> bool: ) return True - def _export_module(self, filepath: Path) -> None: - try: - self._project_controller.export_project(filepath, self._export_backend) - except (ValueError, OSError) as exception: - logger.error_with_traceback( - exception, - f"Failed to export FamiTracker module to {filepath}", - ) - self._dialogs.show_error( - exception, - self._message(GlobalMessageElements.PROJECT_EXPORT_FAILED), - ) - return - - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY), - self._title(GlobalDialogTitleElements.MODULE_EXPORTED), - ) + def _on_export_result(self, result: ExportResult) -> None: + """Reports a finished project export in the words of the format it was written in.""" + match result: + case ExportSuccess( + kind=ExportKind.PROJECT, + tracker_format=TrackerFormat() as tracker_format, + ): + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].exported_message), + self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), + ) + case ExportError( + kind=ExportKind.PROJECT, + tracker_format=TrackerFormat() as tracker_format, + exception=exception, + ): + self._dialogs.show_error( + exception, + self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].export_failed_message), + ) def _guard_open( self, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 40eef05b..aafc2bb7 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Dict, Optional import dearpygui.dearpygui as dpg @@ -18,6 +18,7 @@ from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_FILTERS from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.original_audio import OriginalAudioLocator @@ -84,10 +85,12 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager +from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import DestinationKind, ExportScope from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -113,7 +116,7 @@ def __init__( reconstruction_manager: ReconstructionManager, browser_manager: BrowserManager, export_service: ExportService, - export_backend: TrackerBackend, + tracker_backends: Dict[TrackerFormat, TrackerBackend], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_reconstruct_file: VoidCallback, on_reconstruct_directory: VoidCallback, @@ -129,7 +132,7 @@ def __init__( ) -> None: self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager - self._export_backend = export_backend + self._tracker_backends = tracker_backends self._dialogs = dialogs self._original_audio_locator = original_audio_locator @@ -229,12 +232,15 @@ def __init__( TextType.TITLE, ReconstructionsInstrumentsElements.EXPORT_INSTRUMENTS_DIALOG, ] - self._filter_export_instrument = language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.INSTRUMENT, - ] + self._filters_export_instrument: Dict[TrackerFormat, str] = { + tracker_format: language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + element, + ] + for tracker_format, element in TRACKER_INSTRUMENT_FILTERS.items() + } self._filter_export_wav = language_manager[ Page.GLOBAL, Panel.DIALOG, @@ -306,7 +312,7 @@ def __init__( session_manager, reconstruction_manager, export_service, - export_backend, + tracker_backends, ) self._reconstruction_instruments_panel: GUIReconstructionInstrumentsPanel = GUIReconstructionInstrumentsPanel( pitch_stepper_style=layout.pitch_stepper_style, @@ -361,9 +367,7 @@ def __init__( on_reconstruction_instrument_updated ) - self._reconstruction_instruments_panel.on_instrument_export = ( - self._reconstruction_panel_logic.request_export_instrument_dialog - ) + self._reconstruction_instruments_panel.on_instrument_export = self._request_export_instrument self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = ( self._reconstruction_plot_panel.set_overlay ) @@ -397,7 +401,7 @@ def _on_export_result(self, result: ExportResult) -> None: fp, ) case ExportSuccess( - kind=ExportKind.INSTRUMENTS, + kind=ExportKind.SAMPLE, filepath=fp, truncation=truncation, ): @@ -414,7 +418,7 @@ def _on_export_result(self, result: ExportResult) -> None: self._dialogs.show_error(exception, messages.wav_failed) case ExportError(kind=ExportKind.INSTRUMENT, exception=exception): self._dialogs.show_error(exception, messages.instrument_failed) - case ExportError(kind=ExportKind.INSTRUMENTS, exception=exception): + case ExportError(kind=ExportKind.SAMPLE, exception=exception): self._dialogs.show_error(exception, messages.instruments_failed) def _export_message( @@ -451,17 +455,25 @@ def _update_reconstruction_view( self._reconstruction_audio_panel.update_view(view_model) self._reconstruction_plot_panel.update_view(view_model) + def _request_export_instrument(self, generator_name: GeneratorName) -> None: + self._reconstruction_panel_logic.request_export_instrument_dialog( + generator_name, + TrackerFormat.FAMITRACKER, + ) + def _open_export_instrument_dialog( self, default_filename: str, default_path: str, + tracker_format: TrackerFormat, ) -> None: + backend = self._tracker_backends[tracker_format] filepath = save_file_dialog( title=self._ttl_export_instrument, initial_directory=default_path, default_filename=default_filename, - extensions=[self._export_backend.extension(ExportScope.INSTRUMENT)], - filter_name=self._filter_export_instrument, + extensions=[backend.extension(ExportScope.INSTRUMENT)], + filter_name=self._filters_export_instrument[tracker_format], ) self._handle_export_instrument(filepath) @@ -469,16 +481,37 @@ def _open_export_instrument_dialog( def _handle_export_instrument(self, filepath: Path) -> None: self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath) - def _open_export_instruments_dialog(self, default_path: str) -> None: - directory = select_directory_dialog( - title=self._ttl_export_instruments, - initial_directory=default_path, + def _open_export_instruments_dialog( + self, + default_filename: str, + default_path: str, + tracker_format: TrackerFormat, + ) -> None: + """Prompts for whatever destination the chosen format writes a reconstruction to. + + A format that gathers a whole reconstruction into one document is saved as a file; + one that writes an instrument per slice fills a directory. + """ + backend = self._tracker_backends[tracker_format] + destination = ( + save_file_dialog( + title=self._ttl_export_instruments, + initial_directory=default_path, + default_filename=default_filename, + extensions=[backend.extension(ExportScope.SAMPLE)], + filter_name=self._filters_export_instrument[tracker_format], + ) + if backend.destination_kind(ExportScope.SAMPLE) is DestinationKind.FILE + else select_directory_dialog( + title=self._ttl_export_instruments, + initial_directory=default_path, + ) ) - self._handle_export_instruments(directory) + self._handle_export_instruments(destination) @ignore_none_path - def _handle_export_instruments(self, directory: Path) -> None: - self._reconstruction_panel_logic.handle_export_instruments_confirmed(directory) + def _handle_export_instruments(self, destination: Path) -> None: + self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination) def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None: filepath = save_file_dialog( @@ -681,8 +714,8 @@ def player(self) -> AudioPlayerProtocol: def request_export_wav_dialog(self) -> None: self._reconstruction_panel_logic.request_export_wav_dialog() - def request_export_instruments_dialog(self) -> None: - self._reconstruction_panel_logic.request_export_instruments_dialog() + def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: + self._reconstruction_panel_logic.request_export_instruments_dialog(tracker_format) def _on_browser_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 4238a3fd..e0656a64 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -8,7 +8,6 @@ from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.request import ProjectExport from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp @@ -95,9 +94,10 @@ def replace_project(self, project: Project, *, clean: bool) -> None: self._project_manager.install(project, clean=clean) self.call(self.on_project_replaced) - def export_project(self, path: Path, backend: TrackerBackend) -> None: - """Writes the current project in the format ``backend`` produces.""" - backend.write_project(path, ProjectExport(project=self.project)) + @property + def export_request(self) -> ProjectExport: + """Packages the current project for a tracker backend to write.""" + return ProjectExport(project=self.project) def mark_updated(self) -> None: self._touch() diff --git a/src/sampletones_application/logic/reconstruction/pending.py b/src/sampletones_application/logic/reconstruction/pending.py new file mode 100644 index 00000000..bcfb0209 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/pending.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.format import TrackerFormat + + +@dataclass(frozen=True) +class PendingInstrumentExport: + """The generator slice and target format awaiting a destination from the file dialog. + + The user picks what to export before picking where it goes, so the choice is held + here until the dialog answers with a path. + + Attributes: + generator: The slice the export writes. + tracker_format: The format the slice is written in. + """ + + generator: GeneratorName + tracker_format: TrackerFormat diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 2d7ff210..94dab00f 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,11 +1,12 @@ from pathlib import Path -from typing import Callable, FrozenSet, List, Optional, Protocol, Tuple +from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple import numpy as np from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.pending import PendingInstrumentExport from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionPathState, ReconstructionPathViewModel, @@ -16,6 +17,7 @@ from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.exporters.feature import Features from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -58,16 +60,17 @@ def __init__( session_manager: SessionManager, reconstruction_manager: ReconstructionManager, export_service: ExportServiceProtocol, - export_backend: TrackerBackend, + tracker_backends: Dict[TrackerFormat, TrackerBackend], ) -> None: self._session_manager = session_manager self._reconstruction_manager = reconstruction_manager self._export_service = export_service - self._export_backend = export_backend + self._tracker_backends = tracker_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._selected_generators: List[GeneratorName] = [] - self._pending_generator_name: Optional[GeneratorName] = None + self._pending_instrument: Optional[PendingInstrumentExport] = None + self._pending_sample_format: Optional[TrackerFormat] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -76,8 +79,8 @@ def __init__( self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str], None]] = None - self.on_open_export_instruments_dialog: Optional[Callable[[str], None]] = None + self.on_open_export_instrument_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None + self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None @@ -164,6 +167,7 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: def request_export_instrument_dialog( self, generator_name: GeneratorName, + tracker_format: TrackerFormat, ) -> None: reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -176,21 +180,31 @@ def request_export_instrument_dialog( instrument_name = f"{reconstruction_data.name} ({generator_name})" default_path = str(self._session_manager.get_instrument_path()) - self._pending_generator_name = generator_name + self._pending_instrument = PendingInstrumentExport( + generator=generator_name, + tracker_format=tracker_format, + ) self.call( self.on_open_export_instrument_dialog, instrument_name, default_path, + tracker_format, ) - def request_export_instruments_dialog(self) -> None: + def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") default_path = str(self._session_manager.get_instrument_path()) - self.call(self.on_open_export_instruments_dialog, default_path) + self._pending_sample_format = tracker_format + self.call( + self.on_open_export_instruments_dialog, + reconstruction_data.name, + default_path, + tracker_format, + ) def request_export_wav_dialog(self) -> None: reconstruction_data = self._reconstruction_data @@ -203,25 +217,31 @@ def request_export_wav_dialog(self) -> None: self.call(self.on_open_export_wav_dialog, default_filename, default_path) def handle_export_instrument_confirmed(self, filepath: Path) -> None: - if not self._reconstruction_data or not self._pending_generator_name: + pending = self._pending_instrument + self._pending_instrument = None + if not self._reconstruction_data or pending is None: logger.warning("No reconstruction data available for instrument export") - self._pending_generator_name = None return - generator_name = self._pending_generator_name - feature = self._reconstruction_data.feature_data[generator_name] - self._pending_generator_name = None + feature = self._reconstruction_data.feature_data[pending.generator] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, - self._export_backend, - self._instrument_export(generator_name, feature), + self._tracker_backends[pending.tracker_format], + self._instrument_export(pending.generator, feature), ) - def handle_export_instruments_confirmed(self, directory: Path) -> None: + def handle_export_instruments_confirmed(self, destination: Path) -> None: + """Writes every generator slice of the loaded reconstruction to ``destination``. + + The chosen format decides whether the destination is one file holding the whole + reconstruction or a directory the slices fill. + """ reconstruction_data = self._reconstruction_data - if not reconstruction_data: + tracker_format = self._pending_sample_format + self._pending_sample_format = None + if not reconstruction_data or tracker_format is None: logger.warning("No reconstruction data available for instruments export") return @@ -233,8 +253,8 @@ def handle_export_instruments_confirmed(self, directory: Path) -> None: ), nes_frequency=self._nes_frequency(), ) - self._session_manager.set_instrument_path(directory.parent) - self._export_service.export_sample(directory, self._export_backend, request) + self._session_manager.set_instrument_path(destination.parent) + self._export_service.export_sample(destination, self._tracker_backends[tracker_format], request) def _instrument_export( self, diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py index 0a0e9acf..a7405674 100644 --- a/src/sampletones_application/services/export/error.py +++ b/src/sampletones_application/services/export/error.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from typing import Optional from sampletones_application.services.export.kind import ExportKind +from sampletones_core.trackers.format import TrackerFormat @dataclass(frozen=True, eq=False) @@ -9,8 +11,10 @@ class ExportError: Attributes: kind: The artefact the run set out to produce. + tracker_format: The format the run set out to write, and ``None`` for an audio export. exception: The failure raised while writing. """ kind: ExportKind + tracker_format: Optional[TrackerFormat] exception: Exception diff --git a/src/sampletones_application/services/export/kind.py b/src/sampletones_application/services/export/kind.py index 619eedc2..5d65376c 100644 --- a/src/sampletones_application/services/export/kind.py +++ b/src/sampletones_application/services/export/kind.py @@ -6,4 +6,5 @@ class ExportKind(str, Enum): WAV = "wav" INSTRUMENT = "instrument" - INSTRUMENTS = "instruments" + SAMPLE = "sample" + PROJECT = "project" diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e02f294e..f2573824 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -13,16 +13,24 @@ from sampletones_core.audio import write_wave from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) from sampletones_shared.logger import logger +NO_TRACKER_FORMAT: None = None + class ExportService(ServiceBase[ExportResult]): """Writes exports on a background thread and reports each outcome as a result. The tracker backend arrives per call, so the service stays free of any one file format: it owns the thread boundary and the error boundary, and the backend owns - what lands on disk. + what lands on disk. Each result names the format it was written in, letting one + subscriber report an outcome in the words of the tracker that reads it. """ def __init__(self, priority: int = 0) -> None: @@ -43,6 +51,7 @@ def task() -> None: ExportSuccess( kind=ExportKind.WAV, filepath=filepath, + tracker_format=NO_TRACKER_FORMAT, truncation=None, ) ) @@ -51,6 +60,7 @@ def task() -> None: self._emit( ExportError( kind=ExportKind.WAV, + tracker_format=NO_TRACKER_FORMAT, exception=exception, ) ) @@ -66,6 +76,7 @@ def export_instrument( self._submit( ExportKind.INSTRUMENT, destination, + backend.tracker_format, partial(backend.write_instrument, destination, request), ) @@ -76,15 +87,30 @@ def export_sample( request: SampleExport, ) -> None: self._submit( - ExportKind.INSTRUMENTS, + ExportKind.SAMPLE, destination, + backend.tracker_format, partial(backend.write_sample, destination, request), ) + def export_project( + self, + destination: Path, + backend: TrackerBackend, + request: ProjectExport, + ) -> None: + self._submit( + ExportKind.PROJECT, + destination, + backend.tracker_format, + partial(backend.write_project, destination, request), + ) + def _submit( self, kind: ExportKind, destination: Path, + tracker_format: TrackerFormat, write: Callable[[], ExportArtifact], ) -> None: """Runs one backend write on the executor and reports what it produced. @@ -92,6 +118,7 @@ def _submit( Args: kind: The artefact the run produces, naming the dialog that reports it. destination: The file written, or the directory a batch of instruments filled. + tracker_format: The format the run writes, carried through to the result. write: Calls the backend and returns what it left on disk. """ @@ -99,11 +126,12 @@ def task() -> None: try: artifact = write() for path in artifact.paths: - logger.info(f"Exported instrument: {logger.format_path(path)}") + logger.info(f"Exported {kind.value}: {logger.format_path(path)}") self._emit( ExportSuccess( kind=kind, filepath=destination, + tracker_format=tracker_format, truncation=artifact.truncation, ) ) @@ -112,6 +140,7 @@ def task() -> None: self._emit( ExportError( kind=kind, + tracker_format=tracker_format, exception=exception, ) ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index 8165e6db..c78b10c1 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -4,6 +4,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat @dataclass(frozen=True) @@ -13,10 +14,12 @@ class ExportSuccess: Attributes: kind: The artefact the run produced. filepath: The file written, or the directory a batch of instruments filled. + tracker_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. """ kind: ExportKind filepath: Path + tracker_format: Optional[TrackerFormat] truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 18b5f106..5b52c2f9 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -79,7 +79,8 @@ class ShortcutBindings: save_project: Callback save_project_as: Callback project_properties: Callback - export_project_module: Callback + export_project_famitracker: Callback + export_project_bitphase: Callback close_project: Callback exit: Callback undo: Callback @@ -225,9 +226,14 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: bindings.save_project_as, ) self._shortcut_manager.register( - ShortcutId.EXPORT_PROJECT_MODULE, + ShortcutId.EXPORT_PROJECT_FAMITRACKER, Shortcut(dpg.mvKey_M, CTRL), - bindings.export_project_module, + bindings.export_project_famitracker, + ) + self._shortcut_manager.register( + ShortcutId.EXPORT_PROJECT_BITPHASE, + Shortcut(dpg.mvKey_B, CTRL), + bindings.export_project_bitphase, ) self._shortcut_manager.register( ShortcutId.PROJECT_PROPERTIES, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 8059918a..d3b86571 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -217,7 +217,7 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: enabled=state.project_open, ) self._shortcut_manager.add_menu_item( - ShortcutId.EXPORT_PROJECT_MODULE, + ShortcutId.EXPORT_PROJECT_FAMITRACKER, tag=TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, label=self._label(MenuElements.ITEM_FILE_EXPORT_MODULE), enabled=state.project_open, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 77116b53..9d304c9b 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -10,7 +10,8 @@ class ShortcutId(Enum): SAVE_PROJECT = "SaveProject" SAVE_PROJECT_AS = "SaveProjectAs" PROJECT_PROPERTIES = "ProjectProperties" - EXPORT_PROJECT_MODULE = "ExportProjectModule" + EXPORT_PROJECT_FAMITRACKER = "ExportProjectFamiTracker" + EXPORT_PROJECT_BITPHASE = "ExportProjectBitphase" CLOSE_PROJECT = "CloseProject" EXIT = "Exit" UNDO = "Undo" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 93e51833..c7f13c6e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -35,7 +35,8 @@ global.dialog.title.load_unsaved_reconstruction: "Load reconstruction" global.dialog.title.save_project: "Save project" global.dialog.title.project_saved: "Project saved" global.dialog.title.export_module: "Export FamiTracker module" -global.dialog.title.module_exported: "Module exported" +global.dialog.title.export_bitphase_project: "Export Bitphase project" +global.dialog.title.project_exported: "Project exported" global.dialog.title.new_unsaved_project: "New project" global.dialog.title.open_unsaved_project: "Open project" global.dialog.title.close_unsaved_project: "Close project" @@ -50,6 +51,8 @@ global.dialog.filter.project: "Project files" global.dialog.filter.reconstruction: "Reconstruction files" global.dialog.filter.module: "FamiTracker module" global.dialog.filter.instrument: "FamiTracker instrument" +global.dialog.filter.bitphase_project: "Bitphase project" +global.dialog.filter.bitphase_preset: "Bitphase instrument preset" global.dialog.filter.config: "Configuration files" global.dialog.filter.audio: "Audio files" global.dialog.filter.wave: "WAV audio" @@ -70,6 +73,8 @@ global.dialog.message.project_saved_successfully: "Project saved successfully." global.dialog.message.project_save_failed: "Failed to save project." global.dialog.message.project_exported_successfully: "FamiTracker module exported successfully." global.dialog.message.project_export_failed: "Failed to export FamiTracker module." +global.dialog.message.bitphase_project_exported_successfully: "Bitphase project exported successfully." +global.dialog.message.bitphase_project_export_failed: "Failed to export Bitphase project." 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?" diff --git a/src/sampletones_shared/constants/project.py b/src/sampletones_shared/constants/project.py index 0989f749..db32c5b5 100644 --- a/src/sampletones_shared/constants/project.py +++ b/src/sampletones_shared/constants/project.py @@ -9,7 +9,7 @@ DEFAULT_PROJECT_AUTHOR: Final[str] = "" DEFAULT_PROJECT_COMMENT: Final[str] = "" DEFAULT_PROJECT_FILENAME: Final[str] = "Untitled.stp" -DEFAULT_MODULE_FILENAME: Final[str] = "Untitled.ftm" +DEFAULT_EXPORT_NAME: Final[str] = "Untitled" # Project info length limits MAX_PROJECT_TITLE_LENGTH: Final[int] = 64 diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index d60a5414..46bae0a1 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -144,7 +144,7 @@ def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_feat assert len(results) == 1 assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.INSTRUMENTS + assert results[0].kind == ExportKind.SAMPLE assert results[0].filepath == tmp_path def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index ef77cf8c..e8a5f3d5 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -12,6 +12,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -247,7 +248,12 @@ def test_a_complete_instrument_export_shows_the_success_message( export_coordinator: ReconstructionTabCoordinator, ) -> None: export_coordinator._on_export_result( - ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), truncation=None) + ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("lead.fti"), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=None, + ) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.instrument_success @@ -260,6 +266,7 @@ def test_a_shortened_instrument_export_names_both_frame_counts( ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), + tracker_format=TrackerFormat.FAMITRACKER, truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1), ) ) @@ -275,8 +282,9 @@ def test_a_shortened_reconstruction_export_counts_the_instruments( ) -> None: export_coordinator._on_export_result( ExportSuccess( - kind=ExportKind.INSTRUMENTS, + kind=ExportKind.SAMPLE, filepath=Path("instruments"), + tracker_format=TrackerFormat.FAMITRACKER, truncation=EnvelopeTruncation(frames=252, source_frames=410, instruments=3), ) ) @@ -290,7 +298,7 @@ def test_a_wav_export_shows_its_own_message( export_coordinator: ReconstructionTabCoordinator, ) -> None: export_coordinator._on_export_result( - ExportSuccess(kind=ExportKind.WAV, filepath=Path("track.wav"), truncation=None) + ExportSuccess(kind=ExportKind.WAV, filepath=Path("track.wav"), tracker_format=None, truncation=None) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index b7153ed0..c821a1f5 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -23,7 +23,8 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), MagicMock(), - export_backend=MagicMock(), + MagicMock(), + tracker_backends={}, dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index e9ecd2b2..516b805b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, List +from typing import Callable, Dict, List from unittest.mock import MagicMock import numpy as np @@ -19,6 +19,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.format import TrackerFormat @pytest.fixture @@ -47,19 +48,19 @@ def panel_logic( session_manager: MagicMock, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, - mock_export_backend: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], ) -> ReconstructionPanelLogic: return ReconstructionPanelLogic( session_manager, mock_reconstruction_manager, mock_export_service, - mock_export_backend, + mock_tracker_backends, ) @pytest.fixture -def mock_export_backend() -> MagicMock: - return MagicMock() +def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]: + return {tracker_format: MagicMock() for tracker_format in TrackerFormat} @pytest.fixture @@ -372,7 +373,7 @@ def test_request_export_instrument_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) def test_request_export_instrument_dialog_fires_dialog_callback( self, @@ -383,9 +384,21 @@ def test_request_export_instrument_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) callback.assert_called_once() + def test_request_export_instrument_dialog_carries_the_chosen_format( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instrument_dialog = callback + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.BITPHASE) + assert callback.call_args.args[-1] == TrackerFormat.BITPHASE + def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( self, panel_logic: ReconstructionPanelLogic, @@ -395,7 +408,7 @@ def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE) + panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE, TrackerFormat.FAMITRACKER) callback.assert_not_called() def test_handle_export_instrument_confirmed_with_no_pending_does_not_export( @@ -420,10 +433,26 @@ def test_handle_export_instrument_confirmed_calls_export_service( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") mock_export_service.export_instrument.assert_called_once() + def test_handle_export_instrument_confirmed_selects_the_backend_of_the_chosen_format( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instrument_dialog = MagicMock() + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.BITPHASE) + panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.btp") + backend = mock_export_service.export_instrument.call_args.args[1] + assert backend is mock_tracker_backends[TrackerFormat.BITPHASE] + class TestReconstructionPanelLogicExportInstruments: def test_request_export_instruments_dialog_with_no_data_raises_assertion_error( @@ -431,7 +460,7 @@ def test_request_export_instruments_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instruments_dialog() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) def test_request_export_instruments_dialog_fires_dialog_callback( self, @@ -442,9 +471,21 @@ def test_request_export_instruments_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) callback.assert_called_once() + def test_request_export_instruments_dialog_carries_the_chosen_format( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instruments_dialog = callback + panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE) + assert callback.call_args.args[-1] == TrackerFormat.BITPHASE + def test_handle_export_instruments_confirmed_with_no_data_is_no_op( self, panel_logic: ReconstructionPanelLogic, @@ -454,6 +495,18 @@ def test_handle_export_instruments_confirmed_with_no_data_is_no_op( panel_logic.handle_export_instruments_confirmed(tmp_path) mock_export_service.export_sample.assert_not_called() + def test_handle_export_instruments_confirmed_without_a_requested_format_is_no_op( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.handle_export_instruments_confirmed(tmp_path) + mock_export_service.export_sample.assert_not_called() + def test_handle_export_instruments_confirmed_calls_export_sample( self, panel_logic: ReconstructionPanelLogic, @@ -463,9 +516,27 @@ def test_handle_export_instruments_confirmed_calls_export_sample( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instruments_dialog = MagicMock() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) panel_logic.handle_export_instruments_confirmed(tmp_path) mock_export_service.export_sample.assert_called_once() + def test_handle_export_instruments_confirmed_selects_the_backend_of_the_chosen_format( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instruments_dialog = MagicMock() + panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE) + panel_logic.handle_export_instruments_confirmed(tmp_path / "sample.btp") + backend = mock_export_service.export_sample.call_args.args[1] + assert backend is mock_tracker_backends[TrackerFormat.BITPHASE] + class TestReconstructionPanelLogicExportWav: def test_request_export_wav_dialog_with_no_data_raises_assertion_error( diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index 1f855aa8..a1743601 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -7,54 +7,79 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat class TestExportSuccess: def test_stores_kind_and_filepath(self) -> None: filepath = Path("/exports/track.wav") - success = ExportSuccess(kind=ExportKind.WAV, filepath=filepath, truncation=None) + success = ExportSuccess(kind=ExportKind.WAV, filepath=filepath, tracker_format=None, truncation=None) assert success.kind == ExportKind.WAV assert success.filepath == filepath + assert success.tracker_format is None assert success.truncation is None + def test_stores_the_tracker_format(self) -> None: + success = ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("/x"), + tracker_format=TrackerFormat.BITPHASE, + truncation=None, + ) + assert success.tracker_format == TrackerFormat.BITPHASE + def test_stores_the_truncation(self) -> None: truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1) - success = ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=Path("/x"), truncation=truncation) + success = ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("/x"), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=truncation, + ) assert success.truncation == truncation def test_frozen(self) -> None: - success = ExportSuccess(kind=ExportKind.WAV, filepath=Path("/x"), truncation=None) + success = ExportSuccess(kind=ExportKind.WAV, filepath=Path("/x"), tracker_format=None, truncation=None) with pytest.raises(FrozenInstanceError): success.kind = ExportKind.INSTRUMENT # type: ignore[misc] def test_equality(self) -> None: path = Path("/x") - assert ExportSuccess(kind=ExportKind.WAV, filepath=path, truncation=None) == ExportSuccess( - kind=ExportKind.WAV, filepath=path, truncation=None + assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) == ExportSuccess( + kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None ) - assert ExportSuccess(kind=ExportKind.WAV, filepath=path, truncation=None) != ExportSuccess( - kind=ExportKind.INSTRUMENT, filepath=path, truncation=None + assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) != ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=None, truncation=None + ) + + def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: + path = Path("/x") + assert ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.FAMITRACKER, truncation=None + ) != ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.BITPHASE, truncation=None ) class TestExportError: def test_stores_kind_and_exception(self) -> None: exception = OSError("disk full") - error = ExportError(kind=ExportKind.INSTRUMENT, exception=exception) + error = ExportError(kind=ExportKind.INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER, exception=exception) assert error.kind == ExportKind.INSTRUMENT + assert error.tracker_format == TrackerFormat.FAMITRACKER assert error.exception is exception def test_frozen(self) -> None: - error = ExportError(kind=ExportKind.WAV, exception=OSError()) + error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError()) with pytest.raises(FrozenInstanceError): - error.kind = ExportKind.INSTRUMENTS # type: ignore[misc] + error.kind = ExportKind.SAMPLE # type: ignore[misc] def test_eq_false_same_exception_instances_differ(self) -> None: exception = OSError("same") - error_a = ExportError(kind=ExportKind.WAV, exception=exception) - error_b = ExportError(kind=ExportKind.WAV, exception=exception) + error_a = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception) + error_b = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception) assert error_a != error_b def test_same_instance_equals_itself(self) -> None: - error = ExportError(kind=ExportKind.WAV, exception=OSError()) + error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError()) assert error == error # noqa: PLR0124 diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 129b197f..d7a5183b 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -12,6 +12,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.project import Project from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import ( @@ -95,6 +96,10 @@ def build_sample(count: int = 2) -> SampleExport: ) +def build_project() -> ProjectExport: + return ProjectExport(project=Project.create(title="Song")) + + @pytest.fixture def service(): export_service = ExportService() @@ -213,7 +218,7 @@ def test_success_emits_export_success_with_the_destination(self, service, tmp_pa assert len(results) == 1 result = results[0] assert isinstance(result, ExportSuccess) - assert result.kind == ExportKind.INSTRUMENTS + assert result.kind == ExportKind.SAMPLE assert result.filepath == tmp_path def test_the_backend_receives_every_slice_in_one_call(self, service, tmp_path) -> None: @@ -234,7 +239,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: assert len(results) == 1 result = results[0] assert isinstance(result, ExportError) - assert result.kind == ExportKind.INSTRUMENTS + assert result.kind == ExportKind.SAMPLE assert result.exception is exception def test_a_sample_with_no_slices_emits_success(self, service, tmp_path) -> None: @@ -244,7 +249,71 @@ def test_a_sample_with_no_slices_emits_success(self, service, tmp_path) -> None: assert len(results) == 1 assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.INSTRUMENTS + assert results[0].kind == ExportKind.SAMPLE + + +class TestExportProject: + def test_success_emits_export_success(self, service, tmp_path) -> None: + export_service, results = service + filepath = tmp_path / "song.ftm" + + export_service.export_project(filepath, StubBackend(), build_project()) + + assert len(results) == 1 + result = results[0] + assert isinstance(result, ExportSuccess) + assert result.kind == ExportKind.PROJECT + assert result.filepath == filepath + + def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None: + export_service, _ = service + filepath = tmp_path / "song.ftm" + backend = StubBackend() + request = build_project() + + export_service.export_project(filepath, backend, request) + + assert backend.calls == [("project", filepath, request)] + + def test_error_emits_export_error(self, service, tmp_path) -> None: + export_service, results = service + exception = OSError("no space") + + export_service.export_project( + tmp_path / "song.ftm", + StubBackend(exception=exception), + build_project(), + ) + + assert len(results) == 1 + result = results[0] + assert isinstance(result, ExportError) + assert result.kind == ExportKind.PROJECT + assert result.exception is exception + + +class TestExportFormatReporting: + def test_a_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None: + export_service, results = service + + export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument()) + + assert results[0].tracker_format == TrackerFormat.FAMITRACKER + + def test_a_failed_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None: + export_service, results = service + + export_service.export_sample(tmp_path, StubBackend(exception=OSError("fail")), build_sample()) + + assert results[0].tracker_format == TrackerFormat.FAMITRACKER + + def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: + export_service, results = service + + with patch("sampletones_application.services.export.service.write_wave"): + export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(100)) + + assert results[0].tracker_format is None class TestExportTruncationReporting: From 2e0048c9728e94463a0795b2f3fa3462a0942bb1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 1 Aug 2026 12:03:26 +0200 Subject: [PATCH 04/20] Extended: Bitphase UI elements --- src/sampletones_application/application.py | 17 ++-- .../categories/elements/global_.py | 9 +- .../categories/elements/reconstructions.py | 3 + .../categories/trackers.py | 21 ++++ .../coordinators/tabs/reconstruction.py | 11 +-- src/sampletones_application/shell.py | 56 +++++++---- src/sampletones_application/tags/general.py | 4 +- src/sampletones_application/ui/menu.py | 60 +++++++++--- .../reconstruction/instruments/instruments.py | 43 ++++++-- .../utils/gui/shortcuts/ids.py | 16 ++- src/sampletones_config/lang/en.yaml | 23 +++-- .../categories/test_trackers.py | 98 +++++++++++++++++++ .../reconstruction/test_instruments_panel.py | 30 +++++- 13 files changed, 318 insertions(+), 73 deletions(-) create mode 100644 tests/unit/sampletones_application/categories/test_trackers.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index b8dc3347..8a8ddd17 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,4 +1,3 @@ -from functools import partial from pathlib import Path from typing import Any, Dict, Final, Optional @@ -480,14 +479,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: save_project=self._project_coordinator.save, save_project_as=self._project_coordinator.save_as_dialog, project_properties=self._open_project_properties, - export_project_famitracker=partial( - self._project_coordinator.export_project_dialog, - TrackerFormat.FAMITRACKER, - ), - export_project_bitphase=partial( - self._project_coordinator.export_project_dialog, - TrackerFormat.BITPHASE, - ), + export_project=self._project_coordinator.export_project_dialog, close_project=self._project_coordinator.close_with_confirmation, exit=self._on_close, undo=self._sequencer_tab.undo, @@ -742,9 +734,12 @@ def _export_reconstruction_wav_dialog(self) -> None: if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_wav_dialog() - def _export_reconstruction_instruments_dialog(self) -> None: + def _export_reconstruction_instruments_dialog( + self, + tracker_format: TrackerFormat, + ) -> None: if self._reconstruction_coordinator.check_loaded(): - self._reconstructions_tab.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + self._reconstructions_tab.request_export_instruments_dialog(tracker_format) def _reconstruct_file(self, filepath: Path) -> None: self._main_tab.set_input_path(filepath, convert=True) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 793c1ac3..c9d48ce3 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -64,7 +64,9 @@ class MenuElements(AbstractElement): ITEM_FILE_SAVE_PROJECT = "item_file_save_project" ITEM_FILE_SAVE_PROJECT_AS = "item_file_save_project_as" ITEM_FILE_PROJECT_PROPERTIES = "item_file_project_properties" - ITEM_FILE_EXPORT_MODULE = "item_file_export_module" + GROUP_FILE_EXPORT = "group_file_export" + ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker" + ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase" ITEM_FILE_CLOSE_PROJECT = "item_file_close_project" ITEM_FILE_EXIT = "item_file_exit" GROUP_EDIT = "group_edit" @@ -80,7 +82,10 @@ class MenuElements(AbstractElement): ITEM_RECONSTRUCTION_SAVE_AS = "item_reconstruction_save_as" ITEM_RECONSTRUCTION_CLOSE = "item_reconstruction_close" ITEM_RECONSTRUCTION_EXPORT_WAV = "item_reconstruction_export_wav" - ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS = "item_reconstruction_export_instruments" + GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS = "group_reconstruction_export_instruments" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER = "item_reconstruction_export_instruments_famitracker" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE = "item_reconstruction_export_instruments_bitphase" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET = "item_reconstruction_export_instruments_bitphase_preset" GROUP_PLAYBACK = "group_playback" ITEM_PLAYBACK_PLAY = "item_playback_play" ITEM_PLAYBACK_PAUSE = "item_playback_pause" diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index fb60c609..f29d748d 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -41,6 +41,9 @@ class ReconstructionPanelElements(AbstractElement): class ReconstructionsInstrumentsElements(AbstractElement): SECTION = "section" EXPORT_INSTRUMENT_BUTTON = "export_instrument_button" + EXPORT_INSTRUMENT_FAMITRACKER = "export_instrument_famitracker" + EXPORT_INSTRUMENT_BITPHASE = "export_instrument_bitphase" + EXPORT_INSTRUMENT_BITPHASE_PRESET = "export_instrument_bitphase_preset" COPY_BUTTON = "copy_button" PITCH_LABEL = "pitch_label" HI_PITCH_LABEL = "hi_pitch_label" diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py index 8d7a7557..9990ffca 100644 --- a/src/sampletones_application/categories/trackers.py +++ b/src/sampletones_application/categories/trackers.py @@ -5,6 +5,10 @@ FileFilterElements, GlobalDialogTitleElements, GlobalMessageElements, + MenuElements, +) +from sampletones_application.categories.elements.reconstructions import ( + ReconstructionsInstrumentsElements, ) from sampletones_core.trackers.format import TrackerFormat @@ -49,3 +53,20 @@ class TrackerProjectElements: TrackerFormat.BITPHASE: FileFilterElements.BITPHASE_PROJECT, TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, } + +TRACKER_PROJECT_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { + TrackerFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, + TrackerFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, +} + +TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { + TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + TrackerFormat.BITPHASE: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE, + TrackerFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, +} + +TRACKER_INSTRUMENT_LABELS: Final[Dict[TrackerFormat, ReconstructionsInstrumentsElements]] = { + TrackerFormat.FAMITRACKER: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_FAMITRACKER, + TrackerFormat.BITPHASE: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BITPHASE, + TrackerFormat.BITPHASE_PRESET: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BITPHASE_PRESET, +} diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index aafc2bb7..7fc32f57 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -85,7 +85,6 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.trackers.backend import TrackerBackend @@ -367,7 +366,9 @@ def __init__( on_reconstruction_instrument_updated ) - self._reconstruction_instruments_panel.on_instrument_export = self._request_export_instrument + self._reconstruction_instruments_panel.on_instrument_export = ( + self._reconstruction_panel_logic.request_export_instrument_dialog + ) self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = ( self._reconstruction_plot_panel.set_overlay ) @@ -455,12 +456,6 @@ def _update_reconstruction_view( self._reconstruction_audio_panel.update_view(view_model) self._reconstruction_plot_panel.update_view(view_model) - def _request_export_instrument(self, generator_name: GeneratorName) -> None: - self._reconstruction_panel_logic.request_export_instrument_dialog( - generator_name, - TrackerFormat.FAMITRACKER, - ) - def _open_export_instrument_dialog( self, default_filename: str, diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 5b52c2f9..cd2bc8b1 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Final, Optional import dearpygui.dearpygui as dpg @@ -48,6 +48,8 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.gui.shortcuts.keys import ( @@ -60,6 +62,7 @@ from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_application.viewport import ViewportManager from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.format import TrackerFormat from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import Callback, PathCallback @@ -70,6 +73,15 @@ Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, } _TAG_TABS: Dict[str, Tab] = {tag: Tab(tab) for tab, tag in _TAB_TAGS.items()} +_PROJECT_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { + TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_M, CTRL), + TrackerFormat.BITPHASE: Shortcut(dpg.mvKey_B, CTRL), +} +_SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { + TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_I, CTRL), + TrackerFormat.BITPHASE: Shortcut(), + TrackerFormat.BITPHASE_PRESET: Shortcut(), +} @dataclass(frozen=True) @@ -79,8 +91,7 @@ class ShortcutBindings: save_project: Callback save_project_as: Callback project_properties: Callback - export_project_famitracker: Callback - export_project_bitphase: Callback + export_project: Callable[[TrackerFormat], None] close_project: Callback exit: Callback undo: Callback @@ -94,7 +105,7 @@ class ShortcutBindings: save_reconstruction_as: Callback close_reconstruction: Callback export_wav: Callback - export_instruments: Callback + export_instruments: Callable[[TrackerFormat], None] add_reconstruction_to_sequencer: Callback open_reconstruction_in_explorer: Callback locate_original_audio: Callback @@ -225,16 +236,7 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: Shortcut(dpg.mvKey_S, CTRL_SHIFT), bindings.save_project_as, ) - self._shortcut_manager.register( - ShortcutId.EXPORT_PROJECT_FAMITRACKER, - Shortcut(dpg.mvKey_M, CTRL), - bindings.export_project_famitracker, - ) - self._shortcut_manager.register( - ShortcutId.EXPORT_PROJECT_BITPHASE, - Shortcut(dpg.mvKey_B, CTRL), - bindings.export_project_bitphase, - ) + self._register_export_shortcuts(bindings) self._shortcut_manager.register( ShortcutId.PROJECT_PROPERTIES, Shortcut(dpg.mvKey_P, ALT), @@ -300,11 +302,6 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: Shortcut(dpg.mvKey_E, CTRL), bindings.export_wav, ) - self._shortcut_manager.register( - ShortcutId.EXPORT_RECONSTRUCTION_INSTRUMENTS, - Shortcut(dpg.mvKey_I, CTRL), - bindings.export_instruments, - ) self._shortcut_manager.register( ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, Shortcut(), @@ -398,6 +395,27 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: self._shortcut_manager.bind_all() + def _register_export_shortcuts(self, bindings: ShortcutBindings) -> None: + """Registers one export action per tracker format, the entries the Export submenus list. + + Each action carries the format it writes, so a menu entry and its key combination reach + the same coordinator call. A format registered without a key is offered by the menu + alone, which leaves the assignment to the keybindings options. + """ + for tracker_format, shortcut in _PROJECT_EXPORT_SHORTCUTS.items(): + self._shortcut_manager.register( + PROJECT_EXPORT_SHORTCUT_IDS[tracker_format], + shortcut, + partial(bindings.export_project, tracker_format), + ) + + for tracker_format, shortcut in _SAMPLE_EXPORT_SHORTCUTS.items(): + self._shortcut_manager.register( + SAMPLE_EXPORT_SHORTCUT_IDS[tracker_format], + shortcut, + partial(bindings.export_instruments, tracker_format), + ) + def _register_channel_shortcuts(self, bindings: ShortcutBindings) -> None: """Registers one action per tracker channel, plus the one that brings the whole mix back. diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 165df7e2..f075d6f5 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -410,11 +410,11 @@ Widget.MENU, "item_file_project_properties", ) -TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE = TagName( +TAG_GLOBAL_MENU_ITEM_FILE_EXPORT = TagName( Page.GLOBAL, Panel.IMPLICIT, Widget.MENU, - "item_file_export_module", + "item_file_export", ) TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT = TagName( Page.GLOBAL, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index d3b86571..46f7ebb0 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -10,6 +10,10 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import ( + TRACKER_PROJECT_MENU_LABELS, + TRACKER_SAMPLE_MENU_LABELS, +) from sampletones_application.layout.glyphs import PlayerGlyphs from sampletones_application.layout.player import PlayerLayout from sampletones_application.tags.general import ( @@ -17,7 +21,7 @@ TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, - TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, + TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_OPEN_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, @@ -67,6 +71,8 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager @@ -78,7 +84,7 @@ TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT_AS, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, - TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, + TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, ) RECONSTRUCTION_ITEM_TAGS: Final[Tuple[str, ...]] = ( @@ -216,12 +222,7 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_FILE_PROJECT_PROPERTIES), enabled=state.project_open, ) - self._shortcut_manager.add_menu_item( - ShortcutId.EXPORT_PROJECT_FAMITRACKER, - tag=TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, - label=self._label(MenuElements.ITEM_FILE_EXPORT_MODULE), - enabled=state.project_open, - ) + self._create_project_export_menu(state) dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.CLOSE_PROJECT, @@ -235,6 +236,24 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_FILE_EXIT), ) + def _create_project_export_menu(self, state: MenuBarViewModel) -> None: + """Builds the submenu that writes the open project for one tracker. + + Each format reads its own kind of file, so the formats are listed side by side and + the one chosen decides what the destination dialog offers. The submenu is open while + a project is, which is where the whole group takes its enabled state. + """ + with dpg.menu( + tag=TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, + label=self._label(MenuElements.GROUP_FILE_EXPORT), + enabled=state.project_open, + ): + for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items(): + self._shortcut_manager.add_menu_item( + shortcut_id, + label=self._label(TRACKER_PROJECT_MENU_LABELS[tracker_format]), + ) + def _create_edit_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): self._shortcut_manager.add_menu_item( @@ -322,12 +341,25 @@ def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_RECONSTRUCTION_EXPORT_WAV), enabled=state.reconstruction_loaded, ) - self._shortcut_manager.add_menu_item( - ShortcutId.EXPORT_RECONSTRUCTION_INSTRUMENTS, - tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, - label=self._label(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS), - enabled=state.reconstruction_loaded, - ) + self._create_instruments_export_menu(state) + + def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: + """Builds the submenu that writes the loaded reconstruction's slices for one tracker. + + A format that gathers every slice into one document and one that writes a file per + slice are listed together, since the choice between them is the user's; the + destination dialog then asks for whichever the chosen format fills. + """ + with dpg.menu( + tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, + label=self._label(MenuElements.GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS), + enabled=state.reconstruction_loaded, + ): + for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items(): + self._shortcut_manager.add_menu_item( + shortcut_id, + label=self._label(TRACKER_SAMPLE_MENU_LABELS[tracker_format]), + ) def _create_playback_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_PLAYBACK)): diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 4aaa4ad4..998b1a92 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -15,6 +15,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_LABELS from sampletones_application.constants.global_ import TAG_SEPARATOR from sampletones_application.layout.general.colors import FeatureColors from sampletones_application.layout.graphs import GraphsLayout @@ -72,6 +73,7 @@ from sampletones_core.exporters import Features from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.features import GENERATOR_KIND, supported_features +from sampletones_core.trackers.format import TrackerFormat from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, @@ -81,7 +83,7 @@ from sampletones_shared.types.application import Sender from sampletones_shared.utils.arrays import clamp -OnInstrumentExportCallback = Callable[[GeneratorName], None] +OnInstrumentExportCallback = Callable[[GeneratorName, TrackerFormat], None] OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] @@ -139,6 +141,15 @@ def __init__( TextType.LABEL, ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BUTTON, ] + self._lbl_export_formats: Dict[TrackerFormat, str] = { + tracker_format: language_manager[ + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + TextType.LABEL, + element, + ] + for tracker_format, element in TRACKER_INSTRUMENT_LABELS.items() + } self._lbl_copy = language_manager[ Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, @@ -328,13 +339,14 @@ def _setup_mouse_event_handler(self) -> None: with dpg.handler_registry(tag=self.mouse_item_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_move) - def _handle_export_button_clicked( + def _handle_export_format_selected( self, sender: Sender, app_data: Any, - user_data: GeneratorName, + user_data: Tuple[GeneratorName, TrackerFormat], ) -> None: - self.call(self.on_instrument_export, user_data) + generator_name, tracker_format = user_data + self.call(self.on_instrument_export, generator_name, tracker_format) def _create_tabs_for_generators(self) -> None: for generator_name in GeneratorName.items(): @@ -364,14 +376,13 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ): self.generator_plots[generator_name] = {} button_tag = f"{TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT}{TAG_SEPARATOR}{tab_tag}" - GUIButton( + button = GUIButton( tag=button_tag, parent=tab_tag, label=self._lbl_export_instrument, width=-1, - callback=self._handle_export_button_clicked, - user_data=generator_name, ) + self._create_export_formats_popup(button.button_tag, generator_name) self._status_bar.bind_to_item( button_tag, self._msg_export_instrument.format(generator=self._generator_labels[generator_name]), @@ -388,6 +399,24 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_INSTRUMENT_TABS).bind_to_item(tab_tag) + def _create_export_formats_popup( + self, + button_tag: str, + generator_name: GeneratorName, + ) -> None: + """Hangs the format choice off the export button, opening where the button sits. + + One slice reaches several trackers, so the button asks which one before a destination + is picked, and the chosen format travels with the generator to the export request. + """ + with dpg.popup(button_tag, mousebutton=dpg.mvMouseButton_Left): + for tracker_format, label in self._lbl_export_formats.items(): + dpg.add_menu_item( + label=label, + callback=self._handle_export_format_selected, + user_data=(generator_name, tracker_format), + ) + def _create_generator_content( self, generator_name: GeneratorName, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 9d304c9b..06fb298f 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -2,6 +2,7 @@ from typing import Dict, Final from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.format import TrackerFormat class ShortcutId(Enum): @@ -25,7 +26,9 @@ class ShortcutId(Enum): SAVE_RECONSTRUCTION_AS = "SaveReconstructionAs" CLOSE_RECONSTRUCTION = "CloseReconstruction" EXPORT_RECONSTRUCTION_WAV = "ExportReconstructionWav" - EXPORT_RECONSTRUCTION_INSTRUMENTS = "ExportReconstructionInstruments" + EXPORT_INSTRUMENTS_FAMITRACKER = "ExportInstrumentsFamiTracker" + EXPORT_INSTRUMENTS_BITPHASE = "ExportInstrumentsBitphase" + EXPORT_INSTRUMENTS_BITPHASE_PRESET = "ExportInstrumentsBitphasePreset" ADD_RECONSTRUCTION_TO_SEQUENCER = "AddReconstructionToSequencer" OPEN_RECONSTRUCTION_IN_EXPLORER = "OpenReconstructionInExplorer" LOCATE_ORIGINAL_AUDIO = "LocateOriginalAudio" @@ -55,3 +58,14 @@ class ShortcutId(Enum): GeneratorName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE, GeneratorName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE, } + +PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { + TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER, + TrackerFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE, +} + +SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { + TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, + TrackerFormat.BITPHASE: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE, + TrackerFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, +} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index c7f13c6e..fff4fb2d 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -165,7 +165,9 @@ global.menu.label.item_file_open_project: "Open project..." global.menu.label.item_file_save_project: "Save project" global.menu.label.item_file_save_project_as: "Save project as..." global.menu.label.item_file_project_properties: "Project properties..." -global.menu.label.item_file_export_module: "Export FamiTracker module..." +global.menu.label.group_file_export: "Export" +global.menu.label.item_file_export_famitracker: "FamiTracker module..." +global.menu.label.item_file_export_bitphase: "Bitphase project..." global.menu.label.item_file_close_project: "Close project" global.menu.label.item_file_exit: "Exit" global.menu.label.group_edit: "Edit" @@ -181,7 +183,10 @@ global.menu.label.item_reconstruction_save: "Save reconstruction" global.menu.label.item_reconstruction_save_as: "Save reconstruction as..." global.menu.label.item_reconstruction_close: "Close reconstruction" global.menu.label.item_reconstruction_export_wav: "Export to WAV..." -global.menu.label.item_reconstruction_export_instruments: "Export FamiTracker instruments..." +global.menu.label.group_reconstruction_export_instruments: "Export instruments" +global.menu.label.item_reconstruction_export_instruments_famitracker: "FamiTracker instruments..." +global.menu.label.item_reconstruction_export_instruments_bitphase: "Bitphase project..." +global.menu.label.item_reconstruction_export_instruments_bitphase_preset: "Bitphase presets..." global.menu.label.group_playback: "Playback" global.menu.label.item_playback_play: "Play" global.menu.label.item_playback_pause: "Pause" @@ -389,8 +394,10 @@ reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed # Reconstructions tab — Instruments # ============================================================================= reconstructions.instruments.label.section: "Instruments" -reconstructions.instruments.label.export_instrument_button: "Export FamiTracker instrument" -reconstructions.instruments.label.export_instruments_button: "Export FamiTracker instruments" +reconstructions.instruments.label.export_instrument_button: "Export instrument" +reconstructions.instruments.label.export_instrument_famitracker: "FamiTracker instrument" +reconstructions.instruments.label.export_instrument_bitphase: "Bitphase project" +reconstructions.instruments.label.export_instrument_bitphase_preset: "Bitphase preset" reconstructions.instruments.label.copy_button: "Copy" reconstructions.instruments.label.pitch_label: "Pitch" reconstructions.instruments.label.hi_pitch_label: "Hi-pitch" @@ -403,11 +410,11 @@ reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type va 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_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}." -reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on export." +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_generator_toggle: "Click to turn {on_or_off} {generator_name}." reconstructions.instruments.message.status_generator_not_available: "{generator_name} is not available." -reconstructions.instruments.message.status_export_instrument: "Export the {generator} generator's instrument as a FamiTracker instrument file." +reconstructions.instruments.message.status_export_instrument: "Click to choose the tracker the {generator} generator's instrument is exported for." 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." @@ -417,8 +424,8 @@ reconstructions.instruments.message.export_instruments_failed: "Failed to export reconstructions.instruments.title.export_status_dialog: "Export status" reconstructions.instruments.title.not_loaded_dialog: "Reconstruction not loaded" reconstructions.instruments.title.export_wav_dialog: "Export WAV" -reconstructions.instruments.title.export_instrument_dialog: "Export FamiTracker instrument" -reconstructions.instruments.title.export_instruments_dialog: "Export FamiTracker instruments" +reconstructions.instruments.title.export_instrument_dialog: "Export instrument" +reconstructions.instruments.title.export_instruments_dialog: "Export instruments" reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the initial {} by name (e.g. {}) or value ({})." # ============================================================================= diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_trackers.py new file mode 100644 index 00000000..10fcf908 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_trackers.py @@ -0,0 +1,98 @@ +from typing import Dict, FrozenSet, Set + +import pytest + +from sampletones_application.categories.trackers import ( + TRACKER_INSTRUMENT_FILTERS, + TRACKER_INSTRUMENT_LABELS, + TRACKER_PROJECT_ELEMENTS, + TRACKER_PROJECT_MENU_LABELS, + TRACKER_SAMPLE_MENU_LABELS, +) +from sampletones_application.utils.gui.shortcuts.ids import ( + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, +) +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_core.trackers.scope import ExportScope + + +@pytest.fixture(name="backends") +def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: + return build_tracker_backends() + + +def formats_supporting( + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, +) -> Set[TrackerFormat]: + return {tracker_format for tracker_format, backend in backends.items() if scope in backend.supported_scopes} + + +class TestEveryOfferedFormatHasABackend: + """A menu entry reaches a backend through the registry, so an entry the registry has no + backend for would raise a ``KeyError`` the moment it is chosen.""" + + @pytest.mark.parametrize( + "offered", + [ + frozenset(PROJECT_EXPORT_SHORTCUT_IDS), + frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), + frozenset(TRACKER_PROJECT_MENU_LABELS), + frozenset(TRACKER_SAMPLE_MENU_LABELS), + frozenset(TRACKER_INSTRUMENT_LABELS), + frozenset(TRACKER_INSTRUMENT_FILTERS), + frozenset(TRACKER_PROJECT_ELEMENTS), + ], + ids=[ + "project_shortcuts", + "sample_shortcuts", + "project_menu", + "sample_menu", + "instrument_popup", + "instrument_filters", + "project_elements", + ], + ) + def test_the_registry_builds_every_offered_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + offered: FrozenSet[TrackerFormat], + ) -> None: + assert offered <= frozenset(backends) + + +class TestTheMenusMatchTheSupportedScopes: + """Each submenu lists exactly the formats whose backend writes that scope, so a format + gains its entry by declaring the scope rather than by a second edit in the UI.""" + + def test_the_project_export_menu_lists_the_formats_that_write_a_project( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) + + def test_the_instruments_export_menu_lists_the_formats_that_write_a_sample( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) == formats_supporting(backends, ExportScope.SAMPLE) + + def test_the_instrument_popup_lists_the_formats_that_write_one_slice( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_INSTRUMENT_LABELS) == formats_supporting(backends, ExportScope.INSTRUMENT) + + +class TestEveryMenuEntryCarriesAnAction: + """A submenu builds its entries by pairing a shortcut id with a label, so the two maps + cover the same formats.""" + + def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) + + def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index e0f9c0b3..f6e2a35f 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,9 +1,10 @@ -from typing import List +from typing import List, Tuple from unittest.mock import MagicMock import pytest from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_LABELS from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.loader import load_layout_config from sampletones_application.paths import ( @@ -27,6 +28,7 @@ from sampletones_application.utils.palette import Palette from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.trackers.format import TrackerFormat @pytest.fixture @@ -107,6 +109,32 @@ def test_each_dimension_carries_its_own_length( assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT] +class TestInstrumentExportFormat: + """The export button asks which tracker the slice is written for, and the answer travels + with the generator so the logic below picks the matching backend.""" + + def test_the_chosen_format_reaches_the_export_callback( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + calls: List[Tuple[GeneratorName, TrackerFormat]] = [] + panel.on_instrument_export = lambda generator, tracker_format: calls.append((generator, tracker_format)) + + panel._handle_export_format_selected( + "sender", + None, + (GeneratorName.NOISE, TrackerFormat.BITPHASE_PRESET), + ) + + assert calls == [(GeneratorName.NOISE, TrackerFormat.BITPHASE_PRESET)] + + def test_the_popup_offers_a_label_for_every_format( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + assert set(panel._lbl_export_formats) == set(TRACKER_INSTRUMENT_LABELS) + + class TestSequenceStatusMessage: def test_a_sequence_within_the_limit_describes_editing( self, From 84afaaec2e7a2bc9a67466e01e8c8330fe548fc9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 1 Aug 2026 12:45:47 +0200 Subject: [PATCH 05/20] Fixed: missing initial pitch --- docs/development/bugs-and-todos.md | 1 + .../services/regeneration.py | 1 + src/sampletones_core/exporters/exporter.py | 119 ++++++------------ .../exporters/implementation/noise.py | 20 ++- .../exporters/implementation/pulse.py | 17 ++- .../exporters/implementation/triangle.py | 20 ++- .../exporters/implementation/utils.py | 23 ++-- .../reconstruction/instructions.py | 14 ++- .../reconstruction/reconstruction.py | 99 +++++++++++++-- src/sampletones_shared/application.py | 2 +- src/sampletones_shared/utils/arrays.py | 50 ++++++++ .../services/conftest.py | 5 +- .../logic/project/test_controller.py | 1 + .../services/test_regeneration.py | 44 +++++-- .../exporters/implementation/test_noise.py | 42 ++++--- .../exporters/implementation/test_triangle.py | 33 +++-- 16 files changed, 332 insertions(+), 159 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 590c519a..5b52bd32 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -9,6 +9,7 @@ * Tracker cell shortcuts * Drag and drop * Multiple Reconstruction views +* Playing a fragment by clicking on a waveform ### Tracker diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 6d2e3311..7281497d 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -109,6 +109,7 @@ def _run( generator_name, instructions, audio, + features.initial_pitch, ) self._emit( ServiceSuccess( diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 36ecfbf5..3999e668 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, Generic, Iterable, List, Optional, Union, cast +from typing import Dict, Final, Generic, List, Optional, Union, cast import numpy as np @@ -11,10 +11,12 @@ InstructionTypeUnion, ) from sampletones_core.types.feature import FeatureMap -from sampletones_shared.utils.arrays import trim +from sampletones_shared.utils.arrays import hold, trim from .feature import Features +EMPTY_ENVELOPE_VALUE: Final[int] = 0 + class Exporter(ABC, Generic[InstructionT]): """ @@ -35,16 +37,18 @@ class Exporter(ABC, Generic[InstructionT]): def to_features( self, instructions: List[InstructionT], + initial_pitch: int, ) -> Features: """Converts an instruction sequence into its :class:`Features`. Args: instructions: The channel's per-frame instructions. + initial_pitch: Reference pitch the arpeggio envelope is measured against. Returns: Features: The envelope representation of the sequence. """ - feature_map = self.get_feature_map(instructions) + feature_map = self.get_feature_map(instructions, initial_pitch) return self.from_feature_map_to_features(feature_map) @staticmethod @@ -77,23 +81,41 @@ def from_feature_map_to_features(feature_map: FeatureMap) -> Features: @classmethod @abstractmethod - def get_feature_map(cls, instructions: List[InstructionT]) -> FeatureMap: + def get_feature_map(cls, instructions: List[InstructionT], initial_pitch: int) -> FeatureMap: """Extracts the raw per-dimension feature arrays from an instruction sequence. Args: instructions: The channel's per-frame instructions. + initial_pitch: Reference pitch the arpeggio envelope is measured against. Returns: FeatureMap: The per-dimension arrays for this channel. """ + @classmethod + @abstractmethod + def derive_initial_pitch(cls, instructions: List[InstructionT]) -> int: + """Chooses the reference pitch an instruction sequence's arpeggio is measured against. + + The reference is chosen once, when a reconstruction is built, and stored alongside + the sequence. Every later export measures against that stored value, so editing the + arpeggio moves the frames around a base pitch that stays put. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + int: The reference pitch for the sequence. + """ + @classmethod def from_features(cls, features: Features) -> List[InstructionT]: """Rebuilds the instruction sequence from a :class:`Features`. - Walks the envelopes frame by frame, reading each dimension's value (holding the - previous instruction's value past the end of a shorter envelope) and assembling - one instruction per frame. + Walks the envelopes frame by frame and assembles one instruction per frame. Every + envelope is read relative to itself — a dimension trimmed shorter than the sequence + holds its own final value over the remaining frames — so the arpeggio stays an + offset from ``initial_pitch`` for the whole sequence. Args: features: The envelope representation of a channel. @@ -101,38 +123,25 @@ def from_features(cls, features: Features) -> List[InstructionT]: Returns: List[InstructionT]: The reconstructed per-frame instructions. """ - features_map = features.feature_map initial_pitch = features.initial_pitch + envelopes: Dict[FeatureKey, np.ndarray] = { + key: cast(np.ndarray, value) + for key, value in features.feature_map.items() + if key != FeatureKey.INITIAL_PITCH and value is not None + } + max_length = max((len(array) for array in envelopes.values()), default=0) + instructions: List[InstructionT] = [] - last_instruction: Optional[InstructionT] = None - non_empty_arrays: Iterable[np.ndarray] = cast( - Iterable[np.ndarray], - filter(lambda obj: isinstance(obj, np.ndarray), features_map.values()), - ) - max_length = max(map(len, non_empty_arrays), default=0) for index in range(max_length): instruction_dictionary: Dict[str, Union[bool, int]] = {} - for key, array in features_map.items(): - if key == FeatureKey.INITIAL_PITCH or array is None: - continue - + for key, array in envelopes.items(): attribute = cls._remap_feature_key(key) if not attribute: continue - value = Exporter.get_value( - attribute, - cast(np.ndarray, array), - last_instruction, - index, - initial_value=initial_pitch if attribute == "pitch" else 0, - ) - - instruction_dictionary[attribute] = value + instruction_dictionary[attribute] = int(hold(array, index, default=EMPTY_ENVELOPE_VALUE)) - instruction = cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch) - instructions.append(instruction) - last_instruction = instruction + instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch)) return instructions @@ -163,56 +172,6 @@ def _infer_instruction_on(dictionary: Dict[str, Union[bool, int]]) -> bool: return True - @classmethod - def _handle_special_attributes( - cls, - attribute: InstructionFields, - value: int, - initial_value: int, - ) -> int: - if attribute == "pitch": - value -= initial_value - - return value - - @classmethod - def get_value( - cls, - attribute: InstructionFields, - array: Optional[np.ndarray], - last_instruction: Optional[InstructionT], - index: int, - initial_value: int = 0, - ) -> int: - """Reads one attribute's value for a given frame. - - Returns the array's value at ``index`` when present; past the array's end it - carries the previous instruction's value forward, and falls back to - ``initial_value`` when neither is available. - - Args: - attribute: The instruction field being read. - array: The dimension's envelope, or ``None``. - last_instruction: The instruction from the previous frame, if any. - index: The frame position to read. - initial_value: The value used when the array is empty or exhausted. - - Returns: - int: The attribute's value for the frame. - """ - if array is None or not array.size: - return initial_value - - if index < len(array): - return int(array[index]) - - if last_instruction is not None: - if hasattr(last_instruction, attribute): - value = int(getattr(last_instruction, attribute)) - return cls._handle_special_attributes(attribute, value, initial_value) - - return initial_value - @classmethod def _remap_feature_key(cls, feature_key: FeatureKey) -> Optional[InstructionFields]: if not hasattr(cls, "_ATTRIBUTE_MAP"): diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py index 9833f8c2..5fe7867a 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -57,12 +57,24 @@ def extract_data(cls, instructions: List[NoiseInstruction]) -> Tuple[int, List[i return initial_period, periods, volumes, duty_cycles @classmethod - def get_feature_map(cls, instructions: List[NoiseInstruction]) -> FeatureMap: - initial_period, periods, volumes, duty_cycles = cls.extract_data(instructions) - arpeggio = (np.array(periods) - initial_period) % NUM_PERIODS + def derive_initial_pitch( + cls, + instructions: List[NoiseInstruction], + ) -> int: + initial_period, _, _, _ = cls.extract_data(instructions) + return initial_period + + @classmethod + def get_feature_map( + cls, + instructions: List[NoiseInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, periods, volumes, duty_cycles = cls.extract_data(instructions) + arpeggio = (np.array(periods) - initial_pitch) % NUM_PERIODS return { - FeatureKey.INITIAL_PITCH: initial_period, + FeatureKey.INITIAL_PITCH: initial_pitch, FeatureKey.VOLUME: np.array(volumes).astype(np.int8), FeatureKey.ARPEGGIO: arpeggio.astype(np.int8), FeatureKey.DUTY_CYCLE: np.array(duty_cycles).astype(np.int8), diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py index beca8175..00a4c99c 100644 --- a/src/sampletones_core/exporters/implementation/pulse.py +++ b/src/sampletones_core/exporters/implementation/pulse.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MIN_PITCH -from sampletones_core.exporters.implementation.utils import center_pitches +from sampletones_core.exporters.implementation.utils import center_pitch from sampletones_core.generators import GeneratorTypeUnion, PulseGenerator from sampletones_core.instructions import ( InstructionFields, @@ -59,9 +59,18 @@ def extract_data(cls, instructions: List[PulseInstruction]) -> Tuple[int, List[i return initial_pitch, pitches, volumes, duty_cycles @classmethod - def get_feature_map(cls, instructions: List[PulseInstruction]) -> FeatureMap: - initial_pitch, pitches, volumes, duty_cycles = cls.extract_data(instructions) - initial_pitch, arpeggio = center_pitches(initial_pitch, pitches) + def derive_initial_pitch(cls, instructions: List[PulseInstruction]) -> int: + first_pitch, pitches, _, _ = cls.extract_data(instructions) + return center_pitch(first_pitch, pitches) + + @classmethod + def get_feature_map( + cls, + instructions: List[PulseInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, pitches, volumes, duty_cycles = cls.extract_data(instructions) + arpeggio = np.array(pitches) - initial_pitch return { FeatureKey.INITIAL_PITCH: initial_pitch, diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py index 9c470cd4..4f69ab58 100644 --- a/src/sampletones_core/exporters/implementation/triangle.py +++ b/src/sampletones_core/exporters/implementation/triangle.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH -from sampletones_core.exporters.implementation.utils import center_pitches +from sampletones_core.exporters.implementation.utils import center_pitch from sampletones_core.generators import GeneratorTypeUnion, TriangleGenerator from sampletones_core.instructions import ( InstructionFields, @@ -54,9 +54,21 @@ def extract_data(cls, instructions: List[TriangleInstruction]) -> Tuple[int, Lis return initial_pitch, pitches, volumes @classmethod - def get_feature_map(cls, instructions: List[TriangleInstruction]) -> FeatureMap: - initial_pitch, pitches, volumes = cls.extract_data(instructions) - initial_pitch, arpeggio = center_pitches(initial_pitch, pitches) + def derive_initial_pitch( + cls, + instructions: List[TriangleInstruction], + ) -> int: + first_pitch, pitches, _ = cls.extract_data(instructions) + return center_pitch(first_pitch, pitches) + + @classmethod + def get_feature_map( + cls, + instructions: List[TriangleInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, pitches, volumes = cls.extract_data(instructions) + arpeggio = np.array(pitches) - initial_pitch return { FeatureKey.INITIAL_PITCH: initial_pitch, diff --git a/src/sampletones_core/exporters/implementation/utils.py b/src/sampletones_core/exporters/implementation/utils.py index 9e3d0565..b6d8182a 100644 --- a/src/sampletones_core/exporters/implementation/utils.py +++ b/src/sampletones_core/exporters/implementation/utils.py @@ -1,29 +1,32 @@ -from typing import List, Tuple +from typing import List import numpy as np -def center_pitches( +def center_pitch( initial_pitch: int, pitches: List[int], -) -> Tuple[int, np.ndarray]: +) -> int: """ - Re-centers a pitch sequence around the midpoint of its range. + Picks the pitch at the midpoint of a contour's range. - Shifts every pitch by the midpoint of its ``(min, max)`` range so the offsets - straddle zero, keeping an arpeggio's relative steps small around one center pitch. + Measuring a contour's offsets from the midpoint of its ``(min, max)`` range keeps an + arpeggio's relative steps small and straddling zero around one center pitch. An empty + contour keeps the reference where it is. Args: initial_pitch: Reference pitch the offsets are measured against. - pitches: Absolute pitches to re-center. + pitches: Absolute pitches the contour covers. Returns: - The center pitch (``initial_pitch`` plus the range midpoint) and the array of - signed offsets from that center, so each original pitch equals center + offset. + The center pitch: ``initial_pitch`` plus the midpoint of the offsets' range. """ + if not pitches: + return initial_pitch + differences = [pitch - initial_pitch for pitch in pitches] array = np.array(differences, dtype=np.int8) max_value = np.max(array) min_value = np.min(array) mean_value = (max_value + min_value) // 2 - return int(initial_pitch + mean_value), array - mean_value + return int(initial_pitch + mean_value) diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 211783f4..be2663e5 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -12,9 +12,17 @@ class InstructionsItem(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - generator_name: GeneratorName = Field(..., description="Name of the generator") + generator_name: GeneratorName = Field( + ..., + description="Name of the generator", + ) instructions: List[InstructionData[InstructionUnion]] = Field( - ..., description="List of instruction data for the generator" + ..., + description="List of instruction data for the generator", + ) + initial_pitch: int = Field( + ..., + description="Reference pitch the generator's arpeggio envelope is measured against", ) @classmethod @@ -22,6 +30,7 @@ def create( cls, generator_name: GeneratorName, instructions: List[InstructionUnion], + initial_pitch: int, ) -> InstructionsItem: return InstructionsItem( generator_name=generator_name, @@ -32,4 +41,5 @@ def create( ) for instruction in instructions ], + initial_pitch=initial_pitch, ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index a7a9b607..cd3d9185 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -13,6 +13,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import DataModel, Metadata from sampletones_core.exporters import ( + GENERATOR_NAME_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP, ExporterTypeUnion, ExporterUnion, @@ -47,17 +48,39 @@ class Reconstruction(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True) - metadata: Metadata = Field(default_factory=Metadata.default, description="Reconstruction metadata") - id: str = Field(..., description="Unique identifier for the reconstruction") + metadata: Metadata = Field( + default_factory=Metadata.default, + description="Reconstruction metadata", + ) + id: str = Field( + ..., + description="Unique identifier for the reconstruction", + ) audio_filepath: Optional[Path] = Field( ..., description="Location of the source audio; None marks a reconstruction detached from its local origin", ) - config: Config = Field(..., description="Configuration used for reconstruction", frozen=True) - approximation: np.ndarray = Field(..., description="Audio approximation") - approximations_data: List[ApproximationsItem] = Field(..., description="Approximations per generator") - instructions_data: List[InstructionsItem] = Field(..., description="Instructions per generator") - coefficient: float = Field(..., description="Normalization coefficient used during reconstruction") + config: Config = Field( + ..., + description="Configuration used for reconstruction", + frozen=True, + ) + approximation: np.ndarray = Field( + ..., + description="Audio approximation", + ) + approximations_data: List[ApproximationsItem] = Field( + ..., + description="Approximations per generator", + ) + instructions_data: List[InstructionsItem] = Field( + ..., + description="Instructions per generator", + ) + coefficient: float = Field( + ..., + description="Normalization coefficient used during reconstruction", + ) @cached_property def approximations(self) -> Dict[GeneratorName, np.ndarray]: @@ -70,10 +93,32 @@ def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]: for item in self.instructions_data } + @cached_property + def initial_pitches(self) -> Dict[GeneratorName, int]: + """The reference pitch each generator's arpeggio envelope is measured against.""" + return {item.generator_name: item.initial_pitch for item in self.instructions_data} + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] + @classmethod + def _derive_initial_pitch( + cls, + generator_name: GeneratorName, + instructions: List[InstructionUnion], + ) -> int: + """Chooses the reference pitch a channel's arpeggio envelope is measured against. + + The instruction type selects the exporter, matching how `export` resolves one. A + channel carrying no instructions takes the exporter its generator name pairs with, + which reports that exporter's resting reference. + """ + exporter_class = ( + cls._get_exporter_class(instructions[0]) if instructions else GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + ) + return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] + @classmethod def create( cls, @@ -92,10 +137,12 @@ def create( instructions_data: List[InstructionsItem] = [] for generator_name, instructions_list in instructions.items(): + channel_instructions = list(instructions_list) instructions_data.append( InstructionsItem.create( generator_name=generator_name, - instructions=list(instructions_list), + instructions=channel_instructions, + initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), ) ) @@ -138,7 +185,13 @@ def update_generator_data( generator_name: GeneratorName, instructions: List[InstructionUnion], partial_approximation: np.ndarray, + initial_pitch: int, ) -> None: + """Replaces one generator's instructions, audio, and reference pitch. + + The reference pitch travels with the instructions it produced, so a later export + measures the arpeggio against the same base the edit was made from. + """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") max_length = max( len(partial_approximation), @@ -152,7 +205,11 @@ def update_generator_data( self.approximations_data = self._build_approximations_data(rendered, max_length) self.instructions_data = [ ( - InstructionsItem.create(generator_name=generator_name, instructions=instructions) + InstructionsItem.create( + generator_name=generator_name, + instructions=instructions, + initial_pitch=initial_pitch, + ) if item.generator_name == generator_name else item ) @@ -260,6 +317,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: """Drops the memoized per-generator views so they recompute from their backing data.""" reconstruction.__dict__.pop("approximations", None) reconstruction.__dict__.pop("instructions", None) + reconstruction.__dict__.pop("initial_pitches", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -313,7 +371,11 @@ def validate_metadata(metadata: Metadata) -> None: actual_version=reconstruction_version, ) - def _validate_instructions(self, exporter: ExporterUnion, instructions: List[InstructionUnion]) -> None: + def _validate_instructions( + self, + exporter: ExporterUnion, + instructions: List[InstructionUnion], + ) -> None: first_instruction: InstructionUnion = instructions[0] exporter_class = self._get_exporter_class(instructions[0]) exporter_instruction_type = exporter.get_instruction_type() @@ -336,17 +398,28 @@ def export(self) -> Dict[GeneratorName, Features]: exporter_class = self._get_exporter_class(instructions[0]) exporter: ExporterUnion = exporter_class() self._validate_instructions(exporter, instructions) - feature: Features = exporter.to_features(instructions) # type: ignore[arg-type] + feature: Features = exporter.to_features( + instructions, # type: ignore[arg-type] + self.initial_pitches[name], + ) features[name] = feature return features @field_serializer("approximation") - def _serialize_approximation(self, approximation: np.ndarray, _info: Any) -> SerializedData: + def _serialize_approximation( + self, + approximation: np.ndarray, + _info: Any, + ) -> SerializedData: return serialize_array(approximation) @field_serializer("audio_filepath") - def _serialize_audio_filepath(self, audio_filepath: Optional[Path], _info: Any) -> Optional[str]: + def _serialize_audio_filepath( + self, + audio_filepath: Optional[Path], + _info: Any, + ) -> Optional[str]: if audio_filepath is None: return None diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index 67559978..cf5222e6 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -7,7 +7,7 @@ SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0" -SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.0" +SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.1" SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.0" SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" diff --git a/src/sampletones_shared/utils/arrays.py b/src/sampletones_shared/utils/arrays.py index 5e7cc17e..250b69c5 100644 --- a/src/sampletones_shared/utils/arrays.py +++ b/src/sampletones_shared/utils/arrays.py @@ -345,6 +345,56 @@ def trim(array: Array) -> Array: return module.concatenate([array[: last_end + 1], [last_value]]) +def hold( + array: Array, + index: int, + *, + default: Numeric, +) -> Numeric: + """ + Reads an envelope at an index, holding its final value past its end. + + An envelope describes the frames it covers and sustains its last value over every + frame beyond them, which is how a dimension trimmed shorter than its sequence keeps + describing the whole of it. An empty envelope describes no frame, so it reads as the + given default. + + Args: + array: The 1-dimensional envelope to read. + index: The frame position to read, counted from the envelope's start. + default: The value an empty envelope reads as. + + Returns: + The value at `index`, the final value once `index` reaches the envelope's end, + or `default` for an empty envelope. + + Raises: + TypeError: If array is not an Array. + ValueError: If array is not 1-dimensional, or if index is negative. + + Examples: + >>> int(hold(np.array([12, 0]), 0, default=0)) + 12 + >>> int(hold(np.array([12, 0]), 5, default=0)) + 0 + >>> hold(np.array([]), 3, default=7) + 7 + """ + if not isinstance(array, ArrayClasses): + raise TypeError(f"Expected array to be Array, got {type(array)}") + + if array.ndim != 1: + raise ValueError("Array must be 1-dimensional") + + if index < 0: + raise ValueError(f"Index must be at least 0, got {index}") + + if not array.size: + return default + + return array[min(index, len(array) - 1)] + + def interpolate_segment( array: Array, start_index: int, diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index decd5bcd..140ca412 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -33,7 +33,10 @@ def pulse_instructions() -> list: @pytest.fixture def pulse_features(pulse_instructions) -> Features: - return PulseExporter().to_features(pulse_instructions) + return PulseExporter().to_features( + pulse_instructions, + PulseExporter.derive_initial_pitch(pulse_instructions), + ) @pytest.fixture diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index dfc75109..ed2e55dd 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -508,6 +508,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( GeneratorName.PULSE1, new_instructions, np.zeros(64, dtype=np.float32), + 72, ) stored = controller.project.sample(sample.id).reconstruction diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 79507dff..2e165981 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,6 +1,6 @@ import threading from types import SimpleNamespace -from typing import Any, Dict, List +from typing import Any, Dict, Final, List from unittest.mock import MagicMock, patch import numpy as np @@ -10,6 +10,21 @@ from sampletones_application.services.result import ServiceCancelled, ServiceError, ServiceSuccess from sampletones_core.constants.enums import FeatureKey, GeneratorName +REFERENCE_PITCH: Final[int] = 60 + + +class FakeFeatures(Dict[Any, Any]): + """Stands in for ``Features``: records the edited dimension and carries a reference pitch.""" + + def __init__(self, initial_pitch: int) -> None: + super().__init__() + self.initial_pitch = initial_pitch + + +@pytest.fixture +def features() -> FakeFeatures: + return FakeFeatures(REFERENCE_PITCH) + @pytest.fixture def synthesis_mocks(): @@ -119,7 +134,7 @@ def test_run_when_cancelled_emits_service_cancelled(self) -> None: assert len(results) == 1 assert isinstance(results[0], ServiceCancelled) - def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction) -> None: + def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -127,7 +142,7 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -140,9 +155,8 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction assert outcome.generator_name is synthesis_mocks.generator_name assert outcome.feature_key is FeatureKey.VOLUME - def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruction) -> None: + def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() - features: Dict[Any, Any] = {} feature_key = FeatureKey.VOLUME new_value = 42 @@ -156,13 +170,13 @@ def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruct assert features[feature_key] == new_value - def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction) -> None: + def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -173,7 +187,7 @@ def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction) call_args = updated.update_generator_data.call_args assert call_args.args[0] == synthesis_mocks.generator_name - def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction) -> None: + def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction, features) -> None: extra_instruction = MagicMock() synthesis_mocks.exporter.from_features.return_value = [synthesis_mocks.instruction, extra_instruction] service = RegenerationService() @@ -181,7 +195,7 @@ def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconst service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -241,7 +255,7 @@ class TestRegenerationServiceCancellationConstraints: synthesis that is already in progress. """ - def test_cancel_while_running_does_not_interrupt_synthesis(self, synthesis_mocks) -> None: + def test_cancel_while_running_does_not_interrupt_synthesis(self, synthesis_mocks, features) -> None: service = RegenerationService() results: List[Any] = [] done = threading.Event() @@ -255,7 +269,7 @@ def on_result(result: Any) -> None: task_started = threading.Event() task_unblock = threading.Event() - def blocking_from_features(features): + def blocking_from_features(edited_features): task_started.set() task_unblock.wait(timeout=2.0) return [synthesis_mocks.instruction] @@ -265,7 +279,13 @@ def blocking_from_features(features): reconstruction.config = MagicMock() thread = threading.Thread( - target=lambda: service._run(reconstruction, synthesis_mocks.generator_name, {}, FeatureKey.VOLUME, 1), + target=lambda: service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.VOLUME, + 1, + ), ) thread.start() task_started.wait(timeout=2.0) diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py index 232895af..a2852ea5 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_noise.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_noise.py @@ -68,45 +68,47 @@ def test_empty_instruction_list(self) -> None: assert duty_cycles == [] +class TestNoiseExporterDeriveInitialPitch: + def test_reference_is_the_first_sounding_period(self) -> None: + instructions = [_off(), _noise(period=7, volume=10), _noise(period=2, volume=10)] + assert NoiseExporter.derive_initial_pitch(instructions) == 7 + + def test_empty_instruction_list_references_period_zero(self) -> None: + assert NoiseExporter.derive_initial_pitch([]) == 0 + + class TestNoiseExporterGetFeatureMap: def test_feature_map_contains_all_required_keys(self) -> None: - feature_map = NoiseExporter.get_feature_map( - [ - _noise( - period=3, - volume=10, - ) - ] - ) + feature_map = NoiseExporter.get_feature_map([_noise(period=3, volume=10)], 3) assert FeatureKey.INITIAL_PITCH in feature_map assert FeatureKey.VOLUME in feature_map assert FeatureKey.ARPEGGIO in feature_map assert FeatureKey.DUTY_CYCLE in feature_map - def test_arpeggio_is_relative_to_initial_period_modulo_num_periods(self) -> None: + def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(self) -> None: instructions = [ _noise(period=2, volume=10), - _noise( - period=5, - volume=8, - ), + _noise(period=5, volume=8), ] - feature_map = NoiseExporter.get_feature_map(instructions) - initial = feature_map[FeatureKey.INITIAL_PITCH] + feature_map = NoiseExporter.get_feature_map(instructions, 4) arpeggio = feature_map[FeatureKey.ARPEGGIO] - assert int(arpeggio[0]) == (2 - initial) % NUM_PERIODS - assert int(arpeggio[1]) == (5 - initial) % NUM_PERIODS + assert int(arpeggio[0]) == (2 - 4) % NUM_PERIODS + assert int(arpeggio[1]) == (5 - 4) % NUM_PERIODS + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = NoiseExporter.get_feature_map([_noise(period=2, volume=10)], 9) + assert feature_map[FeatureKey.INITIAL_PITCH] == 9 def test_volume_array_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.VOLUME].dtype == np.int8 def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 def test_duty_cycle_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 diff --git a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py index 678033a7..517cd291 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py @@ -52,27 +52,44 @@ def test_empty_instruction_list_returns_min_pitch(self) -> None: assert volumes == [] +class TestTriangleExporterDeriveInitialPitch: + def test_reference_is_the_midpoint_of_the_contour(self) -> None: + instructions = [_tri(pitch=60), _tri(pitch=72)] + assert TriangleExporter.derive_initial_pitch(instructions) == 66 + + def test_flat_contour_references_its_own_pitch(self) -> None: + instructions = [_tri(pitch=60), _tri(pitch=60)] + assert TriangleExporter.derive_initial_pitch(instructions) == 60 + + def test_empty_instruction_list_references_min_pitch(self) -> None: + assert TriangleExporter.derive_initial_pitch([]) == MIN_PITCH + + class TestTriangleExporterGetFeatureMap: def test_feature_map_contains_required_keys(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)]) + feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 60) assert FeatureKey.INITIAL_PITCH in feature_map assert FeatureKey.VOLUME in feature_map assert FeatureKey.ARPEGGIO in feature_map - def test_arpeggio_is_relative_pitch_difference(self) -> None: + def test_arpeggio_is_relative_to_the_given_reference(self) -> None: instructions = [_tri(pitch=60), _tri(pitch=65)] - feature_map = TriangleExporter.get_feature_map(instructions) - initial = feature_map[FeatureKey.INITIAL_PITCH] + feature_map = TriangleExporter.get_feature_map(instructions, 60) arpeggio = feature_map[FeatureKey.ARPEGGIO] - assert int(arpeggio[0]) == 60 - initial - assert int(arpeggio[1]) == 65 - initial + assert int(arpeggio[0]) == 0 + assert int(arpeggio[1]) == 5 + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 55) + assert feature_map[FeatureKey.INITIAL_PITCH] == 55 + assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 def test_volume_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()]) + feature_map = TriangleExporter.get_feature_map([_tri()], 60) assert feature_map[FeatureKey.VOLUME].dtype == np.int8 def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()]) + feature_map = TriangleExporter.get_feature_map([_tri()], 60) assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 From 34990f8685357a85e9920aa57a4a35d5d13d0f01 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 1 Aug 2026 13:19:35 +0200 Subject: [PATCH 06/20] Updated: docs and tests --- CHANGELOG.md | 5 + docs/formats/famitracker.md | 16 +- docs/formats/reconstructions.md | 7 +- src/sampletones_core/exporters/feature.py | 10 +- .../services/test_regeneration.py | 99 +++++++ .../services/test_regeneration.py | 49 +++- .../exporters/implementation/test_pulse.py | 134 +++++++++ .../exporters/test_exporter.py | 265 ++++++++++++++++++ .../famitracker/test_builder.py | 32 ++- .../reconstruction/test_reconstruction.py | 81 +++++- .../sampletones_shared/utils/test_arrays.py | 115 ++++++++ 11 files changed, 796 insertions(+), 17 deletions(-) create mode 100644 tests/unit/sampletones_core/exporters/implementation/test_pulse.py create mode 100644 tests/unit/sampletones_core/exporters/test_exporter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a44206cf..6ceb1b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # SampleToNES +## v0.3.1 [2026-07-31] + +* Fixed arpeggio editing shifting a sample's pitch permanently. +* Bumped the reconstruction data-version to `2.1`. + ## v0.3.0 [2026-07-31] * Added a _Sequencer_ view with FamiTracker-style patterns. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 61a5fb88..13bad99a 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -164,13 +164,15 @@ and triggering the instrument at `initial_pitch` replays that contour. Volume, d (or noise mode) and any pitch sequences carry across directly. The DPCM key-assignment table is empty by design. -For the pitched channels, `center_pitches` picks the offset origin: it takes the -midpoint of the contour's `(lowest, highest)` range, reports that pitch as -`initial_pitch`, and stores each frame as `pitch − initial_pitch`. The offsets -straddle zero and stay compact around one note, and the pattern cell holds the -contour's midpoint — a rising contour prints its middle note and opens below it. The -noise channel measures its offsets from the first sounding period instead, wrapped -into the 16 available periods. +The offset origin is chosen once, when the reconstruction is built, and stored with it +as that channel's reference pitch (see +[Reconstructions](reconstructions.md#contents)). For the pitched channels +`center_pitch` picks it, taking the midpoint of the contour's `(lowest, highest)` +range; the noise channel takes the first sounding period. Every later export reports +that stored pitch as `initial_pitch` and writes each frame as `pitch − initial_pitch`, +wrapped into the 16 available periods on noise. The offsets straddle zero and stay +compact around one note, and the pattern cell holds the contour's midpoint — a rising +contour prints its middle note and opens below it. ## C. FamiTracker capacity limits diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 236bd58f..d6183416 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -29,7 +29,12 @@ A `.stn` file holds: one waveform per enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`); * **per-channel instructions** — the instruction stream each channel plays, one [instruction](../glossary.md#instruction) per frame. This is the data a - FamiTracker export is built from. + FamiTracker export is built from; +* **per-channel reference pitch** — the note each channel's arpeggio offsets are + measured against, chosen once when the reconstruction is built and stored with + the instructions it describes. An export reads the offsets against this pitch, + so editing an arpeggio moves the frames around a base that stays put (see + [FamiTracker export](famitracker.md)). ## Detached reconstructions diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index e3850adb..33634cce 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -14,13 +14,13 @@ class Features(BaseModel): The per-dimension envelopes describing one FamiTracker instrument. Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, - pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the pitch envelope - is relative to. An optional dimension is absent when the channel does not use it. - The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, ``in``) - exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. + pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio + envelope is relative to. An optional dimension is absent when the channel does not + use it. The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, + ``in``) exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. Attributes: - initial_pitch: Reference pitch the pitch envelope is measured against. + initial_pitch: Reference pitch the arpeggio envelope is measured against. volume: Volume envelope. arpeggio: Arpeggio (relative pitch) envelope. pitch: Pitch envelope, or ``None`` when unused. diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 786f35a8..acb2d48a 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -1,13 +1,18 @@ +from dataclasses import dataclass, field from typing import Any, List from unittest.mock import patch import numpy as np import pytest +from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_application.services.regeneration import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction +from tests.suite.scenario import BaseTestScenario, ScenarioStep _real_queue_add = CallbackQueue.add @@ -18,6 +23,9 @@ DELIVERY_BUDGET_FRAMES = 10 SETTLE_DELAY_FRAMES = 500 +BASE_PITCH = 60 +OCTAVE = 12 + class TestRegenerationServicePipeline: """Full synthesis pipeline: real Config, Features (via PulseExporter), real PulseGenerator, @@ -139,6 +147,97 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ assert isinstance(results[0], ServiceSuccess) +@dataclass +class ArpeggioEditContext: + reconstruction: Reconstruction + features: Features + history: List[List[int]] = field(default_factory=list) + + +def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: + """Applies an arpeggio envelope through the real regeneration pipeline. + + Each edit runs on its own service, exactly as the instruments panel drives one, and the + regenerated reconstruction replaces the context's own so the next edit continues from it. + """ + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + context.reconstruction, + GeneratorName.PULSE1, + context.features, + FeatureKey.ARPEGGIO, + arpeggio, + ) + + assert len(results) == 1 + assert isinstance(results[0], ServiceSuccess) + context.reconstruction = results[0].value.reconstruction + context.history.append(_pitches(context)) + + +def _pitches(context: ArpeggioEditContext) -> List[int]: + instructions = context.reconstruction.get_generator_instructions(GeneratorName.PULSE1) + return [instruction.pitch for instruction in instructions] + + +class TestArpeggioEditKeepsTheSamplePitch: + """The reported bug, end to end on the real pipeline. + + Typing an arpeggio envelope into a channel and clearing it again sounds the sample at the + note it was reconstructed at. The reference pitch travels with the instructions each edit + produces, so the second edit measures its offsets from the base the first one started at. + """ + + def test_clearing_an_arpeggio_returns_the_sample_to_its_pitch(self, reconstruction_data) -> None: + def build() -> ArpeggioEditContext: + reconstruction = reconstruction_data.reconstruction + return ArpeggioEditContext( + reconstruction=reconstruction, + features=FeatureData.load(reconstruction)[GeneratorName.PULSE1], + ) + + def check_the_starting_reference(context: ArpeggioEditContext) -> None: + assert context.features.initial_pitch == BASE_PITCH + assert context.features.arpeggio.tolist() == [0] + + def raise_the_first_frame_an_octave(context: ArpeggioEditContext) -> None: + _edit_arpeggio(context, np.array([OCTAVE, 0, 0, 0], dtype=np.int8)) + assert _pitches(context) == [BASE_PITCH + OCTAVE] + [BASE_PITCH] * 3 + + def reload_the_edited_features(context: ArpeggioEditContext) -> None: + context.features = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + assert context.features.initial_pitch == BASE_PITCH + assert context.features.arpeggio.tolist() == [OCTAVE, 0] + + def clear_the_envelope(context: ArpeggioEditContext) -> None: + _edit_arpeggio(context, np.zeros(len(context.features.arpeggio), dtype=np.int8)) + assert _pitches(context) == [BASE_PITCH] * 4 + + def check_the_reference_held(context: ArpeggioEditContext) -> None: + reloaded = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + assert reloaded.initial_pitch == BASE_PITCH + assert reloaded.arpeggio.tolist() == [0] + + scenario = BaseTestScenario( + label="arpeggio_edit_keeps_the_sample_pitch", + build=build, + steps=[ + ScenarioStep(label="check_the_starting_reference", action=check_the_starting_reference), + ScenarioStep(label="raise_the_first_frame_an_octave", action=raise_the_first_frame_an_octave), + ScenarioStep(label="reload_the_edited_features", action=reload_the_edited_features), + ScenarioStep(label="clear_the_envelope", action=clear_the_envelope), + ScenarioStep(label="check_the_reference_held", action=check_the_reference_held), + ], + ) + + context = scenario.run() + + assert context.history[-1] == [BASE_PITCH] * 4 + + class TestRegenerationDeliveryThroughRealQueue: """Regression guard for the frame-gate starvation that froze reconstruction regeneration. diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 2e165981..57be1d74 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,12 +14,22 @@ class FakeFeatures(Dict[Any, Any]): - """Stands in for ``Features``: records the edited dimension and carries a reference pitch.""" + """Stands in for ``Features``: records the edited dimension and carries a reference pitch. + + Assigning ``FeatureKey.INITIAL_PITCH`` moves the reference pitch, matching the real model, + so the pitch stepper's edit is observable through ``initial_pitch``. + """ def __init__(self, initial_pitch: int) -> None: super().__init__() self.initial_pitch = initial_pitch + def __setitem__(self, feature_key: Any, value: Any) -> None: + if feature_key == FeatureKey.INITIAL_PITCH: + self.initial_pitch = value + else: + super().__setitem__(feature_key, value) + @pytest.fixture def features() -> FakeFeatures: @@ -187,6 +197,43 @@ def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction, call_args = updated.update_generator_data.call_args assert call_args.args[0] == synthesis_mocks.generator_name + def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( + self, synthesis_mocks, reconstruction, features + ) -> None: + """An arpeggio edit stores the reference pitch the edit was made from. + + Handing the unchanged reference back to the reconstruction is what keeps a later + export measuring the envelope against the same base. + """ + service = RegenerationService() + + service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.ARPEGGIO, + np.array([12, 0], dtype=np.int8), + ) + + call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + assert call_args.args[3] == REFERENCE_PITCH + + def test_run_carries_a_moved_reference_pitch(self, synthesis_mocks, reconstruction, features) -> None: + """The pitch stepper's edit stores the new reference pitch.""" + moved_pitch = REFERENCE_PITCH + 12 + service = RegenerationService() + + service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.INITIAL_PITCH, + moved_pitch, + ) + + call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + assert call_args.args[3] == moved_pitch + def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction, features) -> None: extra_instruction = MagicMock() synthesis_mocks.exporter.from_features.return_value = [synthesis_mocks.instruction, extra_instruction] diff --git a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py new file mode 100644 index 00000000..0b4e842e --- /dev/null +++ b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py @@ -0,0 +1,134 @@ +import numpy as np + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.exporters.implementation.pulse import PulseExporter +from sampletones_core.generators import PulseGenerator +from sampletones_core.instructions.implementation.pulse import PulseInstruction + + +def _pulse(pitch: int = 60, volume: int = 8, duty_cycle: int = 0) -> PulseInstruction: + return PulseInstruction(on=volume > 0, pitch=pitch, volume=volume, duty_cycle=duty_cycle) + + +def _off() -> PulseInstruction: + return PulseInstruction(on=False, pitch=MIN_PITCH, volume=0, duty_cycle=0) + + +class TestPulseExporterExtractData: + def test_initial_pitch_from_first_on_instruction(self) -> None: + initial_pitch, _, _, _ = PulseExporter.extract_data([_pulse(pitch=70)]) + assert initial_pitch == 70 + + def test_all_off_instructions_initial_pitch_is_min_pitch(self) -> None: + initial_pitch, _, _, _ = PulseExporter.extract_data([_off(), _off()]) + assert initial_pitch == MIN_PITCH + + def test_off_instruction_produces_zero_volume(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60), _off()]) + assert volumes[1] == 0 + + def test_on_instruction_carries_its_volume(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60, volume=12)]) + assert volumes[0] == 12 + + def test_trailing_nonzero_volume_appends_extra_zero(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60)]) + assert volumes[-1] == 0 + assert len(volumes) == 2 + + def test_off_instructions_before_on_get_backfilled(self) -> None: + _, pitches, _, _ = PulseExporter.extract_data([_off(), _pulse(pitch=55)]) + assert pitches[0] == 55 + + def test_duty_cycle_tracks_the_instruction(self) -> None: + _, _, _, duty_cycles = PulseExporter.extract_data([_pulse(pitch=60, duty_cycle=2)]) + assert duty_cycles[0] == 2 + + def test_empty_instruction_list_returns_min_pitch(self) -> None: + initial_pitch, pitches, volumes, duty_cycles = PulseExporter.extract_data([]) + assert initial_pitch == MIN_PITCH + assert pitches == [] + assert volumes == [] + assert duty_cycles == [] + + +class TestPulseExporterDeriveInitialPitch: + def test_reference_is_the_midpoint_of_the_contour(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=72)] + assert PulseExporter.derive_initial_pitch(instructions) == 66 + + def test_flat_contour_references_its_own_pitch(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=60)] + assert PulseExporter.derive_initial_pitch(instructions) == 60 + + def test_empty_instruction_list_references_min_pitch(self) -> None: + assert PulseExporter.derive_initial_pitch([]) == MIN_PITCH + + +class TestPulseExporterGetFeatureMap: + def test_feature_map_contains_required_keys(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 60) + assert FeatureKey.INITIAL_PITCH in feature_map + assert FeatureKey.VOLUME in feature_map + assert FeatureKey.ARPEGGIO in feature_map + assert FeatureKey.DUTY_CYCLE in feature_map + + def test_arpeggio_is_relative_to_the_given_reference(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=65)] + feature_map = PulseExporter.get_feature_map(instructions, 60) + arpeggio = feature_map[FeatureKey.ARPEGGIO] + assert int(arpeggio[0]) == 0 + assert int(arpeggio[1]) == 5 + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 55) + assert feature_map[FeatureKey.INITIAL_PITCH] == 55 + assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 + + def test_volume_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.VOLUME].dtype == np.int8 + + def test_arpeggio_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 + + def test_duty_cycle_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 + + +class TestPulseExporterReconstruction: + def test_valid_pitch_round_trips(self) -> None: + initial_pitch = 50 + arpeggio = 10 + dictionary = {"pitch": arpeggio, "volume": 8, "duty_cycle": 1} + result = PulseExporter._features_dictionary_to_instruction(dictionary, initial_pitch) + assert result.pitch == initial_pitch + arpeggio + assert result.volume == 8 + assert result.duty_cycle == 1 + assert result.on is True + + def test_invalid_pitch_above_max_returns_null_instruction(self) -> None: + dictionary = {"pitch": 10, "volume": 8, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, MAX_PITCH) + assert result.on is False + + def test_invalid_pitch_below_min_returns_null_instruction(self) -> None: + dictionary = {"pitch": -10, "volume": 8, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) + assert result.on is False + + def test_zero_volume_reconstructed_as_off(self) -> None: + dictionary = {"pitch": 0, "volume": 0, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, 60) + assert result.on is False + + +class TestPulseExporterTypeGetters: + def test_get_instruction_type_returns_pulse_instruction(self) -> None: + assert PulseExporter.get_instruction_type() is PulseInstruction + + def test_get_generator_type_returns_pulse_generator(self) -> None: + assert PulseExporter.get_generator_type() is PulseGenerator diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py new file mode 100644 index 00000000..9b818b23 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -0,0 +1,265 @@ +from dataclasses import dataclass +from typing import Any, Callable, Final, List, Sequence + +import numpy as np +import pytest + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters import ( + ExporterTypeUnion, + Features, + NoiseExporter, + PulseExporter, + TriangleExporter, +) +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 4 +SOUNDING_FRAMES: Final[int] = 5 +OCTAVE: Final[int] = 12 +PERIOD_STEP: Final[int] = 3 +PULSE_VOLUME: Final[int] = 8 +NOISE_VOLUME: Final[int] = 10 + + +def _read_pitch(instruction: Any) -> int: + pitch: int = instruction.pitch + return pitch + + +def _read_period(instruction: Any) -> int: + period: int = instruction.period + return period + + +def _pulse_line(pitch: int) -> List[PulseInstruction]: + return [PulseInstruction(on=True, pitch=pitch, volume=PULSE_VOLUME, duty_cycle=0) for _ in range(SOUNDING_FRAMES)] + + +def _triangle_line(pitch: int) -> List[TriangleInstruction]: + return [TriangleInstruction(on=True, pitch=pitch) for _ in range(SOUNDING_FRAMES)] + + +def _noise_line(period: int) -> List[NoiseInstruction]: + return [NoiseInstruction(on=True, period=period, volume=NOISE_VOLUME, short=False) for _ in range(SOUNDING_FRAMES)] + + +class TestArpeggioReferenceStability(BaseTestSuite): + """The reference pitch an arpeggio is measured against holds across an edit to the envelope. + + Each case walks the sequence a user performs in the instruments panel: a flat contour is + anchored once, an arpeggio envelope is typed in, the channel is rebuilt and exported again + against the stored anchor, and the envelope is finally cleared. The last step is the guard — + clearing the envelope returns every frame to the reference it started from, including the + frames the envelope is too short to cover. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + exporter: ExporterTypeUnion + instructions: Sequence[InstructionUnion] + read_pitch: Callable[[Any], int] + arpeggio: np.ndarray + edited_pitches: List[int] + + test_cases = [ + TestCase( + label="pulse", + exporter=PulseExporter, + instructions=_pulse_line(REFERENCE_PITCH), + read_pitch=_read_pitch, + arpeggio=np.array([OCTAVE, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PITCH + OCTAVE] + [REFERENCE_PITCH] * SOUNDING_FRAMES, + expected=REFERENCE_PITCH, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + instructions=_triangle_line(REFERENCE_PITCH), + read_pitch=_read_pitch, + arpeggio=np.array([OCTAVE, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PITCH + OCTAVE] + [REFERENCE_PITCH] * SOUNDING_FRAMES, + expected=REFERENCE_PITCH, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + instructions=_noise_line(REFERENCE_PERIOD), + read_pitch=_read_period, + arpeggio=np.array([PERIOD_STEP, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PERIOD + PERIOD_STEP] + [REFERENCE_PERIOD] * SOUNDING_FRAMES, + expected=REFERENCE_PERIOD, + ), + ] + + @staticmethod + def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Features: + return test_case.exporter().to_features(list(instructions), test_case.expected) + + @classmethod + def _edited(cls, test_case: TestCase) -> List[InstructionUnion]: + features = cls._export(test_case, test_case.instructions) + features[FeatureKey.ARPEGGIO] = test_case.arpeggio + return test_case.exporter.from_features(features) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_derived_reference_is_the_contour_pitch(self, test_case: TestCase) -> None: + assert test_case.exporter.derive_initial_pitch(list(test_case.instructions)) == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_flat_contour_exports_a_zero_offset(self, test_case: TestCase) -> None: + features = self._export(test_case, test_case.instructions) + + assert features.initial_pitch == test_case.expected + assert features.arpeggio.tolist() == [0] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_edited_arpeggio_offsets_every_frame_from_the_reference(self, test_case: TestCase) -> None: + """The envelope's final value carries over the frames beyond it, as an offset. + + A two-item envelope describes a channel that sounds for longer, so the frames past + its end repeat its last offset. They land on the reference, rather than accumulating + a step per frame. + """ + instructions = self._edited(test_case) + + assert [test_case.read_pitch(instruction) for instruction in instructions] == test_case.edited_pitches + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_re_export_keeps_the_stored_reference(self, test_case: TestCase) -> None: + features = self._export(test_case, self._edited(test_case)) + + assert features.initial_pitch == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_re_export_reads_the_edited_arpeggio_back(self, test_case: TestCase) -> None: + features = self._export(test_case, self._edited(test_case)) + + assert features.arpeggio.tolist() == test_case.arpeggio.tolist() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_cleared_arpeggio_returns_every_frame_to_the_reference(self, test_case: TestCase) -> None: + """Clearing an arpeggio envelope restores the pitch the channel started at. + + This is the reported behaviour: typing ``12 0`` and then clearing it back to ``0`` + sounds the sample at the note it was reconstructed at. + """ + features = self._export(test_case, self._edited(test_case)) + features[FeatureKey.ARPEGGIO] = np.zeros(len(test_case.arpeggio), dtype=np.int8) + + cleared = test_case.exporter.from_features(features) + + pitches = [test_case.read_pitch(instruction) for instruction in cleared] + assert pitches == [test_case.expected] * len(cleared) + assert len(cleared) == len(test_case.edited_pitches) + + +class TestAbsentArpeggioEnvelope(BaseTestSuite): + """An arpeggio envelope covering no frame sounds the whole sequence at its reference pitch.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + exporter: ExporterTypeUnion + features: Features + read_pitch: Callable[[Any], int] + + test_cases = [ + TestCase( + label="pulse", + exporter=PulseExporter, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array([PULSE_VOLUME, PULSE_VOLUME, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=np.array([0], dtype=np.int8), + ), + read_pitch=_read_pitch, + expected=REFERENCE_PITCH, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array([15, 15, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=None, + ), + read_pitch=_read_pitch, + expected=REFERENCE_PITCH, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + features=Features( + initial_pitch=REFERENCE_PERIOD, + volume=np.array([NOISE_VOLUME, NOISE_VOLUME, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=np.array([0], dtype=np.int8), + ), + read_pitch=_read_period, + expected=REFERENCE_PERIOD, + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_frame_sounds_at_the_reference(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + pitches = [test_case.read_pitch(instruction) for instruction in instructions] + assert pitches == [test_case.expected] * len(test_case.features.volume) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_audible_frames_stay_audible(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert instructions[0].on is True + assert instructions[-1].on is False diff --git a/tests/unit/sampletones_core/famitracker/test_builder.py b/tests/unit/sampletones_core/famitracker/test_builder.py index eba3cd4c..7201f22d 100644 --- a/tests/unit/sampletones_core/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/famitracker/test_builder.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from sampletones_core.constants.enums import GeneratorName @@ -15,7 +16,10 @@ from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project -from .conftest import ProjectFixture, build_reconstruction +from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction + +LEAD_PITCH = 60 +OCTAVE = 12 class TestBuildInstrumentTable: @@ -31,7 +35,31 @@ def test_slot_maps_sample_and_generator_to_index(self, project_fixture: ProjectF def test_slot_carries_initial_pitch(self, project_fixture: ProjectFixture) -> None: _, slots = build_instrument_table(project_fixture.project) - assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].initial_pitch == 60 + assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].initial_pitch == LEAD_PITCH + + def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: ProjectFixture) -> None: + """A pattern row triggers the instrument at the note its sample was reconstructed at. + + Raising a channel's first frame an octave moves the arpeggio sequence, and the row + keeps naming the reference pitch — so the tracker plays the contour the reconstruction + view sounds. + """ + arpeggiated = [ + PulseInstruction(on=True, pitch=LEAD_PITCH + OCTAVE, volume=15, duty_cycle=0), + PulseInstruction(on=True, pitch=LEAD_PITCH, volume=8, duty_cycle=0), + ] + project_fixture.lead.reconstruction.update_generator_data( + GeneratorName.PULSE1, + arpeggiated, + np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), + LEAD_PITCH, + ) + + instruments, slots = build_instrument_table(project_fixture.project) + + slot = slots[(project_fixture.lead.id, GeneratorName.PULSE1)] + assert slot.initial_pitch == LEAD_PITCH + assert list(instruments[slot.index].sequences[SequenceKind.ARPEGGIO].items)[0] == OCTAVE def test_looping_sample_loops_populated_sequences(self, project_fixture: ProjectFixture) -> None: instruments, slots = build_instrument_table(project_fixture.project) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 64c2d098..df570d85 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -1,12 +1,15 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final +from typing import Callable, Final, List from unittest.mock import patch +import numpy as np import pytest +from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import Metadata +from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, @@ -28,6 +31,27 @@ _RETUNED_FREQUENCY: Final[int] = 60 _FASTER_FREQUENCY: Final[int] = 120 +_AUDIO_LENGTH: Final[int] = 64 +_BASE_PITCH: Final[int] = 60 +_OCTAVE: Final[int] = 12 +_CONTOUR_MIDPOINT: Final[int] = 66 +_RESET_PITCH: Final[int] = 48 + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: + return Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={GeneratorName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={GeneratorName.PULSE1: instructions}, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + class TestRoundTrip: def test_save_load_round_trip( @@ -192,6 +216,61 @@ def test_deserialize_data_maps_error(self, test_case: TestCase) -> None: Reconstruction.deserialize_data(b"x", source="mem") +class TestInitialPitchReference: + """The reference pitch each channel's arpeggio is measured against is stored, not re-derived. + + Storing it is what keeps an arpeggio edit from moving the base pitch: an edited contour + carries absolute pitches, so deriving a reference from it again would follow the edit. + """ + + def test_create_anchors_each_generator_to_its_contour(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) + + assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _CONTOUR_MIDPOINT + + def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None: + """An arpeggiated channel exports offsets from the pitch it was anchored at. + + The channel is anchored flat at ``_BASE_PITCH`` and then given a contour an octave + up on its first frame — the shape an ``12 0`` envelope produces. The export reports + the stored reference and reads the octave straight back. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + arpeggiated = [_pulse(_BASE_PITCH + _OCTAVE), _pulse(_BASE_PITCH), _pulse(_BASE_PITCH)] + reconstruction.update_generator_data( + GeneratorName.PULSE1, + arpeggiated, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + ) + + features = reconstruction.export()[GeneratorName.PULSE1] + + assert features.initial_pitch == _BASE_PITCH + assert features.arpeggio.tolist() == [_OCTAVE, 0] + + def test_update_generator_data_replaces_the_reference(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_RESET_PITCH)], + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _RESET_PITCH, + ) + + assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH + + def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) + path = tmp_path / "anchored.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert loaded.initial_pitches == reconstruction.initial_pitches + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() diff --git a/tests/unit/sampletones_shared/utils/test_arrays.py b/tests/unit/sampletones_shared/utils/test_arrays.py index 6685eb4b..4183aff7 100644 --- a/tests/unit/sampletones_shared/utils/test_arrays.py +++ b/tests/unit/sampletones_shared/utils/test_arrays.py @@ -11,6 +11,7 @@ from sampletones_shared.utils.arrays import ( cast_to_float, clamp, + hold, infer_dtype, interpolate_segment, is_increasing, @@ -1690,6 +1691,120 @@ def test_trim(self, test_case: TestCase) -> None: assert_array_equal(result, test_case.expected) +class TestHold(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[int, float, Type[Exception]] + array: Any + index: Any + default: Any + + test_cases = [ + TestCase( + array=np.array([12, 5, 0]), + index=0, + default=0, + expected=12, + label="first_frame", + ), + TestCase( + array=np.array([12, 5, 0]), + index=1, + default=0, + expected=5, + label="middle_frame", + ), + TestCase( + array=np.array([12, 5, 7]), + index=2, + default=0, + expected=7, + label="final_frame", + ), + TestCase( + array=np.array([12, 5, 7]), + index=3, + default=0, + expected=7, + label="one_frame_past_the_end_holds_the_final_value", + ), + TestCase( + array=np.array([12, 5, 7]), + index=100, + default=0, + expected=7, + label="far_past_the_end_holds_the_final_value", + ), + TestCase( + array=np.array([4]), + index=9, + default=0, + expected=4, + label="single_frame_envelope_holds_its_only_value", + ), + TestCase( + array=np.array([], dtype=np.int8), + index=0, + default=0, + expected=0, + label="empty_envelope_reads_as_the_default", + ), + TestCase( + array=np.array([], dtype=np.int8), + index=3, + default=7, + expected=7, + label="empty_envelope_reads_as_the_default_at_any_index", + ), + TestCase( + array=np.array([2.5, -1.5]), + index=5, + default=0.0, + expected=-1.5, + label="float_envelope_holds_its_final_value", + ), + TestCase( + array=np.array([1, 2, 3]), + index=-1, + default=0, + expected=ValueError, + label="negative_index_rejected", + ), + TestCase( + array=np.array([[1, 2], [3, 4]]), + index=0, + default=0, + expected=ValueError, + label="array_not_1d", + ), + TestCase( + array=[1, 2, 3], + index=0, + default=0, + expected=TypeError, + label="list_not_array", + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_hold(self, test_case: TestCase) -> None: + if expect_error( + hold, + test_case.expected, + test_case.array, + test_case.index, + default=test_case.default, + ): + return + + result = hold(test_case.array, test_case.index, default=test_case.default) + assert result == test_case.expected + + class TestInterpolateSegment(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From ceb2492e7eb237ac8c673f7fe242384736a349f5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 1 Aug 2026 20:17:29 +0200 Subject: [PATCH 07/20] General improvements --- pyproject.toml | 2 +- scripts/ci/check_version_tag.py | 16 ++- .../reconstruction/instruments/instruments.py | 2 +- .../{bitphase => compatibility}/__init__.py | 0 src/sampletones_core/compatibility/kind.py | 7 ++ src/sampletones_core/compatibility/update.py | 10 ++ src/sampletones_core/data/model.py | 16 ++- .../{bitphase/model => formats}/__init__.py | 0 .../bitphase}/__init__.py | 0 .../{ => formats}/bitphase/btp.py | 2 +- .../{ => formats}/bitphase/builder.py | 32 +++--- .../{ => formats}/bitphase/envelopes.py | 12 +-- .../{ => formats}/bitphase/identifiers.py | 4 +- .../bitphase/model}/__init__.py | 0 .../{ => formats}/bitphase/model/config.py | 0 .../bitphase/model/instrument.py | 6 +- .../{ => formats}/bitphase/model/pattern.py | 4 +- .../{ => formats}/bitphase/model/project.py | 10 +- .../{ => formats}/bitphase/model/song.py | 6 +- .../{ => formats}/bitphase/model/table.py | 4 +- .../{ => formats}/bitphase/notes.py | 6 +- .../{ => formats}/bitphase/preset.py | 16 +-- .../bitphase/specification}/__init__.py | 0 .../bitphase/specification/channels.py | 0 .../bitphase/specification/chip.py | 0 .../bitphase/specification/instruments.py | 4 +- .../bitphase/specification/patterns.py | 2 +- .../{ => formats}/bitphase/tuning.py | 4 +- .../famitracker}/__init__.py | 0 .../{ => formats}/famitracker/binary.py | 2 +- .../{ => formats}/famitracker/builder.py | 18 ++-- .../{ => formats}/famitracker/export.py | 6 +- .../famitracker/instrument.py} | 24 +++-- .../famitracker/model}/__init__.py | 0 .../famitracker/model/instrument.py | 4 +- .../{ => formats}/famitracker/model/module.py | 8 +- .../famitracker/model/pattern.py | 2 +- .../famitracker/model/sequence.py | 9 +- .../ftm.py => formats/famitracker/module.py} | 26 +++-- .../{ => formats}/famitracker/notes.py | 8 +- .../famitracker/sequences}/__init__.py | 0 .../famitracker/sequences/features.py | 4 +- .../famitracker/sequences/pooled.py | 4 +- .../famitracker/sequences/pooling.py | 8 +- .../famitracker/specification}/__init__.py | 0 .../famitracker/specification/blocks.py | 0 .../famitracker/specification/channels.py | 0 .../famitracker/specification/file.py | 0 .../famitracker/specification/instruments.py | 2 +- .../famitracker/specification/parameters.py | 0 .../famitracker/specification/patterns.py | 0 .../famitracker/specification/sequences.py | 0 src/sampletones_core/library/data.py | 1 + .../trackers/implementation}/__init__.py | 0 .../trackers/{ => implementation}/bitphase.py | 6 +- .../{ => implementation}/famitracker.py | 12 +-- src/sampletones_core/trackers/registry.py | 4 +- src/sampletones_core/utils/display.py | 10 +- src/sampletones_shared/deployment/version.py | 101 +++++++++++++----- .../famitracker/test_ftm_pipeline.py | 8 +- .../services/test_export.py | 2 +- tests/suite/famitracker.py | 12 ++- .../services/test_conversion.py | 1 + .../reconstruction/test_instruments_panel.py | 2 +- .../sampletones_core/features/test_spec.py | 2 +- .../formats/famitracker/__init__.py | 0 .../{ => formats}/famitracker/conftest.py | 0 .../formats/famitracker/model/__init__.py | 0 .../famitracker/model/test_sequence.py | 4 +- .../formats/famitracker/sequences/__init__.py | 0 .../famitracker/sequences/test_features.py | 4 +- .../{ => formats}/famitracker/test_binary.py | 4 +- .../{ => formats}/famitracker/test_builder.py | 12 +-- .../{ => formats}/famitracker/test_fti.py | 6 +- .../{ => formats}/famitracker/test_ftm.py | 18 ++-- .../{ => formats}/famitracker/test_notes.py | 4 +- .../trackers/test_famitracker.py | 4 +- uv.lock | 2 +- 78 files changed, 304 insertions(+), 205 deletions(-) rename src/sampletones_core/{bitphase => compatibility}/__init__.py (100%) create mode 100644 src/sampletones_core/compatibility/kind.py create mode 100644 src/sampletones_core/compatibility/update.py rename src/sampletones_core/{bitphase/model => formats}/__init__.py (100%) rename src/sampletones_core/{bitphase/specification => formats/bitphase}/__init__.py (100%) rename src/sampletones_core/{ => formats}/bitphase/btp.py (93%) rename src/sampletones_core/{ => formats}/bitphase/builder.py (92%) rename src/sampletones_core/{ => formats}/bitphase/envelopes.py (94%) rename src/sampletones_core/{ => formats}/bitphase/identifiers.py (73%) rename src/sampletones_core/{famitracker => formats/bitphase/model}/__init__.py (100%) rename src/sampletones_core/{ => formats}/bitphase/model/config.py (100%) rename src/sampletones_core/{ => formats}/bitphase/model/instrument.py (94%) rename src/sampletones_core/{ => formats}/bitphase/model/pattern.py (95%) rename src/sampletones_core/{ => formats}/bitphase/model/project.py (79%) rename src/sampletones_core/{ => formats}/bitphase/model/song.py (88%) rename src/sampletones_core/{ => formats}/bitphase/model/table.py (86%) rename src/sampletones_core/{ => formats}/bitphase/notes.py (94%) rename src/sampletones_core/{ => formats}/bitphase/preset.py (83%) rename src/sampletones_core/{famitracker/model => formats/bitphase/specification}/__init__.py (100%) rename src/sampletones_core/{ => formats}/bitphase/specification/channels.py (100%) rename src/sampletones_core/{ => formats}/bitphase/specification/chip.py (100%) rename src/sampletones_core/{ => formats}/bitphase/specification/instruments.py (95%) rename src/sampletones_core/{ => formats}/bitphase/specification/patterns.py (91%) rename src/sampletones_core/{ => formats}/bitphase/tuning.py (89%) rename src/sampletones_core/{famitracker/sequences => formats/famitracker}/__init__.py (100%) rename src/sampletones_core/{ => formats}/famitracker/binary.py (96%) rename src/sampletones_core/{ => formats}/famitracker/builder.py (92%) rename src/sampletones_core/{ => formats}/famitracker/export.py (70%) rename src/sampletones_core/{famitracker/fti.py => formats/famitracker/instrument.py} (72%) rename src/sampletones_core/{famitracker/specification => formats/famitracker/model}/__init__.py (100%) rename src/sampletones_core/{ => formats}/famitracker/model/instrument.py (76%) rename src/sampletones_core/{ => formats}/famitracker/model/module.py (81%) rename src/sampletones_core/{ => formats}/famitracker/model/pattern.py (92%) rename src/sampletones_core/{ => formats}/famitracker/model/sequence.py (91%) rename src/sampletones_core/{famitracker/ftm.py => formats/famitracker/module.py} (87%) rename src/sampletones_core/{ => formats}/famitracker/notes.py (87%) rename {tests/unit/sampletones_core/famitracker => src/sampletones_core/formats/famitracker/sequences}/__init__.py (100%) rename src/sampletones_core/{ => formats}/famitracker/sequences/features.py (92%) rename src/sampletones_core/{ => formats}/famitracker/sequences/pooled.py (57%) rename src/sampletones_core/{ => formats}/famitracker/sequences/pooling.py (82%) rename {tests/unit/sampletones_core/famitracker/model => src/sampletones_core/formats/famitracker/specification}/__init__.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/blocks.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/channels.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/file.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/instruments.py (76%) rename src/sampletones_core/{ => formats}/famitracker/specification/parameters.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/patterns.py (100%) rename src/sampletones_core/{ => formats}/famitracker/specification/sequences.py (100%) rename {tests/unit/sampletones_core/famitracker/sequences => src/sampletones_core/trackers/implementation}/__init__.py (100%) rename src/sampletones_core/trackers/{ => implementation}/bitphase.py (95%) rename src/sampletones_core/trackers/{ => implementation}/famitracker.py (86%) create mode 100644 tests/unit/sampletones_core/formats/famitracker/__init__.py rename tests/unit/sampletones_core/{ => formats}/famitracker/conftest.py (100%) create mode 100644 tests/unit/sampletones_core/formats/famitracker/model/__init__.py rename tests/unit/sampletones_core/{ => formats}/famitracker/model/test_sequence.py (77%) create mode 100644 tests/unit/sampletones_core/formats/famitracker/sequences/__init__.py rename tests/unit/sampletones_core/{ => formats}/famitracker/sequences/test_features.py (97%) rename tests/unit/sampletones_core/{ => formats}/famitracker/test_binary.py (95%) rename tests/unit/sampletones_core/{ => formats}/famitracker/test_builder.py (94%) rename tests/unit/sampletones_core/{ => formats}/famitracker/test_fti.py (96%) rename tests/unit/sampletones_core/{ => formats}/famitracker/test_ftm.py (92%) rename tests/unit/sampletones_core/{ => formats}/famitracker/test_notes.py (94%) diff --git a/pyproject.toml b/pyproject.toml index 3ad02ec0..bfbc3492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sampletones" -version = "0.3.0" +version = "0.3.1" description = "Approximate audio samples with the NES 2A03 oscillators and export them as FamiTracker instruments" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/ci/check_version_tag.py b/scripts/ci/check_version_tag.py index 24896b38..47d4438c 100644 --- a/scripts/ci/check_version_tag.py +++ b/scripts/ci/check_version_tag.py @@ -17,9 +17,19 @@ def tag_names_version(*, tag: str, project_version: str) -> bool: def main(argv: Sequence[str]) -> int: """Confirm a release tag and the project metadata agree on the version being released.""" - parser = argparse.ArgumentParser(description="Compare a release tag against the project version.") - parser.add_argument("--tag", required=True, help="the release tag being built, such as v0.3.0") - parser.add_argument("--project-version", required=True, help="the version recorded in pyproject.toml") + parser = argparse.ArgumentParser( + description="Compare a release tag against the project version.", + ) + parser.add_argument( + "--tag", + required=True, + help="the release tag being built, such as v0.3.0", + ) + parser.add_argument( + "--project-version", + required=True, + help="the version recorded in pyproject.toml", + ) arguments = parser.parse_args(list(argv)) tag: str = arguments.tag diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 998b1a92..ae17f409 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -71,8 +71,8 @@ ) from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH from sampletones_core.exporters import Features -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.features import GENERATOR_KIND, supported_features +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.trackers.format import TrackerFormat from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, diff --git a/src/sampletones_core/bitphase/__init__.py b/src/sampletones_core/compatibility/__init__.py similarity index 100% rename from src/sampletones_core/bitphase/__init__.py rename to src/sampletones_core/compatibility/__init__.py diff --git a/src/sampletones_core/compatibility/kind.py b/src/sampletones_core/compatibility/kind.py new file mode 100644 index 00000000..af1d6c8d --- /dev/null +++ b/src/sampletones_core/compatibility/kind.py @@ -0,0 +1,7 @@ +from enum import StrEnum, auto + + +class ObjectKind(StrEnum): + LIBRARY = auto() + RECONSTRUCTION = auto() + PROJECT = auto() diff --git a/src/sampletones_core/compatibility/update.py b/src/sampletones_core/compatibility/update.py new file mode 100644 index 00000000..fdc41e65 --- /dev/null +++ b/src/sampletones_core/compatibility/update.py @@ -0,0 +1,10 @@ +from typing import NamedTuple + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.deployment.version import Version + + +class VersionUpdate(NamedTuple): + kind: ObjectKind + base: Version + target: Version diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index f8c2568b..50112c9c 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -87,6 +87,7 @@ def load(cls, path: Pathlike, fast: bool = True) -> Self: def _construct(cls, fast: bool = True, **data: Any) -> Self: if fast: return cls.model_construct(**data) + return cls(**data) def serialize_inner(self) -> SerializedData: @@ -95,6 +96,7 @@ def serialize_inner(self) -> SerializedData: value = getattr(self, field_name) annotation = field_info.annotation result[field_name] = self._pack_value(value, annotation, field_name) + return result @classmethod @@ -108,10 +110,18 @@ def deserialize_inner( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation raw = data.get(field_name) - value = cls._unpack_value(raw, annotation, field_name, validation, fast) + value = cls._unpack_value( + raw, + annotation, + field_name, + validation, + fast, + ) if validation is not None: validation(value) + field_values[field_name] = value + return cls._construct(fast=fast, **field_values) def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: @@ -126,7 +136,9 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: if optional_inner is not None: if value is None: return None + return self._pack_value(value, optional_inner, field_name) + return self._pack_union(value, field_name) if isinstance(annotation, TypeVar): @@ -169,7 +181,9 @@ def _unpack_value( if optional_inner is not None: if raw is None: return None + return cls._unpack_value(raw, optional_inner, field_name, validation, fast) + return cls._unpack_union(raw, field_name) if isinstance(annotation, TypeVar): diff --git a/src/sampletones_core/bitphase/model/__init__.py b/src/sampletones_core/formats/__init__.py similarity index 100% rename from src/sampletones_core/bitphase/model/__init__.py rename to src/sampletones_core/formats/__init__.py diff --git a/src/sampletones_core/bitphase/specification/__init__.py b/src/sampletones_core/formats/bitphase/__init__.py similarity index 100% rename from src/sampletones_core/bitphase/specification/__init__.py rename to src/sampletones_core/formats/bitphase/__init__.py diff --git a/src/sampletones_core/bitphase/btp.py b/src/sampletones_core/formats/bitphase/btp.py similarity index 93% rename from src/sampletones_core/bitphase/btp.py rename to src/sampletones_core/formats/bitphase/btp.py index 9a5b681b..3da5e73b 100644 --- a/src/sampletones_core/bitphase/btp.py +++ b/src/sampletones_core/formats/bitphase/btp.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Final, Tuple -from sampletones_core.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.model.project import BitphaseProject JSON_SEPARATORS: Final[Tuple[str, str]] = (",", ":") FIXED_TIMESTAMP: Final[int] = 0 diff --git a/src/sampletones_core/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py similarity index 92% rename from src/sampletones_core/bitphase/builder.py rename to src/sampletones_core/formats/bitphase/builder.py index 85f38154..50f0ed95 100644 --- a/src/sampletones_core/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -1,39 +1,39 @@ -from __future__ import annotations - import math from dataclasses import dataclass from typing import Dict, List, Sequence, Tuple -from sampletones_core.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes -from sampletones_core.bitphase.identifiers import format_instrument_id -from sampletones_core.bitphase.model.instrument import BitphaseInstrument -from sampletones_core.bitphase.model.pattern import ( +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.pattern import ( BitphaseChannel, BitphasePattern, BitphaseRow, NoteCell, ) -from sampletones_core.bitphase.model.project import BitphaseProject -from sampletones_core.bitphase.model.song import BitphaseSong -from sampletones_core.bitphase.model.table import BitphaseTable -from sampletones_core.bitphase.notes import ( +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.model.song import BitphaseSong +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import ( noise_period_to_note_index, note_index_to_note_cell, pitch_to_note_index, ) -from sampletones_core.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX -from sampletones_core.bitphase.specification.chip import ( +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX +from sampletones_core.formats.bitphase.specification.chip import ( CPU_FREQUENCIES, DEFAULT_A4_TUNING, DEFAULT_CHIP_VARIANT, ) -from sampletones_core.bitphase.specification.instruments import ( +from sampletones_core.formats.bitphase.specification.instruments import ( MAX_INSTRUMENT_ID, MAX_TABLE_ID, MIN_INSTRUMENT_ID, MIN_TABLE_ID, ) -from sampletones_core.bitphase.specification.patterns import ( +from sampletones_core.formats.bitphase.specification.patterns import ( FIRST_PATTERN_ID, FULL_VOLUME, MAX_PATTERN_LENGTH, @@ -42,9 +42,7 @@ TABLE_COLUMN_OFFSET, NoteName, ) -from sampletones_core.bitphase.tuning import generate_tuning_table -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.formats.bitphase.tuning import generate_tuning_table from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row diff --git a/src/sampletones_core/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py similarity index 94% rename from src/sampletones_core/bitphase/envelopes.py rename to src/sampletones_core/formats/bitphase/envelopes.py index aaac4ac4..917e4f3e 100644 --- a/src/sampletones_core/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -3,9 +3,12 @@ import numpy as np -from sampletones_core.bitphase.model.instrument import NesInstrumentRow -from sampletones_core.bitphase.notes import noise_arpeggio_to_table_offset -from sampletones_core.bitphase.specification.instruments import ( +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow +from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset +from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, NO_TABLE_OFFSET, @@ -13,9 +16,6 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters.feature import Features -from sampletones_core.exporters.lengths import equalize_lengths SILENT_ROW: Final[NesInstrumentRow] = NesInstrumentRow( pulse_width=FLAT_PULSE_WIDTH, diff --git a/src/sampletones_core/bitphase/identifiers.py b/src/sampletones_core/formats/bitphase/identifiers.py similarity index 73% rename from src/sampletones_core/bitphase/identifiers.py rename to src/sampletones_core/formats/bitphase/identifiers.py index a5caaf9c..1bd92df9 100644 --- a/src/sampletones_core/bitphase/identifiers.py +++ b/src/sampletones_core/formats/bitphase/identifiers.py @@ -1,5 +1,5 @@ -from sampletones_core.bitphase.specification.instruments import INSTRUMENT_ID_DIGITS -from sampletones_core.bitphase.specification.patterns import SYMBOL_BASE, SYMBOL_DIGITS +from sampletones_core.formats.bitphase.specification.instruments import INSTRUMENT_ID_DIGITS +from sampletones_core.formats.bitphase.specification.patterns import SYMBOL_BASE, SYMBOL_DIGITS def format_instrument_id(number: int) -> str: diff --git a/src/sampletones_core/famitracker/__init__.py b/src/sampletones_core/formats/bitphase/model/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/__init__.py rename to src/sampletones_core/formats/bitphase/model/__init__.py diff --git a/src/sampletones_core/bitphase/model/config.py b/src/sampletones_core/formats/bitphase/model/config.py similarity index 100% rename from src/sampletones_core/bitphase/model/config.py rename to src/sampletones_core/formats/bitphase/model/config.py diff --git a/src/sampletones_core/bitphase/model/instrument.py b/src/sampletones_core/formats/bitphase/model/instrument.py similarity index 94% rename from src/sampletones_core/bitphase/model/instrument.py rename to src/sampletones_core/formats/bitphase/model/instrument.py index 54ab7258..37cb71af 100644 --- a/src/sampletones_core/bitphase/model/instrument.py +++ b/src/sampletones_core/formats/bitphase/model/instrument.py @@ -2,9 +2,9 @@ from pydantic import BaseModel, Field -from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG -from sampletones_core.bitphase.specification.chip import CHIP_TYPE_NES -from sampletones_core.bitphase.specification.instruments import ( +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import ( ABSOLUTE_TONE, CONSTANT_VOLUME, KEEP_PHASE, diff --git a/src/sampletones_core/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py similarity index 95% rename from src/sampletones_core/bitphase/model/pattern.py rename to src/sampletones_core/formats/bitphase/model/pattern.py index 838e0ce5..b2514f15 100644 --- a/src/sampletones_core/bitphase/model/pattern.py +++ b/src/sampletones_core/formats/bitphase/model/pattern.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, Field -from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG -from sampletones_core.bitphase.specification.patterns import ( +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.patterns import ( EMPTY_OCTAVE, FULL_VOLUME, MAX_PATTERN_LENGTH, diff --git a/src/sampletones_core/bitphase/model/project.py b/src/sampletones_core/formats/bitphase/model/project.py similarity index 79% rename from src/sampletones_core/bitphase/model/project.py rename to src/sampletones_core/formats/bitphase/model/project.py index 2fdd07bb..27f1b72a 100644 --- a/src/sampletones_core/bitphase/model/project.py +++ b/src/sampletones_core/formats/bitphase/model/project.py @@ -2,11 +2,11 @@ from pydantic import BaseModel, Field -from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG -from sampletones_core.bitphase.model.instrument import BitphaseInstrument -from sampletones_core.bitphase.model.song import BitphaseSong -from sampletones_core.bitphase.model.table import BitphaseTable -from sampletones_core.bitphase.specification.patterns import FIRST_PATTERN_ID +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.song import BitphaseSong +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.patterns import FIRST_PATTERN_ID class BitphaseProject(BaseModel): diff --git a/src/sampletones_core/bitphase/model/song.py b/src/sampletones_core/formats/bitphase/model/song.py similarity index 88% rename from src/sampletones_core/bitphase/model/song.py rename to src/sampletones_core/formats/bitphase/model/song.py index 1100cb62..3c852e3f 100644 --- a/src/sampletones_core/bitphase/model/song.py +++ b/src/sampletones_core/formats/bitphase/model/song.py @@ -2,9 +2,9 @@ from pydantic import BaseModel, Field -from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG -from sampletones_core.bitphase.model.pattern import BitphasePattern -from sampletones_core.bitphase.specification.chip import ( +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.model.pattern import BitphasePattern +from sampletones_core.formats.bitphase.specification.chip import ( CHIP_TYPE_NES, DEFAULT_A4_TUNING, DEFAULT_CHIP_VARIANT, diff --git a/src/sampletones_core/bitphase/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py similarity index 86% rename from src/sampletones_core/bitphase/model/table.py rename to src/sampletones_core/formats/bitphase/model/table.py index 126be666..c2f3d3d0 100644 --- a/src/sampletones_core/bitphase/model/table.py +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, Field -from sampletones_core.bitphase.model.config import BITPHASE_MODEL_CONFIG -from sampletones_core.bitphase.specification.instruments import ( +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.instruments import ( LOOP_FROM_START, MAX_TABLE_ID, MIN_TABLE_ID, diff --git a/src/sampletones_core/bitphase/notes.py b/src/sampletones_core/formats/bitphase/notes.py similarity index 94% rename from src/sampletones_core/bitphase/notes.py rename to src/sampletones_core/formats/bitphase/notes.py index a6f9c911..0bc9e873 100644 --- a/src/sampletones_core/bitphase/notes.py +++ b/src/sampletones_core/formats/bitphase/notes.py @@ -1,5 +1,6 @@ -from sampletones_core.bitphase.model.pattern import NoteCell -from sampletones_core.bitphase.specification.patterns import ( +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.model.pattern import NoteCell +from sampletones_core.formats.bitphase.specification.patterns import ( FIRST_OCTAVE, MAX_NOTE_INDEX, MIN_NOTE_INDEX, @@ -8,7 +9,6 @@ NOTE_RANGE, NoteName, ) -from sampletones_core.constants.general import NUM_PERIODS def pitch_to_note_index(pitch: int) -> int: diff --git a/src/sampletones_core/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py similarity index 83% rename from src/sampletones_core/bitphase/preset.py rename to src/sampletones_core/formats/bitphase/preset.py index 594a33c0..fbd1a048 100644 --- a/src/sampletones_core/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -2,18 +2,18 @@ from pathlib import Path from typing import Final, Sequence, Tuple -from sampletones_core.bitphase.envelopes import features_to_envelopes -from sampletones_core.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow -from sampletones_core.bitphase.notes import pitch_to_note_index -from sampletones_core.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY -from sampletones_core.bitphase.specification.instruments import ( +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.envelopes import features_to_envelopes +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY +from sampletones_core.formats.bitphase.specification.instruments import ( MAX_TONE_ADD, MIN_TONE_ADD, NO_TONE_OFFSET, ) -from sampletones_core.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX -from sampletones_core.bitphase.tuning import generate_tuning_table -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX +from sampletones_core.formats.bitphase.tuning import generate_tuning_table from sampletones_core.trackers.request import InstrumentExport PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( diff --git a/src/sampletones_core/famitracker/model/__init__.py b/src/sampletones_core/formats/bitphase/specification/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/model/__init__.py rename to src/sampletones_core/formats/bitphase/specification/__init__.py diff --git a/src/sampletones_core/bitphase/specification/channels.py b/src/sampletones_core/formats/bitphase/specification/channels.py similarity index 100% rename from src/sampletones_core/bitphase/specification/channels.py rename to src/sampletones_core/formats/bitphase/specification/channels.py diff --git a/src/sampletones_core/bitphase/specification/chip.py b/src/sampletones_core/formats/bitphase/specification/chip.py similarity index 100% rename from src/sampletones_core/bitphase/specification/chip.py rename to src/sampletones_core/formats/bitphase/specification/chip.py diff --git a/src/sampletones_core/bitphase/specification/instruments.py b/src/sampletones_core/formats/bitphase/specification/instruments.py similarity index 95% rename from src/sampletones_core/bitphase/specification/instruments.py rename to src/sampletones_core/formats/bitphase/specification/instruments.py index 655fa078..2dfb0691 100644 --- a/src/sampletones_core/bitphase/specification/instruments.py +++ b/src/sampletones_core/formats/bitphase/specification/instruments.py @@ -1,10 +1,10 @@ from typing import Final -from sampletones_core.bitphase.specification.patterns import ( +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_VOLUME +from sampletones_core.formats.bitphase.specification.patterns import ( SYMBOL_BASE, TABLE_COLUMN_OFFSET, ) -from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_VOLUME INSTRUMENT_ID_DIGITS: Final[int] = 2 MIN_INSTRUMENT_ID: Final[int] = 1 diff --git a/src/sampletones_core/bitphase/specification/patterns.py b/src/sampletones_core/formats/bitphase/specification/patterns.py similarity index 91% rename from src/sampletones_core/bitphase/specification/patterns.py rename to src/sampletones_core/formats/bitphase/specification/patterns.py index 29ced951..3601cac7 100644 --- a/src/sampletones_core/bitphase/specification/patterns.py +++ b/src/sampletones_core/formats/bitphase/specification/patterns.py @@ -1,7 +1,7 @@ from enum import IntEnum from typing import Final -from sampletones_core.bitphase.specification.chip import TUNING_TABLE_LENGTH +from sampletones_core.formats.bitphase.specification.chip import TUNING_TABLE_LENGTH class NoteName(IntEnum): diff --git a/src/sampletones_core/bitphase/tuning.py b/src/sampletones_core/formats/bitphase/tuning.py similarity index 89% rename from src/sampletones_core/bitphase/tuning.py rename to src/sampletones_core/formats/bitphase/tuning.py index b7827336..7c907759 100644 --- a/src/sampletones_core/bitphase/tuning.py +++ b/src/sampletones_core/formats/bitphase/tuning.py @@ -1,14 +1,14 @@ import math from typing import Tuple -from sampletones_core.bitphase.specification.chip import ( +from sampletones_core.formats.bitphase.specification.chip import ( MAX_TUNING_PERIOD, MIN_TUNING_PERIOD, TUNING_A4_INDEX, TUNING_PERIOD_DIVISOR, TUNING_TABLE_LENGTH, ) -from sampletones_core.bitphase.specification.patterns import NOTE_RANGE +from sampletones_core.formats.bitphase.specification.patterns import NOTE_RANGE def generate_tuning_table( diff --git a/src/sampletones_core/famitracker/sequences/__init__.py b/src/sampletones_core/formats/famitracker/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/sequences/__init__.py rename to src/sampletones_core/formats/famitracker/__init__.py diff --git a/src/sampletones_core/famitracker/binary.py b/src/sampletones_core/formats/famitracker/binary.py similarity index 96% rename from src/sampletones_core/famitracker/binary.py rename to src/sampletones_core/formats/famitracker/binary.py index e111b118..ff778b90 100644 --- a/src/sampletones_core/famitracker/binary.py +++ b/src/sampletones_core/formats/famitracker/binary.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from typing import Iterator -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block class BinaryWriter: diff --git a/src/sampletones_core/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py similarity index 92% rename from src/sampletones_core/famitracker/builder.py rename to src/sampletones_core/formats/famitracker/builder.py index ac150c0b..5cfa6259 100644 --- a/src/sampletones_core/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -8,28 +8,28 @@ InstrumentTable, iterate_sample_slices, ) -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.module import ( +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, ModuleInformation, ModuleParameters, OrderFrame, Track, ) -from sampletones_core.famitracker.model.pattern import PatternData, RowCell -from sampletones_core.famitracker.notes import ( +from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell +from sampletones_core.formats.famitracker.notes import ( period_to_note_cell, pitch_to_note_cell, resolve_machine, ) -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.specification.channels import ( +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, GENERATOR_NAME_TO_CHANNEL_ID, ChannelId, ) -from sampletones_core.famitracker.specification.instruments import MAX_INSTRUMENTS -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS +from sampletones_core.formats.famitracker.specification.parameters import ( DEFAULT_COPYRIGHT, DEFAULT_HIGHLIGHT_FIRST, DEFAULT_HIGHLIGHT_SECOND, @@ -37,7 +37,7 @@ DEFAULT_VIBRATO_STYLE, EXPANSION_NONE, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( DEFAULT_EFFECT_COLUMNS, DPCM_EMPTY_PATTERN_INDEX, EMPTY_EFFECT, diff --git a/src/sampletones_core/famitracker/export.py b/src/sampletones_core/formats/famitracker/export.py similarity index 70% rename from src/sampletones_core/famitracker/export.py rename to src/sampletones_core/formats/famitracker/export.py index e551afe5..16a18538 100644 --- a/src/sampletones_core/famitracker/export.py +++ b/src/sampletones_core/formats/famitracker/export.py @@ -1,7 +1,5 @@ -from __future__ import annotations - -from sampletones_core.famitracker.builder import project_to_module -from sampletones_core.famitracker.ftm import module_to_ftm_bytes +from sampletones_core.formats.famitracker.builder import project_to_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 diff --git a/src/sampletones_core/famitracker/fti.py b/src/sampletones_core/formats/famitracker/instrument.py similarity index 72% rename from src/sampletones_core/famitracker/fti.py rename to src/sampletones_core/formats/famitracker/instrument.py index 5b7ee6e8..4592e8ad 100644 --- a/src/sampletones_core/famitracker/fti.py +++ b/src/sampletones_core/formats/famitracker/instrument.py @@ -1,15 +1,13 @@ -from __future__ import annotations - -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.file import FTI_MAGIC, FTI_VERSION -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.file import FTI_MAGIC, FTI_VERSION +from sampletones_core.formats.famitracker.specification.instruments import ( EMPTY_DPCM_ASSIGNMENTS, EMPTY_DPCM_SAMPLES, INSTRUMENT_TYPE_2A03, ) -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, SEQUENCE_DISABLED, SEQUENCE_ENABLED, @@ -24,12 +22,18 @@ def _write_header(writer: BinaryWriter) -> None: writer.write_bytes(FTI_VERSION) -def _write_type_and_name(writer: BinaryWriter, instrument: Instrument2A03) -> None: +def _write_type_and_name( + writer: BinaryWriter, + instrument: Instrument2A03, +) -> None: writer.write_uint8(INSTRUMENT_TYPE_2A03) writer.write_counted_string(instrument.name) -def _write_sequence(writer: BinaryWriter, sequence: InstrumentSequence) -> None: +def _write_sequence( + writer: BinaryWriter, + sequence: InstrumentSequence, +) -> None: if not sequence.enabled: writer.write_int8(SEQUENCE_DISABLED) return diff --git a/src/sampletones_core/famitracker/specification/__init__.py b/src/sampletones_core/formats/famitracker/model/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/specification/__init__.py rename to src/sampletones_core/formats/famitracker/model/__init__.py diff --git a/src/sampletones_core/famitracker/model/instrument.py b/src/sampletones_core/formats/famitracker/model/instrument.py similarity index 76% rename from src/sampletones_core/famitracker/model/instrument.py rename to src/sampletones_core/formats/famitracker/model/instrument.py index f9f39333..a29c492d 100644 --- a/src/sampletones_core/famitracker/model/instrument.py +++ b/src/sampletones_core/formats/famitracker/model/instrument.py @@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind class Instrument2A03(BaseModel): diff --git a/src/sampletones_core/famitracker/model/module.py b/src/sampletones_core/formats/famitracker/model/module.py similarity index 81% rename from src/sampletones_core/famitracker/model/module.py rename to src/sampletones_core/formats/famitracker/model/module.py index e32ef10d..a03dc61b 100644 --- a/src/sampletones_core/famitracker/model/module.py +++ b/src/sampletones_core/formats/famitracker/model/module.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.pattern import PatternData -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.parameters import Machine +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.pattern import PatternData +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.parameters import Machine OrderFrame = Tuple[int, ...] """One order position: the pattern index each channel plays, in channel-id order.""" diff --git a/src/sampletones_core/famitracker/model/pattern.py b/src/sampletones_core/formats/famitracker/model/pattern.py similarity index 92% rename from src/sampletones_core/famitracker/model/pattern.py rename to src/sampletones_core/formats/famitracker/model/pattern.py index 60212ac5..6dfff230 100644 --- a/src/sampletones_core/famitracker/model/pattern.py +++ b/src/sampletones_core/formats/famitracker/model/pattern.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.channels import ChannelId class NoteCell(BaseModel): diff --git a/src/sampletones_core/famitracker/model/sequence.py b/src/sampletones_core/formats/famitracker/model/sequence.py similarity index 91% rename from src/sampletones_core/famitracker/model/sequence.py rename to src/sampletones_core/formats/famitracker/model/sequence.py index 8c724544..77fa4a69 100644 --- a/src/sampletones_core/famitracker/model/sequence.py +++ b/src/sampletones_core/formats/famitracker/model/sequence.py @@ -1,10 +1,8 @@ -from __future__ import annotations - from typing import Tuple from pydantic import BaseModel, ConfigDict, computed_field, field_validator -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( DEFAULT_SEQUENCE_SETTING, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, @@ -32,7 +30,10 @@ class InstrumentSequence(BaseModel): @field_validator("items") @classmethod - def _fits_famitracker_sequence(cls, items: Tuple[int, ...]) -> Tuple[int, ...]: + def _fits_famitracker_sequence( + cls, + items: Tuple[int, ...], + ) -> Tuple[int, ...]: """Keeps an instance within the item count FamiTracker can represent. FamiTracker holds a sequence in a fixed 252-entry array and stores the count in a diff --git a/src/sampletones_core/famitracker/ftm.py b/src/sampletones_core/formats/famitracker/module.py similarity index 87% rename from src/sampletones_core/famitracker/ftm.py rename to src/sampletones_core/formats/famitracker/module.py index 99d871db..fc88d49a 100644 --- a/src/sampletones_core/famitracker/ftm.py +++ b/src/sampletones_core/formats/famitracker/module.py @@ -1,22 +1,20 @@ -from __future__ import annotations - from typing import Sequence -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.module import ( +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, ModuleInformation, ModuleParameters, Track, ) -from sampletones_core.famitracker.model.pattern import PatternData -from sampletones_core.famitracker.sequences.pooled import PooledSequence -from sampletones_core.famitracker.sequences.pooling import ( +from sampletones_core.formats.famitracker.model.pattern import PatternData +from sampletones_core.formats.famitracker.sequences.pooled import PooledSequence +from sampletones_core.formats.famitracker.sequences.pooling import ( SequenceReferences, build_sequence_pool, ) -from sampletones_core.famitracker.specification.blocks import ( +from sampletones_core.formats.famitracker.specification.blocks import ( BLOCK_COMMENTS, BLOCK_DPCM_SAMPLES, BLOCK_FRAMES, @@ -27,25 +25,25 @@ BLOCK_PATTERNS, BLOCK_SEQUENCES, ) -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import ( +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import ( FTM_END_MARKER, FTM_MAGIC, FTM_VERSION, INFO_STRING_LENGTH, ) -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.specification.instruments import ( DPCM_KEY_ASSIGNMENTS, DPCM_KEY_BYTES, EMPTY_DPCM_SAMPLES, INSTRUMENT_TYPE_2A03, ) -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.parameters import ( COMMENT_HIDDEN_ON_OPEN, FIRST_TRACK_INDEX, SINGLE_TRACK_COUNT, ) -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, SEQUENCE_DISABLED, SEQUENCE_ENABLED, diff --git a/src/sampletones_core/famitracker/notes.py b/src/sampletones_core/formats/famitracker/notes.py similarity index 87% rename from src/sampletones_core/famitracker/notes.py rename to src/sampletones_core/formats/famitracker/notes.py index f2aa7073..dc05f0d9 100644 --- a/src/sampletones_core/famitracker/notes.py +++ b/src/sampletones_core/formats/famitracker/notes.py @@ -1,16 +1,14 @@ -from __future__ import annotations - from typing import Tuple from sampletones_core.constants.general import NUM_PERIODS -from sampletones_core.famitracker.model.pattern import NoteCell -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.model.pattern import NoteCell +from sampletones_core.formats.famitracker.specification.parameters import ( ENGINE_SPEED_MACHINE_DEFAULT, NTSC_FREQUENCY, PAL_FREQUENCY, Machine, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( FT_MAX_PITCH, FT_MIN_PITCH, NOTE_RANGE, diff --git a/tests/unit/sampletones_core/famitracker/__init__.py b/src/sampletones_core/formats/famitracker/sequences/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/__init__.py rename to src/sampletones_core/formats/famitracker/sequences/__init__.py diff --git a/src/sampletones_core/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py similarity index 92% rename from src/sampletones_core/famitracker/sequences/features.py rename to src/sampletones_core/formats/famitracker/sequences/features.py index a93d55fc..75d88510 100644 --- a/src/sampletones_core/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -3,8 +3,8 @@ import numpy as np from sampletones_core.exporters.lengths import equalize_lengths -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, diff --git a/src/sampletones_core/famitracker/sequences/pooled.py b/src/sampletones_core/formats/famitracker/sequences/pooled.py similarity index 57% rename from src/sampletones_core/famitracker/sequences/pooled.py rename to src/sampletones_core/formats/famitracker/sequences/pooled.py index f0781466..04efcd5c 100644 --- a/src/sampletones_core/famitracker/sequences/pooled.py +++ b/src/sampletones_core/formats/famitracker/sequences/pooled.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind @dataclass(frozen=True) diff --git a/src/sampletones_core/famitracker/sequences/pooling.py b/src/sampletones_core/formats/famitracker/sequences/pooling.py similarity index 82% rename from src/sampletones_core/famitracker/sequences/pooling.py rename to src/sampletones_core/formats/famitracker/sequences/pooling.py index fe4842ad..3a5823f2 100644 --- a/src/sampletones_core/famitracker/sequences/pooling.py +++ b/src/sampletones_core/formats/famitracker/sequences/pooling.py @@ -1,9 +1,9 @@ from typing import Dict, List, Sequence, Tuple -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.sequences.pooled import PooledSequence -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.sequences.pooled import PooledSequence +from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCES_PER_TYPE, SequenceKind, ) diff --git a/tests/unit/sampletones_core/famitracker/model/__init__.py b/src/sampletones_core/formats/famitracker/specification/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/model/__init__.py rename to src/sampletones_core/formats/famitracker/specification/__init__.py diff --git a/src/sampletones_core/famitracker/specification/blocks.py b/src/sampletones_core/formats/famitracker/specification/blocks.py similarity index 100% rename from src/sampletones_core/famitracker/specification/blocks.py rename to src/sampletones_core/formats/famitracker/specification/blocks.py diff --git a/src/sampletones_core/famitracker/specification/channels.py b/src/sampletones_core/formats/famitracker/specification/channels.py similarity index 100% rename from src/sampletones_core/famitracker/specification/channels.py rename to src/sampletones_core/formats/famitracker/specification/channels.py diff --git a/src/sampletones_core/famitracker/specification/file.py b/src/sampletones_core/formats/famitracker/specification/file.py similarity index 100% rename from src/sampletones_core/famitracker/specification/file.py rename to src/sampletones_core/formats/famitracker/specification/file.py diff --git a/src/sampletones_core/famitracker/specification/instruments.py b/src/sampletones_core/formats/famitracker/specification/instruments.py similarity index 76% rename from src/sampletones_core/famitracker/specification/instruments.py rename to src/sampletones_core/formats/famitracker/specification/instruments.py index b58be64b..353b535d 100644 --- a/src/sampletones_core/famitracker/specification/instruments.py +++ b/src/sampletones_core/formats/famitracker/specification/instruments.py @@ -1,6 +1,6 @@ from typing import Final -from sampletones_core.famitracker.specification.patterns import NOTE_RANGE, OCTAVE_RANGE +from sampletones_core.formats.famitracker.specification.patterns import NOTE_RANGE, OCTAVE_RANGE INSTRUMENT_TYPE_2A03: Final[int] = 1 MAX_INSTRUMENTS: Final[int] = 64 diff --git a/src/sampletones_core/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py similarity index 100% rename from src/sampletones_core/famitracker/specification/parameters.py rename to src/sampletones_core/formats/famitracker/specification/parameters.py diff --git a/src/sampletones_core/famitracker/specification/patterns.py b/src/sampletones_core/formats/famitracker/specification/patterns.py similarity index 100% rename from src/sampletones_core/famitracker/specification/patterns.py rename to src/sampletones_core/formats/famitracker/specification/patterns.py diff --git a/src/sampletones_core/famitracker/specification/sequences.py b/src/sampletones_core/formats/famitracker/specification/sequences.py similarity index 100% rename from src/sampletones_core/famitracker/specification/sequences.py rename to src/sampletones_core/formats/famitracker/specification/sequences.py diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index 13f621a0..73dd6cba 100644 --- a/src/sampletones_core/library/data.py +++ b/src/sampletones_core/library/data.py @@ -144,6 +144,7 @@ def validate_metadata(metadata: Metadata) -> None: ) library_version = metadata.library_data_version + print(compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION)) if compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION) != 0: raise IncompatibleLibraryDataVersionError( f"Library data version mismatch: expected " diff --git a/tests/unit/sampletones_core/famitracker/sequences/__init__.py b/src/sampletones_core/trackers/implementation/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/sequences/__init__.py rename to src/sampletones_core/trackers/implementation/__init__.py diff --git a/src/sampletones_core/trackers/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py similarity index 95% rename from src/sampletones_core/trackers/bitphase.py rename to src/sampletones_core/trackers/implementation/bitphase.py index c2b3e553..f53b24f4 100644 --- a/src/sampletones_core/trackers/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -1,13 +1,13 @@ from pathlib import Path from typing import FrozenSet, List -from sampletones_core.bitphase.btp import write_btp -from sampletones_core.bitphase.builder import ( +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import ( instrument_to_bitphase, project_to_bitphase, sample_to_bitphase, ) -from sampletones_core.bitphase.preset import instrument_to_preset, write_preset +from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat diff --git a/src/sampletones_core/trackers/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py similarity index 86% rename from src/sampletones_core/trackers/famitracker.py rename to src/sampletones_core/trackers/implementation/famitracker.py index 99c08cd9..6ace7836 100644 --- a/src/sampletones_core/trackers/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -2,12 +2,12 @@ from typing import FrozenSet, List, Optional from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.famitracker.export import write_ftm -from sampletones_core.famitracker.fti import write_fti -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py index aae211c7..c979a926 100644 --- a/src/sampletones_core/trackers/registry.py +++ b/src/sampletones_core/trackers/registry.py @@ -1,9 +1,9 @@ from typing import Dict from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.bitphase import BitphaseBackend, BitphasePresetBackend -from sampletones_core.trackers.famitracker import FamiTrackerBackend from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend def build_tracker_backends() -> Dict[TrackerFormat, TrackerBackend]: diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index 4622c804..c9a3b45b 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -4,9 +4,13 @@ from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.structures import IdentifiedCollection +from sampletones_shared.constants.symbols import MINUS, PLUS DEFAULT_DISPLAY_LENGTH: Final[int] = 2 + +BLANK: Final[str] = "." NOTE_OFF: Final[str] = "~~" +NOTE_BLANK: Final[str] = "..." def display_value( @@ -16,7 +20,7 @@ def display_value( hexadecimal: bool = True, ) -> str: if value is None: - return "." * length + return BLANK * length if hexadecimal: return f"{value:0{length}X}" @@ -67,8 +71,8 @@ def display_volume(value: Optional[int]) -> str: def display_transpose(value: Optional[int]) -> str: if value is None or value == 0: - return "..." + return NOTE_BLANK - sign = "+" if value > 0 else "-" + sign = PLUS if value > 0 else MINUS abs_value = abs(value) return f"{sign}{abs_value:02X}" diff --git a/src/sampletones_shared/deployment/version.py b/src/sampletones_shared/deployment/version.py index d4e10d59..cc420a03 100644 --- a/src/sampletones_shared/deployment/version.py +++ b/src/sampletones_shared/deployment/version.py @@ -1,56 +1,107 @@ -from typing import List +from typing import Dict, Iterable, Self, Tuple, TypeAlias, Union +from pydantic import BaseModel, Field, computed_field, model_validator -def _split_version(version: str) -> List[int]: +RawVersion: TypeAlias = Union[str, Iterable[int]] + + +class Version(BaseModel, frozen=True): + major: int = Field(ge=0) + minor: int = Field(ge=0) + patch: int = Field(ge=0) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def __repr__(self) -> str: + return str(self) + + def __le__(self, other: Self) -> bool: + return self.tuple <= other.tuple + + def __getitem__(self, key: int) -> int: + return self.tuple[key] + + @computed_field # type: ignore[prop-decorator] + @property + def tuple(self) -> Tuple[int, int, int]: + return self.major, self.minor, self.patch + + @model_validator(mode="before") + @classmethod + def parse_string(cls, value: RawVersion) -> Dict[str, int]: + if not isinstance(value, str) and not isinstance(value, tuple): + raise TypeError(f"Expected a tuple or a string, got {type(value)}") + + if isinstance(value, str): + parts = tuple(filter(bool, value.split("."))) + else: + parts = tuple(value) + + if not 1 <= len(parts) <= 3: + raise ValueError("Version must have 1-3 components") + + try: + numbers = list(map(int, parts)) + except ValueError as exception: + raise ValueError("Version components must be integers") from exception + + numbers.extend([0] * (3 - len(numbers))) + + return { + "major": numbers[0], + "minor": numbers[1], + "patch": numbers[2], + } + + +def _split_version(version: RawVersion) -> Version: """ Splits a dotted version string into its integer components. Args: - version (str): A dotted version string such as ``1.4.0``. + version (RawVersion): A dotted version string such as ``1.4.0``, + or a tuple of integers such as ``(1, 4, 0)``. Returns: - List[int]: The version's numeric components in order. + Version: The version object. Raises: - SystemError: If any component is not an integer. + SystemError: If input raw version object is not valid. """ try: - return list(map(int, version.split("."))) + return Version.model_validate(version) except ValueError as exception: raise SystemError(f"Invalid version format: {exception}") from exception -def compare_versions(version1: str, version2: str) -> int: +def compare_versions( + version1: Union[Version, RawVersion], + version2: Union[Version, RawVersion], +) -> int: """ Compares two dotted version strings numerically. Shorter versions are zero-padded, so ``1.2`` and ``1.2.0`` compare equal. Args: - version1 (str): First dotted version string (e.g. ``1.4.0``). - version2 (str): Second dotted version string. + version1 (RawVersion): First dotted version string or a version integer tuple. + version2 (RawVersion): Second dotted version string or a version integer tuple. Returns: int: ``-1`` if version1 precedes version2, ``1`` if it follows, ``0`` if they are equal. Raises: - SystemError: If either string holds a non-integer component. + SystemError: If versions raw objects are not valid. """ - v1_parts = _split_version(version1) - v2_parts = _split_version(version2) - - length_difference = len(v1_parts) - len(v2_parts) - if length_difference > 0: - v2_parts.extend([0] * length_difference) - - elif length_difference < 0: - v1_parts.extend([0] * -length_difference) + if not isinstance(version1, Version): + version1 = _split_version(version1) - for part1, part2 in zip(v1_parts, v2_parts): - if part1 < part2: - return -1 + if not isinstance(version2, Version): + version2 = _split_version(version2) - if part1 > part2: - return 1 + if version1 == version2: + return 0 - return 0 + difference = int(version1 >= version2) + return 2 * difference - 1 diff --git a/tests/integration/famitracker/test_ftm_pipeline.py b/tests/integration/famitracker/test_ftm_pipeline.py index d1e261b4..c9a8e90a 100644 --- a/tests/integration/famitracker/test_ftm_pipeline.py +++ b/tests/integration/famitracker/test_ftm_pipeline.py @@ -4,10 +4,10 @@ from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_core.constants.enums import GeneratorName -from sampletones_core.famitracker.export import write_ftm -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import FTM_VERSION -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import FTM_VERSION +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from sampletones_core.project.project import Project from tests.suite.famitracker import ParsedModule, parse_ftm diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 46bae0a1..6ced762c 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -10,7 +10,7 @@ from sampletones_core.audio import read_wave from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features -from sampletones_core.trackers.famitracker import FamiTrackerBackend +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport NES_FREQUENCY: Final[int] = 60 diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py index 9f19b3b0..66de2bc7 100644 --- a/tests/suite/famitracker.py +++ b/tests/suite/famitracker.py @@ -2,13 +2,13 @@ from dataclasses import dataclass from typing import Dict, List, Tuple -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH -from sampletones_core.famitracker.specification.file import FTM_END_MARKER, FTM_MAGIC -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH +from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_MAGIC +from sampletones_core.formats.famitracker.specification.instruments import ( DPCM_KEY_ASSIGNMENTS, DPCM_KEY_BYTES, ) -from sampletones_core.famitracker.specification.sequences import SEQUENCE_COUNT_2A03 +from sampletones_core.formats.famitracker.specification.sequences import SEQUENCE_COUNT_2A03 class _Cursor: @@ -156,6 +156,7 @@ def _read_blocks(cursor: _Cursor) -> Tuple[Dict[str, bytes], Dict[str, int]]: size = cursor.read_int32() payloads[name] = cursor.read(size) versions[name] = version + return payloads, versions @@ -191,6 +192,7 @@ def _parse_header(payload: bytes, channel_count: int) -> ParsedHeader: channel_id = cursor.read_uint8() effect_columns = cursor.read_uint8() + 1 channels.append(ParsedChannelHeader(channel_id=channel_id, effect_columns=effect_columns)) + return ParsedHeader(track_count=track_count, track_titles=track_titles, channels=channels) @@ -212,6 +214,7 @@ def _parse_instruments(payload: bytes) -> List[ParsedInstrument]: instruments.append( ParsedInstrument(index=index, instrument_type=instrument_type, sequence_refs=refs, name=name) ) + return instruments @@ -238,6 +241,7 @@ def _parse_sequences(payload: bytes) -> List[ParsedSequence]: for sequence in sequences: sequence.release_point = cursor.read_int32() sequence.setting = cursor.read_int32() + return sequences diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index f3f6e16b..d9f0ee02 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -15,6 +15,7 @@ ServiceSuccess, ) from sampletones_core.parallelization import TaskProgress, TaskStatus +from sampletones_shared.types.data import SerializedData @pytest.fixture diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index f6e2a35f..59ab0596 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -27,7 +27,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette import Palette from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.trackers.format import TrackerFormat diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 7c4cbedf..a1fb2061 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -2,7 +2,6 @@ from sampletones_core.exporters.implementation.noise import NoiseExporter from sampletones_core.exporters.implementation.pulse import PulseExporter from sampletones_core.exporters.implementation.triangle import TriangleExporter -from sampletones_core.famitracker.specification.sequences import FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind from sampletones_core.features import ( FEATURE_DIMENSION_ORDER, GENERATOR_KIND, @@ -10,6 +9,7 @@ supported_features, supports, ) +from sampletones_core.formats.famitracker.specification.sequences import FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind def test_supported_features_follow_dimension_order() -> None: diff --git a/tests/unit/sampletones_core/formats/famitracker/__init__.py b/tests/unit/sampletones_core/formats/famitracker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/conftest.py rename to tests/unit/sampletones_core/formats/famitracker/conftest.py diff --git a/tests/unit/sampletones_core/formats/famitracker/model/__init__.py b/tests/unit/sampletones_core/formats/famitracker/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/model/test_sequence.py b/tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py similarity index 77% rename from tests/unit/sampletones_core/famitracker/model/test_sequence.py rename to tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py index eb81b7a6..967c9bb1 100644 --- a/tests/unit/sampletones_core/famitracker/model/test_sequence.py +++ b/tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py @@ -1,7 +1,7 @@ import pytest -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, SequenceKind, ) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/__init__.py b/tests/unit/sampletones_core/formats/famitracker/sequences/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py similarity index 97% rename from tests/unit/sampletones_core/famitracker/sequences/test_features.py rename to tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 25dd81d4..46d6dabf 100644 --- a/tests/unit/sampletones_core/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,8 +1,8 @@ import numpy as np import pytest -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, diff --git a/tests/unit/sampletones_core/famitracker/test_binary.py b/tests/unit/sampletones_core/formats/famitracker/test_binary.py similarity index 95% rename from tests/unit/sampletones_core/famitracker/test_binary.py rename to tests/unit/sampletones_core/formats/famitracker/test_binary.py index e672c3ab..bc1c8f21 100644 --- a/tests/unit/sampletones_core/famitracker/test_binary.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_binary.py @@ -4,8 +4,8 @@ import pytest -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block @dataclass diff --git a/tests/unit/sampletones_core/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py similarity index 94% rename from tests/unit/sampletones_core/famitracker/test_builder.py rename to tests/unit/sampletones_core/formats/famitracker/test_builder.py index 7201f22d..cc95bf02 100644 --- a/tests/unit/sampletones_core/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -2,12 +2,12 @@ import pytest from sampletones_core.constants.enums import GeneratorName -from sampletones_core.famitracker.builder import build_instrument_table, project_to_module -from sampletones_core.famitracker.specification.channels import CHANNEL_COUNT_2A03, ChannelId -from sampletones_core.famitracker.specification.instruments import MAX_INSTRUMENTS -from sampletones_core.famitracker.specification.parameters import EXPANSION_NONE, Machine -from sampletones_core.famitracker.specification.patterns import EMPTY_INSTRUMENT, NoteValue -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module +from sampletones_core.formats.famitracker.specification.channels import CHANNEL_COUNT_2A03, ChannelId +from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS +from sampletones_core.formats.famitracker.specification.parameters import EXPANSION_NONE, Machine +from sampletones_core.formats.famitracker.specification.patterns import EMPTY_INSTRUMENT, NoteValue +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, NO_LOOP_POINT, SequenceKind, diff --git a/tests/unit/sampletones_core/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py similarity index 96% rename from tests/unit/sampletones_core/famitracker/test_fti.py rename to tests/unit/sampletones_core/formats/famitracker/test_fti.py index 21d5dae9..dd59b6b0 100644 --- a/tests/unit/sampletones_core/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -5,9 +5,9 @@ import numpy as np -from sampletones_core.famitracker.fti import write_fti -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences GOLDEN_INSTRUMENT_NAME = "Test Instrument" GOLDEN_VOLUME = np.array([15, 12, 8, 0]) diff --git a/tests/unit/sampletones_core/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py similarity index 92% rename from tests/unit/sampletones_core/famitracker/test_ftm.py rename to tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 15b8c2d4..278621ce 100644 --- a/tests/unit/sampletones_core/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -1,9 +1,9 @@ from pathlib import Path -from sampletones_core.famitracker.builder import project_to_module -from sampletones_core.famitracker.export import write_ftm -from sampletones_core.famitracker.ftm import module_to_ftm_bytes -from sampletones_core.famitracker.specification.blocks import ( +from sampletones_core.formats.famitracker.builder import project_to_module +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.formats.famitracker.specification.blocks import ( BLOCK_COMMENTS, BLOCK_DPCM_SAMPLES, BLOCK_FRAMES, @@ -14,19 +14,19 @@ BLOCK_PATTERNS, BLOCK_SEQUENCES, ) -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import FTM_END_MARKER, FTM_VERSION -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_VERSION +from sampletones_core.formats.famitracker.specification.parameters import ( DEFAULT_SPEED_SPLIT_POINT, EXPANSION_NONE, Machine, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( EMPTY_INSTRUMENT, EMPTY_VOLUME, NoteValue, ) -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from tests.suite.famitracker import ParsedModule, ParsedSequence, parse_ftm from .conftest import ProjectFixture diff --git a/tests/unit/sampletones_core/famitracker/test_notes.py b/tests/unit/sampletones_core/formats/famitracker/test_notes.py similarity index 94% rename from tests/unit/sampletones_core/famitracker/test_notes.py rename to tests/unit/sampletones_core/formats/famitracker/test_notes.py index 962ee4c8..8abfb321 100644 --- a/tests/unit/sampletones_core/famitracker/test_notes.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_notes.py @@ -3,12 +3,12 @@ import pytest -from sampletones_core.famitracker.notes import ( +from sampletones_core.formats.famitracker.notes import ( period_to_note_cell, pitch_to_note_cell, resolve_machine, ) -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.parameters import ( ENGINE_SPEED_MACHINE_DEFAULT, Machine, ) diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 79ede84e..51d638d2 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -7,10 +7,10 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE -from sampletones_core.trackers.famitracker import FamiTrackerBackend from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import DestinationKind, ExportScope diff --git a/uv.lock b/uv.lock index 5a68ed80..c497c08f 100644 --- a/uv.lock +++ b/uv.lock @@ -1708,7 +1708,7 @@ wheels = [ [[package]] name = "sampletones" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "anytree" }, From 05c1006d6806cab1cac9b321a6724f3c1ce2a3ca Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 13:38:48 +0200 Subject: [PATCH 08/20] Added: remaining tests and docs --- docs/development/bugs-and-todos.md | 1 + docs/formats/bitphase.md | 208 +++++++++++++++ docs/formats/famitracker.md | 8 +- docs/glossary.md | 20 ++ docs/index.md | 2 + tests/integration/bitphase/__init__.py | 0 tests/integration/bitphase/conftest.py | 19 ++ .../integration/bitphase/test_btp_pipeline.py | 223 ++++++++++++++++ tests/integration/famitracker/conftest.py | 31 +-- tests/integration/output.py | 49 ++++ tests/integration/paths.py | 3 + tests/suite/bitphase.py | 233 +++++++++++++++++ .../formats/bitphase/__init__.py | 0 .../formats/bitphase/conftest.py | 49 ++++ .../formats/bitphase/test_btp.py | 170 ++++++++++++ .../formats/bitphase/test_builder.py | 225 ++++++++++++++++ .../formats/bitphase/test_envelopes.py | 182 +++++++++++++ .../formats/bitphase/test_identifiers.py | 44 ++++ .../formats/bitphase/test_notes.py | 122 +++++++++ .../formats/bitphase/test_preset.py | 113 ++++++++ .../formats/bitphase/test_project_builder.py | 221 ++++++++++++++++ .../formats/bitphase/test_tuning.py | 106 ++++++++ .../trackers/test_bitphase.py | 246 ++++++++++++++++++ 23 files changed, 2246 insertions(+), 29 deletions(-) create mode 100644 docs/formats/bitphase.md create mode 100644 tests/integration/bitphase/__init__.py create mode 100644 tests/integration/bitphase/conftest.py create mode 100644 tests/integration/bitphase/test_btp_pipeline.py create mode 100644 tests/integration/output.py create mode 100644 tests/suite/bitphase.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/__init__.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/conftest.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_btp.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_builder.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_envelopes.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_identifiers.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_notes.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_preset.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_project_builder.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_tuning.py create mode 100644 tests/unit/sampletones_core/trackers/test_bitphase.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 5b52bd32..36b9a509 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -33,6 +33,7 @@ * Code documentation (docstrings) * Backward compatibility: library/reconstruction upgrade scheme * Respecting FamiTracker limitations +* Carrying the project comment and tempo into a Bitphase document, once the format holds them * Per-tab undo routing ## Bugs diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md new file mode 100644 index 00000000..5778910f --- /dev/null +++ b/docs/formats/bitphase.md @@ -0,0 +1,208 @@ +# Bitphase export format + +This document is the reference for how _SampleToNES_ writes +[Bitphase](https://github.com/paator/bitphase) files. It describes the two files the +`sampletones_core.formats.bitphase` package produces — the `.btp` document and the +`.json` instrument preset — and the Bitphase capacity limits the exporter respects. +Read it before changing anything under `formats/bitphase/`; the sibling +[FamiTracker export](famitracker.md) document covers the other tracker. + +The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle, +noise, DPCM), with the DPCM channel always silent by design. Every constant referenced +here has a named counterpart under `sampletones_core/formats/bitphase/specification/` +(grouped by unit: `chip`, `channels`, `instruments`, `patterns`). + +Bitphase plays a note by three columns acting together, and that shapes the whole +mapping: an **instrument** supplies the per-tick register values, a **table** supplies +the per-tick pitch movement, and the **note column** supplies the pitch they move +around. A reconstruction's volume and duty envelopes become the instrument, its +arpeggio envelope becomes the table, and its reference pitch becomes the note. + +## A. File formats + +### A.1 `.btp` — the document + +A `.btp` is the document's JSON under gzip — no header and no version field. The +exporter writes it without separator padding and with a fixed gzip timestamp, so +exporting an unchanged document twice yields identical bytes. Written by +`formats/bitphase/btp.py`. + +Bitphase's loader reads each field on its own and falls back to a default for any it +misses, so a document that carries every field below loads exactly as it was written. + +``` +Project { name, author, songs[], loopPointId, patternOrder[], tables[], + patternOrderColors{}, instruments[] } +Song { patterns[], tuningTable[], initialSpeed, chipType, chipVariant, + chipFrequency, interruptFrequency, a4TuningHz, virtualChannelMap{} } +Pattern { id, length, channels[], patternRows[] } +Channel { rows[], label } +Row { note: { name, octave }, effects[], instrument, table, volume } +Table { id, rows[], loop, name } +Instrument { id, chipType, rows[], loop, name } +``` + +Instruments and tables belong to the **project** rather than to a song, so every song +addresses the same lists. `patternOrder` names the pattern each order position plays, +and `loopPointId` is the order position playback returns to. + +**Field names are camelCase.** The Pydantic models under `formats/bitphase/model/` +carry snake_case attributes and serialize through a camelCase alias generator, so the +Python side reads like the rest of the codebase while the file reads like Bitphase's. + +### A.2 `.json` — the instrument preset + +Bitphase's instruments panel saves and loads a single instrument at runtime through a +file picker. The file holds `{ chipType, name, loop, rows }`, indented the way Bitphase +writes its own, so a preset written here reads like one saved from the tracker. Written +by `formats/bitphase/preset.py`. + +A preset carries rows alone, so its pitch movement rides in each row's `toneAdd` +(section C.3) rather than in a table. + +## B. The NES instrument + +An instrument advances **one row per engine tick** while a note sounds, so a row +carries every register value the channel takes for that tick. From +`formats/bitphase/model/instrument.py`, matching Bitphase's `NesInstrumentRow`: + +| Field | Range | Runtime meaning | What the exporter writes | +| --- | --- | --- | --- | +| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | +| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item | +| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | +| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | +| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | +| `toneAccumulation` | bool | sums `toneAdd` across ticks | `false`, since each item is an absolute offset | +| `retrigger` | bool | restarts the waveform phase this tick | `false`, so the waveform runs continuously | +| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | + +**Looping.** Playback returns to the instrument's `loop` row once it runs off the end, +which is the only mode there is. A looping slice therefore sets `loop = 0` so its +envelopes repeat from the start while the note is held; a one-shot sets +`loop = len - 1`, and since the volume envelope ends on a note-off item, the +instrument rests in silence once it has played through. A sample's `loop` flag drives +this, the same flag the FamiTracker exporter reads. + +**Equal lengths.** Instrument rows and table rows advance on independent per-tick +counters, so they share a length and a loop point and stay in step for as long as the +note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length — +the same rule the FamiTracker exporter applies, with the item limit left unbounded +here (section D). + +## C. Pitch + +### C.1 The tuning table + +A song carries a 96-entry `tuningTable`, one channel period per note index, built by +`formats/bitphase/tuning.py` as a port of Bitphase's `generate12TETTuningTable`: + +``` +frequency = a4TuningHz * 2 ^ ((index - 45) / 12) +period = round(chipFrequency / 16 / frequency) clamped to 1..2047 +``` + +Rounding matches JavaScript's `Math.round` (half away from zero on positives), so a +table built here equals the one Bitphase derives from the same settings. The exporter +writes NTSC (1 789 773 Hz) at concert pitch; PAL (1 662 607 Hz) and Dendy +(1 773 448 Hz) are named in `specification/chip.py`. + +**A note index is the absolute pitch less 24**, which puts indices 0–95 over pitches +24–119 — the same span the FamiTracker exporter clamps to. A pattern cell stores that +index as a semitone and an octave, which playback resolves back with +`name - 2 + (octave - 1) * 12`. + +The triangle channel's period is written from the same table, so a written note sounds +an octave below — the convention SampleToNES and FamiTracker already share. + +### C.2 Tables carry the contour + +A table holds one semitone offset per tick, and playback adds `rows[position]` to the +channel's note every tick. That is a direct match for a reconstruction's arpeggio +envelope in absolute mode, so the contour crosses over verbatim on the pitched +channels. + +A pattern's `table` column names a table by `id + 1`; `0` leaves the attached table +alone and `-1` detaches it. + +**Noise** derives its period from the note index rather than from the tuning table: +playback reads `period = 15 - (index mod 16)`. Every period therefore repeats once per +sixteen indices, and the exporter picks a base index far enough below the top of the +table for a whole cycle of offsets to stay in range: + +``` +base index = 48 + ((15 - initial_period) mod 16) lands in 48..63 +table offset = (-arpeggio_step) mod 16 lands in 0..15 +``` + +so `15 - ((base + offset) mod 16)` is the period the reconstruction chose, wrapped into +the sixteen the channel holds. + +### C.3 Presets fold the contour into the period + +An instrument preset carries no table, so its pitch movement is expressed as the +per-tick `toneAdd` each row applies to the note's own period. The offsets are measured +against the pitch the slice was reconstructed at, under the tuning a freshly created +Bitphase document plays — NTSC at concert pitch. The noise channel takes its period +from the note, so its preset rows hold a flat offset. + +## D. What the exporter builds per scope + +A `.btp` holds a whole document, so every scope lands in one file; a preset holds one +instrument, so a reconstruction fills a directory of them. + +| Scope | `.btp` | `.json` preset | +| --- | --- | --- | +| One generator slice | a playable document holding that instrument | one file | +| A whole reconstruction | a playable document holding every slice | a directory, one file per slice | +| A project | the song, its samples and its arrangement | — | + +**Instrument and reconstruction documents are playable.** Each slice becomes an +instrument and the table that carries its contour, and one pattern triggers every slice +at row 0 on the channel it was reconstructed for, so opening the document and pressing +play sounds the reconstruction. The pattern is sized to cover the longest instrument, +and where one instrument outlasts a single pattern the order gains resting positions +until it has played through. + +**A project flattens its order.** A SampleToNES order frame points each channel at its +own pattern, where a Bitphase order position names one pattern spanning every channel. +Each frame therefore becomes a pattern of its own carrying that frame's channels side +by side, with `patternOrder = [0..n-1]`. The arrangement crosses over whole; it simply +shares fewer patterns. + +Row cells follow from the columns: an instrument command writes the note from +`initial_pitch + transpose`, the instrument number, the table column and the row's +volume; a note-off writes note name `1`; a blank line leaves every column alone. + +## E. Bitphase capacity limits + +| Quantity | Bitphase limit | Exporter behaviour | +| --- | --- | --- | +| Items per instrument row list | unbounded | writes the envelope whole | +| Rows per table | unbounded | writes the contour whole | +| Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | +| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables | +| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | +| Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | +| Order positions | unbounded | matches | +| Speed | 1–255 | written verbatim from settings | +| DPCM channel | present | emitted empty | + +Tables and instruments are numbered together — each slice takes one of each — so the +table column is what a wide document reaches first: 35 slices fit, and the exporter +raises rather than writing a document whose later voices cannot be named. + +## F. What does not cross over + +Three things the SampleToNES model holds have no counterpart in a Bitphase document, +and the exporter leaves them behind: + +- **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only. +- **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed` + carries `speed` and the tempo is left to the tick rate. +- **A volume column of `0`** — Bitphase reads it as "leave the volume alone", so a row + that asks for silence through the volume column alone reaches playback unchanged. + +`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings +panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate +outside that pair plays correctly while leaving that one selector unmatched. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 13bad99a..ab90b250 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -1,7 +1,7 @@ # FamiTracker export format This document is the reference for how _SampleToNES_ writes FamiTracker files. It -describes the two binary formats the `sampletones_core.famitracker` package +describes the two binary formats the `sampletones_core.formats.famitracker` package produces — the `.fti` instrument file and the `.ftm` module file — and lists the FamiTracker capacity limits that the project domain model will grow to respect. @@ -12,7 +12,7 @@ noise, DPCM), with the DPCM channel and DPCM sample bank always empty by design. All multi-byte integers are **little-endian**. Field types below use `uint8`, `int8`, `uint32`, `int32`; strings are noted per field. Every constant referenced -here has a named counterpart under `sampletones_core/famitracker/specification/` +here has a named counterpart under `sampletones_core/formats/famitracker/specification/` (grouped by unit: `file`, `blocks`, `channels`, `sequences`, `instruments`, `patterns`, `parameters`), and every block has its own writer function so this specification is readable straight from the code. @@ -22,7 +22,7 @@ specification is readable straight from the code. ### A.1 `.fti` — instrument file An `.fti` holds a single 2A03 instrument: its five sequences inline, then an empty -DPCM section. Written by `sampletones_core/famitracker/fti.py`. +DPCM section. Written by `sampletones_core/formats/famitracker/instrument.py`. | Field | Type | Value | | --- | --- | --- | @@ -50,7 +50,7 @@ Each **sequence record**: ### A.2 `.ftm` — module file An `.ftm` is a file header followed by a sequence of named, versioned blocks and a -final `END` marker. Written by `sampletones_core/famitracker/ftm.py`, one function +final `END` marker. Written by `sampletones_core/formats/famitracker/module.py`, one function per block. **File header** diff --git a/docs/glossary.md b/docs/glossary.md index 307923de..d8dddcfa 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -161,6 +161,12 @@ A [_tracker application_](http://famitracker.com/) for composing music for the NES 2A03. _SampleToNES_ exports instruments and modules that it (and its forks) can load. +### Bitphase + +A [_web tracker_](https://github.com/paator/bitphase) whose chips include the NES +2A03. _SampleToNES_ exports documents and instrument presets it can load. See +[Bitphase export](formats/bitphase.md). + ### Tracker / sequencer A pattern-based music editor. _SampleToNES_'s built-in sequencer arranges @@ -185,6 +191,16 @@ The list that arranges patterns into the song's timeline. A complete FamiTracker song, saved as an `.ftm` file — its settings, instruments, patterns, and order together. +### Document + +A complete Bitphase project, saved as a `.btp` file — its songs, instruments, +tables, patterns, and order together. + +### Table + +In Bitphase, a per-tick list of semitone offsets a pattern cell attaches to a +channel, which carries the pitch contour a FamiTracker arpeggio sequence would. + ### Sample (sequencer) A reconstruction added to the sequencer as a playable, placeable voice in the @@ -194,6 +210,8 @@ song. A single FamiTracker instrument, saved as an `.fti` file, exported from one channel of a reconstruction. See [FamiTracker export](formats/famitracker.md). +Bitphase takes the same slice as a `.json` instrument preset. See +[Bitphase export](formats/bitphase.md). ## File types @@ -204,3 +222,5 @@ channel of a reconstruction. See [FamiTracker export](formats/famitracker.md). | `.stp` | [Project](formats/projects.md) — a bundle of reconstructions with a song and settings. | | `.fti` | FamiTracker instrument ([export](formats/famitracker.md)). | | `.ftm` | FamiTracker module ([export](formats/famitracker.md)). | +| `.btp` | Bitphase document ([export](formats/bitphase.md)). | +| `.json` | Bitphase instrument preset ([export](formats/bitphase.md)), or the [configuration file](formats/configuration.md). | diff --git a/docs/index.md b/docs/index.md index 5751f256..e2bbaf58 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,7 @@ The [**formats**](formats/) section documents the files _SampleToNES_ reads and - [Reconstructions](formats/reconstructions.md) — the `.stn` reconstruction data. - [Projects](formats/projects.md) — the `.stp` project bundle. - [FamiTracker export](formats/famitracker.md) — the `.fti` instrument and `.ftm` module formats. +- [Bitphase export](formats/bitphase.md) — the `.btp` document and `.json` instrument preset formats. - [Configuration file](formats/configuration.md) — the `config.json` structure. ## Programming with SampleToNES @@ -59,6 +60,7 @@ The [**development**](development/) section is for contributors. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. - [Bugs and to-dos](development/bugs-and-todos.md) — the working ledger of known gaps. +- [Bitphase integration status](development/bitphase-integration-status.md) — what the Bitphase export covers and what is left to verify. ## Glossary diff --git a/tests/integration/bitphase/__init__.py b/tests/integration/bitphase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py new file mode 100644 index 00000000..b55d74ac --- /dev/null +++ b/tests/integration/bitphase/conftest.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Optional + +import pytest + +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import BTP_OUTPUT_ENV, DOCUMENT_FILENAME + + +@pytest.fixture(scope="session") +def btp_output_dir() -> Optional[Path]: + """The persistent output directory ``SAMPLETONES_BTP_OUTPUT_DIR`` names.""" + return resolve_output_directory(BTP_OUTPUT_ENV) + + +@pytest.fixture +def document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: + """Where a produced ``.btp`` is written.""" + return resolve_output_path(btp_output_dir, tmp_path, DOCUMENT_FILENAME) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py new file mode 100644 index 00000000..9829d27c --- /dev/null +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -0,0 +1,223 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, CHANNEL_LABELS, ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import ( + CHIP_TYPE_NES, + CPU_FREQUENCIES, + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_TABLE_LENGTH, + ChipVariant, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_PULSE_WIDTH, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_VOLUME_OR_RATE, + SUSTAINED_SOUND_LENGTH, +) +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + FULL_VOLUME, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NO_INSTRUMENT_CHANGE, + NOTE_RANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.project.project import Project +from tests.suite.bitphase import LoadedNote, LoadedProject, LoadedRow, parse_btp + +EXPECTED_INSTRUMENT_COUNT: Final[int] = 5 +PLAYED_CHANNELS: Final[List[int]] = [ + int(ChannelIndex.SQUARE1), + int(ChannelIndex.SQUARE2), + int(ChannelIndex.TRIANGLE), + int(ChannelIndex.NOISE), +] + + +def every_row(document: LoadedProject) -> List[LoadedRow]: + """Every tracker line the document holds, across its patterns and their channels.""" + return [row for pattern in document.songs[0].patterns for channel in pattern.channels for row in channel.rows] + + +def note_index(note: LoadedNote) -> int: + """The tuning-table index Bitphase's pattern processor reads back from a note cell.""" + return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + + +@pytest.fixture +def document(integration_project: Project, document_path: Path) -> LoadedProject: + write_btp(document_path, project_to_bitphase(integration_project)) + return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) + + +class TestBtpPipeline: + """End-to-end: synthesized + reconstructed samples -> Project -> `.btp` -> load.""" + + def test_writes_a_loadable_document(self, integration_project: Project, document_path: Path) -> None: + write_btp(document_path, project_to_bitphase(integration_project)) + assert document_path.exists() + assert parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)).songs + + def test_the_document_carries_the_project_metadata( + self, + document: LoadedProject, + integration_project: Project, + ) -> None: + assert document.name == integration_project.info.title + assert document.author == integration_project.info.author + + def test_instrument_count_covers_every_slice(self, document: LoadedProject) -> None: + assert len(document.instruments) == EXPECTED_INSTRUMENT_COUNT + + def test_every_instrument_carries_a_table(self, document: LoadedProject) -> None: + assert len(document.tables) == len(document.instruments) + + def test_the_order_covers_the_song(self, document: LoadedProject, integration_project: Project) -> None: + assert document.pattern_order == list(range(len(integration_project.song.order))) + + def test_the_order_names_patterns_the_song_holds(self, document: LoadedProject) -> None: + held = {pattern.id for pattern in document.songs[0].patterns} + assert set(document.pattern_order) <= held + + def test_patterns_cover_the_played_channels(self, document: LoadedProject) -> None: + triggered = { + index + for pattern in document.songs[0].patterns + for index, channel in enumerate(pattern.channels) + if any(row.instrument != NO_INSTRUMENT_CHANGE for row in channel.rows) + } + assert triggered == set(PLAYED_CHANNELS) + + def test_the_document_carries_audible_volume(self, document: LoadedProject) -> None: + assert any(row.volume_or_rate > 0 for instrument in document.instruments for row in instrument.rows) + + +class TestTheLoaderReadsWhatWasWritten: + """Bitphase reconstructs a project field by field, falling back to a default for each + one it misses, so a field left out of the document reaches playback as that default. + Reading the file back through the same fallbacks is the contract with the tracker. + """ + + def test_the_song_names_the_chip_it_drives(self, document: LoadedProject) -> None: + assert document.songs[0].chip_type == CHIP_TYPE_NES + + def test_every_instrument_names_the_chip_whose_rows_it_holds(self, document: LoadedProject) -> None: + assert {instrument.chip_type for instrument in document.instruments} == {CHIP_TYPE_NES} + + def test_the_song_carries_the_clock_its_tuning_was_built_from(self, document: LoadedProject) -> None: + song = document.songs[0] + assert song.chip_variant == ChipVariant.NTSC + assert song.chip_frequency == CPU_FREQUENCIES[ChipVariant.NTSC] + + def test_the_song_carries_the_speed_and_tick_rate( + self, + document: LoadedProject, + integration_project: Project, + ) -> None: + song = document.songs[0] + assert song.initial_speed == integration_project.settings.speed + assert song.interrupt_frequency == integration_project.settings.nes_frequency + + def test_the_tuning_table_covers_every_note_index(self, document: LoadedProject) -> None: + table = document.songs[0].tuning_table + assert len(table) == TUNING_TABLE_LENGTH + assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in table) + + def test_every_pattern_spans_the_chip_channels(self, document: LoadedProject) -> None: + assert all(len(pattern.channels) == CHANNEL_COUNT for pattern in document.songs[0].patterns) + + def test_every_channel_fills_its_pattern(self, document: LoadedProject) -> None: + assert all( + len(channel.rows) == pattern.length + for pattern in document.songs[0].patterns + for channel in pattern.channels + ) + + def test_each_channel_is_labelled_as_its_position_names_it(self, document: LoadedProject) -> None: + pattern = document.songs[0].patterns[0] + assert [channel.label for channel in pattern.channels] == list(CHANNEL_LABELS) + + +class TestTheTriggersReachTheirVoices: + """A trigger reaches playback through three columns at once — the instrument that + shapes the note, the table that moves it, and the note itself — so a document whose + columns disagree plays a different voice than the project arranged. + """ + + @pytest.fixture(name="triggers") + def triggers_fixture(self, document: LoadedProject) -> List[LoadedRow]: + return [row for row in every_row(document) if row.instrument != NO_INSTRUMENT_CHANGE] + + def test_the_song_triggers_its_instruments(self, triggers: List[LoadedRow]) -> None: + assert triggers + + def test_every_trigger_names_an_instrument_the_document_holds( + self, + document: LoadedProject, + triggers: List[LoadedRow], + ) -> None: + numbers = {instrument.number for instrument in document.instruments} + assert {row.instrument for row in triggers} <= numbers + + def test_every_trigger_attaches_a_table_the_document_holds( + self, + document: LoadedProject, + triggers: List[LoadedRow], + ) -> None: + columns = {table.id + TABLE_COLUMN_OFFSET for table in document.tables} + assert {row.table for row in triggers} <= columns + + def test_every_trigger_names_a_pitched_note(self, triggers: List[LoadedRow]) -> None: + assert all(int(NoteName.C) <= row.note.name <= int(NoteName.B) for row in triggers) + + def test_every_note_lands_inside_the_tuning_table(self, triggers: List[LoadedRow]) -> None: + indices = [note_index(row.note) for row in triggers] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) + + def test_every_volume_column_stays_within_the_channel_range(self, document: LoadedProject) -> None: + assert all(0 <= row.volume <= FULL_VOLUME for row in every_row(document)) + + +class TestTheInstrumentRowsArePlayable: + def test_every_row_holds_a_waveform_the_channel_reads(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(MIN_PULSE_WIDTH <= row.pulse_width <= MAX_PULSE_WIDTH for row in rows) + + def test_every_row_holds_a_level_the_channel_reads(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(MIN_VOLUME_OR_RATE <= row.volume_or_rate <= MAX_VOLUME_OR_RATE for row in rows) + + def test_every_row_reads_its_level_as_a_literal_volume(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(row.envelope is False for row in rows) + + def test_every_row_holds_the_note_for_as_long_as_the_envelope_runs(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(row.sound_length == SUSTAINED_SOUND_LENGTH for row in rows) + + def test_every_instrument_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: + """Playback returns to the loop row once it runs off the end, so a loop point + past the last row would leave the instrument nowhere to resume from. + """ + assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) + + def test_every_table_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: + assert all(table.loop < len(table.rows) for table in document.tables) + + def test_each_instrument_runs_as_long_as_its_table(self, document: LoadedProject) -> None: + """The rows and the table advance on their own per-tick counters, so a length + they share is what keeps the volume envelope aligned with the pitch contour. + """ + lengths = [ + (len(instrument.rows), len(table.rows)) for instrument, table in zip(document.instruments, document.tables) + ] + assert all(rows == table_rows for rows, table_rows in lengths) diff --git a/tests/integration/famitracker/conftest.py b/tests/integration/famitracker/conftest.py index 7089eb12..b10b93fe 100644 --- a/tests/integration/famitracker/conftest.py +++ b/tests/integration/famitracker/conftest.py @@ -1,38 +1,19 @@ -import os -import shutil from pathlib import Path from typing import Optional import pytest -from tests.integration.paths import FTM_OUTPUT_ENV, MODULE_FILENAME, REPO_ROOT +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import FTM_OUTPUT_ENV, MODULE_FILENAME @pytest.fixture(scope="session") def ftm_output_dir() -> Optional[Path]: - """The persistent output directory, or None when emission is not requested. - - Emission is opt-in via the ``SAMPLETONES_FTM_OUTPUT_DIR`` environment variable so - ordinary (and parallel) runs write only to ``tmp_path``. When set, the directory - is cleaned once per session so each run leaves a fresh set of files. - """ - configured = os.environ.get(FTM_OUTPUT_ENV) - if not configured: - return None - - directory = Path(configured) - if not directory.is_absolute(): - directory = REPO_ROOT / directory - - if directory.exists(): - shutil.rmtree(directory) - - directory.mkdir(parents=True, exist_ok=True) - return directory + """The persistent output directory ``SAMPLETONES_FTM_OUTPUT_DIR`` names.""" + return resolve_output_directory(FTM_OUTPUT_ENV) @pytest.fixture def module_path(ftm_output_dir: Optional[Path], tmp_path: Path) -> Path: - """Where a produced ``.ftm`` is written: the persistent dir if opted in, else tmp.""" - base = ftm_output_dir if ftm_output_dir is not None else tmp_path - return base / MODULE_FILENAME + """Where a produced ``.ftm`` is written.""" + return resolve_output_path(ftm_output_dir, tmp_path, MODULE_FILENAME) diff --git a/tests/integration/output.py b/tests/integration/output.py new file mode 100644 index 00000000..423b89d3 --- /dev/null +++ b/tests/integration/output.py @@ -0,0 +1,49 @@ +import os +import shutil +from pathlib import Path +from typing import Optional + +from tests.integration.paths import REPO_ROOT + + +def resolve_output_directory(variable: str) -> Optional[Path]: + """Reads the persistent output directory an environment variable names. + + Emission is opt-in so an ordinary (and parallel) run writes only to ``tmp_path``. + A named directory is cleaned once per session, so each run leaves a fresh set of + files there. + + Args: + variable: Environment variable naming the directory. + + Returns: + Optional[Path]: The prepared directory, or ``None`` while emission is unasked for. + """ + configured = os.environ.get(variable) + if not configured: + return None + + directory = Path(configured) + if not directory.is_absolute(): + directory = REPO_ROOT / directory + + if directory.exists(): + shutil.rmtree(directory) + + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def resolve_output_path(output_directory: Optional[Path], tmp_path: Path, filename: str) -> Path: + """Locates a produced file: the persistent directory where one is named, else ``tmp_path``. + + Args: + output_directory: The persistent directory, or ``None`` while emission is unasked for. + tmp_path: The test's own temporary directory. + filename: Name the produced file carries. + + Returns: + Path: Where the file is written. + """ + base = output_directory if output_directory is not None else tmp_path + return base / filename diff --git a/tests/integration/paths.py b/tests/integration/paths.py index 8048706e..2c5d9c57 100644 --- a/tests/integration/paths.py +++ b/tests/integration/paths.py @@ -21,3 +21,6 @@ def _repo_root() -> Path: MODULE_FILENAME: Final[str] = "drums.ftm" FTM_OUTPUT_ENV: Final[str] = "SAMPLETONES_FTM_OUTPUT_DIR" + +DOCUMENT_FILENAME: Final[str] = "drums.btp" +BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR" diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py new file mode 100644 index 00000000..584d468d --- /dev/null +++ b/tests/suite/bitphase.py @@ -0,0 +1,233 @@ +import gzip +import json +from dataclasses import dataclass +from typing import Any, Dict, Final, List, Optional, Tuple + +BITPHASE_DEFAULT_NAME: Final[str] = "" +BITPHASE_DEFAULT_AUTHOR: Final[str] = "" +BITPHASE_DEFAULT_LOOP_POINT: Final[int] = 0 +BITPHASE_DEFAULT_PATTERN_ORDER: Final[Tuple[int, ...]] = (0,) +BITPHASE_DEFAULT_PATTERN_LENGTH: Final[int] = 64 +BITPHASE_DEFAULT_ROW_COUNT: Final[int] = 64 +BITPHASE_DEFAULT_INTERRUPT_FREQUENCY: Final[int] = 50 +BITPHASE_DEFAULT_INITIAL_SPEED: Final[int] = 3 +BITPHASE_DEFAULT_CHIP_VARIANT: Final[str] = "NTSC" +BITPHASE_DEFAULT_A4_TUNING: Final[float] = 440.0 +BITPHASE_DEFAULT_CHIP_TYPE: Final[str] = "ay" +BITPHASE_DEFAULT_NOTE_NAME: Final[int] = 0 +BITPHASE_DEFAULT_OCTAVE: Final[int] = 0 +BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" +BITPHASE_DEFAULT_LOOP: Final[int] = 0 +BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 +BITPHASE_DEFAULT_PULSE_WIDTH: Final[int] = 2 +BITPHASE_DEFAULT_VOLUME_OR_RATE: Final[int] = 15 + +MIN_INITIAL_SPEED: Final[int] = 1 +MAX_INITIAL_SPEED: Final[int] = 255 + + +@dataclass(frozen=True) +class LoadedNote: + name: int + octave: int + + +@dataclass(frozen=True) +class LoadedRow: + note: LoadedNote + instrument: int + table: int + volume: int + + +@dataclass(frozen=True) +class LoadedChannel: + label: str + rows: List[LoadedRow] + + +@dataclass(frozen=True) +class LoadedPattern: + id: int + length: int + channels: List[LoadedChannel] + + +@dataclass(frozen=True) +class LoadedInstrumentRow: + pulse_width: int + volume_or_rate: int + envelope: bool + sound_length: int + tone_add: int + tone_accumulation: bool + retrigger: bool + sweep: bool + sweep_rate: int + sweep_shift: int + + +@dataclass(frozen=True) +class LoadedInstrument: + id: str + chip_type: str + loop: int + name: str + rows: List[LoadedInstrumentRow] + + @property + def number(self) -> int: + """The value a pattern's instrument column carries to play this instrument.""" + return int(self.id, 36) + + +@dataclass(frozen=True) +class LoadedTable: + id: int + loop: int + name: str + rows: List[int] + + +@dataclass(frozen=True) +class LoadedSong: + chip_type: Optional[str] + chip_variant: str + chip_frequency: Optional[int] + interrupt_frequency: int + a4_tuning_hz: float + initial_speed: int + tuning_table: List[int] + patterns: List[LoadedPattern] + + +@dataclass(frozen=True) +class LoadedProject: + name: str + author: str + loop_point_id: int + pattern_order: List[int] + songs: List[LoadedSong] + tables: List[LoadedTable] + instruments: List[LoadedInstrument] + + +def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: + source = data or {} + return LoadedNote( + name=source.get("name", BITPHASE_DEFAULT_NOTE_NAME), + octave=source.get("octave", BITPHASE_DEFAULT_OCTAVE), + ) + + +def _row(data: Dict[str, Any]) -> LoadedRow: + return LoadedRow( + note=_note(data.get("note")), + instrument=data.get("instrument", 0), + table=data.get("table", 0), + volume=data.get("volume", 0), + ) + + +def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: + rows = data.get("rows") + if rows is None: + return LoadedChannel(label=label, rows=[]) + + return LoadedChannel(label=label, rows=[_row(row) for row in rows]) + + +def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: + channels = data.get("channels") or [] + return LoadedPattern( + id=data.get("id", 0), + length=data.get("length", BITPHASE_DEFAULT_PATTERN_LENGTH), + channels=[ + _channel(channel, labels[index] if index < len(labels) else chr(ord("A") + index)) + for index, channel in enumerate(channels) + ], + ) + + +def _instrument_row(data: Dict[str, Any]) -> LoadedInstrumentRow: + return LoadedInstrumentRow( + pulse_width=data.get("pulseWidth", BITPHASE_DEFAULT_PULSE_WIDTH), + volume_or_rate=data.get("volumeOrRate", BITPHASE_DEFAULT_VOLUME_OR_RATE), + envelope=bool(data.get("envelope", False)), + sound_length=data.get("soundLength", 0), + tone_add=data.get("toneAdd", 0), + tone_accumulation=bool(data.get("toneAccumulation", False)), + retrigger=bool(data.get("retrigger", False)), + sweep=bool(data.get("sweep", False)), + sweep_rate=data.get("sweepRate", 0), + sweep_shift=data.get("sweepShift", 0), + ) + + +def _instrument(data: Dict[str, Any]) -> LoadedInstrument: + identifier = data.get("id") + chip_type = data.get("chipType") + return LoadedInstrument( + id=identifier if isinstance(identifier, str) else BITPHASE_DEFAULT_INSTRUMENT_ID, + chip_type=chip_type if isinstance(chip_type, str) else BITPHASE_DEFAULT_CHIP_TYPE, + loop=data.get("loop", BITPHASE_DEFAULT_LOOP), + name=data.get("name", BITPHASE_DEFAULT_NAME), + rows=[_instrument_row(row) for row in data.get("rows") or []], + ) + + +def _table(data: Dict[str, Any]) -> LoadedTable: + return LoadedTable( + id=data.get("id", BITPHASE_DEFAULT_TABLE_ID), + loop=data.get("loop", BITPHASE_DEFAULT_LOOP), + name=data.get("name", BITPHASE_DEFAULT_NAME), + rows=list(data.get("rows") or []), + ) + + +def _initial_speed(data: Dict[str, Any]) -> int: + speed = data.get("initialSpeed") + if isinstance(speed, int) and MIN_INITIAL_SPEED <= speed <= MAX_INITIAL_SPEED: + return speed + + return BITPHASE_DEFAULT_INITIAL_SPEED + + +def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: + return LoadedSong( + chip_type=data.get("chipType"), + chip_variant=data.get("chipVariant", BITPHASE_DEFAULT_CHIP_VARIANT), + chip_frequency=data.get("chipFrequency"), + interrupt_frequency=data.get("interruptFrequency", BITPHASE_DEFAULT_INTERRUPT_FREQUENCY), + a4_tuning_hz=data.get("a4TuningHz", BITPHASE_DEFAULT_A4_TUNING), + initial_speed=_initial_speed(data), + tuning_table=list(data.get("tuningTable") or []), + patterns=[_pattern(pattern, labels) for pattern in data.get("patterns") or []], + ) + + +def parse_btp(data: bytes, channel_labels: List[str]) -> LoadedProject: + """Reads a ``.btp`` the way Bitphase's project loader does. + + The loader takes each field on its own and falls back to a default for any it + misses, so reading a document through the same fallbacks turns a field left out + into the default value the assertion catches. + + Args: + data: The file's contents. + channel_labels: Channel names the chip schema supplies, which the loader + assigns to a pattern's channels by position. + + Returns: + LoadedProject: The document as Bitphase reconstructs it. + """ + document: Dict[str, Any] = json.loads(gzip.decompress(data)) + return LoadedProject( + name=document.get("name", BITPHASE_DEFAULT_NAME), + author=document.get("author", BITPHASE_DEFAULT_AUTHOR), + loop_point_id=document.get("loopPointId", BITPHASE_DEFAULT_LOOP_POINT), + pattern_order=list(document.get("patternOrder") or BITPHASE_DEFAULT_PATTERN_ORDER), + songs=[_song(song, channel_labels) for song in document.get("songs") or []], + tables=[_table(table) for table in document.get("tables") or []], + instruments=[_instrument(instrument) for instrument in document.get("instruments") or []], + ) diff --git a/tests/unit/sampletones_core/formats/bitphase/__init__.py b/tests/unit/sampletones_core/formats/bitphase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py new file mode 100644 index 00000000..ae7d4078 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -0,0 +1,49 @@ +from typing import Final, Optional, Sequence + +import numpy as np + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.trackers.request import InstrumentExport, SampleExport + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 + + +def build_features( + volume: Sequence[int], + *, + arpeggio: Optional[Sequence[int]] = None, + duty_cycle: Optional[Sequence[int]] = None, + initial_pitch: int = REFERENCE_PITCH, +) -> Features: + """Builds the envelopes of one generator slice, flat in every dimension left out.""" + contour = np.zeros(len(volume), dtype=int) if arpeggio is None else np.array(arpeggio, dtype=int) + return Features( + initial_pitch=initial_pitch, + volume=np.array(volume, dtype=int), + arpeggio=contour, + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + ) + + +def build_instrument( + name: str, + features: Features, + *, + generator: GeneratorName = GeneratorName.PULSE1, + loop: bool = False, +) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=generator, + features=features, + loop=loop, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py new file mode 100644 index 00000000..2940520f --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -0,0 +1,170 @@ +import gzip +import json +from pathlib import Path +from typing import Any, Dict, Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.btp import project_to_bytes, write_btp +from sampletones_core.formats.bitphase.builder import sample_to_bitphase +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES, TUNING_TABLE_LENGTH +from sampletones_core.paths import EXT_FILE_BITPHASE + +from .conftest import build_features, build_instrument, build_sample + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] + +PROJECT_KEYS: Final[List[str]] = [ + "name", + "author", + "songs", + "loopPointId", + "patternOrder", + "tables", + "patternOrderColors", + "instruments", +] +SONG_KEYS: Final[List[str]] = [ + "patterns", + "tuningTable", + "initialSpeed", + "chipType", + "chipVariant", + "chipFrequency", + "interruptFrequency", + "a4TuningHz", + "virtualChannelMap", +] +PATTERN_KEYS: Final[List[str]] = ["id", "length", "channels", "patternRows"] +ROW_KEYS: Final[List[str]] = ["note", "effects", "instrument", "table", "volume"] +INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "rows", "loop", "name"] +INSTRUMENT_ROW_KEYS: Final[List[str]] = [ + "pulseWidth", + "volumeOrRate", + "retrigger", + "soundLength", + "envelope", + "toneAdd", + "toneAccumulation", + "sweep", + "sweepRate", + "sweepShift", +] +TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name"] + + +@pytest.fixture(name="project") +def project_fixture() -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Kick", + build_instrument("Kick Pulse 1", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + build_instrument( + "Kick Noise", + build_features(VOLUME_ENVELOPE, duty_cycle=[1, 1, 0, 0]), + generator=GeneratorName.NOISE, + ), + ) + ) + + +@pytest.fixture(name="document") +def document_fixture(project: BitphaseProject) -> Dict[str, Any]: + return json.loads(gzip.decompress(project_to_bytes(project))) + + +class TestTheFileIsGzippedJson: + def test_the_bytes_decompress_to_json(self, document: Dict[str, Any]) -> None: + assert isinstance(document, dict) + + def test_writing_the_same_document_twice_yields_the_same_bytes(self, project: BitphaseProject) -> None: + """A fixed timestamp keeps the gzip header stable, so an unchanged document + exports byte-identically and a diff shows only real changes. + """ + assert project_to_bytes(project) == project_to_bytes(project) + + def test_the_file_lands_on_disk(self, project: BitphaseProject, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + write_btp(destination, project) + assert json.loads(gzip.decompress(destination.read_bytes()))["name"] == "Kick" + + +class TestTheDocumentCarriesEveryFieldBitphaseReads: + """Bitphase reconstructs a project field by field, falling back to a default for + each one it misses, so a document holding every field loads as it was written. + """ + + @pytest.mark.parametrize("key", PROJECT_KEYS) + def test_the_project_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document + + @pytest.mark.parametrize("key", SONG_KEYS) + def test_the_song_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0] + + @pytest.mark.parametrize("key", PATTERN_KEYS) + def test_the_pattern_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0]["patterns"][0] + + @pytest.mark.parametrize("key", ROW_KEYS) + def test_the_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0]["patterns"][0]["channels"][0]["rows"][0] + + @pytest.mark.parametrize("key", INSTRUMENT_KEYS) + def test_the_instrument_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["instruments"][0] + + @pytest.mark.parametrize("key", INSTRUMENT_ROW_KEYS) + def test_the_instrument_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["instruments"][0]["rows"][0] + + @pytest.mark.parametrize("key", TABLE_KEYS) + def test_the_table_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["tables"][0] + + def test_a_note_names_a_semitone_and_an_octave(self, document: Dict[str, Any]) -> None: + note = document["songs"][0]["patterns"][0]["channels"][0]["rows"][0]["note"] + assert set(note) == {"name", "octave"} + + def test_a_channel_names_the_channel_it_drives(self, document: Dict[str, Any]) -> None: + channel = document["songs"][0]["patterns"][0]["channels"][0] + assert set(channel) == {"rows", "label"} + + +class TestTheDocumentReadsAsNes: + def test_the_song_names_the_chip(self, document: Dict[str, Any]) -> None: + assert document["songs"][0]["chipType"] == CHIP_TYPE_NES + + def test_every_instrument_names_the_chip(self, document: Dict[str, Any]) -> None: + assert {instrument["chipType"] for instrument in document["instruments"]} == {CHIP_TYPE_NES} + + def test_the_tuning_table_covers_every_note_index(self, document: Dict[str, Any]) -> None: + assert len(document["songs"][0]["tuningTable"]) == TUNING_TABLE_LENGTH + + def test_the_order_names_patterns_the_song_holds(self, document: Dict[str, Any]) -> None: + held = {pattern["id"] for pattern in document["songs"][0]["patterns"]} + assert set(document["patternOrder"]) <= held + + +class TestTheEnvelopesSurvive: + def test_the_volume_envelope_crosses_over_whole(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert [row["volumeOrRate"] for row in rows] == VOLUME_ENVELOPE + + def test_the_pitch_contour_crosses_over_whole(self, document: Dict[str, Any]) -> None: + assert document["tables"][0]["rows"] == PITCH_CONTOUR + + def test_the_noise_mode_reaches_the_waveform_field(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][1]["rows"] + assert [row["pulseWidth"] for row in rows] == [1, 1, 0, 0] + + def test_the_rows_read_their_level_as_a_literal_volume(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert all(row["envelope"] is False for row in rows) + + def test_the_rows_hold_the_note_for_as_long_as_the_envelope_runs(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert all(row["soundLength"] == 0 for row in rows) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py new file mode 100644 index 00000000..004e7ae3 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -0,0 +1,225 @@ +import math +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.builder import ( + PREVIEW_REST_PATTERN_ID, + PREVIEW_SPEED, + PREVIEW_TRIGGER_ROW, + instrument_to_bitphase, + sample_to_bitphase, +) +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.notes import ( + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import MAX_TABLE_ID, MIN_INSTRUMENT_ID, MIN_TABLE_ID +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_PATTERN_ID, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) + +from .conftest import NES_FREQUENCY, REFERENCE_PITCH, build_features, build_instrument, build_sample + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +NOISE_PERIOD: Final[int] = 4 +LONG_ENVELOPE_FRAMES: Final[int] = 4000 + + +@pytest.fixture(name="project") +def project_fixture() -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Kick", + build_instrument("Kick Pulse 1", build_features(VOLUME_ENVELOPE)), + build_instrument( + "Kick Noise", + build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ), + ) + ) + + +class TestEverySliceBecomesAVoice: + def test_each_slice_yields_one_instrument(self, project: BitphaseProject) -> None: + assert [instrument.name for instrument in project.instruments] == ["Kick Pulse 1", "Kick Noise"] + + def test_each_slice_yields_the_table_that_carries_its_contour(self, project: BitphaseProject) -> None: + assert [table.name for table in project.tables] == ["Kick Pulse 1", "Kick Noise"] + + def test_instruments_are_numbered_from_the_first_the_column_names(self, project: BitphaseProject) -> None: + assert [instrument.id for instrument in project.instruments] == [ + format_instrument_id(MIN_INSTRUMENT_ID), + format_instrument_id(MIN_INSTRUMENT_ID + 1), + ] + + def test_tables_are_numbered_alongside_the_instruments(self, project: BitphaseProject) -> None: + assert [table.id for table in project.tables] == [MIN_TABLE_ID, MIN_TABLE_ID + 1] + + def test_every_instrument_declares_the_chip_whose_rows_it_holds(self, project: BitphaseProject) -> None: + """A document that leaves the chip unnamed loads as an AY instrument, so the + instrument rows would be read under the wrong layout. + """ + assert {instrument.chip_type for instrument in project.instruments} == {CHIP_TYPE_NES} + + def test_the_song_declares_the_chip_it_drives(self, project: BitphaseProject) -> None: + assert project.songs[0].chip_type == CHIP_TYPE_NES + + +class TestThePreviewPattern: + def test_the_pattern_spans_every_channel(self, project: BitphaseProject) -> None: + assert len(project.songs[0].patterns[0].channels) == CHANNEL_COUNT + + def test_each_voice_is_triggered_on_the_channel_it_was_reconstructed_for( + self, + project: BitphaseProject, + ) -> None: + channels = project.songs[0].patterns[0].channels + triggered = { + index: channel.rows[PREVIEW_TRIGGER_ROW].instrument + for index, channel in enumerate(channels) + if channel.rows[PREVIEW_TRIGGER_ROW].instrument != NO_INSTRUMENT_CHANGE + } + assert triggered == { + int(ChannelIndex.SQUARE1): MIN_INSTRUMENT_ID, + int(ChannelIndex.NOISE): MIN_INSTRUMENT_ID + 1, + } + + def test_a_trigger_attaches_the_voice_table(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.table == MIN_TABLE_ID + TABLE_COLUMN_OFFSET + + def test_a_trigger_passes_the_instrument_volume_through(self, project: BitphaseProject) -> None: + """Row 15 of the volume table is the identity, so the instrument's own envelope + reaches the channel unscaled. + """ + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.volume == FULL_VOLUME + + def test_a_pitched_trigger_names_the_reconstructed_note(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(REFERENCE_PITCH)) + + def test_a_noise_trigger_names_the_note_that_selects_its_period(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW] + assert row.note == note_index_to_note_cell(noise_period_to_note_index(NOISE_PERIOD)) + + def test_the_lines_after_the_trigger_leave_the_channel_alone(self, project: BitphaseProject) -> None: + rows = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows + assert all(row.instrument == NO_INSTRUMENT_CHANGE for row in rows[1:]) + assert all(row.table == NO_TABLE_CHANGE for row in rows[1:]) + + def test_the_pattern_length_stays_within_what_bitphase_holds(self, project: BitphaseProject) -> None: + length = project.songs[0].patterns[0].length + assert MIN_PATTERN_LENGTH <= length <= MAX_PATTERN_LENGTH + + def test_every_channel_of_the_pattern_is_as_long_as_the_pattern(self, project: BitphaseProject) -> None: + pattern = project.songs[0].patterns[0] + assert all(len(channel.rows) == pattern.length for channel in pattern.channels) + + +class TestTheOrderCoversTheLongestInstrument: + """Playback returns to the start of the order, so a document whose order runs out + before its longest instrument does would retrigger the slice mid-note. + """ + + @pytest.fixture(name="long_project") + def long_project_fixture(self) -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Pad", + build_instrument("Pad Pulse 1", build_features([15] * LONG_ENVELOPE_FRAMES)), + ) + ) + + def test_a_short_slice_plays_from_one_position(self, project: BitphaseProject) -> None: + assert project.pattern_order == (FIRST_PATTERN_ID,) + + def test_a_long_slice_rests_for_as_many_positions_as_it_needs(self, long_project: BitphaseProject) -> None: + pattern_length = long_project.songs[0].patterns[0].length + positions = math.ceil(LONG_ENVELOPE_FRAMES / (pattern_length * PREVIEW_SPEED)) + assert long_project.pattern_order == (FIRST_PATTERN_ID,) + (PREVIEW_REST_PATTERN_ID,) * (positions - 1) + + def test_the_resting_positions_name_a_pattern_the_song_holds(self, long_project: BitphaseProject) -> None: + held = {pattern.id for pattern in long_project.songs[0].patterns} + assert set(long_project.pattern_order) <= held + + def test_a_resting_position_leaves_every_channel_silent(self, long_project: BitphaseProject) -> None: + rest = long_project.songs[0].patterns[PREVIEW_REST_PATTERN_ID] + assert all(row.instrument == NO_INSTRUMENT_CHANGE for channel in rest.channels for row in channel.rows) + + +class TestOneSliceOnItsOwn: + def test_a_single_slice_becomes_a_playable_document(self) -> None: + project = instrument_to_bitphase( + build_instrument("Lead", build_features(VOLUME_ENVELOPE, arpeggio=[0, 3, 5, 7])) + ) + assert len(project.instruments) == 1 + assert len(project.tables) == 1 + + def test_the_document_is_named_after_the_slice(self) -> None: + project = instrument_to_bitphase(build_instrument("Lead", build_features(VOLUME_ENVELOPE))) + assert project.name == "Lead" + + def test_the_engine_tick_rate_carries_the_reconstruction_rate(self) -> None: + project = instrument_to_bitphase(build_instrument("Lead", build_features(VOLUME_ENVELOPE))) + assert project.songs[0].interrupt_frequency == NES_FREQUENCY + + def test_a_noise_slice_reaches_the_noise_channel(self) -> None: + project = instrument_to_bitphase( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ) + ) + row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW] + assert row.note.name != int(NoteName.NONE) + + def test_a_noise_table_holds_offsets_within_one_period_cycle(self) -> None: + project = instrument_to_bitphase( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, -1, -2, -3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ) + ) + assert all(0 <= offset < NUM_PERIODS for offset in project.tables[0].rows) + + +class TestCapacityLimits: + """A pattern's table column names one base-36 digit, so a document reaching past + what the column can name is refused rather than written unplayable. + """ + + def test_a_document_filling_the_table_column_is_written(self) -> None: + voices = MAX_TABLE_ID + 1 + request = build_sample( + "Wide", + *(build_instrument(f"Slice {index}", build_features(VOLUME_ENVELOPE)) for index in range(voices)), + ) + assert len(sample_to_bitphase(request).tables) == voices + + def test_a_document_past_the_table_column_is_refused(self) -> None: + voices = MAX_TABLE_ID + 2 + request = build_sample( + "Wider", + *(build_instrument(f"Slice {index}", build_features(VOLUME_ENVELOPE)) for index in range(voices)), + ) + with pytest.raises(ValueError, match="tables"): + sample_to_bitphase(request) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py new file mode 100644 index 00000000..28e4da5d --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -0,0 +1,182 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.specification.instruments import ( + FLAT_PULSE_WIDTH, + LOOP_FROM_START, + NO_TABLE_OFFSET, + NOISE_MODE_LONG, + NOISE_MODE_SHORT, + SILENT_VOLUME, +) + +from .conftest import build_features + + +@dataclass +class PulseWidthCase: + generator: GeneratorName + duty_cycle: int + pulse_width: int + + +PULSE_WIDTH_CASES: List[PulseWidthCase] = [ + PulseWidthCase(generator=GeneratorName.PULSE1, duty_cycle=2, pulse_width=2), + PulseWidthCase(generator=GeneratorName.PULSE2, duty_cycle=3, pulse_width=3), + PulseWidthCase(generator=GeneratorName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH), + PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG), + PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT), +] + +VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 2, 4, 5, 7] + + +class TestRowsCarryTheEnvelopes: + def test_each_volume_item_becomes_one_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE + + def test_the_contour_becomes_the_table(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == PITCH_CONTOUR + + @pytest.mark.parametrize( + "case", + PULSE_WIDTH_CASES, + ids=lambda case: f"{case.generator}-{case.duty_cycle}", + ) + def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidthCase) -> None: + envelopes = features_to_envelopes( + build_features([15], duty_cycle=[case.duty_cycle]), + case.generator, + loop=False, + ) + assert envelopes.rows[0].pulse_width == case.pulse_width + + def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.TRIANGLE, + loop=False, + ) + assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} + + def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: + steps = [0, 1, -1, 5] + envelopes = features_to_envelopes( + build_features([15] * len(steps), arpeggio=steps), + GeneratorName.NOISE, + loop=False, + ) + assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] + + +class TestTheDimensionsStayInStep: + """Instrument rows and table rows advance on their own per-tick counters, so a + length they share is what keeps the volume envelope aligned with the pitch contour. + """ + + @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) + def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), + GeneratorName.PULSE1, + loop=loop, + ) + assert len(envelopes.rows) == len(envelopes.table_rows) + + def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + GeneratorName.PULSE1, + loop=True, + ) + assert len(envelopes.rows) == 2 + + def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] + + def test_a_slice_without_a_contour_holds_its_note(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=[]), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) + + +class TestTheLoopPoint: + def test_a_looping_slice_returns_to_its_first_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=True, + ) + assert envelopes.loop == LOOP_FROM_START + + def test_a_one_shot_rests_on_its_last_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=False, + ) + assert envelopes.loop == len(envelopes.rows) - 1 + + def test_a_one_shot_rests_in_silence(self) -> None: + """Playback always returns to the loop row, so a slice that has played through + rests on the note-off item its volume envelope ends with. + """ + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=False, + ) + assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME + + @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) + def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=loop, + ) + assert envelopes.loop < len(envelopes.rows) + assert envelopes.loop < len(envelopes.table_rows) + + +class TestAnEmptySlice: + """An instrument holds at least one row, so a slice with no volume envelope still + reaches Bitphase as a playable silent instrument. + """ + + @pytest.fixture(name="envelopes") + def envelopes_fixture(self) -> ChannelEnvelopes: + return features_to_envelopes(build_features([]), GeneratorName.PULSE1, loop=False) + + def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: + assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] + + def test_its_table_holds_one_flat_offset(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.table_rows == (NO_TABLE_OFFSET,) + + def test_it_loops_on_that_row(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.loop == LOOP_FROM_START diff --git a/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py new file mode 100644 index 00000000..ec152295 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.specification.instruments import ( + INSTRUMENT_ID_DIGITS, + MAX_INSTRUMENT_ID, + MIN_INSTRUMENT_ID, +) +from sampletones_core.formats.bitphase.specification.patterns import SYMBOL_BASE + + +@dataclass +class IdentifierCase: + number: int + identifier: str + + +IDENTIFIER_CASES: List[IdentifierCase] = [ + IdentifierCase(number=1, identifier="01"), + IdentifierCase(number=10, identifier="0A"), + IdentifierCase(number=35, identifier="0Z"), + IdentifierCase(number=36, identifier="10"), + IdentifierCase(number=MAX_INSTRUMENT_ID, identifier="ZZ"), +] + + +class TestFormatInstrumentId: + @pytest.mark.parametrize("case", IDENTIFIER_CASES, ids=lambda case: str(case.number)) + def test_a_number_renders_as_its_base36_text(self, case: IdentifierCase) -> None: + assert format_instrument_id(case.number) == case.identifier + + def test_bitphase_parses_the_written_text_back(self) -> None: + """A pattern's instrument column is matched against ``parseInt(id, 36)``, so the + text has to read back as the number the column carries. + """ + for number in range(MIN_INSTRUMENT_ID, MAX_INSTRUMENT_ID + 1): + assert int(format_instrument_id(number), SYMBOL_BASE) == number + + def test_every_identifier_fills_the_column(self) -> None: + widths = {len(format_instrument_id(number)) for number in range(MIN_INSTRUMENT_ID, MAX_INSTRUMENT_ID + 1)} + assert widths == {INSTRUMENT_ID_DIGITS} diff --git a/tests/unit/sampletones_core/formats/bitphase/test_notes.py b/tests/unit/sampletones_core/formats/bitphase/test_notes.py new file mode 100644 index 00000000..2649fece --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_notes.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.notes import ( + noise_arpeggio_to_table_offset, + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.formats.bitphase.specification.chip import TUNING_TABLE_LENGTH +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, + NoteName, +) + + +@dataclass +class PitchCase: + pitch: int + index: int + + +@dataclass +class NoteCellCase: + index: int + name: int + octave: int + + +PITCH_CASES: List[PitchCase] = [ + PitchCase(pitch=24, index=0), + PitchCase(pitch=60, index=36), + PitchCase(pitch=119, index=95), + PitchCase(pitch=0, index=0), + PitchCase(pitch=200, index=95), +] + +NOTE_CELL_CASES: List[NoteCellCase] = [ + NoteCellCase(index=0, name=int(NoteName.C), octave=1), + NoteCellCase(index=36, name=int(NoteName.C), octave=4), + NoteCellCase(index=45, name=11, octave=4), + NoteCellCase(index=95, name=int(NoteName.B), octave=8), +] + +LOWEST_STEP: Final[int] = -NUM_PERIODS +HIGHEST_STEP: Final[int] = NUM_PERIODS + + +def bitphase_note_value(name: int, octave: int) -> int: + """The note index Bitphase's pattern processor reads back from a note cell.""" + return name - int(NoteName.C) + (octave - FIRST_OCTAVE) * NOTE_RANGE + + +def bitphase_noise_period(index: int) -> int: + """The noise period Bitphase's playback selects for a note index.""" + return NUM_PERIODS - 1 - index % NUM_PERIODS + + +class TestPitchToNoteIndex: + @pytest.mark.parametrize("case", PITCH_CASES, ids=lambda case: str(case.pitch)) + def test_a_pitch_lands_on_its_tuning_table_index(self, case: PitchCase) -> None: + assert pitch_to_note_index(case.pitch) == case.index + + def test_every_pitch_lands_inside_the_tuning_table(self) -> None: + indices = [pitch_to_note_index(pitch) for pitch in range(-50, 200)] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) + + def test_the_playable_span_keeps_its_distance_from_the_pitch(self) -> None: + pitches = range(NOTE_INDEX_PITCH_OFFSET, NOTE_INDEX_PITCH_OFFSET + TUNING_TABLE_LENGTH) + assert all(pitch_to_note_index(pitch) == pitch - NOTE_INDEX_PITCH_OFFSET for pitch in pitches) + + +class TestNoteIndexToNoteCell: + @pytest.mark.parametrize("case", NOTE_CELL_CASES, ids=lambda case: str(case.index)) + def test_an_index_names_a_semitone_and_an_octave(self, case: NoteCellCase) -> None: + cell = note_index_to_note_cell(case.index) + assert (cell.name, cell.octave) == (case.name, case.octave) + + def test_bitphase_reads_the_written_index_back(self) -> None: + """Playback resolves a cell to ``name - 2 + (octave - 1) * 12``, which is the + index the tuning table is read at, so the round trip is the note's contract. + """ + for index in range(TUNING_TABLE_LENGTH): + cell = note_index_to_note_cell(index) + assert bitphase_note_value(cell.name, cell.octave) == index + + def test_every_cell_names_a_pitched_semitone(self) -> None: + cells = [note_index_to_note_cell(index) for index in range(TUNING_TABLE_LENGTH)] + assert all(int(NoteName.C) <= cell.name <= int(NoteName.B) for cell in cells) + + +class TestNoisePeriods: + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_period_reaches_the_note_index_that_selects_it(self, period: int) -> None: + assert bitphase_noise_period(noise_period_to_note_index(period)) == period + + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_base_note_leaves_a_whole_cycle_of_offsets_playable(self, period: int) -> None: + index = noise_period_to_note_index(period) + assert MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX - (NUM_PERIODS - 1) + + @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) + def test_an_arpeggio_step_moves_the_period_by_that_much(self, step: int) -> None: + """The table offset and the base note together reproduce the period the + reconstruction chose, wrapped into the sixteen the channel holds. + """ + for period in range(NUM_PERIODS): + index = noise_period_to_note_index(period) + noise_arpeggio_to_table_offset(step) + assert bitphase_noise_period(index) == (period + step) % NUM_PERIODS + + @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) + def test_every_reached_note_stays_inside_the_tuning_table(self, step: int) -> None: + offset = noise_arpeggio_to_table_offset(step) + indices = [noise_period_to_note_index(period) + offset for period in range(NUM_PERIODS)] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py new file mode 100644 index 00000000..6643d59b --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path +from typing import Any, Dict, Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.preset import PRESET_TUNING_TABLE, instrument_to_preset, write_preset +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import ( + LOOP_FROM_START, + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.paths import EXT_FILE_JSON + +from .conftest import REFERENCE_PITCH, build_features, build_instrument + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] +NOISE_PERIOD: Final[int] = 4 +PRESET_KEYS: Final[List[str]] = ["chipType", "name", "loop", "rows"] + + +@pytest.fixture(name="preset") +def preset_fixture() -> BitphaseInstrumentPreset: + return instrument_to_preset( + build_instrument("Lead", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + ) + + +@pytest.fixture(name="document") +def document_fixture(preset: BitphaseInstrumentPreset, tmp_path: Path) -> Dict[str, Any]: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + write_preset(destination, preset) + return json.loads(destination.read_text(encoding="utf-8")) + + +class TestThePresetCarriesTheSlice: + def test_it_takes_the_slice_name(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.name == "Lead" + + def test_it_holds_one_row_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: + assert [row.volume_or_rate for row in preset.rows] == VOLUME_ENVELOPE + + def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.loop == len(preset.rows) - 1 + + def test_a_looping_slice_returns_to_its_first_row(self) -> None: + preset = instrument_to_preset( + build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop=True), + ) + assert preset.loop == LOOP_FROM_START + + +class TestThePitchContourRidesInTheToneOffset: + """A preset carries rows alone, so the movement a table would drive is expressed as + the per-tick period offset each row adds to the note's own period. + """ + + def test_each_row_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: + base_index = pitch_to_note_index(REFERENCE_PITCH) + base_period = PRESET_TUNING_TABLE[base_index] + expected = [PRESET_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] + assert [row.tone_add for row in preset.rows] == expected + + def test_the_first_row_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.rows[0].tone_add == NO_TONE_OFFSET + + def test_a_rising_contour_shortens_the_period(self, preset: BitphaseInstrumentPreset) -> None: + offsets = [row.tone_add for row in preset.rows] + assert all(later <= earlier for earlier, later in zip(offsets, offsets[1:])) + + def test_every_offset_fits_the_field(self, preset: BitphaseInstrumentPreset) -> None: + assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + + def test_a_contour_reaching_past_the_tuning_table_holds_its_edge(self) -> None: + preset = instrument_to_preset( + build_instrument("Sweep", build_features(VOLUME_ENVELOPE, arpeggio=[0, 40, 80, 120])), + ) + assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + + def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ), + ) + assert {row.tone_add for row in preset.rows} == {NO_TONE_OFFSET} + + +class TestThePresetFile: + @pytest.mark.parametrize("key", PRESET_KEYS) + def test_it_holds_every_field_the_panel_reads(self, document: Dict[str, Any], key: str) -> None: + assert key in document + + def test_it_names_the_chip_whose_rows_it_holds(self, document: Dict[str, Any]) -> None: + assert document["chipType"] == CHIP_TYPE_NES + + def test_it_is_indented_the_way_bitphase_writes_its_own( + self, preset: BitphaseInstrumentPreset, tmp_path: Path + ) -> None: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + write_preset(destination, preset) + assert '\n "name"' in destination.read_text(encoding="utf-8") + + def test_its_rows_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: + assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["rows"][0]) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py new file mode 100644 index 00000000..466314f8 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -0,0 +1,221 @@ +from pathlib import Path +from typing import Dict, Final, List, Mapping, Optional, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.notes import note_index_to_note_cell, pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import ChannelIndex +from sampletones_core.formats.bitphase.specification.patterns import ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + SYMBOL_BASE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.instructions.implementation.triangle import TriangleInstruction +from sampletones_core.instructions.instruction import Instruction +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.instruments.sample import Sample +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.settings import ProjectSettings +from sampletones_core.project.song import Song +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures import IdentifiedCollection + +RECONSTRUCTION_LENGTH: Final[int] = 4 +ROWS_PER_PATTERN: Final[int] = 8 +LEAD_PITCH: Final[int] = 60 +BASS_PITCH: Final[int] = 36 +TRANSPOSE: Final[int] = 5 +ROW_VOLUME: Final[int] = 10 +TRIGGER_ROW: Final[int] = 0 +NOTE_OFF_ROW: Final[int] = 2 +TRANSPOSED_ROW: Final[int] = 4 +EMPTY_ROW: Final[int] = 6 + + +def build_reconstruction(instructions: Mapping[GeneratorName, Sequence[Instruction]]) -> Reconstruction: + approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions} + return Reconstruction.create( + approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32), + approximations=approximations, + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + + +def pulse_sample(name: str, pitch: int) -> Sample: + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0)] + return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions})) + + +def triangle_sample(name: str, pitch: int) -> Sample: + instructions = [TriangleInstruction(on=True, pitch=pitch)] + return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions})) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return pulse_sample("Lead", LEAD_PITCH) + + +@pytest.fixture(name="bass") +def bass_fixture() -> Sample: + return triangle_sample("Bass", BASS_PITCH) + + +@pytest.fixture(name="source") +def source_fixture(lead: Sample, bass: Sample) -> Project: + samples: IdentifiedCollection[Sample] = IdentifiedCollection() + for sample in (lead, bass): + samples.append(sample) + + pulse_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + pulse_rows[TRIGGER_ROW] = Row( + command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + transpose=0, + volume=ROW_VOLUME, + ) + pulse_rows[NOTE_OFF_ROW] = Row(command=NoteOff()) + pulse_rows[TRANSPOSED_ROW] = Row( + command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + transpose=TRANSPOSE, + ) + + triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + triangle_rows[TRIGGER_ROW] = Row( + command=Instrument(sample_id=bass.id, generator_name=GeneratorName.TRIANGLE), + transpose=0, + ) + + channels = { + GeneratorName.PULSE1: Channel(generator=GeneratorName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), + GeneratorName.PULSE2: Channel(generator=GeneratorName.PULSE2, patterns={}), + GeneratorName.TRIANGLE: Channel(generator=GeneratorName.TRIANGLE, patterns={0: Pattern(rows=triangle_rows)}), + GeneratorName.NOISE: Channel(generator=GeneratorName.NOISE, patterns={}), + } + order: List[Dict[GeneratorName, Optional[int]]] = [ + {GeneratorName.PULSE1: 0, GeneratorName.TRIANGLE: 0}, + {GeneratorName.PULSE1: None, GeneratorName.TRIANGLE: 0}, + ] + + project = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + project.samples = samples + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +@pytest.fixture(name="document") +def document_fixture(source: Project) -> BitphaseProject: + return project_to_bitphase(source) + + +class TestTheDocumentCarriesTheProject: + def test_the_title_and_author_cross_over(self, document: BitphaseProject, source: Project) -> None: + assert (document.name, document.author) == (source.info.title, source.info.author) + + def test_the_speed_and_tick_rate_cross_over(self, document: BitphaseProject, source: Project) -> None: + song = document.songs[0] + assert song.initial_speed == source.settings.speed + assert song.interrupt_frequency == source.settings.nes_frequency + + def test_every_sample_slice_becomes_an_instrument(self, document: BitphaseProject) -> None: + assert [instrument.name for instrument in document.instruments] == ["Lead Pulse 1", "Bass Triangle"] + + +class TestTheOrderFlattens: + """A SampleToNES order frame points each channel at its own pattern, where a Bitphase + order position names one pattern spanning every channel, so each frame becomes a + pattern of its own carrying that frame's channels side by side. + """ + + def test_each_order_frame_becomes_one_pattern(self, document: BitphaseProject, source: Project) -> None: + assert len(document.songs[0].patterns) == len(source.song.order) + + def test_the_order_plays_those_patterns_in_turn(self, document: BitphaseProject, source: Project) -> None: + assert document.pattern_order == tuple(range(len(source.song.order))) + + def test_a_frame_carries_the_channels_it_names(self, document: BitphaseProject) -> None: + pattern = document.songs[0].patterns[0] + triggered = { + index + for index, channel in enumerate(pattern.channels) + if any(row.instrument != NO_INSTRUMENT_CHANGE for row in channel.rows) + } + assert triggered == {int(ChannelIndex.SQUARE1), int(ChannelIndex.TRIANGLE)} + + def test_a_channel_the_frame_leaves_unset_stays_empty(self, document: BitphaseProject) -> None: + pattern = document.songs[0].patterns[1] + rows = pattern.channels[int(ChannelIndex.SQUARE1)].rows + assert all(row.instrument == NO_INSTRUMENT_CHANGE for row in rows) + + def test_every_pattern_is_as_long_as_the_song_declares(self, document: BitphaseProject, source: Project) -> None: + patterns = document.songs[0].patterns + assert all(pattern.length == source.song.rows_per_pattern for pattern in patterns) + + +class TestRowCells: + def test_a_trigger_names_its_instrument_and_table(self, document: BitphaseProject) -> None: + """Bitphase matches the instrument column against ``parseInt(id, 36)``, so the + column and the instrument's own identifier name the same voice. + """ + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.instrument == int(document.instruments[0].id, SYMBOL_BASE) + assert row.table == document.tables[0].id + TABLE_COLUMN_OFFSET + + def test_a_trigger_plays_the_pitch_the_slice_was_reconstructed_at(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(LEAD_PITCH)) + + def test_a_transposed_trigger_moves_that_pitch(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(LEAD_PITCH + TRANSPOSE)) + + def test_a_row_volume_reaches_the_volume_column(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.volume == ROW_VOLUME + + def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.volume == NO_VOLUME_CHANGE + + def test_a_note_off_stops_the_channel(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[NOTE_OFF_ROW] + assert row.note.name == int(NoteName.OFF) + assert row.instrument == NO_INSTRUMENT_CHANGE + + def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[EMPTY_ROW] + assert row.note.name == int(NoteName.NONE) + assert (row.instrument, row.table, row.volume) == ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + ) + + +class TestAnUnbuildableRow: + def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: + rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + rows[TRIGGER_ROW] = Row(command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE2)) + source.song.channels[GeneratorName.PULSE2] = Channel( + generator=GeneratorName.PULSE2, + patterns={0: Pattern(rows=rows)}, + ) + source.song.order[0][GeneratorName.PULSE2] = 0 + + with pytest.raises(ValueError, match="has no instrument"): + project_to_bitphase(source) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py new file mode 100644 index 00000000..7c187ec7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.formats.bitphase.specification.chip import ( + CPU_FREQUENCIES, + DEFAULT_A4_TUNING, + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_A4_INDEX, + TUNING_TABLE_LENGTH, + ChipVariant, +) +from sampletones_core.formats.bitphase.tuning import generate_tuning_table + +BITPHASE_NTSC_TABLE: Final[Tuple[int, ...]] = ( + 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2034, 1920, 1812, + 1710, 1614, 1524, 1438, 1357, 1281, 1209, 1141, 1077, 1017, 960, 906, + 855, 807, 762, 719, 679, 641, 605, 571, 539, 508, 480, 453, + 428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, + 214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113, + 107, 101, 95, 90, 85, 80, 76, 71, 67, 64, 60, 57, + 53, 50, 48, 45, 42, 40, 38, 36, 34, 32, 30, 28, + 27, 25, 24, 22, 21, 20, 19, 18, 17, 16, 15, 14, +) # fmt: skip + + +@dataclass +class PeriodCase: + variant: ChipVariant + index: int + period: int + + +VARIANT_CASES: List[PeriodCase] = [ + PeriodCase(variant=ChipVariant.NTSC, index=9, period=2034), + PeriodCase(variant=ChipVariant.NTSC, index=45, period=254), + PeriodCase(variant=ChipVariant.NTSC, index=95, period=14), + PeriodCase(variant=ChipVariant.PAL, index=9, period=1889), + PeriodCase(variant=ChipVariant.PAL, index=45, period=236), + PeriodCase(variant=ChipVariant.PAL, index=95, period=13), + PeriodCase(variant=ChipVariant.DENDY, index=9, period=2015), + PeriodCase(variant=ChipVariant.DENDY, index=45, period=252), + PeriodCase(variant=ChipVariant.DENDY, index=95, period=14), +] + +SLOW_CLOCK: Final[int] = 1000 +RAISED_A4_TUNING: Final[float] = 432.0 +RAISED_A4_PERIOD: Final[int] = 259 +NARROW_TIMER_LIMIT: Final[int] = 255 + + +@pytest.fixture(name="ntsc_table") +def ntsc_table_fixture() -> Tuple[int, ...]: + return generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=DEFAULT_A4_TUNING, + ) + + +class TestTheTableMatchesBitphase: + """The tuning table is the contract with Bitphase: the tracker derives its own from + the same settings, so a document whose table differs plays at a different pitch than + the reconstruction it came from. These numbers come from Bitphase's own generator. + """ + + def test_the_ntsc_table_equals_the_one_bitphase_derives(self, ntsc_table: Tuple[int, ...]) -> None: + assert ntsc_table == BITPHASE_NTSC_TABLE + + @pytest.mark.parametrize("case", VARIANT_CASES, ids=lambda case: f"{case.variant}-{case.index}") + def test_each_system_clock_yields_bitphase_periods(self, case: PeriodCase) -> None: + table = generate_tuning_table(CPU_FREQUENCIES[case.variant], a4_tuning=DEFAULT_A4_TUNING) + assert table[case.index] == case.period + + def test_a_shifted_concert_pitch_moves_the_whole_table(self) -> None: + table = generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=RAISED_A4_TUNING, + ) + assert table[TUNING_A4_INDEX] == RAISED_A4_PERIOD + + +class TestTableShape: + def test_the_table_covers_every_note_index(self, ntsc_table: Tuple[int, ...]) -> None: + assert len(ntsc_table) == TUNING_TABLE_LENGTH + + def test_a_rising_note_index_shortens_the_period(self, ntsc_table: Tuple[int, ...]) -> None: + assert all(later <= earlier for earlier, later in zip(ntsc_table, ntsc_table[1:])) + + @pytest.mark.parametrize("variant", list(ChipVariant)) + def test_every_period_fits_the_channel_timer(self, variant: ChipVariant) -> None: + table = generate_tuning_table(CPU_FREQUENCIES[variant], a4_tuning=DEFAULT_A4_TUNING) + assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in table) + + def test_a_clock_too_slow_for_the_top_notes_holds_the_shortest_period(self) -> None: + table = generate_tuning_table(SLOW_CLOCK, a4_tuning=DEFAULT_A4_TUNING) + assert table[-1] == MIN_TUNING_PERIOD + + def test_a_narrower_timer_holds_the_longest_period(self) -> None: + table = generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=DEFAULT_A4_TUNING, + max_period=NARROW_TIMER_LIMIT, + ) + assert max(table) == NARROW_TIMER_LIMIT diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py new file mode 100644 index 00000000..5a188ca7 --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -0,0 +1,246 @@ +import gzip +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Final, List, Optional + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend +from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport +from sampletones_core.trackers.scope import DestinationKind, ExportScope + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +ENVELOPE_FRAMES: Final[int] = 16 +LONG_ENVELOPE_FRAMES: Final[int] = 600 +PROJECT_TITLE: Final[str] = "Demo" + + +@dataclass +class ScopeCase: + scope: ExportScope + destination: DestinationKind + + +PRESET_SCOPE_CASES: List[ScopeCase] = [ + ScopeCase(scope=ExportScope.INSTRUMENT, destination=DestinationKind.FILE), + ScopeCase(scope=ExportScope.SAMPLE, destination=DestinationKind.DIRECTORY), +] + + +def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: + duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + return Features( + initial_pitch=REFERENCE_PITCH, + volume=np.full(frames, 15, dtype=int), + arpeggio=np.zeros(frames, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=build_features(frames), + loop=False, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + + +def read_document(destination: Path) -> Dict[str, Any]: + document: Dict[str, Any] = json.loads(gzip.decompress(destination.read_bytes())) + return document + + +@pytest.fixture(name="backend") +def backend_fixture() -> BitphaseBackend: + return BitphaseBackend() + + +@pytest.fixture(name="preset_backend") +def preset_backend_fixture() -> BitphasePresetBackend: + return BitphasePresetBackend() + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + return Project.create(title=PROJECT_TITLE, author="Tester", settings=ProjectSettings()) + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: + assert backend.tracker_format == TrackerFormat.BITPHASE + + def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize("scope", list(ExportScope)) + def test_every_scope_lands_in_one_file(self, backend: BitphaseBackend, scope: ExportScope) -> None: + """A document holds instruments, tables and patterns together, so a whole + reconstruction fits in the same kind of file one slice does. + """ + assert backend.destination_kind(scope) == DestinationKind.FILE + + @pytest.mark.parametrize("scope", list(ExportScope)) + def test_every_scope_carries_the_document_extension(self, backend: BitphaseBackend, scope: ExportScope) -> None: + assert backend.extension(scope) == EXT_FILE_BITPHASE + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + + artifact = backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_the_document_holds_the_slice(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + document = read_document(destination) + + assert [instrument["name"] for instrument in document["instruments"]] == ["Short"] + + def test_a_long_envelope_crosses_over_whole(self, backend: BitphaseBackend, tmp_path: Path) -> None: + """Bitphase stores instrument rows without a length limit, so a reconstruction + reaches the document at its full length. + """ + destination = tmp_path / f"Long{EXT_FILE_BITPHASE}" + artifact = backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) + + document = read_document(destination) + + assert len(document["instruments"][0]["rows"]) == LONG_ENVELOPE_FRAMES + assert artifact.truncation is None + + +class TestWriteSample: + def test_every_slice_lands_in_one_document(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + request = build_sample( + "Kick", + build_instrument("Kick Pulse 1", ENVELOPE_FRAMES), + build_instrument("Kick Noise", ENVELOPE_FRAMES), + ) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == (destination,) + assert len(read_document(destination)["instruments"]) == 2 + + def test_the_document_is_named_after_the_reconstruction(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert read_document(destination)["name"] == "Kick" + + +class TestWriteProject: + def test_the_document_is_written_and_reported( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert artifact.paths == (destination,) + assert destination.exists() + + def test_the_document_takes_the_project_title( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + backend.write_project(destination, ProjectExport(project=project)) + + assert read_document(destination)["name"] == PROJECT_TITLE + + +class TestThePresetBackend: + def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: + assert preset_backend.tracker_format == TrackerFormat.BITPHASE_PRESET + + def test_a_preset_holds_instruments_rather_than_a_song(self, preset_backend: BitphasePresetBackend) -> None: + assert preset_backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + + @pytest.mark.parametrize("case", PRESET_SCOPE_CASES, ids=lambda case: str(case.scope)) + def test_one_slice_writes_a_file_and_a_reconstruction_fills_a_directory( + self, + preset_backend: BitphasePresetBackend, + case: ScopeCase, + ) -> None: + assert preset_backend.destination_kind(case.scope) == case.destination + + @pytest.mark.parametrize("case", PRESET_SCOPE_CASES, ids=lambda case: str(case.scope)) + def test_every_supported_scope_carries_the_preset_extension( + self, + preset_backend: BitphasePresetBackend, + case: ScopeCase, + ) -> None: + assert preset_backend.extension(case.scope) == EXT_FILE_JSON + + def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + + artifact = preset_backend.write_instrument(destination, build_instrument("Lead", ENVELOPE_FRAMES)) + + assert artifact.paths == (destination,) + assert json.loads(destination.read_text(encoding="utf-8"))["name"] == "Lead" + + def test_each_slice_lands_in_a_file_named_after_its_instrument( + self, + preset_backend: BitphasePresetBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / "Kick" + request = build_sample( + "Kick", + build_instrument("Kick Pulse 1", ENVELOPE_FRAMES), + build_instrument("Kick Noise", ENVELOPE_FRAMES), + ) + + artifact = preset_backend.write_sample(destination, request) + + assert artifact.paths == ( + destination / f"Kick Pulse 1{EXT_FILE_JSON}", + destination / f"Kick Noise{EXT_FILE_JSON}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / "Kick" + + preset_backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert destination.is_dir() + + def test_a_project_is_refused( + self, + preset_backend: BitphasePresetBackend, + project: Project, + tmp_path: Path, + ) -> None: + with pytest.raises(ValueError, match="one instrument"): + preset_backend.write_project(tmp_path / f"Demo{EXT_FILE_JSON}", ProjectExport(project=project)) From 0ff4507f9ce0168acc19fcea7c5d00623f4aa531 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 15:44:50 +0200 Subject: [PATCH 09/20] Updated: documentation --- CHANGELOG.md | 1 + docs/development/bugs-and-todos.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ceb1b2c..ef699221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v0.3.1 [2026-07-31] +* Added support to [Bitphase](https://github.com/paator/bitphase). * Fixed arpeggio editing shifting a sample's pitch permanently. * Bumped the reconstruction data-version to `2.1`. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 36b9a509..759f9e91 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -3,6 +3,7 @@ ### Navigation * Interface scale +* VSync/frame rate options * Tree navigation using keys * Waveform LOD for zooming * Keybindings options @@ -10,6 +11,7 @@ * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform +* Transpose/note pitch display duality ### Tracker @@ -39,3 +41,4 @@ ## Bugs * No refreshing after library generation +* Inconsistent instruments naming scheme From c5b3efa13edcfd4b6adae0b341fb44d03a56d035 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 16:30:05 +0200 Subject: [PATCH 10/20] Unified: generator slice naming across export paths --- .../logic/reconstruction/reconstruction.py | 15 +++----- src/sampletones_core/exporters/naming.py | 19 ++++++++++ src/sampletones_core/exporters/slices.py | 3 +- .../sampletones_core/exporters/test_naming.py | 36 +++++++++++++++++++ .../formats/bitphase/test_btp.py | 4 +-- .../formats/bitphase/test_builder.py | 10 +++--- .../formats/bitphase/test_project_builder.py | 2 +- .../formats/famitracker/test_ftm.py | 4 +-- .../trackers/test_bitphase.py | 12 +++---- 9 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 src/sampletones_core/exporters/naming.py create mode 100644 tests/unit/sampletones_core/exporters/test_naming.py diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 94dab00f..0baf8938 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -16,6 +16,7 @@ from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, SampleExport @@ -177,7 +178,7 @@ def request_export_instrument_dialog( if generator_name not in feature_data.generators: return - instrument_name = f"{reconstruction_data.name} ({generator_name})" + instrument_name = self._get_instrument_name(generator_name) default_path = str(self._session_manager.get_instrument_path()) self._pending_instrument = PendingInstrumentExport( @@ -312,19 +313,13 @@ def open_reconstruction_in_explorer(self) -> None: open_path_in_explorer(filepath) - def _get_instrument_name( - self, - generator_name: Optional[GeneratorName] = None, - ) -> str: + def _get_instrument_name(self, generator_name: GeneratorName) -> str: + """Names the loaded reconstruction's slice for one generator.""" reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be present") - filename = reconstruction_data.name - if generator_name is None: - return filename - - return f"{filename}_{generator_name}" + return instrument_slice_name(reconstruction_data.name, generator_name) def _emit_audio_data(self) -> None: audio_data = self._compute_audio_data() diff --git a/src/sampletones_core/exporters/naming.py b/src/sampletones_core/exporters/naming.py new file mode 100644 index 00000000..a4871f6a --- /dev/null +++ b/src/sampletones_core/exporters/naming.py @@ -0,0 +1,19 @@ +from sampletones_core.constants.enums import GeneratorName + + +def instrument_slice_name(base_name: str, generator: GeneratorName) -> str: + """Names one generator slice of a reconstruction. + + Every export path shares this form, so a slice carries the same name whether it + reaches a tracker as a standalone instrument file or as one entry of a project's + instrument table. The parenthesised suffix keeps the base name readable while + identifying the channel the slice drives. + + Args: + base_name: The name of the reconstruction or sample the slice came from. + generator: The NES channel the slice covers. + + Returns: + str: The slice's name, of the form ``base (generator)``. + """ + return f"{base_name} ({generator})" diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index f2933aa1..6b87f51e 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project @@ -39,7 +40,7 @@ class SampleSlice: @property def instrument_name(self) -> str: """The exported instrument's name, naming both its sample and its channel.""" - return f"{self.sample.name} {self.generator.capitalized}" + return instrument_slice_name(self.sample.name, self.generator) @property def key(self) -> Tuple[str, GeneratorName]: diff --git a/tests/unit/sampletones_core/exporters/test_naming.py b/tests/unit/sampletones_core/exporters/test_naming.py new file mode 100644 index 00000000..001e7151 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_naming.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.naming import instrument_slice_name + +BASE_NAME: Final[str] = "Kick" + + +@dataclass(frozen=True) +class NameCase: + generator: GeneratorName + expected: str + + +NAME_CASES: Final[List[NameCase]] = [ + NameCase(generator=GeneratorName.PULSE1, expected="Kick (pulse1)"), + NameCase(generator=GeneratorName.PULSE2, expected="Kick (pulse2)"), + NameCase(generator=GeneratorName.TRIANGLE, expected="Kick (triangle)"), + NameCase(generator=GeneratorName.NOISE, expected="Kick (noise)"), +] + + +class TestInstrumentSliceName: + @pytest.mark.parametrize("case", NAME_CASES, ids=lambda case: case.generator.value) + def test_the_generator_follows_the_base_name_in_parentheses(self, case: NameCase) -> None: + assert instrument_slice_name(BASE_NAME, case.generator) == case.expected + + def test_every_generator_gets_a_distinct_name(self) -> None: + names = {instrument_slice_name(BASE_NAME, generator) for generator in GeneratorName.items()} + assert len(names) == len(GeneratorName.items()) + + def test_the_base_name_is_carried_verbatim(self) -> None: + assert instrument_slice_name("Lead 2 (alt)", GeneratorName.PULSE1).startswith("Lead 2 (alt) ") diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 2940520f..21b5ba4d 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -61,9 +61,9 @@ def project_fixture() -> BitphaseProject: return sample_to_bitphase( build_sample( "Kick", - build_instrument("Kick Pulse 1", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + build_instrument("Kick (pulse1)", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), build_instrument( - "Kick Noise", + "Kick (noise)", build_features(VOLUME_ENVELOPE, duty_cycle=[1, 1, 0, 0]), generator=GeneratorName.NOISE, ), diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py index 004e7ae3..1ca42272 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -45,9 +45,9 @@ def project_fixture() -> BitphaseProject: return sample_to_bitphase( build_sample( "Kick", - build_instrument("Kick Pulse 1", build_features(VOLUME_ENVELOPE)), + build_instrument("Kick (pulse1)", build_features(VOLUME_ENVELOPE)), build_instrument( - "Kick Noise", + "Kick (noise)", build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD), generator=GeneratorName.NOISE, ), @@ -57,10 +57,10 @@ def project_fixture() -> BitphaseProject: class TestEverySliceBecomesAVoice: def test_each_slice_yields_one_instrument(self, project: BitphaseProject) -> None: - assert [instrument.name for instrument in project.instruments] == ["Kick Pulse 1", "Kick Noise"] + assert [instrument.name for instrument in project.instruments] == ["Kick (pulse1)", "Kick (noise)"] def test_each_slice_yields_the_table_that_carries_its_contour(self, project: BitphaseProject) -> None: - assert [table.name for table in project.tables] == ["Kick Pulse 1", "Kick Noise"] + assert [table.name for table in project.tables] == ["Kick (pulse1)", "Kick (noise)"] def test_instruments_are_numbered_from_the_first_the_column_names(self, project: BitphaseProject) -> None: assert [instrument.id for instrument in project.instruments] == [ @@ -143,7 +143,7 @@ def long_project_fixture(self) -> BitphaseProject: return sample_to_bitphase( build_sample( "Pad", - build_instrument("Pad Pulse 1", build_features([15] * LONG_ENVELOPE_FRAMES)), + build_instrument("Pad (pulse1)", build_features([15] * LONG_ENVELOPE_FRAMES)), ) ) 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 466314f8..cfd42ecb 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -133,7 +133,7 @@ def test_the_speed_and_tick_rate_cross_over(self, document: BitphaseProject, sou assert song.interrupt_frequency == source.settings.nes_frequency def test_every_sample_slice_becomes_an_instrument(self, document: BitphaseProject) -> None: - assert [instrument.name for instrument in document.instruments] == ["Lead Pulse 1", "Bass Triangle"] + assert [instrument.name for instrument in document.instruments] == ["Lead (pulse1)", "Bass (triangle)"] class TestTheOrderFlattens: diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 278621ce..1abf9360 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -121,8 +121,8 @@ def test_all_instruments_are_2a03(self, project_fixture: ProjectFixture) -> None def test_instrument_names_include_generator(self, project_fixture: ProjectFixture) -> None: names = [instrument.name for instrument in _parsed(project_fixture).instruments] - assert names[0] == "lead Pulse 1" - assert "Triangle" in names[4] + assert names[0] == "lead (pulse1)" + assert names[4] == "bell (triangle)" def test_volume_reference_resolves_to_populated_sequence(self, project_fixture: ProjectFixture) -> None: parsed = _parsed(project_fixture) diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 5a188ca7..f38dc996 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -136,8 +136,8 @@ def test_every_slice_lands_in_one_document(self, backend: BitphaseBackend, tmp_p destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" request = build_sample( "Kick", - build_instrument("Kick Pulse 1", ENVELOPE_FRAMES), - build_instrument("Kick Noise", ENVELOPE_FRAMES), + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), ) artifact = backend.write_sample(destination, request) @@ -217,15 +217,15 @@ def test_each_slice_lands_in_a_file_named_after_its_instrument( destination = tmp_path / "Kick" request = build_sample( "Kick", - build_instrument("Kick Pulse 1", ENVELOPE_FRAMES), - build_instrument("Kick Noise", ENVELOPE_FRAMES), + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), ) artifact = preset_backend.write_sample(destination, request) assert artifact.paths == ( - destination / f"Kick Pulse 1{EXT_FILE_JSON}", - destination / f"Kick Noise{EXT_FILE_JSON}", + destination / f"Kick (pulse1){EXT_FILE_JSON}", + destination / f"Kick (noise){EXT_FILE_JSON}", ) assert all(path.exists() for path in artifact.paths) From 7ac3d207f8d53c64c94f57f92d423a36bb4dd750 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 19:38:28 +0200 Subject: [PATCH 11/20] Changed: instrument export destinations to files --- .../coordinators/tabs/reconstruction.py | 32 ++-- .../logic/reconstruction/reconstruction.py | 26 ++- src/sampletones_core/trackers/backend.py | 23 +-- src/sampletones_core/trackers/extensions.py | 54 ++++++ .../trackers/implementation/bitphase.py | 18 +- .../trackers/implementation/famitracker.py | 12 +- src/sampletones_core/trackers/scope.py | 11 -- .../services/test_export.py | 13 +- .../reconstruction/test_reconstruction.py | 31 ++++ .../services/export/test_service.py | 5 +- .../trackers/test_bitphase.py | 46 ++---- .../trackers/test_extensions.py | 155 ++++++++++++++++++ .../trackers/test_famitracker.py | 30 +--- 13 files changed, 314 insertions(+), 142 deletions(-) create mode 100644 src/sampletones_core/trackers/extensions.py create mode 100644 tests/unit/sampletones_core/trackers/test_extensions.py diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 7fc32f57..5aedc220 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -72,10 +72,7 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) -from sampletones_application.utils.file_dialogs.api import ( - save_file_dialog, - select_directory_dialog, -) +from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item @@ -89,7 +86,7 @@ from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -482,25 +479,18 @@ def _open_export_instruments_dialog( default_path: str, tracker_format: TrackerFormat, ) -> None: - """Prompts for whatever destination the chosen format writes a reconstruction to. + """Prompts for the destination the loaded reconstruction's slices are named after. - A format that gathers a whole reconstruction into one document is saved as a file; - one that writes an instrument per slice fills a directory. + A format that gathers the whole reconstruction into one document writes it there, + while one that keeps an instrument per file writes its slices beside it. """ backend = self._tracker_backends[tracker_format] - destination = ( - save_file_dialog( - title=self._ttl_export_instruments, - initial_directory=default_path, - default_filename=default_filename, - extensions=[backend.extension(ExportScope.SAMPLE)], - filter_name=self._filters_export_instrument[tracker_format], - ) - if backend.destination_kind(ExportScope.SAMPLE) is DestinationKind.FILE - else select_directory_dialog( - title=self._ttl_export_instruments, - initial_directory=default_path, - ) + destination = save_file_dialog( + title=self._ttl_export_instruments, + initial_directory=default_path, + default_filename=default_filename, + extensions=[backend.extension(ExportScope.SAMPLE)], + filter_name=self._filters_export_instrument[tracker_format], ) self._handle_export_instruments(destination) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 0baf8938..3a8047ef 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -218,6 +218,11 @@ def request_export_wav_dialog(self) -> None: self.call(self.on_open_export_wav_dialog, default_filename, default_path) def handle_export_instrument_confirmed(self, filepath: Path) -> None: + """Writes one generator slice of the loaded reconstruction to ``filepath``. + + The instrument carries the name the destination was saved under, so renaming the + file in the dialog renames the instrument the tracker lists. + """ pending = self._pending_instrument self._pending_instrument = None if not self._reconstruction_data or pending is None: @@ -230,14 +235,15 @@ def handle_export_instrument_confirmed(self, filepath: Path) -> None: self._export_service.export_instrument( filepath, self._tracker_backends[pending.tracker_format], - self._instrument_export(pending.generator, feature), + self._instrument_export(pending.generator, feature, filepath.stem), ) def handle_export_instruments_confirmed(self, destination: Path) -> None: """Writes every generator slice of the loaded reconstruction to ``destination``. - The chosen format decides whether the destination is one file holding the whole - reconstruction or a directory the slices fill. + The destination names the batch: each slice takes its generator suffix from the + stem, so a format gathering the whole reconstruction into one document writes it + there while one keeping an instrument per file writes its slices beside it. """ reconstruction_data = self._reconstruction_data tracker_format = self._pending_sample_format @@ -246,10 +252,15 @@ def handle_export_instruments_confirmed(self, destination: Path) -> None: logger.warning("No reconstruction data available for instruments export") return + base_name = destination.stem request = SampleExport( - name=reconstruction_data.name, + name=base_name, instruments=tuple( - self._instrument_export(generator_name, feature) + self._instrument_export( + generator_name, + feature, + instrument_slice_name(base_name, generator_name), + ) for generator_name, feature in reconstruction_data.feature_data.generators.items() ), nes_frequency=self._nes_frequency(), @@ -261,14 +272,15 @@ def _instrument_export( self, generator_name: GeneratorName, feature: Features, + name: str, ) -> InstrumentExport: - """Names one generator slice and packages it for a tracker backend. + """Packages one generator slice under ``name`` for a tracker backend. A reconstruction has no loop flag of its own — that belongs to a sample placed in a project — so the instrument plays its envelopes once. """ return InstrumentExport( - name=self._get_instrument_name(generator_name), + name=name, generator=generator_name, features=feature, loop=False, diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/trackers/backend.py index ffd32dbb..6f3a6d77 100644 --- a/src/sampletones_core/trackers/backend.py +++ b/src/sampletones_core/trackers/backend.py @@ -8,17 +8,16 @@ ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope class TrackerBackend(Protocol): """Writes the application's work in the file format one tracker reads. A backend owns both the byte layout and the shape each :class:`ExportScope` takes on - disk, so a format that reads a whole reconstruction from a single file writes one - where another writes a directory of per-instrument files. Callers ask - :meth:`destination_kind` what to prompt for and hand the answer straight back as the - ``destination``. + disk, so a format that gathers a whole reconstruction into one document writes one + where another writes a file per instrument. Every scope is written to a file path the + caller chooses, and :meth:`extension` names the extension it carries. """ @property @@ -29,16 +28,6 @@ def tracker_format(self) -> TrackerFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: """The scopes this format can express.""" - def destination_kind(self, scope: ExportScope) -> DestinationKind: - """Whether ``scope`` is written to a file or into a directory. - - Args: - scope: The scope about to be exported. - - Returns: - DestinationKind: What the caller should prompt the user for. - """ - def extension(self, scope: ExportScope) -> str: """The extension the files of ``scope`` carry, leading dot included. @@ -75,7 +64,9 @@ def write_sample( """Writes every generator slice of one reconstruction. Args: - destination: The file to write, or the directory to fill. + destination: The file this scope is written to. A format that keeps one + instrument per file writes its slices beside it, each named after the + instrument it carries. request: The reconstruction's slices. Returns: diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/trackers/extensions.py new file mode 100644 index 00000000..3641e8f4 --- /dev/null +++ b/src/sampletones_core/trackers/extensions.py @@ -0,0 +1,54 @@ +from typing import Mapping, Optional, Tuple + +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import ExportScope + + +def scope_extensions( + backends: Mapping[TrackerFormat, TrackerBackend], + scope: ExportScope, +) -> Tuple[str, ...]: + """The extensions a destination for ``scope`` may carry, leading dot included. + + One export action reaches every format able to express the scope, so the dialog that + picks a destination offers all of their extensions at once and the chosen one names + the format. Each extension appears once, in the order the backends were registered. + + Args: + backends: Every backend the application writes through, keyed by its format. + scope: The scope about to be exported. + + Returns: + Tuple[str, ...]: The extension of each format that can express ``scope``. + """ + extensions = (backend.extension(scope) for backend in backends.values() if scope in backend.supported_scopes) + return tuple(dict.fromkeys(extensions)) + + +def format_for_extension( + backends: Mapping[TrackerFormat, TrackerBackend], + scope: ExportScope, + extension: str, +) -> Optional[TrackerFormat]: + """The format whose ``scope`` files carry ``extension``. + + The destination the user names decides which tracker the export is written for, so + the extension it ends in resolves to a format here. Case folds, letting a destination + typed in capitals reach the same backend. + + Args: + backends: Every backend the application writes through, keyed by its format. + scope: The scope about to be exported. + extension: The extension the chosen destination carries, leading dot included. + + Returns: + Optional[TrackerFormat]: The format claiming ``extension``, or ``None`` when no + format able to express ``scope`` writes it. + """ + wanted = extension.casefold() + for tracker_format, backend in backends.items(): + if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted: + return tracker_format + + return None diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py index f53b24f4..9ee5c86e 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -12,7 +12,7 @@ from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) @@ -37,9 +37,6 @@ def tracker_format(self) -> TrackerFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return DOCUMENT_SCOPES - def destination_kind(self, scope: ExportScope) -> DestinationKind: - return DestinationKind.FILE - def extension(self, scope: ExportScope) -> str: return EXT_FILE_BITPHASE @@ -72,9 +69,9 @@ class BitphasePresetBackend: """Writes the single-instrument ``.json`` files Bitphase's instruments panel loads. The panel reads one instrument per file into the slot the user has selected, so a - whole reconstruction lands as a directory of them, one file per generator slice - named after the instrument. A preset carries rows alone, so its pitch contour rides - in each row's tone offset. + whole reconstruction lands as a set of them beside the chosen destination, one file + per generator slice named after the instrument. A preset carries rows alone, so its + pitch contour rides in each row's tone offset. """ @property @@ -85,9 +82,6 @@ def tracker_format(self) -> TrackerFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return PRESET_SCOPES - def destination_kind(self, scope: ExportScope) -> DestinationKind: - return DestinationKind.DIRECTORY if scope == ExportScope.SAMPLE else DestinationKind.FILE - def extension(self, scope: ExportScope) -> str: return EXT_FILE_JSON @@ -104,11 +98,11 @@ def write_sample( destination: Path, request: SampleExport, ) -> ExportArtifact: - destination.mkdir(parents=True, exist_ok=True) + destination.parent.mkdir(parents=True, exist_ok=True) paths: List[Path] = [] for instrument in request.instruments: - filepath = destination / f"{instrument.name}{EXT_FILE_JSON}" + filepath = destination.with_name(f"{instrument.name}{EXT_FILE_JSON}") paths.extend(self.write_instrument(filepath, instrument).paths) return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index 6ace7836..2a5a7339 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -16,7 +16,7 @@ ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) @@ -25,7 +25,8 @@ class FamiTrackerBackend: """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. FamiTracker reads one instrument per ``.fti`` file, so a whole reconstruction lands - as a directory of them, one file per generator slice named after the instrument. + as a set of them beside the chosen destination, one file per generator slice named + after the instrument. """ @property @@ -36,9 +37,6 @@ def tracker_format(self) -> TrackerFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return SUPPORTED_SCOPES - def destination_kind(self, scope: ExportScope) -> DestinationKind: - return DestinationKind.DIRECTORY if scope == ExportScope.SAMPLE else DestinationKind.FILE - def extension(self, scope: ExportScope) -> str: return EXT_FILE_MODULE if scope == ExportScope.PROJECT else EXT_FILE_INSTRUMENT @@ -76,12 +74,12 @@ def write_sample( destination: Path, request: SampleExport, ) -> ExportArtifact: - destination.mkdir(parents=True, exist_ok=True) + destination.parent.mkdir(parents=True, exist_ok=True) paths: List[Path] = [] truncations: List[Optional[EnvelopeTruncation]] = [] for instrument in request.instruments: - filepath = destination / f"{instrument.name}{EXT_FILE_INSTRUMENT}" + filepath = destination.with_name(f"{instrument.name}{EXT_FILE_INSTRUMENT}") artifact = self.write_instrument(filepath, instrument) paths.extend(artifact.paths) truncations.append(artifact.truncation) diff --git a/src/sampletones_core/trackers/scope.py b/src/sampletones_core/trackers/scope.py index f83b6d0f..c7bd3afe 100644 --- a/src/sampletones_core/trackers/scope.py +++ b/src/sampletones_core/trackers/scope.py @@ -11,14 +11,3 @@ class ExportScope(StrEnum): INSTRUMENT = "instrument" SAMPLE = "sample" PROJECT = "project" - - -class DestinationKind(StrEnum): - """Whether a scope's destination is a file to write or a directory to fill. - - The coordinator reads this to choose between a save-file and a select-directory - dialog, so the choice follows the backend rather than a branch on format. - """ - - FILE = "file" - DIRECTORY = "directory" diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 6ced762c..4c5890e8 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -129,23 +129,24 @@ def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features, backe instrument_export("inst_0", pulse_features), instrument_export("inst_1", pulse_features), ) - export_service.export_sample(tmp_path, backend, request) + export_service.export_sample(tmp_path / "sample.fti", backend, request) assert (tmp_path / "inst_0.fti").exists() assert (tmp_path / "inst_1.fti").exists() - def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_features, backend) -> None: + def test_emits_export_success_with_the_destination(self, tmp_path, pulse_features, backend) -> None: + destination = tmp_path / "sample.fti" export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) request = sample_export("sample", instrument_export("inst", pulse_features)) - export_service.export_sample(tmp_path, backend, request) + export_service.export_sample(destination, backend, request) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) assert results[0].kind == ExportKind.SAMPLE - assert results[0].filepath == tmp_path + assert results[0].filepath == destination def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: new_dir = tmp_path / "subdir" @@ -153,7 +154,7 @@ def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> No export_service.subscribe(lambda _: None) request = sample_export("sample", instrument_export("inst", pulse_features)) - export_service.export_sample(new_dir, backend, request) + export_service.export_sample(new_dir / "sample.fti", backend, request) assert new_dir.exists() @@ -162,7 +163,7 @@ def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> No results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_sample(tmp_path, backend, sample_export("sample")) + export_service.export_sample(tmp_path / "sample.fti", backend, sample_export("sample")) assert list(tmp_path.glob("*.fti")) == [] assert isinstance(results[0], ExportSuccess) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 516b805b..17aea905 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -437,6 +437,21 @@ def test_handle_export_instrument_confirmed_calls_export_service( panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") mock_export_service.export_instrument.assert_called_once() + def test_handle_export_instrument_confirmed_names_the_instrument_after_the_destination( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instrument_dialog = MagicMock() + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) + panel_logic.handle_export_instrument_confirmed(tmp_path / "Clap (pulse1).fti") + request = mock_export_service.export_instrument.call_args.args[2] + assert request.name == "Clap (pulse1)" + def test_handle_export_instrument_confirmed_selects_the_backend_of_the_chosen_format( self, panel_logic: ReconstructionPanelLogic, @@ -521,6 +536,22 @@ def test_handle_export_instruments_confirmed_calls_export_sample( panel_logic.handle_export_instruments_confirmed(tmp_path) mock_export_service.export_sample.assert_called_once() + def test_handle_export_instruments_confirmed_names_the_batch_after_the_destination( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instruments_dialog = MagicMock() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + panel_logic.handle_export_instruments_confirmed(tmp_path / "Clap.fti") + request = mock_export_service.export_sample.call_args.args[2] + assert request.name == "Clap" + assert [instrument.name for instrument in request.instruments] == ["Clap (pulse1)"] + def test_handle_export_instruments_confirmed_selects_the_backend_of_the_chosen_format( self, panel_logic: ReconstructionPanelLogic, diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index d7a5183b..ebde20c2 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -20,7 +20,7 @@ ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope NES_FREQUENCY: Final[int] = 60 @@ -49,9 +49,6 @@ def tracker_format(self) -> TrackerFormat: def supported_scopes(self) -> frozenset: return frozenset(ExportScope) - def destination_kind(self, scope: ExportScope) -> DestinationKind: - return DestinationKind.FILE - def extension(self, scope: ExportScope) -> str: return ".fti" diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index f38dc996..9d3fca4c 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -15,7 +15,7 @@ from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 @@ -24,16 +24,7 @@ PROJECT_TITLE: Final[str] = "Demo" -@dataclass -class ScopeCase: - scope: ExportScope - destination: DestinationKind - - -PRESET_SCOPE_CASES: List[ScopeCase] = [ - ScopeCase(scope=ExportScope.INSTRUMENT, destination=DestinationKind.FILE), - ScopeCase(scope=ExportScope.SAMPLE, destination=DestinationKind.DIRECTORY), -] +PRESET_SCOPES: List[ExportScope] = [ExportScope.INSTRUMENT, ExportScope.SAMPLE] def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: @@ -89,13 +80,6 @@ def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: assert backend.supported_scopes == frozenset(ExportScope) - @pytest.mark.parametrize("scope", list(ExportScope)) - def test_every_scope_lands_in_one_file(self, backend: BitphaseBackend, scope: ExportScope) -> None: - """A document holds instruments, tables and patterns together, so a whole - reconstruction fits in the same kind of file one slice does. - """ - assert backend.destination_kind(scope) == DestinationKind.FILE - @pytest.mark.parametrize("scope", list(ExportScope)) def test_every_scope_carries_the_document_extension(self, backend: BitphaseBackend, scope: ExportScope) -> None: assert backend.extension(scope) == EXT_FILE_BITPHASE @@ -185,21 +169,13 @@ def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBacken def test_a_preset_holds_instruments_rather_than_a_song(self, preset_backend: BitphasePresetBackend) -> None: assert preset_backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) - @pytest.mark.parametrize("case", PRESET_SCOPE_CASES, ids=lambda case: str(case.scope)) - def test_one_slice_writes_a_file_and_a_reconstruction_fills_a_directory( - self, - preset_backend: BitphasePresetBackend, - case: ScopeCase, - ) -> None: - assert preset_backend.destination_kind(case.scope) == case.destination - - @pytest.mark.parametrize("case", PRESET_SCOPE_CASES, ids=lambda case: str(case.scope)) + @pytest.mark.parametrize("scope", PRESET_SCOPES, ids=lambda scope: str(scope)) def test_every_supported_scope_carries_the_preset_extension( self, preset_backend: BitphasePresetBackend, - case: ScopeCase, + scope: ExportScope, ) -> None: - assert preset_backend.extension(case.scope) == EXT_FILE_JSON + assert preset_backend.extension(scope) == EXT_FILE_JSON def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: destination = tmp_path / f"Lead{EXT_FILE_JSON}" @@ -209,12 +185,12 @@ def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, assert artifact.paths == (destination,) assert json.loads(destination.read_text(encoding="utf-8"))["name"] == "Lead" - def test_each_slice_lands_in_a_file_named_after_its_instrument( + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( self, preset_backend: BitphasePresetBackend, tmp_path: Path, ) -> None: - destination = tmp_path / "Kick" + destination = tmp_path / f"Kick{EXT_FILE_JSON}" request = build_sample( "Kick", build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), @@ -224,17 +200,17 @@ def test_each_slice_lands_in_a_file_named_after_its_instrument( artifact = preset_backend.write_sample(destination, request) assert artifact.paths == ( - destination / f"Kick (pulse1){EXT_FILE_JSON}", - destination / f"Kick (noise){EXT_FILE_JSON}", + tmp_path / f"Kick (pulse1){EXT_FILE_JSON}", + tmp_path / f"Kick (noise){EXT_FILE_JSON}", ) assert all(path.exists() for path in artifact.paths) def test_a_missing_directory_is_created(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: - destination = tmp_path / "nested" / "Kick" + destination = tmp_path / "nested" / f"Kick{EXT_FILE_JSON}" preset_backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) - assert destination.is_dir() + assert destination.parent.is_dir() def test_a_project_is_refused( self, diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py new file mode 100644 index 00000000..e458f7f6 --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional + +import pytest + +from sampletones_core.paths import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.extensions import format_for_extension, scope_extensions +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_core.trackers.scope import ExportScope + +UNKNOWN_EXTENSION: Final[str] = ".xm" +NO_EXTENSION: Final[str] = "" + + +@dataclass(frozen=True) +class ExtensionCase: + scope: ExportScope + extension: str + expected: Optional[TrackerFormat] + + +EXTENSION_CASES: Final[List[ExtensionCase]] = [ + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_INSTRUMENT, + expected=TrackerFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_BITPHASE, + expected=TrackerFormat.BITPHASE, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_JSON, + expected=TrackerFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.SAMPLE, + extension=EXT_FILE_JSON, + expected=TrackerFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.PROJECT, + extension=EXT_FILE_MODULE, + expected=TrackerFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=UNKNOWN_EXTENSION, + expected=None, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=NO_EXTENSION, + expected=None, + ), +] + + +@pytest.fixture(name="backends") +def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: + return build_tracker_backends() + + +class TestScopeExtensions: + def test_one_slice_may_be_saved_for_every_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(scope_extensions(backends, ExportScope.INSTRUMENT)) == { + EXT_FILE_INSTRUMENT, + EXT_FILE_BITPHASE, + EXT_FILE_JSON, + } + + def test_a_project_reaches_only_the_formats_holding_a_whole_composition( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + """A preset carries one instrument, so its extension stays off a project's list.""" + assert set(scope_extensions(backends, ExportScope.PROJECT)) == { + EXT_FILE_MODULE, + EXT_FILE_BITPHASE, + } + + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_each_extension_is_offered_once( + self, + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, + ) -> None: + extensions = scope_extensions(backends, scope) + assert len(extensions) == len(set(extensions)) + + def test_the_extensions_follow_the_order_the_backends_were_registered( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert scope_extensions(backends, ExportScope.INSTRUMENT) == ( + EXT_FILE_INSTRUMENT, + EXT_FILE_BITPHASE, + EXT_FILE_JSON, + ) + + +class TestFormatForExtension: + @pytest.mark.parametrize( + "case", + EXTENSION_CASES, + ids=lambda case: f"{case.scope}{case.extension}", + ) + def test_the_extension_names_the_format_that_writes_it( + self, + backends: Dict[TrackerFormat, TrackerBackend], + case: ExtensionCase, + ) -> None: + assert format_for_extension(backends, case.scope, case.extension) == case.expected + + def test_an_extension_typed_in_capitals_reaches_the_same_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert ( + format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper()) + == TrackerFormat.FAMITRACKER + ) + + def test_a_format_that_cannot_express_the_scope_stays_unmatched( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + """A preset holds one instrument, so a project named with its extension resolves + to no format at all. + """ + assert format_for_extension(backends, ExportScope.PROJECT, EXT_FILE_JSON) is None + + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_every_offered_extension_resolves( + self, + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, + ) -> None: + """The dialog offers exactly what the resolution accepts, so a destination taking + one of the offered extensions always names a backend. + """ + for extension in scope_extensions(backends, scope): + assert format_for_extension(backends, scope, extension) is not None diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 51d638d2..9b889d10 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -12,7 +12,7 @@ from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport -from sampletones_core.trackers.scope import DestinationKind, ExportScope +from sampletones_core.trackers.scope import ExportScope NES_FREQUENCY: Final[int] = 60 @@ -55,22 +55,6 @@ def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: assert backend.supported_scopes == frozenset(ExportScope) - @pytest.mark.parametrize( - ("scope", "expected"), - [ - (ExportScope.INSTRUMENT, DestinationKind.FILE), - (ExportScope.SAMPLE, DestinationKind.DIRECTORY), - (ExportScope.PROJECT, DestinationKind.FILE), - ], - ) - def test_a_sample_fills_a_directory_while_the_others_write_a_file( - self, - backend: FamiTrackerBackend, - scope: ExportScope, - expected: DestinationKind, - ) -> None: - assert backend.destination_kind(scope) == expected - @pytest.mark.parametrize( ("scope", "expected"), [ @@ -126,28 +110,28 @@ def test_a_shortened_export_still_writes_the_file(self, backend: FamiTrackerBack class TestWriteSample: - def test_each_slice_lands_in_a_file_named_after_its_instrument( + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( self, backend: FamiTrackerBackend, tmp_path: Path, ) -> None: - destination = tmp_path / "Kick" + destination = tmp_path / f"Kick{EXT_FILE_INSTRUMENT}" request = build_sample("Kick", build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16)) artifact = backend.write_sample(destination, request) assert artifact.paths == ( - destination / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", - destination / f"Kick (noise){EXT_FILE_INSTRUMENT}", + tmp_path / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", + tmp_path / f"Kick (noise){EXT_FILE_INSTRUMENT}", ) assert all(path.exists() for path in artifact.paths) def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - destination = tmp_path / "nested" / "Kick" + destination = tmp_path / "nested" / f"Kick{EXT_FILE_INSTRUMENT}" backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", 16))) - assert destination.is_dir() + assert destination.parent.is_dir() def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: request = build_sample( From 5b44c96e63894ba1a1197c225cb2eeed608bd0b3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 20:23:11 +0200 Subject: [PATCH 12/20] Changed: instrument export destinations to files named after the batch --- .../coordinators/project.py | 4 +- .../coordinators/reconstruction.py | 3 +- .../calibration/corpus/writer.py | 3 +- .../library/filename/fields.py | 3 +- .../library/filename/utils.py | 3 +- src/sampletones_core/project/container.py | 4 +- .../trackers/implementation/bitphase.py | 3 +- .../trackers/implementation/famitracker.py | 3 +- src/sampletones_shared/utils/system/paths.py | 20 +++++++++ .../utils/system/test_paths.py | 44 +++++++++++++++++++ 10 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 1e459d48..f582e4c4 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -46,7 +46,7 @@ ) from sampletones_shared.logger import logger from sampletones_shared.types.callback import Callback, VoidCallback -from sampletones_shared.utils.system.paths import get_directory +from sampletones_shared.utils.system.paths import get_directory, get_filename class ProjectCoordinator: @@ -185,7 +185,7 @@ def save_as_dialog(self) -> bool: def _get_project_filename(self, extension: str) -> str: name = self.project_name or DEFAULT_EXPORT_NAME - return f"{name}{extension}" + return get_filename(name, extension) def export_project_dialog(self, tracker_format: TrackerFormat) -> None: """Prompts for a destination and writes the open project in ``tracker_format``. diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index cc27d87b..5edf82aa 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -43,6 +43,7 @@ from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import Callback, VoidCallback +from sampletones_shared.utils.system.paths import get_filename class ReconstructionCoordinator: @@ -143,7 +144,7 @@ def save_as_dialog(self) -> None: default_filename = filepath.name default_path = str(filepath.parent) else: - default_filename = f"{reconstruction_data.name}{EXT_FILE_RECONSTRUCTION}" + default_filename = get_filename(reconstruction_data.name, EXT_FILE_RECONSTRUCTION) default_path = str(self._session_manager.get_reconstruction_path()) filepath = save_file_dialog( diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py index 2823e7ed..f7367a6b 100644 --- a/src/sampletones_core/calibration/corpus/writer.py +++ b/src/sampletones_core/calibration/corpus/writer.py @@ -3,6 +3,7 @@ from sampletones_core.audio.io import write_wave from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_shared.utils.system.paths import get_filename from .item import CorpusItem @@ -26,7 +27,7 @@ def write_corpus( directory.mkdir(parents=True, exist_ok=True) paths: Dict[str, Path] = {} for item in items: - path = directory / f"{item.name}{EXT_FILE_WAVE}" + path = directory / get_filename(item.name, EXT_FILE_WAVE) write_wave(path, sample_rate, item.audio) paths[item.name] = path diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 1ae3acde..5e050b77 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -10,6 +10,7 @@ from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import HASH_PATTERN +from sampletones_shared.utils.system.paths import get_filename FILENAME_SEPARATOR: Final[str] = "_" @@ -31,7 +32,7 @@ def stem(self) -> str: @property def filename(self) -> str: - return f"{self.stem}{EXT_FILE_LIBRARY}" + return get_filename(self.stem, EXT_FILE_LIBRARY) @classmethod def create(cls, pathlike: Pathlike) -> InstructionsFilenameFields: diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 7b9e08ec..194cd700 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -11,6 +11,7 @@ from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.system.paths import get_filename def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: @@ -32,7 +33,7 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: transformation_gamma=transformation_gamma, spectrum_method=spectrum_method, config_hash=config_hash, - filename=f"{filename}{EXT_FILE_LIBRARY}", + filename=get_filename(filename, EXT_FILE_LIBRARY), ) diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index af5fdefd..c5186a2c 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -29,6 +29,7 @@ ) from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import JSON_INDENT +from sampletones_shared.utils.system.paths import get_filename class ProjectContainer: @@ -54,7 +55,8 @@ def save(project: Project, path: Pathlike) -> None: with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr(PROJECT_DOCUMENT_NAME, payload) for reconstruction_id, reconstruction in reconstructions.items(): - name = f"{RECONSTRUCTIONS_DIRECTORY}/{reconstruction_id}{EXT_FILE_RECONSTRUCTION}" + filename = get_filename(reconstruction_id, EXT_FILE_RECONSTRUCTION) + name = f"{RECONSTRUCTIONS_DIRECTORY}/{filename}" archive.writestr(name, reconstruction.serialize()) @staticmethod diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py index 9ee5c86e..83e40cf5 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -13,6 +13,7 @@ from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.utils.system.paths import get_filename DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) @@ -102,7 +103,7 @@ def write_sample( paths: List[Path] = [] for instrument in request.instruments: - filepath = destination.with_name(f"{instrument.name}{EXT_FILE_JSON}") + filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_JSON)) paths.extend(self.write_instrument(filepath, instrument).paths) return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index 2a5a7339..cebbd102 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -17,6 +17,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.utils.system.paths import get_filename SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) @@ -79,7 +80,7 @@ def write_sample( paths: List[Path] = [] truncations: List[Optional[EnvelopeTruncation]] = [] for instrument in request.instruments: - filepath = destination.with_name(f"{instrument.name}{EXT_FILE_INSTRUMENT}") + filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_INSTRUMENT)) artifact = self.write_instrument(filepath, instrument) paths.extend(artifact.paths) truncations.append(artifact.truncation) diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index b37a0e6c..30f19867 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -49,6 +49,26 @@ def to_path(path: GeneralPathlike) -> Path: return Path(path) +def get_filename(name: str, extension: str) -> str: + """ + Composes a file name from the name a thing is known by and its extension. + + Every place that names a file composes it here — an exported instrument, a saved + library, a corpus item, a destination a dialog suggests — so a name and the file + holding it stay in step. The name is carried verbatim, so one holding dots keeps + them (``Kick v1.2`` becomes ``Kick v1.2.fti``). :func:`ensure_suffix` covers a path + that may already end with the extension. + + Args: + name (str): The name the file is known by, without its extension. + extension (str): The extension the file carries, leading dot included. + + Returns: + str: The file name, of the form ``name.extension``. + """ + return f"{name}{extension}" + + def ensure_suffix(path: Path, suffix: str) -> Path: """ Returns the path with ``suffix`` appended when its name lacks that ending. diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 2b6f494d..2ae5874d 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -12,6 +12,7 @@ DEFAULT_MAX_FILENAME_DISPLAY, ensure_suffix, get_directory, + get_filename, open_directory_in_explorer_linux, open_file_in_explorer_linux, open_path_in_explorer, @@ -152,6 +153,49 @@ def test_to_path(self, test_case: TestCase) -> None: assert result == Path(test_case.expected) +class TestGetFilename(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + name: str + extension: str + expected: str + + test_cases = [ + TestCase( + name="song", + extension=".stp", + expected="song.stp", + label="appends_the_extension", + ), + TestCase( + name="Kick (pulse1)", + extension=".fti", + expected="Kick (pulse1).fti", + label="carries_a_parenthesised_slice_name", + ), + TestCase( + name="Kick v1.2", + extension=".fti", + expected="Kick v1.2.fti", + label="keeps_incidental_dots", + ), + TestCase( + name="song.stp", + extension=".stp", + expected="song.stp.stp", + label="appends_to_a_name_already_ending_in_the_extension", + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_get_filename(self, test_case: TestCase) -> None: + assert get_filename(test_case.name, test_case.extension) == test_case.expected + + class TestEnsureSuffix(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From 079cb7aaa62edc93f82b5a7e68ebf492e70e6185 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 3 Aug 2026 23:08:35 +0200 Subject: [PATCH 13/20] Added: desktop portal file dialogs reporting the chosen file type --- docs/development/dependencies.md | 6 +- pyproject.toml | 1 + src/sampletones_application/application.py | 24 +- .../categories/elements/global_.py | 6 +- .../categories/elements/reconstructions.py | 3 - .../categories/export.py | 28 +++ .../categories/trackers.py | 34 +-- .../coordinators/config.py | 33 +-- .../coordinators/project.py | 26 ++- .../coordinators/reconstruction.py | 33 +-- .../coordinators/tabs/reconstruction.py | 99 ++++++-- .../logic/reconstruction/pending.py | 20 -- .../logic/reconstruction/reconstruction.py | 116 +++++++--- .../services/export/service.py | 9 +- .../services/export/success.py | 2 +- src/sampletones_application/shell.py | 1 - src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 5 +- .../reconstruction/instruments/instruments.py | 51 +---- .../utils/file_dialogs/api.py | 90 +++++--- .../utils/file_dialogs/backend.py | 21 +- .../utils/file_dialogs/destination.py | 37 +++ .../utils/file_dialogs/filter.py | 57 ++++- .../utils/file_dialogs/kdialog.py | 33 +-- .../utils/file_dialogs/portal/__init__.py | 0 .../utils/file_dialogs/portal/backend.py | 211 ++++++++++++++++++ .../utils/file_dialogs/portal/client.py | 164 ++++++++++++++ .../utils/file_dialogs/selection.py | 33 ++- .../utils/file_dialogs/tkinter_backend.py | 37 +-- .../utils/file_dialogs/zenity.py | 35 +-- .../utils/gui/shortcuts/ids.py | 2 - src/sampletones_config/lang/en.yaml | 13 +- src/sampletones_core/trackers/extensions.py | 28 +++ .../services/test_export.py | 11 +- .../categories/test_trackers.py | 39 +--- .../reconstruction/test_reconstruction.py | 167 ++++++++++---- .../reconstruction/test_instruments_panel.py | 45 ++-- .../sampletones_application/ui/test_menu.py | 60 ++++- .../utils/file_dialogs/portal/__init__.py | 0 .../utils/file_dialogs/portal/test_backend.py | 208 +++++++++++++++++ .../utils/file_dialogs/portal/test_client.py | 154 +++++++++++++ .../utils/file_dialogs/test_api.py | 113 ++++++++-- .../utils/file_dialogs/test_filter.py | 58 ++++- .../utils/file_dialogs/test_kdialog.py | 29 ++- .../utils/file_dialogs/test_selection.py | 26 ++- .../file_dialogs/test_tkinter_backend.py | 28 ++- .../utils/file_dialogs/test_zenity.py | 31 ++- .../trackers/test_extensions.py | 32 ++- uv.lock | 11 + 49 files changed, 1858 insertions(+), 418 deletions(-) delete mode 100644 src/sampletones_application/logic/reconstruction/pending.py create mode 100644 src/sampletones_application/utils/file_dialogs/destination.py create mode 100644 src/sampletones_application/utils/file_dialogs/portal/__init__.py create mode 100644 src/sampletones_application/utils/file_dialogs/portal/backend.py create mode 100644 src/sampletones_application/utils/file_dialogs/portal/client.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/portal/__init__.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index c0c61698..58623fcf 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -18,10 +18,14 @@ See [GPU acceleration](../guide/installation.md#gpu-acceleration) for enabling i Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). No external compiler or system dependency is required — it is installed automatically with the package. +## File dialogs + +Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. -PortAudio is required. Tk backs the file dialogs where `kdialog` and `zenity` are absent, and `make release` requires it so the shipped executable stays self-contained. +PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained. The executable links against the glibc of the machine that builds it and runs on that version or newer, so a redistributable artifact belongs on the oldest Debian or Ubuntu release being supported. diff --git a/pyproject.toml b/pyproject.toml index bfbc3492..d1ccdc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "scipy>=1.13,<2", "screeninfo>=0.8,<0.9", "tqdm>=4.66,<5", + "jeepney>=0.8,<1; sys_platform == 'linux'", "pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'", "pywin32>=306; platform_system == 'Windows'", "PyYAML>=6.0,<7", diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 8a8ddd17..1414563e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -104,6 +104,7 @@ open_file_dialog, select_directory_dialog, ) +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.fps import FPSTimer from sampletones_application.utils.frame_limiter import FrameLimiter @@ -676,13 +677,17 @@ def _reconstruct_file_dialog(self) -> None: GlobalDialogTitleElements.RECONSTRUCT_FILE, ], initial_directory=self.session_manager.get_audio_input_path(), - extensions=EXT_FILES_AUDIO, - filter_name=self.language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.AUDIO, - ], + filters=( + FileFilter.for_extensions( + self.language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.AUDIO, + ], + EXT_FILES_AUDIO, + ), + ), ) self._handle_reconstruct_file(filepath) @@ -734,10 +739,7 @@ def _export_reconstruction_wav_dialog(self) -> None: if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_wav_dialog() - def _export_reconstruction_instruments_dialog( - self, - tracker_format: TrackerFormat, - ) -> None: + def _export_reconstruction_instruments_dialog(self, tracker_format: TrackerFormat) -> None: if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_instruments_dialog(tracker_format) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index c9d48ce3..c3a0d242 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -84,7 +84,6 @@ class MenuElements(AbstractElement): ITEM_RECONSTRUCTION_EXPORT_WAV = "item_reconstruction_export_wav" GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS = "group_reconstruction_export_instruments" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER = "item_reconstruction_export_instruments_famitracker" - ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE = "item_reconstruction_export_instruments_bitphase" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET = "item_reconstruction_export_instruments_bitphase_preset" GROUP_PLAYBACK = "group_playback" ITEM_PLAYBACK_PLAY = "item_playback_play" @@ -184,6 +183,8 @@ class GlobalMessageElements(AbstractElement): CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" OPERATION_IN_PROGRESS = "operation_in_progress" + UNSUPPORTED_EXTENSION = "unsupported_extension" + MISSING_EXTENSION = "missing_extension" ABOUT_DESCRIPTION = "about_description" @@ -214,6 +215,7 @@ class GlobalDialogTitleElements(AbstractElement): REMOVE_SAMPLE = "remove_sample" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" + UNSUPPORTED_EXTENSION = "unsupported_extension" ABOUT = "about" @@ -221,7 +223,7 @@ class FileFilterElements(AbstractElement): PROJECT = "project" RECONSTRUCTION = "reconstruction" MODULE = "module" - INSTRUMENT = "instrument" + FAMITRACKER_INSTRUMENT = "famitracker_instrument" BITPHASE_PROJECT = "bitphase_project" BITPHASE_PRESET = "bitphase_preset" CONFIG = "config" diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index f29d748d..fb60c609 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -41,9 +41,6 @@ class ReconstructionPanelElements(AbstractElement): class ReconstructionsInstrumentsElements(AbstractElement): SECTION = "section" EXPORT_INSTRUMENT_BUTTON = "export_instrument_button" - EXPORT_INSTRUMENT_FAMITRACKER = "export_instrument_famitracker" - EXPORT_INSTRUMENT_BITPHASE = "export_instrument_bitphase" - EXPORT_INSTRUMENT_BITPHASE_PRESET = "export_instrument_bitphase_preset" COPY_BUTTON = "copy_button" PITCH_LABEL = "pitch_label" HI_PITCH_LABEL = "hi_pitch_label" diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py index 2f1d7e58..21fd40aa 100644 --- a/src/sampletones_application/categories/export.py +++ b/src/sampletones_application/categories/export.py @@ -2,6 +2,10 @@ from dataclasses import dataclass +from sampletones_application.categories.elements.global_ import ( + GlobalDialogTitleElements, + GlobalMessageElements, +) from sampletones_application.categories.elements.reconstructions import ( ReconstructionPanelElements, ReconstructionsInstrumentsElements, @@ -29,6 +33,9 @@ class ExportMessages: instruments_failed: Shown when a reconstruction's instrument export fails. wav_success: Shown when the reconstruction reaches a WAV file. wav_failed: Shown when the WAV export fails. + unsupported_extension_title: Title of the dialog reporting an extension no format claims. + unsupported_extension: Template naming the extension chosen and those the scope accepts. + missing_extension: Template naming the extensions a destination given none accepts. """ status_title: str @@ -41,6 +48,9 @@ class ExportMessages: instruments_failed: str wav_success: str wav_failed: str + unsupported_extension_title: str + unsupported_extension: str + missing_extension: str @classmethod def build(cls, language_manager: LanguageManager) -> ExportMessages: @@ -105,6 +115,24 @@ def build(cls, language_manager: LanguageManager) -> ExportMessages: TextType.MESSAGE, ReconstructionPanelElements.EXPORT_WAV_FAILED, ], + unsupported_extension_title=language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.TITLE, + GlobalDialogTitleElements.UNSUPPORTED_EXTENSION, + ], + unsupported_extension=language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.MESSAGE, + GlobalMessageElements.UNSUPPORTED_EXTENSION, + ], + missing_extension=language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.MESSAGE, + GlobalMessageElements.MISSING_EXTENSION, + ], ) @staticmethod diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py index 9990ffca..b0a6bb00 100644 --- a/src/sampletones_application/categories/trackers.py +++ b/src/sampletones_application/categories/trackers.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final +from typing import Dict, Final, Tuple from sampletones_application.categories.elements.global_ import ( FileFilterElements, @@ -7,9 +7,6 @@ GlobalMessageElements, MenuElements, ) -from sampletones_application.categories.elements.reconstructions import ( - ReconstructionsInstrumentsElements, -) from sampletones_core.trackers.format import TrackerFormat @@ -48,25 +45,28 @@ class TrackerProjectElements: ), } -TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { - TrackerFormat.FAMITRACKER: FileFilterElements.INSTRUMENT, - TrackerFormat.BITPHASE: FileFilterElements.BITPHASE_PROJECT, - TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, -} - TRACKER_PROJECT_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { TrackerFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, TrackerFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, } +INSTRUMENT_EXPORT_FORMATS: Final[Tuple[TrackerFormat, ...]] = ( + TrackerFormat.FAMITRACKER, + TrackerFormat.BITPHASE_PRESET, +) +"""The formats an instrument export offers, in the order they are listed. + +Both write one file per generator slice, which is what exporting instruments produces. A +Bitphase project holds a whole composition, so it is written through the project export. +""" + +TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { + TrackerFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, + TrackerFormat.BITPHASE: FileFilterElements.BITPHASE_PROJECT, + TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, +} + TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, - TrackerFormat.BITPHASE: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE, TrackerFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, } - -TRACKER_INSTRUMENT_LABELS: Final[Dict[TrackerFormat, ReconstructionsInstrumentsElements]] = { - TrackerFormat.FAMITRACKER: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_FAMITRACKER, - TrackerFormat.BITPHASE: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BITPHASE, - TrackerFormat.BITPHASE_PRESET: ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BITPHASE_PRESET, -} diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index d6db15bd..0b48f571 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -27,6 +27,7 @@ open_file_dialog, save_file_dialog, ) +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.paths import EXT_FILE_JSON @@ -75,13 +76,7 @@ def save_dialog(self) -> None: ], initial_directory=self._session_manager.get_config_path(), default_filename=DEFAULT_CONFIG_FILENAME, - extensions=[EXT_FILE_JSON], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.CONFIG, - ], + filters=self._config_filters(), ) self._handle_save(filepath) @@ -121,17 +116,25 @@ def load_dialog(self) -> None: GlobalDialogTitleElements.LOAD_CONFIG, ], initial_directory=self._session_manager.get_config_path(), - extensions=[EXT_FILE_JSON], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.CONFIG, - ], + filters=self._config_filters(), ) self._handle_load(filepath) + def _config_filters(self) -> Tuple[FileFilter, ...]: + """The single type a configuration is written as and read from.""" + return ( + FileFilter.for_extensions( + self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.CONFIG, + ], + [EXT_FILE_JSON], + ), + ) + @ignore_none_path def _handle_load(self, filepath: Path) -> None: try: diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index f582e4c4..39b8d3a1 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from sampletones_application.categories.abstract import AbstractElement from sampletones_application.categories.elements.global_ import ( @@ -29,6 +29,7 @@ open_file_dialog, save_file_dialog, ) +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.paths import EXT_FILE_PROJECT @@ -177,12 +178,20 @@ def save_as_dialog(self) -> bool: title=self._title(GlobalDialogTitleElements.SAVE_PROJECT), initial_directory=directory, default_filename=filename, - extensions=[EXT_FILE_PROJECT], - filter_name=self._filter_name(FileFilterElements.PROJECT), + filters=self._project_filters(), ) return self._handle_save_as(filepath) + def _project_filters(self) -> Tuple[FileFilter, ...]: + """The single type a project of this application's own is written as and read from.""" + return ( + FileFilter.for_extensions( + self._filter_name(FileFilterElements.PROJECT), + [EXT_FILE_PROJECT], + ), + ) + def _get_project_filename(self, extension: str) -> str: name = self.project_name or DEFAULT_EXPORT_NAME return get_filename(name, extension) @@ -204,8 +213,12 @@ def export_project_dialog(self, tracker_format: TrackerFormat) -> None: title=self._title(elements.dialog_title), initial_directory=get_directory(path), default_filename=self._get_project_filename(extension), - extensions=[extension], - filter_name=self._filter_name(elements.filter_name), + filters=( + FileFilter.for_extensions( + self._filter_name(elements.filter_name), + [extension], + ), + ), ) self._handle_export_project(filepath, tracker_format) @@ -214,8 +227,7 @@ def _open_dialog(self) -> None: filepath = open_file_dialog( title=self._title(GlobalDialogTitleElements.OPEN_UNSAVED_PROJECT), initial_directory=self._session_manager.get_project_path(), - extensions=[EXT_FILE_PROJECT], - filter_name=self._filter_name(FileFilterElements.PROJECT), + filters=self._project_filters(), ) self._handle_open(filepath) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 5edf82aa..99d72b03 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Optional, Tuple from sampletones_application.categories.elements.global_ import ( DialogElements, @@ -33,6 +33,7 @@ open_file_dialog, save_file_dialog, ) +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.audio import AudioDeviceManager @@ -156,17 +157,25 @@ def save_as_dialog(self) -> None: ], initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_RECONSTRUCTION], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.RECONSTRUCTION, - ], + filters=self._reconstruction_filters(), ) self._handle_save_as(filepath) + def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: + """The single type a reconstruction is written as and read from.""" + return ( + FileFilter.for_extensions( + self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.RECONSTRUCTION, + ], + [EXT_FILE_RECONSTRUCTION], + ), + ) + @ignore_none_path def _handle_save_as(self, filepath: Path) -> None: try: @@ -215,13 +224,7 @@ def _load_dialog(self) -> None: ReconstructionsBrowserElements.LOAD_RECONSTRUCTION_DIALOG, ], initial_directory=self._session_manager.get_reconstruction_path(), - extensions=[EXT_FILE_RECONSTRUCTION], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.RECONSTRUCTION, - ], + filters=self._reconstruction_filters(), ) self._handle_load(filepath) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 5aedc220..86b75898 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, Optional +from typing import Callable, Dict, Optional, Tuple import dearpygui.dearpygui as dpg @@ -18,7 +18,10 @@ from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_FILTERS +from sampletones_application.categories.trackers import ( + INSTRUMENT_EXPORT_FORMATS, + TRACKER_INSTRUMENT_FILTERS, +) from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.original_audio import OriginalAudioLocator @@ -46,6 +49,7 @@ SUF_PANEL_CENTER, SUF_PANEL_LEFT, SUF_PANEL_RIGHT, + TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION, TAG_GLOBAL_TAB_RECONSTRUCTION, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -73,6 +77,7 @@ GUIReconstructionPlotPanel, ) from sampletones_application.utils.file_dialogs.api import save_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item @@ -228,7 +233,13 @@ def __init__( TextType.TITLE, ReconstructionsInstrumentsElements.EXPORT_INSTRUMENTS_DIALOG, ] - self._filters_export_instrument: Dict[TrackerFormat, str] = { + self._filter_export_wav = language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.WAVE, + ] + self._instrument_filter_names: Dict[TrackerFormat, str] = { tracker_format: language_manager[ Page.GLOBAL, Panel.DIALOG, @@ -237,12 +248,6 @@ def __init__( ] for tracker_format, element in TRACKER_INSTRUMENT_FILTERS.items() } - self._filter_export_wav = language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.WAVE, - ] self._msg_locate_audio_failed = language_manager[ Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, @@ -349,6 +354,7 @@ def __init__( self._reconstruction_panel_logic.on_open_export_instrument_dialog = self._open_export_instrument_dialog self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog + self._reconstruction_panel_logic.on_unsupported_export_extension = self._show_unsupported_extension self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found( path, self._msg_locate_audio_failed ) @@ -457,18 +463,28 @@ def _open_export_instrument_dialog( self, default_filename: str, default_path: str, - tracker_format: TrackerFormat, ) -> None: - backend = self._tracker_backends[tracker_format] + """Prompts for the file one generator slice is written to. + + Every format that writes a single slice is offered at once, so the extension the + destination is given names the tracker it is written for. + """ filepath = save_file_dialog( title=self._ttl_export_instrument, initial_directory=default_path, default_filename=default_filename, - extensions=[backend.extension(ExportScope.INSTRUMENT)], - filter_name=self._filters_export_instrument[tracker_format], + filters=self._instrument_filters(ExportScope.INSTRUMENT), ) self._handle_export_instrument(filepath) + def _instrument_filters(self, scope: ExportScope) -> Tuple[FileFilter, ...]: + """The types a destination for ``scope`` may be given, one per tracker offered. + + Naming each tracker's own type puts the trackers an export can reach in the dialog's + type selector, so the one that is picked there names the format. + """ + return tuple(self._tracker_filter(tracker_format, scope) for tracker_format in INSTRUMENT_EXPORT_FORMATS) + @ignore_none_path def _handle_export_instrument(self, filepath: Path) -> None: self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath) @@ -481,30 +497,67 @@ def _open_export_instruments_dialog( ) -> None: """Prompts for the destination the loaded reconstruction's slices are named after. - A format that gathers the whole reconstruction into one document writes it there, - while one that keeps an instrument per file writes its slices beside it. + The tracker was chosen with the action, so the dialog offers its file type alone: a + format that gathers the whole reconstruction into one document writes it at the + destination, while one that keeps an instrument per file writes its slices beside it. """ - backend = self._tracker_backends[tracker_format] destination = save_file_dialog( title=self._ttl_export_instruments, initial_directory=default_path, default_filename=default_filename, - extensions=[backend.extension(ExportScope.SAMPLE)], - filter_name=self._filters_export_instrument[tracker_format], + filters=(self._tracker_filter(tracker_format, ExportScope.SAMPLE),), + ) + self._handle_export_instruments(destination, tracker_format) + + def _tracker_filter( + self, + tracker_format: TrackerFormat, + scope: ExportScope, + ) -> FileFilter: + """The type ``tracker_format`` writes ``scope`` files as, named after that tracker.""" + return FileFilter.for_extensions( + self._instrument_filter_names[tracker_format], + [self._tracker_backends[tracker_format].extension(scope)], + ) + + def _show_unsupported_extension( + self, + extension: str, + supported: Tuple[str, ...], + ) -> None: + """Reports that the destination's extension names no tracker format. + + The extension decides which tracker an export is written for, so one no format + claims leaves nothing to write. The dialog names what the export accepts, and a + destination given no extension at all is told so directly. + """ + messages = self._export_messages + extensions = ", ".join(supported) + message = ( + messages.unsupported_extension.format(extension=extension, extensions=extensions) + if extension + else messages.missing_extension.format(extensions=extensions) + ) + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION, + message, + messages.unsupported_extension_title, ) - self._handle_export_instruments(destination) @ignore_none_path - def _handle_export_instruments(self, destination: Path) -> None: - self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination) + def _handle_export_instruments( + self, + destination: Path, + tracker_format: TrackerFormat, + ) -> None: + self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, tracker_format) def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None: filepath = save_file_dialog( title=self._export_messages.wav_title, initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_WAVE], - filter_name=self._filter_export_wav, + filters=(FileFilter.for_extensions(self._filter_export_wav, [EXT_FILE_WAVE]),), ) self._handle_export_wav(filepath) diff --git a/src/sampletones_application/logic/reconstruction/pending.py b/src/sampletones_application/logic/reconstruction/pending.py deleted file mode 100644 index bcfb0209..00000000 --- a/src/sampletones_application/logic/reconstruction/pending.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass - -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.trackers.format import TrackerFormat - - -@dataclass(frozen=True) -class PendingInstrumentExport: - """The generator slice and target format awaiting a destination from the file dialog. - - The user picks what to export before picking where it goes, so the choice is held - here until the dialog answers with a path. - - Attributes: - generator: The slice the export writes. - tracker_format: The format the slice is written in. - """ - - generator: GeneratorName - tracker_format: TrackerFormat diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 3a8047ef..6341de0d 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -6,7 +6,6 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.logic.reconstruction.pending import PendingInstrumentExport from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionPathState, ReconstructionPathViewModel, @@ -18,12 +17,17 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.extensions import ( + format_for_extension, + scope_extensions, +) from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.system.paths import open_path_in_explorer +from sampletones_shared.utils.system.paths import get_filename, open_path_in_explorer class ExportServiceProtocol(Protocol): @@ -70,8 +74,7 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._selected_generators: List[GeneratorName] = [] - self._pending_instrument: Optional[PendingInstrumentExport] = None - self._pending_sample_format: Optional[TrackerFormat] = None + self._pending_generator: Optional[GeneratorName] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -80,9 +83,10 @@ def __init__( self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None + self.on_open_export_instrument_dialog: Optional[Callable[[str, str], None]] = None self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None + self.on_unsupported_export_extension: Optional[Callable[[str, Tuple[str, ...]], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None @@ -165,11 +169,14 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: ) self._emit_audio_data() - def request_export_instrument_dialog( - self, - generator_name: GeneratorName, - tracker_format: TrackerFormat, - ) -> None: + def request_export_instrument_dialog(self, generator_name: GeneratorName) -> None: + """Asks for the destination one generator slice is written to. + + Every tracker able to write a single slice is offered at once, so the generator alone + waits here until the dialog answers with a path. The suggestion is the instrument's + name on its own, leaving the tracker to the dialog's file-type selector and to any + extension typed over it. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") @@ -181,28 +188,32 @@ def request_export_instrument_dialog( instrument_name = self._get_instrument_name(generator_name) default_path = str(self._session_manager.get_instrument_path()) - self._pending_instrument = PendingInstrumentExport( - generator=generator_name, - tracker_format=tracker_format, - ) + self._pending_generator = generator_name self.call( self.on_open_export_instrument_dialog, instrument_name, default_path, - tracker_format, ) def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: + """Asks for the destination the loaded reconstruction's slices are named after. + + The tracker comes from the action that was chosen, so the dialog offers that + tracker's file type alone and the suggestion already ends in its extension. + + Args: + tracker_format: The tracker the slices are written for. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") default_path = str(self._session_manager.get_instrument_path()) + extension = self._tracker_backends[tracker_format].extension(ExportScope.SAMPLE) - self._pending_sample_format = tracker_format self.call( self.on_open_export_instruments_dialog, - reconstruction_data.name, + get_filename(reconstruction_data.name, extension), default_path, tracker_format, ) @@ -220,35 +231,46 @@ def request_export_wav_dialog(self) -> None: def handle_export_instrument_confirmed(self, filepath: Path) -> None: """Writes one generator slice of the loaded reconstruction to ``filepath``. - The instrument carries the name the destination was saved under, so renaming the - file in the dialog renames the instrument the tracker lists. + The extension picks the tracker the slice is written for, and the instrument carries + the name the destination was saved under, so renaming the file in the dialog renames + the instrument the tracker lists. """ - pending = self._pending_instrument - self._pending_instrument = None - if not self._reconstruction_data or pending is None: + generator = self._pending_generator + self._pending_generator = None + if not self._reconstruction_data or generator is None: logger.warning("No reconstruction data available for instrument export") return - feature = self._reconstruction_data.feature_data[pending.generator] + tracker_format = self._resolve_tracker_format(filepath, ExportScope.INSTRUMENT) + if tracker_format is None: + return + + feature = self._reconstruction_data.feature_data[generator] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, - self._tracker_backends[pending.tracker_format], - self._instrument_export(pending.generator, feature, filepath.stem), + self._tracker_backends[tracker_format], + self._instrument_export(generator, feature, filepath.stem), ) - def handle_export_instruments_confirmed(self, destination: Path) -> None: + def handle_export_instruments_confirmed( + self, + destination: Path, + tracker_format: TrackerFormat, + ) -> None: """Writes every generator slice of the loaded reconstruction to ``destination``. - The destination names the batch: each slice takes its generator suffix from the - stem, so a format gathering the whole reconstruction into one document writes it - there while one keeping an instrument per file writes its slices beside it. + The destination names the batch: each slice takes its generator suffix from the stem, + so a format gathering the whole reconstruction into one document writes it there while + one keeping an instrument per file writes its slices beside it. + + Args: + destination: The file the export was confirmed with. + tracker_format: The tracker the slices are written for. """ reconstruction_data = self._reconstruction_data - tracker_format = self._pending_sample_format - self._pending_sample_format = None - if not reconstruction_data or tracker_format is None: + if not reconstruction_data: logger.warning("No reconstruction data available for instruments export") return @@ -268,6 +290,36 @@ def handle_export_instruments_confirmed(self, destination: Path) -> None: self._session_manager.set_instrument_path(destination.parent) self._export_service.export_sample(destination, self._tracker_backends[tracker_format], request) + def _resolve_tracker_format( + self, + destination: Path, + scope: ExportScope, + ) -> Optional[TrackerFormat]: + """Reads the tracker format out of the destination's extension. + + Reports an extension no format claims through + :attr:`on_unsupported_export_extension`, naming what the scope accepts so the user + can name the destination again. + + Args: + destination: The destination the export was confirmed with. + scope: The scope about to be written. + + Returns: + Optional[TrackerFormat]: The format to write in, or ``None`` when the extension + names none. + """ + tracker_format = format_for_extension(self._tracker_backends, scope, destination.suffix) + if tracker_format is None: + logger.warning(f"No tracker format writes '{destination.suffix}' for a {scope} export") + self.call( + self.on_unsupported_export_extension, + destination.suffix, + scope_extensions(self._tracker_backends, scope), + ) + + return tracker_format + def _instrument_export( self, generator_name: GeneratorName, diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index f2573824..e24a42d9 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -115,9 +115,13 @@ def _submit( ) -> None: """Runs one backend write on the executor and reports what it produced. + The result reports a path the run actually wrote, so the dialog announcing it opens + a file that is there: a batch naming its slices after the destination writes those + slices rather than the destination itself. + Args: kind: The artefact the run produces, naming the dialog that reports it. - destination: The file written, or the directory a batch of instruments filled. + destination: The destination the run was given. tracker_format: The format the run writes, carried through to the result. write: Calls the backend and returns what it left on disk. """ @@ -127,10 +131,11 @@ def task() -> None: artifact = write() for path in artifact.paths: logger.info(f"Exported {kind.value}: {logger.format_path(path)}") + self._emit( ExportSuccess( kind=kind, - filepath=destination, + filepath=artifact.paths[0] if artifact.paths else destination, tracker_format=tracker_format, truncation=artifact.truncation, ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index c78b10c1..502a2a94 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -13,7 +13,7 @@ class ExportSuccess: Attributes: kind: The artefact the run produced. - filepath: The file written, or the directory a batch of instruments filled. + filepath: A file the run wrote, which a batch reports as the first of its slices. tracker_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. diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index cd2bc8b1..f7f54a8b 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -79,7 +79,6 @@ } _SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_I, CTRL), - TrackerFormat.BITPHASE: Shortcut(), TrackerFormat.BITPHASE_PRESET: Shortcut(), } diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index f075d6f5..0a19a52a 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -362,6 +362,12 @@ Widget.DIALOG, "path_message", ) +TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "unsupported_extension", +) TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 46f7ebb0..5ede9c32 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -346,9 +346,8 @@ def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: """Builds the submenu that writes the loaded reconstruction's slices for one tracker. - A format that gathers every slice into one document and one that writes a file per - slice are listed together, since the choice between them is the user's; the - destination dialog then asks for whichever the chosen format fills. + Each format able to write a file per slice gets its own item, so choosing the tracker + is one click and the destination dialog then offers that tracker's file type alone. """ with dpg.menu( tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index ae17f409..a5270976 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -15,7 +15,6 @@ from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import build_pitch_tooltip -from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_LABELS from sampletones_application.constants.global_ import TAG_SEPARATOR from sampletones_application.layout.general.colors import FeatureColors from sampletones_application.layout.graphs import GraphsLayout @@ -73,7 +72,6 @@ from sampletones_core.exporters import Features from sampletones_core.features import GENERATOR_KIND, supported_features from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS -from sampletones_core.trackers.format import TrackerFormat from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, @@ -81,9 +79,10 @@ ) from sampletones_shared.logger import logger from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp -OnInstrumentExportCallback = Callable[[GeneratorName, TrackerFormat], None] +OnInstrumentExportCallback = Callable[[GeneratorName], None] OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] @@ -141,15 +140,6 @@ def __init__( TextType.LABEL, ReconstructionsInstrumentsElements.EXPORT_INSTRUMENT_BUTTON, ] - self._lbl_export_formats: Dict[TrackerFormat, str] = { - tracker_format: language_manager[ - Page.RECONSTRUCTIONS, - Panel.INSTRUMENTS, - TextType.LABEL, - element, - ] - for tracker_format, element in TRACKER_INSTRUMENT_LABELS.items() - } self._lbl_copy = language_manager[ Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, @@ -339,14 +329,13 @@ def _setup_mouse_event_handler(self) -> None: with dpg.handler_registry(tag=self.mouse_item_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_move) - def _handle_export_format_selected( - self, - sender: Sender, - app_data: Any, - user_data: Tuple[GeneratorName, TrackerFormat], - ) -> None: - generator_name, tracker_format = user_data - self.call(self.on_instrument_export, generator_name, tracker_format) + def _export_callback(self, generator_name: GeneratorName) -> VoidCallback: + """The press handler for one generator's export button. + + DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it, so + the generator is captured in a closure, which carries one. + """ + return lambda: self.call(self.on_instrument_export, generator_name) def _create_tabs_for_generators(self) -> None: for generator_name in GeneratorName.items(): @@ -376,13 +365,13 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ): self.generator_plots[generator_name] = {} button_tag = f"{TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT}{TAG_SEPARATOR}{tab_tag}" - button = GUIButton( + GUIButton( tag=button_tag, parent=tab_tag, label=self._lbl_export_instrument, width=-1, + callback=self._export_callback(generator_name), ) - self._create_export_formats_popup(button.button_tag, generator_name) self._status_bar.bind_to_item( button_tag, self._msg_export_instrument.format(generator=self._generator_labels[generator_name]), @@ -399,24 +388,6 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_INSTRUMENT_TABS).bind_to_item(tab_tag) - def _create_export_formats_popup( - self, - button_tag: str, - generator_name: GeneratorName, - ) -> None: - """Hangs the format choice off the export button, opening where the button sits. - - One slice reaches several trackers, so the button asks which one before a destination - is picked, and the chosen format travels with the generator to the export request. - """ - with dpg.popup(button_tag, mousebutton=dpg.mvMouseButton_Left): - for tracker_format, label in self._lbl_export_formats.items(): - dpg.add_menu_item( - label=label, - callback=self._handle_export_format_selected, - user_data=(generator_name, tracker_format), - ) - def _create_generator_content( self, generator_name: GeneratorName, diff --git a/src/sampletones_application/utils/file_dialogs/api.py b/src/sampletones_application/utils/file_dialogs/api.py index 5fde1764..2ae187c0 100644 --- a/src/sampletones_application/utils/file_dialogs/api.py +++ b/src/sampletones_application/utils/file_dialogs/api.py @@ -1,10 +1,9 @@ +from itertools import chain from pathlib import Path -from typing import Iterable, Optional, Tuple +from typing import Optional, Tuple -from sampletones_application.utils.file_dialogs.filter import ( - FileFilter, - normalize_extensions, -) +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.selection import ( select_file_dialog_backend, ) @@ -16,14 +15,13 @@ def open_file_dialog( *, title: str, initial_directory: Optional[Pathlike] = None, - extensions: Iterable[str] = (), - filter_name: Optional[str] = None, + filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: backend = select_file_dialog_backend() return backend.open_file( title=title, initial_directory=_optional_path(initial_directory), - file_filter=_build_filter(normalize_extensions(extensions), filter_name), + filters=filters, ) @@ -32,25 +30,27 @@ def save_file_dialog( title: str, initial_directory: Optional[Pathlike] = None, default_filename: Optional[str] = None, - extensions: Iterable[str] = (), - filter_name: Optional[str] = None, + filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: - patterns = normalize_extensions(extensions) + """ + Asks for a destination to save to, yielding ``None`` once the dialog is dismissed. + + The answer carries one of the offered extensions, so a caller receives a destination it + can write straight away and a caller reading the format out of the extension always + finds one. ``filters`` is ordered, and its first type is the one the dialog opens on. + """ backend = select_file_dialog_backend() - path = backend.save_file( + destination = backend.save_file( title=title, initial_directory=_optional_path(initial_directory), suggested_name=default_filename, - file_filter=_build_filter(patterns, filter_name), + filters=filters, ) - if path is None: + if destination is None: return None - if len(patterns) == 1: - path = ensure_suffix(path, patterns[0].removeprefix("*")) - - return path + return _with_offered_extension(destination, filters) def select_directory_dialog( @@ -65,15 +65,51 @@ def select_directory_dialog( ) -def _optional_path(value: Optional[Pathlike]) -> Optional[Path]: - return to_path(value) if value is not None else None +def _with_offered_extension( + destination: SaveDestination, + filters: Tuple[FileFilter, ...], +) -> Path: + """ + Returns the destination's path ending in an extension one of the offered types accepts. + A name already carrying one of those extensions stands as it is, so typing an extension is + how a type is named where a dialog reports none. Any other name takes the extension of the + governing type: the one the dialog reported for a dialog whose selector carries the choice, + and otherwise the type the dialog opened on. + """ + offered = _offered_extensions(filters) + if not offered: + return destination.path -def _build_filter( - patterns: Tuple[str, ...], - filter_name: Optional[str], -) -> Optional[FileFilter]: - if not patterns: - return None + if _carries_one_of(destination.path, offered): + return destination.path + + return ensure_suffix(destination.path, _governing_extension(destination.file_type, offered)) + + +def _governing_extension( + file_type: Optional[FileFilter], + offered: Tuple[str, ...], +) -> str: + """The extension a name carrying none of the offered ones is saved under.""" + if file_type is not None and file_type.extensions: + return file_type.extensions[0] - return FileFilter(name=filter_name or "", patterns=patterns) + return offered[0] + + +def _carries_one_of( + path: Path, + extensions: Tuple[str, ...], +) -> bool: + name = path.name.lower() + return any(name.endswith(extension.lower()) for extension in extensions) + + +def _offered_extensions(filters: Tuple[FileFilter, ...]) -> Tuple[str, ...]: + """The extensions every offered type accepts, in the order the types are shown.""" + return tuple(chain.from_iterable(file_filter.extensions for file_filter in filters)) + + +def _optional_path(value: Optional[Pathlike]) -> Optional[Path]: + return to_path(value) if value is not None else None diff --git a/src/sampletones_application/utils/file_dialogs/backend.py b/src/sampletones_application/utils/file_dialogs/backend.py index 3bdf5c76..95e80f35 100644 --- a/src/sampletones_application/utils/file_dialogs/backend.py +++ b/src/sampletones_application/utils/file_dialogs/backend.py @@ -1,6 +1,7 @@ from pathlib import Path -from typing import Optional, Protocol +from typing import Optional, Protocol, Tuple +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter @@ -8,9 +9,15 @@ class FileDialogBackend(Protocol): """ A native file-dialog implementation for one platform or desktop tool. - An implementation drives a system dialog (kdialog, zenity) or ``tkinter`` and - returns the chosen path, yielding ``None`` when the user cancels. The selector in - ``selection`` picks the implementation that fits the running environment. + An implementation drives a system dialog (the desktop portal, kdialog, zenity) or + ``tkinter`` and returns the chosen path, yielding ``None`` when the user cancels. The + selector in ``selection`` picks the implementation that fits the running environment. + + ``filters`` carries the types the dialog offers, in the order they are shown. Each + implementation renders as many of them as its dialog accepts, so a caller states the + types it writes once and every backend shows what it can. A save answers with a + ``SaveDestination``, which carries the type the user selected for implementations whose + dialog reports it. """ def open_file( @@ -18,7 +25,7 @@ def open_file( *, title: str, initial_directory: Optional[Path], - file_filter: Optional[FileFilter], + filters: Tuple[FileFilter, ...], ) -> Optional[Path]: ... def save_file( @@ -27,8 +34,8 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: ... + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: ... def select_directory( self, diff --git a/src/sampletones_application/utils/file_dialogs/destination.py b/src/sampletones_application/utils/file_dialogs/destination.py new file mode 100644 index 00000000..09ddcb06 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/destination.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +@dataclass(frozen=True) +class SaveDestination: + """ + Where a save dialog was told to write, and the file type it was chosen under. + + A dialog whose type selector reports the active type carries it in ``file_type``, one of the + types the dialog was asked to offer, which is what lets the API layer settle the extension + from the type the user picked. A dialog that answers with a name alone leaves it ``None``, + and the extension then follows from the name and the type the dialog opened on. + """ + + path: Path + file_type: Optional[FileFilter] + + +def untyped_destination(path: Optional[Path]) -> Optional[SaveDestination]: + """ + Returns the destination a dialog answering with a name alone gives, for the name it answered. + + Args: + path: The name the dialog answered with, or ``None`` once it was dismissed. + + Returns: + Optional[SaveDestination]: The destination carrying that name, or ``None`` for a + dismissed dialog. + """ + if path is None: + return None + + return SaveDestination(path=path, file_type=None) diff --git a/src/sampletones_application/utils/file_dialogs/filter.py b/src/sampletones_application/utils/file_dialogs/filter.py index 2dbfe09a..beec2bdb 100644 --- a/src/sampletones_application/utils/file_dialogs/filter.py +++ b/src/sampletones_application/utils/file_dialogs/filter.py @@ -1,11 +1,12 @@ from dataclasses import dataclass -from typing import Iterable, Tuple +from itertools import chain +from typing import Iterable, Optional, Tuple @dataclass(frozen=True) class FileFilter: """ - An extension filter offered by a native file dialog. + One file type offered by a native file dialog. Carries a human-readable ``name`` and the ``*``-prefixed glob ``patterns`` it matches. Each backend renders these into its own filter syntax; ``label`` is the @@ -15,6 +16,32 @@ class FileFilter: name: str patterns: Tuple[str, ...] + @classmethod + def for_extensions( + cls, + name: str, + extensions: Iterable[str], + ) -> "FileFilter": + """ + Returns the type matching ``extensions``, shown under ``name``. + + Accepts bare or already-globbed extensions, so a caller names the extensions it + writes and the glob form stays an implementation detail of the dialog layer. + + Args: + name: The human-readable name the dialog shows this type under. + extensions: The extensions the type matches, leading dot included. + + Returns: + FileFilter: The type a dialog offers for those extensions. + """ + return cls(name=name, patterns=normalize_extensions(extensions)) + + @property + def extensions(self) -> Tuple[str, ...]: + """The extensions this type matches, leading dot included.""" + return tuple(pattern.removeprefix("*") for pattern in self.patterns) + @property def label(self) -> str: """ @@ -39,3 +66,29 @@ def normalize_extensions(extensions: Iterable[str]) -> Tuple[str, ...]: ``"*.stp"`` for each, so every backend receives a uniform pattern form. """ return tuple(f"*{extension.removeprefix('*')}" for extension in extensions) + + +def merge_filters(filters: Tuple[FileFilter, ...]) -> Optional[FileFilter]: + """ + Returns the one type a dialog limited to a single filter offers. + + A dialog that takes one filter still accepts every type: the names join into one label + and the patterns gather behind it, so each accepted extension is named on screen and + every matching file stays reachable in the browser. One type passes through as it is, + which is the form that lets a dialog fill its extension in on its own. + + Args: + filters: The types the dialog was asked to offer. + + Returns: + Optional[FileFilter]: The single type to offer, or ``None`` when none was asked for. + """ + if not filters: + return None + + if len(filters) == 1: + return filters[0] + + names = ", ".join(file_filter.name for file_filter in filters if file_filter.name) + patterns = chain.from_iterable(file_filter.patterns for file_filter in filters) + return FileFilter(name=names, patterns=tuple(dict.fromkeys(patterns))) diff --git a/src/sampletones_application/utils/file_dialogs/kdialog.py b/src/sampletones_application/utils/file_dialogs/kdialog.py index ed9faf42..00e2b986 100644 --- a/src/sampletones_application/utils/file_dialogs/kdialog.py +++ b/src/sampletones_application/utils/file_dialogs/kdialog.py @@ -1,8 +1,12 @@ import subprocess from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Tuple -from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter, merge_filters from sampletones_shared.utils.system.paths import normalize_path @@ -11,7 +15,9 @@ class KDialogBackend: File dialogs backed by KDE's ``kdialog`` (Qt). ``kdialog`` activates the supplied filter, so the file-type selector opens on the - chosen type. + chosen type. Its command line carries one filter, so offering a single type hands KDE + a lone pattern and its own extension checkbox fills that extension in; several types + gather into one filter whose label names each of them. """ def open_file( @@ -19,14 +25,14 @@ def open_file( *, title: str, initial_directory: Optional[Path], - file_filter: Optional[FileFilter], + filters: Tuple[FileFilter, ...], ) -> Optional[Path]: command = [ "kdialog", "--getopenfilename", _start_location(initial_directory), ] - command += _filter_arguments(file_filter) + command += _filter_arguments(filters) command += ["--title", title] return _run(command) @@ -36,8 +42,8 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: command = [ "kdialog", "--getsavefilename", @@ -46,9 +52,9 @@ def save_file( suggested_name, ), ] - command += _filter_arguments(file_filter) + command += _filter_arguments(filters) command += ["--title", title] - return _run(command) + return untyped_destination(_run(command)) def select_directory( self, @@ -77,12 +83,13 @@ def _start_location( return str(base) -def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: - if file_filter is None: +def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + merged = merge_filters(filters) + if merged is None: return [] - patterns = " ".join(file_filter.patterns) - return [f"{patterns}|{file_filter.label}"] + patterns = " ".join(merged.patterns) + return [f"{patterns}|{merged.label}"] def _run(command: List[str]) -> Optional[Path]: diff --git a/src/sampletones_application/utils/file_dialogs/portal/__init__.py b/src/sampletones_application/utils/file_dialogs/portal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/utils/file_dialogs/portal/backend.py b/src/sampletones_application/utils/file_dialogs/portal/backend.py new file mode 100644 index 00000000..95e170c0 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/portal/backend.py @@ -0,0 +1,211 @@ +from functools import lru_cache +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple +from urllib.parse import unquote, urlparse + +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.portal.client import ( + ChooserResult, + FileChooserClient, + Variant, +) + +OPEN_FILE_METHOD: Final[str] = "OpenFile" +SAVE_FILE_METHOD: Final[str] = "SaveFile" + +FILTERS_OPTION: Final[str] = "filters" +CURRENT_FILTER_OPTION: Final[str] = "current_filter" +CURRENT_NAME_OPTION: Final[str] = "current_name" +CURRENT_FOLDER_OPTION: Final[str] = "current_folder" +DIRECTORY_OPTION: Final[str] = "directory" + +FILTER_SIGNATURE: Final[str] = "(sa(us))" +FILTERS_SIGNATURE: Final[str] = f"a{FILTER_SIGNATURE}" +STRING_SIGNATURE: Final[str] = "s" +BYTES_SIGNATURE: Final[str] = "ay" +BOOLEAN_SIGNATURE: Final[str] = "b" + +GLOB_PATTERN: Final[int] = 0 +"""The portal's kind for a filter pattern written as a shell glob.""" + +FILE_SCHEME: Final[str] = "file" +PATH_TERMINATOR: Final[bytes] = b"\0" + +MINIMUM_FILE_CHOOSER_VERSION: Final[int] = 3 +"""The version reporting the chosen type and accepting a folder to open in.""" + +PortalFilter = Tuple[str, List[Tuple[int, str]]] + + +class PortalBackend: + """ + File dialogs opened through the XDG desktop portal. + + The portal hands each request to the desktop's own file chooser, so a dialog looks and + behaves as the rest of the desktop does. Every offered type reaches the file-type selector + as its own entry and the response names the entry the user left it on, which is what lets a + save settle its extension from the type that was picked rather than the name that was typed. + """ + + def __init__(self, client: FileChooserClient) -> None: + self._client = client + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + result = self._client.call( + method=OPEN_FILE_METHOD, + title=title, + options=_open_options( + initial_directory, + filters, + ), + ) + return _chosen_path(result) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + result = self._client.call( + method=SAVE_FILE_METHOD, + title=title, + options=_save_options( + initial_directory, + suggested_name, + filters, + ), + ) + path = _chosen_path(result) + if result is None or path is None: + return None + + return SaveDestination( + path=path, + file_type=_reported_type(result, filters), + ) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + result = self._client.call( + method=OPEN_FILE_METHOD, + title=title, + options=_directory_options(initial_directory), + ) + return _chosen_path(result) + + +@lru_cache(maxsize=1) +def portal_backend() -> Optional[PortalBackend]: + """ + Returns portal-backed dialogs once a portal implementing ``FileChooser`` answers on the bus. + + The answer holds for the life of the process, since a desktop either runs a portal or leaves + dialogs to another backend, so every dialog after the first opens with no further round trip. + """ + client = FileChooserClient() + version = client.version() + if version is None or version < MINIMUM_FILE_CHOOSER_VERSION: + return None + + return PortalBackend(client) + + +def _open_options( + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], +) -> Dict[str, Variant]: + return { + **_folder_option(initial_directory), + **_filter_options(filters), + } + + +def _save_options( + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], +) -> Dict[str, Variant]: + options: Dict[str, Variant] = { + **_folder_option(initial_directory), + **_filter_options(filters), + } + if suggested_name: + options[CURRENT_NAME_OPTION] = (STRING_SIGNATURE, suggested_name) + + return options + + +def _directory_options(initial_directory: Optional[Path]) -> Dict[str, Variant]: + return { + **_folder_option(initial_directory), + DIRECTORY_OPTION: (BOOLEAN_SIGNATURE, True), + } + + +def _folder_option(initial_directory: Optional[Path]) -> Dict[str, Variant]: + """The folder the dialog opens in, as the NUL-terminated byte string the portal reads.""" + if initial_directory is None: + return {} + + encoded = str(initial_directory).encode() + PATH_TERMINATOR + return {CURRENT_FOLDER_OPTION: (BYTES_SIGNATURE, encoded)} + + +def _filter_options(filters: Tuple[FileFilter, ...]) -> Dict[str, Variant]: + """ + The types the selector lists, and the one it opens on. + + Naming the first type as the current one opens the dialog on the type a caller offers first, + matching the extension a suggested name carries. + """ + if not filters: + return {} + + listed = [_portal_filter(file_filter) for file_filter in filters] + return { + FILTERS_OPTION: (FILTERS_SIGNATURE, listed), + CURRENT_FILTER_OPTION: (FILTER_SIGNATURE, listed[0]), + } + + +def _portal_filter(file_filter: FileFilter) -> PortalFilter: + patterns = [(GLOB_PATTERN, pattern) for pattern in file_filter.patterns] + return (file_filter.label, patterns) + + +def _reported_type( + result: ChooserResult, + filters: Tuple[FileFilter, ...], +) -> Optional[FileFilter]: + """The offered type whose label the dialog reported, for a portal implementation reporting one.""" + for file_filter in filters: + if file_filter.label == result.filter_label: + return file_filter + + return None + + +def _chosen_path(result: Optional[ChooserResult]) -> Optional[Path]: + """The local path the dialog answered with, for the ``file`` locations the portal hands back.""" + if result is None or not result.uris: + return None + + location = urlparse(result.uris[0]) + if location.scheme != FILE_SCHEME: + return None + + return Path(unquote(location.path)) diff --git a/src/sampletones_application/utils/file_dialogs/portal/client.py b/src/sampletones_application/utils/file_dialogs/portal/client.py new file mode 100644 index 00000000..fb7de6c4 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/portal/client.py @@ -0,0 +1,164 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Tuple, Type, cast + +from jeepney import ( + AuthenticationError, + DBusAddress, + DBusErrorResponse, + HeaderFields, + MatchRule, + Properties, + message_bus, + new_method_call, +) +from jeepney.io.blocking import open_dbus_connection, unwrap_msg +from jeepney.low_level import Message + +Variant = Tuple[str, object] +"""A D-Bus variant as jeepney represents it: the value's signature, then the value.""" + +SESSION_BUS: Final[str] = "SESSION" +PORTAL_BUS_NAME: Final[str] = "org.freedesktop.portal.Desktop" +PORTAL_OBJECT_PATH: Final[str] = "/org/freedesktop/portal/desktop" +FILE_CHOOSER_INTERFACE: Final[str] = "org.freedesktop.portal.FileChooser" +REQUEST_INTERFACE: Final[str] = "org.freedesktop.portal.Request" +RESPONSE_SIGNAL: Final[str] = "Response" +VERSION_PROPERTY: Final[str] = "version" + +CALL_SIGNATURE: Final[str] = "ssa{sv}" +PARENT_WINDOW: Final[str] = "" +URIS_RESULT: Final[str] = "uris" +CURRENT_FILTER_RESULT: Final[str] = "current_filter" +SUCCESS_CODE: Final[int] = 0 + +PORTAL_OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = ( + KeyError, # the session bus address is absent from the environment + RuntimeError, # the address names a transport jeepney speaks no dialect of + OSError, # the socket the address names refused the connection + AuthenticationError, + DBusErrorResponse, # the bus answers, and no portal claims the interface +) + +FILE_CHOOSER: Final[DBusAddress] = DBusAddress( + PORTAL_OBJECT_PATH, + bus_name=PORTAL_BUS_NAME, + interface=FILE_CHOOSER_INTERFACE, +) + + +@dataclass(frozen=True) +class ChooserResult: + """ + What a file-chooser dialog answered with. + + ``uris`` carries the chosen locations in the dialog's own order. ``filter_label`` is the + label of the type its selector stood on, present for a portal implementation that reports + the selection. + """ + + uris: Tuple[str, ...] + filter_label: Optional[str] + + +class FileChooserClient: + """ + The desktop portal's ``FileChooser`` interface, reached over the session bus. + + A call asks the portal for a dialog and answers once the user closes it. The portal replies + to the call with the object path of a request and delivers the outcome as a signal on that + path, so each call subscribes to the signal before asking and then waits for the response + belonging to its own request. Every dialog runs in the desktop's own portal implementation, + which is what makes the file-type selector and the type it reports available at all. + """ + + def version(self) -> Optional[int]: + """ + Returns the ``FileChooser`` version the portal on the session bus implements. + + Answers ``None`` where the session bus is out of reach or no portal claims the + interface, which is the environment's way of saying dialogs belong to another backend. + """ + try: + with open_dbus_connection(bus=SESSION_BUS) as connection: + reply = connection.send_and_get_reply(Properties(FILE_CHOOSER).get(VERSION_PROPERTY)) + (version,) = cast(Tuple[Variant], unwrap_msg(reply)) + except PORTAL_OUT_OF_REACH_ERRORS: + return None + + return cast(int, version[1]) + + def call( + self, + *, + method: str, + title: str, + options: Dict[str, Variant], + ) -> Optional[ChooserResult]: + """ + Opens the dialog ``method`` names and waits for the user to answer it. + + Args: + method: The ``FileChooser`` method to call, naming the kind of dialog to open. + title: The window title the dialog carries. + options: The portal options for that method, each value a D-Bus variant. + + Returns: + Optional[ChooserResult]: What the dialog answered, or ``None`` once it was dismissed. + """ + rule = MatchRule( + type="signal", + interface=REQUEST_INTERFACE, + member=RESPONSE_SIGNAL, + ) + request = new_method_call( + FILE_CHOOSER, + method, + CALL_SIGNATURE, + ( + PARENT_WINDOW, + title, + options, + ), + ) + + with open_dbus_connection(bus=SESSION_BUS) as connection: + with connection.filter(rule) as responses: + connection.send_and_get_reply(message_bus.AddMatch(rule)) + (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request))) + while True: + response = connection.recv_until_filtered(responses) + if _signal_path(response) == handle: + return _read_response(response) + + +def _read_response(response: Message) -> Optional[ChooserResult]: + code, results = cast(Tuple[int, Dict[str, Variant]], response.body) + if code != SUCCESS_CODE: + return None + + return ChooserResult( + uris=_uris(results), + filter_label=_filter_label(results), + ) + + +def _uris(results: Dict[str, Variant]) -> Tuple[str, ...]: + uris = results.get(URIS_RESULT) + if uris is None: + return () + + return tuple(cast(List[str], uris[1])) + + +def _filter_label(results: Dict[str, Variant]) -> Optional[str]: + """The label of the type the dialog stood on, as the portal reports the whole filter back.""" + reported = results.get(CURRENT_FILTER_RESULT) + if reported is None: + return None + + label, _patterns = cast(Tuple[str, List[Tuple[int, str]]], reported[1]) + return label + + +def _signal_path(response: Message) -> Optional[str]: + return cast(Optional[str], response.header.fields.get(HeaderFields.path)) diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index 5c209a94..d8a610ff 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -12,6 +12,7 @@ KDIALOG: Final[str] = "kdialog" ZENITY: Final[str] = "zenity" TKINTER_MODULE: Final[str] = "tkinter" +JEEPNEY_MODULE: Final[str] = "jeepney" DESKTOP_ENVIRONMENT_VARIABLE: Final[str] = "XDG_CURRENT_DESKTOP" KDE_DESKTOP: Final[str] = "KDE" @@ -23,10 +24,10 @@ def select_file_dialog_backend() -> FileDialogBackend: """ Returns the file-dialog backend that fits the running environment. - On Linux the choice follows the desktop environment and installed tools, with ``tkinter`` as - the last resort; on other platforms ``tkinter`` drives the native dialog. Availability is - probed for each candidate, so an environment lacking Tk opens dialogs through the desktop - tools instead. + On Linux the choice follows the desktop portal, then the desktop environment and its installed + tools, with ``tkinter`` as the last resort; on other platforms ``tkinter`` drives the native + dialog. Availability is probed for each candidate, so an environment lacking Tk opens dialogs + through the desktop tools instead. Raises: FileDialogUnavailableError: If the environment provides no usable backend. @@ -46,6 +47,13 @@ def select_file_dialog_backend() -> FileDialogBackend: def _select_linux_backend() -> Optional[FileDialogBackend]: + """ + Returns the Linux backend to open dialogs with, in order of what each dialog can express. + + The desktop portal comes first: it lists every offered file type in its selector and reports + the one the user picked, so a caller offering several types learns which was chosen. Behind + it stand the desktop's own command-line tools, and Tk last. + """ kdialog = KDialogBackend() if shutil.which(KDIALOG) is not None else None zenity = ZenityBackend() if shutil.which(ZENITY) is not None else None @@ -56,7 +64,22 @@ def _select_linux_backend() -> Optional[FileDialogBackend]: else: preferred, alternative = zenity, kdialog - return preferred or alternative or _tkinter_backend() + return _portal_backend() or preferred or alternative or _tkinter_backend() + + +def _portal_backend() -> Optional[FileDialogBackend]: + """ + Returns a portal-backed implementation once ``jeepney`` is installed and a portal answers. + + ``jeepney`` is declared for Linux alone, so its presence is probed before the portal module + is imported, which leaves application startup on every other platform independent of it. + """ + if importlib.util.find_spec(JEEPNEY_MODULE) is None: + return None + + from sampletones_application.utils.file_dialogs.portal.backend import portal_backend + + return portal_backend() def _tkinter_backend() -> Optional[FileDialogBackend]: diff --git a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py b/src/sampletones_application/utils/file_dialogs/tkinter_backend.py index f158666e..487ccb48 100644 --- a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py +++ b/src/sampletones_application/utils/file_dialogs/tkinter_backend.py @@ -2,6 +2,10 @@ from tkinter import Tk, filedialog from typing import Callable, List, Optional, Tuple +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_shared.utils.system.paths import normalize_path @@ -12,8 +16,8 @@ class TkinterBackend: Tk renders the platform's native dialog on Windows and macOS, which makes this the backend there; on Linux it is the last resort when neither kdialog nor zenity is - installed. Each call raises a transient hidden root so the dialog owns no lasting - window. + installed. Every offered type reaches the dialog's type selector as its own entry. + Each call raises a transient hidden root so the dialog owns no lasting window. """ def open_file( @@ -21,13 +25,13 @@ def open_file( *, title: str, initial_directory: Optional[Path], - file_filter: Optional[FileFilter], + filters: Tuple[FileFilter, ...], ) -> Optional[Path]: return _run( lambda: filedialog.askopenfilename( title=title, initialdir=_initial_directory(initial_directory), - filetypes=_filetypes(file_filter), + filetypes=_filetypes(filters), ) ) @@ -37,14 +41,16 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - return _run( - lambda: filedialog.asksaveasfilename( - title=title, - initialdir=_initial_directory(initial_directory), - initialfile=suggested_name or "", - filetypes=_filetypes(file_filter), + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + return untyped_destination( + _run( + lambda: filedialog.asksaveasfilename( + title=title, + initialdir=_initial_directory(initial_directory), + initialfile=suggested_name or "", + filetypes=_filetypes(filters), + ) ) ) @@ -67,12 +73,9 @@ def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: def _filetypes( - file_filter: Optional[FileFilter], + filters: Tuple[FileFilter, ...], ) -> List[Tuple[str, Tuple[str, ...]]]: - if file_filter is None: - return [] - - return [(file_filter.label, tuple(file_filter.patterns))] + return [(file_filter.label, file_filter.patterns) for file_filter in filters] def _run(dialog: Callable[[], str]) -> Optional[Path]: diff --git a/src/sampletones_application/utils/file_dialogs/zenity.py b/src/sampletones_application/utils/file_dialogs/zenity.py index 80ebfa44..33db8918 100644 --- a/src/sampletones_application/utils/file_dialogs/zenity.py +++ b/src/sampletones_application/utils/file_dialogs/zenity.py @@ -1,8 +1,12 @@ import os import subprocess from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Tuple +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_shared.utils.system.paths import normalize_path @@ -11,9 +15,9 @@ class ZenityBackend: """ File dialogs backed by GNOME's ``zenity`` (GTK). - The named filter appears in the file-type selector. ``zenity`` lists the filter - but leaves the selector on its "(None)" entry, since its command line offers no - way to pre-select a filter; the extension is still guaranteed by the API layer. + Every offered type reaches the file-type selector as its own entry, so each accepted + extension is named on screen. GTK selects among them to narrow what the browser lists, + and reports the name that was typed; the extension is guaranteed by the API layer. """ def open_file( @@ -21,11 +25,11 @@ def open_file( *, title: str, initial_directory: Optional[Path], - file_filter: Optional[FileFilter], + filters: Tuple[FileFilter, ...], ) -> Optional[Path]: command = ["zenity", "--file-selection", "--title", title] command += _filename_arguments(initial_directory, None) - command += _filter_arguments(file_filter) + command += _filter_arguments(filters) return _run(command) def save_file( @@ -34,8 +38,8 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: command = [ "zenity", "--file-selection", @@ -45,8 +49,8 @@ def save_file( title, ] command += _filename_arguments(initial_directory, suggested_name) - command += _filter_arguments(file_filter) - return _run(command) + command += _filter_arguments(filters) + return untyped_destination(_run(command)) def select_directory( self, @@ -79,12 +83,13 @@ def _filename_arguments( return ["--filename", f"{base}{os.sep}"] -def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: - if file_filter is None: - return [] +def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + arguments: List[str] = [] + for file_filter in filters: + patterns = " ".join(file_filter.patterns) + arguments += ["--file-filter", f"{file_filter.label} | {patterns}"] - patterns = " ".join(file_filter.patterns) - return ["--file-filter", f"{file_filter.label} | {patterns}"] + return arguments def _run(command: List[str]) -> Optional[Path]: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 06fb298f..966fd475 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -27,7 +27,6 @@ class ShortcutId(Enum): CLOSE_RECONSTRUCTION = "CloseReconstruction" EXPORT_RECONSTRUCTION_WAV = "ExportReconstructionWav" EXPORT_INSTRUMENTS_FAMITRACKER = "ExportInstrumentsFamiTracker" - EXPORT_INSTRUMENTS_BITPHASE = "ExportInstrumentsBitphase" EXPORT_INSTRUMENTS_BITPHASE_PRESET = "ExportInstrumentsBitphasePreset" ADD_RECONSTRUCTION_TO_SEQUENCER = "AddReconstructionToSequencer" OPEN_RECONSTRUCTION_IN_EXPLORER = "OpenReconstructionInExplorer" @@ -66,6 +65,5 @@ class ShortcutId(Enum): SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, - TrackerFormat.BITPHASE: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE, TrackerFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, } diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index fff4fb2d..7b81e333 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -44,13 +44,14 @@ global.dialog.title.no_project_open: "No project open" global.dialog.title.remove_sample: "Remove sample" global.dialog.title.change_nes_frequency: "Change NES frequency" global.dialog.title.frequency_mismatch: "Different NES frequency" +global.dialog.title.unsupported_extension: "Unsupported file type" global.dialog.title.about: "About" # Global — Dialog file filters global.dialog.filter.project: "Project files" global.dialog.filter.reconstruction: "Reconstruction files" global.dialog.filter.module: "FamiTracker module" -global.dialog.filter.instrument: "FamiTracker instrument" +global.dialog.filter.famitracker_instrument: "FamiTracker instrument" global.dialog.filter.bitphase_project: "Bitphase project" global.dialog.filter.bitphase_preset: "Bitphase instrument preset" global.dialog.filter.config: "Configuration files" @@ -97,6 +98,8 @@ global.dialog.message.remove_sample: "The sample \"{name}\" is used by one or mo 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.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.unsupported_extension: "The file type \"{extension}\" belongs to no supported tracker. Save the export as one of: {extensions}." +global.dialog.message.missing_extension: "The file name carries no extension, and the extension names the tracker the export is written for. Save the export as one of: {extensions}." global.dialog.message.about_description: "An application for approximating audio samples with the NES 2A03 oscillators and exporting them as FamiTracker instruments/modules." # Global — Dialog templates @@ -185,7 +188,6 @@ global.menu.label.item_reconstruction_close: "Close reconstruction" global.menu.label.item_reconstruction_export_wav: "Export to WAV..." global.menu.label.group_reconstruction_export_instruments: "Export instruments" global.menu.label.item_reconstruction_export_instruments_famitracker: "FamiTracker instruments..." -global.menu.label.item_reconstruction_export_instruments_bitphase: "Bitphase project..." global.menu.label.item_reconstruction_export_instruments_bitphase_preset: "Bitphase presets..." global.menu.label.group_playback: "Playback" global.menu.label.item_playback_play: "Play" @@ -394,10 +396,7 @@ reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed # Reconstructions tab — Instruments # ============================================================================= reconstructions.instruments.label.section: "Instruments" -reconstructions.instruments.label.export_instrument_button: "Export instrument" -reconstructions.instruments.label.export_instrument_famitracker: "FamiTracker instrument" -reconstructions.instruments.label.export_instrument_bitphase: "Bitphase project" -reconstructions.instruments.label.export_instrument_bitphase_preset: "Bitphase preset" +reconstructions.instruments.label.export_instrument_button: "Export instrument..." reconstructions.instruments.label.copy_button: "Copy" reconstructions.instruments.label.pitch_label: "Pitch" reconstructions.instruments.label.hi_pitch_label: "Hi-pitch" @@ -414,7 +413,7 @@ reconstructions.instruments.message.status_sequence_too_long: "{instrument_featu reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." reconstructions.instruments.message.status_generator_toggle: "Click to turn {on_or_off} {generator_name}." reconstructions.instruments.message.status_generator_not_available: "{generator_name} is not available." -reconstructions.instruments.message.status_export_instrument: "Click to choose the tracker the {generator} generator's instrument is exported for." +reconstructions.instruments.message.status_export_instrument: "Writes the {generator} generator's instrument, for the tracker the chosen extension names." 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." diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/trackers/extensions.py index 3641e8f4..8256080b 100644 --- a/src/sampletones_core/trackers/extensions.py +++ b/src/sampletones_core/trackers/extensions.py @@ -26,6 +26,34 @@ def scope_extensions( return tuple(dict.fromkeys(extensions)) +def default_scope_extension( + backends: Mapping[TrackerFormat, TrackerBackend], + scope: ExportScope, +) -> str: + """The extension a destination for ``scope`` takes when it is given none. + + The extension chooses the format, so a destination has to end in one for the export to + reach a backend. The first format able to express the scope stands in when the user + types a bare name, and it is the extension the save dialog suggests, so the type an + export lands in is on screen before it is confirmed. + + Args: + backends: Every backend the application writes through, keyed by its format. + scope: The scope about to be exported. + + Returns: + str: The extension the scope falls back to, leading dot included. + + Raises: + ValueError: If no format can express ``scope``. + """ + extensions = scope_extensions(backends, scope) + if not extensions: + raise ValueError(f"No tracker format writes a {scope} export") + + return extensions[0] + + def format_for_extension( backends: Mapping[TrackerFormat, TrackerBackend], scope: ExportScope, diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 4c5890e8..f0d1939c 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -134,19 +134,22 @@ def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features, backe assert (tmp_path / "inst_0.fti").exists() assert (tmp_path / "inst_1.fti").exists() - def test_emits_export_success_with_the_destination(self, tmp_path, pulse_features, backend) -> None: - destination = tmp_path / "sample.fti" + def test_emits_export_success_with_a_path_that_was_written(self, tmp_path, pulse_features, backend) -> None: + """A batch names its slices after the destination, so the result reports one of the + slices it wrote and the dialog announcing it opens a file that is there. + """ export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) request = sample_export("sample", instrument_export("inst", pulse_features)) - export_service.export_sample(destination, backend, request) + export_service.export_sample(tmp_path / "sample.fti", backend, request) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) assert results[0].kind == ExportKind.SAMPLE - assert results[0].filepath == destination + assert results[0].filepath == tmp_path / "inst.fti" + assert results[0].filepath.exists() def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: new_dir = tmp_path / "subdir" diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_trackers.py index 10fcf908..4d31834b 100644 --- a/tests/unit/sampletones_application/categories/test_trackers.py +++ b/tests/unit/sampletones_application/categories/test_trackers.py @@ -3,16 +3,10 @@ import pytest from sampletones_application.categories.trackers import ( - TRACKER_INSTRUMENT_FILTERS, - TRACKER_INSTRUMENT_LABELS, TRACKER_PROJECT_ELEMENTS, TRACKER_PROJECT_MENU_LABELS, - TRACKER_SAMPLE_MENU_LABELS, -) -from sampletones_application.utils.gui.shortcuts.ids import ( - PROJECT_EXPORT_SHORTCUT_IDS, - SAMPLE_EXPORT_SHORTCUT_IDS, ) +from sampletones_application.utils.gui.shortcuts.ids import PROJECT_EXPORT_SHORTCUT_IDS from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends @@ -39,20 +33,12 @@ class TestEveryOfferedFormatHasABackend: "offered", [ frozenset(PROJECT_EXPORT_SHORTCUT_IDS), - frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), frozenset(TRACKER_PROJECT_MENU_LABELS), - frozenset(TRACKER_SAMPLE_MENU_LABELS), - frozenset(TRACKER_INSTRUMENT_LABELS), - frozenset(TRACKER_INSTRUMENT_FILTERS), frozenset(TRACKER_PROJECT_ELEMENTS), ], ids=[ "project_shortcuts", - "sample_shortcuts", "project_menu", - "sample_menu", - "instrument_popup", - "instrument_filters", "project_elements", ], ) @@ -65,8 +51,12 @@ def test_the_registry_builds_every_offered_format( class TestTheMenusMatchTheSupportedScopes: - """Each submenu lists exactly the formats whose backend writes that scope, so a format - gains its entry by declaring the scope rather than by a second edit in the UI.""" + """The project submenu lists exactly the formats whose backend writes a project, so a + format gains its entry by declaring the scope rather than by a second edit in the UI. + + An instrument export names its format through the destination's extension, so the + formats it reaches are covered where that resolution lives. + """ def test_the_project_export_menu_lists_the_formats_that_write_a_project( self, @@ -74,18 +64,6 @@ def test_the_project_export_menu_lists_the_formats_that_write_a_project( ) -> None: assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) - def test_the_instruments_export_menu_lists_the_formats_that_write_a_sample( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) == formats_supporting(backends, ExportScope.SAMPLE) - - def test_the_instrument_popup_lists_the_formats_that_write_one_slice( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(TRACKER_INSTRUMENT_LABELS) == formats_supporting(backends, ExportScope.INSTRUMENT) - class TestEveryMenuEntryCarriesAnAction: """A submenu builds its entries by pairing a shortcut id with a label, so the two maps @@ -93,6 +71,3 @@ class TestEveryMenuEntryCarriesAnAction: def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) - - def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 17aea905..955ccf18 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, List +from typing import Callable, Dict, Final, List from unittest.mock import MagicMock import numpy as np @@ -18,8 +18,32 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.paths import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) from sampletones_core.reconstructions import Reconstruction from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends + +NO_EXTENSION: Final[str] = "" + + +@dataclass(frozen=True) +class FormatCase: + extension: str + tracker_format: TrackerFormat + + +INSTRUMENT_FORMAT_CASES: Final[List[FormatCase]] = [ + FormatCase(extension=EXT_FILE_INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER), + FormatCase(extension=EXT_FILE_BITPHASE, tracker_format=TrackerFormat.BITPHASE), + FormatCase(extension=EXT_FILE_JSON, tracker_format=TrackerFormat.BITPHASE_PRESET), +] + +UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] @pytest.fixture @@ -60,7 +84,19 @@ def panel_logic( @pytest.fixture def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]: - return {tracker_format: MagicMock() for tracker_format in TrackerFormat} + """Stands in for the real backends while declaring the scopes and extensions they do. + + The logic reads the destination's extension to pick a backend, so each stub mirrors what + the registry's backend declares and leaves only the writing to the mock. + """ + backends: Dict[TrackerFormat, MagicMock] = {} + for tracker_format, backend in build_tracker_backends().items(): + stub = MagicMock() + stub.supported_scopes = backend.supported_scopes + stub.extension.side_effect = backend.extension + backends[tracker_format] = stub + + return backends @pytest.fixture @@ -373,7 +409,7 @@ def test_request_export_instrument_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) def test_request_export_instrument_dialog_fires_dialog_callback( self, @@ -384,20 +420,23 @@ def test_request_export_instrument_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) callback.assert_called_once() - def test_request_export_instrument_dialog_carries_the_chosen_format( + def test_request_export_instrument_dialog_suggests_the_slice_name( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: + """The suggestion is the slice name alone, leaving the tracker to the dialog's own + file-type selector. + """ mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.BITPHASE) - assert callback.call_args.args[-1] == TrackerFormat.BITPHASE + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + assert callback.call_args.args[0] == "Sample (pulse1)" def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( self, @@ -408,7 +447,7 @@ def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE, TrackerFormat.FAMITRACKER) + panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE) callback.assert_not_called() def test_handle_export_instrument_confirmed_with_no_pending_does_not_export( @@ -433,7 +472,7 @@ def test_handle_export_instrument_confirmed_calls_export_service( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") mock_export_service.export_instrument.assert_called_once() @@ -447,12 +486,13 @@ def test_handle_export_instrument_confirmed_names_the_instrument_after_the_desti ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.FAMITRACKER) + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) panel_logic.handle_export_instrument_confirmed(tmp_path / "Clap (pulse1).fti") request = mock_export_service.export_instrument.call_args.args[2] assert request.name == "Clap (pulse1)" - def test_handle_export_instrument_confirmed_selects_the_backend_of_the_chosen_format( + @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension) + def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_names( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -460,13 +500,49 @@ def test_handle_export_instrument_confirmed_selects_the_backend_of_the_chosen_fo mock_export_service: MagicMock, mock_tracker_backends: Dict[TrackerFormat, MagicMock], tmp_path: Path, + case: FormatCase, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1, TrackerFormat.BITPHASE) - panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.btp") + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{case.extension}") backend = mock_export_service.export_instrument.call_args.args[1] - assert backend is mock_tracker_backends[TrackerFormat.BITPHASE] + assert backend is mock_tracker_backends[case.tracker_format] + + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) + def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + extension: str, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instrument_dialog = MagicMock() + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{extension}") + mock_export_service.export_instrument.assert_not_called() + + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) + def test_handle_export_instrument_confirmed_reports_the_extension_and_what_is_accepted( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + extension: str, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.on_open_export_instrument_dialog = MagicMock() + callback = MagicMock() + panel_logic.on_unsupported_export_extension = callback + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{extension}") + chosen, supported = callback.call_args.args + assert chosen == extension + assert set(supported) == {EXT_FILE_INSTRUMENT, EXT_FILE_BITPHASE, EXT_FILE_JSON} class TestReconstructionPanelLogicExportInstruments: @@ -489,37 +565,44 @@ def test_request_export_instruments_dialog_fires_dialog_callback( panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) callback.assert_called_once() - def test_request_export_instruments_dialog_carries_the_chosen_format( + def test_request_export_instruments_dialog_suggests_the_reconstruction_name( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: + """The tracker is settled before the dialog opens, so the suggestion ends in the + extension that tracker writes. + """ mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE) - assert callback.call_args.args[-1] == TrackerFormat.BITPHASE + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + assert callback.call_args.args[0] == f"{loaded_data.name}{EXT_FILE_INSTRUMENT}" - def test_handle_export_instruments_confirmed_with_no_data_is_no_op( + def test_request_export_instruments_dialog_carries_the_chosen_tracker( self, panel_logic: ReconstructionPanelLogic, - mock_export_service: MagicMock, - tmp_path: Path, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, ) -> None: - panel_logic.handle_export_instruments_confirmed(tmp_path) - mock_export_service.export_sample.assert_not_called() + """The dialog offers one type, so the tracker travels with the request.""" + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instruments_dialog = callback + panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE_PRESET) + assert callback.call_args.args[2] == TrackerFormat.BITPHASE_PRESET - def test_handle_export_instruments_confirmed_without_a_requested_format_is_no_op( + def test_handle_export_instruments_confirmed_with_no_data_is_no_op( self, panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instruments_confirmed(tmp_path) + panel_logic.handle_export_instruments_confirmed( + tmp_path / "sample.fti", + TrackerFormat.FAMITRACKER, + ) mock_export_service.export_sample.assert_not_called() def test_handle_export_instruments_confirmed_calls_export_sample( @@ -531,9 +614,10 @@ def test_handle_export_instruments_confirmed_calls_export_sample( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instruments_dialog = MagicMock() - panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) - panel_logic.handle_export_instruments_confirmed(tmp_path) + panel_logic.handle_export_instruments_confirmed( + tmp_path / "sample.fti", + TrackerFormat.FAMITRACKER, + ) mock_export_service.export_sample.assert_called_once() def test_handle_export_instruments_confirmed_names_the_batch_after_the_destination( @@ -545,14 +629,16 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instruments_dialog = MagicMock() - panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) - panel_logic.handle_export_instruments_confirmed(tmp_path / "Clap.fti") + panel_logic.handle_export_instruments_confirmed( + tmp_path / "Clap.fti", + TrackerFormat.FAMITRACKER, + ) request = mock_export_service.export_sample.call_args.args[2] assert request.name == "Clap" assert [instrument.name for instrument in request.instruments] == ["Clap (pulse1)"] - def test_handle_export_instruments_confirmed_selects_the_backend_of_the_chosen_format( + @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension) + def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -560,13 +646,18 @@ def test_handle_export_instruments_confirmed_selects_the_backend_of_the_chosen_f mock_export_service: MagicMock, mock_tracker_backends: Dict[TrackerFormat, MagicMock], tmp_path: Path, + case: FormatCase, ) -> None: + """The action names the tracker, so the destination's own extension leaves the + backend it is written through untouched. + """ mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instruments_dialog = MagicMock() - panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE) - panel_logic.handle_export_instruments_confirmed(tmp_path / "sample.btp") + panel_logic.handle_export_instruments_confirmed( + tmp_path / f"sample{case.extension}", + case.tracker_format, + ) backend = mock_export_service.export_sample.call_args.args[1] - assert backend is mock_tracker_backends[TrackerFormat.BITPHASE] + assert backend is mock_tracker_backends[case.tracker_format] class TestReconstructionPanelLogicExportWav: diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 59ab0596..79e7e693 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,10 +1,9 @@ -from typing import List, Tuple +from typing import List from unittest.mock import MagicMock import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.trackers import TRACKER_INSTRUMENT_LABELS from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.loader import load_layout_config from sampletones_application.paths import ( @@ -28,7 +27,6 @@ from sampletones_application.utils.palette import Palette from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS -from sampletones_core.trackers.format import TrackerFormat @pytest.fixture @@ -109,30 +107,43 @@ def test_each_dimension_carries_its_own_length( assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT] -class TestInstrumentExportFormat: - """The export button asks which tracker the slice is written for, and the answer travels - with the generator so the logic below picks the matching backend.""" +class TestInstrumentExport: + """The export button carries the generator whose slice it writes; the destination the + dialog answers with names the tracker, so no format travels from here.""" - def test_the_chosen_format_reaches_the_export_callback( + def test_the_generator_reaches_the_export_callback( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - calls: List[Tuple[GeneratorName, TrackerFormat]] = [] - panel.on_instrument_export = lambda generator, tracker_format: calls.append((generator, tracker_format)) + calls: List[GeneratorName] = [] + panel.on_instrument_export = calls.append - panel._handle_export_format_selected( - "sender", - None, - (GeneratorName.NOISE, TrackerFormat.BITPHASE_PRESET), - ) + panel._export_callback(GeneratorName.NOISE)() - assert calls == [(GeneratorName.NOISE, TrackerFormat.BITPHASE_PRESET)] + assert calls == [GeneratorName.NOISE] - def test_the_popup_offers_a_label_for_every_format( + def test_each_generator_gets_its_own_handler( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - assert set(panel._lbl_export_formats) == set(TRACKER_INSTRUMENT_LABELS) + calls: List[GeneratorName] = [] + panel.on_instrument_export = calls.append + + for generator_name in GeneratorName.items(): + panel._export_callback(generator_name)() + + assert calls == list(GeneratorName.items()) + + def test_the_handler_is_one_the_framework_can_dispatch( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + """DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it, + so a press handler carries one and takes the arguments the framework offers a button. + """ + callback = panel._export_callback(GeneratorName.NOISE) + + assert callback.__code__.co_argcount == 0 class TestSequenceStatusMessage: diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index e00e72e0..d26dbe00 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -7,10 +7,15 @@ from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, + TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) from sampletones_application.ui import menu as menu_module from sampletones_application.ui.menu import MenuBar -from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_application.utils.gui.shortcuts.ids import ( + CHANNEL_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, + ShortcutId, +) from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_core.constants.enums import GeneratorName @@ -42,11 +47,16 @@ class _DearPyGuiRecorder: def __init__(self) -> None: self.values: Dict[str, bool] = {} self.enabled: Dict[str, bool] = {} + self.menus: List[Dict[str, Any]] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: + self.menus.append(kwargs) yield 0 + def submenu(self, tag: str) -> Dict[str, Any]: + return next(entry for entry in self.menus if entry.get("tag") == tag) + def add_separator(self, **kwargs: Any) -> int: return 0 @@ -57,10 +67,14 @@ def configure_item(self, item: str, **kwargs: Any) -> None: self.enabled[item] = kwargs["enabled"] -def _state(muted: FrozenSet[GeneratorName]) -> MenuBarViewModel: +def _state( + muted: FrozenSet[GeneratorName], + *, + reconstruction_loaded: bool = False, +) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, - reconstruction_loaded=False, + reconstruction_loaded=reconstruction_loaded, reconstruction_saveable=False, reconstruction_in_project=False, reconstruction_file_backed=False, @@ -107,6 +121,46 @@ def menu_bar(shortcuts: _ShortcutManagerRecorder) -> MenuBar: return instance +class TestInstrumentsExportMenu: + """Each tracker that writes a file per slice gets its own item, so choosing the tracker + is one click and the destination dialog then offers that tracker's type alone.""" + + def test_every_offered_tracker_is_listed( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset())) + + entries = [item for item in shortcuts.items if item["shortcut_id"] in SAMPLE_EXPORT_SHORTCUT_IDS.values()] + + assert [entry["label"] for entry in entries] == [ + "FamiTracker instruments...", + "Bitphase presets...", + ] + + def test_the_submenu_waits_for_a_loaded_reconstruction( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset())) + + assert framework.submenu(TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS)["enabled"] is False + + def test_the_submenu_is_offered_once_a_reconstruction_is_loaded( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset(), reconstruction_loaded=True)) + + assert framework.submenu(TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS)["enabled"] is True + + class TestChannelsMenuItems: def test_every_channel_is_named_in_the_tracker_order( self, diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/__init__.py b/tests/unit/sampletones_application/utils/file_dialogs/portal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py new file mode 100644 index 00000000..0fb86085 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py @@ -0,0 +1,208 @@ +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.portal.backend import ( + CURRENT_FILTER_OPTION, + CURRENT_FOLDER_OPTION, + CURRENT_NAME_OPTION, + DIRECTORY_OPTION, + FILTERS_OPTION, + MINIMUM_FILE_CHOOSER_VERSION, + PortalBackend, +) +from sampletones_application.utils.file_dialogs.portal.client import ChooserResult, Variant + +FAMITRACKER_FILTER: Final[FileFilter] = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +PRESET_FILTER: Final[FileFilter] = FileFilter(name="Bitphase instrument preset", patterns=("*.json",)) +INSTRUMENT_FILTERS: Final[Tuple[FileFilter, ...]] = (FAMITRACKER_FILTER, PRESET_FILTER) + +HOME: Final[Path] = Path("/home/user") + + +class FakeClient: + """A portal answering with one prepared result, recording what it was asked to show.""" + + def __init__( + self, + result: Optional[ChooserResult], + version: Optional[int] = MINIMUM_FILE_CHOOSER_VERSION, + ) -> None: + self._result = result + self._version = version + self.calls: List[Tuple[str, str, Dict[str, Variant]]] = [] + + def version(self) -> Optional[int]: + return self._version + + def call( + self, + *, + method: str, + title: str, + options: Dict[str, Variant], + ) -> Optional[ChooserResult]: + self.calls.append((method, title, options)) + return self._result + + +def _saved( + uri: str, + label: Optional[str], +) -> ChooserResult: + return ChooserResult(uris=(uri,), filter_label=label) + + +class TestPortalBackendSave: + def test_options_carry_the_types_the_name_and_the_folder(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="Kick (pulse1)", + filters=INSTRUMENT_FILTERS, + ) + + method, title, options = client.calls[0] + assert (method, title) == ("SaveFile", "Export instrument") + assert options[FILTERS_OPTION] == ( + "a(sa(us))", + [ + ("FamiTracker instrument (*.fti)", [(0, "*.fti")]), + ("Bitphase instrument preset (*.json)", [(0, "*.json")]), + ], + ) + assert options[CURRENT_NAME_OPTION] == ("s", "Kick (pulse1)") + assert options[CURRENT_FOLDER_OPTION] == ("ay", b"/home/user\x00") + + def test_the_dialog_opens_on_the_first_offered_type(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + backend.save_file( + title="Export instrument", + initial_directory=None, + suggested_name=None, + filters=INSTRUMENT_FILTERS, + ) + + options = client.calls[0][2] + assert options[CURRENT_FILTER_OPTION] == ("(sa(us))", ("FamiTracker instrument (*.fti)", [(0, "*.fti")])) + assert CURRENT_NAME_OPTION not in options + assert CURRENT_FOLDER_OPTION not in options + + def test_the_reported_label_names_the_offered_type(self) -> None: + client = FakeClient(_saved("file:///home/user/kick", PRESET_FILTER.label)) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination == SaveDestination(path=Path("/home/user/kick"), file_type=PRESET_FILTER) + + def test_an_unreported_type_leaves_the_destination_typeless(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", None)) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination == SaveDestination(path=Path("/home/user/kick.fti"), file_type=None) + + def test_a_dismissed_dialog_answers_with_nothing(self) -> None: + client = FakeClient(None) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination is None + + +class TestPortalBackendOpen: + def test_an_escaped_uri_reads_as_the_path_it_names(self) -> None: + client = FakeClient(_saved("file:///home/user/Kick%20%28pulse1%29.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + filepath = backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=INSTRUMENT_FILTERS, + ) + + assert filepath == Path("/home/user/Kick (pulse1).fti") + assert client.calls[0][0] == "OpenFile" + + def test_a_location_outside_the_file_system_answers_with_nothing(self) -> None: + client = FakeClient(_saved("https://example.invalid/kick.fti", None)) + backend = PortalBackend(client) + + filepath = backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=INSTRUMENT_FILTERS, + ) + + assert filepath is None + + def test_no_offered_types_leaves_the_selector_out(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", None)) + backend = PortalBackend(client) + + backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=(), + ) + + options = client.calls[0][2] + assert FILTERS_OPTION not in options + assert CURRENT_FILTER_OPTION not in options + + +class TestPortalBackendSelectDirectory: + def test_the_dialog_is_asked_for_a_folder(self) -> None: + client = FakeClient(_saved("file:///home/user/instruments", None)) + backend = PortalBackend(client) + + directory = backend.select_directory(title="Choose folder", initial_directory=HOME) + + method, _title, options = client.calls[0] + assert directory == Path("/home/user/instruments") + assert method == "OpenFile" + assert options[DIRECTORY_OPTION] == ("b", True) + + +class TestPortalAvailability: + @pytest.mark.parametrize("version", [None, MINIMUM_FILE_CHOOSER_VERSION - 1]) + def test_a_portal_below_the_needed_version_leaves_dialogs_to_another_backend( + self, + version: Optional[int], + ) -> None: + from sampletones_application.utils.file_dialogs.portal import backend as backend_module + + client = FakeClient(None, version=version) + with pytest.MonkeyPatch.context() as patcher: + patcher.setattr(backend_module, "FileChooserClient", lambda: client) + backend_module.portal_backend.cache_clear() + try: + assert backend_module.portal_backend() is None + finally: + backend_module.portal_backend.cache_clear() diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py new file mode 100644 index 00000000..83e1135b --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py @@ -0,0 +1,154 @@ +from collections import deque +from contextlib import contextmanager +from types import SimpleNamespace +from typing import Deque, Dict, Final, Iterator, List, Optional, Tuple + +import pytest +from jeepney import HeaderFields, MessageType + +from sampletones_application.utils.file_dialogs.portal import client as client_module +from sampletones_application.utils.file_dialogs.portal.client import ( + ChooserResult, + FileChooserClient, + Variant, +) + +HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_42/sampletones" +OTHER_HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_7/elsewhere" +LABEL: Final[str] = "Bitphase instrument preset (*.json)" + + +def _message( + body: Tuple[object, ...], + path: Optional[str] = None, +) -> SimpleNamespace: + fields: Dict[HeaderFields, str] = {} if path is None else {HeaderFields.path: path} + return SimpleNamespace( + header=SimpleNamespace(fields=fields, message_type=MessageType.method_return), + body=body, + ) + + +def _response( + code: int, + results: Dict[str, Variant], + path: str = HANDLE, +) -> SimpleNamespace: + return _message((code, results), path=path) + + +class FakeConnection: + """A session bus answering method calls in order and delivering prepared signals.""" + + def __init__( + self, + replies: List[SimpleNamespace], + signals: List[SimpleNamespace], + ) -> None: + self._replies = deque(replies) + self._signals = deque(signals) + self.sent: List[str] = [] + self.rules: List[object] = [] + self.closed = False + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, *arguments: object) -> None: + self.closed = True + + @contextmanager + def filter(self, rule: object) -> Iterator[Deque[SimpleNamespace]]: + self.rules.append(rule) + yield self._signals + + def send_and_get_reply(self, message: object) -> SimpleNamespace: + member = getattr(message, "header").fields[HeaderFields.member] + self.sent.append(member) + return self._replies.popleft() + + def recv_until_filtered(self, queue: Deque[SimpleNamespace]) -> SimpleNamespace: + return queue.popleft() + + +def _connecting(connection: FakeConnection) -> object: + def opener(*, bus: str) -> FakeConnection: + assert bus == "SESSION" + return connection + + return opener + + +class TestVersion: + def test_the_portal_reports_the_interface_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection(replies=[_message((("u", 3),))], signals=[]) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().version() == 3 + assert connection.closed + + @pytest.mark.parametrize( + "failure", + [ + KeyError("DBUS_SESSION_BUS_ADDRESS"), + FileNotFoundError("no such socket"), + RuntimeError("unsupported transport"), + ], + ) + def test_a_bus_out_of_reach_leaves_the_version_unknown( + self, + monkeypatch: pytest.MonkeyPatch, + failure: Exception, + ) -> None: + def opener(*, bus: str) -> FakeConnection: + raise failure + + monkeypatch.setattr(client_module, "open_dbus_connection", opener) + + assert FileChooserClient().version() is None + + +class TestCall: + def test_the_response_to_the_open_request_is_the_answer(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message((HANDLE,))], + signals=[ + _response( + 0, + { + "uris": ("as", ["file:///home/user/kick.json"]), + "current_filter": ("(sa(us))", (LABEL, [(0, "*.json")])), + }, + ) + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=LABEL) + assert connection.sent == ["AddMatch", "SaveFile"] + + def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Every portal response on the bus reaches the subscription, so each call waits for its own.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message((HANDLE,))], + signals=[ + _response(0, {"uris": ("as", ["file:///elsewhere/other.json"])}, path=OTHER_HANDLE), + _response(0, {"uris": ("as", ["file:///home/user/kick.json"])}), + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=None) + + def test_a_dismissed_dialog_answers_with_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py index a9196f97..cf58b61f 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List, Optional, Tuple +from typing import Final, List, Optional, Tuple from unittest.mock import patch from sampletones_application.utils.file_dialogs.api import ( @@ -7,22 +7,39 @@ save_file_dialog, select_directory_dialog, ) +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter MODULE = "sampletones_application.utils.file_dialogs.api" -Call = Tuple[str, str, Optional[Path], Optional[FileFilter]] +PROJECT_FILTER: Final[FileFilter] = FileFilter(name="Project files", patterns=("*.stp",)) +FAMITRACKER_FILTER: Final[FileFilter] = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +PRESET_FILTER: Final[FileFilter] = FileFilter(name="Bitphase preset", patterns=("*.json",)) +INSTRUMENT_FILTERS: Final[Tuple[FileFilter, ...]] = ( + FAMITRACKER_FILTER, + FileFilter(name="Bitphase project", patterns=("*.btp",)), + PRESET_FILTER, +) + +Call = Tuple[str, str, Optional[Path], Tuple[FileFilter, ...]] class FakeBackend: - def __init__(self, result: Optional[Path]) -> None: + """A backend answering with one prepared path, and the type it reports having been chosen.""" + + def __init__( + self, + result: Optional[Path], + reported_type: Optional[FileFilter] = None, + ) -> None: self._result = result + self._reported_type = reported_type self.calls: List[Call] = [] def open_file( - self, *, title: str, initial_directory: Optional[Path], file_filter: Optional[FileFilter] + self, *, title: str, initial_directory: Optional[Path], filters: Tuple[FileFilter, ...] ) -> Optional[Path]: - self.calls.append(("open", title, initial_directory, file_filter)) + self.calls.append(("open", title, initial_directory, filters)) return self._result def save_file( @@ -31,13 +48,16 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - self.calls.append(("save", title, initial_directory, file_filter)) - return self._result + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + self.calls.append(("save", title, initial_directory, filters)) + if self._result is None: + return None + + return SaveDestination(path=self._result, file_type=self._reported_type) def select_directory(self, *, title: str, initial_directory: Optional[Path]) -> Optional[Path]: - self.calls.append(("directory", title, initial_directory, None)) + self.calls.append(("directory", title, initial_directory, ())) return self._result @@ -45,33 +65,85 @@ class TestSaveFileDialog: def test_appends_missing_extension(self) -> None: backend = FakeBackend(Path("/home/user/song")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"], filter_name="Project files") + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result == Path("/home/user/song.stp") - file_filter = backend.calls[0][3] - assert file_filter == FileFilter(name="Project files", patterns=("*.stp",)) + assert backend.calls[0][3] == (PROJECT_FILTER,) def test_keeps_present_extension(self) -> None: backend = FakeBackend(Path("/home/user/song.stp")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"]) + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result == Path("/home/user/song.stp") def test_cancel_returns_none(self) -> None: backend = FakeBackend(None) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"]) + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result is None + def test_a_bare_name_takes_the_first_of_several_offered_types(self) -> None: + backend = FakeBackend(Path("/home/user/kick")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.fti") + + def test_a_typed_extension_chooses_among_several_offered_types(self) -> None: + backend = FakeBackend(Path("/home/user/kick.json")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.json") + + def test_the_reported_type_names_a_bare_name(self) -> None: + backend = FakeBackend(Path("/home/user/kick"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.json") + + def test_a_typed_extension_stands_over_the_reported_type(self) -> None: + """Typing an offered extension names the type, whichever one the selector stood on.""" + backend = FakeBackend(Path("/home/user/kick.fti"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.fti") + + def test_an_extension_outside_the_offered_types_takes_the_reported_one(self) -> None: + backend = FakeBackend(Path("/home/user/kick.xm"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.xm.json") + + def test_a_dotted_name_is_saved_as_the_governing_type(self) -> None: + """A name carrying dots of its own keeps them, so ``Kick 1.2`` saves as a file of the + type the dialog stood on rather than one named after its trailing segment. + """ + backend = FakeBackend(Path("/home/user/Kick 1.2")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/Kick 1.2.fti") + + def test_one_offered_type_is_saved_as_that_type(self) -> None: + backend = FakeBackend(Path("/home/user/Kick 1.2")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) + + assert result == Path("/home/user/Kick 1.2.stp") + def test_without_extension_no_filter_and_no_append(self) -> None: backend = FakeBackend(Path("/home/user/song")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): result = save_file_dialog(title="Save") assert result == Path("/home/user/song") - assert backend.calls[0][3] is None + assert backend.calls[0][3] == () class TestOpenFileDialog: @@ -81,14 +153,13 @@ def test_builds_filter_and_converts_directory(self) -> None: result = open_file_dialog( title="Open", initial_directory="/audio", - extensions=[".wav", ".mp3"], - filter_name="Audio files", + filters=(FileFilter.for_extensions("Audio files", [".wav", ".mp3"]),), ) assert result == Path("/audio/clip.wav") - _, _, initial_directory, file_filter = backend.calls[0] + _, _, initial_directory, filters = backend.calls[0] assert initial_directory == Path("/audio") - assert file_filter == FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) + assert filters == (FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")),) class TestSelectDirectoryDialog: @@ -98,4 +169,4 @@ def test_passes_through(self) -> None: result = select_directory_dialog(title="Choose", initial_directory="/audio") assert result == Path("/audio/library") - assert backend.calls[0] == ("directory", "Choose", Path("/audio"), None) + assert backend.calls[0] == ("directory", "Choose", Path("/audio"), ()) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py index b58ce28b..c93d0f92 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py @@ -1,8 +1,15 @@ -from typing import Tuple +from typing import Optional, Tuple import pytest -from sampletones_application.utils.file_dialogs.filter import FileFilter, normalize_extensions +from sampletones_application.utils.file_dialogs.filter import ( + FileFilter, + merge_filters, + normalize_extensions, +) + +FAMITRACKER_INSTRUMENT = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +BITPHASE_PRESET = FileFilter(name="Bitphase preset", patterns=("*.json",)) @pytest.mark.parametrize( @@ -29,3 +36,50 @@ def test_normalize_extensions(extensions: Tuple[str, ...], expected: Tuple[str, ) def test_label(name: str, patterns: Tuple[str, ...], expected: str) -> None: assert FileFilter(name=name, patterns=patterns).label == expected + + +@pytest.mark.parametrize( + "extensions, expected", + [ + ([".fti"], (".fti",)), + (["*.fti"], (".fti",)), + ([".fti", ".btp", ".json"], (".fti", ".btp", ".json")), + ], +) +def test_a_type_names_the_extensions_it_matches( + extensions: Tuple[str, ...], + expected: Tuple[str, ...], +) -> None: + """The glob form belongs to the dialogs, so a caller reads plain extensions back out.""" + assert FileFilter.for_extensions("Instrument", extensions).extensions == expected + + +@pytest.mark.parametrize( + "filters, expected", + [ + ((), None), + ((FAMITRACKER_INSTRUMENT,), FAMITRACKER_INSTRUMENT), + ( + (FAMITRACKER_INSTRUMENT, BITPHASE_PRESET), + FileFilter(name="FamiTracker instrument, Bitphase preset", patterns=("*.fti", "*.json")), + ), + ], +) +def test_merge_filters( + filters: Tuple[FileFilter, ...], + expected: Optional[FileFilter], +) -> None: + assert merge_filters(filters) == expected + + +def test_merging_leaves_one_type_alone() -> None: + """A lone type keeps its single pattern, which is the form a dialog fills the + extension in for. + """ + assert merge_filters((BITPHASE_PRESET,)).patterns == ("*.json",) + + +def test_merging_offers_a_shared_pattern_once() -> None: + audio = FileFilter(name="Audio", patterns=("*.wav", "*.mp3")) + wave = FileFilter(name="WAV audio", patterns=("*.wav",)) + assert merge_filters((audio, wave)).patterns == ("*.wav", "*.mp3") diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py b/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py index 02c1be85..aebcac46 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py @@ -1,6 +1,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend @@ -22,11 +23,11 @@ def test_save_command_carries_suggested_name_and_named_filter(self) -> None: title="Save project", initial_directory=Path("/home/user"), suggested_name="song.stp", - file_filter=file_filter, + filters=(file_filter,), ) command = run.call_args.args[0] - assert result == Path("/home/user/song.stp") + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) assert "--getsavefilename" in command assert str(Path("/home/user/song.stp")) in command assert "*.stp|Project files (*.stp)" in command @@ -39,7 +40,7 @@ def test_open_command_carries_multi_pattern_filter(self) -> None: result = backend.open_file( title="Open", initial_directory=Path("/audio"), - file_filter=file_filter, + filters=(file_filter,), ) command = run.call_args.args[0] @@ -47,6 +48,26 @@ def test_open_command_carries_multi_pattern_filter(self) -> None: assert "--getopenfilename" in command assert "*.wav *.mp3|Audio files (*.wav *.mp3)" in command + def test_several_types_gather_into_one_filter_naming_each(self) -> None: + """One filter reaches ``kdialog``'s command line, so it carries every accepted + pattern behind a label naming each type. + """ + backend = KDialogBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/kick.json\n")) as run: + backend.save_file( + title="Export instrument", + initial_directory=Path("/home/user"), + suggested_name="kick", + filters=filters, + ) + + command = run.call_args.args[0] + assert "*.fti *.json|FamiTracker instrument, Bitphase preset (*.fti *.json)" in command + def test_directory_command_has_no_filter(self) -> None: backend = KDialogBackend() with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: @@ -64,7 +85,7 @@ def test_cancel_returns_none(self) -> None: title="Save", initial_directory=None, suggested_name=None, - file_filter=FileFilter(name="", patterns=("*.stp",)), + filters=(FileFilter(name="", patterns=("*.stp",)),), ) assert result is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py index 74e9c898..8debcc78 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py @@ -1,10 +1,13 @@ import os +from contextlib import AbstractContextManager from typing import Callable, Optional -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.portal.backend import PortalBackend +from sampletones_application.utils.file_dialogs.portal.client import FileChooserClient from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend from sampletones_application.utils.file_dialogs.zenity import ZenityBackend @@ -12,6 +15,7 @@ from sampletones_shared.utils.system.system import System MODULE = "sampletones_application.utils.file_dialogs.selection" +PORTAL_MODULE = "sampletones_application.utils.file_dialogs.portal.backend" def _which(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[str]]: @@ -23,6 +27,11 @@ def resolver(tool: str) -> Optional[str]: return resolver +def _portal(backend: Optional[PortalBackend]) -> AbstractContextManager[MagicMock]: + """Answers the portal probe with ``backend``, standing in for a desktop that runs one.""" + return patch(f"{PORTAL_MODULE}.portal_backend", return_value=backend) + + def _find_spec(available: bool) -> Callable[[str], Optional[object]]: def resolver(module: str) -> Optional[object]: return object() if available else None @@ -39,10 +48,22 @@ def test_macos_uses_tkinter(self) -> None: with patch(f"{MODULE}.System.current", return_value=System.MACOS): assert isinstance(select_file_dialog_backend(), TkinterBackend) + def test_the_portal_leads_where_it_answers(self) -> None: + """The portal lists every offered type and reports the chosen one, so it comes first.""" + portal = PortalBackend(FileChooserClient()) + with ( + patch(f"{MODULE}.System.current", return_value=System.LINUX), + patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(portal), + patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), + ): + assert select_file_dialog_backend() is portal + def test_kde_prefers_kdialog(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): assert isinstance(select_file_dialog_backend(), KDialogBackend) @@ -51,6 +72,7 @@ def test_gnome_prefers_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), ): assert isinstance(select_file_dialog_backend(), ZenityBackend) @@ -59,6 +81,7 @@ def test_kde_without_kdialog_falls_back_to_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): assert isinstance(select_file_dialog_backend(), ZenityBackend) @@ -67,6 +90,7 @@ def test_no_linux_tools_uses_tkinter(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=False)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), ): assert isinstance(select_file_dialog_backend(), TkinterBackend) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py index 5f0c85fa..0428e429 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py @@ -1,6 +1,7 @@ from pathlib import Path from unittest.mock import patch +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend @@ -17,22 +18,43 @@ def test_save_passes_filetypes_and_disposes_root(self) -> None: title="Save", initial_directory=Path("/home/user"), suggested_name="song", - file_filter=file_filter, + filters=(file_filter,), ) kwargs = filedialog.asksaveasfilename.call_args.kwargs - assert result == Path("/home/user/song.stp") + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) assert kwargs["filetypes"] == [("Project files (*.stp)", ("*.stp",))] assert kwargs["initialfile"] == "song" assert kwargs["initialdir"] == str(Path("/home/user")) tk.return_value.withdraw.assert_called_once() tk.return_value.destroy.assert_called_once() + def test_each_offered_type_becomes_its_own_filetype(self) -> None: + backend = TkinterBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with patch(f"{MODULE}.Tk"), patch(f"{MODULE}.filedialog") as filedialog: + filedialog.asksaveasfilename.return_value = "/home/user/kick.json" + backend.save_file( + title="Export instrument", + initial_directory=None, + suggested_name="kick", + filters=filters, + ) + + kwargs = filedialog.asksaveasfilename.call_args.kwargs + assert kwargs["filetypes"] == [ + ("FamiTracker instrument (*.fti)", ("*.fti",)), + ("Bitphase preset (*.json)", ("*.json",)), + ] + def test_open_without_filter_uses_empty_filetypes(self) -> None: backend = TkinterBackend() with patch(f"{MODULE}.Tk"), patch(f"{MODULE}.filedialog") as filedialog: filedialog.askopenfilename.return_value = "" - result = backend.open_file(title="Open", initial_directory=None, file_filter=None) + result = backend.open_file(title="Open", initial_directory=None, filters=()) kwargs = filedialog.askopenfilename.call_args.kwargs assert result is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py index cdbc24e7..0c01111a 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py @@ -2,6 +2,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.zenity import ZenityBackend @@ -23,11 +24,11 @@ def test_save_command_uses_named_filter_and_filename(self) -> None: title="Save project", initial_directory=Path("/home/user"), suggested_name="song.stp", - file_filter=file_filter, + filters=(file_filter,), ) command = run.call_args.args[0] - assert result == Path("/home/user/song.stp") + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) assert "--save" in command assert command[command.index("--file-filter") + 1] == "Project files (*.stp) | *.stp" assert command[command.index("--filename") + 1] == str(Path("/home/user/song.stp")) @@ -36,11 +37,33 @@ def test_open_command_filter_format(self) -> None: backend = ZenityBackend() file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav\n")) as run: - backend.open_file(title="Open", initial_directory=Path("/audio"), file_filter=file_filter) + backend.open_file(title="Open", initial_directory=Path("/audio"), filters=(file_filter,)) command = run.call_args.args[0] assert command[command.index("--file-filter") + 1] == "Audio files (*.wav *.mp3) | *.wav *.mp3" + def test_each_offered_type_reaches_the_selector_as_its_own_entry(self) -> None: + """GTK narrows the browser by the type picked in the selector, so every accepted + type is listed for itself. + """ + backend = ZenityBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/kick.json\n")) as run: + backend.save_file( + title="Export instrument", + initial_directory=Path("/home/user"), + suggested_name="kick", + filters=filters, + ) + + command = run.call_args.args[0] + assert command.count("--file-filter") == 2 + assert "FamiTracker instrument (*.fti) | *.fti" in command + assert "Bitphase preset (*.json) | *.json" in command + def test_directory_command_uses_directory_flag(self) -> None: backend = ZenityBackend() with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: @@ -57,7 +80,7 @@ def test_cancel_returns_none(self) -> None: result = backend.open_file( title="Open", initial_directory=None, - file_filter=FileFilter(name="", patterns=("*.stp",)), + filters=(FileFilter(name="", patterns=("*.stp",)),), ) assert result is None diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py index e458f7f6..902acfe1 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -10,7 +10,11 @@ EXT_FILE_MODULE, ) from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.extensions import format_for_extension, scope_extensions +from sampletones_core.trackers.extensions import ( + default_scope_extension, + format_for_extension, + scope_extensions, +) from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.trackers.scope import ExportScope @@ -111,6 +115,32 @@ def test_the_extensions_follow_the_order_the_backends_were_registered( ) +class TestDefaultScopeExtension: + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_the_default_is_one_of_the_offered_extensions( + self, + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, + ) -> None: + assert default_scope_extension(backends, scope) in scope_extensions(backends, scope) + + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_the_default_resolves_to_a_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, + ) -> None: + """A destination suggested under the default reaches a backend as it stands, so + confirming the dialog untouched writes a file. + """ + extension = default_scope_extension(backends, scope) + assert format_for_extension(backends, scope, extension) is not None + + def test_a_scope_no_format_writes_is_refused(self) -> None: + with pytest.raises(ValueError): + default_scope_extension({}, ExportScope.INSTRUMENT) + + class TestFormatForExtension: @pytest.mark.parametrize( "case", diff --git a/uv.lock b/uv.lock index c497c08f..0098df28 100644 --- a/uv.lock +++ b/uv.lock @@ -688,6 +688,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -1713,6 +1722,7 @@ source = { editable = "." } dependencies = [ { name = "anytree" }, { name = "dearpygui" }, + { name = "jeepney", marker = "sys_platform == 'linux' or (extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11')" }, { name = "librosa" }, { name = "msgpack" }, { name = "numpy" }, @@ -1762,6 +1772,7 @@ requires-dist = [ { name = "cupy-cuda11x", marker = "extra == 'gpu-cuda11'", specifier = ">=13,<14" }, { name = "cupy-cuda12x", extras = ["ctk"], marker = "extra == 'gpu'", specifier = ">=14,<15" }, { name = "dearpygui", specifier = ">=2.3,<3" }, + { name = "jeepney", marker = "sys_platform == 'linux'", specifier = ">=0.8,<1" }, { name = "librosa", specifier = ">=0.11,<0.12" }, { name = "msgpack", specifier = ">=1.0,<2" }, { name = "numpy", specifier = ">=2.0,<3" }, From a416d953f57809d64c0cf85c97f8ebd44b0e0a80 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 00:07:28 +0200 Subject: [PATCH 14/20] Simplified: instrument export around the dialog's file-type selector --- docs/development/architecture.md | 6 +- .../categories/elements/global_.py | 3 - .../categories/export.py | 28 ------ .../categories/trackers.py | 2 +- .../coordinators/tabs/reconstruction.py | 54 ++++-------- .../logic/reconstruction/reconstruction.py | 70 ++++++++------- src/sampletones_application/tags/general.py | 6 -- .../utils/file_dialogs/portal/client.py | 17 +++- .../utils/file_dialogs/selection.py | 6 +- src/sampletones_config/lang/en.yaml | 3 - src/sampletones_core/trackers/extensions.py | 57 +------------ .../categories/test_trackers.py | 42 ++++++++- .../reconstruction/test_reconstruction.py | 63 ++++++-------- .../trackers/test_extensions.py | 85 ++----------------- 14 files changed, 153 insertions(+), 289 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2a8d4e8f..b36500ed 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -102,7 +102,9 @@ A new exclusive operation joins by contributing its `is_active` to the authority Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. -`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol with `kdialog`, `zenity`, and `tkinter` implementations, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — `kdialog` activates the supplied filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries the configured extension, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. +`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector and reports the one the user picked, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. + +Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. ### 12. One dispatcher owns the keyboard @@ -283,7 +285,7 @@ There are two coordinator kinds: | `categories/` | `LanguageManager` and the `Page / Panel / TextType / Element` enum hierarchy used as lookup keys | | `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` | | `constants/` | DPG widget tags (`TAG_*`) and tag suffix fragments (`SUF_*`) | -| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | +| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | | `viewport.py` | Manages DPG viewport geometry and fullscreen state | --- diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index c3a0d242..7071a083 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -183,8 +183,6 @@ class GlobalMessageElements(AbstractElement): CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" OPERATION_IN_PROGRESS = "operation_in_progress" - UNSUPPORTED_EXTENSION = "unsupported_extension" - MISSING_EXTENSION = "missing_extension" ABOUT_DESCRIPTION = "about_description" @@ -215,7 +213,6 @@ class GlobalDialogTitleElements(AbstractElement): REMOVE_SAMPLE = "remove_sample" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" - UNSUPPORTED_EXTENSION = "unsupported_extension" ABOUT = "about" diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py index 21fd40aa..2f1d7e58 100644 --- a/src/sampletones_application/categories/export.py +++ b/src/sampletones_application/categories/export.py @@ -2,10 +2,6 @@ from dataclasses import dataclass -from sampletones_application.categories.elements.global_ import ( - GlobalDialogTitleElements, - GlobalMessageElements, -) from sampletones_application.categories.elements.reconstructions import ( ReconstructionPanelElements, ReconstructionsInstrumentsElements, @@ -33,9 +29,6 @@ class ExportMessages: instruments_failed: Shown when a reconstruction's instrument export fails. wav_success: Shown when the reconstruction reaches a WAV file. wav_failed: Shown when the WAV export fails. - unsupported_extension_title: Title of the dialog reporting an extension no format claims. - unsupported_extension: Template naming the extension chosen and those the scope accepts. - missing_extension: Template naming the extensions a destination given none accepts. """ status_title: str @@ -48,9 +41,6 @@ class ExportMessages: instruments_failed: str wav_success: str wav_failed: str - unsupported_extension_title: str - unsupported_extension: str - missing_extension: str @classmethod def build(cls, language_manager: LanguageManager) -> ExportMessages: @@ -115,24 +105,6 @@ def build(cls, language_manager: LanguageManager) -> ExportMessages: TextType.MESSAGE, ReconstructionPanelElements.EXPORT_WAV_FAILED, ], - unsupported_extension_title=language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.TITLE, - GlobalDialogTitleElements.UNSUPPORTED_EXTENSION, - ], - unsupported_extension=language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.MESSAGE, - GlobalMessageElements.UNSUPPORTED_EXTENSION, - ], - missing_extension=language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.MESSAGE, - GlobalMessageElements.MISSING_EXTENSION, - ], ) @staticmethod diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py index b0a6bb00..deb41b4e 100644 --- a/src/sampletones_application/categories/trackers.py +++ b/src/sampletones_application/categories/trackers.py @@ -62,9 +62,9 @@ class TrackerProjectElements: TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { TrackerFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, - TrackerFormat.BITPHASE: FileFilterElements.BITPHASE_PROJECT, TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, } +"""The file type each instrument-export format is offered under, keyed by its format.""" TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 86b75898..b47f9f7d 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -49,7 +49,6 @@ SUF_PANEL_CENTER, SUF_PANEL_LEFT, SUF_PANEL_RIGHT, - TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION, TAG_GLOBAL_TAB_RECONSTRUCTION, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -87,6 +86,7 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager +from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.trackers.backend import TrackerBackend @@ -354,7 +354,6 @@ def __init__( self._reconstruction_panel_logic.on_open_export_instrument_dialog = self._open_export_instrument_dialog self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog - self._reconstruction_panel_logic.on_unsupported_export_extension = self._show_unsupported_extension self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found( path, self._msg_locate_audio_failed ) @@ -463,31 +462,38 @@ def _open_export_instrument_dialog( self, default_filename: str, default_path: str, + generator_name: GeneratorName, ) -> None: - """Prompts for the file one generator slice is written to. + """Prompts for the file the ``generator_name`` slice is written to. - Every format that writes a single slice is offered at once, so the extension the - destination is given names the tracker it is written for. + Every format that writes a single slice is offered at once, so the type picked in the + dialog names the tracker the slice is written for. """ filepath = save_file_dialog( title=self._ttl_export_instrument, initial_directory=default_path, default_filename=default_filename, - filters=self._instrument_filters(ExportScope.INSTRUMENT), + filters=self._instrument_filters(), ) - self._handle_export_instrument(filepath) + self._handle_export_instrument(filepath, generator_name) - def _instrument_filters(self, scope: ExportScope) -> Tuple[FileFilter, ...]: - """The types a destination for ``scope`` may be given, one per tracker offered. + def _instrument_filters(self) -> Tuple[FileFilter, ...]: + """The types a destination for one slice may be given, one per tracker offered. Naming each tracker's own type puts the trackers an export can reach in the dialog's type selector, so the one that is picked there names the format. """ - return tuple(self._tracker_filter(tracker_format, scope) for tracker_format in INSTRUMENT_EXPORT_FORMATS) + return tuple( + self._tracker_filter(tracker_format, ExportScope.INSTRUMENT) for tracker_format in INSTRUMENT_EXPORT_FORMATS + ) @ignore_none_path - def _handle_export_instrument(self, filepath: Path) -> None: - self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath) + def _handle_export_instrument( + self, + filepath: Path, + generator_name: GeneratorName, + ) -> None: + self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, generator_name) def _open_export_instruments_dialog( self, @@ -520,30 +526,6 @@ def _tracker_filter( [self._tracker_backends[tracker_format].extension(scope)], ) - def _show_unsupported_extension( - self, - extension: str, - supported: Tuple[str, ...], - ) -> None: - """Reports that the destination's extension names no tracker format. - - The extension decides which tracker an export is written for, so one no format - claims leaves nothing to write. The dialog names what the export accepts, and a - destination given no extension at all is told so directly. - """ - messages = self._export_messages - extensions = ", ".join(supported) - message = ( - messages.unsupported_extension.format(extension=extension, extensions=extensions) - if extension - else messages.missing_extension.format(extensions=extensions) - ) - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION, - message, - messages.unsupported_extension_title, - ) - @ignore_none_path def _handle_export_instruments( self, diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 6341de0d..0234748d 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -17,10 +17,7 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.extensions import ( - format_for_extension, - scope_extensions, -) +from sampletones_core.trackers.extensions import format_for_extension from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import ExportScope @@ -74,7 +71,6 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._selected_generators: List[GeneratorName] = [] - self._pending_generator: Optional[GeneratorName] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -83,10 +79,9 @@ def __init__( self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str], None]] = None + self.on_open_export_instrument_dialog: Optional[Callable[[str, str, GeneratorName], None]] = None self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None - self.on_unsupported_export_extension: Optional[Callable[[str, Tuple[str, ...]], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None @@ -172,10 +167,13 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: def request_export_instrument_dialog(self, generator_name: GeneratorName) -> None: """Asks for the destination one generator slice is written to. - Every tracker able to write a single slice is offered at once, so the generator alone - waits here until the dialog answers with a path. The suggestion is the instrument's - name on its own, leaving the tracker to the dialog's file-type selector and to any - extension typed over it. + Every tracker able to write a single slice is offered at once, so the generator travels + with the request to the dialog and back. The suggestion is the instrument's name on its + own, leaving the tracker to the dialog's file-type selector and to any extension typed + over it. + + Args: + generator_name: The generator whose slice is written. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -188,11 +186,11 @@ def request_export_instrument_dialog(self, generator_name: GeneratorName) -> Non instrument_name = self._get_instrument_name(generator_name) default_path = str(self._session_manager.get_instrument_path()) - self._pending_generator = generator_name self.call( self.on_open_export_instrument_dialog, instrument_name, default_path, + generator_name, ) def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: @@ -228,30 +226,34 @@ def request_export_wav_dialog(self) -> None: self.call(self.on_open_export_wav_dialog, default_filename, default_path) - def handle_export_instrument_confirmed(self, filepath: Path) -> None: - """Writes one generator slice of the loaded reconstruction to ``filepath``. + def handle_export_instrument_confirmed( + self, + filepath: Path, + generator_name: GeneratorName, + ) -> None: + """Writes the ``generator_name`` slice of the loaded reconstruction to ``filepath``. The extension picks the tracker the slice is written for, and the instrument carries the name the destination was saved under, so renaming the file in the dialog renames the instrument the tracker lists. + + Args: + filepath: The destination the dialog was confirmed with. + generator_name: The generator whose slice is written. """ - generator = self._pending_generator - self._pending_generator = None - if not self._reconstruction_data or generator is None: + reconstruction_data = self._reconstruction_data + if not reconstruction_data: logger.warning("No reconstruction data available for instrument export") return - tracker_format = self._resolve_tracker_format(filepath, ExportScope.INSTRUMENT) - if tracker_format is None: - return - - feature = self._reconstruction_data.feature_data[generator] + tracker_format = self._tracker_format(filepath, ExportScope.INSTRUMENT) + feature = reconstruction_data.feature_data[generator_name] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, self._tracker_backends[tracker_format], - self._instrument_export(generator, feature, filepath.stem), + self._instrument_export(generator_name, feature, filepath.stem), ) def handle_export_instruments_confirmed( @@ -290,33 +292,29 @@ def handle_export_instruments_confirmed( self._session_manager.set_instrument_path(destination.parent) self._export_service.export_sample(destination, self._tracker_backends[tracker_format], request) - def _resolve_tracker_format( + def _tracker_format( self, destination: Path, scope: ExportScope, - ) -> Optional[TrackerFormat]: + ) -> TrackerFormat: """Reads the tracker format out of the destination's extension. - Reports an extension no format claims through - :attr:`on_unsupported_export_extension`, naming what the scope accepts so the user - can name the destination again. + A save dialog answers with one of the extensions it offered, and an export offers the + types its own formats write, so every destination reaching here names a format. Args: destination: The destination the export was confirmed with. scope: The scope about to be written. Returns: - Optional[TrackerFormat]: The format to write in, or ``None`` when the extension - names none. + TrackerFormat: The format to write in. + + Raises: + ValueError: If no format able to express ``scope`` claims the extension. """ tracker_format = format_for_extension(self._tracker_backends, scope, destination.suffix) if tracker_format is None: - logger.warning(f"No tracker format writes '{destination.suffix}' for a {scope} export") - self.call( - self.on_unsupported_export_extension, - destination.suffix, - scope_extensions(self._tracker_backends, scope), - ) + raise ValueError(f"No tracker format writes '{destination.suffix}' for a {scope} export") return tracker_format diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 0a19a52a..f075d6f5 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -362,12 +362,6 @@ Widget.DIALOG, "path_message", ) -TAG_GLOBAL_DIALOG_UNSUPPORTED_EXTENSION = TagName( - Page.GLOBAL, - Panel.IMPLICIT, - Widget.DIALOG, - "unsupported_extension", -) TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/utils/file_dialogs/portal/client.py b/src/sampletones_application/utils/file_dialogs/portal/client.py index fb7de6c4..c273b76d 100644 --- a/src/sampletones_application/utils/file_dialogs/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/portal/client.py @@ -32,12 +32,21 @@ SUCCESS_CODE: Final[int] = 0 PORTAL_OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = ( - KeyError, # the session bus address is absent from the environment - RuntimeError, # the address names a transport jeepney speaks no dialect of - OSError, # the socket the address names refused the connection + KeyError, + RuntimeError, + OSError, AuthenticationError, - DBusErrorResponse, # the bus answers, and no portal claims the interface + DBusErrorResponse, ) +"""How an environment without a reachable portal announces itself, in jeepney's terms. + +Each stage of reaching the portal has its own failure: ``KeyError`` for a session bus address +absent from the environment, ``RuntimeError`` for an address naming a transport jeepney speaks +no dialect of, ``OSError`` for a socket refusing the connection, ``AuthenticationError`` for a +bus declining the handshake, and ``DBusErrorResponse`` for a bus that answers while no portal +claims the interface. Together they mean the same thing to a caller: dialogs belong to another +backend. +""" FILE_CHOOSER: Final[DBusAddress] = DBusAddress( PORTAL_OBJECT_PATH, diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index d8a610ff..e143638c 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -54,6 +54,10 @@ def _select_linux_backend() -> Optional[FileDialogBackend]: the one the user picked, so a caller offering several types learns which was chosen. Behind it stand the desktop's own command-line tools, and Tk last. """ + portal = _portal_backend() + if portal is not None: + return portal + kdialog = KDialogBackend() if shutil.which(KDIALOG) is not None else None zenity = ZenityBackend() if shutil.which(ZENITY) is not None else None @@ -64,7 +68,7 @@ def _select_linux_backend() -> Optional[FileDialogBackend]: else: preferred, alternative = zenity, kdialog - return _portal_backend() or preferred or alternative or _tkinter_backend() + return preferred or alternative or _tkinter_backend() def _portal_backend() -> Optional[FileDialogBackend]: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7b81e333..a94a96cd 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -44,7 +44,6 @@ global.dialog.title.no_project_open: "No project open" global.dialog.title.remove_sample: "Remove sample" global.dialog.title.change_nes_frequency: "Change NES frequency" global.dialog.title.frequency_mismatch: "Different NES frequency" -global.dialog.title.unsupported_extension: "Unsupported file type" global.dialog.title.about: "About" # Global — Dialog file filters @@ -98,8 +97,6 @@ global.dialog.message.remove_sample: "The sample \"{name}\" is used by one or mo 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.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.unsupported_extension: "The file type \"{extension}\" belongs to no supported tracker. Save the export as one of: {extensions}." -global.dialog.message.missing_extension: "The file name carries no extension, and the extension names the tracker the export is written for. Save the export as one of: {extensions}." global.dialog.message.about_description: "An application for approximating audio samples with the NES 2A03 oscillators and exporting them as FamiTracker instruments/modules." # Global — Dialog templates diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/trackers/extensions.py index 8256080b..7333ea06 100644 --- a/src/sampletones_core/trackers/extensions.py +++ b/src/sampletones_core/trackers/extensions.py @@ -1,59 +1,10 @@ -from typing import Mapping, Optional, Tuple +from typing import Mapping, Optional from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.scope import ExportScope -def scope_extensions( - backends: Mapping[TrackerFormat, TrackerBackend], - scope: ExportScope, -) -> Tuple[str, ...]: - """The extensions a destination for ``scope`` may carry, leading dot included. - - One export action reaches every format able to express the scope, so the dialog that - picks a destination offers all of their extensions at once and the chosen one names - the format. Each extension appears once, in the order the backends were registered. - - Args: - backends: Every backend the application writes through, keyed by its format. - scope: The scope about to be exported. - - Returns: - Tuple[str, ...]: The extension of each format that can express ``scope``. - """ - extensions = (backend.extension(scope) for backend in backends.values() if scope in backend.supported_scopes) - return tuple(dict.fromkeys(extensions)) - - -def default_scope_extension( - backends: Mapping[TrackerFormat, TrackerBackend], - scope: ExportScope, -) -> str: - """The extension a destination for ``scope`` takes when it is given none. - - The extension chooses the format, so a destination has to end in one for the export to - reach a backend. The first format able to express the scope stands in when the user - types a bare name, and it is the extension the save dialog suggests, so the type an - export lands in is on screen before it is confirmed. - - Args: - backends: Every backend the application writes through, keyed by its format. - scope: The scope about to be exported. - - Returns: - str: The extension the scope falls back to, leading dot included. - - Raises: - ValueError: If no format can express ``scope``. - """ - extensions = scope_extensions(backends, scope) - if not extensions: - raise ValueError(f"No tracker format writes a {scope} export") - - return extensions[0] - - def format_for_extension( backends: Mapping[TrackerFormat, TrackerBackend], scope: ExportScope, @@ -61,9 +12,9 @@ def format_for_extension( ) -> Optional[TrackerFormat]: """The format whose ``scope`` files carry ``extension``. - The destination the user names decides which tracker the export is written for, so - the extension it ends in resolves to a format here. Case folds, letting a destination - typed in capitals reach the same backend. + The destination the user names decides which tracker the export is written for, so the + extension it ends in resolves to a format here. Case folds, letting a destination typed + in capitals reach the same backend. Args: backends: Every backend the application writes through, keyed by its format. diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_trackers.py index 4d31834b..7f42d3e7 100644 --- a/tests/unit/sampletones_application/categories/test_trackers.py +++ b/tests/unit/sampletones_application/categories/test_trackers.py @@ -3,10 +3,16 @@ import pytest from sampletones_application.categories.trackers import ( + INSTRUMENT_EXPORT_FORMATS, + TRACKER_INSTRUMENT_FILTERS, TRACKER_PROJECT_ELEMENTS, TRACKER_PROJECT_MENU_LABELS, + TRACKER_SAMPLE_MENU_LABELS, +) +from sampletones_application.utils.gui.shortcuts.ids import ( + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, ) -from sampletones_application.utils.gui.shortcuts.ids import PROJECT_EXPORT_SHORTCUT_IDS from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends @@ -35,11 +41,17 @@ class TestEveryOfferedFormatHasABackend: frozenset(PROJECT_EXPORT_SHORTCUT_IDS), frozenset(TRACKER_PROJECT_MENU_LABELS), frozenset(TRACKER_PROJECT_ELEMENTS), + frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), + frozenset(TRACKER_SAMPLE_MENU_LABELS), + frozenset(INSTRUMENT_EXPORT_FORMATS), ], ids=[ "project_shortcuts", "project_menu", "project_elements", + "sample_shortcuts", + "sample_menu", + "instrument_button", ], ) def test_the_registry_builds_every_offered_format( @@ -54,8 +66,8 @@ class TestTheMenusMatchTheSupportedScopes: """The project submenu lists exactly the formats whose backend writes a project, so a format gains its entry by declaring the scope rather than by a second edit in the UI. - An instrument export names its format through the destination's extension, so the - formats it reaches are covered where that resolution lives. + An instrument export offers a chosen few of the formats able to write its scope, so each + offered format is required to write that scope while the reverse stays a curated choice. """ def test_the_project_export_menu_lists_the_formats_that_write_a_project( @@ -64,6 +76,18 @@ def test_the_project_export_menu_lists_the_formats_that_write_a_project( ) -> None: assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) + def test_the_instruments_menu_offers_formats_that_write_a_whole_sample( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE) + + def test_the_instrument_button_offers_formats_that_write_one_slice( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT) + class TestEveryMenuEntryCarriesAnAction: """A submenu builds its entries by pairing a shortcut id with a label, so the two maps @@ -71,3 +95,15 @@ class TestEveryMenuEntryCarriesAnAction: def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) + + def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) + + +class TestEveryOfferedFormatIsNamedInItsDialog: + """A save dialog offers each format under its own file type, so a format an export offers + without a type name would reach the dialog unnamed.""" + + def test_every_instrument_export_format_carries_a_file_type(self) -> None: + offered = set(INSTRUMENT_EXPORT_FORMATS) | set(TRACKER_SAMPLE_MENU_LABELS) + assert offered <= set(TRACKER_INSTRUMENT_FILTERS) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 955ccf18..242e85f6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -450,16 +450,26 @@ def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE) callback.assert_not_called() - def test_handle_export_instrument_confirmed_with_no_pending_does_not_export( + def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, + ) -> None: + """The generator travels with the request, so the confirmation names it back.""" + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instrument_dialog = callback + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + assert callback.call_args.args[2] == GeneratorName.PULSE1 + + def test_handle_export_instrument_confirmed_with_no_data_does_not_export( + self, + panel_logic: ReconstructionPanelLogic, mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") + panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1) mock_export_service.export_instrument.assert_not_called() def test_handle_export_instrument_confirmed_calls_export_service( @@ -471,9 +481,7 @@ def test_handle_export_instrument_confirmed_calls_export_service( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") + panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1) mock_export_service.export_instrument.assert_called_once() def test_handle_export_instrument_confirmed_names_the_instrument_after_the_destination( @@ -485,9 +493,7 @@ def test_handle_export_instrument_confirmed_names_the_instrument_after_the_desti tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / "Clap (pulse1).fti") + panel_logic.handle_export_instrument_confirmed(tmp_path / "Clap (pulse1).fti", GeneratorName.PULSE1) request = mock_export_service.export_instrument.call_args.args[2] assert request.name == "Clap (pulse1)" @@ -503,30 +509,15 @@ def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_na case: FormatCase, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{case.extension}") + panel_logic.handle_export_instrument_confirmed( + tmp_path / f"instrument{case.extension}", + GeneratorName.PULSE1, + ) backend = mock_export_service.export_instrument.call_args.args[1] assert backend is mock_tracker_backends[case.tracker_format] @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - mock_export_service: MagicMock, - tmp_path: Path, - extension: str, - ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{extension}") - mock_export_service.export_instrument.assert_not_called() - - @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) - def test_handle_export_instrument_confirmed_reports_the_extension_and_what_is_accepted( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -534,15 +525,15 @@ def test_handle_export_instrument_confirmed_reports_the_extension_and_what_is_ac tmp_path: Path, extension: str, ) -> None: + """The dialog answers with one of the types it offered, so an extension naming no + format is a broken invariant rather than a choice to report. + """ mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - callback = MagicMock() - panel_logic.on_unsupported_export_extension = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / f"instrument{extension}") - chosen, supported = callback.call_args.args - assert chosen == extension - assert set(supported) == {EXT_FILE_INSTRUMENT, EXT_FILE_BITPHASE, EXT_FILE_JSON} + with pytest.raises(ValueError): + panel_logic.handle_export_instrument_confirmed( + tmp_path / f"instrument{extension}", + GeneratorName.PULSE1, + ) class TestReconstructionPanelLogicExportInstruments: diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py index 902acfe1..10ac1651 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -10,11 +10,7 @@ EXT_FILE_MODULE, ) from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.extensions import ( - default_scope_extension, - format_for_extension, - scope_extensions, -) +from sampletones_core.trackers.extensions import format_for_extension from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.trackers.scope import ExportScope @@ -74,73 +70,6 @@ def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: return build_tracker_backends() -class TestScopeExtensions: - def test_one_slice_may_be_saved_for_every_format( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(scope_extensions(backends, ExportScope.INSTRUMENT)) == { - EXT_FILE_INSTRUMENT, - EXT_FILE_BITPHASE, - EXT_FILE_JSON, - } - - def test_a_project_reaches_only_the_formats_holding_a_whole_composition( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - """A preset carries one instrument, so its extension stays off a project's list.""" - assert set(scope_extensions(backends, ExportScope.PROJECT)) == { - EXT_FILE_MODULE, - EXT_FILE_BITPHASE, - } - - @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) - def test_each_extension_is_offered_once( - self, - backends: Dict[TrackerFormat, TrackerBackend], - scope: ExportScope, - ) -> None: - extensions = scope_extensions(backends, scope) - assert len(extensions) == len(set(extensions)) - - def test_the_extensions_follow_the_order_the_backends_were_registered( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert scope_extensions(backends, ExportScope.INSTRUMENT) == ( - EXT_FILE_INSTRUMENT, - EXT_FILE_BITPHASE, - EXT_FILE_JSON, - ) - - -class TestDefaultScopeExtension: - @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) - def test_the_default_is_one_of_the_offered_extensions( - self, - backends: Dict[TrackerFormat, TrackerBackend], - scope: ExportScope, - ) -> None: - assert default_scope_extension(backends, scope) in scope_extensions(backends, scope) - - @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) - def test_the_default_resolves_to_a_format( - self, - backends: Dict[TrackerFormat, TrackerBackend], - scope: ExportScope, - ) -> None: - """A destination suggested under the default reaches a backend as it stands, so - confirming the dialog untouched writes a file. - """ - extension = default_scope_extension(backends, scope) - assert format_for_extension(backends, scope, extension) is not None - - def test_a_scope_no_format_writes_is_refused(self) -> None: - with pytest.raises(ValueError): - default_scope_extension({}, ExportScope.INSTRUMENT) - - class TestFormatForExtension: @pytest.mark.parametrize( "case", @@ -173,13 +102,15 @@ def test_a_format_that_cannot_express_the_scope_stays_unmatched( assert format_for_extension(backends, ExportScope.PROJECT, EXT_FILE_JSON) is None @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) - def test_every_offered_extension_resolves( + def test_every_extension_a_backend_writes_resolves_back_to_it( self, backends: Dict[TrackerFormat, TrackerBackend], scope: ExportScope, ) -> None: - """The dialog offers exactly what the resolution accepts, so a destination taking - one of the offered extensions always names a backend. + """A dialog offers the extension of each format it can reach, so a destination taking + one of them names the backend that put it in the selector. Each scope's extensions are + therefore distinct across formats, which is what the resolution reads them as. """ - for extension in scope_extensions(backends, scope): - assert format_for_extension(backends, scope, extension) is not None + for tracker_format, backend in backends.items(): + if scope in backend.supported_scopes: + assert format_for_extension(backends, scope, backend.extension(scope)) == tracker_format From b258e988222b71a95649e37847d75044e130ee0e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 00:37:35 +0200 Subject: [PATCH 15/20] Bounded: the portal dialog wait --- .../utils/file_dialogs/portal/client.py | 97 +++++++++++++++---- .../utils/file_dialogs/portal/test_client.py | 95 ++++++++++++++++-- 2 files changed, 164 insertions(+), 28 deletions(-) diff --git a/src/sampletones_application/utils/file_dialogs/portal/client.py b/src/sampletones_application/utils/file_dialogs/portal/client.py index c273b76d..f860beeb 100644 --- a/src/sampletones_application/utils/file_dialogs/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/portal/client.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final, List, Optional, Tuple, Type, cast +from typing import Deque, Dict, Final, List, Optional, Tuple, Type, cast from jeepney import ( AuthenticationError, @@ -11,7 +11,7 @@ message_bus, new_method_call, ) -from jeepney.io.blocking import open_dbus_connection, unwrap_msg +from jeepney.io.blocking import DBusConnection, open_dbus_connection, unwrap_msg from jeepney.low_level import Message Variant = Tuple[str, object] @@ -22,7 +22,9 @@ PORTAL_OBJECT_PATH: Final[str] = "/org/freedesktop/portal/desktop" FILE_CHOOSER_INTERFACE: Final[str] = "org.freedesktop.portal.FileChooser" REQUEST_INTERFACE: Final[str] = "org.freedesktop.portal.Request" +BUS_INTERFACE: Final[str] = "org.freedesktop.DBus" RESPONSE_SIGNAL: Final[str] = "Response" +NAME_OWNER_CHANGED_SIGNAL: Final[str] = "NameOwnerChanged" VERSION_PROPERTY: Final[str] = "version" CALL_SIGNATURE: Final[str] = "ssa{sv}" @@ -30,6 +32,8 @@ URIS_RESULT: Final[str] = "uris" CURRENT_FILTER_RESULT: Final[str] = "current_filter" SUCCESS_CODE: Final[int] = 0 +BUS_NAME_ARGUMENT: Final[int] = 0 +NO_OWNER: Final[str] = "" PORTAL_OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = ( KeyError, @@ -76,8 +80,10 @@ class FileChooserClient: A call asks the portal for a dialog and answers once the user closes it. The portal replies to the call with the object path of a request and delivers the outcome as a signal on that path, so each call subscribes to the signal before asking and then waits for the response - belonging to its own request. Every dialog runs in the desktop's own portal implementation, - which is what makes the file-type selector and the type it reports available at all. + belonging to its own request. The same subscription covers the bus announcing who owns the + portal's name, which is what tells a waiting call that the portal it is waiting on left. + Every dialog runs in the desktop's own portal implementation, which is what makes the + file-type selector and the type it reports available at all. """ def version(self) -> Optional[int]: @@ -112,13 +118,11 @@ def call( options: The portal options for that method, each value a D-Bus variant. Returns: - Optional[ChooserResult]: What the dialog answered, or ``None`` once it was dismissed. + Optional[ChooserResult]: What the dialog answered, ``None`` once it was dismissed or + once the portal drawing it left the bus. """ - rule = MatchRule( - type="signal", - interface=REQUEST_INTERFACE, - member=RESPONSE_SIGNAL, - ) + response_rule = _response_rule() + owner_rule = _portal_owner_rule() request = new_method_call( FILE_CHOOSER, method, @@ -131,13 +135,66 @@ def call( ) with open_dbus_connection(bus=SESSION_BUS) as connection: - with connection.filter(rule) as responses: - connection.send_and_get_reply(message_bus.AddMatch(rule)) + with connection.filter(response_rule) as signals, connection.filter(owner_rule, queue=signals): + connection.send_and_get_reply(message_bus.AddMatch(response_rule)) + connection.send_and_get_reply(message_bus.AddMatch(owner_rule)) (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request))) - while True: - response = connection.recv_until_filtered(responses) - if _signal_path(response) == handle: - return _read_response(response) + return _answer( + connection, + signals, + handle, + ) + + +def _response_rule() -> MatchRule: + """Subscribes to the outcome of every portal request, each call recognising its own.""" + return MatchRule( + type="signal", + interface=REQUEST_INTERFACE, + member=RESPONSE_SIGNAL, + ) + + +def _portal_owner_rule() -> MatchRule: + """Subscribes to the bus announcing the portal's name changing hands.""" + rule = MatchRule( + type="signal", + interface=BUS_INTERFACE, + member=NAME_OWNER_CHANGED_SIGNAL, + ) + rule.add_arg_condition(BUS_NAME_ARGUMENT, PORTAL_BUS_NAME) + return rule + + +def _answer( + connection: DBusConnection, + signals: Deque[Message], + handle: str, +) -> Optional[ChooserResult]: + """ + Waits for the request ``handle`` to answer, or for the portal owing that answer to leave. + + A dialog stands open for as long as the user takes over it, so the wait runs to the user's + own pace. What bounds it instead is the portal: the bus announces the name being released, + and a released name means the dialog on screen went with the process that drew it, leaving + a request that answers to nobody. That ends the wait the way a dismissal does, since either + way the user named no destination. + """ + while True: + signal = connection.recv_until_filtered(signals) + if _portal_left_the_bus(signal): + return None + + if _signal_path(signal) == handle: + return _read_response(signal) + + +def _portal_left_the_bus(signal: Message) -> bool: + if _signal_member(signal) != NAME_OWNER_CHANGED_SIGNAL: + return False + + _name, _previous_owner, current_owner = cast(Tuple[str, str, str], signal.body) + return current_owner == NO_OWNER def _read_response(response: Message) -> Optional[ChooserResult]: @@ -169,5 +226,9 @@ def _filter_label(results: Dict[str, Variant]) -> Optional[str]: return label -def _signal_path(response: Message) -> Optional[str]: - return cast(Optional[str], response.header.fields.get(HeaderFields.path)) +def _signal_path(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.path)) + + +def _signal_member(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.member)) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py index 83e1135b..70c9ea90 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py @@ -1,13 +1,17 @@ from collections import deque from contextlib import contextmanager from types import SimpleNamespace -from typing import Deque, Dict, Final, Iterator, List, Optional, Tuple +from typing import Deque, Dict, Final, Iterator, List, Optional, Tuple, cast import pytest -from jeepney import HeaderFields, MessageType +from jeepney import HeaderFields, MatchRule, MessageType from sampletones_application.utils.file_dialogs.portal import client as client_module from sampletones_application.utils.file_dialogs.portal.client import ( + NAME_OWNER_CHANGED_SIGNAL, + NO_OWNER, + PORTAL_BUS_NAME, + RESPONSE_SIGNAL, ChooserResult, FileChooserClient, Variant, @@ -16,13 +20,20 @@ HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_42/sampletones" OTHER_HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_7/elsewhere" LABEL: Final[str] = "Bitphase instrument preset (*.json)" +PORTAL_OWNER: Final[str] = ":1.42" def _message( body: Tuple[object, ...], path: Optional[str] = None, + member: Optional[str] = None, ) -> SimpleNamespace: - fields: Dict[HeaderFields, str] = {} if path is None else {HeaderFields.path: path} + fields: Dict[HeaderFields, str] = {} + if path is not None: + fields[HeaderFields.path] = path + if member is not None: + fields[HeaderFields.member] = member + return SimpleNamespace( header=SimpleNamespace(fields=fields, message_type=MessageType.method_return), body=body, @@ -34,7 +45,25 @@ def _response( results: Dict[str, Variant], path: str = HANDLE, ) -> SimpleNamespace: - return _message((code, results), path=path) + return _message( + (code, results), + path=path, + member=RESPONSE_SIGNAL, + ) + + +def _name_owner_changed( + previous_owner: str, + current_owner: str, +) -> SimpleNamespace: + return _message( + ( + PORTAL_BUS_NAME, + previous_owner, + current_owner, + ), + member=NAME_OWNER_CHANGED_SIGNAL, + ) class FakeConnection: @@ -58,9 +87,14 @@ def __exit__(self, *arguments: object) -> None: self.closed = True @contextmanager - def filter(self, rule: object) -> Iterator[Deque[SimpleNamespace]]: + def filter( + self, + rule: object, + *, + queue: Optional[Deque[SimpleNamespace]] = None, + ) -> Iterator[Deque[SimpleNamespace]]: self.rules.append(rule) - yield self._signals + yield self._signals if queue is None else queue def send_and_get_reply(self, message: object) -> SimpleNamespace: member = getattr(message, "header").fields[HeaderFields.member] @@ -111,7 +145,7 @@ def opener(*, bus: str) -> FakeConnection: class TestCall: def test_the_response_to_the_open_request_is_the_answer(self, monkeypatch: pytest.MonkeyPatch) -> None: connection = FakeConnection( - replies=[_message(("ok",)), _message((HANDLE,))], + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], signals=[ _response( 0, @@ -127,12 +161,12 @@ def test_the_response_to_the_open_request_is_the_answer(self, monkeypatch: pytes result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=LABEL) - assert connection.sent == ["AddMatch", "SaveFile"] + assert connection.sent == ["AddMatch", "AddMatch", "SaveFile"] def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: """Every portal response on the bus reaches the subscription, so each call waits for its own.""" connection = FakeConnection( - replies=[_message(("ok",)), _message((HANDLE,))], + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], signals=[ _response(0, {"uris": ("as", ["file:///elsewhere/other.json"])}, path=OTHER_HANDLE), _response(0, {"uris": ("as", ["file:///home/user/kick.json"])}), @@ -146,9 +180,50 @@ def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.Mon def test_a_dismissed_dialog_answers_with_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: connection = FakeConnection( - replies=[_message(("ok",)), _message((HANDLE,))], + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], signals=[_response(1, {})], ) monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) assert FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) is None + + +class TestAPortalLeavingTheBus: + """The portal owes every open dialog its response, so the bus announcing that name released + is what tells a waiting call the answer is never coming.""" + + def test_the_call_subscribes_to_the_portal_s_name(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + subscriptions = [cast(MatchRule, rule).serialise() for rule in connection.rules] + assert any(NAME_OWNER_CHANGED_SIGNAL in rule and PORTAL_BUS_NAME in rule for rule in subscriptions) + + def test_the_name_released_ends_the_wait(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_name_owner_changed(PORTAL_OWNER, NO_OWNER)], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) is None + + def test_the_name_taken_up_leaves_the_dialog_waiting(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A call may be what starts the portal, so the name arriving is the dialog opening.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[ + _name_owner_changed(NO_OWNER, PORTAL_OWNER), + _response(0, {"uris": ("as", ["file:///home/user/kick.json"])}), + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=None) From 3cf6c792a68e8e95d4942429cf2b640f7c5518ba Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 10:59:00 +0200 Subject: [PATCH 16/20] Updated: documentation --- README.md | 2 +- conftest.py | 38 ++++++++++++++++++++++++++++++++ docs/development/dependencies.md | 2 ++ docs/formats/bitphase.md | 5 +++-- docs/formats/reconstructions.md | 4 ++-- docs/guide/files.md | 26 ++++++++++++++++++++-- docs/guide/getting-started.md | 9 ++++---- docs/guide/interface.md | 14 ++++++++---- docs/index.md | 3 ++- 9 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 conftest.py diff --git a/README.md b/README.md index ba05c6df..9997d6ac 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It supports: * `pulse2` * `triangle` * `noise` -* exporting reconstructed audio as FamiTracker `.fti` instruments or as `.wav` +* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav` ## Installation diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..6e674eab --- /dev/null +++ b/conftest.py @@ -0,0 +1,38 @@ +import importlib.util +from pathlib import Path +from typing import Final, Optional, Tuple + +JEEPNEY_MODULE: Final[str] = "jeepney" + +PORTAL_PATHS: Final[Tuple[str, ...]] = ( + "src/sampletones_application/utils/file_dialogs/portal", + "tests/unit/sampletones_application/utils/file_dialogs/portal", + "tests/unit/sampletones_application/utils/file_dialogs/test_selection.py", +) + +PORTAL_LIBRARY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None + + +def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: + """ + Keeps collection to the modules the running platform imports. + + ``jeepney`` is declared for Linux alone, so what speaks to the desktop portal is collected + where that library is installed. The behaviour those modules describe belongs to the Linux + desktop, and the Linux runs of the suite cover it. + + Args: + collection_path: The file or directory pytest is about to look into. + + Returns: + Optional[bool]: ``True`` for a path that stays out of collection, ``None`` to leave the + choice with pytest. + """ + if PORTAL_LIBRARY_INSTALLED: + return None + + root = Path(__file__).parent + if any(collection_path.is_relative_to(root / path) for path in PORTAL_PATHS): + return True + + return None diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 58623fcf..5c09edac 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -22,6 +22,8 @@ Instruction libraries and reconstructions are serialized with [MessagePack](http Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. +`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 5778910f..256757e9 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -149,12 +149,13 @@ from the note, so its preset rows hold a flat offset. ## D. What the exporter builds per scope A `.btp` holds a whole document, so every scope lands in one file; a preset holds one -instrument, so a reconstruction fills a directory of them. +instrument, so a reconstruction lands as a set of them beside the name the export was +given, one per slice. | Scope | `.btp` | `.json` preset | | --- | --- | --- | | One generator slice | a playable document holding that instrument | one file | -| A whole reconstruction | a playable document holding every slice | a directory, one file per slice | +| A whole reconstruction | a playable document holding every slice | one file per slice, beside the chosen name | | A project | the song, its samples and its arrangement | — | **Instrument and reconstruction documents are playable.** Each slice becomes an diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index d6183416..7b455b98 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -55,6 +55,6 @@ application version is stored alongside it, for reference. `.stn` files live in the documents folder. They are binary ([MessagePack](https://msgpack.org/)) with the audio arrays embedded, so a file -is self-contained. The instruction streams can be exported to FamiTracker — one +is self-contained. The instruction streams can be exported to a tracker — one instrument per channel, or a whole module — as described in -[FamiTracker export](famitracker.md). +[FamiTracker export](famitracker.md) and [Bitphase export](bitphase.md). diff --git a/docs/guide/files.md b/docs/guide/files.md index 78d5971c..06ec3d40 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -26,7 +26,29 @@ You can point the library and output folders elsewhere from the **Main** tab's | `.stp` | [project](../formats/projects.md) | `projects/` | | `.fti` | FamiTracker instrument (exported) | wherever you choose | | `.ftm` | FamiTracker module (exported) | wherever you choose | +| `.json` | Bitphase instrument preset (exported) | wherever you choose | +| `.btp` | Bitphase project (exported) | wherever you choose | The `.fti` and `.ftm` files are what you load into -[FamiTracker](../formats/famitracker.md); the other three are _SampleToNES_'s own -formats. +[FamiTracker](../formats/famitracker.md), and `.json` and `.btp` are what +[Bitphase](../formats/bitphase.md) reads; the rest are _SampleToNES_'s own formats. + +## Exported files + +The extension names the tracker an export is written for: `.fti` and `.ftm` go to +FamiTracker, `.json` and `.btp` to Bitphase. The save dialog offers the file types +that fit what you are exporting and fills in the extension of the type it is set to. +Exporting one channel offers both trackers, so switching the type there switches the +tracker; typing an extension yourself picks the tracker directly. + +What you name in the dialog also names what a tracker lists: + +| Export | You name | What is written | +| --- | --- | --- | +| **Instruments** panel ▸ **Export instrument...** | the file | that file, its instrument carrying the name you gave | +| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)` | +| **File ▸ Export** | the file | that file, holding the whole song | + +So exporting a `Kick` reconstruction to FamiTracker instruments writes +`Kick (pulse1).fti`, `Kick (triangle).fti`, and one file for every other channel the +reconstruction uses, all in the folder you chose. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 323ae93e..342022fc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -18,8 +18,9 @@ instruments, and building a whole song. Both assume it is already runs. 5. When it finishes, click **Load** to open the result on the **Reconstructions** tab. -6. Click **Export FamiTracker instruments** and choose a folder. One `.fti` - instrument is written per channel. +6. Choose **Reconstruction ▸ Export instruments ▸ FamiTracker instruments...** and + name the export. One `.fti` file is generated per instrument: `Kick (pulse1).fti`, + `Kick (triangle).fti`, and so on. That is the shortest path from a sound to instruments you can load in FamiTracker. The [interface guide](interface.md) covers the **Main** and **Reconstructions** @@ -37,8 +38,8 @@ tabs in full. sample to a channel with the cell's right-click **Set instrument**. 5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, and **NES frequency** under **Module options**. -6. Choose **Export as FamiTracker module** (or **File ▸ Export FamiTracker - module...**) and pick a path for the `.ftm` file. +6. Choose **File ▸ Export ▸ FamiTracker module...** and pick a path for the `.ftm` + file. **Bitphase project...** beside it writes the same song as a `.btp`. The [sequencer guide](sequencer.md) covers the tracker grid, the order, samples, and undo history in full. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index c0680f53..d9fb86d3 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -39,14 +39,20 @@ Open a saved reconstruction from the list on the left; if the current one has unsaved edits, you are asked whether to save it first. You can play it back and switch **Play audio source:** between **Reconstruction** and **Original audio** to compare the two, and **Locate original audio** re-links the source file if it has -moved. To get your results out, **Export FamiTracker instruments** writes one -`.fti` per channel, **Export reconstruction to WAV** renders the audio, and **Add -to Sequencer** sends the reconstruction into a song as a sample (see the +moved. + +To get your results out, **Reconstruction ▸ Export instruments** writes the +whole reconstruction as one file per channel — `.fti` under **FamiTracker +instruments...**, `.json` under **Bitphase presets...** — and **Reconstruction ▸ +Export to WAV...** renders the audio. **Add to Sequencer**, on a reconstruction's +right-click menu, sends it into a song as a sample (see the [sequencer guide](sequencer.md)). For finer control, the **Instruments** panel on the right shows each channel's instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit -by dragging the bars or typing values, and export one channel at a time. +by dragging the bars or typing values. **Export instrument...** writes the channel +on show, for whichever tracker the save dialog's file type names — see +[where your files live](files.md#exported-files). ## Instructions diff --git a/docs/index.md b/docs/index.md index e2bbaf58..92a22ec4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,8 @@ _SampleToNES_ approximates an audio sample using only the sound channels of the NES's 2A03 chip — two pulse waves, a triangle and noise — and lets you arrange -the results into a song and export them to [FamiTracker](glossary.md#famitracker). +the results into a song and export them to [FamiTracker](glossary.md#famitracker) +or [Bitphase](glossary.md#bitphase). This is the documentation for using it, understanding how it works, and building on it. From bdb1ffa3c5a34f95aefb09ef074abd2cf3e21e55 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 11:57:03 +0200 Subject: [PATCH 17/20] Moved: concrete file-dialog backends into a backends subpackage --- conftest.py | 4 ++-- docs/development/architecture.md | 4 ++-- docs/development/bugs-and-todos.md | 2 +- .../categories/trackers.py | 6 ------ .../coordinators/reconstruction.py | 1 + .../logic/reconstruction/reconstruction.py | 16 +++++++++++++--- .../reconstruction/instruments/instruments.py | 7 ++++--- .../{portal => backends}/__init__.py | 0 .../utils/file_dialogs/{ => backends}/kdialog.py | 0 .../file_dialogs/backends}/portal/__init__.py | 0 .../{ => backends}/portal/backend.py | 6 +++--- .../file_dialogs/{ => backends}/portal/client.py | 10 ---------- .../{tkinter_backend.py => backends/tkinter.py} | 0 .../utils/file_dialogs/{ => backends}/zenity.py | 0 .../file_dialogs/{backend.py => protocol.py} | 0 .../utils/file_dialogs/selection.py | 10 +++++----- .../utils/file_dialogs/backends/__init__.py | 0 .../file_dialogs/backends/portal/__init__.py | 0 .../{ => backends}/portal/test_backend.py | 10 +++++----- .../{ => backends}/portal/test_client.py | 4 ++-- .../file_dialogs/{ => backends}/test_kdialog.py | 4 ++-- .../test_tkinter.py} | 4 ++-- .../file_dialogs/{ => backends}/test_zenity.py | 4 ++-- .../utils/file_dialogs/test_selection.py | 12 ++++++------ 24 files changed, 50 insertions(+), 54 deletions(-) rename src/sampletones_application/utils/file_dialogs/{portal => backends}/__init__.py (100%) rename src/sampletones_application/utils/file_dialogs/{ => backends}/kdialog.py (100%) rename {tests/unit/sampletones_application/utils/file_dialogs => src/sampletones_application/utils/file_dialogs/backends}/portal/__init__.py (100%) rename src/sampletones_application/utils/file_dialogs/{ => backends}/portal/backend.py (98%) rename src/sampletones_application/utils/file_dialogs/{ => backends}/portal/client.py (92%) rename src/sampletones_application/utils/file_dialogs/{tkinter_backend.py => backends/tkinter.py} (100%) rename src/sampletones_application/utils/file_dialogs/{ => backends}/zenity.py (100%) rename src/sampletones_application/utils/file_dialogs/{backend.py => protocol.py} (100%) create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/backends/__init__.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/backends/portal/__init__.py rename tests/unit/sampletones_application/utils/file_dialogs/{ => backends}/portal/test_backend.py (96%) rename tests/unit/sampletones_application/utils/file_dialogs/{ => backends}/portal/test_client.py (97%) rename tests/unit/sampletones_application/utils/file_dialogs/{ => backends}/test_kdialog.py (95%) rename tests/unit/sampletones_application/utils/file_dialogs/{test_tkinter_backend.py => backends/test_tkinter.py} (95%) rename tests/unit/sampletones_application/utils/file_dialogs/{ => backends}/test_zenity.py (95%) diff --git a/conftest.py b/conftest.py index 6e674eab..1fee4a4c 100644 --- a/conftest.py +++ b/conftest.py @@ -5,8 +5,8 @@ JEEPNEY_MODULE: Final[str] = "jeepney" PORTAL_PATHS: Final[Tuple[str, ...]] = ( - "src/sampletones_application/utils/file_dialogs/portal", - "tests/unit/sampletones_application/utils/file_dialogs/portal", + "src/sampletones_application/utils/file_dialogs/backends/portal", + "tests/unit/sampletones_application/utils/file_dialogs/backends/portal", "tests/unit/sampletones_application/utils/file_dialogs/test_selection.py", ) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index b36500ed..24bd3e3d 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -102,7 +102,7 @@ A new exclusive operation joins by contributing its `is_active` to the authority Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. -`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector and reports the one the user picked, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. +`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector and reports the one the user picked, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. @@ -285,7 +285,7 @@ There are two coordinator kinds: | `categories/` | `LanguageManager` and the `Page / Panel / TextType / Element` enum hierarchy used as lookup keys | | `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` | | `constants/` | DPG widget tags (`TAG_*`) and tag suffix fragments (`SUF_*`) | -| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | +| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | | `viewport.py` | Manages DPG viewport geometry and fullscreen state | --- diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 759f9e91..f181a0af 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -28,6 +28,7 @@ * Theme selector and palette management * In-application guide/tutorial +* Language selector ### Technical @@ -41,4 +42,3 @@ ## Bugs * No refreshing after library generation -* Inconsistent instruments naming scheme diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py index deb41b4e..be1a789c 100644 --- a/src/sampletones_application/categories/trackers.py +++ b/src/sampletones_application/categories/trackers.py @@ -54,17 +54,11 @@ class TrackerProjectElements: TrackerFormat.FAMITRACKER, TrackerFormat.BITPHASE_PRESET, ) -"""The formats an instrument export offers, in the order they are listed. - -Both write one file per generator slice, which is what exporting instruments produces. A -Bitphase project holds a whole composition, so it is written through the project export. -""" TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { TrackerFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, } -"""The file type each instrument-export format is offered under, keyed by its format.""" TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 99d72b03..bc1fc6b3 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -194,6 +194,7 @@ def _handle_save_as(self, filepath: Path) -> None: GlobalMessageElements.RECONSTRUCTION_SAVE_FAILED, ], ) + return self._session_manager.set_reconstruction_path(filepath.parent) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 0234748d..ab4a51d3 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -164,7 +164,10 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: ) self._emit_audio_data() - def request_export_instrument_dialog(self, generator_name: GeneratorName) -> None: + def request_export_instrument_dialog( + self, + generator_name: GeneratorName, + ) -> None: """Asks for the destination one generator slice is written to. Every tracker able to write a single slice is offered at once, so the generator travels @@ -193,7 +196,10 @@ def request_export_instrument_dialog(self, generator_name: GeneratorName) -> Non generator_name, ) - def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: + def request_export_instruments_dialog( + self, + tracker_format: TrackerFormat, + ) -> None: """Asks for the destination the loaded reconstruction's slices are named after. The tracker comes from the action that was chosen, so the dialog offers that @@ -290,7 +296,11 @@ def handle_export_instruments_confirmed( nes_frequency=self._nes_frequency(), ) self._session_manager.set_instrument_path(destination.parent) - self._export_service.export_sample(destination, self._tracker_backends[tracker_format], request) + self._export_service.export_sample( + destination, + self._tracker_backends[tracker_format], + request, + ) def _tracker_format( self, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index a5270976..7702e258 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -331,8 +331,6 @@ def _setup_mouse_event_handler(self) -> None: def _export_callback(self, generator_name: GeneratorName) -> VoidCallback: """The press handler for one generator's export button. - - DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it, so the generator is captured in a closure, which carries one. """ return lambda: self.call(self.on_instrument_export, generator_name) @@ -341,7 +339,10 @@ def _create_tabs_for_generators(self) -> None: for generator_name in GeneratorName.items(): self._create_generator_tab(generator_name) - def _generator_kind(self, generator_name: GeneratorName) -> LibraryGeneratorName: + def _generator_kind( + self, + generator_name: GeneratorName, + ) -> LibraryGeneratorName: return GENERATOR_KIND[generator_name] def _generator_features( diff --git a/src/sampletones_application/utils/file_dialogs/portal/__init__.py b/src/sampletones_application/utils/file_dialogs/backends/__init__.py similarity index 100% rename from src/sampletones_application/utils/file_dialogs/portal/__init__.py rename to src/sampletones_application/utils/file_dialogs/backends/__init__.py diff --git a/src/sampletones_application/utils/file_dialogs/kdialog.py b/src/sampletones_application/utils/file_dialogs/backends/kdialog.py similarity index 100% rename from src/sampletones_application/utils/file_dialogs/kdialog.py rename to src/sampletones_application/utils/file_dialogs/backends/kdialog.py diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/__init__.py b/src/sampletones_application/utils/file_dialogs/backends/portal/__init__.py similarity index 100% rename from tests/unit/sampletones_application/utils/file_dialogs/portal/__init__.py rename to src/sampletones_application/utils/file_dialogs/backends/portal/__init__.py diff --git a/src/sampletones_application/utils/file_dialogs/portal/backend.py b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py similarity index 98% rename from src/sampletones_application/utils/file_dialogs/portal/backend.py rename to src/sampletones_application/utils/file_dialogs/backends/portal/backend.py index 95e170c0..d91b921e 100644 --- a/src/sampletones_application/utils/file_dialogs/portal/backend.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py @@ -3,13 +3,13 @@ from typing import Dict, Final, List, Optional, Tuple from urllib.parse import unquote, urlparse -from sampletones_application.utils.file_dialogs.destination import SaveDestination -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.portal.client import ( +from sampletones_application.utils.file_dialogs.backends.portal.client import ( ChooserResult, FileChooserClient, Variant, ) +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter OPEN_FILE_METHOD: Final[str] = "OpenFile" SAVE_FILE_METHOD: Final[str] = "SaveFile" diff --git a/src/sampletones_application/utils/file_dialogs/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py similarity index 92% rename from src/sampletones_application/utils/file_dialogs/portal/client.py rename to src/sampletones_application/utils/file_dialogs/backends/portal/client.py index f860beeb..e5770d9d 100644 --- a/src/sampletones_application/utils/file_dialogs/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py @@ -15,7 +15,6 @@ from jeepney.low_level import Message Variant = Tuple[str, object] -"""A D-Bus variant as jeepney represents it: the value's signature, then the value.""" SESSION_BUS: Final[str] = "SESSION" PORTAL_BUS_NAME: Final[str] = "org.freedesktop.portal.Desktop" @@ -42,15 +41,6 @@ AuthenticationError, DBusErrorResponse, ) -"""How an environment without a reachable portal announces itself, in jeepney's terms. - -Each stage of reaching the portal has its own failure: ``KeyError`` for a session bus address -absent from the environment, ``RuntimeError`` for an address naming a transport jeepney speaks -no dialect of, ``OSError`` for a socket refusing the connection, ``AuthenticationError`` for a -bus declining the handshake, and ``DBusErrorResponse`` for a bus that answers while no portal -claims the interface. Together they mean the same thing to a caller: dialogs belong to another -backend. -""" FILE_CHOOSER: Final[DBusAddress] = DBusAddress( PORTAL_OBJECT_PATH, diff --git a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py b/src/sampletones_application/utils/file_dialogs/backends/tkinter.py similarity index 100% rename from src/sampletones_application/utils/file_dialogs/tkinter_backend.py rename to src/sampletones_application/utils/file_dialogs/backends/tkinter.py diff --git a/src/sampletones_application/utils/file_dialogs/zenity.py b/src/sampletones_application/utils/file_dialogs/backends/zenity.py similarity index 100% rename from src/sampletones_application/utils/file_dialogs/zenity.py rename to src/sampletones_application/utils/file_dialogs/backends/zenity.py diff --git a/src/sampletones_application/utils/file_dialogs/backend.py b/src/sampletones_application/utils/file_dialogs/protocol.py similarity index 100% rename from src/sampletones_application/utils/file_dialogs/backend.py rename to src/sampletones_application/utils/file_dialogs/protocol.py diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index e143638c..2859c5ad 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -3,9 +3,9 @@ import shutil from typing import Final, Optional -from sampletones_application.utils.file_dialogs.backend import FileDialogBackend -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend +from sampletones_application.utils.file_dialogs.protocol import FileDialogBackend from sampletones_shared.exceptions import FileDialogUnavailableError from sampletones_shared.utils.system.system import System @@ -81,7 +81,7 @@ def _portal_backend() -> Optional[FileDialogBackend]: if importlib.util.find_spec(JEEPNEY_MODULE) is None: return None - from sampletones_application.utils.file_dialogs.portal.backend import portal_backend + from sampletones_application.utils.file_dialogs.backends.portal.backend import portal_backend return portal_backend() @@ -97,7 +97,7 @@ def _tkinter_backend() -> Optional[FileDialogBackend]: if importlib.util.find_spec(TKINTER_MODULE) is None: return None - from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend + from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend return TkinterBackend() diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/__init__.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/__init__.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py similarity index 96% rename from tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py index 0fb86085..f03f2df8 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py @@ -3,9 +3,7 @@ import pytest -from sampletones_application.utils.file_dialogs.destination import SaveDestination -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.portal.backend import ( +from sampletones_application.utils.file_dialogs.backends.portal.backend import ( CURRENT_FILTER_OPTION, CURRENT_FOLDER_OPTION, CURRENT_NAME_OPTION, @@ -14,7 +12,9 @@ MINIMUM_FILE_CHOOSER_VERSION, PortalBackend, ) -from sampletones_application.utils.file_dialogs.portal.client import ChooserResult, Variant +from sampletones_application.utils.file_dialogs.backends.portal.client import ChooserResult, Variant +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter FAMITRACKER_FILTER: Final[FileFilter] = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) PRESET_FILTER: Final[FileFilter] = FileFilter(name="Bitphase instrument preset", patterns=("*.json",)) @@ -196,7 +196,7 @@ def test_a_portal_below_the_needed_version_leaves_dialogs_to_another_backend( self, version: Optional[int], ) -> None: - from sampletones_application.utils.file_dialogs.portal import backend as backend_module + from sampletones_application.utils.file_dialogs.backends.portal import backend as backend_module client = FakeClient(None, version=version) with pytest.MonkeyPatch.context() as patcher: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py similarity index 97% rename from tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py index 70c9ea90..a94f24db 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/portal/test_client.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py @@ -6,8 +6,8 @@ import pytest from jeepney import HeaderFields, MatchRule, MessageType -from sampletones_application.utils.file_dialogs.portal import client as client_module -from sampletones_application.utils.file_dialogs.portal.client import ( +from sampletones_application.utils.file_dialogs.backends.portal import client as client_module +from sampletones_application.utils.file_dialogs.backends.portal.client import ( NAME_OWNER_CHANGED_SIGNAL, NO_OWNER, PORTAL_BUS_NAME, diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py similarity index 95% rename from tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py index aebcac46..9e9de294 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py @@ -1,11 +1,11 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend -MODULE = "sampletones_application.utils.file_dialogs.kdialog" +MODULE = "sampletones_application.utils.file_dialogs.backends.kdialog" def _completed(stdout: str) -> MagicMock: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py similarity index 95% rename from tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py index 0428e429..8fd284fc 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py @@ -1,11 +1,11 @@ from pathlib import Path from unittest.mock import patch +from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend -MODULE = "sampletones_application.utils.file_dialogs.tkinter_backend" +MODULE = "sampletones_application.utils.file_dialogs.backends.tkinter" class TestTkinterBackend: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py similarity index 95% rename from tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py index 0c01111a..c6f6d793 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py @@ -2,11 +2,11 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend -MODULE = "sampletones_application.utils.file_dialogs.zenity" +MODULE = "sampletones_application.utils.file_dialogs.backends.zenity" def _completed(stdout: str) -> MagicMock: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py index 8debcc78..dcbf9424 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py @@ -5,17 +5,17 @@ import pytest -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend -from sampletones_application.utils.file_dialogs.portal.backend import PortalBackend -from sampletones_application.utils.file_dialogs.portal.client import FileChooserClient +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.backends.portal.backend import PortalBackend +from sampletones_application.utils.file_dialogs.backends.portal.client import FileChooserClient +from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend -from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend from sampletones_shared.exceptions import FileDialogUnavailableError from sampletones_shared.utils.system.system import System MODULE = "sampletones_application.utils.file_dialogs.selection" -PORTAL_MODULE = "sampletones_application.utils.file_dialogs.portal.backend" +PORTAL_MODULE = "sampletones_application.utils.file_dialogs.backends.portal.backend" def _which(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[str]]: From 7d39248667b230081c61865af83364151045aa9e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 12:09:21 +0200 Subject: [PATCH 18/20] Folded: file-dialog helpers into their backends and shared the command runner --- .../utils/file_dialogs/backends/command.py | 28 ++++++++ .../utils/file_dialogs/backends/kdialog.py | 61 +++++++---------- .../utils/file_dialogs/backends/tkinter.py | 50 +++++++------- .../utils/file_dialogs/backends/zenity.py | 65 ++++++++----------- .../file_dialogs/backends/test_command.py | 40 ++++++++++++ .../file_dialogs/backends/test_kdialog.py | 19 +++--- .../file_dialogs/backends/test_zenity.py | 19 +++--- 7 files changed, 165 insertions(+), 117 deletions(-) create mode 100644 src/sampletones_application/utils/file_dialogs/backends/command.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py diff --git a/src/sampletones_application/utils/file_dialogs/backends/command.py b/src/sampletones_application/utils/file_dialogs/backends/command.py new file mode 100644 index 00000000..96cd0da8 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/command.py @@ -0,0 +1,28 @@ +import subprocess +from pathlib import Path +from typing import List, Optional + +from sampletones_shared.utils.system.paths import normalize_path + + +def run_dialog_command(command: List[str]) -> Optional[Path]: + """ + Runs a command-line dialog tool and returns the path it reports. + + ``kdialog`` and ``zenity`` share one contract: the chosen path arrives on standard output, + and a dismissed dialog leaves that output empty, which answers ``None``. The exit status + carries the same dismissal, so the reported path alone decides the answer. + + Args: + command (List[str]): The tool and the arguments to run it with. + + Returns: + Optional[Path]: The path the dialog reports, or ``None`` once the dialog is dismissed. + """ + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/file_dialogs/backends/kdialog.py b/src/sampletones_application/utils/file_dialogs/backends/kdialog.py index 00e2b986..8c5a5dbe 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/kdialog.py +++ b/src/sampletones_application/utils/file_dialogs/backends/kdialog.py @@ -1,13 +1,12 @@ -import subprocess from pathlib import Path from typing import List, Optional, Tuple +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command from sampletones_application.utils.file_dialogs.destination import ( SaveDestination, untyped_destination, ) from sampletones_application.utils.file_dialogs.filter import FileFilter, merge_filters -from sampletones_shared.utils.system.paths import normalize_path class KDialogBackend: @@ -30,11 +29,11 @@ def open_file( command = [ "kdialog", "--getopenfilename", - _start_location(initial_directory), + self._start_location(initial_directory), ] - command += _filter_arguments(filters) + command += self._filter_arguments(filters) command += ["--title", title] - return _run(command) + return run_dialog_command(command) def save_file( self, @@ -47,14 +46,14 @@ def save_file( command = [ "kdialog", "--getsavefilename", - _start_location( + self._start_location( initial_directory, suggested_name, ), ] - command += _filter_arguments(filters) + command += self._filter_arguments(filters) command += ["--title", title] - return untyped_destination(_run(command)) + return untyped_destination(run_dialog_command(command)) def select_directory( self, @@ -65,38 +64,28 @@ def select_directory( command = [ "kdialog", "--getexistingdirectory", - _start_location(initial_directory), + self._start_location(initial_directory), "--title", title, ] - return _run(command) + return run_dialog_command(command) + @staticmethod + def _start_location( + initial_directory: Optional[Path], + suggested_name: Optional[str] = None, + ) -> str: + base = initial_directory if initial_directory is not None else Path.home() + if suggested_name: + return str(base / suggested_name) -def _start_location( - initial_directory: Optional[Path], - suggested_name: Optional[str] = None, -) -> str: - base = initial_directory if initial_directory is not None else Path.home() - if suggested_name: - return str(base / suggested_name) - - return str(base) - - -def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: - merged = merge_filters(filters) - if merged is None: - return [] - - patterns = " ".join(merged.patterns) - return [f"{patterns}|{merged.label}"] + return str(base) + @staticmethod + def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + merged = merge_filters(filters) + if merged is None: + return [] -def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - return normalize_path(result.stdout.strip()) + patterns = " ".join(merged.patterns) + return [f"{patterns}|{merged.label}"] diff --git a/src/sampletones_application/utils/file_dialogs/backends/tkinter.py b/src/sampletones_application/utils/file_dialogs/backends/tkinter.py index 487ccb48..50c4d903 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/tkinter.py +++ b/src/sampletones_application/utils/file_dialogs/backends/tkinter.py @@ -27,11 +27,11 @@ def open_file( initial_directory: Optional[Path], filters: Tuple[FileFilter, ...], ) -> Optional[Path]: - return _run( + return self._run( lambda: filedialog.askopenfilename( title=title, - initialdir=_initial_directory(initial_directory), - filetypes=_filetypes(filters), + initialdir=self._initial_directory(initial_directory), + filetypes=self._filetypes(filters), ) ) @@ -44,12 +44,12 @@ def save_file( filters: Tuple[FileFilter, ...], ) -> Optional[SaveDestination]: return untyped_destination( - _run( + self._run( lambda: filedialog.asksaveasfilename( title=title, - initialdir=_initial_directory(initial_directory), + initialdir=self._initial_directory(initial_directory), initialfile=suggested_name or "", - filetypes=_filetypes(filters), + filetypes=self._filetypes(filters), ) ) ) @@ -60,30 +60,30 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: - return _run( + return self._run( lambda: filedialog.askdirectory( title=title, - initialdir=_initial_directory(initial_directory), + initialdir=self._initial_directory(initial_directory), ) ) + @staticmethod + def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: + return str(initial_directory) if initial_directory is not None else None -def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: - return str(initial_directory) if initial_directory is not None else None - - -def _filetypes( - filters: Tuple[FileFilter, ...], -) -> List[Tuple[str, Tuple[str, ...]]]: - return [(file_filter.label, file_filter.patterns) for file_filter in filters] - + @staticmethod + def _filetypes( + filters: Tuple[FileFilter, ...], + ) -> List[Tuple[str, Tuple[str, ...]]]: + return [(file_filter.label, file_filter.patterns) for file_filter in filters] -def _run(dialog: Callable[[], str]) -> Optional[Path]: - root = Tk() - root.withdraw() - try: - selection = dialog() - finally: - root.destroy() + @staticmethod + def _run(dialog: Callable[[], str]) -> Optional[Path]: + root = Tk() + root.withdraw() + try: + selection = dialog() + finally: + root.destroy() - return normalize_path(selection) + return normalize_path(selection) diff --git a/src/sampletones_application/utils/file_dialogs/backends/zenity.py b/src/sampletones_application/utils/file_dialogs/backends/zenity.py index 33db8918..2402c507 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/zenity.py +++ b/src/sampletones_application/utils/file_dialogs/backends/zenity.py @@ -1,14 +1,13 @@ import os -import subprocess from pathlib import Path from typing import List, Optional, Tuple +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command from sampletones_application.utils.file_dialogs.destination import ( SaveDestination, untyped_destination, ) from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_shared.utils.system.paths import normalize_path class ZenityBackend: @@ -28,9 +27,9 @@ def open_file( filters: Tuple[FileFilter, ...], ) -> Optional[Path]: command = ["zenity", "--file-selection", "--title", title] - command += _filename_arguments(initial_directory, None) - command += _filter_arguments(filters) - return _run(command) + command += self._filename_arguments(initial_directory, None) + command += self._filter_arguments(filters) + return run_dialog_command(command) def save_file( self, @@ -48,9 +47,9 @@ def save_file( "--title", title, ] - command += _filename_arguments(initial_directory, suggested_name) - command += _filter_arguments(filters) - return untyped_destination(_run(command)) + command += self._filename_arguments(initial_directory, suggested_name) + command += self._filter_arguments(filters) + return untyped_destination(run_dialog_command(command)) def select_directory( self, @@ -65,38 +64,28 @@ def select_directory( "--title", title, ] - command += _filename_arguments(initial_directory, None) - return _run(command) - - -def _filename_arguments( - initial_directory: Optional[Path], - suggested_name: Optional[str], -) -> List[str]: - if initial_directory is None and not suggested_name: - return [] - - base = initial_directory if initial_directory is not None else Path.home() - if suggested_name: - return ["--filename", str(base / suggested_name)] - - return ["--filename", f"{base}{os.sep}"] + command += self._filename_arguments(initial_directory, None) + return run_dialog_command(command) + @staticmethod + def _filename_arguments( + initial_directory: Optional[Path], + suggested_name: Optional[str], + ) -> List[str]: + if initial_directory is None and not suggested_name: + return [] -def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: - arguments: List[str] = [] - for file_filter in filters: - patterns = " ".join(file_filter.patterns) - arguments += ["--file-filter", f"{file_filter.label} | {patterns}"] + base = initial_directory if initial_directory is not None else Path.home() + if suggested_name: + return ["--filename", str(base / suggested_name)] - return arguments + return ["--filename", f"{base}{os.sep}"] + @staticmethod + def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + arguments: List[str] = [] + for file_filter in filters: + patterns = " ".join(file_filter.patterns) + arguments += ["--file-filter", f"{file_filter.label} | {patterns}"] -def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - return normalize_path(result.stdout.strip()) + return arguments diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py new file mode 100644 index 00000000..1673117c --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py @@ -0,0 +1,40 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command + +MODULE = "sampletones_application.utils.file_dialogs.backends.command" + +COMMAND = ["kdialog", "--getopenfilename"] + + +def _completed(stdout: str) -> MagicMock: + result = MagicMock() + result.stdout = stdout + return result + + +class TestRunDialogCommand: + def test_the_reported_path_reaches_the_caller(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: + result = run_dialog_command(COMMAND) + + assert result == Path("/home/user/song.stp") + assert run.call_args.args[0] == COMMAND + + def test_the_tool_answers_on_standard_output(self) -> None: + """The path is read from the captured output, and a dismissal is read from it as well, + so the exit status stays with the caller of the tool. + """ + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav")) as run: + run_dialog_command(COMMAND) + + assert run.call_args.kwargs == {"capture_output": True, "text": True, "check": False} + + def test_surrounding_whitespace_leaves_the_path(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed(" /audio/clip.wav \n")): + assert run_dialog_command(COMMAND) == Path("/audio/clip.wav") + + def test_empty_output_answers_none(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed("\n")): + assert run_dialog_command(COMMAND) is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py index 9e9de294..5e790418 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py @@ -1,4 +1,6 @@ +from contextlib import AbstractContextManager from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend @@ -8,17 +10,16 @@ MODULE = "sampletones_application.utils.file_dialogs.backends.kdialog" -def _completed(stdout: str) -> MagicMock: - result = MagicMock() - result.stdout = stdout - return result +def _chosen(path: Optional[Path]) -> AbstractContextManager[MagicMock]: + """Answers the dialog with ``path``, standing in for what ``kdialog`` reports.""" + return patch(f"{MODULE}.run_dialog_command", return_value=path) class TestKDialogBackend: def test_save_command_carries_suggested_name_and_named_filter(self) -> None: backend = KDialogBackend() file_filter = FileFilter(name="Project files", patterns=("*.stp",)) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: + with _chosen(Path("/home/user/song.stp")) as run: result = backend.save_file( title="Save project", initial_directory=Path("/home/user"), @@ -36,7 +37,7 @@ def test_save_command_carries_suggested_name_and_named_filter(self) -> None: def test_open_command_carries_multi_pattern_filter(self) -> None: backend = KDialogBackend() file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav\n")) as run: + with _chosen(Path("/audio/clip.wav")) as run: result = backend.open_file( title="Open", initial_directory=Path("/audio"), @@ -57,7 +58,7 @@ def test_several_types_gather_into_one_filter_naming_each(self) -> None: FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), FileFilter(name="Bitphase preset", patterns=("*.json",)), ) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/kick.json\n")) as run: + with _chosen(Path("/home/user/kick.json")) as run: backend.save_file( title="Export instrument", initial_directory=Path("/home/user"), @@ -70,7 +71,7 @@ def test_several_types_gather_into_one_filter_naming_each(self) -> None: def test_directory_command_has_no_filter(self) -> None: backend = KDialogBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: + with _chosen(Path("/audio/library")) as run: result = backend.select_directory(title="Choose", initial_directory=Path("/audio")) command = run.call_args.args[0] @@ -80,7 +81,7 @@ def test_directory_command_has_no_filter(self) -> None: def test_cancel_returns_none(self) -> None: backend = KDialogBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("")): + with _chosen(None): result = backend.save_file( title="Save", initial_directory=None, diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py index c6f6d793..aa182eb3 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py @@ -1,5 +1,7 @@ import os +from contextlib import AbstractContextManager from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend @@ -9,17 +11,16 @@ MODULE = "sampletones_application.utils.file_dialogs.backends.zenity" -def _completed(stdout: str) -> MagicMock: - result = MagicMock() - result.stdout = stdout - return result +def _chosen(path: Optional[Path]) -> AbstractContextManager[MagicMock]: + """Answers the dialog with ``path``, standing in for what ``zenity`` reports.""" + return patch(f"{MODULE}.run_dialog_command", return_value=path) class TestZenityBackend: def test_save_command_uses_named_filter_and_filename(self) -> None: backend = ZenityBackend() file_filter = FileFilter(name="Project files", patterns=("*.stp",)) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: + with _chosen(Path("/home/user/song.stp")) as run: result = backend.save_file( title="Save project", initial_directory=Path("/home/user"), @@ -36,7 +37,7 @@ def test_save_command_uses_named_filter_and_filename(self) -> None: def test_open_command_filter_format(self) -> None: backend = ZenityBackend() file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav\n")) as run: + with _chosen(Path("/audio/clip.wav")) as run: backend.open_file(title="Open", initial_directory=Path("/audio"), filters=(file_filter,)) command = run.call_args.args[0] @@ -51,7 +52,7 @@ def test_each_offered_type_reaches_the_selector_as_its_own_entry(self) -> None: FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), FileFilter(name="Bitphase preset", patterns=("*.json",)), ) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/kick.json\n")) as run: + with _chosen(Path("/home/user/kick.json")) as run: backend.save_file( title="Export instrument", initial_directory=Path("/home/user"), @@ -66,7 +67,7 @@ def test_each_offered_type_reaches_the_selector_as_its_own_entry(self) -> None: def test_directory_command_uses_directory_flag(self) -> None: backend = ZenityBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: + with _chosen(Path("/audio/library")) as run: result = backend.select_directory(title="Choose", initial_directory=Path("/audio")) command = run.call_args.args[0] @@ -76,7 +77,7 @@ def test_directory_command_uses_directory_flag(self) -> None: def test_cancel_returns_none(self) -> None: backend = ZenityBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("")): + with _chosen(None): result = backend.open_file( title="Open", initial_directory=None, From 28ec5c2705d26a2ea47f620ae22a120cf38a76bb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 12:19:38 +0200 Subject: [PATCH 19/20] Split: the portal client into variant, response, client, and backend --- .../file_dialogs/backends/portal/backend.py | 211 +++++++++--------- .../file_dialogs/backends/portal/client.py | 161 +++++-------- .../file_dialogs/backends/portal/response.py | 63 ++++++ .../file_dialogs/backends/portal/variant.py | 7 + .../backends/portal/test_backend.py | 3 +- .../backends/portal/test_client.py | 4 +- .../backends/portal/test_response.py | 55 +++++ 7 files changed, 298 insertions(+), 206 deletions(-) create mode 100644 src/sampletones_application/utils/file_dialogs/backends/portal/response.py create mode 100644 src/sampletones_application/utils/file_dialogs/backends/portal/variant.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py index d91b921e..32269afa 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py @@ -3,9 +3,12 @@ from typing import Dict, Final, List, Optional, Tuple from urllib.parse import unquote, urlparse -from sampletones_application.utils.file_dialogs.backends.portal.client import ( - ChooserResult, - FileChooserClient, +from sampletones_application.utils.file_dialogs.backends.portal.client import FileChooserClient +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import ( + BOOLEAN_SIGNATURE, + BYTES_SIGNATURE, + STRING_SIGNATURE, Variant, ) from sampletones_application.utils.file_dialogs.destination import SaveDestination @@ -22,9 +25,6 @@ FILTER_SIGNATURE: Final[str] = "(sa(us))" FILTERS_SIGNATURE: Final[str] = f"a{FILTER_SIGNATURE}" -STRING_SIGNATURE: Final[str] = "s" -BYTES_SIGNATURE: Final[str] = "ay" -BOOLEAN_SIGNATURE: Final[str] = "b" GLOB_PATTERN: Final[int] = 0 """The portal's kind for a filter pattern written as a shell glob.""" @@ -61,12 +61,12 @@ def open_file( result = self._client.call( method=OPEN_FILE_METHOD, title=title, - options=_open_options( + options=self._open_options( initial_directory, filters, ), ) - return _chosen_path(result) + return self._chosen_path(result) def save_file( self, @@ -79,19 +79,22 @@ def save_file( result = self._client.call( method=SAVE_FILE_METHOD, title=title, - options=_save_options( + options=self._save_options( initial_directory, suggested_name, filters, ), ) - path = _chosen_path(result) + path = self._chosen_path(result) if result is None or path is None: return None return SaveDestination( path=path, - file_type=_reported_type(result, filters), + file_type=self._reported_type( + result, + filters, + ), ) def select_directory( @@ -103,9 +106,104 @@ def select_directory( result = self._client.call( method=OPEN_FILE_METHOD, title=title, - options=_directory_options(initial_directory), + options=self._directory_options(initial_directory), ) - return _chosen_path(result) + return self._chosen_path(result) + + @classmethod + def _open_options( + cls, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + return { + **cls._folder_option(initial_directory), + **cls._filter_options(filters), + } + + @classmethod + def _save_options( + cls, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + options: Dict[str, Variant] = { + **cls._folder_option(initial_directory), + **cls._filter_options(filters), + } + if suggested_name: + options[CURRENT_NAME_OPTION] = (STRING_SIGNATURE, suggested_name) + + return options + + @classmethod + def _directory_options( + cls, + initial_directory: Optional[Path], + ) -> Dict[str, Variant]: + return { + **cls._folder_option(initial_directory), + DIRECTORY_OPTION: (BOOLEAN_SIGNATURE, True), + } + + @staticmethod + def _folder_option(initial_directory: Optional[Path]) -> Dict[str, Variant]: + """The folder the dialog opens in, as the NUL-terminated byte string the portal reads.""" + if initial_directory is None: + return {} + + encoded = str(initial_directory).encode() + PATH_TERMINATOR + return {CURRENT_FOLDER_OPTION: (BYTES_SIGNATURE, encoded)} + + @classmethod + def _filter_options( + cls, + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + """ + The types the selector lists, and the one it opens on. + + Naming the first type as the current one opens the dialog on the type a caller offers first, + matching the extension a suggested name carries. + """ + if not filters: + return {} + + listed = [cls._portal_filter(file_filter) for file_filter in filters] + return { + FILTERS_OPTION: (FILTERS_SIGNATURE, listed), + CURRENT_FILTER_OPTION: (FILTER_SIGNATURE, listed[0]), + } + + @staticmethod + def _portal_filter(file_filter: FileFilter) -> PortalFilter: + patterns = [(GLOB_PATTERN, pattern) for pattern in file_filter.patterns] + return (file_filter.label, patterns) + + @staticmethod + def _reported_type( + result: ChooserResult, + filters: Tuple[FileFilter, ...], + ) -> Optional[FileFilter]: + """The offered type whose label the dialog reported, for a portal implementation reporting one.""" + for file_filter in filters: + if file_filter.label == result.filter_label: + return file_filter + + return None + + @staticmethod + def _chosen_path(result: Optional[ChooserResult]) -> Optional[Path]: + """The local path the dialog answered with, for the ``file`` locations the portal hands back.""" + if result is None or not result.uris: + return None + + location = urlparse(result.uris[0]) + if location.scheme != FILE_SCHEME: + return None + + return Path(unquote(location.path)) @lru_cache(maxsize=1) @@ -122,90 +220,3 @@ def portal_backend() -> Optional[PortalBackend]: return None return PortalBackend(client) - - -def _open_options( - initial_directory: Optional[Path], - filters: Tuple[FileFilter, ...], -) -> Dict[str, Variant]: - return { - **_folder_option(initial_directory), - **_filter_options(filters), - } - - -def _save_options( - initial_directory: Optional[Path], - suggested_name: Optional[str], - filters: Tuple[FileFilter, ...], -) -> Dict[str, Variant]: - options: Dict[str, Variant] = { - **_folder_option(initial_directory), - **_filter_options(filters), - } - if suggested_name: - options[CURRENT_NAME_OPTION] = (STRING_SIGNATURE, suggested_name) - - return options - - -def _directory_options(initial_directory: Optional[Path]) -> Dict[str, Variant]: - return { - **_folder_option(initial_directory), - DIRECTORY_OPTION: (BOOLEAN_SIGNATURE, True), - } - - -def _folder_option(initial_directory: Optional[Path]) -> Dict[str, Variant]: - """The folder the dialog opens in, as the NUL-terminated byte string the portal reads.""" - if initial_directory is None: - return {} - - encoded = str(initial_directory).encode() + PATH_TERMINATOR - return {CURRENT_FOLDER_OPTION: (BYTES_SIGNATURE, encoded)} - - -def _filter_options(filters: Tuple[FileFilter, ...]) -> Dict[str, Variant]: - """ - The types the selector lists, and the one it opens on. - - Naming the first type as the current one opens the dialog on the type a caller offers first, - matching the extension a suggested name carries. - """ - if not filters: - return {} - - listed = [_portal_filter(file_filter) for file_filter in filters] - return { - FILTERS_OPTION: (FILTERS_SIGNATURE, listed), - CURRENT_FILTER_OPTION: (FILTER_SIGNATURE, listed[0]), - } - - -def _portal_filter(file_filter: FileFilter) -> PortalFilter: - patterns = [(GLOB_PATTERN, pattern) for pattern in file_filter.patterns] - return (file_filter.label, patterns) - - -def _reported_type( - result: ChooserResult, - filters: Tuple[FileFilter, ...], -) -> Optional[FileFilter]: - """The offered type whose label the dialog reported, for a portal implementation reporting one.""" - for file_filter in filters: - if file_filter.label == result.filter_label: - return file_filter - - return None - - -def _chosen_path(result: Optional[ChooserResult]) -> Optional[Path]: - """The local path the dialog answered with, for the ``file`` locations the portal hands back.""" - if result is None or not result.uris: - return None - - location = urlparse(result.uris[0]) - if location.scheme != FILE_SCHEME: - return None - - return Path(unquote(location.path)) diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py index e5770d9d..e4b79214 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py @@ -1,5 +1,4 @@ -from dataclasses import dataclass -from typing import Deque, Dict, Final, List, Optional, Tuple, Type, cast +from typing import Deque, Dict, Final, Optional, Tuple, Type, cast from jeepney import ( AuthenticationError, @@ -14,7 +13,8 @@ from jeepney.io.blocking import DBusConnection, open_dbus_connection, unwrap_msg from jeepney.low_level import Message -Variant = Tuple[str, object] +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant SESSION_BUS: Final[str] = "SESSION" PORTAL_BUS_NAME: Final[str] = "org.freedesktop.portal.Desktop" @@ -28,9 +28,6 @@ CALL_SIGNATURE: Final[str] = "ssa{sv}" PARENT_WINDOW: Final[str] = "" -URIS_RESULT: Final[str] = "uris" -CURRENT_FILTER_RESULT: Final[str] = "current_filter" -SUCCESS_CODE: Final[int] = 0 BUS_NAME_ARGUMENT: Final[int] = 0 NO_OWNER: Final[str] = "" @@ -49,20 +46,6 @@ ) -@dataclass(frozen=True) -class ChooserResult: - """ - What a file-chooser dialog answered with. - - ``uris`` carries the chosen locations in the dialog's own order. ``filter_label`` is the - label of the type its selector stood on, present for a portal implementation that reports - the selection. - """ - - uris: Tuple[str, ...] - filter_label: Optional[str] - - class FileChooserClient: """ The desktop portal's ``FileChooser`` interface, reached over the session bus. @@ -111,8 +94,8 @@ def call( Optional[ChooserResult]: What the dialog answered, ``None`` once it was dismissed or once the portal drawing it left the bus. """ - response_rule = _response_rule() - owner_rule = _portal_owner_rule() + response_rule = self._response_rule() + owner_rule = self._portal_owner_rule() request = new_method_call( FILE_CHOOSER, method, @@ -129,96 +112,68 @@ def call( connection.send_and_get_reply(message_bus.AddMatch(response_rule)) connection.send_and_get_reply(message_bus.AddMatch(owner_rule)) (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request))) - return _answer( + return self._answer( connection, signals, handle, ) + @staticmethod + def _response_rule() -> MatchRule: + """Subscribes to the outcome of every portal request, each call recognising its own.""" + return MatchRule( + type="signal", + interface=REQUEST_INTERFACE, + member=RESPONSE_SIGNAL, + ) -def _response_rule() -> MatchRule: - """Subscribes to the outcome of every portal request, each call recognising its own.""" - return MatchRule( - type="signal", - interface=REQUEST_INTERFACE, - member=RESPONSE_SIGNAL, - ) - - -def _portal_owner_rule() -> MatchRule: - """Subscribes to the bus announcing the portal's name changing hands.""" - rule = MatchRule( - type="signal", - interface=BUS_INTERFACE, - member=NAME_OWNER_CHANGED_SIGNAL, - ) - rule.add_arg_condition(BUS_NAME_ARGUMENT, PORTAL_BUS_NAME) - return rule - - -def _answer( - connection: DBusConnection, - signals: Deque[Message], - handle: str, -) -> Optional[ChooserResult]: - """ - Waits for the request ``handle`` to answer, or for the portal owing that answer to leave. - - A dialog stands open for as long as the user takes over it, so the wait runs to the user's - own pace. What bounds it instead is the portal: the bus announces the name being released, - and a released name means the dialog on screen went with the process that drew it, leaving - a request that answers to nobody. That ends the wait the way a dismissal does, since either - way the user named no destination. - """ - while True: - signal = connection.recv_until_filtered(signals) - if _portal_left_the_bus(signal): - return None - - if _signal_path(signal) == handle: - return _read_response(signal) - - -def _portal_left_the_bus(signal: Message) -> bool: - if _signal_member(signal) != NAME_OWNER_CHANGED_SIGNAL: - return False - - _name, _previous_owner, current_owner = cast(Tuple[str, str, str], signal.body) - return current_owner == NO_OWNER - - -def _read_response(response: Message) -> Optional[ChooserResult]: - code, results = cast(Tuple[int, Dict[str, Variant]], response.body) - if code != SUCCESS_CODE: - return None - - return ChooserResult( - uris=_uris(results), - filter_label=_filter_label(results), - ) - - -def _uris(results: Dict[str, Variant]) -> Tuple[str, ...]: - uris = results.get(URIS_RESULT) - if uris is None: - return () - - return tuple(cast(List[str], uris[1])) - + @staticmethod + def _portal_owner_rule() -> MatchRule: + """Subscribes to the bus announcing the portal's name changing hands.""" + rule = MatchRule( + type="signal", + interface=BUS_INTERFACE, + member=NAME_OWNER_CHANGED_SIGNAL, + ) + rule.add_arg_condition(BUS_NAME_ARGUMENT, PORTAL_BUS_NAME) + return rule + + @classmethod + def _answer( + cls, + connection: DBusConnection, + signals: Deque[Message], + handle: str, + ) -> Optional[ChooserResult]: + """ + Waits for the request ``handle`` to answer, or for the portal owing that answer to leave. -def _filter_label(results: Dict[str, Variant]) -> Optional[str]: - """The label of the type the dialog stood on, as the portal reports the whole filter back.""" - reported = results.get(CURRENT_FILTER_RESULT) - if reported is None: - return None + A dialog stands open for as long as the user takes over it, so the wait runs to the user's + own pace. What bounds it instead is the portal: the bus announces the name being released, + and a released name means the dialog on screen went with the process that drew it, leaving + a request that answers to nobody. That ends the wait the way a dismissal does, since either + way the user named no destination. + """ + while True: + signal = connection.recv_until_filtered(signals) + if cls._portal_left_the_bus(signal): + return None - label, _patterns = cast(Tuple[str, List[Tuple[int, str]]], reported[1]) - return label + if cls._signal_path(signal) == handle: + return ChooserResult.from_response(signal) + @classmethod + def _portal_left_the_bus(cls, signal: Message) -> bool: + if cls._signal_member(signal) != NAME_OWNER_CHANGED_SIGNAL: + return False -def _signal_path(signal: Message) -> Optional[str]: - return cast(Optional[str], signal.header.fields.get(HeaderFields.path)) + _name, _previous_owner, current_owner = cast(Tuple[str, str, str], signal.body) + return current_owner == NO_OWNER + @staticmethod + def _signal_path(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.path)) -def _signal_member(signal: Message) -> Optional[str]: - return cast(Optional[str], signal.header.fields.get(HeaderFields.member)) + @staticmethod + def _signal_member(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.member)) diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/response.py b/src/sampletones_application/utils/file_dialogs/backends/portal/response.py new file mode 100644 index 00000000..f8aa5bb6 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/response.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Self, Tuple, cast + +from jeepney.low_level import Message + +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +SUCCESS_CODE: Final[int] = 0 +URIS_RESULT: Final[str] = "uris" +CURRENT_FILTER_RESULT: Final[str] = "current_filter" + + +@dataclass(frozen=True) +class ChooserResult: + """ + What a file-chooser dialog answered with. + + ``uris`` carries the chosen locations in the dialog's own order. ``filter_label`` is the + label of the type its selector stood on, present for a portal implementation that reports + the selection. + """ + + uris: Tuple[str, ...] + filter_label: Optional[str] + + @classmethod + def from_response(cls, response: Message) -> Optional[Self]: + """ + Reads what a dialog answered from the response signal carrying it. + + Args: + response: The ``Response`` signal the portal delivers on a request's object path. + + Returns: + Optional[Self]: What the dialog answered, ``None`` for a code other than success, + which is how the portal reports a dismissal. + """ + code, results = cast(Tuple[int, Dict[str, Variant]], response.body) + if code != SUCCESS_CODE: + return None + + return cls( + uris=cls._uris(results), + filter_label=cls._filter_label(results), + ) + + @staticmethod + def _uris(results: Dict[str, Variant]) -> Tuple[str, ...]: + uris = results.get(URIS_RESULT) + if uris is None: + return () + + return tuple(cast(List[str], uris[1])) + + @staticmethod + def _filter_label(results: Dict[str, Variant]) -> Optional[str]: + """The label of the type the dialog stood on, as the portal reports the whole filter back.""" + reported = results.get(CURRENT_FILTER_RESULT) + if reported is None: + return None + + label, _patterns = cast(Tuple[str, List[Tuple[int, str]]], reported[1]) + return label diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py b/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py new file mode 100644 index 00000000..d1d2fb0b --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py @@ -0,0 +1,7 @@ +from typing import Final, Tuple + +Variant = Tuple[str, object] + +STRING_SIGNATURE: Final[str] = "s" +BYTES_SIGNATURE: Final[str] = "ay" +BOOLEAN_SIGNATURE: Final[str] = "b" diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py index f03f2df8..9112fe24 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py @@ -12,7 +12,8 @@ MINIMUM_FILE_CHOOSER_VERSION, PortalBackend, ) -from sampletones_application.utils.file_dialogs.backends.portal.client import ChooserResult, Variant +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py index a94f24db..c860fca8 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py @@ -12,10 +12,10 @@ NO_OWNER, PORTAL_BUS_NAME, RESPONSE_SIGNAL, - ChooserResult, FileChooserClient, - Variant, ) +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_42/sampletones" OTHER_HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_7/elsewhere" diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py new file mode 100644 index 00000000..605431b3 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py @@ -0,0 +1,55 @@ +from types import SimpleNamespace +from typing import Dict, Final + +from sampletones_application.utils.file_dialogs.backends.portal.response import ( + CURRENT_FILTER_RESULT, + SUCCESS_CODE, + URIS_RESULT, + ChooserResult, +) +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +URI: Final[str] = "file:///home/user/kick.json" +OTHER_URI: Final[str] = "file:///home/user/snare.json" +LABEL: Final[str] = "Bitphase instrument preset (*.json)" +DISMISSED_CODE: Final[int] = 1 + +URIS_SIGNATURE: Final[str] = "as" +FILTER_SIGNATURE: Final[str] = "(sa(us))" + + +def _response( + code: int, + results: Dict[str, Variant], +) -> SimpleNamespace: + return SimpleNamespace(body=(code, results)) + + +class TestChooserResult: + def test_the_chosen_locations_arrive_in_the_dialog_s_order(self) -> None: + response = _response(SUCCESS_CODE, {URIS_RESULT: (URIS_SIGNATURE, [URI, OTHER_URI])}) + + assert ChooserResult.from_response(response) == ChooserResult( + uris=(URI, OTHER_URI), + filter_label=None, + ) + + def test_the_reported_filter_names_the_type_the_selector_stood_on(self) -> None: + """The portal reports the whole filter, and its label is what names the type.""" + response = _response( + SUCCESS_CODE, + { + URIS_RESULT: (URIS_SIGNATURE, [URI]), + CURRENT_FILTER_RESULT: (FILTER_SIGNATURE, (LABEL, [(0, "*.json")])), + }, + ) + + assert ChooserResult.from_response(response) == ChooserResult(uris=(URI,), filter_label=LABEL) + + def test_a_dismissal_answers_with_nothing(self) -> None: + assert ChooserResult.from_response(_response(DISMISSED_CODE, {})) is None + + def test_a_response_carrying_no_locations_answers_with_none_chosen(self) -> None: + response = _response(SUCCESS_CODE, {CURRENT_FILTER_RESULT: (FILTER_SIGNATURE, (LABEL, []))}) + + assert ChooserResult.from_response(response) == ChooserResult(uris=(), filter_label=LABEL) From d8f3620e24ae635e1bb8559086f368f89cfd2542 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 4 Aug 2026 14:45:51 +0200 Subject: [PATCH 20/20] Fixed: portal dialogs opening behind the application window --- docs/development/architecture.md | 2 +- .../file_dialogs/backends/portal/client.py | 7 +- .../file_dialogs/backends/portal/parent.py | 191 ++++++++++++++++++ .../backends/portal/test_client.py | 20 ++ .../backends/portal/test_parent.py | 80 ++++++++ 5 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 src/sampletones_application/utils/file_dialogs/backends/portal/parent.py create mode 100644 tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 24bd3e3d..2214d5b6 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -102,7 +102,7 @@ A new exclusive operation joins by contributing its `is_active` to the authority Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. -`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector and reports the one the user picked, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. +`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py index e4b79214..2a7beea7 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py @@ -13,6 +13,7 @@ from jeepney.io.blocking import DBusConnection, open_dbus_connection, unwrap_msg from jeepney.low_level import Message +from sampletones_application.utils.file_dialogs.backends.portal.parent import parent_window_handle from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant @@ -27,7 +28,6 @@ VERSION_PROPERTY: Final[str] = "version" CALL_SIGNATURE: Final[str] = "ssa{sv}" -PARENT_WINDOW: Final[str] = "" BUS_NAME_ARGUMENT: Final[int] = 0 NO_OWNER: Final[str] = "" @@ -85,6 +85,9 @@ def call( """ Opens the dialog ``method`` names and waits for the user to answer it. + The call names this application's window as the dialog's parent, which is what places + the dialog over the window it was asked from. + Args: method: The ``FileChooser`` method to call, naming the kind of dialog to open. title: The window title the dialog carries. @@ -101,7 +104,7 @@ def call( method, CALL_SIGNATURE, ( - PARENT_WINDOW, + parent_window_handle(), title, options, ), diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py b/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py new file mode 100644 index 00000000..55c9ee11 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py @@ -0,0 +1,191 @@ +import ctypes +import os +from ctypes import CDLL, POINTER, byref, c_char_p, c_int, c_long, c_ubyte, c_ulong, c_void_p +from typing import Final, List, Optional, Self + +X11_LIBRARY: Final[str] = "libX11.so.6" + +CLIENT_LIST_PROPERTY: Final[bytes] = b"_NET_CLIENT_LIST" +PROCESS_PROPERTY: Final[bytes] = b"_NET_WM_PID" + +WINDOW_ATOM: Final[int] = 33 +CARDINAL_ATOM: Final[int] = 6 + +NO_ATOM: Final[int] = 0 +PROPERTY_READ: Final[int] = 0 + +PROPERTY_OFFSET: Final[int] = 0 +PROPERTY_WORD_LIMIT: Final[int] = 1024 + +ATOM_MUST_EXIST: Final[bool] = True +KEEP_PROPERTY: Final[bool] = False + +X11_HANDLE_PREFIX: Final[str] = "x11:" +NO_PARENT_WINDOW: Final[str] = "" + + +class X11Display: + """ + A connection to the X server, opened to read the windows an X11 desktop manages. + + The desktop lists the windows it manages on the root window and each listed window carries + the process that owns it, so an application finds its own window by the process it runs as. + A connection holds a socket to the server for as long as it stays open, which ``close`` + releases once a lookup is done with it. + """ + + def __init__( + self, + library: CDLL, + display: int, + ) -> None: + self._library = library + self._display = display + + @classmethod + def open(cls) -> Optional[Self]: + """ + Opens the display the environment names. + + Returns: + Optional[Self]: The open connection, ``None`` where libX11 is out of reach or the + environment names no server, which is how a session running without X11 answers. + """ + try: + library = CDLL(X11_LIBRARY) + except OSError: + return None + + cls._declare_signatures(library) + display = library.XOpenDisplay(None) + if not display: + return None + + return cls(library, display) + + def close(self) -> None: + """Releases the connection to the server.""" + self._library.XCloseDisplay(self._display) + + def window_of_process(self, process_id: int) -> Optional[int]: + """ + Returns the identifier of the window the desktop manages for a process. + + The window list and the process owning a window are properties the X server gives types + of its own, which a read names by the atoms those types are known under. + + Args: + process_id: The process whose window to look for. + + Returns: + Optional[int]: The first listed window that process owns, ``None`` where the desktop + lists none for it. + """ + root = self._library.XDefaultRootWindow(self._display) + for window in self._numbers(root, CLIENT_LIST_PROPERTY, WINDOW_ATOM): + if process_id in self._numbers(window, PROCESS_PROPERTY, CARDINAL_ATOM): + return window + + return None + + def _numbers( + self, + window: int, + name: bytes, + value_type: int, + ) -> List[int]: + """ + The numbers a window's property holds, empty for a window carrying no such property. + + The server reports what it read through the values passed by reference, and owns the + array it answers with until ``XFree`` releases it. A property of the 32-bit format + arrives as an array of C longs, which is what its values are read as, and one read takes + as many of those words as a desktop's window list needs. + """ + atom = self._library.XInternAtom(self._display, name, ATOM_MUST_EXIST) + if atom == NO_ATOM: + return [] + + type_read = c_ulong(0) + format_read = c_int(0) + items_read = c_ulong(0) + remaining = c_ulong(0) + values = POINTER(c_ubyte)() + status = self._library.XGetWindowProperty( + self._display, + window, + atom, + PROPERTY_OFFSET, + PROPERTY_WORD_LIMIT, + KEEP_PROPERTY, + value_type, + byref(type_read), + byref(format_read), + byref(items_read), + byref(remaining), + byref(values), + ) + if status != PROPERTY_READ or not values: + return [] + + try: + numbers = ctypes.cast(values, POINTER(c_ulong)) + return [int(numbers[index]) for index in range(items_read.value)] + finally: + self._library.XFree(values) + + @staticmethod + def _declare_signatures(library: CDLL) -> None: + """The types of the libX11 calls a lookup makes, which ctypes reads to marshal them.""" + library.XOpenDisplay.argtypes = [c_char_p] + library.XOpenDisplay.restype = c_void_p + library.XCloseDisplay.argtypes = [c_void_p] + library.XCloseDisplay.restype = c_int + library.XDefaultRootWindow.argtypes = [c_void_p] + library.XDefaultRootWindow.restype = c_ulong + library.XInternAtom.argtypes = [c_void_p, c_char_p, c_int] + library.XInternAtom.restype = c_ulong + library.XGetWindowProperty.argtypes = [ + c_void_p, + c_ulong, + c_ulong, + c_long, + c_long, + c_int, + c_ulong, + POINTER(c_ulong), + POINTER(c_int), + POINTER(c_ulong), + POINTER(c_ulong), + POINTER(POINTER(c_ubyte)), + ] + library.XGetWindowProperty.restype = c_int + library.XFree.argtypes = [c_void_p] + library.XFree.restype = c_int + + +def parent_window_handle() -> str: + """ + Returns the handle naming the window a portal dialog belongs to. + + The portal gives a dialog the window that asked for it as its parent, which is what keeps + the dialog above that window and lets the desktop place it there. An X11 desktop names a + window by its identifier written in hexadecimal. + + Returns: + str: The handle naming this application's window, empty where the session names no such + window, which asks the portal for a dialog standing on its own. + """ + display = X11Display.open() + if display is None: + return NO_PARENT_WINDOW + + try: + window_id = display.window_of_process(os.getpid()) + finally: + display.close() + + if window_id is None: + return NO_PARENT_WINDOW + + return f"{X11_HANDLE_PREFIX}{window_id:x}" diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py index c860fca8..7342d00e 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py @@ -21,6 +21,7 @@ OTHER_HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_7/elsewhere" LABEL: Final[str] = "Bitphase instrument preset (*.json)" PORTAL_OWNER: Final[str] = ":1.42" +PARENT_WINDOW: Final[str] = "x11:2200132" def _message( @@ -77,6 +78,7 @@ def __init__( self._replies = deque(replies) self._signals = deque(signals) self.sent: List[str] = [] + self.bodies: List[Tuple[object, ...]] = [] self.rules: List[object] = [] self.closed = False @@ -99,6 +101,7 @@ def filter( def send_and_get_reply(self, message: object) -> SimpleNamespace: member = getattr(message, "header").fields[HeaderFields.member] self.sent.append(member) + self.bodies.append(getattr(message, "body")) return self._replies.popleft() def recv_until_filtered(self, queue: Deque[SimpleNamespace]) -> SimpleNamespace: @@ -163,6 +166,23 @@ def test_the_response_to_the_open_request_is_the_answer(self, monkeypatch: pytes assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=LABEL) assert connection.sent == ["AddMatch", "AddMatch", "SaveFile"] + def test_the_dialog_names_the_application_s_window_as_its_parent(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The window a dialog belongs to is what the portal places it over.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + monkeypatch.setattr(client_module, "parent_window_handle", lambda: PARENT_WINDOW) + + FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert connection.bodies[-1] == ( + PARENT_WINDOW, + "Export instrument", + {}, + ) + def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: """Every portal response on the bus reaches the subscription, so each call waits for its own.""" connection = FakeConnection( diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py new file mode 100644 index 00000000..30e035a6 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py @@ -0,0 +1,80 @@ +import os +from typing import Final, List, Optional, Type + +import pytest + +from sampletones_application.utils.file_dialogs.backends.portal import parent as parent_module +from sampletones_application.utils.file_dialogs.backends.portal.parent import ( + NO_PARENT_WINDOW, + parent_window_handle, +) + +WINDOW_ID: Final[int] = 0x2200132 +HANDLE: Final[str] = "x11:2200132" + + +class FakeDisplay: + """An X server answering with one prepared window, recording the lookups and its release.""" + + def __init__(self, window_id: Optional[int]) -> None: + self._window_id = window_id + self.processes: List[int] = [] + self.closed = False + + def window_of_process(self, process_id: int) -> Optional[int]: + self.processes.append(process_id) + return self._window_id + + def close(self) -> None: + self.closed = True + + +def _opening(display: Optional[FakeDisplay]) -> Type[object]: + class FakeX11Display: + @staticmethod + def open() -> Optional[FakeDisplay]: + return display + + return FakeX11Display + + +class TestParentWindowHandle: + def test_the_handle_names_the_window_in_hexadecimal(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(parent_module, "X11Display", _opening(FakeDisplay(WINDOW_ID))) + + assert parent_window_handle() == HANDLE + + def test_the_window_looked_for_is_the_one_this_process_draws_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + display = FakeDisplay(WINDOW_ID) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + parent_window_handle() + + assert display.processes == [os.getpid()] + + def test_the_connection_is_released_once_the_window_is_found(self, monkeypatch: pytest.MonkeyPatch) -> None: + display = FakeDisplay(WINDOW_ID) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + parent_window_handle() + + assert display.closed + + def test_a_desktop_listing_no_window_for_this_process_leaves_the_dialog_on_its_own( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + display = FakeDisplay(None) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + assert parent_window_handle() == NO_PARENT_WINDOW + assert display.closed + + def test_a_session_running_without_x11_leaves_the_dialog_on_its_own( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A display that stays closed is how a session with no X server answers.""" + monkeypatch.setattr(parent_module, "X11Display", _opening(None)) + + assert parent_window_handle() == NO_PARENT_WINDOW