From 250f2d69d5c041a4fee90ff039f430119345ba9e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 23:04:31 +0200 Subject: [PATCH 01/10] Added: tick clock for exact engine-tick duration in samples --- .../logic/sequencer/playback/protocol.py | 6 + .../logic/sequencer/playback/synthesizer.py | 133 ++++++++---- src/sampletones_core/generators/generator.py | 17 +- src/sampletones_core/timing/__init__.py | 2 + src/sampletones_core/timing/clock.py | 98 +++++++++ .../sequencer/playback/test_tick_clock.py | 175 +++++++++++++++ .../sampletones_core/timing/test_clock.py | 200 ++++++++++++++++++ 7 files changed, 592 insertions(+), 39 deletions(-) create mode 100644 src/sampletones_core/timing/clock.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py create mode 100644 tests/unit/sampletones_core/timing/test_clock.py diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py index 71986e11..c605c864 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -17,8 +17,14 @@ class ChannelGeneratorProtocol(Protocol): The instruction parameter is typed ``Any`` because the generator-to-instruction pairing is a runtime invariant maintained by ``GENERATOR_CLASSES`` dispatch, which lies outside the static type system. + + ``frame_length`` is settable so the synthesiser can give each tick the span its clock + states, which is what keeps a rendered tick lasting ``1 / nes_frequency`` seconds at a + sample rate the tick divides unevenly. """ + frame_length: int + def __call__( self, instruction: Any, diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 01d943f8..9cadf7f4 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field, replace +from itertools import accumulate from typing import Callable, Dict, FrozenSet, List, Optional, Tuple import numpy as np @@ -27,7 +28,7 @@ from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove +from sampletones_core.timing import Groove, Metre, RowRate, TickClock, calculate_groove from .protocol import ChannelGeneratorProtocol @@ -79,6 +80,48 @@ def groove(self) -> Groove: ) +@dataclass(frozen=True) +class _RowFrames: + """Where each of a row's ticks starts and ends within the row's audio. + + A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the + lengths within one row vary where the sample rate does not divide the tick rate. Resolving the + boundaries once per row is what lets every channel write into the same offsets. + + Attributes: + lengths: The samples each of the row's ticks spans, in order. + bounds: Each tick's start offset, ending with the row's total length. + """ + + lengths: Tuple[int, ...] + bounds: Tuple[int, ...] + + @classmethod + def from_clock( + cls, + clock: TickClock, + *, + elapsed_ticks: int, + ticks: int, + ) -> _RowFrames: + """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" + lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) + return cls( + lengths=lengths, + bounds=tuple(accumulate(lengths, initial=0)), + ) + + @property + def total(self) -> int: + """The samples the whole row spans.""" + return self.bounds[-1] + + @property + def longest(self) -> int: + """The samples the row's longest tick spans.""" + return max(self.lengths, default=0) + + def _silence(samples: int) -> np.ndarray: return np.zeros(samples, dtype=np.float32) @@ -114,6 +157,10 @@ class RowSynthesizer: row a pattern's tenth row plays for is the row an exported module plays it for: both index the same groove from the pattern's first row. + Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` + gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample + rate and the groove's tempo is the tempo heard. + Generators are constructed once from ``config`` and carry timer state across rows for phase continuity within a sustained note. Triggering a new note calls ``generator.reset()`` for a clean phase start. @@ -138,6 +185,8 @@ def __init__( self._position = SongPosition() self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) self._groove: Groove = self._timing.groove() + self._tick_clock: TickClock = self._clock_for(config.library.nes_frequency) + self._elapsed_ticks: int = 0 self._channel_states: Dict[GeneratorName, _ChannelState] = { generator_name: _ChannelState( generator=GENERATOR_CLASSES[generator_name]( @@ -166,6 +215,7 @@ def set_position(self, order_position: int, row_index: int) -> None: self._position.row_index = row_index def reset(self) -> None: + self._elapsed_ticks = 0 for state in self._channel_states.values(): state.sample_id = None state.tick_index = 0 @@ -182,11 +232,14 @@ def _ensure_generators(self, nes_frequency: int) -> None: to the frequency). Pitch is derived from the APU clock, not this rate, so only the per-tick frame length changes; the generators' phase continuity resets, which is acceptable for an occasional settings edit. + + The tick clock follows the same value, since it states how long one of those ticks lasts. """ if nes_frequency == self._nes_frequency: return self._nes_frequency = nes_frequency + self._tick_clock = self._clock_for(nes_frequency) config = self._playback_config(nes_frequency) for generator_name, state in self._channel_states.items(): state.generator = GENERATOR_CLASSES[generator_name]( @@ -194,6 +247,12 @@ def _ensure_generators(self, nes_frequency: int) -> None: generator_name.value, ) + def _clock_for(self, nes_frequency: int) -> TickClock: + return TickClock.from_parameters( + sample_rate=self._config.library.sample_rate, + nes_frequency=nes_frequency, + ) + def _playback_config(self, nes_frequency: int) -> Config: library = self._config.library.model_copy(update={"nes_frequency": nes_frequency}) return self._config.model_copy(update={"library": library}) @@ -206,22 +265,27 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: self._ensure_generators(settings.nes_frequency) self._ensure_groove(project) - frame_length = round(self._config.library.sample_rate / settings.nes_frequency) - ticks_per_row = self._groove.ticks[self._position.row_index] - chunk_length = frame_length * ticks_per_row + frames = _RowFrames.from_clock( + self._tick_clock, + elapsed_ticks=self._elapsed_ticks, + ticks=self._groove.ticks[self._position.row_index], + ) position_before = replace(self._position) - if self.is_finished: - return np.zeros(chunk_length, dtype=np.float32), position_before - - mixed = self._mix_channels( - project, - song, - frame_length, - ticks_per_row, - chunk_length, + finished = self.is_finished + mixed = ( + _silence(frames.total) + if finished + else self._mix_channels( + project, + song, + frames, + ) ) - self._advance_position(song) + + self._elapsed_ticks += len(frames.lengths) + if not finished: + self._advance_position(song) return mixed, position_before @@ -244,19 +308,15 @@ def _mix_channels( self, project: Project, song: Song, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: - mixed = _silence(chunk_length) + mixed = _silence(frames.total) for generator_name in GeneratorName.items(): channel_audio = self._render_channel( generator_name, project, song, - frame_length, - ticks_per_row, - chunk_length, + frames, ) mixed += channel_audio @@ -267,9 +327,7 @@ def _render_channel( generator_name: GeneratorName, project: Project, song: Song, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: state = self._channel_states[generator_name] @@ -279,16 +337,14 @@ def _render_channel( sample_id = state.sample_id if sample_id is None or generator_name not in self._active_channels(): - return _silence(chunk_length) + return _silence(frames.total) return self._synthesize_ticks( state, sample_id, project, generator_name, - frame_length, - ticks_per_row, - chunk_length, + frames, ) def _resolve_row(self, generator_name: GeneratorName, song: Song) -> Optional[Row]: @@ -329,29 +385,28 @@ def _synthesize_ticks( sample_id: str, project: Project, generator_name: GeneratorName, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: sample = project.sample(sample_id) if sample is None: - return _silence(chunk_length) + return _silence(frames.total) instructions = sample.reconstruction.instructions.get(generator_name) if not instructions: - return _silence(chunk_length) + return _silence(frames.total) - output = _silence(chunk_length) - silence_frame = _silence(frame_length) + output = _silence(frames.total) + silence_frame = _silence(frames.longest) - for tick in range(ticks_per_row): + for tick, frame_length in enumerate(frames.lengths): frame = self._synthesize_tick( state, instructions, - silence_frame, + silence_frame[:frame_length], sample.loop, + frame_length, ) - output[tick * frame_length : (tick + 1) * frame_length] = frame + output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame state.tick_index += 1 return output @@ -362,6 +417,7 @@ def _synthesize_tick( instructions: List[InstructionUnion], silence_frame: np.ndarray, loop: bool, + frame_length: int, ) -> np.ndarray: if loop: instruction = instructions[state.tick_index % len(instructions)] @@ -370,6 +426,7 @@ def _synthesize_tick( else: return silence_frame + state.generator.frame_length = frame_length return state.generator( _apply_modifiers( instruction, diff --git a/src/sampletones_core/generators/generator.py b/src/sampletones_core/generators/generator.py index e211e716..19fc7ced 100644 --- a/src/sampletones_core/generators/generator.py +++ b/src/sampletones_core/generators/generator.py @@ -203,7 +203,22 @@ def get_possible_instructions(self) -> List[InstructionT]: @property def frame_length(self) -> int: - return self.config.library.frame_length + """The samples the next rendered frame spans. + + The timer holds the length, seeded from the configuration it was built with. Setting it + renders the next frame over that many samples instead, which is how a caller driving the + engine's ticks gives each tick the span its clock states. Oscillator continuity is carried + by the timer's own state, so a frame of a different length resumes exactly where the last + one ended. + """ + return self.timer.frame_length + + @frame_length.setter + def frame_length(self, value: int) -> None: + if value < 1: + raise ValueError(f"frame_length must be at least 1, got {value}") + + self.timer.frame_length = value @classmethod @abstractmethod diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py index 83f5fddc..11b4d4ca 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -1,3 +1,4 @@ +from .clock import TickClock from .distribution import distribute_by_halving, distribute_proportionally from .groove import Groove, calculate_groove from .metre import Metre @@ -7,6 +8,7 @@ "Groove", "Metre", "RowRate", + "TickClock", "calculate_groove", "distribute_by_halving", "distribute_proportionally", diff --git a/src/sampletones_core/timing/clock.py b/src/sampletones_core/timing/clock.py new file mode 100644 index 00000000..8c93cb20 --- /dev/null +++ b/src/sampletones_core/timing/clock.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from math import floor + + +@dataclass(frozen=True) +class TickClock: + """The audio samples each engine tick spans, held exact so a tick lasts what the engine holds it for. + + A tick is the interrupt the engine consumes one instruction on, and it lasts + ``1 / nes_frequency`` seconds whatever rate the audio is rendered at. Where that duration + falls between two samples, giving every tick the same rounded length shifts the tempo by + the rounding, and the shift accumulates over a song. Spreading the fractional part across + consecutive ticks instead holds the running total on the exact clock, so the tempo a + :class:`~sampletones_core.timing.groove.Groove` states is the tempo the audio plays at, at + any sample rate. + + This is the rule a groove applies, one level down: a groove spreads a fractional ticks-per-row + across a pattern's rows, and a tick clock spreads a fractional samples-per-tick across the + ticks themselves. Both answer with whole numbers that sum to the exact total. + + Attributes: + samples_per_tick: The exact samples one tick spans. + """ + + samples_per_tick: Fraction + + def __post_init__(self) -> None: + if self.samples_per_tick < 1: + raise ValueError(f"samples_per_tick must be at least 1, got {self.samples_per_tick}") + + @classmethod + def from_parameters( + cls, + *, + sample_rate: int, + nes_frequency: int, + ) -> TickClock: + """Derives the clock a render at ``sample_rate`` runs the engine's ticks on. + + Args: + sample_rate: The audio sample rate in Hz. + nes_frequency: The engine tick rate in Hz. + + Returns: + TickClock: The exact samples one tick spans at those rates. + + Raises: + ValueError: If either rate is below 1, or a tick spans less than one sample. + """ + if sample_rate < 1: + raise ValueError(f"sample_rate must be at least 1, got {sample_rate}") + + if nes_frequency < 1: + raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}") + + return cls(samples_per_tick=Fraction(sample_rate, nes_frequency)) + + @property + def is_exact(self) -> bool: + """Whether every tick spans the same whole number of samples.""" + return self.samples_per_tick.denominator == 1 + + def samples_at(self, ticks: int) -> int: + """The samples the first ``ticks`` ticks span together. + + Args: + ticks: How many ticks have elapsed, at least 0. + + Returns: + int: The cumulative sample count, within one sample of the exact duration. + + Raises: + ValueError: If ``ticks`` is negative. + """ + if ticks < 0: + raise ValueError(f"ticks must be at least 0, got {ticks}") + + return floor(self.samples_per_tick * ticks) + + def frame_length(self, tick_index: int) -> int: + """The samples the tick at ``tick_index`` spans. + + Taking the difference of two cumulative counts is what makes a run of frame lengths sum + to the exact span of the ticks it covers, however the fraction falls. + + Args: + tick_index: The tick's position in the run, counted from 0. + + Returns: + int: The tick's length in samples. + + Raises: + ValueError: If ``tick_index`` is negative. + """ + return self.samples_at(tick_index + 1) - self.samples_at(tick_index) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py new file mode 100644 index 00000000..ae9cf7c1 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -0,0 +1,175 @@ +from fractions import Fraction +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_application.constants.playback import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( + add_sample, + all_channels, + make_controller, + make_pulse_reconstruction, + place_row, +) + +UNEVEN_SAMPLE_RATE: Final[int] = 22050 +EVEN_SAMPLE_RATE: Final[int] = 44100 +UNEVEN_RATES: Final[Tuple[int, ...]] = (8000, 16000, 22050) + + +def _config(sample_rate: int) -> Config: + config = Config() + return config.model_copy(update={"library": config.library.model_copy(update={"sample_rate": sample_rate})}) + + +def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: + settings = controller.project.settings + return calculate_groove( + RowRate.from_settings(settings), + Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ).ticks + + +class TestRowsFollowTheTickClock(BaseTestSuite): + """A rendered row spans the samples its ticks span, so the groove's tempo is the tempo heard.""" + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) + def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=controller.project.settings.nes_frequency, + ) + assert rendered == clock.samples_at(sum(ticks)) + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES) + def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: + """The property a fixed rounded frame length loses: the error stays below one sample.""" + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + ticks = _expected_ticks(controller) + patterns = 40 + + rendered = 0 + for _ in range(patterns): + synthesizer.set_position(0, 0) + rendered += sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + + exact = Fraction(sample_rate, controller.project.settings.nes_frequency) * sum(ticks) * patterns + assert abs(rendered - exact) < 1 + + def test_a_row_spans_the_sum_of_its_ticks(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + clock = TickClock.from_parameters( + sample_rate=UNEVEN_SAMPLE_RATE, + nes_frequency=controller.project.settings.nes_frequency, + ) + ticks = _expected_ticks(controller) + + elapsed = 0 + for row_ticks in ticks: + chunk, _ = synthesizer.render_row() + expected = clock.samples_at(elapsed + row_ticks) - clock.samples_at(elapsed) + assert len(chunk) == expected + elapsed += row_ticks + + def test_rows_vary_in_length_where_their_ticks_straddle_a_sample(self) -> None: + """The variation is the mechanism; a run of identical lengths would mean the drift is back. + + An odd tick count is what makes it visible at the row: five ticks of 367.5 samples span + 1837.5, so consecutive rows take the floor and the ceiling in turn. + """ + controller = make_controller() + controller.set_speed(5) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + lengths = {len(synthesizer.render_row()[0]) for _ in range(len(_expected_ticks(controller)))} + assert lengths == {1837, 1838} + + def test_reset_returns_the_clock_to_the_first_tick(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + first = len(synthesizer.render_row()[0]) + + synthesizer.set_position(0, 0) + synthesizer.reset() + + assert len(synthesizer.render_row()[0]) == first + + def test_a_frequency_change_rebuilds_the_clock(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(EVEN_SAMPLE_RATE), active_channels=all_channels) + + controller.set_nes_frequency(60) + synthesizer.render_row() + controller.set_nes_frequency(30) + synthesizer.set_position(0, 0) + synthesizer.reset() + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + clock = TickClock.from_parameters(sample_rate=EVEN_SAMPLE_RATE, nes_frequency=30) + assert rendered == clock.samples_at(sum(ticks)) + + +class TestChannelsFillTheRow(BaseTestSuite): + """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" + + def test_a_sounding_channel_fills_every_tick(self) -> None: + controller = make_controller() + reconstruction = make_pulse_reconstruction(count=1) + sample = add_sample(controller, reconstruction, loop=True) + place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + + chunk, _ = synthesizer.render_row() + + assert np.any(chunk != 0.0) + assert not np.any(np.isnan(chunk)) + + def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> None: + """A tick of a different length resumes the oscillator where the last one ended.""" + controller = make_controller() + reconstruction = make_pulse_reconstruction(count=1) + sample = add_sample(controller, reconstruction, loop=True) + place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + + chunk, _ = synthesizer.render_row() + steps = np.abs(np.diff(chunk)) + + assert float(steps.max()) <= 1.0 diff --git a/tests/unit/sampletones_core/timing/test_clock.py b/tests/unit/sampletones_core/timing/test_clock.py new file mode 100644 index 00000000..93a2dff8 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_clock.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.timing.clock import TickClock +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +LONG_RUN_TICKS: Final[int] = 36000 +NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 199, 300) + + +class TestTickClock(BaseTestSuite): + """One case table, read both for the frame lengths it produces and for the rules they obey.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + sample_rate: int + nes_frequency: int + + @property + def label(self) -> str: + return f"{self.sample_rate}hz_{self.nes_frequency}tick" + + @property + def clock(self) -> TickClock: + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) + + test_cases = ( + TestCase(sample_rate=44100, nes_frequency=60, expected=(735,) * 8), + TestCase(sample_rate=48000, nes_frequency=60, expected=(800,) * 8), + TestCase(sample_rate=96000, nes_frequency=60, expected=(1600,) * 8), + TestCase(sample_rate=44100, nes_frequency=30, expected=(1470,) * 8), + TestCase( + sample_rate=22050, + nes_frequency=60, + expected=(367, 368, 367, 368, 367, 368, 367, 368), + ), + TestCase( + sample_rate=8000, + nes_frequency=60, + expected=(133, 133, 134, 133, 133, 134, 133, 133), + ), + TestCase( + sample_rate=16000, + nes_frequency=60, + expected=(266, 267, 267, 266, 267, 267, 266, 267), + ), + TestCase( + sample_rate=44100, + nes_frequency=120, + expected=(367, 368, 367, 368, 367, 368, 367, 368), + ), + TestCase( + sample_rate=8000, + nes_frequency=300, + expected=(26, 27, 27, 26, 27, 27, 26, 27), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_frame_lengths_match(self, test_case: TestCase) -> None: + clock = test_case.clock + lengths = tuple(clock.frame_length(tick) for tick in range(len(test_case.expected))) + assert lengths == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_lengths_sum_to_the_cumulative_count(self, test_case: TestCase) -> None: + clock = test_case.clock + assert sum(clock.frame_length(tick) for tick in range(len(test_case.expected))) == clock.samples_at( + len(test_case.expected) + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None: + """Consecutive ticks differ by at most one sample, so no tick is audibly off on its own.""" + clock = test_case.clock + lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)} + assert max(lengths) - min(lengths) <= 1 + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_is_exact_reports_a_uniform_run(self, test_case: TestCase) -> None: + clock = test_case.clock + lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)} + assert clock.is_exact == (len(lengths) == 1) + + +class TestTheClockHoldsTheTempo(BaseTestSuite): + """The property the whole clock exists for: a run of ticks lands on its exact duration.""" + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_a_long_run_lands_on_the_exact_sample_count( + self, + sample_rate: int, + nes_frequency: int, + ) -> None: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + rendered = sum(clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)) + exact = Fraction(sample_rate, nes_frequency) * LONG_RUN_TICKS + assert rendered == int(exact) if exact.denominator == 1 else abs(rendered - exact) < 1 + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_the_cumulative_count_never_drifts_past_one_sample( + self, + sample_rate: int, + nes_frequency: int, + ) -> None: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + rate = Fraction(sample_rate, nes_frequency) + assert all(abs(clock.samples_at(ticks) - rate * ticks) < 1 for ticks in range(0, LONG_RUN_TICKS, 97)) + + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_a_whole_division_gives_every_tick_the_rounded_length(self, sample_rate: int) -> None: + """Where the division is whole the clock agrees with the length a timer is built with.""" + for nes_frequency in NES_FREQUENCIES: + if sample_rate % nes_frequency: + continue + + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + expected = round(sample_rate / nes_frequency) + assert clock.is_exact + assert all(clock.frame_length(tick) == expected for tick in range(64)) + + +class TestTickClockBounds(BaseTestSuite): + def test_the_first_tick_starts_at_zero(self) -> None: + clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60) + assert clock.samples_at(0) == 0 + + def test_every_tick_spans_at_least_one_sample(self) -> None: + clock = TickClock.from_parameters( + sample_rate=min(SAMPLE_RATES), + nes_frequency=MAX_NES_FREQUENCY, + ) + assert all(clock.frame_length(tick) >= 1 for tick in range(1024)) + + @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY)) + def test_the_engine_range_is_covered_at_every_rate(self, nes_frequency: int) -> None: + for sample_rate in SAMPLE_RATES: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + assert clock.samples_per_tick == Fraction(sample_rate, nes_frequency) + + def test_a_tick_shorter_than_a_sample_is_rejected(self) -> None: + with pytest.raises(ValueError, match="samples_per_tick must be at least 1"): + TickClock.from_parameters(sample_rate=100, nes_frequency=300) + + @pytest.mark.parametrize("sample_rate", (0, -1)) + def test_a_rate_below_one_is_rejected(self, sample_rate: int) -> None: + with pytest.raises(ValueError, match="sample_rate must be at least 1"): + TickClock.from_parameters(sample_rate=sample_rate, nes_frequency=60) + + @pytest.mark.parametrize("nes_frequency", (0, -1)) + def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None: + with pytest.raises(ValueError, match="nes_frequency must be at least 1"): + TickClock.from_parameters(sample_rate=44100, nes_frequency=nes_frequency) + + def test_a_negative_tick_count_is_rejected(self) -> None: + clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60) + with pytest.raises(ValueError, match="ticks must be at least 0"): + clock.samples_at(-1) From ba550ce5574848cf434921fb042add4a8ff826ae Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 23:36:52 +0200 Subject: [PATCH 02/10] Added: streaming audio writers for WAV and MP3 --- pyproject.toml | 1 + src/sampletones_application/application.py | 13 +- .../coordinators/tabs/sequencer.py | 1 + .../logic/sequencer/playback/synthesizer.py | 110 +++++++---- src/sampletones_core/audio/manager.py | 10 + .../audio/writers/__init__.py | 44 +++++ src/sampletones_core/audio/writers/bitrate.py | 117 ++++++++++++ .../audio/writers/capability.py | 68 +++++++ src/sampletones_core/audio/writers/format.py | 49 +++++ .../audio/writers/protocol.py | 30 +++ .../audio/writers/selection.py | 80 ++++++++ .../audio/writers/soundfile.py | 116 ++++++++++++ src/sampletones_core/audio/writers/spec.py | 85 +++++++++ src/sampletones_core/configs/config.py | 33 +++- .../reconstruction/reconstruction.py | 4 +- src/sampletones_shared/exceptions/__init__.py | 3 +- src/sampletones_shared/exceptions/audio.py | 4 + .../logic/sequencer/playback/conftest.py | 21 ++- .../sequencer/playback/test_synthesizer.py | 22 ++- .../sequencer/playback/test_tick_clock.py | 81 ++++---- .../audio/writers/__init__.py | 0 .../audio/writers/test_spec.py | 107 +++++++++++ .../audio/writers/test_writer.py | 174 ++++++++++++++++++ uv.lock | 2 + 24 files changed, 1085 insertions(+), 90 deletions(-) create mode 100644 src/sampletones_core/audio/writers/__init__.py create mode 100644 src/sampletones_core/audio/writers/bitrate.py create mode 100644 src/sampletones_core/audio/writers/capability.py create mode 100644 src/sampletones_core/audio/writers/format.py create mode 100644 src/sampletones_core/audio/writers/protocol.py create mode 100644 src/sampletones_core/audio/writers/selection.py create mode 100644 src/sampletones_core/audio/writers/soundfile.py create mode 100644 src/sampletones_core/audio/writers/spec.py create mode 100644 tests/unit/sampletones_core/audio/writers/__init__.py create mode 100644 tests/unit/sampletones_core/audio/writers/test_spec.py create mode 100644 tests/unit/sampletones_core/audio/writers/test_writer.py diff --git a/pyproject.toml b/pyproject.toml index 19f08acd..99ca6af6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "rich>=13.0,<16", "scipy>=1.13,<2", "screeninfo>=0.8,<0.9", + "soundfile>=0.13,<0.14", "tqdm>=4.66,<5", "jeepney>=0.8,<1; sys_platform == 'linux'", "pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'", diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 848b17fc..078505f1 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1140,8 +1140,17 @@ def _apply_audio_settings( sample_rate: SampleRate, buffer_size: BufferSize, ) -> None: - """Applies the dialog's committed device, sample rate, and buffer size.""" - self.audio_device_manager.configure_device(device_index, sample_rate) + """Applies the dialog's committed device, sample rate, and buffer size. + + Switching devices needs the output free; a source that keeps hold of it leaves the + settings as they stand and reports the failure. + """ + try: + self.audio_device_manager.configure_device(device_index, sample_rate) + except PlaybackError as exception: + self._on_playback_error(exception) + return + self.audio_device_manager.set_buffer_size(buffer_size) def _owning_project_sample(self) -> Optional[Sample]: diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 78c87724..cfe0bb40 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -197,6 +197,7 @@ def __init__( project_controller, config_manager.config, active_channels=lambda: self._sequencer_channels_logic.active_channels, + sample_rate=lambda: audio_device_manager.sample_rate, ), should_loop=lambda: session_manager.loop_song, master_gain=lambda: session_manager.master_gain, diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 9cadf7f4..706d2d69 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -80,6 +80,31 @@ def groove(self) -> Groove: ) +@dataclass(frozen=True) +class _EngineRates: + """The pair of rates a tick is sized from, held together so a change is one comparison. + + Each rate is owned elsewhere: the project states how many instructions the engine consumes + each second, and whoever takes the audio states the rate it is rendered at — the output + device for playback, the chosen format for a file. Together they fix how many samples one + tick spans, so the synthesiser follows both. + + Attributes: + nes_frequency: The engine ticks consumed each second. + sample_rate: The samples the rendered audio holds each second. + """ + + nes_frequency: int + sample_rate: int + + def clock(self) -> TickClock: + """The samples each tick spans under this pair of rates.""" + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) + + @dataclass(frozen=True) class _RowFrames: """Where each of a row's ticks starts and ends within the row's audio. @@ -161,9 +186,13 @@ class RowSynthesizer: gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample rate and the groove's tempo is the tempo heard. - Generators are constructed once from ``config`` and carry timer state across - rows for phase continuity within a sustained note. Triggering a new note - calls ``generator.reset()`` for a clean phase start. + ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that + audio runs at: the output device for live playback, the chosen format for a file. Reading it + per row keeps the two in step, so a rendered second is a second wherever the audio goes. + + Generators are constructed from ``config`` at the rates in force and carry timer + state across rows for phase continuity within a sustained note. Triggering a new + note calls ``generator.reset()`` for a clean phase start. ``active_channels`` reports which channels sound and is consulted once per channel per row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A @@ -177,24 +206,21 @@ def __init__( config: Config, *, active_channels: Callable[[], FrozenSet[GeneratorName]], + sample_rate: Callable[[], int], ) -> None: self._project_controller = project_controller self._config = config self._active_channels = active_channels - self._nes_frequency: int = config.library.nes_frequency + self._sample_rate = sample_rate self._position = SongPosition() self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) self._groove: Groove = self._timing.groove() - self._tick_clock: TickClock = self._clock_for(config.library.nes_frequency) + self._rates: _EngineRates = self._current_rates() + self._tick_clock: TickClock = self._rates.clock() self._elapsed_ticks: int = 0 self._channel_states: Dict[GeneratorName, _ChannelState] = { - generator_name: _ChannelState( - generator=GENERATOR_CLASSES[generator_name]( - config, - generator_name.value, - ), - ) - for generator_name in GeneratorName.items() + generator_name: _ChannelState(generator=generator) + for generator_name, generator in self._build_generators(self._rates).items() } @property @@ -222,47 +248,55 @@ def reset(self) -> None: state.transpose = 0 state.volume = MAX_VOLUME - def _ensure_generators(self, nes_frequency: int) -> None: - """Rebuilds the channel generators when the engine refresh rate changes. + def _ensure_generators(self) -> None: + """Rebuilds the channel generators when either rate a tick is sized from changes. - ``nes_frequency`` is the rate at which instructions (engine ticks) are - consumed, so each tick spans ``sample_rate / nes_frequency`` audio samples. - The generators must follow the project's current value so a row keeps a - constant real-time duration as the rate changes (the tempo is otherwise tied - to the frequency). Pitch is derived from the APU clock, not this rate, so only - the per-tick frame length changes; the generators' phase continuity resets, - which is acceptable for an occasional settings edit. + The engine consumes ``nes_frequency`` instructions a second and the audio holds + ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the + project's frequency keeps a row a constant real-time duration as that frequency changes, + and following the output's rate keeps a rendered second a second wherever the audio goes. + Pitch derives from the APU clock rather than either rate, so a change moves only the + per-tick frame length; the generators' phase continuity resets, which is acceptable for an + occasional settings edit. - The tick clock follows the same value, since it states how long one of those ticks lasts. + The tick clock follows the same pair, since it states how long one of those ticks lasts. """ - if nes_frequency == self._nes_frequency: + rates = self._current_rates() + if rates == self._rates: return - self._nes_frequency = nes_frequency - self._tick_clock = self._clock_for(nes_frequency) - config = self._playback_config(nes_frequency) - for generator_name, state in self._channel_states.items(): - state.generator = GENERATOR_CLASSES[generator_name]( + self._rates = rates + self._tick_clock = rates.clock() + for generator_name, generator in self._build_generators(rates).items(): + self._channel_states[generator_name].generator = generator + + def _current_rates(self) -> _EngineRates: + return _EngineRates( + nes_frequency=self._project_controller.project.settings.nes_frequency, + sample_rate=self._sample_rate(), + ) + + def _build_generators(self, rates: _EngineRates) -> Dict[GeneratorName, ChannelGeneratorProtocol]: + config = self._engine_config(rates) + return { + generator_name: GENERATOR_CLASSES[generator_name]( config, generator_name.value, ) + for generator_name in GeneratorName.items() + } - def _clock_for(self, nes_frequency: int) -> TickClock: - return TickClock.from_parameters( - sample_rate=self._config.library.sample_rate, - nes_frequency=nes_frequency, + def _engine_config(self, rates: _EngineRates) -> Config: + return self._config.with_library( + nes_frequency=rates.nes_frequency, + sample_rate=rates.sample_rate, ) - def _playback_config(self, nes_frequency: int) -> Config: - library = self._config.library.model_copy(update={"nes_frequency": nes_frequency}) - return self._config.model_copy(update={"library": library}) - def render_row(self) -> Tuple[np.ndarray, SongPosition]: project = self._project_controller.project - settings = project.settings song = project.song self._position.wrap_overflow(song.rows_per_pattern) - self._ensure_generators(settings.nes_frequency) + self._ensure_generators() self._ensure_groove(project) frames = _RowFrames.from_clock( diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index bbcfab30..66530df5 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -429,12 +429,18 @@ def configure_device( Stops any active playback and switches to the specified device and sample rate. If the sample rate is not supported, falls back to the first supported rate for that device. + A stream handed out to a streaming source was opened on the device and rate in force at + the time, so the new settings reach it only once it is wound down; the source is asked to + hand the output back before the switch, and playback resumes under the new settings. + Args: device_index: Index of the device to configure. sample_rate: Desired sample rate in Hz. Raises: ValueError: If the device index is not found. + PlaybackError: If a handed-out stream survives its release, which leaves the device + and rate as they stand. """ if device_index not in self._devices: raise ValueError(f"Device with index {device_index} not found") @@ -448,6 +454,10 @@ def configure_device( sample_rate = fallback_rate self.stop() + self.call(self.on_acquire_output) + if not self._release_output_streams(): + raise PlaybackError("An output stream is still held; the audio device stays as it is") + self.device_index = device_index self.sample_rate = sample_rate logger.info(f"Audio device configured: '{self.device_name}' (index={device_index}, sample_rate={sample_rate})") diff --git a/src/sampletones_core/audio/writers/__init__.py b/src/sampletones_core/audio/writers/__init__.py new file mode 100644 index 00000000..25d33237 --- /dev/null +++ b/src/sampletones_core/audio/writers/__init__.py @@ -0,0 +1,44 @@ +from .bitrate import ( + MP3_LADDERS, + MP3_SAMPLE_RATES, + default_mp3_bitrate, + mp3_bitrates, + mp3_compression_level, +) +from .capability import FORMAT_CAPABILITIES, FormatCapability, capability_of +from .format import ( + AUDIO_DEPTHS, + DEFAULT_AUDIO_DEPTH, + DEFAULT_AUDIO_FORMAT, + AudioDepth, + AudioFormat, +) +from .protocol import AudioWriter +from .selection import available_audio_formats, available_depths, open_audio_writer +from .soundfile import SoundFileAudioWriter +from .spec import AudioOutputSpec, AudioOutputSpecBase, Mp3OutputSpec, WaveOutputSpec + +__all__ = [ + "AUDIO_DEPTHS", + "DEFAULT_AUDIO_DEPTH", + "DEFAULT_AUDIO_FORMAT", + "FORMAT_CAPABILITIES", + "MP3_LADDERS", + "MP3_SAMPLE_RATES", + "AudioDepth", + "AudioFormat", + "AudioOutputSpec", + "AudioOutputSpecBase", + "AudioWriter", + "FormatCapability", + "Mp3OutputSpec", + "SoundFileAudioWriter", + "WaveOutputSpec", + "available_audio_formats", + "available_depths", + "capability_of", + "default_mp3_bitrate", + "mp3_bitrates", + "mp3_compression_level", + "open_audio_writer", +] diff --git a/src/sampletones_core/audio/writers/bitrate.py b/src/sampletones_core/audio/writers/bitrate.py new file mode 100644 index 00000000..1891cdec --- /dev/null +++ b/src/sampletones_core/audio/writers/bitrate.py @@ -0,0 +1,117 @@ +from typing import Final, Mapping, Tuple + +MPEG_1_LADDER: Final[Mapping[int, float]] = { + 320: 0.05, + 256: 0.19, + 224: 0.33, + 192: 0.44, + 160: 0.55, + 128: 0.65, + 112: 0.72, + 96: 0.78, + 80: 0.83, + 64: 0.88, + 56: 0.91, + 48: 0.94, + 40: 0.97, + 32: 0.99, +} + +MPEG_2_LADDER: Final[Mapping[int, float]] = { + 160: 0.02, + 144: 0.10, + 128: 0.21, + 112: 0.31, + 96: 0.42, + 80: 0.52, + 64: 0.62, + 56: 0.68, + 48: 0.73, + 40: 0.78, + 32: 0.84, + 24: 0.89, + 16: 0.94, + 8: 0.98, +} + +MPEG_2_5_LADDER: Final[Mapping[int, float]] = { + 64: 0.03, + 56: 0.13, + 48: 0.27, + 40: 0.41, + 32: 0.56, + 24: 0.70, + 16: 0.84, + 8: 0.96, +} + +MP3_LADDERS: Final[Mapping[int, Mapping[int, float]]] = { + 8000: MPEG_2_5_LADDER, + 16000: MPEG_2_LADDER, + 22050: MPEG_2_LADDER, + 44100: MPEG_1_LADDER, + 48000: MPEG_1_LADDER, +} + +MP3_SAMPLE_RATES: Final[Tuple[int, ...]] = tuple(sorted(MP3_LADDERS)) +PREFERRED_MP3_BITRATE: Final[int] = 192 + + +def mp3_bitrates(sample_rate: int) -> Tuple[int, ...]: + """The bitrates MP3 encodes at ``sample_rate``, highest first. + + Each MPEG audio version defines its own ladder of bitrates and covers its own set of sample + rates, so the choice on offer narrows as the rate drops: the full ladder up to 320 kbps at + 44100 and 48000 Hz, a ladder topping out at 160 kbps at 16000 and 22050 Hz, and one topping + out at 64 kbps at 8000 Hz. + + Args: + sample_rate: The rate the file is written at. + + Returns: + Tuple[int, ...]: The bitrates in kbps, highest first. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``. + """ + return tuple(MP3_LADDERS[sample_rate]) + + +def mp3_compression_level(sample_rate: int, bitrate: int) -> float: + """The encoder setting that reaches ``bitrate`` at ``sample_rate``. + + libsndfile asks for MP3 quality as a compression level between 0 and 1 and turns that into a + rung on the ladder its MPEG version defines, so the level standing for a given bitrate depends + on the sample rate as well. Each level here sits in the middle of the band that selects its + rung, which leaves room either side for the rounding an encoder build applies. + + Args: + sample_rate: The rate the file is written at. + bitrate: The bitrate in kbps, one of those :func:`mp3_bitrates` reports. + + Returns: + float: The compression level to open the file with. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``, or does not reach ``bitrate`` there. + """ + return MP3_LADDERS[sample_rate][bitrate] + + +def default_mp3_bitrate(sample_rate: int) -> int: + """The bitrate a render starts at: the preferred one where the rate reaches it, else its best. + + Args: + sample_rate: The rate the file is written at. + + Returns: + int: The bitrate in kbps. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``. + """ + bitrates = mp3_bitrates(sample_rate) + return next( + (bitrate for bitrate in bitrates if bitrate <= PREFERRED_MP3_BITRATE), + bitrates[-1], + ) diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py new file mode 100644 index 00000000..aa8ad78f --- /dev/null +++ b/src/sampletones_core/audio/writers/capability.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from typing import Final, Mapping, Tuple + +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE + +from .bitrate import MP3_SAMPLE_RATES +from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat + + +@dataclass(frozen=True) +class FormatCapability: + """What one container holds, as the format itself defines it. + + A chooser reads this to offer the settings a format accepts, and a specification is checked + against it before a file is opened, so a combination the encoder would reject is caught while + it is still a request. + + Attributes: + extension: The suffix a file of this format carries. + sample_rates: The rates the format encodes, lowest first. + depths: The sample forms the format stores, coarsest first; empty where the format sets + its own and offers a bitrate instead. + """ + + extension: str + sample_rates: Tuple[int, ...] + depths: Tuple[AudioDepth, ...] + + @property + def stores_samples(self) -> bool: + """Whether the format stores samples directly, which is what gives it a depth to choose.""" + return bool(self.depths) + + def supports_sample_rate(self, sample_rate: int) -> bool: + return sample_rate in self.sample_rates + + def supports_depth(self, depth: AudioDepth) -> bool: + return depth in self.depths + + +FORMAT_CAPABILITIES: Final[Mapping[AudioFormat, FormatCapability]] = { + AudioFormat.WAVE: FormatCapability( + extension=EXT_FILE_WAVE, + sample_rates=tuple(SAMPLE_RATES), + depths=AUDIO_DEPTHS, + ), + AudioFormat.MP3: FormatCapability( + extension=EXT_FILE_MP3, + sample_rates=MP3_SAMPLE_RATES, + depths=(), + ), +} + + +def capability_of(audio_format: AudioFormat) -> FormatCapability: + """What ``audio_format`` holds. + + Args: + audio_format: The container to describe. + + Returns: + FormatCapability: The settings that format accepts. + + Raises: + KeyError: If the format has no entry in the registry. + """ + return FORMAT_CAPABILITIES[audio_format] diff --git a/src/sampletones_core/audio/writers/format.py b/src/sampletones_core/audio/writers/format.py new file mode 100644 index 00000000..1ef86d98 --- /dev/null +++ b/src/sampletones_core/audio/writers/format.py @@ -0,0 +1,49 @@ +from enum import StrEnum +from typing import Final, Mapping, Tuple + + +class AudioFormat(StrEnum): + """The container a rendered song is written into.""" + + WAVE = "wave" + MP3 = "mp3" + + +class AudioDepth(StrEnum): + """The form each sample takes in a file that stores samples directly. + + The integer depths quantize the signal to a fixed number of steps, coarsest first; the float + depth stores the rendered value as it stands. Eight bits gives 256 steps across the range, the + grain a chip render is often chosen for. + """ + + PCM_U8 = "pcm_u8" + PCM_16 = "pcm_16" + PCM_24 = "pcm_24" + PCM_32 = "pcm_32" + FLOAT_32 = "float_32" + + @property + def bits(self) -> int: + """The bits one stored sample occupies.""" + return DEPTH_BITS[self] + + +DEPTH_BITS: Final[Mapping[AudioDepth, int]] = { + AudioDepth.PCM_U8: 8, + AudioDepth.PCM_16: 16, + AudioDepth.PCM_24: 24, + AudioDepth.PCM_32: 32, + AudioDepth.FLOAT_32: 32, +} + +AUDIO_DEPTHS: Final[Tuple[AudioDepth, ...]] = ( + AudioDepth.PCM_U8, + AudioDepth.PCM_16, + AudioDepth.PCM_24, + AudioDepth.PCM_32, + AudioDepth.FLOAT_32, +) + +DEFAULT_AUDIO_FORMAT: Final[AudioFormat] = AudioFormat.WAVE +DEFAULT_AUDIO_DEPTH: Final[AudioDepth] = AudioDepth.PCM_16 diff --git a/src/sampletones_core/audio/writers/protocol.py b/src/sampletones_core/audio/writers/protocol.py new file mode 100644 index 00000000..61200307 --- /dev/null +++ b/src/sampletones_core/audio/writers/protocol.py @@ -0,0 +1,30 @@ +from types import TracebackType +from typing import Optional, Protocol, Self, Type + +import numpy as np + + +class AudioWriter(Protocol): + """A file open for audio, taking it a chunk at a time for the length of a ``with`` block. + + Writing incrementally is what lets a render of any length report its progress and answer a + cancel: the caller hands over each chunk as it is produced, and the whole song never has to + exist in memory at once. Leaving the block finalizes the file, whether the render finished or + stopped partway, so the destination is a complete file of whatever was written. + """ + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: ... + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk of mono float32 audio to the file. + + Args: + chunk: The samples to append, in the range [-1, 1]. + """ diff --git a/src/sampletones_core/audio/writers/selection.py b/src/sampletones_core/audio/writers/selection.py new file mode 100644 index 00000000..9713342f --- /dev/null +++ b/src/sampletones_core/audio/writers/selection.py @@ -0,0 +1,80 @@ +from pathlib import Path +from typing import Mapping, Tuple + +import soundfile + +from sampletones_shared.exceptions import UnsupportedAudioFormatError + +from .capability import capability_of +from .format import AudioDepth, AudioFormat +from .protocol import AudioWriter +from .soundfile import CONTAINERS, FIXED_SUBTYPES, SUBTYPES, SoundFileAudioWriter +from .spec import AudioOutputSpec + + +def available_audio_formats() -> Tuple[AudioFormat, ...]: + """The formats this installation writes, in the order a chooser offers them. + + libsndfile is built with a codec set that varies by platform and packaging, and the MP3 encoder + in particular is present only where it was compiled in. Asking the library what it holds keeps + a chooser honest about the machine it is running on. + + Returns: + Tuple[AudioFormat, ...]: The formats that can be written here. + """ + containers = soundfile.available_formats() + return tuple(audio_format for audio_format in AudioFormat if _is_writable(audio_format, containers)) + + +def available_depths(audio_format: AudioFormat) -> Tuple[AudioDepth, ...]: + """The depths this installation stores ``audio_format`` samples at, coarsest first. + + Args: + audio_format: The container to describe. + + Returns: + Tuple[AudioDepth, ...]: The depths the format declares that the encoder also writes; empty + for a format that sets its own and offers a bitrate instead. + """ + container = CONTAINERS[audio_format] + return tuple( + depth + for depth in capability_of(audio_format).depths + if soundfile.check_format( + container, + SUBTYPES[depth], + ) + ) + + +def open_audio_writer(path: Path, spec: AudioOutputSpec) -> AudioWriter: + """Opens a writer for ``path`` in the format ``spec`` states. + + The writer is a context manager: entering it opens the file and leaving it finalizes what was + written. + + Args: + path: Where the file is written. + spec: The format, rate, and quality it is written at. + + Returns: + AudioWriter: A writer ready to be entered. + + Raises: + UnsupportedAudioFormatError: If this installation does not write the requested format. + """ + if spec.audio_format not in available_audio_formats(): + raise UnsupportedAudioFormatError(f"This installation does not write {spec.audio_format} files") + + return SoundFileAudioWriter(path, spec) + + +def _is_writable(audio_format: AudioFormat, containers: Mapping[str, str]) -> bool: + container = CONTAINERS[audio_format] + if container not in containers: + return False + + if capability_of(audio_format).stores_samples: + return bool(available_depths(audio_format)) + + return bool(soundfile.check_format(container, FIXED_SUBTYPES[audio_format])) diff --git a/src/sampletones_core/audio/writers/soundfile.py b/src/sampletones_core/audio/writers/soundfile.py new file mode 100644 index 00000000..b1e0d633 --- /dev/null +++ b/src/sampletones_core/audio/writers/soundfile.py @@ -0,0 +1,116 @@ +from pathlib import Path +from types import TracebackType +from typing import Any, Dict, Final, Mapping, Optional, Self, Type + +import numpy as np +import soundfile + +from sampletones_shared.exceptions import AudioWriteError + +from .bitrate import mp3_compression_level +from .format import AudioDepth, AudioFormat +from .spec import AudioOutputSpec, Mp3OutputSpec, WaveOutputSpec + +CONTAINERS: Final[Mapping[AudioFormat, str]] = { + AudioFormat.WAVE: "WAV", + AudioFormat.MP3: "MP3", +} + +SUBTYPES: Final[Mapping[AudioDepth, str]] = { + AudioDepth.PCM_U8: "PCM_U8", + AudioDepth.PCM_16: "PCM_16", + AudioDepth.PCM_24: "PCM_24", + AudioDepth.PCM_32: "PCM_32", + AudioDepth.FLOAT_32: "FLOAT", +} + +MP3_SUBTYPE: Final[str] = "MPEG_LAYER_III" + +FIXED_SUBTYPES: Final[Mapping[AudioFormat, str]] = { + AudioFormat.MP3: MP3_SUBTYPE, +} + +CONSTANT_BITRATE_MODE: Final[str] = "CONSTANT" +WRITE_MODE: Final[str] = "w" +CHANNELS: Final[int] = 1 + + +def encoding_arguments(spec: AudioOutputSpec) -> Dict[str, Any]: + """The libsndfile settings that write ``spec``. + + This is where the encoder's vocabulary is spoken: eight-bit WAV is unsigned where the deeper + integer forms are signed, the float form is named for its width alone, and MP3 takes its + quality as a compression level rather than a bitrate. + + Args: + spec: The format, rate, and quality the file is written at. + + Returns: + Dict[str, Any]: Keyword arguments for opening a ``soundfile.SoundFile`` for writing. + """ + match spec: + case WaveOutputSpec(depth=depth): + return { + "format": CONTAINERS[AudioFormat.WAVE], + "subtype": SUBTYPES[depth], + } + case Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate): + return { + "format": CONTAINERS[AudioFormat.MP3], + "subtype": MP3_SUBTYPE, + "bitrate_mode": CONSTANT_BITRATE_MODE, + "compression_level": mp3_compression_level(sample_rate, bitrate), + } + + +class SoundFileAudioWriter: + """Writes rendered audio to a file through libsndfile. + + Holds the file open for the length of a ``with`` block and appends each chunk as it arrives, + so a render streams to disk while it is being produced. + + Attributes: + path: Where the file is written. + spec: The format, rate, and quality it is written at. + """ + + def __init__(self, path: Path, spec: AudioOutputSpec) -> None: + self.path = path + self.spec = spec + self._file: Optional[soundfile.SoundFile] = None + + def __enter__(self) -> Self: + self._file = soundfile.SoundFile( + self.path, + mode=WRITE_MODE, + samplerate=self.spec.sample_rate, + channels=CHANNELS, + **encoding_arguments(self.spec), + ) + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + opened, self._file = self._file, None + if opened is not None: + opened.close() + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk of mono float32 audio to the file. + + Args: + chunk: The samples to append, in the range [-1, 1]. Values outside it are held at the + range's edge by the integer depths and kept as they stand by the float depth. + + Raises: + AudioWriteError: If the file is not open, which is to say the call is outside the + ``with`` block that owns it. + """ + if self._file is None: + raise AudioWriteError(f"No file open at '{self.path}'; write within the writer's context") + + self._file.write(chunk) diff --git a/src/sampletones_core/audio/writers/spec.py b/src/sampletones_core/audio/writers/spec.py new file mode 100644 index 00000000..574ea35a --- /dev/null +++ b/src/sampletones_core/audio/writers/spec.py @@ -0,0 +1,85 @@ +from typing import Literal, Self, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE + +from .bitrate import default_mp3_bitrate, mp3_bitrates +from .capability import FormatCapability, capability_of +from .format import DEFAULT_AUDIO_DEPTH, AudioDepth, AudioFormat + + +class AudioOutputSpecBase(BaseModel): + """What every request to write audio states, whatever the container. + + The rate is checked against the format's capability on construction, so a specification that + exists is one the encoder accepts. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + audio_format: AudioFormat = Field(..., description="The container the audio is written into.") + sample_rate: int = Field( + ..., + ge=MIN_SAMPLE_RATE, + le=MAX_SAMPLE_RATE, + description="The samples the written audio holds each second.", + ) + + @property + def capability(self) -> FormatCapability: + return capability_of(self.audio_format) + + @property + def extension(self) -> str: + return self.capability.extension + + @model_validator(mode="after") + def _validate_sample_rate(self) -> Self: + if not self.capability.supports_sample_rate(self.sample_rate): + raise ValueError(f"{self.audio_format} does not encode at {self.sample_rate} Hz") + + return self + + +class WaveOutputSpec(AudioOutputSpecBase): + """A WAV file, which stores each sample at a chosen depth.""" + + audio_format: Literal[AudioFormat.WAVE] = AudioFormat.WAVE + depth: AudioDepth = Field( + default=DEFAULT_AUDIO_DEPTH, + description="The form each stored sample takes.", + ) + + @model_validator(mode="after") + def _validate_depth(self) -> Self: + if not self.capability.supports_depth(self.depth): + raise ValueError(f"WAV does not store samples as {self.depth}") + + return self + + +class Mp3OutputSpec(AudioOutputSpecBase): + """An MP3 file, which encodes to a chosen bitrate rather than storing samples. + + The bitrates on offer depend on the sample rate, since each MPEG audio version defines its own + ladder, so the pair is validated together. + """ + + audio_format: Literal[AudioFormat.MP3] = AudioFormat.MP3 + bitrate: int = Field(..., description="The kilobits the encoded audio holds each second.") + + @classmethod + def at(cls, sample_rate: int) -> Self: + """A specification at ``sample_rate`` and the bitrate a render starts at there.""" + return cls(sample_rate=sample_rate, bitrate=default_mp3_bitrate(sample_rate)) + + @model_validator(mode="after") + def _validate_bitrate(self) -> Self: + if self.bitrate not in mp3_bitrates(self.sample_rate): + raise ValueError(f"MP3 at {self.sample_rate} Hz does not encode at {self.bitrate} kbps") + + return self + + +AudioOutputSpec = Union[WaveOutputSpec, Mp3OutputSpec] diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index e1a1c8f3..5390791f 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import List, Self +from typing import Dict, List, Optional, Self from pydantic import ConfigDict, Field @@ -63,6 +63,37 @@ def save(self, path: Pathlike) -> None: config_dict = self.model_dump() save_json(path, config_dict) + def with_library( + self, + *, + nes_frequency: Optional[int] = None, + sample_rate: Optional[int] = None, + ) -> Self: + """A copy running at the given engine and audio rates, keeping every other setting. + + The rates a generator is built with decide how many samples one engine tick spans, so a + caller driving the engine at rates of its own — a render at a chosen output rate, a + reconstruction retuned to a project's frequency — asks for a configuration here rather + than editing the one it was handed. + + Args: + nes_frequency: The engine ticks consumed each second, or ``None`` to keep the current + value. + sample_rate: The samples the audio holds each second, or ``None`` to keep the current + value. + + Returns: + Self: The configuration at those rates. + """ + updates: Dict[str, int] = {} + if nes_frequency is not None: + updates["nes_frequency"] = nes_frequency + + if sample_rate is not None: + updates["sample_rate"] = sample_rate + + return self.model_copy(update={"library": self.library.model_copy(update=updates)}) + @property def max_workers(self) -> int: return self.general.max_workers diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index addc2ce4..c4c882ce 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -247,9 +247,7 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: if self.config.nes_frequency == nes_frequency: return self - library = self.config.library.model_copy(update={"nes_frequency": nes_frequency}) - config = self.config.model_copy(update={"library": library}) - return self._resynthesized(config) + return self._resynthesized(self.config.with_library(nes_frequency=nes_frequency)) def _resynthesized(self, config: Config) -> Reconstruction: """Re-renders every generator's approximation from its instructions at ``config``. diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 7dce2285..169cd46b 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -1,4 +1,4 @@ -from .audio import PlaybackError, UnsupportedAudioFormatError +from .audio import AudioWriteError, PlaybackError, UnsupportedAudioFormatError from .base import SampleToNESError from .callback import CallbackQueueStop from .cuda import CuPyNotInstalledWarning @@ -42,6 +42,7 @@ from .window import WindowError, WindowNotAvailableError __all__ = [ + "AudioWriteError", "CallbackQueueStop", "CuPyNotInstalledWarning", "DeserializationError", diff --git a/src/sampletones_shared/exceptions/audio.py b/src/sampletones_shared/exceptions/audio.py index 86d14e87..c7ef7969 100644 --- a/src/sampletones_shared/exceptions/audio.py +++ b/src/sampletones_shared/exceptions/audio.py @@ -11,3 +11,7 @@ class UnsupportedAudioFormatError(AudioError): class PlaybackError(AudioError): """Base class for exceptions raised during playback.""" + + +class AudioWriteError(AudioError): + """Exception raised when audio cannot be written to a file.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 9c8eac41..5548b6be 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import FrozenSet +from typing import Callable, FrozenSet import numpy as np import pytest @@ -9,6 +9,7 @@ from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import GeneratorName from sampletones_core.instructions import ( NoiseInstruction, @@ -30,6 +31,22 @@ def all_channels() -> FrozenSet[GeneratorName]: return ALL_CHANNELS +def make_synthesizer( + controller: ProjectController, + config: Config, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + active_channels: Callable[[], FrozenSet[GeneratorName]] = all_channels, +) -> RowSynthesizer: + """A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies.""" + return RowSynthesizer( + controller, + config, + active_channels=active_channels, + sample_rate=lambda: sample_rate, + ) + + def make_pulse_reconstruction( *, pitch: int = 60, @@ -156,4 +173,4 @@ def controller() -> ProjectController: @pytest.fixture def synthesizer(controller: ProjectController, config: Config) -> RowSynthesizer: - return RowSynthesizer(controller, config, active_channels=all_channels) + return make_synthesizer(controller, config) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 1283c04a..5d34066f 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, FrozenSet, List, Optional, Tuple +from typing import Dict, Final, FrozenSet, List, Optional, Tuple import numpy as np @@ -11,20 +11,23 @@ from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.timing import Metre, RowRate, calculate_groove from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, - all_channels, make_controller, make_pulse_reconstruction, + make_synthesizer, place_modifier_row, place_note_off, place_row, ) +SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE + class MaskProvider: """A channel mask a test moves between rows, standing in for the channels logic.""" @@ -54,7 +57,7 @@ def _make_context() -> SynthesizerContext: controller = make_controller() mask = MaskProvider() return SynthesizerContext( - synthesizer=RowSynthesizer(controller, Config(), active_channels=mask), + synthesizer=make_synthesizer(controller, Config(), active_channels=mask), mask=mask, ) @@ -327,7 +330,7 @@ def mute_pulse1(context: SynthesizerContext) -> None: def render_and_compare_against_unmasked(context: SynthesizerContext) -> None: audio_masked = _render(context) - audible_synthesizer = RowSynthesizer(_controller(context), Config(), active_channels=all_channels) + audible_synthesizer = make_synthesizer(_controller(context), Config()) audio_with_pulse1, _ = audible_synthesizer.render_row() assert np.allclose(audio_masked, 0.0) @@ -816,10 +819,10 @@ def test_tempo_is_independent_of_nes_frequency(self) -> None: def pattern_duration_seconds(nes_frequency: int) -> float: controller = make_controller() controller.set_nes_frequency(nes_frequency) - synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) rows = controller.project.song.rows_per_pattern total_samples = sum(len(synthesizer.render_row()[0]) for _ in range(rows)) - return total_samples / controller.project.settings.sample_rate + return total_samples / SAMPLE_RATE assert abs(pattern_duration_seconds(60) - pattern_duration_seconds(30)) < 0.1 @@ -831,16 +834,15 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non recon = make_pulse_reconstruction(count=12) sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels) - sample_rate = controller.project.settings.sample_rate + synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) pulse_state = synthesizer._channel_states[GeneratorName.PULSE1] controller.set_nes_frequency(60) synthesizer.render_row() - assert pulse_state.generator.frame_length == round(sample_rate / 60) + assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60) controller.set_nes_frequency(30) synthesizer.render_row() - assert pulse_state.generator.frame_length == round(sample_rate / 30) + assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) assert pulse_state.sample_id is not None diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index ae9cf7c1..c5c2cdf3 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -16,6 +16,7 @@ all_channels, make_controller, make_pulse_reconstruction, + make_synthesizer, place_row, ) @@ -24,11 +25,6 @@ UNEVEN_RATES: Final[Tuple[int, ...]] = (8000, 16000, 22050) -def _config(sample_rate: int) -> Config: - config = Config() - return config.model_copy(update={"library": config.library.model_copy(update={"sample_rate": sample_rate})}) - - def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: settings = controller.project.settings return calculate_groove( @@ -45,7 +41,7 @@ class TestRowsFollowTheTickClock(BaseTestSuite): @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) ticks = _expected_ticks(controller) rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) @@ -60,7 +56,7 @@ def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: """The property a fixed rounded frame length loses: the error stays below one sample.""" controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) ticks = _expected_ticks(controller) patterns = 40 @@ -74,11 +70,7 @@ def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: def test_a_row_spans_the_sum_of_its_ticks(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) clock = TickClock.from_parameters( sample_rate=UNEVEN_SAMPLE_RATE, nes_frequency=controller.project.settings.nes_frequency, @@ -100,21 +92,13 @@ def test_rows_vary_in_length_where_their_ticks_straddle_a_sample(self) -> None: """ controller = make_controller() controller.set_speed(5) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) lengths = {len(synthesizer.render_row()[0]) for _ in range(len(_expected_ticks(controller)))} assert lengths == {1837, 1838} def test_reset_returns_the_clock_to_the_first_tick(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) first = len(synthesizer.render_row()[0]) synthesizer.set_position(0, 0) @@ -124,7 +108,7 @@ def test_reset_returns_the_clock_to_the_first_tick(self) -> None: def test_a_frequency_change_rebuilds_the_clock(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(EVEN_SAMPLE_RATE), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=EVEN_SAMPLE_RATE) controller.set_nes_frequency(60) synthesizer.render_row() @@ -138,6 +122,45 @@ def test_a_frequency_change_rebuilds_the_clock(self) -> None: assert rendered == clock.samples_at(sum(ticks)) +class TestTheOutputRateIsFollowed(BaseTestSuite): + """The audio is rendered at the rate its consumer reports, so a rendered second lasts a second. + + Live playback opens its device stream at that rate and a render writes its file at it, so a + synthesiser fixed to some other rate plays the song at the ratio between the two. + """ + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) + def test_a_pattern_lasts_the_seconds_its_ticks_last(self, sample_rate: int) -> None: + controller = make_controller() + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + expected = Fraction(sum(ticks), controller.project.settings.nes_frequency) + + assert abs(Fraction(rendered, sample_rate) - expected) < Fraction(1, sample_rate) + + def test_a_rate_change_is_picked_up_on_the_next_row(self) -> None: + """Selecting another output device rate re-times the audio rather than the song.""" + controller = make_controller() + rates = [EVEN_SAMPLE_RATE] + synthesizer = RowSynthesizer( + controller, + Config(), + active_channels=all_channels, + sample_rate=lambda: rates[0], + ) + at_even = len(synthesizer.render_row()[0]) + + rates[0] = UNEVEN_SAMPLE_RATE + synthesizer.set_position(0, 0) + synthesizer.reset() + at_uneven = len(synthesizer.render_row()[0]) + + difference = abs(Fraction(at_even, EVEN_SAMPLE_RATE) - Fraction(at_uneven, UNEVEN_SAMPLE_RATE)) + assert difference < Fraction(1, UNEVEN_SAMPLE_RATE) + + class TestChannelsFillTheRow(BaseTestSuite): """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" @@ -146,11 +169,7 @@ def test_a_sounding_channel_fills_every_tick(self) -> None: reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() @@ -163,11 +182,7 @@ def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> N reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() steps = np.abs(np.diff(chunk)) diff --git a/tests/unit/sampletones_core/audio/writers/__init__.py b/tests/unit/sampletones_core/audio/writers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py new file mode 100644 index 00000000..cf3961be --- /dev/null +++ b/tests/unit/sampletones_core/audio/writers/test_spec.py @@ -0,0 +1,107 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.audio.writers import ( + AUDIO_DEPTHS, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + Mp3OutputSpec, + WaveOutputSpec, + capability_of, + default_mp3_bitrate, + mp3_bitrates, +) +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from tests.suite.base import BaseTestSuite + +MPEG_1_RATE: Final[int] = 44100 +MPEG_2_RATE: Final[int] = 22050 +MPEG_2_5_RATE: Final[int] = 8000 + + +class TestWaveOutputSpec(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + @pytest.mark.parametrize("depth", AUDIO_DEPTHS) + def test_every_rate_and_depth_is_accepted(self, sample_rate: int, depth: AudioDepth) -> None: + spec = WaveOutputSpec(sample_rate=sample_rate, depth=depth) + + assert spec.audio_format is AudioFormat.WAVE + assert spec.sample_rate == sample_rate + assert spec.depth is depth + + def test_the_extension_comes_from_the_capability(self) -> None: + assert WaveOutputSpec(sample_rate=MPEG_1_RATE).extension == EXT_FILE_WAVE + + def test_a_rate_outside_the_offered_set_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 44101 Hz"): + WaveOutputSpec(sample_rate=44101) + + def test_a_specification_is_frozen(self) -> None: + spec = WaveOutputSpec(sample_rate=MPEG_1_RATE) + + with pytest.raises(ValidationError): + spec.sample_rate = MPEG_2_RATE + + +class TestMp3OutputSpec(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES) + def test_every_bitrate_on_the_ladder_is_accepted(self, sample_rate: int) -> None: + for bitrate in mp3_bitrates(sample_rate): + spec = Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate) + + assert spec.audio_format is AudioFormat.MP3 + assert spec.bitrate == bitrate + + def test_the_extension_comes_from_the_capability(self) -> None: + assert Mp3OutputSpec.at(MPEG_1_RATE).extension == EXT_FILE_MP3 + + @pytest.mark.parametrize("sample_rate", (96000, 192000)) + def test_a_rate_the_encoder_rejects_is_rejected_here(self, sample_rate: int) -> None: + """MPEG audio defines its sample rates, and 96 kHz is not among them.""" + with pytest.raises(ValidationError, match="does not encode at"): + Mp3OutputSpec(sample_rate=sample_rate, bitrate=192) + + def test_a_bitrate_above_the_rate_s_ladder_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 320 kbps"): + Mp3OutputSpec(sample_rate=MPEG_2_RATE, bitrate=320) + + def test_a_bitrate_off_the_ladder_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 200 kbps"): + Mp3OutputSpec(sample_rate=MPEG_1_RATE, bitrate=200) + + @pytest.mark.parametrize( + ("sample_rate", "expected"), + ( + (MPEG_1_RATE, 192), + (48000, 192), + (MPEG_2_RATE, 160), + (16000, 160), + (MPEG_2_5_RATE, 64), + ), + ) + def test_the_default_bitrate_is_the_best_the_rate_reaches(self, sample_rate: int, expected: int) -> None: + assert default_mp3_bitrate(sample_rate) == expected + assert Mp3OutputSpec.at(sample_rate).bitrate == expected + + +class TestFormatCapabilities(BaseTestSuite): + def test_wave_stores_samples_and_mp3_does_not(self) -> None: + assert capability_of(AudioFormat.WAVE).stores_samples + assert not capability_of(AudioFormat.MP3).stores_samples + + def test_the_mp3_rates_are_the_rates_a_ladder_is_declared_for(self) -> None: + assert capability_of(AudioFormat.MP3).sample_rates == MP3_SAMPLE_RATES + assert all(mp3_bitrates(sample_rate) for sample_rate in MP3_SAMPLE_RATES) + + def test_the_ladders_run_from_highest_to_lowest(self) -> None: + for sample_rate in MP3_SAMPLE_RATES: + bitrates = mp3_bitrates(sample_rate) + + assert list(bitrates) == sorted(bitrates, reverse=True) + + def test_the_wave_rates_are_the_rates_the_application_offers(self) -> None: + assert capability_of(AudioFormat.WAVE).sample_rates == tuple(SAMPLE_RATES) diff --git a/tests/unit/sampletones_core/audio/writers/test_writer.py b/tests/unit/sampletones_core/audio/writers/test_writer.py new file mode 100644 index 00000000..1a3b58ee --- /dev/null +++ b/tests/unit/sampletones_core/audio/writers/test_writer.py @@ -0,0 +1,174 @@ +from pathlib import Path +from typing import Dict, Final, Tuple + +import numpy as np +import pytest +import soundfile + +from sampletones_core.audio.writers import ( + AUDIO_DEPTHS, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + AudioOutputSpec, + Mp3OutputSpec, + WaveOutputSpec, + available_audio_formats, + available_depths, + open_audio_writer, +) +from sampletones_shared.exceptions import AudioWriteError +from tests.suite.base import BaseTestSuite + +SAMPLE_RATE: Final[int] = 44100 +SECONDS: Final[float] = 1.0 +CHUNK: Final[int] = 367 +TONE_FREQUENCY: Final[float] = 440.0 +DEPTH_TOLERANCES: Final[Dict[AudioDepth, float]] = { + AudioDepth.PCM_U8: 1.0 / 128, + AudioDepth.PCM_16: 1.0 / 32768, + AudioDepth.PCM_24: 1.0 / 8388608, + AudioDepth.PCM_32: 1.0 / 8388608, + AudioDepth.FLOAT_32: 1e-6, +} + + +def _tone(sample_rate: int, seconds: float = SECONDS) -> np.ndarray: + samples = int(sample_rate * seconds) + return (0.5 * np.sin(2 * np.pi * TONE_FREQUENCY * np.arange(samples) / sample_rate)).astype(np.float32) + + +def _chunks(audio: np.ndarray, size: int = CHUNK) -> Tuple[np.ndarray, ...]: + return tuple(audio[offset : offset + size] for offset in range(0, len(audio), size)) + + +def _write(path: Path, spec: AudioOutputSpec, audio: np.ndarray) -> None: + with open_audio_writer(path, spec) as writer: + for chunk in _chunks(audio): + writer.write(chunk) + + +class TestTheEncoderIsProbed(BaseTestSuite): + """What the registry declares is offered only where the installed encoder also writes it.""" + + def test_wave_is_always_available(self) -> None: + assert AudioFormat.WAVE in available_audio_formats() + + def test_the_offered_depths_are_the_declared_ones_the_encoder_writes(self) -> None: + assert set(available_depths(AudioFormat.WAVE)) <= set(AUDIO_DEPTHS) + + def test_a_format_that_sets_its_own_depth_offers_none(self) -> None: + assert available_depths(AudioFormat.MP3) == () + + +class TestWaveRoundTrip(BaseTestSuite): + """Audio written a chunk at a time reads back whole, at the depth it was asked for.""" + + @pytest.mark.parametrize("depth", AUDIO_DEPTHS) + def test_a_render_reads_back_at_its_depth(self, tmp_path: Path, depth: AudioDepth) -> None: + audio = _tone(SAMPLE_RATE) + path = tmp_path / f"render{WaveOutputSpec(sample_rate=SAMPLE_RATE).extension}" + + _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE, depth=depth), audio) + restored, sample_rate = soundfile.read(path, dtype="float32") + + assert sample_rate == SAMPLE_RATE + assert len(restored) == len(audio) + assert float(np.abs(restored - audio).max()) <= DEPTH_TOLERANCES[depth] + + @pytest.mark.parametrize("sample_rate", (8000, 22050, 48000, 96000, 192000)) + def test_every_offered_rate_is_written(self, tmp_path: Path, sample_rate: int) -> None: + audio = _tone(sample_rate, seconds=0.1) + path = tmp_path / "render.wav" + + _write(path, WaveOutputSpec(sample_rate=sample_rate), audio) + info = soundfile.info(path) + + assert info.samplerate == sample_rate + assert info.frames == len(audio) + + def test_chunks_of_differing_lengths_are_written_whole(self, tmp_path: Path) -> None: + """A row varies in length where the tick clock spreads a fraction, so chunks do too.""" + path = tmp_path / "render.wav" + lengths = (367, 368, 367, 1, 4096, 12) + audio = _tone(SAMPLE_RATE) + + offset = 0 + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + for length in lengths: + writer.write(audio[offset : offset + length]) + offset += length + + assert soundfile.info(path).frames == sum(lengths) + + def test_a_finished_file_stands_on_its_own(self, tmp_path: Path) -> None: + path = tmp_path / "render.wav" + + _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE), _tone(SAMPLE_RATE)) + + assert path.exists() + assert path.stat().st_size > 0 + + +class TestMp3RoundTrip(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES) + def test_a_render_reads_back_at_its_rate(self, tmp_path: Path, sample_rate: int) -> None: + audio = _tone(sample_rate) + path = tmp_path / "render.mp3" + + _write(path, Mp3OutputSpec.at(sample_rate), audio) + info = soundfile.info(path) + + assert info.samplerate == sample_rate + assert info.frames == len(audio) + + @pytest.mark.parametrize("bitrate", (320, 192, 128, 64)) + def test_the_encoded_rate_follows_the_chosen_bitrate(self, tmp_path: Path, bitrate: int) -> None: + """The ladder is what makes a bitrate choice mean something, so it is measured.""" + seconds = 8.0 + audio = _tone(SAMPLE_RATE, seconds=seconds) + path = tmp_path / "render.mp3" + + _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio) + measured = path.stat().st_size * 8 / seconds / 1000 + + assert abs(measured - bitrate) < 0.05 * bitrate + + def test_a_higher_bitrate_makes_a_larger_file(self, tmp_path: Path) -> None: + audio = _tone(SAMPLE_RATE, seconds=4.0) + sizes = [] + for bitrate in (64, 128, 320): + path = tmp_path / f"render_{bitrate}.mp3" + _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio) + sizes.append(path.stat().st_size) + + assert sizes == sorted(sizes) + + +class TestTheWriterOwnsItsFile(BaseTestSuite): + def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None: + writer = open_audio_writer(tmp_path / "render.wav", WaveOutputSpec(sample_rate=SAMPLE_RATE)) + + with pytest.raises(AudioWriteError, match="write within the writer's context"): + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + def test_writing_after_the_block_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "render.wav" + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + with pytest.raises(AudioWriteError, match="write within the writer's context"): + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + def test_a_render_interrupted_partway_leaves_a_readable_file(self, tmp_path: Path) -> None: + """A cancel leaves the file finalized, so the caller decides whether to keep it.""" + path = tmp_path / "render.wav" + audio = _tone(SAMPLE_RATE) + written = 0 + + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + for chunk in _chunks(audio)[:10]: + writer.write(chunk) + written += len(chunk) + + assert soundfile.info(path).frames == written diff --git a/uv.lock b/uv.lock index 0098df28..d8dc1be3 100644 --- a/uv.lock +++ b/uv.lock @@ -1735,6 +1735,7 @@ dependencies = [ { name = "rich" }, { name = "scipy" }, { name = "screeninfo" }, + { name = "soundfile" }, { name = "tqdm" }, ] @@ -1789,6 +1790,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.0,<16" }, { name = "scipy", specifier = ">=1.13,<2" }, { name = "screeninfo", specifier = ">=0.8,<0.9" }, + { name = "soundfile", specifier = ">=0.13,<0.14" }, { name = "tqdm", specifier = ">=4.66,<5" }, ] provides-extras = ["build", "gpu", "gpu-cuda11"] From c6c57f12a6a935883f59281d6c1c18ade18d5f4f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:13:46 +0200 Subject: [PATCH 03/10] Added: song render service --- THIRD-PARTY-NOTICES.md | 16 +- docs/development/playback.md | 4 +- scripts/checks/import_boundary.py | 1 + .../logic/sequencer/playback/synthesizer.py | 474 ------------------ .../playback/synthesizer/__init__.py | 17 + .../sequencer/playback/synthesizer/bank.py | 86 ++++ .../sequencer/playback/synthesizer/frames.py | 47 ++ .../playback/synthesizer/modifiers.py | 43 ++ .../sequencer/playback/synthesizer/rates.py | 38 ++ .../sequencer/playback/synthesizer/state.py | 41 ++ .../playback/synthesizer/synthesizer.py | 284 +++++++++++ .../sequencer/playback/synthesizer/timing.py | 47 ++ .../services/__init__.py | 10 + .../services/render/__init__.py | 18 + .../services/render/constants.py | 5 + .../services/render/progress.py | 49 ++ .../services/render/result.py | 31 ++ .../services/render/scratch.py | 86 ++++ .../services/render/service.py | 167 ++++++ .../services/render/sink.py | 194 +++++++ .../services/song_player/player.py | 2 +- .../services/song_player/protocol.py | 30 -- .../services/synthesis/__init__.py | 5 + .../services/synthesis/protocol.py | 33 ++ src/sampletones_core/audio/__init__.py | 2 + src/sampletones_core/audio/processing.py | 13 + .../playback/test_apply_modifiers.py | 12 +- .../sequencer/playback/test_synthesizer.py | 4 +- .../services/render/__init__.py | 0 .../services/render/conftest.py | 79 +++ .../services/render/test_service.py | 270 ++++++++++ .../services/render/test_sink.py | 158 ++++++ 32 files changed, 1746 insertions(+), 520 deletions(-) delete mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/state.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py create mode 100644 src/sampletones_application/services/render/__init__.py create mode 100644 src/sampletones_application/services/render/constants.py create mode 100644 src/sampletones_application/services/render/progress.py create mode 100644 src/sampletones_application/services/render/result.py create mode 100644 src/sampletones_application/services/render/scratch.py create mode 100644 src/sampletones_application/services/render/service.py create mode 100644 src/sampletones_application/services/render/sink.py delete mode 100644 src/sampletones_application/services/song_player/protocol.py create mode 100644 src/sampletones_application/services/synthesis/__init__.py create mode 100644 src/sampletones_application/services/synthesis/protocol.py create mode 100644 tests/unit/sampletones_application/services/render/__init__.py create mode 100644 tests/unit/sampletones_application/services/render/conftest.py create mode 100644 tests/unit/sampletones_application/services/render/test_service.py create mode 100644 tests/unit/sampletones_application/services/render/test_sink.py diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 5e6f29fb..65d37c0f 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -35,12 +35,16 @@ are not used in any SampleToNES component name. Every dependency is installed separately by `pip`/`uv` from PyPI and imported at runtime. -Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Two are under the -GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) (LGPL-3.0, -a direct dependency) and [soxr](https://pypi.org/project/soxr/) (LGPL-2.1-or-later, a -transitive dependency of `librosa`) — and two, `certifi` and `tqdm`, are under MPL-2.0. - -All four are used as unmodified, separately installed libraries loaded dynamically at +Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Three carry code +under the GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) +(LGPL-3.0, a direct dependency), [soxr](https://pypi.org/project/soxr/) +(LGPL-2.1-or-later, a transitive dependency of `librosa`), and +[soundfile](https://pypi.org/project/soundfile/) (BSD-3-Clause itself, a direct +dependency, whose wheel carries the libsndfile shared library under LGPL-2.1-or-later with +LAME and mpg123 statically linked into it) — and two, `certifi` and `tqdm`, are under +MPL-2.0. + +All of them are used as unmodified, separately installed libraries loaded dynamically at import time. No LGPL- or MPL-licensed code is copied into the wheel or the sdist, so the MIT License applies to the PyPI package without further obligation. diff --git a/docs/development/playback.md b/docs/development/playback.md index 165312e0..96892f3f 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -191,8 +191,10 @@ terminating would reclaim. | The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) | | Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | | Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | -| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer.py`) | +| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | +| The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) | | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | +| How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | | The song's render-ahead buffer | `services/song_player/` | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index f373fb8e..14c61f15 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -39,6 +39,7 @@ SERVICE_CONTRACTS = [ "sampletones_application.services.result", + "sampletones_application.services.render.result", "sampletones_application.services.song_player.result", ] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py deleted file mode 100644 index 706d2d69..00000000 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ /dev/null @@ -1,474 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field, replace -from itertools import accumulate -from typing import Callable, Dict, FrozenSet, List, Optional, Tuple - -import numpy as np - -from sampletones_application.constants.playback import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, -) -from sampletones_application.logic.project.controller import ProjectController -from sampletones_core.audio import clip_audio_inplace -from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH -from sampletones_core.generators.maps import GENERATOR_CLASSES -from sampletones_core.instructions import ( - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) -from sampletones_core.project import Project -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.song import Song -from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove, Metre, RowRate, TickClock, calculate_groove - -from .protocol import ChannelGeneratorProtocol - - -@dataclass -class _ChannelState: - generator: ChannelGeneratorProtocol - sample_id: Optional[str] = field(default=None) - tick_index: int = field(default=0) - transpose: int = field(default=0) - volume: int = field(default=MAX_VOLUME) - - -@dataclass(frozen=True) -class _SongTiming: - """Everything a project's groove is built from, held together so a change is one comparison. - - Attributes: - rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. - metre: The pattern length and the beat and bar grouping the ticks are spread over. - """ - - rate: RowRate - metre: Metre - - @classmethod - def from_project(cls, project: Project) -> _SongTiming: - """Reads the timing a project plays at, taking the pattern length from its song.""" - return cls( - rate=RowRate.from_settings(project.settings), - metre=Metre.from_settings( - project.settings, - rows=project.song.rows_per_pattern, - ), - ) - - def groove(self) -> Groove: - """Spreads the row rate across a pattern's rows. - - Playback follows whatever tempo the project states, so the one bound it sets is that - every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the - settings can ask for, which leaves the groove free to realize the rate exactly. - """ - return calculate_groove( - self.rate, - self.metre, - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ) - - -@dataclass(frozen=True) -class _EngineRates: - """The pair of rates a tick is sized from, held together so a change is one comparison. - - Each rate is owned elsewhere: the project states how many instructions the engine consumes - each second, and whoever takes the audio states the rate it is rendered at — the output - device for playback, the chosen format for a file. Together they fix how many samples one - tick spans, so the synthesiser follows both. - - Attributes: - nes_frequency: The engine ticks consumed each second. - sample_rate: The samples the rendered audio holds each second. - """ - - nes_frequency: int - sample_rate: int - - def clock(self) -> TickClock: - """The samples each tick spans under this pair of rates.""" - return TickClock.from_parameters( - sample_rate=self.sample_rate, - nes_frequency=self.nes_frequency, - ) - - -@dataclass(frozen=True) -class _RowFrames: - """Where each of a row's ticks starts and ends within the row's audio. - - A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the - lengths within one row vary where the sample rate does not divide the tick rate. Resolving the - boundaries once per row is what lets every channel write into the same offsets. - - Attributes: - lengths: The samples each of the row's ticks spans, in order. - bounds: Each tick's start offset, ending with the row's total length. - """ - - lengths: Tuple[int, ...] - bounds: Tuple[int, ...] - - @classmethod - def from_clock( - cls, - clock: TickClock, - *, - elapsed_ticks: int, - ticks: int, - ) -> _RowFrames: - """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" - lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) - return cls( - lengths=lengths, - bounds=tuple(accumulate(lengths, initial=0)), - ) - - @property - def total(self) -> int: - """The samples the whole row spans.""" - return self.bounds[-1] - - @property - def longest(self) -> int: - """The samples the row's longest tick spans.""" - return max(self.lengths, default=0) - - -def _silence(samples: int) -> np.ndarray: - return np.zeros(samples, dtype=np.float32) - - -def _apply_modifiers( - instruction: InstructionUnion, - transpose: int, - row_volume: int, -) -> InstructionUnion: - match instruction: - case PulseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) - return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) - case TriangleInstruction(): - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) - on = instruction.on and row_volume > MAX_VOLUME // 2 - return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) - case NoiseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_period = (instruction.period + transpose) % 16 - return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) - - -class RowSynthesizer: - """Real-time synthesis engine for tracker song playback. - - Reads the live ``Project`` from ``project_controller`` on every ``render_row`` - call so that pattern edits, tempo changes, and sample swaps take effect - immediately while playback keeps running. - - A row lasts the ticks the project's groove gives its position within the pattern, so the - row a pattern's tenth row plays for is the row an exported module plays it for: both index - the same groove from the pattern's first row. - - Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` - gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample - rate and the groove's tempo is the tempo heard. - - ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that - audio runs at: the output device for live playback, the chosen format for a file. Reading it - per row keeps the two in step, so a rendered second is a second wherever the audio goes. - - Generators are constructed from ``config`` at the rates in force and carry timer - state across rows for phase continuity within a sustained note. Triggering a new - note calls ``generator.reset()`` for a clean phase start. - - ``active_channels`` reports which channels sound and is consulted once per channel per - row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A - silenced channel still takes each row's instrument, transpose, and volume, so unmuting - resumes on the state the pattern has reached. - """ - - def __init__( - self, - project_controller: ProjectController, - config: Config, - *, - active_channels: Callable[[], FrozenSet[GeneratorName]], - sample_rate: Callable[[], int], - ) -> None: - self._project_controller = project_controller - self._config = config - self._active_channels = active_channels - self._sample_rate = sample_rate - self._position = SongPosition() - self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) - self._groove: Groove = self._timing.groove() - self._rates: _EngineRates = self._current_rates() - self._tick_clock: TickClock = self._rates.clock() - self._elapsed_ticks: int = 0 - self._channel_states: Dict[GeneratorName, _ChannelState] = { - generator_name: _ChannelState(generator=generator) - for generator_name, generator in self._build_generators(self._rates).items() - } - - @property - def order_position(self) -> int: - return self._position.order_position - - @property - def row_index(self) -> int: - return self._position.row_index - - @property - def is_finished(self) -> bool: - project = self._project_controller.project - return self._position.order_position >= project.song.order_length() - - def set_position(self, order_position: int, row_index: int) -> None: - self._position.order_position = order_position - self._position.row_index = row_index - - def reset(self) -> None: - self._elapsed_ticks = 0 - for state in self._channel_states.values(): - state.sample_id = None - state.tick_index = 0 - state.transpose = 0 - state.volume = MAX_VOLUME - - def _ensure_generators(self) -> None: - """Rebuilds the channel generators when either rate a tick is sized from changes. - - The engine consumes ``nes_frequency`` instructions a second and the audio holds - ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the - project's frequency keeps a row a constant real-time duration as that frequency changes, - and following the output's rate keeps a rendered second a second wherever the audio goes. - Pitch derives from the APU clock rather than either rate, so a change moves only the - per-tick frame length; the generators' phase continuity resets, which is acceptable for an - occasional settings edit. - - The tick clock follows the same pair, since it states how long one of those ticks lasts. - """ - rates = self._current_rates() - if rates == self._rates: - return - - self._rates = rates - self._tick_clock = rates.clock() - for generator_name, generator in self._build_generators(rates).items(): - self._channel_states[generator_name].generator = generator - - def _current_rates(self) -> _EngineRates: - return _EngineRates( - nes_frequency=self._project_controller.project.settings.nes_frequency, - sample_rate=self._sample_rate(), - ) - - def _build_generators(self, rates: _EngineRates) -> Dict[GeneratorName, ChannelGeneratorProtocol]: - config = self._engine_config(rates) - return { - generator_name: GENERATOR_CLASSES[generator_name]( - config, - generator_name.value, - ) - for generator_name in GeneratorName.items() - } - - def _engine_config(self, rates: _EngineRates) -> Config: - return self._config.with_library( - nes_frequency=rates.nes_frequency, - sample_rate=rates.sample_rate, - ) - - def render_row(self) -> Tuple[np.ndarray, SongPosition]: - project = self._project_controller.project - song = project.song - self._position.wrap_overflow(song.rows_per_pattern) - self._ensure_generators() - self._ensure_groove(project) - - frames = _RowFrames.from_clock( - self._tick_clock, - elapsed_ticks=self._elapsed_ticks, - ticks=self._groove.ticks[self._position.row_index], - ) - - position_before = replace(self._position) - finished = self.is_finished - mixed = ( - _silence(frames.total) - if finished - else self._mix_channels( - project, - song, - frames, - ) - ) - - self._elapsed_ticks += len(frames.lengths) - if not finished: - self._advance_position(song) - - return mixed, position_before - - def _ensure_groove(self, project: Project) -> None: - """Rebuilds the groove when the row rate or the metre it is spread over changes. - - An engine that holds a row for a whole number of ticks reaches a fractional row rate by - varying that number from row to row, and the groove is where those counts are decided. - Rebuilding only on a timing edit keeps a tempo change immediate while the distribution - itself, which spans a whole pattern, is computed once. - """ - timing = _SongTiming.from_project(project) - if timing == self._timing: - return - - self._timing = timing - self._groove = timing.groove() - - def _mix_channels( - self, - project: Project, - song: Song, - frames: _RowFrames, - ) -> np.ndarray: - mixed = _silence(frames.total) - for generator_name in GeneratorName.items(): - channel_audio = self._render_channel( - generator_name, - project, - song, - frames, - ) - mixed += channel_audio - - return clip_audio_inplace(mixed) - - def _render_channel( - self, - generator_name: GeneratorName, - project: Project, - song: Song, - frames: _RowFrames, - ) -> np.ndarray: - state = self._channel_states[generator_name] - - row = self._resolve_row(generator_name, song) - if row is not None: - self._apply_row_to_state(state, row) - - sample_id = state.sample_id - if sample_id is None or generator_name not in self._active_channels(): - return _silence(frames.total) - - return self._synthesize_ticks( - state, - sample_id, - project, - generator_name, - frames, - ) - - def _resolve_row(self, generator_name: GeneratorName, song: Song) -> Optional[Row]: - if self._position.order_position >= song.order_length(): - return None - - order_entry = song.order[self._position.order_position].get(generator_name) - if order_entry is None: - return None - - pattern = song.pattern(generator_name, order_entry) - if pattern is None or self._position.row_index >= len(pattern.rows): - return None - - return pattern.rows[self._position.row_index] - - def _apply_row_to_state(self, state: _ChannelState, row: Row) -> None: - match row.command: - case Instrument() as instrument: - state.generator.reset() - state.sample_id = instrument.sample_id - state.tick_index = 0 - state.transpose = row.transpose if row.transpose is not None else 0 - state.volume = row.volume if row.volume is not None else MAX_VOLUME - case NoteOff(): - state.generator.reset() - state.sample_id = None - state.tick_index = 0 - case None: - if row.transpose is not None: - state.transpose = row.transpose - if row.volume is not None: - state.volume = row.volume - - def _synthesize_ticks( - self, - state: _ChannelState, - sample_id: str, - project: Project, - generator_name: GeneratorName, - frames: _RowFrames, - ) -> np.ndarray: - sample = project.sample(sample_id) - if sample is None: - return _silence(frames.total) - - instructions = sample.reconstruction.instructions.get(generator_name) - if not instructions: - return _silence(frames.total) - - output = _silence(frames.total) - silence_frame = _silence(frames.longest) - - for tick, frame_length in enumerate(frames.lengths): - frame = self._synthesize_tick( - state, - instructions, - silence_frame[:frame_length], - sample.loop, - frame_length, - ) - output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame - state.tick_index += 1 - - return output - - def _synthesize_tick( - self, - state: _ChannelState, - instructions: List[InstructionUnion], - silence_frame: np.ndarray, - loop: bool, - frame_length: int, - ) -> np.ndarray: - if loop: - instruction = instructions[state.tick_index % len(instructions)] - elif state.tick_index < len(instructions): - instruction = instructions[state.tick_index] - else: - return silence_frame - - state.generator.frame_length = frame_length - return state.generator( - _apply_modifiers( - instruction, - state.transpose, - state.volume, - ), - save=True, - ) - - def _advance_position(self, song: Song) -> None: - self._position.advance(song.rows_per_pattern, song.order_length()) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py new file mode 100644 index 00000000..36dfc253 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -0,0 +1,17 @@ +from .bank import ChannelBank +from .frames import RowFrames +from .modifiers import apply_modifiers +from .rates import EngineRates +from .state import ChannelState +from .synthesizer import RowSynthesizer +from .timing import SongTiming + +__all__ = [ + "ChannelBank", + "ChannelState", + "EngineRates", + "RowFrames", + "RowSynthesizer", + "SongTiming", + "apply_modifiers", +] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py new file mode 100644 index 00000000..1e8ab94a --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py @@ -0,0 +1,86 @@ +from typing import Dict + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.generators.maps import GENERATOR_CLASSES +from sampletones_core.timing import TickClock + +from ..protocol import ChannelGeneratorProtocol +from .rates import EngineRates +from .state import ChannelState + + +class ChannelBank: + """The channels a song sounds through, and the rates they are built at. + + One generator per NES channel, each holding the timer state that carries a note's phase across + ticks and rows, beside the pattern state its channel has reached. Holding the rates here as + well is what makes following them a single decision: the generators and the tick clock are + built from the same pair, so they agree on how long a tick is. + """ + + def __init__(self, config: Config, rates: EngineRates) -> None: + self._config = config + self._rates = rates + self._clock: TickClock = rates.clock() + self._states: Dict[GeneratorName, ChannelState] = { + generator_name: ChannelState(generator=generator) + for generator_name, generator in self._build_generators(rates).items() + } + + @property + def clock(self) -> TickClock: + """The samples each tick spans at the rates in force.""" + return self._clock + + def state(self, generator_name: GeneratorName) -> ChannelState: + """What ``generator_name`` carries from row to row.""" + return self._states[generator_name] + + def reset(self) -> None: + """Returns every channel to silence at full volume, as a song starts them.""" + for state in self._states.values(): + state.reset() + + def follow(self, rates: EngineRates) -> None: + """Rebuilds the generators when either rate a tick is sized from changes. + + The engine consumes ``nes_frequency`` instructions a second and the audio holds + ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the + project's frequency keeps a row a constant real-time duration as that frequency changes, + and following the output's rate keeps a rendered second a second wherever the audio goes. + Pitch derives from the APU clock rather than either rate, so a change moves only the + per-tick frame length; the generators' phase continuity resets, which is acceptable for an + occasional settings edit. + + The tick clock follows the same pair, since it states how long one of those ticks lasts. + + Args: + rates: The pair in force for the row about to be rendered. + """ + if rates == self._rates: + return + + self._rates = rates + self._clock = rates.clock() + for generator_name, generator in self._build_generators(rates).items(): + self._states[generator_name].generator = generator + + def _build_generators( + self, + rates: EngineRates, + ) -> Dict[GeneratorName, ChannelGeneratorProtocol]: + config = self._engine_config(rates) + return { + generator_name: GENERATOR_CLASSES[generator_name]( + config, + generator_name.value, + ) + for generator_name in GeneratorName.items() + } + + def _engine_config(self, rates: EngineRates) -> Config: + return self._config.with_library( + nes_frequency=rates.nes_frequency, + sample_rate=rates.sample_rate, + ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py new file mode 100644 index 00000000..8f461055 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from itertools import accumulate +from typing import Self, Tuple + +from sampletones_core.timing import TickClock + + +@dataclass(frozen=True) +class RowFrames: + """Where each of a row's ticks starts and ends within the row's audio. + + A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the + lengths within one row vary where the sample rate does not divide the tick rate. Resolving the + boundaries once per row is what lets every channel write into the same offsets. + + Attributes: + lengths: The samples each of the row's ticks spans, in order. + bounds: Each tick's start offset, ending with the row's total length. + """ + + lengths: Tuple[int, ...] + bounds: Tuple[int, ...] + + @classmethod + def from_clock( + cls, + clock: TickClock, + *, + elapsed_ticks: int, + ticks: int, + ) -> Self: + """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" + lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) + return cls( + lengths=lengths, + bounds=tuple(accumulate(lengths, initial=0)), + ) + + @property + def total(self) -> int: + """The samples the whole row spans.""" + return self.bounds[-1] + + @property + def longest(self) -> int: + """The samples the row's longest tick spans.""" + return max(self.lengths, default=0) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py new file mode 100644 index 00000000..52a88b7e --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py @@ -0,0 +1,43 @@ +from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) + + +def apply_modifiers( + instruction: InstructionUnion, + transpose: int, + row_volume: int, +) -> InstructionUnion: + """Bends one tick's instruction by the transpose and volume the pattern has reached. + + A sample carries the instructions it was reconstructed from; a pattern states how loud and how + high it is played. Each channel takes both in the terms it understands: the pulse channels + scale their volume and shift their pitch, the triangle shifts its pitch and sounds while the + row asks for more than half volume, and the noise channel scales its volume and walks its + period around the sixteen the hardware offers. + + Args: + instruction: The tick's instruction as the sample holds it. + transpose: The semitone offset the pattern has reached, held within the pitch range. + row_volume: The level the pattern has reached, scaling the instruction's own. + + Returns: + InstructionUnion: A copy of the instruction as the channel sounds it. + """ + match instruction: + case PulseInstruction(): + scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) + case TriangleInstruction(): + effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + on = instruction.on and row_volume > MAX_VOLUME // 2 + return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) + case NoiseInstruction(): + scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + effective_period = (instruction.period + transpose) % 16 + return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py new file mode 100644 index 00000000..b256c6b0 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_core.project import Project +from sampletones_core.timing import TickClock + + +@dataclass(frozen=True) +class EngineRates: + """The pair of rates a tick is sized from, held together so a change is one comparison. + + Each rate is owned elsewhere: the project states how many instructions the engine consumes + each second, and whoever takes the audio states the rate it is rendered at — the output + device for playback, the chosen format for a file. Together they fix how many samples one + tick spans, so the synthesiser follows both. + + Attributes: + nes_frequency: The engine ticks consumed each second. + sample_rate: The samples the rendered audio holds each second. + """ + + nes_frequency: int + sample_rate: int + + @classmethod + def from_project(cls, project: Project, sample_rate: int) -> Self: + """The rates in force for ``project`` rendered at ``sample_rate``.""" + return cls( + nes_frequency=project.settings.nes_frequency, + sample_rate=sample_rate, + ) + + def clock(self) -> TickClock: + """The samples each tick spans under this pair of rates.""" + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py new file mode 100644 index 00000000..ca2974a8 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field +from typing import Optional + +from sampletones_core.constants.general import MAX_VOLUME + +from ..protocol import ChannelGeneratorProtocol + + +@dataclass +class ChannelState: + """What one channel carries from row to row. + + A pattern states a channel's instrument, transpose, and volume only where it changes them, so + the channel keeps the last of each until another row states otherwise. The tick index is how + far into the sounding sample's instructions the channel has played, which is what lets a note + sustain across rows. + + Attributes: + generator: The synthesiser filling the channel's ticks. + sample_id: The sample the channel is sounding, or ``None`` while it is silent. + tick_index: How many ticks of that sample's instructions the channel has played. + transpose: The semitone offset a row last set. + volume: The level a row last set. + """ + + generator: ChannelGeneratorProtocol + sample_id: Optional[str] = field(default=None) + tick_index: int = field(default=0) + transpose: int = field(default=0) + volume: int = field(default=MAX_VOLUME) + + def reset(self) -> None: + """Returns the channel to silence at full volume, as a song starts it. + + The generator is kept, since it is built from the rates in force rather than from + anything a song reaches. + """ + self.sample_id = None + self.tick_index = 0 + self.transpose = 0 + self.volume = MAX_VOLUME diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py new file mode 100644 index 00000000..34aeb1f7 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -0,0 +1,284 @@ +from dataclasses import replace +from typing import Callable, FrozenSet, List, Optional, Tuple + +import numpy as np + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.audio import clip_audio_inplace, silence +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project import Project +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.song import Song +from sampletones_core.project.song_position import SongPosition +from sampletones_core.timing import Groove + +from .bank import ChannelBank +from .frames import RowFrames +from .modifiers import apply_modifiers +from .rates import EngineRates +from .state import ChannelState +from .timing import SongTiming + + +class RowSynthesizer: + """Real-time synthesis engine for tracker song playback. + + Reads the live ``Project`` from ``project_controller`` on every ``render_row`` + call so that pattern edits, tempo changes, and sample swaps take effect + immediately while playback keeps running. + + A row lasts the ticks the project's groove gives its position within the pattern, so the + row a pattern's tenth row plays for is the row an exported module plays it for: both index + the same groove from the pattern's first row. + + Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` + gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample + rate and the groove's tempo is the tempo heard. + + ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that + audio runs at: the output device for live playback, the chosen format for a file. Reading it + per row keeps the two in step, so a rendered second is a second wherever the audio goes. + + Generators are held in a :class:`ChannelBank` built from ``config`` at the rates in force, + carrying timer state across rows for phase continuity within a sustained note. Triggering a + new note calls ``generator.reset()`` for a clean phase start. + + ``active_channels`` reports which channels sound and is consulted once per channel per + row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A + silenced channel still takes each row's instrument, transpose, and volume, so unmuting + resumes on the state the pattern has reached. + """ + + def __init__( + self, + project_controller: ProjectController, + config: Config, + *, + active_channels: Callable[[], FrozenSet[GeneratorName]], + sample_rate: Callable[[], int], + ) -> None: + self._project_controller = project_controller + self._active_channels = active_channels + self._sample_rate = sample_rate + self._position = SongPosition() + self._timing: SongTiming = SongTiming.from_project(project_controller.project) + self._groove: Groove = self._timing.groove() + self._channels = ChannelBank(config, self._current_rates()) + self._elapsed_ticks: int = 0 + + @property + def order_position(self) -> int: + return self._position.order_position + + @property + def row_index(self) -> int: + return self._position.row_index + + @property + def is_finished(self) -> bool: + project = self._project_controller.project + return self._position.order_position >= project.song.order_length() + + def set_position(self, order_position: int, row_index: int) -> None: + self._position.order_position = order_position + self._position.row_index = row_index + + def reset(self) -> None: + self._elapsed_ticks = 0 + self._channels.reset() + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: + project = self._project_controller.project + song = project.song + self._position.wrap_overflow(song.rows_per_pattern) + self._channels.follow(self._current_rates()) + self._ensure_groove(project) + + frames = RowFrames.from_clock( + self._channels.clock, + elapsed_ticks=self._elapsed_ticks, + ticks=self._groove.ticks[self._position.row_index], + ) + + position_before = replace(self._position) + finished = self.is_finished + mixed = ( + silence(frames.total) + if finished + else self._mix_channels( + project, + song, + frames, + ) + ) + + self._elapsed_ticks += len(frames.lengths) + if not finished: + self._advance_position(song) + + return mixed, position_before + + def _current_rates(self) -> EngineRates: + return EngineRates.from_project( + self._project_controller.project, + self._sample_rate(), + ) + + def _ensure_groove(self, project: Project) -> None: + """Rebuilds the groove when the row rate or the metre it is spread over changes. + + An engine that holds a row for a whole number of ticks reaches a fractional row rate by + varying that number from row to row, and the groove is where those counts are decided. + Rebuilding only on a timing edit keeps a tempo change immediate while the distribution + itself, which spans a whole pattern, is computed once. + """ + timing = SongTiming.from_project(project) + if timing == self._timing: + return + + self._timing = timing + self._groove = timing.groove() + + def _mix_channels( + self, + project: Project, + song: Song, + frames: RowFrames, + ) -> np.ndarray: + mixed = silence(frames.total) + for generator_name in GeneratorName.items(): + channel_audio = self._render_channel( + generator_name, + project, + song, + frames, + ) + mixed += channel_audio + + return clip_audio_inplace(mixed) + + def _render_channel( + self, + generator_name: GeneratorName, + project: Project, + song: Song, + frames: RowFrames, + ) -> np.ndarray: + state = self._channels.state(generator_name) + + row = self._resolve_row(generator_name, song) + if row is not None: + self._apply_row_to_state(state, row) + + sample_id = state.sample_id + if sample_id is None or generator_name not in self._active_channels(): + return silence(frames.total) + + return self._synthesize_ticks( + state, + sample_id, + project, + generator_name, + frames, + ) + + def _resolve_row( + self, + generator_name: GeneratorName, + song: Song, + ) -> Optional[Row]: + if self._position.order_position >= song.order_length(): + return None + + order_entry = song.order[self._position.order_position].get(generator_name) + if order_entry is None: + return None + + pattern = song.pattern(generator_name, order_entry) + if pattern is None or self._position.row_index >= len(pattern.rows): + return None + + return pattern.rows[self._position.row_index] + + def _apply_row_to_state(self, state: ChannelState, row: Row) -> None: + match row.command: + case Instrument() as instrument: + state.generator.reset() + state.sample_id = instrument.sample_id + state.tick_index = 0 + state.transpose = row.transpose if row.transpose is not None else 0 + state.volume = row.volume if row.volume is not None else MAX_VOLUME + case NoteOff(): + state.generator.reset() + state.sample_id = None + state.tick_index = 0 + case None: + if row.transpose is not None: + state.transpose = row.transpose + if row.volume is not None: + state.volume = row.volume + + def _synthesize_ticks( + self, + state: ChannelState, + sample_id: str, + project: Project, + generator_name: GeneratorName, + frames: RowFrames, + ) -> np.ndarray: + sample = project.sample(sample_id) + if sample is None: + return silence(frames.total) + + instructions = sample.reconstruction.instructions.get(generator_name) + if not instructions: + return silence(frames.total) + + output = silence(frames.total) + silence_frame = silence(frames.longest) + + for tick, frame_length in enumerate(frames.lengths): + frame = self._synthesize_tick( + state, + instructions, + silence_frame[:frame_length], + sample.loop, + frame_length, + ) + output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame + state.tick_index += 1 + + return output + + def _synthesize_tick( + self, + state: ChannelState, + instructions: List[InstructionUnion], + silence_frame: np.ndarray, + loop: bool, + frame_length: int, + ) -> np.ndarray: + if loop: + instruction = instructions[state.tick_index % len(instructions)] + elif state.tick_index < len(instructions): + instruction = instructions[state.tick_index] + else: + return silence_frame + + state.generator.frame_length = frame_length + return state.generator( + apply_modifiers( + instruction, + state.transpose, + state.volume, + ), + save=True, + ) + + def _advance_position(self, song: Song) -> None: + self._position.advance(song.rows_per_pattern, song.order_length()) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py new file mode 100644 index 00000000..96c72dd7 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_application.constants.playback import ( + MAX_TICKS_PER_ROW, + MIN_TICKS_PER_ROW, +) +from sampletones_core.project import Project +from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove + + +@dataclass(frozen=True) +class SongTiming: + """Everything a project's groove is built from, held together so a change is one comparison. + + Attributes: + rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. + metre: The pattern length and the beat and bar grouping the ticks are spread over. + """ + + rate: RowRate + metre: Metre + + @classmethod + def from_project(cls, project: Project) -> Self: + """Reads the timing a project plays at, taking the pattern length from its song.""" + return cls( + rate=RowRate.from_settings(project.settings), + metre=Metre.from_settings( + project.settings, + rows=project.song.rows_per_pattern, + ), + ) + + def groove(self) -> Groove: + """Spreads the row rate across a pattern's rows. + + Playback follows whatever tempo the project states, so the one bound it sets is that + every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the + settings can ask for, which leaves the groove free to realize the rate exactly. + """ + return calculate_groove( + self.rate, + self.metre, + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ) diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 2ce762af..59f0dcca 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -10,6 +10,11 @@ RegenerationResult, RegenerationService, ) +from sampletones_application.services.render import ( + RenderResult, + RenderStage, + SongRenderService, +) from sampletones_application.services.result import ( ConversionResult, ServiceCancelled, @@ -20,6 +25,7 @@ ServiceSuccess, ) from sampletones_application.services.retune import RetunedSample, RetuneResult, SampleRetuneService +from sampletones_application.services.synthesis import RowSynthesizerProtocol __all__ = [ "ConversionResult", @@ -32,8 +38,11 @@ "RegeneratedInstrument", "RegenerationResult", "RegenerationService", + "RenderResult", + "RenderStage", "RetuneResult", "RetunedSample", + "RowSynthesizerProtocol", "SampleRetuneService", "ServiceBase", "ServiceCancelled", @@ -42,4 +51,5 @@ "ServiceProgress", "ServiceStarted", "ServiceSuccess", + "SongRenderService", ] diff --git a/src/sampletones_application/services/render/__init__.py b/src/sampletones_application/services/render/__init__.py new file mode 100644 index 00000000..589c798b --- /dev/null +++ b/src/sampletones_application/services/render/__init__.py @@ -0,0 +1,18 @@ +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.service import SongRenderService +from sampletones_application.services.render.sink import ( + DirectRenderSink, + NormalizingRenderSink, + RenderSink, + build_render_sink, +) + +__all__ = [ + "DirectRenderSink", + "NormalizingRenderSink", + "RenderResult", + "RenderSink", + "RenderStage", + "SongRenderService", + "build_render_sink", +] diff --git a/src/sampletones_application/services/render/constants.py b/src/sampletones_application/services/render/constants.py new file mode 100644 index 00000000..df1ca36e --- /dev/null +++ b/src/sampletones_application/services/render/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +PROGRESS_STEPS: Final[int] = 200 +ENCODE_BLOCK_SAMPLES: Final[int] = 1 << 16 +SCRATCH_SUFFIX: Final[str] = ".scratch" diff --git a/src/sampletones_application/services/render/progress.py b/src/sampletones_application/services/render/progress.py new file mode 100644 index 00000000..da09d67e --- /dev/null +++ b/src/sampletones_application/services/render/progress.py @@ -0,0 +1,49 @@ +from typing import Callable + +from sampletones_application.services.render.constants import PROGRESS_STEPS +from sampletones_application.services.render.result import RenderStage +from sampletones_application.services.result import ServiceProgress +from sampletones_core.parallelization import ETAEstimator + + +class StageProgress: + """One pass of a render, reported at a bounded rate. + + A render walks a song sample by sample, so reporting every step would fill the callback + queue with updates no eye resolves and no bar redraws. Emitting on a fraction of the total + holds the report rate steady whatever the song's length, and the last position is always + reported, so a bar arrives at its end. + """ + + def __init__( + self, + stage: RenderStage, + total: int, + *, + emit: Callable[[ServiceProgress[RenderStage]], None], + ) -> None: + self._stage = stage + self._total = total + self._emit = emit + self._estimator = ETAEstimator(total=total) + self._interval = max(1, total // PROGRESS_STEPS) + self._reported: int = 0 + + def advance(self, completed: int) -> None: + """Reports the pass at ``completed`` samples where a step is due. + + Args: + completed: The samples this pass has covered so far. + """ + if completed < self._total and completed - self._reported < self._interval: + return + + self._reported = completed + self._emit( + ServiceProgress( + completed=completed, + total=self._total, + current_item=self._stage, + eta_seconds=self._estimator.update(completed), + ) + ) diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py new file mode 100644 index 00000000..9ee105e5 --- /dev/null +++ b/src/sampletones_application/services/render/result.py @@ -0,0 +1,31 @@ +from enum import StrEnum +from pathlib import Path +from typing import Union + +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) + + +class RenderStage(StrEnum): + """The pass a render is on, naming what a progress report is counting. + + Both passes count the samples the song holds, so a report reads the same way whichever one + it comes from and a bar crosses the same axis twice. + """ + + SYNTHESIS = "synthesis" + ENCODING = "encoding" + + +RenderResult = Union[ + ServiceStarted, + ServiceProgress[RenderStage], + ServiceSuccess[Path], + ServiceError, + ServiceCancelled, +] diff --git a/src/sampletones_application/services/render/scratch.py b/src/sampletones_application/services/render/scratch.py new file mode 100644 index 00000000..0e838ab7 --- /dev/null +++ b/src/sampletones_application/services/render/scratch.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import BinaryIO, Final, Iterator, Optional + +import numpy as np + +from sampletones_shared.exceptions import AudioWriteError + +NO_PEAK: Final[float] = 0.0 + + +class ScratchAudio: + """A render's samples spilled to disk beside its destination while their peak is discovered. + + Scaling a render to its peak needs the whole render before any of it can be written, and a + song is longer than a buffer worth holding in memory. Raw float32 samples are what a private + intermediate needs: the file is written once, read back once in blocks, and removed, so a + container would only describe what the writer already knows. + + Attributes: + path: Where the samples are spilled. + """ + + def __init__(self, path: Path) -> None: + self.path = path + self._handle: Optional[BinaryIO] = None + self._peak: float = NO_PEAK + self._samples: int = 0 + + @property + def samples(self) -> int: + """How many samples have been spilled.""" + return self._samples + + @property + def peak(self) -> float: + """The loudest sample spilled so far, as an absolute amplitude.""" + return self._peak + + def start(self) -> None: + """Opens the spill file, replacing anything a previous run left at the path.""" + self._handle = self.path.open("wb") + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk, keeping the loudest sample seen across the whole spill. + + Args: + chunk: Float samples to spill. + + Raises: + AudioWriteError: If the spill file is not open. + """ + if self._handle is None: + raise AudioWriteError(f"No spill file open at '{self.path}'; write between start and seal") + + chunk.astype(np.float32, copy=False).tofile(self._handle) + self._peak = max(self._peak, float(np.max(np.abs(chunk), initial=NO_PEAK))) + self._samples += len(chunk) + + def seal(self) -> None: + """Closes the spill file, leaving what was written ready to read back.""" + if self._handle is None: + return + + self._handle.close() + self._handle = None + + def blocks(self, size: int) -> Iterator[np.ndarray]: + """Reads the spilled samples back in order, in blocks of at most ``size`` samples. + + Args: + size: The samples one block holds at most; the last block holds what remains. + + Yields: + np.ndarray: One block of the spilled float samples. + """ + with self.path.open("rb") as handle: + while True: + block = np.fromfile(handle, dtype=np.float32, count=size) + if not block.size: + return + + yield block + + def remove(self) -> None: + """Deletes the spill file, whether or not it was read back.""" + self.path.unlink(missing_ok=True) diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py new file mode 100644 index 00000000..fd43c284 --- /dev/null +++ b/src/sampletones_application/services/render/service.py @@ -0,0 +1,167 @@ +import threading +from functools import partial +from pathlib import Path + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.render.progress import StageProgress +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.sink import ( + EncodeReporter, + RenderSink, + build_render_sink, +) +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.audio.writers import AudioOutputSpec +from sampletones_shared.logger import logger + + +class SongRenderService(ServiceBase[RenderResult]): + """Renders a whole song to a file on a background thread, reporting each pass as it runs. + + The synthesiser arrives per call, so the service holds no opinion on what a song sounds + like: it drives the same kernel the player drives, one row at a time, and hands each row to + a sink. The sink decides what becomes of a row — straight to the encoder, or spilled and + written back at the level the whole render turned out to reach — so the service reports one + pass or two without knowing which format waits on the other side. + + A render is one at a time. Cancelling is honoured between rows and between encoded blocks, + and the file a cancelled or failed run was writing is removed, so a result names a path only + where a finished file stands. + """ + + def __init__(self, priority: int = 0) -> None: + super().__init__(priority) + self._executor = SingleThreadExecutor() + self._cancel_event = threading.Event() + self._running = threading.Event() + + def start( + self, + *, + synthesizer: RowSynthesizerProtocol, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + """Begins a render on the worker thread; reports whether it took the request. + + Args: + synthesizer: The kernel the song is rendered through, from its first row. + destination: Where the finished file is written. + spec: The format, rate, and quality it is written at. + normalize: Whether the render is scaled so its loudest sample reaches full scale. + total_samples: The samples the whole song holds, which the passes are measured against. + + Returns: + bool: Whether a render started; a request arriving while one runs is declined. + """ + if self.is_running(): + logger.warning(f"{self.class_name}: a render is already running; start ignored") + return False + + self._cancel_event.clear() + self._running.set() + sink = build_render_sink(destination, spec, normalize=normalize) + started = self._executor.execute( + partial(self._run, synthesizer, sink, total_samples), + wait=False, + ) + if not started: + self._running.clear() + + return started + + def cancel(self) -> None: + """Asks a running render to stop at its next row or block.""" + self._cancel_event.set() + + def is_running(self) -> bool: + return self._running.is_set() + + def shutdown(self) -> None: + """Winds a running render down for application exit. + + The worker runs on a :class:`SingleThreadExecutor`, so the teardown that joins every + background worker reaches this one; asking it to stop first is what keeps that join short. + """ + self._cancel_event.set() + + def _run( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> None: + try: + self._emit(ServiceStarted(total=total_samples)) + self._report_outcome(sink, self._render(synthesizer, sink, total_samples)) + except Exception as exception: # pylint: disable=broad-exception-caught + logger.error_with_traceback(exception, f"{self.class_name}: failed to render to {sink.destination}") + sink.discard() + self._emit(ServiceError(exception=exception)) + finally: + self._running.clear() + + def _render( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> bool: + with sink: + if not self._synthesize(synthesizer, sink, total_samples): + return False + + return sink.finish(self._encode_reporter(total_samples)) + + def _synthesize( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> bool: + """Renders the song from its first row into the sink; reports whether it reached the end. + + The song is rendered as the document holds it, from the top: the position a listener left + the playhead at is a listening choice, and a render describes the whole song. + """ + progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit) + synthesizer.set_position(0, 0) + synthesizer.reset() + + rendered = 0 + while not synthesizer.is_finished: + if self._cancel_event.is_set(): + return False + + chunk, _ = synthesizer.render_row() + sink.write(chunk) + rendered = min(total_samples, rendered + len(chunk)) + progress.advance(rendered) + + return not self._cancel_event.is_set() + + def _encode_reporter(self, total_samples: int) -> EncodeReporter: + progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit) + return partial(self._report_encoded, progress) + + def _report_encoded(self, progress: StageProgress, encoded: int) -> bool: + progress.advance(encoded) + return not self._cancel_event.is_set() + + def _report_outcome(self, sink: RenderSink, completed: bool) -> None: + if not completed: + sink.discard() + self._emit(ServiceCancelled()) + return + + logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}") + self._emit(ServiceSuccess(value=sink.destination)) diff --git a/src/sampletones_application/services/render/sink.py b/src/sampletones_application/services/render/sink.py new file mode 100644 index 00000000..4f46dae3 --- /dev/null +++ b/src/sampletones_application/services/render/sink.py @@ -0,0 +1,194 @@ +from contextlib import ExitStack +from pathlib import Path +from types import TracebackType +from typing import Callable, Final, Optional, Protocol, Self, Type + +import numpy as np + +from sampletones_application.services.render.constants import ( + ENCODE_BLOCK_SAMPLES, + SCRATCH_SUFFIX, +) +from sampletones_application.services.render.scratch import NO_PEAK, ScratchAudio +from sampletones_core.audio.writers import AudioOutputSpec, AudioWriter, open_audio_writer +from sampletones_shared.constants.audio import UNITY_GAIN +from sampletones_shared.exceptions import AudioWriteError + +FULL_SCALE: Final[float] = 1.0 + +EncodeReporter = Callable[[int], bool] + + +class RenderSink(Protocol): + """Where a render's rows go on their way to the destination file. + + A sink is entered for the length of one render: rows arrive through ``write`` in the order + they are synthesised, and ``finish`` completes whatever the sink still owes the destination. + Leaving the sink closes what it opened and clears what was only ever temporary; ``discard`` + is how a caller that decided against the result removes the file itself. + + Attributes: + destination: The file the render is written to. + """ + + destination: Path + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: ... + + def write(self, chunk: np.ndarray) -> None: ... + + def finish(self, report: EncodeReporter, /) -> bool: ... + + def discard(self) -> None: ... + + +class DirectRenderSink: + """Writes each row to the destination as it is synthesised. + + One pass over the song, at the level the synthesiser produced: the encoder receives a row as + soon as it exists, so the file grows with the render and nothing is held between the two. + """ + + def __init__(self, destination: Path, spec: AudioOutputSpec) -> None: + self.destination = destination + self._spec = spec + self._stack = ExitStack() + self._writer: Optional[AudioWriter] = None + + def __enter__(self) -> Self: + self._writer = self._stack.enter_context(open_audio_writer(self.destination, self._spec)) + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self._writer = None + self._stack.close() + + def write(self, chunk: np.ndarray) -> None: + """Hands one row to the encoder. + + Args: + chunk: The row's samples. + + Raises: + AudioWriteError: If the sink has not been entered. + """ + if self._writer is None: + raise AudioWriteError(f"No file open at '{self.destination}'; write within the sink's context") + + self._writer.write(chunk) + + def finish(self, _report: EncodeReporter, /) -> bool: + """Reports the destination complete, since every row was written as it arrived.""" + return True + + def discard(self) -> None: + """Deletes the destination, so a render the caller dropped names no file.""" + self.destination.unlink(missing_ok=True) + + +class NormalizingRenderSink: + """Spills the render, then writes it at the scale that brings its peak to full. + + The loudest sample is known only once the last row is synthesised, so the rows are spilled + beside the destination as they arrive and read back in blocks against the peak they turned + out to hold. The destination is opened for the second pass alone, which is what makes the + encoder see the finished levels rather than the raw ones. + """ + + def __init__(self, destination: Path, spec: AudioOutputSpec) -> None: + self.destination = destination + self._spec = spec + self._scratch = ScratchAudio(destination.with_name(destination.name + SCRATCH_SUFFIX)) + + def __enter__(self) -> Self: + self._scratch.start() + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self._scratch.seal() + self._scratch.remove() + + def write(self, chunk: np.ndarray) -> None: + """Spills one row, keeping the peak the render has reached. + + Args: + chunk: The row's samples. + + Raises: + AudioWriteError: If the sink has not been entered. + """ + self._scratch.write(chunk) + + def finish(self, report: EncodeReporter, /) -> bool: + """Encodes the spilled render at its scale, reporting how far the pass has come. + + Args: + report: Takes the samples encoded so far and states whether to carry on. + + Returns: + bool: Whether the destination holds the whole render. + """ + self._scratch.seal() + scale = self._scale() + encoded = 0 + with open_audio_writer(self.destination, self._spec) as writer: + for block in self._scratch.blocks(ENCODE_BLOCK_SAMPLES): + writer.write(block * scale) + encoded += len(block) + if not report(encoded): + return False + + return True + + def discard(self) -> None: + """Deletes the destination, so a render the caller dropped names no file.""" + self.destination.unlink(missing_ok=True) + + def _scale(self) -> float: + """The factor bringing the spilled render's peak to full scale. + + A render that stayed silent has no peak to reach for, so it is written as it stands. + """ + if self._scratch.peak <= NO_PEAK: + return UNITY_GAIN + + return FULL_SCALE / self._scratch.peak + + +def build_render_sink( + destination: Path, + spec: AudioOutputSpec, + *, + normalize: bool, +) -> RenderSink: + """The sink a render writes through, chosen by whether its level is scaled to its peak. + + Args: + destination: The file the render is written to. + spec: The format, rate, and quality it is written at. + normalize: Whether the render is scaled so its loudest sample reaches full scale. + + Returns: + RenderSink: A sink ready to be entered. + """ + if normalize: + return NormalizingRenderSink(destination, spec) + + return DirectRenderSink(destination, spec) diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py index d8ec39f0..2cdc565c 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -12,13 +12,13 @@ STOP_JOIN_TIMEOUT, STOP_POLL_TIMEOUT, ) -from sampletones_application.services.song_player.protocol import RowSynthesizerProtocol from sampletones_application.services.song_player.result import ( SongPlaybackError, SongPlaybackStopped, SongPlayerResult, SongPositionUpdate, ) +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE from sampletones_core.project.song_position import SongPosition diff --git a/src/sampletones_application/services/song_player/protocol.py b/src/sampletones_application/services/song_player/protocol.py deleted file mode 100644 index 58a7dd0c..00000000 --- a/src/sampletones_application/services/song_player/protocol.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Protocol, Tuple - -import numpy as np - -from sampletones_core.project.song_position import SongPosition - - -class RowSynthesizerProtocol(Protocol): - """Streaming synthesis kernel the song-player service drives, one row at a time. - - This is the service's input contract; the concrete synthesiser lives in the logic layer - and satisfies it structurally. Each ``render_row`` call produces one row's worth of audio - (ticks_per_row × frame_length samples), advances the internal position cursor, and returns - a snapshot of the cursor from before the advance so callers can post accurate position events. - """ - - @property - def order_position(self) -> int: ... - - @property - def row_index(self) -> int: ... - - @property - def is_finished(self) -> bool: ... - - def set_position(self, order_position: int, row_index: int) -> None: ... - - def render_row(self) -> Tuple[np.ndarray, SongPosition]: ... - - def reset(self) -> None: ... diff --git a/src/sampletones_application/services/synthesis/__init__.py b/src/sampletones_application/services/synthesis/__init__.py new file mode 100644 index 00000000..767f0ca5 --- /dev/null +++ b/src/sampletones_application/services/synthesis/__init__.py @@ -0,0 +1,5 @@ +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol + +__all__ = [ + "RowSynthesizerProtocol", +] diff --git a/src/sampletones_application/services/synthesis/protocol.py b/src/sampletones_application/services/synthesis/protocol.py new file mode 100644 index 00000000..7b257f70 --- /dev/null +++ b/src/sampletones_application/services/synthesis/protocol.py @@ -0,0 +1,33 @@ +from typing import Protocol, Tuple + +import numpy as np + +from sampletones_core.project.song_position import SongPosition + + +class RowSynthesizerProtocol(Protocol): + """Streaming synthesis kernel a service drives, one row at a time. + + This is the input contract every consumer of a song's audio takes; the concrete synthesiser + lives in the logic layer and satisfies it structurally. Each ``render_row`` call produces one + row's worth of audio, advances the internal position cursor, and returns a snapshot of the + cursor from before the advance so callers can post accurate position events. + + The player and the renderer drive the same kernel through this one contract, which is what + makes a rendered file sound like what playback produces: the synthesis code is written once. + """ + + @property + def order_position(self) -> int: ... + + @property + def row_index(self) -> int: ... + + @property + def is_finished(self) -> bool: ... + + def set_position(self, order_position: int, row_index: int) -> None: ... + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: ... + + def reset(self) -> None: ... diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index fda067be..436ef3c6 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -11,6 +11,7 @@ normalize, quantize, resample, + silence, to_mono, ) from .validation import ( @@ -36,6 +37,7 @@ "quantize", "read_wave", "resample", + "silence", "to_mono", "validate_audio_array", "validate_buffer_size", diff --git a/src/sampletones_core/audio/processing.py b/src/sampletones_core/audio/processing.py index 99518d48..53f5bfe8 100644 --- a/src/sampletones_core/audio/processing.py +++ b/src/sampletones_core/audio/processing.py @@ -14,6 +14,19 @@ from .validation import validate_audio_array +def silence(samples: int) -> np.ndarray: + """ + Build a buffer of the given length holding no sound. + + Args: + samples: How many samples the buffer spans. + + Returns: + A float32 array of zeros, ready to be mixed into or written over. + """ + return np.zeros(samples, dtype=np.float32) + + def clip_audio(audio: np.ndarray) -> np.ndarray: """ Clip audio samples to the valid range [-1.0, 1.0]. diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py index d3d8161f..2b4dcaa0 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.logic.sequencer.playback.synthesizer import ( - _apply_modifiers, + apply_modifiers, ) from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH from sampletones_core.instructions import ( @@ -86,7 +86,7 @@ def test_volume_scaled_correctly( volume=case.instruction_volume, duty_cycle=0, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=0, row_volume=case.row_volume, @@ -107,7 +107,7 @@ def test_volume_scaled_correctly( volume=case.instruction_volume, short=False, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=0, row_volume=case.row_volume, @@ -180,7 +180,7 @@ def test_pitch_transposed_correctly( volume=15, duty_cycle=0, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=MAX_VOLUME, @@ -253,7 +253,7 @@ def test_period_transposed_correctly( volume=15, short=False, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=MAX_VOLUME, @@ -359,7 +359,7 @@ def test_modifiers_applied( case: TriangleModifiersCase, ) -> None: instruction = TriangleInstruction(on=True, pitch=case.pitch) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=case.row_volume, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 5d34066f..53e72864 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -70,7 +70,7 @@ def _state( context: SynthesizerContext, generator: GeneratorName = GeneratorName.PULSE1, ): - return context.synthesizer._channel_states[generator] + return context.synthesizer._channels.state(generator) def _render(context: SynthesizerContext) -> np.ndarray: @@ -835,7 +835,7 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) - pulse_state = synthesizer._channel_states[GeneratorName.PULSE1] + pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) controller.set_nes_frequency(60) synthesizer.render_row() diff --git a/tests/unit/sampletones_application/services/render/__init__.py b/tests/unit/sampletones_application/services/render/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py new file mode 100644 index 00000000..a67b0d58 --- /dev/null +++ b/tests/unit/sampletones_application/services/render/conftest.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import numpy as np +import soundfile + +from sampletones_core.audio.writers import AudioOutputSpec, WaveOutputSpec +from sampletones_core.project.song_position import SongPosition + +SAMPLE_RATE: Final[int] = 44100 +ROW_SAMPLES: Final[int] = 735 +ROWS: Final[int] = 24 +TOTAL_SAMPLES: Final[int] = ROW_SAMPLES * ROWS +LEVEL: Final[float] = 0.25 + + +def wave_spec(sample_rate: int = SAMPLE_RATE) -> AudioOutputSpec: + return WaveOutputSpec(sample_rate=sample_rate) + + +class FakeSynthesizer: + """A kernel that renders a fixed number of identical rows, standing in for a song. + + Each row is a constant level, so a normalising pass has a peak to find and a written file + can be checked sample by sample without modelling a generator. + """ + + def __init__( + self, + *, + rows: int = ROWS, + row_samples: int = ROW_SAMPLES, + level: float = LEVEL, + on_row: Optional[Callable[[int], None]] = None, + error: Optional[Exception] = None, + ) -> None: + self._rows = rows + self._row_samples = row_samples + self._level = level + self._on_row = on_row + self._error = error + self.rendered: int = 0 + self.resets: int = 0 + self.positions: List[Tuple[int, int]] = [] + + @property + def order_position(self) -> int: + return self.rendered + + @property + def row_index(self) -> int: + return 0 + + @property + def is_finished(self) -> bool: + return self.rendered >= self._rows + + def set_position(self, order_position: int, row_index: int) -> None: + self.positions.append((order_position, row_index)) + + def reset(self) -> None: + self.resets += 1 + self.rendered = 0 + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: + if self._error is not None and self.rendered == self._rows // 2: + raise self._error + + if self._on_row is not None: + self._on_row(self.rendered) + + self.rendered += 1 + row = np.full(self._row_samples, self._level, dtype=np.float32) + return row, SongPosition() + + +def read_samples(path: Path) -> np.ndarray: + audio, _ = soundfile.read(path, dtype="float32") + return np.asarray(audio, dtype=np.float32) diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py new file mode 100644 index 00000000..2a1ce82c --- /dev/null +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -0,0 +1,270 @@ +from pathlib import Path +from typing import List + +import numpy as np +import pytest + +from sampletones_application.services.render.constants import SCRATCH_SUFFIX +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.service import SongRenderService +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.services.render.conftest import ( + LEVEL, + TOTAL_SAMPLES, + FakeSynthesizer, + read_samples, + wave_spec, +) + + +def _render( + destination: Path, + synthesizer: FakeSynthesizer, + *, + normalize: bool = False, + total_samples: int = TOTAL_SAMPLES, +) -> List[RenderResult]: + """Runs one render to completion, returning everything it reported.""" + service = SongRenderService() + results: List[RenderResult] = [] + service.subscribe(results.append) + service.start( + synthesizer=synthesizer, + destination=destination, + spec=wave_spec(), + normalize=normalize, + total_samples=total_samples, + ) + return results + + +def _progress(results: List[RenderResult], stage: RenderStage) -> List[ServiceProgress[RenderStage]]: + return [result for result in results if isinstance(result, ServiceProgress) and result.current_item is stage] + + +class TestARenderReachesItsFile(BaseTestSuite): + """A render that runs to the end leaves the whole song at the destination.""" + + def test_the_success_names_the_destination(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + results = _render(destination, FakeSynthesizer()) + + assert results[-1] == ServiceSuccess(value=destination) + + def test_the_file_holds_every_row(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer()) + + assert len(read_samples(destination)) == TOTAL_SAMPLES + + def test_the_song_is_rendered_from_its_first_row(self, tmp_path: Path) -> None: + """A render describes the document, so where a listener left the playhead does not reach it.""" + synthesizer = FakeSynthesizer() + + _render(tmp_path / "song.wav", synthesizer) + + assert synthesizer.positions[0] == (0, 0) + assert synthesizer.resets == 1 + + def test_the_first_report_states_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + + assert results[0] == ServiceStarted(total=TOTAL_SAMPLES) + + +class TestProgressIsReported(BaseTestSuite): + """Every pass reports the samples it has covered, against the total the song holds.""" + + def test_synthesis_progress_climbs_to_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + reports = _progress(results, RenderStage.SYNTHESIS) + + assert [report.completed for report in reports] == sorted(report.completed for report in reports) + assert reports[-1].completed == TOTAL_SAMPLES + + def test_every_report_is_measured_against_the_song(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + reports = _progress(results, RenderStage.SYNTHESIS) + + assert all(report.total == TOTAL_SAMPLES for report in reports) + + def test_a_direct_render_reports_one_pass(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + + assert not _progress(results, RenderStage.ENCODING) + + def test_a_normalized_render_reports_both_passes(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True) + + assert _progress(results, RenderStage.SYNTHESIS) + assert _progress(results, RenderStage.ENCODING) + + def test_the_encoding_pass_climbs_to_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True) + reports = _progress(results, RenderStage.ENCODING) + + assert reports[-1].completed == TOTAL_SAMPLES + + +class TestNormalizing(BaseTestSuite): + """Normalising scales the whole render by what its loudest sample turned out to be.""" + + def test_the_peak_reaches_full_scale(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(), normalize=True) + + assert float(np.abs(read_samples(destination)).max()) == pytest.approx(1.0, abs=1e-4) + + def test_a_direct_render_keeps_the_level_it_was_given(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer()) + + assert float(np.abs(read_samples(destination)).max()) == pytest.approx(LEVEL, abs=1e-4) + + def test_silence_is_written_as_it_stands(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(level=0.0), normalize=True) + + assert not float(np.abs(read_samples(destination)).max()) + + def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(), normalize=True) + + assert not (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() + + +class TestCancelling(BaseTestSuite): + """A cancelled render reports itself cancelled and names no file.""" + + def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: + return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None) + + def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + service = SongRenderService() + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=destination, + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not destination.exists() + + def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None: + service = SongRenderService() + results: List[RenderResult] = [] + service.subscribe(results.append) + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert results[-1] == ServiceCancelled() + + def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=True, + total_samples=TOTAL_SAMPLES, + ) + + assert not list(tmp_path.iterdir()) + + +class TestFailing(BaseTestSuite): + """A render that raises reports the failure and takes its partial file with it.""" + + def test_the_failure_is_reported(self, tmp_path: Path) -> None: + error = RuntimeError("no sample") + + results = _render(tmp_path / "song.wav", FakeSynthesizer(error=error)) + reported = results[-1] + + assert isinstance(reported, ServiceError) + assert reported.exception is error + + def test_the_partial_file_is_removed(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(error=RuntimeError("no sample"))) + + assert not destination.exists() + + def test_a_failing_render_stops_running(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=FakeSynthesizer(error=RuntimeError("no sample")), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not service.is_running() + + +class TestOneRenderAtATime(BaseTestSuite): + """A render holds the service until it finishes, so a second request is declined.""" + + def test_a_request_arriving_mid_render_is_declined(self, tmp_path: Path) -> None: + service = SongRenderService() + declined: List[bool] = [] + + def request_again(rendered: int) -> None: + if rendered: + return + + declined.append( + service.start( + synthesizer=FakeSynthesizer(), + destination=tmp_path / "second.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + ) + + service.start( + synthesizer=FakeSynthesizer(on_row=request_again), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert declined == [False] + assert not (tmp_path / "second.wav").exists() + + def test_the_service_is_free_once_a_render_finishes(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=FakeSynthesizer(), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not service.is_running() diff --git a/tests/unit/sampletones_application/services/render/test_sink.py b/tests/unit/sampletones_application/services/render/test_sink.py new file mode 100644 index 00000000..38fe6160 --- /dev/null +++ b/tests/unit/sampletones_application/services/render/test_sink.py @@ -0,0 +1,158 @@ +from pathlib import Path +from typing import List + +import numpy as np +import pytest + +from sampletones_application.services.render.constants import SCRATCH_SUFFIX +from sampletones_application.services.render.scratch import ScratchAudio +from sampletones_application.services.render.sink import ( + DirectRenderSink, + NormalizingRenderSink, + build_render_sink, +) +from sampletones_shared.exceptions import AudioWriteError +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.services.render.conftest import ( + read_samples, + wave_spec, +) + +KEEP_GOING = True + + +def _rows(count: int, samples: int, level: float) -> List[np.ndarray]: + return [np.full(samples, level, dtype=np.float32) for _ in range(count)] + + +class TestTheSinkIsChosenByTheLevelChoice(BaseTestSuite): + def test_a_plain_render_writes_straight_out(self, tmp_path: Path) -> None: + sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=False) + + assert isinstance(sink, DirectRenderSink) + + def test_a_normalized_render_spills_first(self, tmp_path: Path) -> None: + sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=True) + + assert isinstance(sink, NormalizingRenderSink) + + +class TestTheSinkOwnsItsFile(BaseTestSuite): + def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None: + sink = DirectRenderSink(tmp_path / "song.wav", wave_spec()) + + with pytest.raises(AudioWriteError, match="write within the sink's context"): + sink.write(np.zeros(4, dtype=np.float32)) + + def test_spilling_outside_the_block_is_refused(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with pytest.raises(AudioWriteError, match="write between start and seal"): + sink.write(np.zeros(4, dtype=np.float32)) + + def test_discarding_removes_the_destination(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + sink = DirectRenderSink(destination, wave_spec()) + with sink: + sink.write(np.zeros(64, dtype=np.float32)) + + sink.discard() + + assert not destination.exists() + + def test_discarding_a_render_that_never_ran_is_harmless(self, tmp_path: Path) -> None: + sink = DirectRenderSink(tmp_path / "song.wav", wave_spec()) + + sink.discard() + + assert not list(tmp_path.iterdir()) + + +class TestNormalizingSink(BaseTestSuite): + def _write(self, sink: NormalizingRenderSink, rows: List[np.ndarray]) -> bool: + with sink: + for row in rows: + sink.write(row) + + return sink.finish(lambda _encoded: KEEP_GOING) + + def test_the_loudest_row_sets_the_scale_for_every_row(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + sink = NormalizingRenderSink(destination, wave_spec()) + + self._write(sink, [*_rows(1, 32, 0.1), *_rows(1, 32, 0.5)]) + written = read_samples(destination) + + assert float(written[:32].max()) == pytest.approx(0.2, abs=1e-4) + assert float(written[32:].max()) == pytest.approx(1.0, abs=1e-4) + + def test_a_pass_stopped_partway_reports_the_file_unfinished(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with sink: + sink.write(np.full(4, 0.5, dtype=np.float32)) + completed = sink.finish(lambda _encoded: not KEEP_GOING) + + assert not completed + + def test_the_spill_stands_beside_the_destination(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with sink: + sink.write(np.full(4, 0.5, dtype=np.float32)) + + assert (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() + + +class TestScratchAudio(BaseTestSuite): + """The spill file holds what it was given, and reports what it holds.""" + + def test_the_samples_read_back_in_the_order_they_were_written(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + written = np.arange(10, dtype=np.float32) + + scratch.start() + scratch.write(written[:4]) + scratch.write(written[4:]) + scratch.seal() + + assert np.array_equal(np.concatenate(list(scratch.blocks(3))), written) + + def test_the_blocks_are_bounded_by_the_size_asked_for(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.write(np.zeros(10, dtype=np.float32)) + scratch.seal() + + assert [len(block) for block in scratch.blocks(4)] == [4, 4, 2] + + def test_the_peak_spans_every_chunk(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.write(np.full(4, 0.2, dtype=np.float32)) + scratch.write(np.full(4, -0.7, dtype=np.float32)) + scratch.write(np.full(4, 0.3, dtype=np.float32)) + scratch.seal() + + assert scratch.peak == pytest.approx(0.7) + assert scratch.samples == 12 + + def test_sealing_twice_is_harmless(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.seal() + scratch.seal() + + assert not scratch.samples + + def test_removing_clears_the_spill(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.seal() + scratch.remove() + + assert not scratch.path.exists() From 1b2c299d3b6fbd0795278bea8c152e11c2e7b09a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:28:06 +0200 Subject: [PATCH 04/10] Added: project source seam for rendering --- .../logic/history/manager.py | 3 +- .../logic/history/snapshot.py | 19 +----- .../playback/synthesizer/synthesizer.py | 24 +++---- .../logic/sequencer/renderer.py | 31 --------- .../logic/shared/project_source.py | 55 ++++++++++++++++ .../coordinators/tabs/test_sequencer.py | 6 +- .../logic/history/test_fingerprint.py | 2 +- .../logic/history/test_snapshot.py | 27 -------- .../sequencer/playback/test_synthesizer.py | 8 ++- .../logic/shared/test_project_source.py | 65 +++++++++++++++++++ 10 files changed, 144 insertions(+), 96 deletions(-) delete mode 100644 src/sampletones_application/logic/sequencer/renderer.py create mode 100644 src/sampletones_application/logic/shared/project_source.py delete mode 100644 tests/unit/sampletones_application/logic/history/test_snapshot.py create mode 100644 tests/unit/sampletones_application/logic/shared/test_project_source.py diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index ac591141..62bb8677 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -3,6 +3,7 @@ from typing import Iterator, List, Optional, Tuple from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -11,7 +12,7 @@ from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError from .fingerprint import ReconstructionHashCache, fingerprint_project -from .snapshot import HistoryEntry, snapshot_project +from .snapshot import HistoryEntry from .transaction import CoalesceKey, PendingTransaction diff --git a/src/sampletones_application/logic/history/snapshot.py b/src/sampletones_application/logic/history/snapshot.py index d3ed3c4a..711fe0f9 100644 --- a/src/sampletones_application/logic/history/snapshot.py +++ b/src/sampletones_application/logic/history/snapshot.py @@ -1,7 +1,6 @@ -import copy from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, Optional +from typing import Optional from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_core.project import Project @@ -9,22 +8,6 @@ from .action import HistoryAction -def snapshot_project(project: Project) -> Project: - """Captures an independent copy of a project that shares reconstruction audio. - - The song, settings, metadata and sample shells are deep-copied so later edits - to the live project leave the snapshot untouched. Each sample's reconstruction - is shared by reference, so the snapshot reuses those multi-megabyte audio - arrays. Reconstruction edits are copy-on-write — each installs a fresh - reconstruction — so the shared reconstruction stays valid for the life of the - snapshot. - """ - shared_reconstructions: Dict[int, object] = { - id(sample.reconstruction): sample.reconstruction for sample in project.samples - } - return copy.deepcopy(project, shared_reconstructions) - - @dataclass(frozen=True) class HistoryEntry: """One committed state in the history stack. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 34aeb1f7..0ded782e 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import ProjectSource from sampletones_core.audio import clip_audio_inplace, silence from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName @@ -26,11 +26,13 @@ class RowSynthesizer: - """Real-time synthesis engine for tracker song playback. + """Synthesis engine for tracker song audio, one row at a time. - Reads the live ``Project`` from ``project_controller`` on every ``render_row`` - call so that pattern edits, tempo changes, and sample swaps take effect - immediately while playback keeps running. + Reads the ``Project`` from ``project_source`` on every ``render_row`` call. Over the live + controller that makes pattern edits, tempo changes, and sample swaps take effect immediately + while playback keeps running; over a + :class:`~sampletones_application.logic.shared.project_source.ProjectSnapshot` it makes a whole + render describe one state of the document. A row lasts the ticks the project's groove gives its position within the pattern, so the row a pattern's tenth row plays for is the row an exported module plays it for: both index @@ -56,17 +58,17 @@ class RowSynthesizer: def __init__( self, - project_controller: ProjectController, + project_source: ProjectSource, config: Config, *, active_channels: Callable[[], FrozenSet[GeneratorName]], sample_rate: Callable[[], int], ) -> None: - self._project_controller = project_controller + self._project_source = project_source self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() - self._timing: SongTiming = SongTiming.from_project(project_controller.project) + self._timing: SongTiming = SongTiming.from_project(project_source.project) self._groove: Groove = self._timing.groove() self._channels = ChannelBank(config, self._current_rates()) self._elapsed_ticks: int = 0 @@ -81,7 +83,7 @@ def row_index(self) -> int: @property def is_finished(self) -> bool: - project = self._project_controller.project + project = self._project_source.project return self._position.order_position >= project.song.order_length() def set_position(self, order_position: int, row_index: int) -> None: @@ -93,7 +95,7 @@ def reset(self) -> None: self._channels.reset() def render_row(self) -> Tuple[np.ndarray, SongPosition]: - project = self._project_controller.project + project = self._project_source.project song = project.song self._position.wrap_overflow(song.rows_per_pattern) self._channels.follow(self._current_rates()) @@ -125,7 +127,7 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: def _current_rates(self) -> EngineRates: return EngineRates.from_project( - self._project_controller.project, + self._project_source.project, self._sample_rate(), ) diff --git a/src/sampletones_application/logic/sequencer/renderer.py b/src/sampletones_application/logic/sequencer/renderer.py deleted file mode 100644 index 7c8bf44d..00000000 --- a/src/sampletones_application/logic/sequencer/renderer.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Protocol - -import numpy as np - -from sampletones_core.project import Project - - -class SongRenderer(Protocol): - """Renders a project's song into a single playable mono waveform. - - This is the seam between the sequencer and audio output. An implementation - walks each channel's ``order`` → ``patterns`` → ``rows``, feeds every active - row's referenced sample reconstruction instructions (shifted by the row - ``transpose`` and scaled by ``volume``) through the matching - :class:`~sampletones_core.generators.generator.Generator`, advancing one - tracker row every ``speed`` engine ticks at the project ``tempo`` / - ``nes_frequency``, then mixes the four channels into one buffer. - """ - - def render(self, project: Project) -> np.ndarray: ... - - -class UnimplementedSongRenderer: - """Placeholder renderer until the synthesis engine lands. - - Kept as a concrete type so the sequencer can hold a renderer reference and - fail loudly if play is wired before the engine exists. - """ - - def render(self, project: Project) -> np.ndarray: - raise NotImplementedError("Song rendering is not implemented yet; see SongRenderer.") diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py new file mode 100644 index 00000000..20854674 --- /dev/null +++ b/src/sampletones_application/logic/shared/project_source.py @@ -0,0 +1,55 @@ +import copy +from dataclasses import dataclass +from typing import Dict, Protocol, Self + +from sampletones_core.project import Project + + +def snapshot_project(project: Project) -> Project: + """Captures an independent copy of a project that shares reconstruction audio. + + The song, settings, metadata and sample shells are deep-copied so later edits + to the live project leave the snapshot untouched. Each sample's reconstruction + is shared by reference, so the snapshot reuses those multi-megabyte audio + arrays. Reconstruction edits are copy-on-write — each installs a fresh + reconstruction — so the shared reconstruction stays valid for the life of the + snapshot. + """ + shared_reconstructions: Dict[int, object] = { + id(sample.reconstruction): sample.reconstruction for sample in project.samples + } + return copy.deepcopy(project, shared_reconstructions) + + +class ProjectSource(Protocol): + """Where a reader of the open document finds the project it works on. + + A reader of the song needs the project and nothing else about where it came from. + :class:`~sampletones_application.logic.project.controller.ProjectController` satisfies this, so + playback follows every edit as it is made; :class:`ProjectSnapshot` satisfies it too, so a long + operation describes the document as it stood when it was asked for. Depending on this protocol + is what lets one synthesis kernel serve both. + """ + + @property + def project(self) -> Project: ... + + +@dataclass(frozen=True) +class ProjectSnapshot: + """One project held still, the document a long operation reads. + + A render walks the whole song on a worker thread while the user keeps editing. Reading a + snapshot makes the result describe one state of the document: the state it was requested in, + from the first row to the last. + + Attributes: + project: The document as it stood when the snapshot was taken. + """ + + project: Project + + @classmethod + def capture(cls, source: ProjectSource) -> Self: + """Takes the document ``source`` currently holds, copied through :func:`snapshot_project`.""" + return cls(project=snapshot_project(source.project)) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 464f0aaf..cda08806 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -12,16 +12,14 @@ from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager -from sampletones_application.logic.history.snapshot import ( - HistoryEntry, - snapshot_project, -) +from sampletones_application.logic.history.snapshot import HistoryEntry from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.channels import ( ALL_CHANNELS, SequencerChannelsLogic, ) +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index d5f054fe..a1f08ffd 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -8,8 +8,8 @@ ReconstructionHashCache, fingerprint_project, ) -from sampletones_application.logic.history.snapshot import snapshot_project from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_core.reconstructions import Reconstruction from sampletones_shared.utils.serialization import hash_model from tests.conftest import ReconstructionFactory diff --git a/tests/unit/sampletones_application/logic/history/test_snapshot.py b/tests/unit/sampletones_application/logic/history/test_snapshot.py deleted file mode 100644 index 4e15bcb7..00000000 --- a/tests/unit/sampletones_application/logic/history/test_snapshot.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Callable - -from sampletones_application.logic.history.snapshot import snapshot_project -from sampletones_application.logic.project.controller import ProjectController -from sampletones_core.reconstructions import Reconstruction - - -class TestSnapshotIndependence: - def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None: - project_controller.set_tempo(120) - - snapshot = snapshot_project(project_controller.project) - project_controller.set_tempo(200) - - assert snapshot.settings.tempo == 120 - assert snapshot.song is not project_controller.project.song - - def test_reconstruction_audio_is_shared( - self, - project_controller: ProjectController, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = project_controller.add_sample(reconstruction_factory(), name="lead") - - snapshot = snapshot_project(project_controller.project) - - assert snapshot.samples[sample.id].reconstruction is sample.reconstruction diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 53e72864..71a2e6a7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -47,6 +47,7 @@ def __call__(self) -> FrozenSet[GeneratorName]: @dataclass class SynthesizerContext: synthesizer: RowSynthesizer + controller: ProjectController mask: MaskProvider chunks: List[np.ndarray] = field(default_factory=list) tick_snapshots: Dict[str, int] = field(default_factory=dict) @@ -58,12 +59,13 @@ def _make_context() -> SynthesizerContext: mask = MaskProvider() return SynthesizerContext( synthesizer=make_synthesizer(controller, Config(), active_channels=mask), + controller=controller, mask=mask, ) -def _controller(context: SynthesizerContext): - return context.synthesizer._project_controller +def _controller(context: SynthesizerContext) -> ProjectController: + return context.controller def _state( @@ -95,7 +97,7 @@ def _row_ticks( rows: int, ) -> Tuple[int, ...]: """The ticks ``rows`` consecutive rendered rows last, read back from the audio they produced.""" - settings = synthesizer._project_controller.project.settings + settings = synthesizer._project_source.project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) return tuple(len(synthesizer.render_row()[0]) // frame_length for _ in range(rows)) diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py new file mode 100644 index 00000000..4d8b081c --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py @@ -0,0 +1,65 @@ +from typing import Callable + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.shared.project_source import ( + ProjectSnapshot, + ProjectSource, + snapshot_project, +) +from sampletones_core.reconstructions import Reconstruction +from tests.suite.base import BaseTestSuite + + +@pytest.fixture +def project_controller() -> ProjectController: + return ProjectController(ProjectManager()) + + +class TestSnapshotIndependence(BaseTestSuite): + def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None: + project_controller.set_tempo(120) + + snapshot = snapshot_project(project_controller.project) + project_controller.set_tempo(200) + + assert snapshot.settings.tempo == 120 + assert snapshot.song is not project_controller.project.song + + def test_reconstruction_audio_is_shared( + self, + project_controller: ProjectController, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = project_controller.add_sample(reconstruction_factory(), name="lead") + + snapshot = snapshot_project(project_controller.project) + + assert snapshot.samples[sample.id].reconstruction is sample.reconstruction + + +class TestASnapshotIsASource(BaseTestSuite): + """A captured document reads as the source a synthesiser takes.""" + + def test_the_live_controller_is_a_source(self, project_controller: ProjectController) -> None: + source: ProjectSource = project_controller + + assert source.project is project_controller.project + + def test_a_snapshot_is_a_source(self, project_controller: ProjectController) -> None: + source: ProjectSource = ProjectSnapshot.capture(project_controller) + + assert source.project.settings.tempo == project_controller.project.settings.tempo + + def test_the_document_stands_still_while_the_project_moves_on( + self, + project_controller: ProjectController, + ) -> None: + project_controller.set_tempo(120) + + snapshot = ProjectSnapshot.capture(project_controller) + project_controller.set_tempo(200) + + assert snapshot.project.settings.tempo == 120 From 4d2482a3656d146f088b483b8543a3aed08ce3e6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:50:02 +0200 Subject: [PATCH 05/10] Added: render logic and view model for song rendering --- .../categories/hierarchy.py | 1 + .../logic/project/controller.py | 5 + .../logic/render/__init__.py | 7 + .../logic/render/logic.py | 308 ++++++++++++++ .../logic/render/protocol.py | 34 ++ .../playback/synthesizer/__init__.py | 2 + .../sequencer/playback/synthesizer/length.py | 43 ++ .../view_model/shared/display_settings.py | 12 +- .../view_model/shared/nearest.py | 24 ++ .../view_model/shared/render.py | 264 ++++++++++++ src/sampletones_config/lang/en.yaml | 6 + src/sampletones_shared/utils/system/paths.py | 24 ++ .../logic/render/__init__.py | 0 .../logic/render/test_logic.py | 378 ++++++++++++++++++ .../sequencer/playback/test_song_length.py | 68 ++++ .../view_model/shared/test_nearest.py | 18 + .../view_model/shared/test_render.py | 158 ++++++++ .../utils/system/test_paths.py | 62 +++ 18 files changed, 1404 insertions(+), 10 deletions(-) create mode 100644 src/sampletones_application/logic/render/__init__.py create mode 100644 src/sampletones_application/logic/render/logic.py create mode 100644 src/sampletones_application/logic/render/protocol.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/length.py create mode 100644 src/sampletones_application/view_model/shared/nearest.py create mode 100644 src/sampletones_application/view_model/shared/render.py create mode 100644 tests/unit/sampletones_application/logic/render/__init__.py create mode 100644 tests/unit/sampletones_application/logic/render/test_logic.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_nearest.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_render.py diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 50d32efc..0a5fbfa4 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -95,3 +95,4 @@ class Panel(StrEnum): DISPLAY = auto() KEYBINDINGS = auto() PROPERTIES = auto() + RENDER = auto() diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index fc5d9c95..1c5d1352 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -53,6 +53,11 @@ def order_length(self) -> int: def is_open(self) -> bool: return self._project_manager.is_open + @property + def name(self) -> str: + """The name the open project is known by, which a project saved to a file takes from it.""" + return self._project_manager.name + @property def has_samples(self) -> bool: return bool(self.project.samples) diff --git a/src/sampletones_application/logic/render/__init__.py b/src/sampletones_application/logic/render/__init__.py new file mode 100644 index 00000000..d44f30fd --- /dev/null +++ b/src/sampletones_application/logic/render/__init__.py @@ -0,0 +1,7 @@ +from .logic import SongRenderLogic +from .protocol import SongRenderServiceProtocol + +__all__ = [ + "SongRenderLogic", + "SongRenderServiceProtocol", +] diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py new file mode 100644 index 00000000..0cc91e35 --- /dev/null +++ b/src/sampletones_application/logic/render/logic.py @@ -0,0 +1,308 @@ +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.channels import ALL_CHANNELS +from sampletones_application.logic.sequencer.playback.synthesizer import ( + RowSynthesizer, + SongLength, +) +from sampletones_application.logic.shared.project_source import ProjectSnapshot +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.view_model.shared.render import ( + ACTIVE_PHASES, + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import ( + DEFAULT_AUDIO_FORMAT, + AudioFormat, + available_audio_formats, + available_depths, +) +from sampletones_core.parallelization import ETAEstimator +from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME +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 get_filename, replace_suffix + +from .protocol import SongRenderServiceProtocol + + +class SongRenderLogic(CallbackMixin): + """Owns writing the open song to an audio file: what is written, where, and how far it has got. + + The song is rendered through the engine that plays it, over the document as it stood when the + render was asked for, so a file describes one state of the project however the editing goes + on. The rate is whatever the chosen format is written at, which the engine follows, so the + tempo the groove states is the tempo the file holds. + + A render is an exclusive operation, held from the moment the dialog opens until it closes, so + the phase alone reports whether the application is busy with one. + """ + + def __init__( + self, + project_controller: ProjectController, + config_manager: ConfigManager, + session_manager: SessionManager, + render_service: SongRenderServiceProtocol, + *, + language_manager: LanguageManager, + is_operation_active: Callable[[], bool], + ) -> None: + self._project_controller = project_controller + self._config_manager = config_manager + self._session_manager = session_manager + self._service = render_service + self._is_operation_active = is_operation_active + self._msg_cancelling = language_manager["settings.render.message.status_cancelling"] + self._msg_cancelled = language_manager["settings.render.message.status_cancelled"] + self._msg_completed = language_manager["settings.render.message.status_completed"] + self._msg_failed = language_manager["settings.render.message.status_failed"] + self._eta_template = language_manager["global.dialog.template.time_estimation"] + self._stage_messages: Dict[RenderStage, str] = { + RenderStage.SYNTHESIS: language_manager["settings.render.message.status_synthesis"], + RenderStage.ENCODING: language_manager["settings.render.message.status_encoding"], + } + + self._formats: Tuple[AudioFormat, ...] = available_audio_formats() + self._settings = SongRenderSettings.initial(self._offered_format()) + self._phase: RenderPhase = RenderPhase.IDLE + self._destination: Optional[Path] = None + self._status_text: str = "" + self._progress: float = 0.0 + + self._service.subscribe(self._on_service_result) + + self.on_view_changed: Optional[Callable[[SongRenderViewModel], None]] = None + self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None + self.on_success: Optional[PathCallback] = None + self.on_error: Optional[Callable[[Exception], None]] = None + self.on_cancelled: Optional[VoidCallback] = None + + @property + def is_active(self) -> bool: + """A render occupies the application from the dialog opening until it closes.""" + return self._phase in ACTIVE_PHASES + + def open(self) -> bool: + """Offers the render settings for the open song, reporting whether the dialog took over. + + The destination is proposed afresh each time, so it carries the name the project is known + by into the directory audio was last written to. This is where the exclusivity is claimed, + which is why the busy authority is asked here and nowhere else along the way. + """ + if self._is_operation_active(): + logger.warning("An exclusive operation is already in progress; the render was not offered") + return False + + self._phase = RenderPhase.CONFIGURING + self._destination = self._proposed_destination() + self._status_text = "" + self._progress = 0.0 + self._emit_view() + return True + + def close(self) -> None: + """Returns to idle once the dialog is done with, releasing the application.""" + self._phase = RenderPhase.IDLE + self._progress = 0.0 + + def apply(self, settings: SongRenderSettings) -> None: + """Takes the choices the dialog stands at, renaming the destination after the format. + + Args: + settings: The reconciled choices the dialog reports. + """ + if self._phase != RenderPhase.CONFIGURING: + return + + previous = self._settings.spec.extension + self._settings = settings + extension = settings.spec.extension + if extension != previous: + self._destination = replace_suffix(self._require_destination(), previous, extension) + + self._emit_view() + + def request_destination(self) -> None: + """Asks for the file the render writes, starting from the one standing.""" + self.call( + self.on_choose_destination, + self._require_destination(), + self._settings.spec.audio_format, + ) + + def set_destination(self, destination: Path) -> None: + """Writes the render to ``destination``, remembering its directory for the next one.""" + self._destination = destination + self._session_manager.set_audio_path(destination) + self._emit_view() + + def start(self) -> None: + """Renders the song to the chosen file, from its first row to its last. + + The kernel is built here, over a snapshot of the document and at the rate the chosen + format is written at, so the worker reads a project that stands still while the editing + carries on. + """ + if self._phase != RenderPhase.CONFIGURING: + return + + length = self._length() + if length.samples <= 0: + logger.warning("The song holds no rows to render") + return + + started = self._service.start( + synthesizer=self._build_synthesizer(), + destination=self._require_destination(), + spec=self._settings.spec, + normalize=self._settings.normalize, + total_samples=length.samples, + ) + if not started: + return + + self._phase = RenderPhase.RENDERING + self._status_text = self._stage_messages[RenderStage.SYNTHESIS] + self._progress = 0.0 + self._emit_view() + + def cancel(self) -> None: + """Asks a running render to stop at its next row or block.""" + if not self._service.is_running(): + return + + self._phase = RenderPhase.CANCELLING + self._status_text = self._msg_cancelling + self._emit_view() + self._service.cancel() + + def cleanup(self) -> None: + """Winds a running render down for application exit.""" + self._service.shutdown() + + def _on_service_result(self, result: RenderResult) -> None: + match result: + case ServiceStarted(): + self._report(self._stage_messages[RenderStage.SYNTHESIS], 0.0) + case ServiceProgress() as progress: + self._handle_progress(progress) + case ServiceSuccess(value=destination): + self._on_render_complete(destination) + case ServiceError(exception=exception): + self._on_render_error(exception) + case ServiceCancelled(): + self._on_cancellation_complete() + + def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None: + """Puts a pass's report on the bar, holding the message a stop was asked under.""" + if self._phase == RenderPhase.CANCELLING: + return + + self._phase = RenderPhase.RENDERING + stage = progress.current_item + status_text = self._status_text if stage is None else self._stage_status(stage, progress.eta_seconds) + self._report(status_text, progress.completed / max(progress.total, 1)) + + def _stage_status(self, stage: RenderStage, eta_seconds: Optional[float]) -> str: + """What the pass is doing, and how long it has left where an estimate stands.""" + status_text = self._stage_messages[stage] + eta_string = ETAEstimator.format_duration(eta_seconds) + if eta_string: + status_text += self._eta_template.format(eta_string=eta_string) + + return status_text + + def _on_render_complete(self, destination: Path) -> None: + self._phase = RenderPhase.COMPLETED + self._report(self._msg_completed, 1.0) + self.call(self.on_success, destination) + + def _on_render_error(self, exception: Exception) -> None: + self._phase = RenderPhase.FAILED + self._report(self._msg_failed, 0.0) + self.call(self.on_error, exception) + + def _on_cancellation_complete(self) -> None: + self._phase = RenderPhase.CANCELLED + self._report(self._msg_cancelled, 0.0) + self.call(self.on_cancelled) + + def _report(self, status_text: str, progress: float) -> None: + self._status_text = status_text + self._progress = progress + self._emit_view() + + def _build_synthesizer(self) -> RowSynthesizer: + """The kernel a render runs on: the engine that plays the song, over a held document. + + Every channel sounds and the level stays at unity, since muting and the master gain are + choices a listener makes about what reaches the speakers, while a render describes the + document. + """ + sample_rate = self._settings.spec.sample_rate + return RowSynthesizer( + ProjectSnapshot.capture(self._project_controller), + self._config_manager.config.with_library(sample_rate=sample_rate), + active_channels=lambda: ALL_CHANNELS, + sample_rate=lambda: sample_rate, + ) + + def _length(self) -> SongLength: + return SongLength.measure( + self._project_controller.project, + sample_rate=self._settings.spec.sample_rate, + ) + + def _offered_format(self) -> AudioFormat: + """The container a dialog opens on: the usual one, or the first this installation writes.""" + if DEFAULT_AUDIO_FORMAT in self._formats: + return DEFAULT_AUDIO_FORMAT + + return next(iter(self._formats), DEFAULT_AUDIO_FORMAT) + + def _proposed_destination(self) -> Path: + """The file the dialog opens on: the project's name, where audio was last written.""" + name = self._project_controller.name or DEFAULT_EXPORT_NAME + return self._session_manager.get_audio_path() / get_filename(name, self._settings.spec.extension) + + def _require_destination(self) -> Path: + """The file the open dialog writes to. + + Raises: + SystemError: when a render is driven while its dialog is closed. + """ + if self._destination is None: + raise SystemError("A render is set up only while its dialog is open") + + return self._destination + + def _emit_view(self) -> None: + self.call(self.on_view_changed, self._build_view()) + + def _build_view(self) -> SongRenderViewModel: + return SongRenderViewModel( + phase=self._phase, + formats=self._formats, + depths=available_depths(self._settings.spec.audio_format), + settings=self._settings, + destination=self._require_destination(), + total_samples=self._length().samples, + status_text=self._status_text, + progress=self._progress, + ) diff --git a/src/sampletones_application/logic/render/protocol.py b/src/sampletones_application/logic/render/protocol.py new file mode 100644 index 00000000..03461dbd --- /dev/null +++ b/src/sampletones_application/logic/render/protocol.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import Callable, Protocol + +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_application.services.render.result import RenderResult +from sampletones_core.audio.writers import AudioOutputSpec + + +class SongRenderServiceProtocol(Protocol): + """The slice of the render service the render logic drives. + + Typing the collaborator structurally keeps the logic layer bound to the service's result + contract alone; the composition root supplies the real service. The kernel named here is the + one the logic builds, which the service takes through the wider contract every consumer of a + song's audio is written against. + """ + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: ... + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: ... + + def cancel(self) -> None: ... + + def is_running(self) -> bool: ... + + def shutdown(self) -> None: ... diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py index 36dfc253..40b55f8c 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -1,5 +1,6 @@ from .bank import ChannelBank from .frames import RowFrames +from .length import SongLength from .modifiers import apply_modifiers from .rates import EngineRates from .state import ChannelState @@ -12,6 +13,7 @@ "EngineRates", "RowFrames", "RowSynthesizer", + "SongLength", "SongTiming", "apply_modifiers", ] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py new file mode 100644 index 00000000..718e9ff5 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_core.project import Project + +from .rates import EngineRates +from .timing import SongTiming + + +@dataclass(frozen=True) +class SongLength: + """How long a song runs, in the units the audio it produces is measured in. + + Every row lasts the ticks the project's groove gives it, so a song's length is a whole + number of engine ticks before a sample is rendered. The rates the audio is produced at + turn those ticks into samples, which is the total a progress bar crosses and the duration + a dialog projects. + + Attributes: + ticks: The engine ticks the whole order lasts. + rates: The engine and audio rates those ticks are rendered at. + """ + + ticks: int + rates: EngineRates + + @classmethod + def measure(cls, project: Project, *, sample_rate: int) -> Self: + """The length ``project`` runs to when rendered at ``sample_rate``. + + Every pattern holds the song's row count, so one groove covers the whole order and the + tick total is that groove's, once for each position the order plays. + """ + groove = SongTiming.from_project(project).groove() + return cls( + ticks=project.song.order_length() * groove.total_ticks, + rates=EngineRates.from_project(project, sample_rate), + ) + + @property + def samples(self) -> int: + """The samples the whole song holds at the rate it is rendered at.""" + return self.rates.clock().samples_at(self.ticks) diff --git a/src/sampletones_application/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py index 74c7d74b..804c2d00 100644 --- a/src/sampletones_application/view_model/shared/display_settings.py +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -4,6 +4,7 @@ from pydantic import BaseModel +from sampletones_application.view_model.shared.nearest import nearest_offered from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution @@ -60,19 +61,10 @@ def frame_rate_labels(frame_rates: Tuple[int, ...], *, unlimited_label: str) -> def nearest_frame_rate(max_fps: int, frame_rates: Tuple[int, ...]) -> int: """The offered frame rate a stored preference selects, the closest one it lies between. - A preference outlives the list that was offered when it was written, so a stored value the - build has since dropped still selects an entry the combo shows. - Raises: ValueError: when no frame rate is offered. """ - if not frame_rates: - raise ValueError("Selecting a frame rate requires at least one offered rate") - - if max_fps in frame_rates: - return max_fps - - return min(frame_rates, key=lambda frame_rate: (abs(frame_rate - max_fps), frame_rate)) + return nearest_offered(max_fps, frame_rates) def nearest_resolution( diff --git a/src/sampletones_application/view_model/shared/nearest.py b/src/sampletones_application/view_model/shared/nearest.py new file mode 100644 index 00000000..485020cc --- /dev/null +++ b/src/sampletones_application/view_model/shared/nearest.py @@ -0,0 +1,24 @@ +from typing import Tuple + + +def nearest_offered(value: int, offered: Tuple[int, ...]) -> int: + """The offered number a standing choice selects: the closest one, the smaller where two tie. + + A choice outlives the list that was offered when it was made — a frame rate a build has + since dropped, a sample rate the newly chosen format encodes nothing near — so snapping it + onto the offer keeps a combo showing a value that is in force. + + Args: + value: The number a choice stands at. + offered: The numbers on offer. + + Returns: + int: The offered number the choice selects. + + Raises: + ValueError: when nothing is offered. + """ + if not offered: + raise ValueError("Selecting a value requires at least one offered number") + + return min(offered, key=lambda candidate: (abs(candidate - value), candidate)) diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py new file mode 100644 index 00000000..26f20daf --- /dev/null +++ b/src/sampletones_application/view_model/shared/render.py @@ -0,0 +1,264 @@ +from enum import StrEnum +from pathlib import Path +from typing import Final, FrozenSet, Optional, Self, Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nearest import nearest_offered +from sampletones_application.view_model.shared.percent import format_percent +from sampletones_core.audio.writers import ( + DEFAULT_AUDIO_DEPTH, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + AudioOutputSpec, + Mp3OutputSpec, + WaveOutputSpec, + capability_of, + default_mp3_bitrate, + mp3_bitrates, +) +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE +from sampletones_core.parallelization import ETAEstimator + + +class RenderPhase(StrEnum): + IDLE = "idle" + CONFIGURING = "configuring" + RENDERING = "rendering" + CANCELLING = "cancelling" + COMPLETED = "completed" + CANCELLED = "cancelled" + FAILED = "failed" + + +ACTIVE_PHASES: Final[FrozenSet[RenderPhase]] = frozenset( + { + RenderPhase.CONFIGURING, + RenderPhase.RENDERING, + RenderPhase.CANCELLING, + } +) + + +def build_spec( + audio_format: AudioFormat, + sample_rate: int, + *, + depth: Optional[AudioDepth], + bitrate: Optional[int], +) -> AudioOutputSpec: + """The specification a set of standing choices states for ``audio_format``. + + Each choice is snapped onto what the container accepts: the rate becomes the offered one + nearest it, a depth the format stores is kept, and a bitrate stays where the rate's own + ladder reaches it. A choice the format leaves behind falls back to what it opens on, so + moving between containers always arrives at a specification the encoder writes. + + Args: + audio_format: The container the audio is written into. + sample_rate: The rate the choices stand at. + depth: The form each stored sample takes, where one was chosen. + bitrate: The kilobits each encoded second holds, where one was chosen. + + Returns: + AudioOutputSpec: The specification for that format. + """ + match audio_format: + case AudioFormat.WAVE: + capability = capability_of(AudioFormat.WAVE) + return WaveOutputSpec( + sample_rate=nearest_offered(sample_rate, capability.sample_rates), + depth=depth if depth is not None and capability.supports_depth(depth) else DEFAULT_AUDIO_DEPTH, + ) + case AudioFormat.MP3: + rate = nearest_offered(sample_rate, MP3_SAMPLE_RATES) + return Mp3OutputSpec( + sample_rate=rate, + bitrate=bitrate if bitrate in mp3_bitrates(rate) else default_mp3_bitrate(rate), + ) + + +class SongRenderSettings(BaseModel, frozen=True): + """The choices a render is made under: what the file is written as, and at what level. + + Each ``with_`` method answers with the settings carrying one choice changed and the others + reconciled against what that choice leaves possible, so every value held here is one the + encoder accepts. The reconciliation runs in one place because the offers depend on each + other: a container encodes its own set of rates, and each rate offers the bitrates its MPEG + version defines. + """ + + spec: AudioOutputSpec + normalize: bool + + @classmethod + def initial(cls, audio_format: AudioFormat) -> Self: + """The choices a dialog opens on: ``audio_format`` at the usual rate, rendered at unity.""" + return cls( + spec=build_spec( + audio_format, + DEFAULT_SAMPLE_RATE, + depth=DEFAULT_AUDIO_DEPTH, + bitrate=None, + ), + normalize=False, + ) + + @property + def depth(self) -> Optional[AudioDepth]: + """The form each stored sample takes, where the format stores samples directly.""" + match self.spec: + case WaveOutputSpec() as wave: + return wave.depth + case Mp3OutputSpec(): + return None + + @property + def bitrate(self) -> Optional[int]: + """The kilobits each encoded second holds, where the format encodes to a bitrate.""" + match self.spec: + case WaveOutputSpec(): + return None + case Mp3OutputSpec() as mp3: + return mp3.bitrate + + def with_format(self, audio_format: AudioFormat) -> Self: + """The settings written as ``audio_format``, at the nearest rate it encodes.""" + return self._with_spec( + build_spec( + audio_format, + self.spec.sample_rate, + depth=self.depth, + bitrate=self.bitrate, + ) + ) + + def with_sample_rate(self, sample_rate: int) -> Self: + """The settings written at ``sample_rate``, keeping the quality it reaches there.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + sample_rate, + depth=self.depth, + bitrate=self.bitrate, + ) + ) + + def with_depth(self, depth: AudioDepth) -> Self: + """The settings storing each sample as ``depth``.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + self.spec.sample_rate, + depth=depth, + bitrate=self.bitrate, + ) + ) + + def with_bitrate(self, bitrate: int) -> Self: + """The settings encoding each second to ``bitrate`` kilobits.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + self.spec.sample_rate, + depth=self.depth, + bitrate=bitrate, + ) + ) + + def with_normalize(self, normalize: bool) -> Self: + """The settings scaled so the loudest sample reaches full scale, or left at unity.""" + return self.model_copy(update={"normalize": normalize}) + + def _with_spec(self, spec: AudioOutputSpec) -> Self: + return self.model_copy(update={"spec": spec}) + + +class SongRenderViewModel(BaseModel, frozen=True): + """What the render dialog draws: the options this installation offers, the choices standing, + and how far a running render has got. + + The setup and the progress are two faces of one dialog, so the phase decides which is shown + and the derived flags are read rather than stored. The offers narrow with the choices — the + rates a container encodes, the bitrates a rate reaches — so a combo repopulates from here as + soon as the choice above it changes. + + Attributes: + phase: Where the render stands, from the dialog opening to the outcome it reports. + formats: The containers this installation writes, in the order they are offered. + depths: The forms this installation stores the chosen container's samples in. + settings: The choices the dialog is standing at. + destination: The file a render writes. + total_samples: The samples the whole song holds at the chosen rate. + status_text: What the running pass is doing, and how long it has left. + progress: How far the running pass has got, from 0 to 1. + """ + + phase: RenderPhase + formats: Tuple[AudioFormat, ...] + depths: Tuple[AudioDepth, ...] + settings: SongRenderSettings + destination: Path + total_samples: int + status_text: str + progress: float + + @property + def spec(self) -> AudioOutputSpec: + return self.settings.spec + + @property + def sample_rates(self) -> Tuple[int, ...]: + """The rates the chosen container encodes, lowest first.""" + return self.spec.capability.sample_rates + + @property + def bitrates(self) -> Tuple[int, ...]: + """The bitrates the chosen rate encodes at, for a container that offers a bitrate.""" + if self.spec.capability.stores_samples: + return () + + return mp3_bitrates(self.spec.sample_rate) + + @property + def stores_samples(self) -> bool: + """Whether the chosen container stores samples, which is what gives it a depth to choose.""" + return self.spec.capability.stores_samples + + @property + def duration_seconds(self) -> float: + """How long the song plays for, in seconds.""" + return self.total_samples / self.spec.sample_rate + + @property + def duration_label(self) -> str: + """The length the render is projected to run to, as the dialog states it.""" + return ETAEstimator.format_duration(self.duration_seconds) + + @property + def progress_overlay(self) -> str: + """The percentage label rendered over the progress bar, derived from the fraction.""" + return format_percent(self.progress) + + @property + def is_active(self) -> bool: + return self.phase in ACTIVE_PHASES + + @property + def setup_visible(self) -> bool: + return self.phase == RenderPhase.CONFIGURING + + @property + def progress_visible(self) -> bool: + return self.phase != RenderPhase.CONFIGURING + + @property + def render_enabled(self) -> bool: + """Whether a render starts from here: a song with something to write, still being set up.""" + return self.phase == RenderPhase.CONFIGURING and self.total_samples > 0 + + @property + def cancel_enabled(self) -> bool: + """Whether a running render still takes a stop, which one already stopping has taken.""" + return self.phase == RenderPhase.RENDERING diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9736afe2..a27a59e7 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -661,6 +661,12 @@ settings.display.label.keep_editing_button: "Keep editing" settings.display.message.countdown: "Keep the window the way it looks now? It goes back to how it was when the count reaches zero." settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" +settings.render.message.status_synthesis: "Rendering the song..." +settings.render.message.status_encoding: "Writing the file..." +settings.render.message.status_cancelling: "Stopping the render..." +settings.render.message.status_cancelled: "Render cancelled." +settings.render.message.status_completed: "Render complete." +settings.render.message.status_failed: "Render failed." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index 30f19867..3b3ce37f 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -93,6 +93,30 @@ def ensure_suffix(path: Path, suffix: str) -> Path: return path.with_name(f"{path.name}{normalized_suffix}") +def replace_suffix(path: Path, previous: str, suffix: str) -> Path: + """ + Returns the path carrying ``suffix`` where its name ends with ``previous``. + + A destination follows the format written to it, so choosing another format renames the + file the destination points at. The ending is compared case-insensitively, and a name + ending in anything else keeps every part of itself and takes the new suffix on the end, + the way :func:`ensure_suffix` leaves incidental dots intact (``my.mix`` becomes + ``my.mix.mp3``). + + Args: + path (Path): The path whose extension follows a change of format. + previous (str): The extension the name is expected to end with, leading dot included. + suffix (str): The extension the path takes, with or without a leading dot. + + Returns: + Path: The path ending with the given suffix. + """ + if previous and path.name.lower().endswith(previous.lower()): + return path.with_name(get_filename(path.name[: -len(previous)], suffix)) + + return ensure_suffix(path, suffix) + + def shorten_path(path: GeneralPathlike, levels: int = SHORTEN_PATH_LEVELS) -> str: """ Shortens a file path for display by keeping the root, first directory, and last few parts. diff --git a/tests/unit/sampletones_application/logic/render/__init__.py b/tests/unit/sampletones_application/logic/render/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py new file mode 100644 index 00000000..b656454c --- /dev/null +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -0,0 +1,378 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.logic.sequencer.playback.synthesizer import ( + RowSynthesizer, + SongLength, +) +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceSuccess, +) +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioFormat, AudioOutputSpec +from sampletones_core.configs import Config +from tests.suite.language import FakeLanguageManager + +AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") +PROJECT_NAME: Final[str] = "chiptune" +LOW_RATE: Final[int] = 8000 + + +@dataclass(frozen=True) +class RenderRequest: + synthesizer: RowSynthesizer + destination: Path + spec: AudioOutputSpec + normalize: bool + total_samples: int + + +class FakeRenderService: + """The render service as the logic drives it, holding what it was asked to render. + + Results are delivered through the handler the logic subscribes, so a test walks a render the + way the worker reports one. + """ + + def __init__(self, *, accepts: bool = True) -> None: + self.accepts = accepts + self.requests: List[RenderRequest] = [] + self.cancels: int = 0 + self.shutdowns: int = 0 + self.running: bool = False + self._handler: Optional[Callable[[RenderResult], None]] = None + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: + self._handler = handler + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + if not self.accepts: + return False + + self.requests.append( + RenderRequest( + synthesizer=synthesizer, + destination=destination, + spec=spec, + normalize=normalize, + total_samples=total_samples, + ) + ) + self.running = True + return True + + def cancel(self) -> None: + self.cancels += 1 + + def is_running(self) -> bool: + return self.running + + def shutdown(self) -> None: + self.shutdowns += 1 + + def emit(self, result: RenderResult) -> None: + assert self._handler is not None, "The logic subscribes to the service it is given" + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self._handler(result) + + @property + def request(self) -> RenderRequest: + assert self.requests, "A render was expected to start" + return self.requests[-1] + + +class RenderFixture: + """A render logic wired to a real project and a service that records what it is asked for.""" + + def __init__(self, *, operation_active: bool = False, accepts: bool = True) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY + self.service = FakeRenderService(accepts=accepts) + self.views: List[SongRenderViewModel] = [] + self.logic = SongRenderLogic( + self.controller, + MagicMock(config=Config()), + self.session_manager, + self.service, + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + is_operation_active=lambda: operation_active, + ) + self.logic.on_view_changed = self.views.append + + @property + def view(self) -> SongRenderViewModel: + assert self.views, "A view was expected to be emitted" + return self.views[-1] + + def configure(self) -> None: + self.logic.open() + + def start_at(self, sample_rate: int) -> None: + self.configure() + self.logic.apply(self.view.settings.with_sample_rate(sample_rate)) + self.logic.start() + + +@pytest.fixture +def render() -> RenderFixture: + return RenderFixture() + + +def render_whole_song(synthesizer: RowSynthesizer) -> int: + """The samples a kernel produces when the song is played from its first row to its last.""" + synthesizer.set_position(0, 0) + synthesizer.reset() + rendered = 0 + while not synthesizer.is_finished: + chunk, _ = synthesizer.render_row() + rendered += len(chunk) + + return rendered + + +class TestOfferingTheRender: + def test_the_dialog_opens_on_a_destination_named_after_the_project( + self, + render: RenderFixture, + ) -> None: + render.configure() + + assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + assert render.view.phase == RenderPhase.CONFIGURING + + def test_the_render_occupies_the_application_from_the_dialog_opening( + self, + render: RenderFixture, + ) -> None: + assert not render.logic.is_active + + render.configure() + + assert render.logic.is_active + + def test_another_exclusive_operation_holds_the_dialog_closed(self) -> None: + render = RenderFixture(operation_active=True) + + assert not render.logic.open() + assert not render.logic.is_active + assert not render.views + + def test_closing_releases_the_application(self, render: RenderFixture) -> None: + render.configure() + + render.logic.close() + + assert not render.logic.is_active + + +class TestTheDestinationFollowsTheFormat: + def test_choosing_another_container_renames_the_file(self, render: RenderFixture) -> None: + render.configure() + + render.logic.apply(render.view.settings.with_format(AudioFormat.MP3)) + + assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3" + + def test_a_chosen_destination_is_remembered_for_the_next_render( + self, + render: RenderFixture, + ) -> None: + render.configure() + chosen = Path("/home/user/renders/take one.wav") + + render.logic.set_destination(chosen) + + assert render.view.destination == chosen + render.session_manager.set_audio_path.assert_called_once_with(chosen) + + def test_the_destination_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None: + render.configure() + on_choose_destination = MagicMock() + render.logic.on_choose_destination = on_choose_destination + + render.logic.request_destination() + + on_choose_destination.assert_called_once_with( + AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav", + AudioFormat.WAVE, + ) + + +class TestStartingTheRender: + def test_the_service_is_asked_for_the_chosen_file(self, render: RenderFixture) -> None: + render.configure() + render.logic.apply(render.view.settings.with_normalize(True)) + + render.logic.start() + + request = render.service.request + assert request.destination == render.view.destination + assert request.spec == render.view.settings.spec + assert request.normalize + + def test_the_song_is_measured_at_the_rate_it_is_written_at(self, render: RenderFixture) -> None: + render.start_at(LOW_RATE) + + expected = SongLength.measure(render.controller.project, sample_rate=LOW_RATE) + assert render.service.request.total_samples == expected.samples + + def test_the_kernel_renders_the_measured_song_over_a_held_document( + self, + render: RenderFixture, + ) -> None: + """The kernel reads a snapshot at the chosen rate, so the audio it produces is the length + the service was told to expect however the project moves on.""" + render.start_at(LOW_RATE) + request = render.service.request + + render.controller.set_tempo(render.controller.project.settings.tempo + 40) + + assert render_whole_song(request.synthesizer) == request.total_samples + + def test_a_declined_request_leaves_the_dialog_setting_up(self) -> None: + render = RenderFixture(accepts=False) + render.configure() + + render.logic.start() + + assert render.view.phase == RenderPhase.CONFIGURING + + def test_a_render_starts_from_the_setup_alone(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.start() + + assert len(render.service.requests) == 1 + + +class TestReportingTheRender: + def test_a_pass_reports_how_far_it_has_got(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.service.emit( + ServiceProgress( + completed=25, + total=100, + current_item=RenderStage.SYNTHESIS, + ) + ) + + assert render.view.phase == RenderPhase.RENDERING + assert render.view.progress == 0.25 + assert render.view.status_text.startswith("settings.render.message.status_synthesis") + + def test_the_second_pass_names_itself(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.service.emit( + ServiceProgress( + completed=50, + total=100, + current_item=RenderStage.ENCODING, + ) + ) + + assert render.view.status_text.startswith("settings.render.message.status_encoding") + + def test_a_stop_holds_its_message_over_the_reports_still_arriving( + self, + render: RenderFixture, + ) -> None: + render.configure() + render.logic.start() + render.logic.cancel() + + render.service.emit( + ServiceProgress( + completed=75, + total=100, + current_item=RenderStage.SYNTHESIS, + ) + ) + + assert render.view.phase == RenderPhase.CANCELLING + assert render.view.status_text == "settings.render.message.status_cancelling" + + def test_a_stop_reaches_the_service(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.cancel() + + assert render.service.cancels == 1 + + def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_success = MagicMock() + render.logic.on_success = on_success + written = AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + render.service.emit(ServiceSuccess(value=written)) + + on_success.assert_called_once_with(written) + assert render.view.phase == RenderPhase.COMPLETED + assert render.view.progress == 1.0 + assert not render.logic.is_active + + def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_cancelled = MagicMock() + render.logic.on_cancelled = on_cancelled + + render.logic.cancel() + render.service.emit(ServiceCancelled()) + + on_cancelled.assert_called_once() + assert render.view.phase == RenderPhase.CANCELLED + assert not render.logic.is_active + + def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_error = MagicMock() + render.logic.on_error = on_error + failure = OSError("no room on the device") + + render.service.emit(ServiceError(exception=failure)) + + on_error.assert_called_once_with(failure) + assert render.view.phase == RenderPhase.FAILED + assert not render.logic.is_active + + def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.cleanup() + + assert render.service.shutdowns == 1 diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py new file mode 100644 index 00000000..ab23991a --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py @@ -0,0 +1,68 @@ +from typing import Final + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.playback.synthesizer import SongLength +from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming + +FRACTIONAL_RATE: Final[int] = 22050 +EXACT_RATE: Final[int] = 44100 + + +def measure(controller: ProjectController, sample_rate: int) -> SongLength: + return SongLength.measure(controller.project, sample_rate=sample_rate) + + +class TestTheOrderStatesTheTicks: + def test_the_song_lasts_its_groove_once_for_each_order_position( + self, + controller: ProjectController, + ) -> None: + groove = SongTiming.from_project(controller.project).groove() + + length = measure(controller, EXACT_RATE) + + assert length.ticks == controller.project.song.order_length() * groove.total_ticks + + def test_appending_a_frame_lengthens_the_song_by_a_pattern( + self, + controller: ProjectController, + ) -> None: + before = measure(controller, EXACT_RATE) + groove = SongTiming.from_project(controller.project).groove() + + controller.append_frame() + + assert measure(controller, EXACT_RATE).ticks == before.ticks + groove.total_ticks + + +class TestTheRateStatesTheSamples: + """The clock spreads a fractional samples-per-tick across ticks, so a total lands on the exact + duration whatever rate it is rendered at.""" + + def test_a_rate_dividing_evenly_gives_a_whole_frame_for_every_tick( + self, + controller: ProjectController, + ) -> None: + length = measure(controller, EXACT_RATE) + nes_frequency = controller.project.settings.nes_frequency + + assert length.samples == length.ticks * EXACT_RATE // nes_frequency + + def test_a_rate_dividing_fractionally_still_lands_on_the_exact_duration( + self, + controller: ProjectController, + ) -> None: + length = measure(controller, FRACTIONAL_RATE) + nes_frequency = controller.project.settings.nes_frequency + + assert length.samples == length.ticks * FRACTIONAL_RATE // nes_frequency + assert length.samples * 2 - measure(controller, EXACT_RATE).samples <= 1 + + def test_a_song_plays_for_the_same_time_at_every_rate( + self, + controller: ProjectController, + ) -> None: + fractional = measure(controller, FRACTIONAL_RATE) + exact = measure(controller, EXACT_RATE) + + assert abs(fractional.samples / FRACTIONAL_RATE - exact.samples / EXACT_RATE) < 1e-3 diff --git a/tests/unit/sampletones_application/view_model/shared/test_nearest.py b/tests/unit/sampletones_application/view_model/shared/test_nearest.py new file mode 100644 index 00000000..09f03256 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_nearest.py @@ -0,0 +1,18 @@ +import pytest + +from sampletones_application.view_model.shared.nearest import nearest_offered + + +class TestNearestOffered: + def test_an_offered_value_selects_itself(self) -> None: + assert nearest_offered(48000, (8000, 44100, 48000)) == 48000 + + def test_a_value_between_offers_selects_the_closer_one(self) -> None: + assert nearest_offered(96000, (8000, 44100, 48000)) == 48000 + + def test_two_offers_equally_close_select_the_smaller(self) -> None: + assert nearest_offered(30, (20, 40)) == 20 + + def test_nothing_offered_reports_the_empty_choice(self) -> None: + with pytest.raises(ValueError): + nearest_offered(44100, ()) diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py new file mode 100644 index 00000000..f2f37bf0 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -0,0 +1,158 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import ( + AudioDepth, + AudioFormat, + Mp3OutputSpec, + WaveOutputSpec, +) + +DESTINATION: Final[Path] = Path("/home/user/song.wav") +TOTAL_SAMPLES: Final[int] = 44100 + + +def wave_settings( + *, + sample_rate: int = 44100, + depth: AudioDepth = AudioDepth.PCM_16, +) -> SongRenderSettings: + return SongRenderSettings( + spec=WaveOutputSpec(sample_rate=sample_rate, depth=depth), + normalize=False, + ) + + +def view_model( + settings: SongRenderSettings, + *, + phase: RenderPhase = RenderPhase.CONFIGURING, + total_samples: int = TOTAL_SAMPLES, +) -> SongRenderViewModel: + return SongRenderViewModel( + phase=phase, + formats=(AudioFormat.WAVE, AudioFormat.MP3), + depths=(AudioDepth.PCM_16, AudioDepth.PCM_24), + settings=settings, + destination=DESTINATION, + total_samples=total_samples, + status_text="", + progress=0.0, + ) + + +class TestChoicesFollowTheFormat: + """Every choice a dialog stands at is reconciled against what the container accepts.""" + + def test_a_rate_the_new_format_encodes_is_kept(self) -> None: + settings = wave_settings(sample_rate=48000).with_format(AudioFormat.MP3) + + assert settings.spec.audio_format == AudioFormat.MP3 + assert settings.spec.sample_rate == 48000 + + def test_a_rate_the_new_format_leaves_behind_moves_to_the_nearest(self) -> None: + settings = wave_settings(sample_rate=192000).with_format(AudioFormat.MP3) + + assert settings.spec.sample_rate == 48000 + + def test_a_container_storing_samples_opens_on_a_depth(self) -> None: + settings = SongRenderSettings.initial(AudioFormat.MP3).with_format(AudioFormat.WAVE) + + assert settings.depth is not None + assert settings.bitrate is None + + def test_a_depth_survives_a_rate_change(self) -> None: + settings = wave_settings(depth=AudioDepth.PCM_U8).with_sample_rate(8000) + + assert settings.spec.sample_rate == 8000 + assert settings.depth == AudioDepth.PCM_U8 + + def test_the_normalise_choice_stands_through_a_format_change(self) -> None: + settings = wave_settings().with_normalize(True).with_format(AudioFormat.MP3) + + assert settings.normalize + + +class TestBitratesFollowTheRate: + """Each MPEG version defines its own ladder, so the bitrate follows the rate that selects it.""" + + def test_a_bitrate_the_new_rate_reaches_is_kept(self) -> None: + settings = SongRenderSettings( + spec=Mp3OutputSpec(sample_rate=44100, bitrate=64), + normalize=False, + ).with_sample_rate(22050) + + assert settings.bitrate == 64 + + def test_a_bitrate_beyond_the_new_ladder_moves_onto_it(self) -> None: + settings = SongRenderSettings( + spec=Mp3OutputSpec(sample_rate=44100, bitrate=320), + normalize=False, + ).with_sample_rate(8000) + + assert settings.bitrate == 64 + + def test_the_chosen_bitrate_is_taken(self) -> None: + settings = SongRenderSettings.initial(AudioFormat.MP3).with_bitrate(96) + + assert settings.bitrate == 96 + + +class TestWhatTheDialogDraws: + def test_a_container_storing_samples_offers_depths_and_no_bitrates(self) -> None: + view = view_model(wave_settings()) + + assert view.stores_samples + assert view.bitrates == () + + def test_a_container_encoding_to_a_bitrate_offers_the_ladder_of_its_rate(self) -> None: + view = view_model(SongRenderSettings.initial(AudioFormat.MP3).with_sample_rate(8000)) + + assert not view.stores_samples + assert view.bitrates == (64, 56, 48, 40, 32, 24, 16, 8) + + def test_the_offered_rates_are_the_containers_own(self) -> None: + view = view_model(SongRenderSettings.initial(AudioFormat.MP3)) + + assert view.sample_rates == (8000, 16000, 22050, 44100, 48000) + + def test_the_projected_duration_is_the_song_at_the_chosen_rate(self) -> None: + view = view_model(wave_settings(sample_rate=44100), total_samples=88200) + + assert view.duration_seconds == 2.0 + + def test_setting_up_shows_the_setup_alone(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.CONFIGURING) + + assert view.setup_visible + assert not view.progress_visible + assert view.render_enabled + assert view.is_active + + def test_rendering_shows_the_progress_alone(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.RENDERING) + + assert view.progress_visible + assert not view.setup_visible + assert not view.render_enabled + assert view.cancel_enabled + + def test_a_render_already_stopping_takes_no_further_stop(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.CANCELLING) + + assert view.is_active + assert not view.cancel_enabled + + def test_a_song_holding_no_rows_starts_no_render(self) -> None: + view = view_model(wave_settings(), total_samples=0) + + assert not view.render_enabled + + def test_an_outcome_releases_the_application(self) -> None: + for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED): + assert not view_model(wave_settings(), phase=phase).is_active diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 23b162f8..b0ab5a04 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -14,6 +14,7 @@ open_directory_in_explorer_linux, open_file_in_explorer_linux, open_path_in_explorer, + replace_suffix, shorten_filename, shorten_path, to_path, @@ -264,6 +265,67 @@ def test_ensure_suffix(self, test_case: TestCase) -> None: assert result == Path(test_case.expected) +class TestReplaceSuffix(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + input_path: str + previous: str + suffix: str + expected: str + + test_cases = ( + TestCase( + input_path="song.wav", + previous=".wav", + suffix=".mp3", + expected="song.mp3", + label="replaces_the_previous_extension", + ), + TestCase( + input_path="song.WAV", + previous=".wav", + suffix=".mp3", + expected="song.mp3", + label="replaces_case_insensitively", + ), + TestCase( + input_path="/home/user/my song v1.2.wav", + previous=".wav", + suffix=".mp3", + expected="/home/user/my song v1.2.mp3", + label="keeps_incidental_dots_and_directory", + ), + TestCase( + input_path="my.mix", + previous=".wav", + suffix=".mp3", + expected="my.mix.mp3", + label="appends_where_the_name_ends_otherwise", + ), + TestCase( + input_path="song", + previous=".wav", + suffix=".wav", + expected="song.wav", + label="appends_where_the_name_carries_no_extension", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_replace_suffix(self, test_case: TestCase) -> None: + result = replace_suffix( + Path(test_case.input_path), + test_case.previous, + test_case.suffix, + ) + + assert result == Path(test_case.expected) + + class TestShortenPath(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From 5dc24e34c3a9b3c6f9ba3be600c9a36c320070c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 07:17:56 +0200 Subject: [PATCH 06/10] Added: render dialog window --- .../layout/settings/__init__.py | 2 + .../layout/settings/render.py | 7 + src/sampletones_application/tags/settings.py | 115 +++++ .../ui/panels/dialogs/render.py | 450 ++++++++++++++++++ .../view_model/shared/render.py | 37 ++ src/sampletones_config/lang/en.yaml | 19 + .../layout/settings/render.yaml | 3 + .../ui/panels/dialogs/test_render.py | 309 ++++++++++++ 8 files changed, 942 insertions(+) create mode 100644 src/sampletones_application/layout/settings/render.py create mode 100644 src/sampletones_application/ui/panels/dialogs/render.py create mode 100644 src/sampletones_config/layout/settings/render.yaml create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_render.py diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index dcebe426..99c11b68 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -3,6 +3,7 @@ from sampletones_application.layout.settings.audio import AudioSettingsLayout from sampletones_application.layout.settings.display import DisplaySettingsLayout from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout +from sampletones_application.layout.settings.render import RenderSettingsLayout class SettingsLayout(BaseModel, extra="forbid", frozen=True): @@ -17,3 +18,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): audio: AudioSettingsLayout display: DisplaySettingsLayout keybindings: KeybindingsSettingsLayout + render: RenderSettingsLayout diff --git a/src/sampletones_application/layout/settings/render.py b/src/sampletones_application/layout/settings/render.py new file mode 100644 index 00000000..58dc9e84 --- /dev/null +++ b/src/sampletones_application/layout/settings/render.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import Dimensions + + +class RenderSettingsLayout(BaseModel, extra="forbid", frozen=True): + window: Dimensions diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index 3f090575..8a48c2f2 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -137,6 +137,121 @@ "revert", ) +TAG_SETTINGS_RENDER_WINDOW = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.WINDOW, + "render", +) +TAG_SETTINGS_RENDER_GROUP_SETUP = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "setup", +) +TAG_SETTINGS_RENDER_GROUP_PROGRESS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "progress", +) +TAG_SETTINGS_RENDER_GROUP_DEPTH = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "depth", +) +TAG_SETTINGS_RENDER_GROUP_BITRATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "bitrate", +) +TAG_SETTINGS_RENDER_GROUP_DESTINATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "destination", +) +TAG_SETTINGS_RENDER_COMBO_FORMAT = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "format", +) +TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "sample_rate", +) +TAG_SETTINGS_RENDER_COMBO_DEPTH = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "depth", +) +TAG_SETTINGS_RENDER_COMBO_BITRATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "bitrate", +) +TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.CHECKBOX, + "normalize", +) +TAG_SETTINGS_RENDER_TEXT_DURATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.TEXT, + "duration", +) +TAG_SETTINGS_RENDER_TEXT_STATUS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.TEXT, + "status", +) +TAG_SETTINGS_RENDER_PATH_DESTINATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.PATH, + "destination", +) +TAG_SETTINGS_RENDER_PROGRESS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.PROGRESS, + "render", +) +TAG_SETTINGS_RENDER_BUTTON_BROWSE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "browse", +) +TAG_SETTINGS_RENDER_BUTTON_START = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "start", +) +TAG_SETTINGS_RENDER_BUTTON_CLOSE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "close", +) +TAG_SETTINGS_RENDER_BUTTON_CANCEL = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "cancel", +) + TAG_SETTINGS_KEYBINDINGS_WINDOW = TagName( Page.SETTINGS, Panel.KEYBINDINGS, diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py new file mode 100644 index 00000000..fad454d3 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -0,0 +1,450 @@ +from typing import Any, Callable, Dict, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.settings import ( + TAG_SETTINGS_RENDER_BUTTON_BROWSE, + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + TAG_SETTINGS_RENDER_BUTTON_CLOSE, + TAG_SETTINGS_RENDER_BUTTON_START, + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_GROUP_BITRATE, + TAG_SETTINGS_RENDER_GROUP_DEPTH, + TAG_SETTINGS_RENDER_GROUP_DESTINATION, + TAG_SETTINGS_RENDER_GROUP_PROGRESS, + TAG_SETTINGS_RENDER_GROUP_SETUP, + TAG_SETTINGS_RENDER_PATH_DESTINATION, + TAG_SETTINGS_RENDER_PROGRESS, + TAG_SETTINGS_RENDER_TEXT_DURATION, + TAG_SETTINGS_RENDER_TEXT_STATUS, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.field import labeled_field +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.render import ( + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioDepth, AudioFormat +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +SettingsCallback = Callable[[SongRenderSettings], None] + + +class GUIRenderWindow(GUIDialogWindow): + """Modal form over writing the open song to an audio file. + + The dialog has two faces and shows one at a time: the setup, where the file is described and + the render is started, and the progress, where the running pass reports itself and takes a + stop. Which one stands is the phase the view model carries, so the window draws whatever it + is handed. + + Every control reports the whole edited state through ``on_settings_changed``, which the owner + reconciles and hands back — so what a container accepts decides what the next combo offers, + and the dialog shows the choices as they end up rather than as they were asked for. + """ + + def __init__( + self, + *, + layout: SettingsLayout, + path_colors: PathColors, + language_manager: LanguageManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._layout = layout + self._path_colors = path_colors + self._status_bar = status_bar + self._view_model: Optional[SongRenderViewModel] = None + self._destination_text: Optional[GUIPathText] = None + + self.on_settings_changed: Optional[SettingsCallback] = None + self.on_browse: Optional[VoidCallback] = None + self.on_render: Optional[VoidCallback] = None + self.on_cancel: Optional[VoidCallback] = None + self.on_close: Optional[VoidCallback] = None + + self._formats_by_label: Dict[str, AudioFormat] = {} + self._sample_rates_by_label: Dict[str, int] = {} + self._depths_by_label: Dict[str, AudioDepth] = {} + self._bitrates_by_label: Dict[str, int] = {} + + self._fmt_sample_rate = language_manager["settings.render.template.sample_rate"] + self._fmt_bitrate = language_manager["settings.render.template.bitrate"] + self._msg_path = language_manager["global.status.message.path"] + self._format_labels: Dict[AudioFormat, str] = { + AudioFormat.WAVE: language_manager["settings.render.label.format_wave"], + AudioFormat.MP3: language_manager["settings.render.label.format_mp3"], + } + self._depth_labels: Dict[AudioDepth, str] = { + AudioDepth.PCM_U8: language_manager["settings.render.label.depth_pcm_u8"], + AudioDepth.PCM_16: language_manager["settings.render.label.depth_pcm_16"], + AudioDepth.PCM_24: language_manager["settings.render.label.depth_pcm_24"], + AudioDepth.PCM_32: language_manager["settings.render.label.depth_pcm_32"], + AudioDepth.FLOAT_32: language_manager["settings.render.label.depth_float_32"], + } + + super().__init__( + tag=TAG_SETTINGS_RENDER_WINDOW, + width=layout.render.window.width, + height=layout.render.window.height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, view_model: SongRenderViewModel) -> None: + """Shows the window seeded with the render being set up.""" + self._view_model = view_model + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" + + def update_view(self, view_model: SongRenderViewModel) -> None: + """Re-seeds the controls of the open window from where the render stands.""" + self._view_model = view_model + self._render() + + def create_window(self) -> None: + with self.dialog_window( + label=self._language_manager["settings.render.title.window_title"], + on_close=self._request_close, + ): + self._create_setup() + self._create_progress() + + self._bind_dialog_theme( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + ) + + self._render() + self._install_navigation( + [ + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_FORMAT), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_DEPTH), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_BITRATE), + FocusStop.field(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_BROWSE, self._request_destination), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CLOSE, self._request_close), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_START, self._request_render), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CANCEL, self._request_cancel), + ], + on_escape=self._request_close, + ) + + def _create_setup(self) -> None: + with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_SETUP): + self._create_format_selection() + self._create_sample_rate_selection() + self._create_depth_selection() + self._create_bitrate_selection() + self._create_normalize_switch() + self._create_duration() + dpg.add_separator() + self._create_destination() + dpg.add_separator() + self._create_setup_buttons() + + def _create_format_selection(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.format"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_FORMAT, + items=[], + width=self._layout.combo_width, + callback=self._on_format_changed, + ) + + def _create_sample_rate_selection(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.sample_rate"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + items=[], + width=self._layout.combo_width, + callback=self._on_sample_rate_changed, + ) + + def _create_depth_selection(self) -> None: + with ( + dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_DEPTH), + labeled_field( + self._language_manager["settings.render.label.depth"], + self._layout.label_width, + ), + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_DEPTH, + items=[], + width=self._layout.combo_width, + callback=self._on_depth_changed, + ) + + def _create_bitrate_selection(self) -> None: + with ( + dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_BITRATE), + labeled_field( + self._language_manager["settings.render.label.bitrate"], + self._layout.label_width, + ), + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_BITRATE, + items=[], + width=self._layout.combo_width, + callback=self._on_bitrate_changed, + ) + + def _create_normalize_switch(self) -> None: + dpg.add_checkbox( + tag=TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + label=self._language_manager["settings.render.label.normalize"], + callback=self._on_normalize_changed, + ) + + def _create_duration(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.duration"], + self._layout.label_width, + ): + dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_DURATION) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_DURATION, Font.MONO) + + def _create_destination(self) -> None: + """Lays out the file a render writes, with the browse button beside the path it stands at. + + The button leads the row so it holds its place whatever the path reads, and the path + follows it as the answer to what the button asks. + """ + with labeled_field( + self._language_manager["settings.render.label.destination"], + self._layout.label_width, + ): + dpg.add_group(horizontal=True, tag=TAG_SETTINGS_RENDER_GROUP_DESTINATION) + + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_BROWSE, + label=self._language_manager["settings.render.label.browse_button"], + parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, + callback=self._request_destination, + ) + self._destination_text = GUIPathText( + tag=TAG_SETTINGS_RENDER_PATH_DESTINATION, + path=self._require_view_model().destination, + parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_path, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + + @table_wrapper(columns=2) + def _create_setup_buttons(self) -> None: + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_CLOSE, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_close, + width=-1, + ) + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_START, + label=self._language_manager["settings.render.label.render_button"], + callback=self._request_render, + width=-1, + ) + + def _create_progress(self) -> None: + with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=False): + dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_STATUS) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_STATUS, Font.MONO_SMALL) + dpg.add_progress_bar( + tag=TAG_SETTINGS_RENDER_PROGRESS, + default_value=0.0, + width=-1, + ) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_PROGRESS, Font.MONO) + dpg.add_separator() + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_CANCEL, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_cancel, + width=-1, + ) + + def _render(self) -> None: + """Shows the face the phase calls for, with each choice standing at what it reconciled to.""" + view_model = self._require_view_model() + self._render_setup(view_model) + self._render_progress(view_model) + + def _render_setup(self, view_model: SongRenderViewModel) -> None: + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_SETUP, show=view_model.setup_visible) + self._render_formats(view_model) + self._render_sample_rates(view_model) + self._render_depths(view_model) + self._render_bitrates(view_model) + dpg_configure_item( + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + enabled=view_model.setup_visible, + ) + dpg_set_value(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, view_model.settings.normalize) + dpg_set_value(TAG_SETTINGS_RENDER_TEXT_DURATION, view_model.duration_label) + if self._destination_text is not None: + self._destination_text.set_path(view_model.destination) + + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_BROWSE, enabled=view_model.setup_visible) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CLOSE, enabled=view_model.setup_visible) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_START, enabled=view_model.render_enabled) + + def _render_formats(self, view_model: SongRenderViewModel) -> None: + self._formats_by_label = { + self._format_labels[audio_format]: audio_format for audio_format in view_model.formats + } + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + items=list(self._formats_by_label), + enabled=view_model.setup_visible, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + self._format_labels[view_model.spec.audio_format], + ) + + def _render_sample_rates(self, view_model: SongRenderViewModel) -> None: + self._sample_rates_by_label = dict( + zip( + view_model.sample_rate_labels(self._fmt_sample_rate), + view_model.sample_rates, + ) + ) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + items=list(self._sample_rates_by_label), + enabled=view_model.setup_visible, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + view_model.sample_rate_label(self._fmt_sample_rate), + ) + + def _render_depths(self, view_model: SongRenderViewModel) -> None: + self._depths_by_label = {self._depth_labels[depth]: depth for depth in view_model.depths} + depth = view_model.settings.depth + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_DEPTH, show=view_model.depth_visible) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + items=list(self._depths_by_label), + enabled=view_model.depth_enabled, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + self._depth_labels[depth] if depth is not None else "", + ) + + def _render_bitrates(self, view_model: SongRenderViewModel) -> None: + self._bitrates_by_label = dict( + zip( + view_model.bitrate_labels(self._fmt_bitrate), + view_model.bitrates, + ) + ) + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_BITRATE, show=view_model.bitrate_visible) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + items=list(self._bitrates_by_label), + enabled=view_model.bitrate_enabled, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + view_model.bitrate_label(self._fmt_bitrate), + ) + + def _render_progress(self, view_model: SongRenderViewModel) -> None: + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=view_model.progress_visible) + dpg_set_value(TAG_SETTINGS_RENDER_TEXT_STATUS, view_model.status_text) + dpg_set_value(TAG_SETTINGS_RENDER_PROGRESS, view_model.progress) + dpg_configure_item(TAG_SETTINGS_RENDER_PROGRESS, overlay=view_model.progress_overlay) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CANCEL, enabled=view_model.cancel_enabled) + + def _on_format_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_format(self._formats_by_label[app_data])) + + def _on_sample_rate_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_sample_rate(self._sample_rates_by_label[app_data])) + + def _on_depth_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_depth(self._depths_by_label[app_data])) + + def _on_bitrate_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_bitrate(self._bitrates_by_label[app_data])) + + def _on_normalize_changed(self, _sender: Sender, app_data: bool) -> None: + self._emit(self._settings().with_normalize(bool(app_data))) + + def _emit(self, settings: SongRenderSettings) -> None: + self.call(self.on_settings_changed, settings) + + def _request_destination(self) -> None: + self.call(self.on_browse) + + def _request_render(self) -> None: + self.call(self.on_render) + + def _request_cancel(self) -> None: + self.call(self.on_cancel) + + def _request_close(self) -> None: + """Answers Escape and the title bar: a setup is done with, a running render is asked to stop. + + A render already stopping, and one that has reported its outcome, answer neither — what + they are waiting for is the service, which arrives on its own. + """ + view_model = self._require_view_model() + if view_model.cancel_enabled: + self._request_cancel() + elif view_model.setup_visible: + self.call(self.on_close) + + def _settings(self) -> SongRenderSettings: + return self._require_view_model().settings + + def _require_view_model(self) -> SongRenderViewModel: + """The render on screen. + + Raises: + SystemError: when the window is drawn before :meth:`open` seeds it. + """ + if self._view_model is None: + raise SystemError("The render window is drawn from a view model it was opened with") + + return self._view_model diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 26f20daf..5e8928c7 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -226,6 +226,23 @@ def stores_samples(self) -> bool: """Whether the chosen container stores samples, which is what gives it a depth to choose.""" return self.spec.capability.stores_samples + def sample_rate_labels(self, template: str) -> Tuple[str, ...]: + """The rates on offer, each as ``template`` states it.""" + return tuple(template.format(rate=sample_rate) for sample_rate in self.sample_rates) + + def sample_rate_label(self, template: str) -> str: + """The chosen rate, as ``template`` states it.""" + return template.format(rate=self.spec.sample_rate) + + def bitrate_labels(self, template: str) -> Tuple[str, ...]: + """The bitrates on offer, each as ``template`` states it.""" + return tuple(template.format(bitrate=bitrate) for bitrate in self.bitrates) + + def bitrate_label(self, template: str) -> str: + """The chosen bitrate, as ``template`` states it, for a container that offers one.""" + bitrate = self.settings.bitrate + return template.format(bitrate=bitrate) if bitrate is not None else "" + @property def duration_seconds(self) -> float: """How long the song plays for, in seconds.""" @@ -253,6 +270,26 @@ def setup_visible(self) -> bool: def progress_visible(self) -> bool: return self.phase != RenderPhase.CONFIGURING + @property + def depth_visible(self) -> bool: + """Whether the depth is chosen here, which a container storing samples is what offers.""" + return self.stores_samples + + @property + def bitrate_visible(self) -> bool: + """Whether the bitrate is chosen here, which a container encoding to one is what offers.""" + return not self.stores_samples + + @property + def depth_enabled(self) -> bool: + """Whether the depth takes an edit: offered by the container, while the setup is showing.""" + return self.setup_visible and self.depth_visible + + @property + def bitrate_enabled(self) -> bool: + """Whether the bitrate takes an edit: offered by the container, while the setup is showing.""" + return self.setup_visible and self.bitrate_visible + @property def render_enabled(self) -> bool: """Whether a render starts from here: a song with something to write, still being set up.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a27a59e7..5eeb09a1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -661,6 +661,25 @@ settings.display.label.keep_editing_button: "Keep editing" settings.display.message.countdown: "Keep the window the way it looks now? It goes back to how it was when the count reaches zero." settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" +settings.render.title.window_title: "Render song" +settings.render.label.format: "Format" +settings.render.label.sample_rate: "Sample rate" +settings.render.label.depth: "Bit depth" +settings.render.label.bitrate: "Bitrate" +settings.render.label.normalize: "Normalize peak" +settings.render.label.duration: "Length" +settings.render.label.destination: "File" +settings.render.label.browse_button: "Browse..." +settings.render.label.render_button: "Render" +settings.render.label.format_wave: "WAV" +settings.render.label.format_mp3: "MP3" +settings.render.label.depth_pcm_u8: "8-bit PCM" +settings.render.label.depth_pcm_16: "16-bit PCM" +settings.render.label.depth_pcm_24: "24-bit PCM" +settings.render.label.depth_pcm_32: "32-bit PCM" +settings.render.label.depth_float_32: "32-bit float" +settings.render.template.sample_rate: "{rate} Hz" +settings.render.template.bitrate: "{bitrate} kbps" settings.render.message.status_synthesis: "Rendering the song..." settings.render.message.status_encoding: "Writing the file..." settings.render.message.status_cancelling: "Stopping the render..." diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml new file mode 100644 index 00000000..2f91551f --- /dev/null +++ b/src/sampletones_config/layout/settings/render.yaml @@ -0,0 +1,3 @@ +window: + width: 480 + height: 0 diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py new file mode 100644 index 00000000..9b8cece3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py @@ -0,0 +1,309 @@ +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_RENDER_BUTTON_BROWSE, + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + TAG_SETTINGS_RENDER_BUTTON_CLOSE, + TAG_SETTINGS_RENDER_BUTTON_START, + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_GROUP_BITRATE, + TAG_SETTINGS_RENDER_GROUP_DEPTH, + TAG_SETTINGS_RENDER_GROUP_PROGRESS, + TAG_SETTINGS_RENDER_GROUP_SETUP, + TAG_SETTINGS_RENDER_PATH_DESTINATION, + TAG_SETTINGS_RENDER_PROGRESS, + TAG_SETTINGS_RENDER_TEXT_DURATION, + TAG_SETTINGS_RENDER_TEXT_STATUS, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioDepth, AudioFormat +from sampletones_shared.utils.system.paths import shorten_path +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DESTINATION: Final[Path] = Path("/home/user/audio/chiptune.wav") +TOTAL_SAMPLES: Final[int] = 44100 * 90 +STATUS_TEXT: Final[str] = "Rendering the song..." + + +def view_model( + *, + settings: SongRenderSettings, + phase: RenderPhase = RenderPhase.CONFIGURING, + progress: float = 0.0, +) -> SongRenderViewModel: + return SongRenderViewModel( + phase=phase, + formats=(AudioFormat.WAVE, AudioFormat.MP3), + depths=(AudioDepth.PCM_U8, AudioDepth.PCM_16, AudioDepth.PCM_24), + settings=settings, + destination=DESTINATION, + total_samples=TOTAL_SAMPLES, + status_text=STATUS_TEXT if phase == RenderPhase.RENDERING else "", + progress=progress, + ) + + +def wave_settings() -> SongRenderSettings: + return SongRenderSettings.initial(AudioFormat.WAVE) + + +def mp3_settings() -> SongRenderSettings: + return SongRenderSettings.initial(AudioFormat.MP3) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIRenderWindow: + return GUIRenderWindow( + layout=layout_config.settings, + path_colors=layout_config.general.colors.paths, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + status_bar=GUIStatusBar(display_time=1.0), + ) + + +def render( + window: GUIRenderWindow, + *, + settings: Optional[SongRenderSettings] = None, + phase: RenderPhase = RenderPhase.CONFIGURING, + progress: float = 0.0, +) -> None: + """Builds the widget tree for the given state, the way ``open`` does without a live frame.""" + window.update_view( + view_model( + settings=settings if settings is not None else wave_settings(), + phase=phase, + progress=progress, + ) + ) + window.create_window() + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +class TestTheSetup: + def test_every_written_container_reaches_the_combo(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["items"] == ["WAV", "MP3"] + + def test_the_rates_offered_are_the_containers_own(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)["items"] == [ + "8000 Hz", + "16000 Hz", + "22050 Hz", + "44100 Hz", + "48000 Hz", + ] + + def test_a_container_storing_samples_offers_a_depth(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_DEPTH) == "16-bit PCM" + + def test_a_container_encoding_to_a_bitrate_offers_one(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_BITRATE) == "192 kbps" + + def test_the_song_is_shown_at_the_length_it_renders_to(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_DURATION) == "1m 30s" + + def test_the_file_and_the_actions_over_it_are_offered(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_RENDER_PATH_DESTINATION) == shorten_path(DESTINATION) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_BROWSE) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_START) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + +class TestTheTwoFaces: + def test_setting_up_shows_the_setup_alone(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"] + + def test_rendering_shows_the_progress_alone(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.RENDERING, progress=0.5) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_PROGRESS) == pytest.approx(0.5) + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_PROGRESS)["overlay"] == "50%" + assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_STATUS) == STATUS_TEXT + + def test_a_control_off_screen_takes_no_focus(self, window: GUIRenderWindow) -> None: + """The focus ring skips a disabled stop, which is what keeps Tab on the face being shown.""" + render(window, phase=RenderPhase.RENDERING) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["enabled"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"] + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] + + def test_a_render_already_stopping_takes_no_further_stop(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.CANCELLING) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] + + def test_the_hidden_choice_takes_no_focus(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"] + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_BITRATE)["enabled"] + + +class TestReportedEdits: + """Every control reports the whole edited state, so the owner reconciles one value.""" + + @pytest.fixture(name="reported") + def reported_fixture(self, window: GUIRenderWindow) -> List[SongRenderSettings]: + reported: List[SongRenderSettings] = [] + window.on_settings_changed = reported.append + render(window) + return reported + + def test_picking_a_container_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_FORMAT)(TAG_SETTINGS_RENDER_COMBO_FORMAT, "MP3") + + assert reported[-1].spec.audio_format == AudioFormat.MP3 + + def test_picking_a_rate_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + "8000 Hz", + ) + + assert reported[-1].spec.sample_rate == 8000 + + def test_picking_a_depth_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_DEPTH)( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + "8-bit PCM", + ) + + assert reported[-1].depth == AudioDepth.PCM_U8 + + def test_picking_a_bitrate_reports_it(self, window: GUIRenderWindow) -> None: + reported: List[SongRenderSettings] = [] + window.on_settings_changed = reported.append + render(window, settings=mp3_settings()) + + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_BITRATE)( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + "128 kbps", + ) + + assert reported[-1].bitrate == 128 + + def test_asking_for_the_peak_to_reach_full_scale_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE)( + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + True, + ) + + assert reported[-1].normalize + + +class TestReportedActions: + def test_the_browse_button_asks_for_a_file(self, window: GUIRenderWindow) -> None: + asked: List[None] = [] + window.on_browse = lambda: asked.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_BROWSE) + + assert asked + + def test_the_render_button_starts_the_render(self, window: GUIRenderWindow) -> None: + started: List[None] = [] + window.on_render = lambda: started.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_START) + + assert started + + def test_the_stop_button_stops_a_running_render(self, window: GUIRenderWindow) -> None: + stopped: List[None] = [] + window.on_cancel = lambda: stopped.append(None) + render(window, phase=RenderPhase.RENDERING) + + press(TAG_SETTINGS_RENDER_BUTTON_CANCEL) + + assert stopped + + def test_leaving_the_setup_closes_the_dialog(self, window: GUIRenderWindow) -> None: + closed: List[None] = [] + stopped: List[None] = [] + window.on_close = lambda: closed.append(None) + window.on_cancel = lambda: stopped.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + assert closed + assert not stopped + + def test_leaving_a_running_render_stops_it_instead(self, window: GUIRenderWindow) -> None: + """Escape and the title bar answer through the same handler the Cancel button does.""" + closed: List[None] = [] + stopped: List[None] = [] + window.on_close = lambda: closed.append(None) + window.on_cancel = lambda: stopped.append(None) + render(window, phase=RenderPhase.RENDERING) + + press(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + assert stopped + assert not closed From a4c88097a91b24fac5906508bf2fb50012a1441c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 10:11:19 +0200 Subject: [PATCH 07/10] Added: song render wiring, menu entry and shortcut --- src/sampletones_application/application.py | 61 ++- .../categories/elements/global_.py | 1 + .../categories/elements/settings.py | 1 + .../coordinators/render.py | 170 +++++++++ src/sampletones_application/shell.py | 2 + src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 11 + .../utils/gui/shortcuts/ids.py | 1 + .../view_model/shared/menu.py | 6 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 7 + tests/suite/render.py | 83 ++++ .../coordinators/test_render.py | 361 ++++++++++++++++++ .../logic/render/test_logic.py | 79 +--- .../sampletones_application/test_busy_lock.py | 34 +- .../sampletones_application/ui/test_menu.py | 1 + .../view_model/shared/test_menu.py | 2 + 18 files changed, 742 insertions(+), 86 deletions(-) create mode 100644 src/sampletones_application/coordinators/render.py create mode 100644 tests/suite/render.py create mode 100644 tests/unit/sampletones_application/coordinators/test_render.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 078505f1..f1b0653d 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -23,6 +23,7 @@ from sampletones_application.coordinators.reconstruction import ( ReconstructionCoordinator, ) +from sampletones_application.coordinators.render import SongRenderCoordinator from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) @@ -46,6 +47,7 @@ ) from sampletones_application.logic.reconstruction.browser_manager import BrowserManager from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( InstructionsTabParameters, MainTabParameters, @@ -72,6 +74,7 @@ ServiceCancelled, ServiceError, ServiceSuccess, + SongRenderService, ) from sampletones_application.shell import ApplicationShell, ShortcutBindings from sampletones_application.tags.general import ( @@ -100,6 +103,7 @@ from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, ) +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -228,6 +232,7 @@ def __init__( self.conversion_service: ConversionService = ConversionService(priority=_priority) self.regeneration_service: RegenerationService = RegenerationService(priority=_priority) self.export_service: ExportService = ExportService(priority=_priority) + self.render_service: SongRenderService = SongRenderService(priority=_priority) self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) @@ -279,6 +284,14 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self.render_window: GUIRenderWindow = GUIRenderWindow( + layout=self.layout.settings, + path_colors=self.layout.general.colors.paths, + language_manager=self.language_manager, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + status_bar=self.status_bar, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -466,6 +479,23 @@ def __init__( language_manager=self.language_manager, ) + self._render_logic = SongRenderLogic( + self.project_controller, + self.config_manager, + self.session_manager, + self.render_service, + language_manager=self.language_manager, + is_operation_active=self._is_operation_active, + ) + + self._render_coordinator = SongRenderCoordinator( + self._render_logic, + window=self.render_window, + dialogs=self.dialogs, + language_manager=self.language_manager, + on_activity_changed=self._on_render_activity_changed, + ) + self._shell = ApplicationShell( session_manager=self.session_manager, language_manager=self.language_manager, @@ -554,6 +584,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: save_project_as=self._project_coordinator.save_as_dialog, project_properties=self._open_project_properties, export_project=self._project_coordinator.export_project_dialog, + render_song=self._render_coordinator.open, close_project=self._project_coordinator.close_with_confirmation, exit=self._on_close, undo=self._sequencer_tab.undo, @@ -663,6 +694,7 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, play_label=self.language_manager["global.menu.label.item_playback_play"], @@ -701,6 +733,7 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, play_label=self._playback_router.play_label, @@ -809,14 +842,30 @@ def _is_converter_panel_visible(self) -> bool: return self._main_tab.is_converter_panel_visible() def _is_operation_active(self) -> bool: - return self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating() + return ( + self._main_tab.is_converter_active() + or self._instructions_tab.is_library_generating() + or self._render_coordinator.is_active + ) def _refresh_busy_state(self) -> None: - """Re-evaluate the reconstruct and generate-library buttons whenever a conversion or library - generation starts or finishes, keeping the two long operations mutually exclusive. Each panel - reads the live ``_is_operation_active`` state for itself; this only nudges them to - re-apply, so the busy truth lives in one place.""" + """Re-evaluate the reconstruct and generate-library buttons whenever a conversion, library + generation or render starts or finishes, keeping the long operations mutually exclusive. Each + panel reads the live ``_is_operation_active`` state for itself; this only nudges them to + re-apply, so the busy truth lives in one place. The menu follows the same edge, since what + greys an entry offering another such operation is one already running.""" self._instructions_tab.refresh_generate_button() + self._update_menu() + + def _on_render_activity_changed(self) -> None: + """Follows a render claiming the application and handing it back. + + What a render occupies is the same ground a conversion or a library generation occupies, + so its edges reach the same busy state — the action buttons of each tab, the converter's + own view, and the menu entries that would start another exclusive operation. + """ + self._refresh_busy_state() + self._main_tab.refresh_converter_view() def _on_library_operation_changed(self) -> None: """Responds to a library generation starting or finishing: refreshes the cross-tab action @@ -1361,6 +1410,7 @@ def _is_project_open(self) -> bool: return self.project_controller.is_open def _exit_application(self) -> None: + self._render_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() @@ -1417,6 +1467,7 @@ def run(self) -> None: except KeyboardInterrupt: return finally: + self._render_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index d2c524a0..5dfca380 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -67,6 +67,7 @@ class MenuElements(AbstractElement): GROUP_FILE_EXPORT = "group_file_export" ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker" ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase" + ITEM_FILE_RENDER_SONG = "item_file_render_song" ITEM_FILE_CLOSE_PROJECT = "item_file_close_project" ITEM_FILE_EXIT = "item_file_exit" GROUP_EDIT = "group_edit" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 25338b7e..6c9943c0 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -41,6 +41,7 @@ class KeybindingActionElements(AbstractElement): PROJECT_PROPERTIES = "project_properties" EXPORT_PROJECT_FAMITRACKER = "export_project_famitracker" EXPORT_PROJECT_BITPHASE = "export_project_bitphase" + RENDER_SONG = "render_song" CLOSE_PROJECT = "close_project" EXIT = "exit" UNDO = "undo" diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py new file mode 100644 index 00000000..b2ed163a --- /dev/null +++ b/src/sampletones_application/coordinators/render.py @@ -0,0 +1,170 @@ +from functools import partial +from pathlib import Path +from typing import Dict, Optional, Tuple + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow +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.frame import FrameCallbackManager +from sampletones_application.view_model.shared.render import SongRenderViewModel +from sampletones_core.audio.writers import AudioFormat, capability_of +from sampletones_shared.types.callback import VoidCallback + + +class SongRenderCoordinator: + """Owns writing the song to an audio file from the reader's side: the dialog, the destination + it is asked for, and the report a finished render makes. + + The logic holds the render itself, so what is orchestrated here is the screen: the window + opens over the settings the logic offers and follows every view it emits, the destination is + asked for through the OS dialog, and each outcome leaves the window and raises the dialog that + reports it. + + A render claims the application for as long as its dialog stands, so every way out passes + through one close, which releases the claim and tells the application to read it again. + """ + + def __init__( + self, + render_logic: SongRenderLogic, + *, + window: GUIRenderWindow, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + on_activity_changed: VoidCallback, + ) -> None: + self._logic = render_logic + self._window = window + self._dialogs = dialogs + self._on_activity_changed = on_activity_changed + self._view_model: Optional[SongRenderViewModel] = None + self._window_open = False + + self._title_destination = language_manager["settings.render.title.destination_dialog"] + self._title_rendered = language_manager["settings.render.title.rendered"] + self._msg_rendered = language_manager["settings.render.message.rendered"] + self._msg_failed = language_manager["settings.render.message.render_failed"] + self._filter_names: Dict[AudioFormat, str] = { + AudioFormat.WAVE: language_manager["global.dialog.filter.wave"], + AudioFormat.MP3: language_manager["global.dialog.filter.mp3"], + } + + self._logic.on_view_changed = self._on_view_changed + self._logic.on_choose_destination = self._choose_destination + self._logic.on_success = self._on_success + self._logic.on_error = self._on_error + self._logic.on_cancelled = self._on_cancelled + + self._window.on_settings_changed = self._logic.apply + self._window.on_browse = self._logic.request_destination + self._window.on_render = self._logic.start + self._window.on_cancel = self._logic.cancel + self._window.on_close = self._close + + @property + def is_active(self) -> bool: + """A render occupies the application from the dialog opening until it closes.""" + return self._logic.is_active + + def open(self) -> None: + """Offers the render settings for the open song, over the document as it stands now.""" + if not self._logic.open(): + return + + self._window_open = True + self._window.open(self._require_view_model()) + self._on_activity_changed() + + def cleanup(self) -> None: + """Winds a running render down for application exit.""" + self._logic.cleanup() + + def _on_view_changed(self, view_model: SongRenderViewModel) -> None: + """Keeps the open window standing at where the render has got to.""" + self._view_model = view_model + if self._window_open: + self._window.update_view(view_model) + + def _choose_destination( + self, + destination: Path, + audio_format: AudioFormat, + ) -> None: + """Asks for the file the render writes, starting from the one the dialog stands at.""" + filepath = save_file_dialog( + title=self._title_destination, + initial_directory=destination.parent, + default_filename=destination.name, + filters=self._destination_filters(audio_format), + ) + + self._set_destination(filepath) + + @ignore_none_path + def _set_destination(self, filepath: Path) -> None: + self._logic.set_destination(filepath) + + def _destination_filters(self, audio_format: AudioFormat) -> Tuple[FileFilter, ...]: + """The type a destination is offered under: the container the dialog stands at. + + The format is chosen in the dialog itself, so the file type follows it and a name typed + without an extension takes the one that container is written under. + """ + return ( + FileFilter.for_extensions( + self._filter_names[audio_format], + [capability_of(audio_format).extension], + ), + ) + + def _on_success(self, destination: Path) -> None: + """Reports the file a finished render wrote, as a path that opens in the file manager.""" + self._close() + self._present( + partial( + self._dialogs.show_message_with_path, + self._title_rendered, + self._msg_rendered, + destination, + ) + ) + + def _on_error(self, exception: Exception) -> None: + """Reports what a render failed on, leaving the destination as it was.""" + self._close() + self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) + + def _on_cancelled(self) -> None: + """Closes the dialog of a render that was stopped, which leaves no file to report.""" + self._close() + + def _close(self) -> None: + """Takes the dialog off screen and hands the application back.""" + self._window_open = False + self._view_model = None + self._window.hide() + self._logic.close() + self._on_activity_changed() + + def _present(self, raise_dialog: VoidCallback) -> None: + """Raises ``raise_dialog`` once the frame the window left the screen in has finished. + + The render window is modal and DearPyGui carries one modal at a time, so a report waits + for the frame that draws the screen without it and opens onto a clear screen. + """ + FrameCallbackManager.set_frame_callback(raise_dialog) + + def _require_view_model(self) -> SongRenderViewModel: + """The render the dialog opens on. + + Raises: + SystemError: when the window is raised before the logic offers a view. + """ + if self._view_model is None: + raise SystemError("The render window is opened over the view the logic emits") + + return self._view_model diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 9d156936..e74b9bba 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -71,6 +71,7 @@ class ShortcutBindings: save_project_as: Callback project_properties: Callback export_project: Callable[[TrackerFormat], None] + render_song: Callback close_project: Callback exit: Callback undo: Callback @@ -216,6 +217,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.SAVE_PROJECT: bindings.save_project, ShortcutId.SAVE_PROJECT_AS: bindings.save_project_as, ShortcutId.PROJECT_PROPERTIES: bindings.project_properties, + ShortcutId.RENDER_SONG: bindings.render_song, ShortcutId.CLOSE_PROJECT: bindings.close_project, ShortcutId.EXIT: bindings.exit, ShortcutId.UNDO: bindings.undo, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 6d6065d8..7cd592fb 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -416,6 +416,12 @@ Widget.MENU, "item_file_export", ) +TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_file_render_song", +) TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index c1e06fdb..6b63ed91 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -25,6 +25,7 @@ TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_OPEN_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, + TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT_AS, TAG_GLOBAL_MENU_ITEM_PLAYBACK_AUTOPLAY, @@ -203,6 +204,12 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: enabled=state.project_open, ) self._create_project_export_menu(state) + self._shortcut_manager.add_menu_item( + ShortcutId.RENDER_SONG, + tag=TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, + label=self._label(MenuElements.ITEM_FILE_RENDER_SONG), + enabled=state.render_enabled, + ) dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.CLOSE_PROJECT, @@ -513,6 +520,10 @@ def update(self, state: MenuBarViewModel) -> None: for project_item_tag in PROJECT_ITEM_TAGS: dpg_configure_item(project_item_tag, enabled=state.project_open) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, + enabled=state.render_enabled, + ) dpg_configure_item( TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, enabled=state.undo_enabled, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index aa2adaf8..f8298e9a 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -47,6 +47,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: PROJECT_PROPERTIES = ("ProjectProperties", ShortcutCategory.APPLICATION) EXPORT_PROJECT_FAMITRACKER = ("ExportProjectFamiTracker", ShortcutCategory.APPLICATION) EXPORT_PROJECT_BITPHASE = ("ExportProjectBitphase", ShortcutCategory.APPLICATION) + RENDER_SONG = ("RenderSong", ShortcutCategory.APPLICATION) CLOSE_PROJECT = ("CloseProject", ShortcutCategory.APPLICATION) EXIT = ("Exit", ShortcutCategory.APPLICATION) UNDO = ("Undo", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_application/view_model/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py index 04bb3798..b14d4d15 100644 --- a/src/sampletones_application/view_model/shared/menu.py +++ b/src/sampletones_application/view_model/shared/menu.py @@ -7,6 +7,7 @@ class MenuBarViewModel(BaseModel, frozen=True): channels: SequencerChannelsViewModel project_open: bool + operation_active: bool reconstruction_loaded: bool reconstruction_saveable: bool reconstruction_in_project: bool @@ -35,6 +36,11 @@ def undo_enabled(self) -> bool: def redo_enabled(self) -> bool: return self.project_open and self.can_redo + @property + def render_enabled(self) -> bool: + """Rendering the song needs a song to render, while the application is free to run one.""" + return self.project_open and not self.operation_active + @property def add_to_sequencer_enabled(self) -> bool: """Adding the loaded reconstruction needs an open project that does not already hold it.""" diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 140c84e3..79a9d406 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Alt+P"} ExportProjectFamiTracker: {combination: "Ctrl+M"} ExportProjectBitphase: {combination: "Ctrl+B"} + RenderSong: {combination: "Ctrl+Shift+E"} CloseProject: {combination: "Ctrl+W"} Exit: {combination: "Alt+F4"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 175a1dfc..09648678 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Cmd+Alt+P"} ExportProjectFamiTracker: {combination: "Cmd+M"} ExportProjectBitphase: {combination: "Cmd+B"} + RenderSong: {combination: "Cmd+Shift+E"} CloseProject: {combination: "Cmd+W"} Exit: {combination: "Cmd+Q"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5eeb09a1..5909684e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -55,6 +55,7 @@ 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" +global.dialog.filter.mp3: "MP3 audio" # Global — Dialog messages global.dialog.message.tree_no_results: "No results found." @@ -165,6 +166,7 @@ global.menu.label.item_file_project_properties: "Project properties..." 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_render_song: "Render song..." global.menu.label.item_file_close_project: "Close project" global.menu.label.item_file_exit: "Exit" global.menu.label.group_edit: "Edit" @@ -662,6 +664,8 @@ settings.display.message.countdown: "Keep the window the way it looks now? It go settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" settings.render.title.window_title: "Render song" +settings.render.title.destination_dialog: "Save rendered song" +settings.render.title.rendered: "Song rendered" settings.render.label.format: "Format" settings.render.label.sample_rate: "Sample rate" settings.render.label.depth: "Bit depth" @@ -686,6 +690,8 @@ settings.render.message.status_cancelling: "Stopping the render..." settings.render.message.status_cancelled: "Render cancelled." settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." +settings.render.message.rendered: "The song was rendered successfully." +settings.render.message.render_failed: "Failed to render the song." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" @@ -716,6 +722,7 @@ settings.keybindings.label.save_project_as: "Save project as" settings.keybindings.label.project_properties: "Project properties" settings.keybindings.label.export_project_famitracker: "Export project to FamiTracker" settings.keybindings.label.export_project_bitphase: "Export project to Bitphase" +settings.keybindings.label.render_song: "Render song to an audio file" settings.keybindings.label.close_project: "Close project" settings.keybindings.label.exit: "Exit" settings.keybindings.label.undo: "Undo" diff --git a/tests/suite/render.py b/tests/suite/render.py new file mode 100644 index 00000000..5831f05f --- /dev/null +++ b/tests/suite/render.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List, Optional + +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_application.services.render.result import RenderResult +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceSuccess, +) +from sampletones_core.audio.writers import AudioOutputSpec + + +@dataclass(frozen=True) +class RenderRequest: + synthesizer: RowSynthesizer + destination: Path + spec: AudioOutputSpec + normalize: bool + total_samples: int + + +class FakeRenderService: + """The render service as the logic drives it, holding what it was asked to render. + + Results are delivered through the handler the logic subscribes, so a test walks a render the + way the worker reports one. + """ + + def __init__(self, *, accepts: bool = True) -> None: + self.accepts = accepts + self.requests: List[RenderRequest] = [] + self.cancels: int = 0 + self.shutdowns: int = 0 + self.running: bool = False + self._handler: Optional[Callable[[RenderResult], None]] = None + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: + self._handler = handler + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + if not self.accepts: + return False + + self.requests.append( + RenderRequest( + synthesizer=synthesizer, + destination=destination, + spec=spec, + normalize=normalize, + total_samples=total_samples, + ) + ) + self.running = True + return True + + def cancel(self) -> None: + self.cancels += 1 + + def is_running(self) -> bool: + return self.running + + def shutdown(self) -> None: + self.shutdowns += 1 + + def emit(self, result: RenderResult) -> None: + assert self._handler is not None, "The logic subscribes to the service it is given" + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self._handler(result) + + @property + def request(self) -> RenderRequest: + assert self.requests, "A render was expected to start" + return self.requests[-1] diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py new file mode 100644 index 00000000..8e642d44 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/test_render.py @@ -0,0 +1,361 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators import render as render_module +from sampletones_application.coordinators.render import SongRenderCoordinator +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceSuccess, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.view_model.shared.render import ( + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioFormat +from sampletones_core.configs import Config +from sampletones_shared.types.callback import VoidCallback +from tests.suite.language import FakeLanguageManager +from tests.suite.render import FakeRenderService + +AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") +PROJECT_NAME: Final[str] = "chiptune" +CHOSEN: Final[Path] = Path("/home/user/renders/take one.wav") + + +class _WindowRecorder: + """Stands in for the render dialog, holding what it was told to show.""" + + def __init__(self) -> None: + self.view_models: List[SongRenderViewModel] = [] + self.visible = False + self.hides = 0 + self.on_settings_changed: Any = None + self.on_browse: Any = None + self.on_render: Any = None + self.on_cancel: Any = None + self.on_close: Any = None + + def open(self, view_model: SongRenderViewModel) -> None: + self.visible = True + self.view_models.append(view_model) + + def update_view(self, view_model: SongRenderViewModel) -> None: + self.view_models.append(view_model) + + def hide(self) -> None: + self.hides += 1 + self.visible = False + + @property + def view(self) -> SongRenderViewModel: + assert self.view_models, "A view was expected to reach the window" + return self.view_models[-1] + + +class _DialogsRecorder: + def __init__(self) -> None: + self.paths: List[Dict[str, Any]] = [] + self.errors: List[Dict[str, Any]] = [] + + def show_message_with_path(self, title: str, message: str, path: Path) -> None: + self.paths.append({"title": title, "message": message, "path": path}) + + def show_error(self, exception: Exception, message: Optional[str] = None) -> None: + self.errors.append({"exception": exception, "message": message}) + + +class _SaveDialogRecorder: + """The OS save dialog as the coordinator asks it, answering with a stated path.""" + + def __init__(self) -> None: + self.answer: Optional[Path] = CHOSEN + self.requests: List[Dict[str, Any]] = [] + + def __call__(self, **kwargs: Any) -> Optional[Path]: + self.requests.append(kwargs) + return self.answer + + @property + def filters(self) -> List[FileFilter]: + assert self.requests, "A destination was expected to be asked for" + return list(self.requests[-1]["filters"]) + + +class RenderFixture: + """The coordinator over a real render logic, a recording service, and a recorded screen. + + The frame the report waits for is taken as passing when a test asks for it, so the hand-off + from the window to the dialog that reports an outcome is walked one step at a time. + """ + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + *, + operation_active: bool = False, + ) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY + self.service = FakeRenderService() + self.logic = SongRenderLogic( + self.controller, + MagicMock(config=Config()), + self.session_manager, + self.service, + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + is_operation_active=lambda: operation_active, + ) + + self.window = _WindowRecorder() + self.dialogs = _DialogsRecorder() + self.save_dialog = _SaveDialogRecorder() + self.activity = 0 + self.pending: List[VoidCallback] = [] + + monkeypatch.setattr(render_module, "save_file_dialog", self.save_dialog) + monkeypatch.setattr( + render_module.FrameCallbackManager, + "set_frame_callback", + lambda callback, frame_count=1: self.pending.append(callback), + ) + + self.coordinator = SongRenderCoordinator( + self.logic, + window=self.window, # type: ignore[arg-type] + dialogs=self.dialogs, # type: ignore[arg-type] + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + on_activity_changed=self._on_activity_changed, + ) + + def _on_activity_changed(self) -> None: + self.activity += 1 + + def open(self) -> None: + self.coordinator.open() + + def edit(self, settings: SongRenderSettings) -> None: + self.window.on_settings_changed(settings) + + def browse(self) -> None: + self.window.on_browse() + + def start(self) -> None: + self.window.on_render() + + def stop(self) -> None: + self.window.on_cancel() + + def close(self) -> None: + self.window.on_close() + + def advance_frame(self) -> None: + """Runs what was waiting for the frame the window left the screen in.""" + pending = self.pending + self.pending = [] + for callback in pending: + callback() + + +@pytest.fixture +def render(monkeypatch: pytest.MonkeyPatch) -> RenderFixture: + return RenderFixture(monkeypatch) + + +class TestOfferingTheRender: + def test_the_dialog_opens_over_the_render_being_set_up(self, render: RenderFixture) -> None: + render.open() + + assert render.window.visible + assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + def test_opening_claims_the_application(self, render: RenderFixture) -> None: + render.open() + + assert render.coordinator.is_active + assert render.activity == 1 + + def test_another_exclusive_operation_leaves_the_dialog_closed( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + render = RenderFixture(monkeypatch, operation_active=True) + + render.open() + + assert not render.window.visible + assert not render.window.view_models + assert not render.activity + + def test_closing_the_setup_hands_the_application_back(self, render: RenderFixture) -> None: + render.open() + + render.close() + + assert render.window.hides == 1 + assert not render.coordinator.is_active + assert render.activity == 2 + + +class TestTheEditsTheDialogReports: + def test_an_edit_comes_back_reconciled(self, render: RenderFixture) -> None: + render.open() + + render.edit(render.window.view.settings.with_format(AudioFormat.MP3)) + + assert render.window.view.spec.audio_format == AudioFormat.MP3 + assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3" + + def test_the_running_render_reaches_the_window(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + + assert render.window.view.progress == 1.0 + + +class TestAskingForTheDestination: + def test_the_file_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None: + render.open() + + render.browse() + + request = render.save_dialog.requests[-1] + assert request["initial_directory"] == AUDIO_DIRECTORY + assert request["default_filename"] == f"{PROJECT_NAME}.wav" + + def test_the_type_offered_is_the_container_standing(self, render: RenderFixture) -> None: + render.open() + render.edit(render.window.view.settings.with_format(AudioFormat.MP3)) + + render.browse() + + assert [file_filter.extensions for file_filter in render.save_dialog.filters] == [(".mp3",)] + + def test_a_chosen_file_becomes_the_one_the_render_writes(self, render: RenderFixture) -> None: + render.open() + + render.browse() + + assert render.window.view.destination == CHOSEN + render.session_manager.set_audio_path.assert_called_once_with(CHOSEN) + + def test_a_dismissed_dialog_leaves_the_file_alone(self, render: RenderFixture) -> None: + render.open() + render.save_dialog.answer = None + standing = render.window.view.destination + + render.browse() + + assert render.window.view.destination == standing + + +class TestDrivingTheRender: + def test_the_start_reaches_the_service(self, render: RenderFixture) -> None: + render.open() + + render.start() + + assert render.service.request.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + def test_the_stop_reaches_the_service(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.stop() + + assert render.service.cancels == 1 + + def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.coordinator.cleanup() + + assert render.service.shutdowns == 1 + + +class TestReportingTheOutcome: + def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + render.advance_frame() + + assert render.dialogs.paths == [ + { + "title": "settings.render.title.rendered", + "message": "settings.render.message.rendered", + "path": CHOSEN, + } + ] + + def test_the_report_waits_for_the_screen_the_window_left(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + + assert render.window.hides == 1 + assert not render.dialogs.paths + + def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: + render.open() + render.start() + failure = OSError("no room on the device") + + render.service.emit(ServiceError(exception=failure)) + render.advance_frame() + + assert render.dialogs.errors == [ + { + "exception": failure, + "message": "settings.render.message.render_failed", + } + ] + + def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) -> None: + render.open() + render.start() + render.stop() + + render.service.emit(ServiceCancelled()) + render.advance_frame() + + assert render.window.hides == 1 + assert not render.dialogs.paths + assert not render.dialogs.errors + + @pytest.mark.parametrize( + "outcome", + [ + ServiceSuccess(value=CHOSEN), + ServiceError(exception=OSError("no room on the device")), + ServiceCancelled(), + ], + ids=["completed", "failed", "cancelled"], + ) + def test_every_outcome_hands_the_application_back( + self, + render: RenderFixture, + outcome: Any, + ) -> None: + render.open() + render.start() + + render.service.emit(outcome) + + assert not render.coordinator.is_active + assert not render.window.visible diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index b656454c..df2ee241 100644 --- a/tests/unit/sampletones_application/logic/render/test_logic.py +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -1,6 +1,5 @@ -from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, List, Optional +from typing import Final, List from unittest.mock import MagicMock import pytest @@ -12,7 +11,7 @@ RowSynthesizer, SongLength, ) -from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.result import RenderStage from sampletones_application.services.result import ( ServiceCancelled, ServiceError, @@ -23,86 +22,16 @@ RenderPhase, SongRenderViewModel, ) -from sampletones_core.audio.writers import AudioFormat, AudioOutputSpec +from sampletones_core.audio.writers import AudioFormat from sampletones_core.configs import Config from tests.suite.language import FakeLanguageManager +from tests.suite.render import FakeRenderService AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") PROJECT_NAME: Final[str] = "chiptune" LOW_RATE: Final[int] = 8000 -@dataclass(frozen=True) -class RenderRequest: - synthesizer: RowSynthesizer - destination: Path - spec: AudioOutputSpec - normalize: bool - total_samples: int - - -class FakeRenderService: - """The render service as the logic drives it, holding what it was asked to render. - - Results are delivered through the handler the logic subscribes, so a test walks a render the - way the worker reports one. - """ - - def __init__(self, *, accepts: bool = True) -> None: - self.accepts = accepts - self.requests: List[RenderRequest] = [] - self.cancels: int = 0 - self.shutdowns: int = 0 - self.running: bool = False - self._handler: Optional[Callable[[RenderResult], None]] = None - - def subscribe(self, handler: Callable[[RenderResult], None]) -> None: - self._handler = handler - - def start( - self, - *, - synthesizer: RowSynthesizer, - destination: Path, - spec: AudioOutputSpec, - normalize: bool, - total_samples: int, - ) -> bool: - if not self.accepts: - return False - - self.requests.append( - RenderRequest( - synthesizer=synthesizer, - destination=destination, - spec=spec, - normalize=normalize, - total_samples=total_samples, - ) - ) - self.running = True - return True - - def cancel(self) -> None: - self.cancels += 1 - - def is_running(self) -> bool: - return self.running - - def shutdown(self) -> None: - self.shutdowns += 1 - - def emit(self, result: RenderResult) -> None: - assert self._handler is not None, "The logic subscribes to the service it is given" - self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) - self._handler(result) - - @property - def request(self) -> RenderRequest: - assert self.requests, "A render was expected to start" - return self.requests[-1] - - class RenderFixture: """A render logic wired to a real project and a service that records what it is asked for.""" diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py index d2835987..5449f8ee 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -3,7 +3,12 @@ from sampletones_application.application import Application -def _application(*, converter_running: bool = False, library_generating: bool = False) -> Application: +def _application( + *, + converter_running: bool = False, + library_generating: bool = False, + rendering: bool = False, +) -> Application: """An application with only the attributes the busy methods touch, bypassing the full composition root constructor.""" application = Application.__new__(Application) @@ -12,12 +17,15 @@ def _application(*, converter_running: bool = False, library_generating: bool = application._instructions_tab = MagicMock() application._instructions_tab.is_library_generating.return_value = library_generating application._reconstructions_tab = MagicMock() + application._render_coordinator = MagicMock() + application._render_coordinator.is_active = rendering + application._update_menu = MagicMock() return application class TestBusySourceOfTruth: - """``_is_operation_active`` is the single busy authority: a conversion or a library - generation each make it true, and only an idle pair makes it false.""" + """``_is_operation_active`` is the single busy authority: a conversion, a library generation or + a render each make it true, and only an idle set makes it false.""" def test_busy_while_converter_runs(self) -> None: assert _application(converter_running=True)._is_operation_active() is True @@ -25,15 +33,29 @@ def test_busy_while_converter_runs(self) -> None: def test_busy_while_library_generates(self) -> None: assert _application(library_generating=True)._is_operation_active() is True - def test_idle_when_neither_runs(self) -> None: + def test_busy_while_song_renders(self) -> None: + assert _application(rendering=True)._is_operation_active() is True + + def test_idle_when_none_runs(self) -> None: assert _application()._is_operation_active() is False class TestBusyRefreshPropagation: - """A busy-state change nudges both tabs to re-evaluate their action buttons; each panel reads the - live busy authority for itself, so no value is pushed.""" + """A busy-state change nudges both tabs to re-evaluate their action buttons and the menu to + re-read what may start another such operation; each reads the live busy authority for itself, + so no value is pushed.""" def test_refresh_nudges_both_tabs(self) -> None: application = _application() application._refresh_busy_state() application._instructions_tab.refresh_generate_button.assert_called_once_with() + + def test_refresh_reaches_the_menu(self) -> None: + application = _application() + application._refresh_busy_state() + application._update_menu.assert_called_once_with() + + def test_a_render_edge_refreshes_the_converter_view(self) -> None: + application = _application() + application._on_render_activity_changed() + application._main_tab.refresh_converter_view.assert_called_once_with() diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 4dd228e4..1f13cf65 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -89,6 +89,7 @@ def _state( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=False, can_redo=False, play_label="Play", diff --git a/tests/unit/sampletones_application/view_model/shared/test_menu.py b/tests/unit/sampletones_application/view_model/shared/test_menu.py index 6341a05c..0b0dd2e3 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -67,6 +67,7 @@ def test_enablement_follows_project_and_history_state( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=case.can_undo, can_redo=case.can_redo, play_label="Play", @@ -103,6 +104,7 @@ def test_save_flag_is_carried_verbatim( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=False, can_redo=False, play_label="Play", From dbac510c2d2a198ef802efb3d28a25b58a7a037a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 10:38:36 +0200 Subject: [PATCH 08/10] Documented: song rendering to an audio file --- CHANGELOG.md | 2 ++ docs/development/dependencies.md | 21 +++++++++++++++ docs/development/playback.md | 44 ++++++++++++++++++++++++++++++-- docs/guide/interface.md | 7 ++++- docs/guide/sequencer.md | 26 +++++++++++++++++++ docs/index.md | 4 +-- 6 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51593f8d..c2950592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * Theme selector * Keybinding settings * Bumped the reconstruction data-version to `2.1`. +* Improved Sequencer module playback. +* Added song export to WAV/MP3. ## v0.3.0 [2026-07-31] diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index a64068f6..7d553cb0 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -24,6 +24,27 @@ Playback goes through PortAudio, reached with the `pyaudio` package. PyPI carrie Compiling on macOS also depends on the interpreter's architecture. The python.org installer ships a universal2 build, which compiles extensions for both Apple Silicon and Intel, while Homebrew's `libportaudio` carries the machine's own architecture. Pinning `ARCHFLAGS` to `uname -m` settles it on the native one: `make setup` sets it directly, and the CI workflows take it from `scripts/macos/build/build_env.sh`, which reports it as a `KEY=VALUE` line alongside the PortAudio prefix for a Homebrew installed outside its usual place. +## Audio rendering + +Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a +prebuilt libsndfile 1.2.2 for every supported platform, so the encoders come with the package and +need nothing installed alongside them. + +Which formats an installation writes is asked of the library at runtime, because libsndfile is built +with a codec set that varies by platform and packaging — the MP3 encoder in particular arrived in +1.2.0 and is present where it was compiled in. The chooser offers the formats the library reports, +so what a user is shown describes the machine it is running on. + +| Format | Sample rates | Quality | +| --- | --- | --- | +| WAV | 8000, 16000, 22050, 44100, 48000, 96000, 192000 Hz | 8, 16, 24 or 32-bit PCM, or 32-bit float | +| MP3 | 8000, 16000, 22050, 44100, 48000 Hz | a bitrate from the ladder its MPEG version defines | + +The bitrates on offer narrow with the sample rate: up to 320 kbps at 44100 and 48000 Hz, 160 kbps at +16000 and 22050 Hz, and 64 kbps at 8000 Hz. libsndfile takes MP3 quality as a compression level +between 0 and 1 and turns it into a rung on that ladder, so a bitrate is reached through the level +its rate maps it to, measured per rate and held in `sampletones_core/audio/writers/bitrate.py`. + ## 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. diff --git a/docs/development/playback.md b/docs/development/playback.md index 96892f3f..ebfb6200 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -25,14 +25,17 @@ a control over what is heard. The contracts here bind every tab and every player same behaviour. 5. **Listening choices stay out of the document.** What the user chooses to hear is session state; what the project holds is the whole song. Saving, export, rendering, and history read the - document, so each of them works on the full song whatever the user is listening to. + document, so each of them works on the full song whatever the user is listening to. A render + reads the document as it stood when it was asked for: every channel sounding, at unity gain, + played through once. 6. **Live state is pulled while sound is produced.** A player reads the settings that shape its sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a listening control take effect inside the sound already playing. 7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from the project's tempo and metre together with the row's place in the pattern, so it is a function of position: the same row lasts the same time however playback reached it, and a module exported - from the song can state the same figures. + from the song can state the same figures. The integer tick counts the groove places *are* the + tempo, so a render realises them exactly at every rate it offers. ## Two kinds of sound @@ -159,6 +162,37 @@ sequencer distinguishes a history restore from a document transition. And the mu listening session, so opening, creating, or closing a document starts a fresh one with every channel audible. +## Rendering the song to a file + +A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer` +serves both: the player drives it to feed the device, the render drives it to feed a file writer. +The synthesis is therefore written once, and the file and the playback agree on what the song +sounds like by construction. + +Two things differ between them, and each is stated by whoever asks for the audio. The **document** +is a seam: a kernel reads its project through `ProjectSource`, which the live controller satisfies +for playback and a frozen `ProjectSnapshot` satisfies for a render — so the player follows every +edit as the buffer drains (principle 6), while a render describes one state of the document however +the project moves on. The **rate** is the consumer's: the device for playback, the chosen output +format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a +file is written at the rate its engine ran at. + +The song's exact length follows from the timing model before a sample is rendered: the order's +length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is +what the progress bar counts against and what a finished file measures. + +Rendering is an exclusive operation (architecture principle 10). It occupies the application from +the moment its dialog opens until that dialog closes, and it joins the same busy authority as +conversion and library generation, so each of the three holds the others off and every surface +offering one reads a single answer. + +The write itself takes one pass, or two where the user asks for a normalised peak: the first pass +spills raw samples and discovers the peak, the second reads them back and encodes at the scale that +peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single +unit across both. A cancel is honoured between rows and between encoded blocks, and a render that +is stopped or fails clears the destination and the spill, so a result names a path where a finished +file stands. + ## Teardown The device is torn down once every source holding a stream has released it. A source that streams to @@ -196,6 +230,12 @@ terminating would reclaim. | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | | How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | | The song's render-ahead buffer | `services/song_player/` | +| The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) | +| The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) | +| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | +| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) | +| The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) | +| The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction players: it implements the same protocol and is arbitrated by the same rules. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index af07b27e..3d88b4e4 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -78,7 +78,12 @@ redo, **Reconstruction** for the current reconstruction and its exports, **Playback** for playing and for muting the sequencer's channels, **View** for settings and the window, and **Help** for **About**. -Two of them are easy to miss. **View ▸ Show advanced settings** reveals the extra +Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** +for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for +the sequencer's whole song, as a WAV or an MP3 — +[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers. + +Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback device, sample rate, and buffer size; these change what you hear, while the **Sample rate** and **NES frequency** on the **Main** tab change how audio is diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index acefed06..f9b974df 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -134,3 +134,29 @@ When the song is ready, **Export as FamiTracker module** (or **File ▸ Export FamiTracker module...**) writes the `.ftm`. See [FamiTracker export](../formats/famitracker.md) for what the module contains and the limits it respects. + +## Rendering to audio + +A module is for a tracker. To get a file anyone can play, use **File ▸ Render +song...** (`Ctrl+Shift+E`), which writes the whole song as audio. + +The dialog holds the choices: + +| Setting | What it does | +|---------|--------------| +| **Format** | **WAV** for the full-quality file, **MP3** for a smaller one | +| **Sample rate** | How many samples a second the file holds; 44100 Hz is the usual choice | +| **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has | +| **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it | +| **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was | +| **File** | Where it is written. **Browse...** opens the save dialog, and the folder you pick is offered again next time | + +**Length** tells you how long the file will be before you start. **Render** begins, +and a bar reports how far it has got; **Cancel** stops it and leaves the file +unwritten. When it finishes, _SampleToNES_ shows the file it wrote — click the path +to open its folder. + +A render takes the song itself, once through, with every channel sounding: muting +and **Loop song** are for listening and stay out of the file. It is one of the long +jobs that run alone, so the item is unavailable while a conversion or a library +generation is going, and those wait for a render in the same way. diff --git a/docs/index.md b/docs/index.md index 92a22ec4..f5383290 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward. - [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration. - [Getting started](guide/getting-started.md) — your first reconstruction and your first song. - [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus. -- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song and exporting a module. +- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio. - [Command line](guide/command-line.md) — running without the graphical interface. - [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses. - [Configuration](guide/configuration.md) — the settings you can change, and where. @@ -56,7 +56,7 @@ The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. -- [Playback](development/playback.md) — the audio transport shared by every view. +- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From bd6ef69c11676875ccab87ee30e1573594deac4f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 13:25:29 +0200 Subject: [PATCH 09/10] Added: destination path text --- docs/guide/interface.md | 6 +- docs/guide/sequencer.md | 2 +- .../ui/elements/path.py | 27 +++++ .../ui/panels/dialogs/render.py | 10 +- .../ui/panels/main/converter.py | 9 +- src/sampletones_config/lang/en.yaml | 1 + .../layout/settings/render.yaml | 2 +- .../ui/elements/test_path.py | 103 ++++++++++++++++++ 8 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/test_path.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 3d88b4e4..275784de 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -19,8 +19,10 @@ left, set up how the reconstruction is done in the centre, and click **Convert sample** (or **Convert directory** for a folder). The [instruction library](../concepts/instruction-library.md) for your settings is built automatically the first time it is needed, so you can convert straight away. -When a single file finishes, **Load** opens the result on the **Reconstructions** -tab; **Cancel** stops a run, and only one runs at a time. +While it runs, the panel names the file going in and where the result is going, and +clicking either path shows it in your file manager. When a single file finishes, +**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and +only one runs at a time. A few settings are worth knowing before you convert. Under **Reconstructor settings**, the **Generators** toggles choose which channels take part — at least diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index f9b974df..49361de5 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -149,7 +149,7 @@ The dialog holds the choices: | **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has | | **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it | | **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was | -| **File** | Where it is written. **Browse...** opens the save dialog, and the folder you pick is offered again next time | +| **File** | Where it is written. **Browse...** opens the save dialog, clicking the path shows where the file is going in your file manager, and the folder you pick is offered again next time | **Length** tells you how long the file will be before you start. **Render** begins, and a bar reports how far it has got; **Cancel** stops it and leaves the file diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index 0d29bbf4..0c394378 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -164,3 +164,30 @@ def get_path(self) -> Path: def destroy(self) -> None: dpg_delete_item(self.handler_tag) dpg_delete_item(self.tag) + + +class GUIDestinationPathText(GUIPathText): + """A path an operation is going to write to, which reveals the nearest place that stands. + + A destination names what the operation will leave behind, so it points into the filesystem + before anything is there. A click therefore reaches the destination itself once it is written, + and the closest directory on its way while it is still being described. + """ + + def _on_clicked(self) -> None: + standing = self._nearest_standing() + if standing is not None: + open_path_in_explorer(standing) + + def _nearest_standing(self) -> Optional[Path]: + if not self.path.name: + return None + + if self.path.exists(): + return self.path + + for directory in self.path.parents: + if directory.is_dir(): + return directory + + return None diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py index fad454d3..459494d2 100644 --- a/src/sampletones_application/ui/panels/dialogs/render.py +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -31,7 +31,7 @@ from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.path import GUIDestinationPathText from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop @@ -77,7 +77,7 @@ def __init__( self._path_colors = path_colors self._status_bar = status_bar self._view_model: Optional[SongRenderViewModel] = None - self._destination_text: Optional[GUIPathText] = None + self._destination_text: Optional[GUIDestinationPathText] = None self.on_settings_changed: Optional[SettingsCallback] = None self.on_browse: Optional[VoidCallback] = None @@ -92,7 +92,7 @@ def __init__( self._fmt_sample_rate = language_manager["settings.render.template.sample_rate"] self._fmt_bitrate = language_manager["settings.render.template.bitrate"] - self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] self._format_labels: Dict[AudioFormat, str] = { AudioFormat.WAVE: language_manager["settings.render.label.format_wave"], AudioFormat.MP3: language_manager["settings.render.label.format_mp3"], @@ -257,13 +257,13 @@ def _create_destination(self) -> None: parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, callback=self._request_destination, ) - self._destination_text = GUIPathText( + self._destination_text = GUIDestinationPathText( tag=TAG_SETTINGS_RENDER_PATH_DESTINATION, path=self._require_view_model().destination, parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, color=self._path_colors.default, hover_color=self._path_colors.hover, - status_message=self._msg_path, + status_message=self._msg_destination, font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 93853a45..a5de9f92 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -28,7 +28,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel -from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme @@ -57,7 +57,7 @@ def __init__( ) -> None: self._language_manager = language_manager self.input_path_text: Optional[GUIPathText] = None - self.output_path_text: Optional[GUIPathText] = None + self.output_path_text: Optional[GUIDestinationPathText] = None self._status_bar = status_bar self._action_button: Optional[GUIButton] = None self._theme_convert: Optional[Theme] = None @@ -69,6 +69,7 @@ def __init__( self._layout = layout self._path_colors = path_colors self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] self._msg_status_convert = language_manager["main.converter.message.status_convert"] self._status_action_message = self._msg_status_convert @@ -193,14 +194,14 @@ def _create_summary(self) -> None: font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) - self.output_path_text = GUIPathText( + self.output_path_text = GUIDestinationPathText( path=None, prefix=self._language_manager["main.converter.message.status_output_label"], tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, color=self._path_colors.default, hover_color=self._path_colors.hover, - status_message=self._msg_path, + status_message=self._msg_destination, font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5909684e..88cd8bc9 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -217,6 +217,7 @@ global.menu.label.tab_sequencer: "Sequencer" # Global — Status bar messages # ============================================================================= global.status.message.path: "Click to open path in file explorer." +global.status.message.destination: "Click to show the destination in file explorer." global.status.message.node_reconstruction_no_autoplay: "Double-click to open reconstruction. Right-click to open context menu." global.status.message.node_reconstruction: "Click to play reconstruction. Double-click to open reconstruction. Right-click to open context menu." global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu." diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml index 2f91551f..3703a5ca 100644 --- a/src/sampletones_config/layout/settings/render.yaml +++ b/src/sampletones_config/layout/settings/render.yaml @@ -1,3 +1,3 @@ window: - width: 480 + width: 540 height: 0 diff --git a/tests/unit/sampletones_application/ui/elements/test_path.py b/tests/unit/sampletones_application/ui/elements/test_path.py new file mode 100644 index 00000000..0d4c5d4e --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_path.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_application.ui.elements import path as path_module +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText + +FILENAME = "chiptune.wav" + + +def _path_text(path: Path) -> GUIPathText: + """A path text carrying only the path a click reads, bypassing the DearPyGui-dependent + constructor.""" + instance = GUIPathText.__new__(GUIPathText) + instance.path = path + return instance + + +def _destination_text(path: Path) -> GUIDestinationPathText: + instance = GUIDestinationPathText.__new__(GUIDestinationPathText) + instance.path = path + return instance + + +@pytest.fixture +def opened(monkeypatch: pytest.MonkeyPatch) -> List[Path]: + revealed: List[Path] = [] + monkeypatch.setattr(path_module, "open_path_in_explorer", revealed.append) + return revealed + + +class TestAPathThatStands: + """A path text shows what it points at, so a click reaches the file itself.""" + + def test_an_existing_file_is_revealed(self, tmp_path: Path, opened: List[Path]) -> None: + filepath = tmp_path / FILENAME + filepath.touch() + + _path_text(filepath)._on_clicked() + + assert opened == [filepath] + + def test_a_file_yet_to_be_written_reveals_nothing( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _path_text(tmp_path / FILENAME)._on_clicked() + + assert not opened + + +class TestADestination: + """A destination names what an operation will leave behind, so a click reaches the nearest place + that stands however far the operation has got.""" + + def test_the_directory_a_file_is_written_into_is_revealed( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _destination_text(tmp_path / FILENAME)._on_clicked() + + assert opened == [tmp_path] + + def test_a_written_file_is_revealed_itself( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + filepath = tmp_path / FILENAME + filepath.touch() + + _destination_text(filepath)._on_clicked() + + assert opened == [filepath] + + def test_a_written_directory_is_revealed_itself( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + directory = tmp_path / "renders" + directory.mkdir() + + _destination_text(directory)._on_clicked() + + assert opened == [directory] + + def test_a_directory_yet_to_be_created_falls_back_to_the_one_holding_it( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _destination_text(tmp_path / "renders" / "session" / FILENAME)._on_clicked() + + assert opened == [tmp_path] + + def test_an_empty_path_reveals_nothing(self, opened: List[Path]) -> None: + _destination_text(Path())._on_clicked() + + assert not opened From 05af9fc79e87389f75fd8edcfd47e59ded0fcf0d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 14:00:49 +0200 Subject: [PATCH 10/10] Fixed: song synthesis reading the output rate before there is a consumer --- docs/development/playback.md | 5 ++ .../playback/synthesizer/synthesizer.py | 40 ++++++++++--- .../sequencer/playback/test_synthesizer.py | 2 +- .../sequencer/playback/test_tick_clock.py | 59 ++++++++++++++++++- .../sampletones_application/test_startup.py | 29 ++++++++- 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index ebfb6200..4f33ace3 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -177,6 +177,11 @@ the project moves on. The **rate** is the consumer's: the device for playback, t format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a file is written at the rate its engine ran at. +A rate is therefore asked for once there is audio to take it, which is the first row a kernel +renders: a device has been chosen by the time playback starts, and a format by the time a render +does. A session on a machine offering no output device opens on that rule, and everything that +writes rather than sounds — editing, exporting a module, rendering to a file — works on it. + The song's exact length follows from the timing model before a sample is rendered: the order's length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is what the progress bar counts against and what a finished file measures. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 0ded782e..3035821e 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -46,9 +46,11 @@ class RowSynthesizer: audio runs at: the output device for live playback, the chosen format for a file. Reading it per row keeps the two in step, so a rendered second is a second wherever the audio goes. - Generators are held in a :class:`ChannelBank` built from ``config`` at the rates in force, - carrying timer state across rows for phase continuity within a sustained note. Triggering a - new note calls ``generator.reset()`` for a clean phase start. + Generators are held in a :class:`ChannelBank` built from ``config`` at the rates the first row + is rendered at, so the rate is asked for once there is audio to take it — a device is chosen by + the time playback starts, and a format by the time a render does. They carry timer state across + rows for phase continuity within a sustained note, and triggering a new note calls + ``generator.reset()`` for a clean phase start. ``active_channels`` reports which channels sound and is consulted once per channel per row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A @@ -65,12 +67,13 @@ def __init__( sample_rate: Callable[[], int], ) -> None: self._project_source = project_source + self._config = config self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() self._timing: SongTiming = SongTiming.from_project(project_source.project) self._groove: Groove = self._timing.groove() - self._channels = ChannelBank(config, self._current_rates()) + self._channels: Optional[ChannelBank] = None self._elapsed_ticks: int = 0 @property @@ -92,17 +95,18 @@ def set_position(self, order_position: int, row_index: int) -> None: def reset(self) -> None: self._elapsed_ticks = 0 - self._channels.reset() + if self._channels is not None: + self._channels.reset() def render_row(self) -> Tuple[np.ndarray, SongPosition]: project = self._project_source.project song = project.song self._position.wrap_overflow(song.rows_per_pattern) - self._channels.follow(self._current_rates()) + channels = self._bank() self._ensure_groove(project) frames = RowFrames.from_clock( - self._channels.clock, + channels.clock, elapsed_ticks=self._elapsed_ticks, ticks=self._groove.ticks[self._position.row_index], ) @@ -116,6 +120,7 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: project, song, frames, + channels, ) ) @@ -125,6 +130,22 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: return mixed, position_before + def _bank(self) -> ChannelBank: + """The channels the row about to be rendered sounds through, at the rates in force. + + Building them here is what lets a session start where nothing yet takes the audio: the rate + belongs to whoever consumes it, so it is asked for at the moment there is a consumer to + answer. Every later row follows the pair, so a device or a format changing underneath is + heard from the next row on. + """ + rates = self._current_rates() + if self._channels is None: + self._channels = ChannelBank(self._config, rates) + else: + self._channels.follow(rates) + + return self._channels + def _current_rates(self) -> EngineRates: return EngineRates.from_project( self._project_source.project, @@ -151,6 +172,7 @@ def _mix_channels( project: Project, song: Song, frames: RowFrames, + channels: ChannelBank, ) -> np.ndarray: mixed = silence(frames.total) for generator_name in GeneratorName.items(): @@ -159,6 +181,7 @@ def _mix_channels( project, song, frames, + channels, ) mixed += channel_audio @@ -170,8 +193,9 @@ def _render_channel( project: Project, song: Song, frames: RowFrames, + channels: ChannelBank, ) -> np.ndarray: - state = self._channels.state(generator_name) + state = channels.state(generator_name) row = self._resolve_row(generator_name, song) if row is not None: diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 71a2e6a7..cba1f808 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -837,10 +837,10 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) - pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) controller.set_nes_frequency(60) synthesizer.render_row() + pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60) controller.set_nes_frequency(30) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index c5c2cdf3..7d18bc32 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -1,5 +1,5 @@ from fractions import Fraction -from typing import Final, Tuple +from typing import Final, Optional, Tuple import numpy as np import pytest @@ -161,6 +161,63 @@ def test_a_rate_change_is_picked_up_on_the_next_row(self) -> None: assert difference < Fraction(1, UNEVEN_SAMPLE_RATE) +class _LateRate: + """The rate a machine with no output device reports: none, until a device is chosen.""" + + def __init__(self) -> None: + self.rate: Optional[int] = None + self.reads: int = 0 + + def __call__(self) -> int: + self.reads += 1 + if self.rate is None: + raise ValueError("No audio device selected") + + return self.rate + + +class TestTheRateIsAskedForWhenAudioIsTaken(BaseTestSuite): + """The rate belongs to whoever takes the audio, so it is asked for once there is audio to take. + + That is what lets a session come up on a machine where nothing can play it: the song is edited, + exported and rendered to a file all the same, and the first row sounds at the rate the consumer + that reached it reports. + """ + + def test_a_synthesizer_stands_where_no_rate_can_be_stated(self) -> None: + rate = _LateRate() + + synthesizer = RowSynthesizer( + make_controller(), + Config(), + active_channels=all_channels, + sample_rate=rate, + ) + synthesizer.set_position(0, 0) + synthesizer.reset() + + assert rate.reads == 0 + + def test_the_first_row_renders_at_the_rate_that_answers(self) -> None: + controller = make_controller() + rate = _LateRate() + synthesizer = RowSynthesizer( + controller, + Config(), + active_channels=all_channels, + sample_rate=rate, + ) + + rate.rate = UNEVEN_SAMPLE_RATE + rendered = len(synthesizer.render_row()[0]) + + clock = TickClock.from_parameters( + sample_rate=UNEVEN_SAMPLE_RATE, + nes_frequency=controller.project.settings.nes_frequency, + ) + assert rendered == clock.samples_at(_expected_ticks(controller)[0]) + + class TestChannelsFillTheRow(BaseTestSuite): """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index b6cc4525..ad01f313 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,4 +1,4 @@ -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from pathlib import Path from typing import Any, Callable, Dict, Final, Generator, List from unittest.mock import PropertyMock, patch @@ -67,6 +67,19 @@ def _display_patches() -> List[Any]: return display_patches +@contextmanager +def _no_audio_devices() -> Generator[None, None, None]: + """The machine a headless run comes up on: the backend reports no output device at all.""" + with ( + patch("pyaudio.PyAudio.get_device_count", return_value=0), + patch( + "pyaudio.PyAudio.get_default_output_device_info", + side_effect=OSError, + ), + ): + yield + + def _profile(directory: Path) -> UserProfile: """Starts the application on a profile of its own, in the state a first run finds. @@ -96,6 +109,20 @@ def test_initialises_without_error(self, tmp_path: Path) -> None: Application(profile=_profile(tmp_path)) + def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None: + """Editing a song, exporting a module and rendering to a file need no output device. + + The rate the audio is rendered at is the consumer's to state, so a machine offering no + device to play through still opens the window and everything that writes rather than + sounds works on it. + """ + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_no_audio_devices()) + + Application(profile=_profile(tmp_path)) + @pytest.fixture def app(tmp_path: Path) -> Generator[Any, Application, Any]: