Skip to content
3 changes: 3 additions & 0 deletions automation/boundaries/reveals.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class FileManagerStandIn:
application's own code runs and reaches them: the first names a file manager they stand for,
the file manager writes the path it was given to ``shown_log``, and ``xdg-open`` writes a folder
there and a file to ``opened_log``, one line each.

The programs are shell scripts, which Linux and macOS start by name from their ``#!`` line.
Windows starts a program by name from an ``.exe`` file.
"""

def __init__(
Expand Down
4 changes: 2 additions & 2 deletions docs/concepts/reconstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,8 @@ dividers. A bend counts divider steps from its own note, so each note's frames a
frame that reads nothing keeps a bend its own note read, and a new note starts from its own reading.

A frame makes no proposal where it rests, where the stem holding it leaves that channel out, where its
channel is not pitched (the noise channel's sixteen periods have no finer grid), or where its reading
falls below the confidence threshold. A conversion that bent no note records both bend dimensions as ones
channel is not pitched (the noise channel's sixteen periods have no finer grid), where the recording is
silent, or where its reading falls below the confidence threshold. A conversion that bent no note records both bend dimensions as ones
the channel governs, so it writes the instrument an unrefined run writes.

Each stem entry says which recordings are carried, and on which channels, in `bends`: a subset of the
Expand Down
6 changes: 6 additions & 0 deletions docs/development/application/render-thread.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across fr
continues. Every background result reaches UI state this way. Applying one to UI state directly from the
worker thread is forbidden.

A report of where a run stands crosses through `LatestDelivery` (`utils/callbacks/latest.py`). One delivery
waits in the queue at a time, and the drain that reaches it hands over the newest report filed, so a burst
of reports costs one handling. The delivery keeps the place its first report took, which keeps a result
posted after that report, such as the run's outcome, behind it. [Progress](../progress.md) gives the
principle.

A result carries what the landing installs. The regeneration worker renders every sound the
Reconstructions tab draws or plays: each channel's audio, the mix of what the reader hears, the mix of the
recordings heard, which the waveform draws as the original, and the envelopes each channel exports for the
Expand Down
1 change: 1 addition & 0 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Runs long operations (file conversion, waveform regeneration, export, playback s

- Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)` and `_emit(result)`.
- `_emit` posts the result to `CallbackQueue`, which puts every handler on the render thread (principle 6).
- `_emit` delivers every result it is given, which is how an outcome travels. `_report` carries where a run stands, and the newest report waiting supersedes the ones before it, so a subscriber draws a run once per drain however often the run reports.
- Result types are a tagged union, so a subscriber matches exhaustively. A long operation reports through `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError` and `ServiceCanceled`, and a service whose reports carry more, such as the playhead or the request they answer, declares a union of its own in its `result.py`.
- A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately. The generic contracts every service reports through are in `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws. See [`progress.md`](progress.md).
- A service imports nothing from `logic/`, `view_model/`, `ui/`, `coordinators/` or `config/`, which the boundary check holds.
Expand Down
25 changes: 25 additions & 0 deletions docs/development/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ Both carriers use it: `StageProgress` in the application services, and `JobRepor
`JobReporter` throttles on the worker side, so the line between processes carries only what a bar can be
redrawn at.

The render thread takes the newest report. A report states the whole of where a run stands, so a report
still waiting for the render loop is superseded by the next one. A run filing thousands of reports between
two frames, such as a library generation counting its instructions, is drawn once per drain, and the screen
keeps pace with the run. The outcome of the run arrives after the reports filed before it.
[The render thread](application/render-thread.md) describes the crossing.

### 4. A run reports in items when it has many, and in the part done of one item when it has one

An operation counts the items it is measured in: files, samples, jobs. Where it is measured in many items,
Expand Down Expand Up @@ -85,6 +91,15 @@ queue that reports travel up, and a flag that a withdrawal travels down. Both li
context the pool's workers run in. Each end reaches a task as an ordinary value it is built with, so a
worker started as a fresh interpreter reconnects on its own. That makes one channel serve every platform.

### 6. A report redraws the run over a setup read once

A report says where a run stands: the count, the stage, the item under way. The setup the run was started
from stands still while it runs, so a panel reads what it shows of the setup once per change to it, and
every report draws over that reading. The converter reads its rows, and the recordings its button names,
where a gesture changes what it gathered. A report therefore costs the same for one gathered recording as
for thousands. The button names the run the same way before it, during it and after it, whether the run
succeeded, failed or was canceled.

---

## Mechanics
Expand All @@ -101,6 +116,16 @@ arrived may be read after it. The run records the tasks it has counted (`TaskSte
reports go. That keeps a task's own progress and the run's completed count from describing the same work
twice, and it keeps the reading inside the run it describes.

### A batch goes on past a failing item

A task fails by raising or by losing its worker. A run whose answers make sense only together ends with
the first failure: a library missing one instruction is no library. So does a run of one task, whose
failure is the whole of its outcome. A run of several tasks that stand on their own
(`skips_failed_tasks`), such as a folder converted one recording at a time, records the failure against
its task, logs it with its traceback, and goes on. One unreadable or crashing recording among thousands
then costs that recording alone. The completed outcome keeps the answers in task order and names every
task the run went on without, so whoever reports the end of the run names them too.

### The pool's and the channel's lifetime

The run's monitor thread (`TaskProcessor`) is the one owner of its pool. However the run ends, the monitor
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ A `convert` command takes either `--channels` or `--stems`.

- **Convert one file**: `sampletones convert input.wav -o output.stn`
- **Convert a folder**: `sampletones convert path/to/folder`. Every recording in the folder
becomes its own reconstruction, saved in your reconstructions folder.
becomes its own reconstruction, saved in your reconstructions folder. A recording that fails is
skipped, and the end of the run lists the skipped recordings with their errors.
- **Mix several recordings into one reconstruction**:
`sampletones convert bass.wav lead.wav --stems stems.json`. The stems file lists the recordings in
the same order. [Reconstructions](../formats/reconstructions.md) shows it.
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/converting.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ button reads **Open**. It takes you to the **Reconstruction** tab, where you pic
If the open reconstruction has unsaved changes, **Load** asks what to do with them. If the conversion
replaced that same file, click **Cancel** and use **Save reconstruction as...** to keep your changes.

If a recording can't be converted, the conversion skips it and goes on with the rest. The dialog at
the end lists the recordings it skipped.

The first conversion with new settings takes longer, because it builds the [instruction
library](../concepts/instruction-library.md) for them.

Expand Down
66 changes: 66 additions & 0 deletions src/sampletones_application/categories/conversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Optional, Self, Tuple

from sampletones_application.categories.manager import LanguageManager
from sampletones_application.categories.skipped import ROW_BULLET

MAX_REPORTED_RECORDINGS: Final[int] = 8
ONE_RECORDING: Final[int] = 1


@dataclass(frozen=True)
class SkippedRecordingMessages:
"""The words a batch reports the recordings it went on without in.

A batch converts each recording on its own, so a recording that fails is skipped and the rest are
converted. The report counts the skipped recordings, names each by its file name, and closes on how
many more it leaves out. The log keeps the error each one ended in.

Attributes:
one: The heading over a single skipped recording.
several: The heading counting several skipped recordings.
more: The line naming the recordings past the ones listed.
"""

one: str
several: str
more: str

@classmethod
def build(cls, language_manager: LanguageManager) -> Self:
"""Resolves every word the report prints.

Args:
language_manager: The catalog the words are read from.

Returns:
Self: The bundle the end of a conversion reads.
"""
return cls(
one=language_manager["main.converter.message.skipped_recording_heading"],
several=language_manager["main.converter.template.skipped_recordings_heading"],
more=language_manager["main.converter.template.skipped_recordings_more"],
)

def notice(self, skipped: Tuple[Path, ...]) -> Optional[str]:
"""Phrases the recordings a batch skipped, under a heading that counts them.

Args:
skipped: The recordings the batch went on without, in the order it reached them.

Returns:
Optional[str]: The paragraph the end of a conversion appends, and ``None`` where every
recording was converted.
"""
if not skipped:
return None

listed = skipped[:MAX_REPORTED_RECORDINGS]
heading = self.one if len(skipped) == ONE_RECORDING else self.several.format(count=len(skipped))
lines = [heading, *(f"{ROW_BULLET}{recording.name}" for recording in listed)]
remaining = len(skipped) - len(listed)
if remaining > 0:
lines.append(self.more.format(count=remaining))

return "\n".join(lines)
31 changes: 30 additions & 1 deletion src/sampletones_application/coordinators/tabs/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import dearpygui.dearpygui as dpg

from sampletones_application.categories.conversion import SkippedRecordingMessages
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.categories.skipped import PARAGRAPH_BREAK
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.config.managers.session import SessionManager
from sampletones_application.constants.output import OutputKind
Expand Down Expand Up @@ -131,6 +133,7 @@ def __init__(
self._side_panel_count: int
self._config_height = layout.config_height
self._ttl_progress = language_manager["main.converter.title.progress_dialog"]
self._skipped_recording_messages = SkippedRecordingMessages.build(language_manager)
self._repaint_priority = layout.scheduling.priorities.gui_action

self._build_explorer(
Expand Down Expand Up @@ -519,7 +522,16 @@ def _confirm_overwriting_target(self, targets: Tuple[Path, ...]) -> None:
)

def _on_conversion_success(self, success: ConversionSuccess) -> None:
"""Offers what the run wrote, with the recordings it skipped named beneath the offer.

A run that skipped every recording it held has nothing to offer, so it reports the recordings
it skipped and the converter returns to idle.
"""
self._hooks.on_refresh_trees()
if success.skipped and not success.written:
self._report_nothing_written(success)
return

if success.is_single:
message = self._language_manager["main.converter.message.load_file_prompt"]
ok_label = self._language_manager["main.converter.label.load_button"]
Expand All @@ -531,7 +543,7 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None:

self._dialogs.show_confirmation(
TAG_MAIN_CONVERTER_DIALOG_LOAD,
message,
self._with_skipped(message, success),
self._language_manager["main.converter.title.load_dialog"],
self._converter_logic.handle_load_request,
ok_label=ok_label,
Expand All @@ -540,6 +552,23 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None:
on_cancel=self._converter_logic.close,
)

def _report_nothing_written(self, success: ConversionSuccess) -> None:
self._converter_logic.close()
self._dialogs.show_info(
self._converter_panel.tag,
self._with_skipped(self._language_manager["main.converter.message.nothing_written"], success),
self._language_manager["main.converter.title.nothing_written_dialog"],
modal=True,
)

def _with_skipped(self, message: str, success: ConversionSuccess) -> str:
"""``message`` followed by the paragraph naming the recordings the run skipped, where it skipped any."""
notice = self._skipped_recording_messages.notice(success.skipped)
if notice is None:
return message

return PARAGRAPH_BREAK.join((message, notice))

def _request_output(self, output: OutputKind) -> None:
"""Answers the output switch, asking which recordings to mix where the list overflows one.

Expand Down
7 changes: 5 additions & 2 deletions src/sampletones_application/logic/instruction/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from sampletones_application.logic.instruction.library_manager import (
InstructionsLibraryManager,
)
from sampletones_application.utils.callbacks.latest import LatestDelivery
from sampletones_application.utils.callbacks.priority import DEFAULT_PRIORITY
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.view_model.instruction.library import (
LibraryPanelViewModel,
Expand Down Expand Up @@ -92,9 +94,10 @@ def __init__(
self._msg_window_not_available = language_manager["instructions.library.message.status_window_not_available"]
self._msg_load_error = language_manager["instructions.library.message.status_load_error"]

self._generation_reports = LatestDelivery(self._on_generation_progress, priority=DEFAULT_PRIORITY)
self._library_manager.set_callbacks(
on_generation_start=partial(CallbackQueue.add, self._on_generation_start),
on_generation_progress=partial(CallbackQueue.add, self._on_generation_progress),
on_generation_progress=self._generation_reports.post,
on_generation_completed=partial(CallbackQueue.add, self._on_generation_completed),
on_generation_error=partial(CallbackQueue.add, self._on_generation_error),
on_generation_canceled=partial(CallbackQueue.add, self._on_generation_canceled),
Expand Down Expand Up @@ -490,7 +493,7 @@ def _on_generation_progress(
task_status: TaskStatus,
task_progress: TaskProgress,
) -> None:
"""Paints a report of the generation under way."""
"""Paints the newest report of the generation under way."""
match task_status:
case TaskStatus.COMPLETED:
self._emit_view(self._language_manager["instructions.library.message.status_saving"], progress=1.0)
Expand Down
13 changes: 12 additions & 1 deletion src/sampletones_application/logic/main/converter/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def __init__(
selected=None,
)
self._rows = self._read_rows()
self._converted = self._read_converted()

self._run = ConversionRun(conversion_service, messages=self._messages)
self._run.on_report = self._on_report
Expand Down Expand Up @@ -529,6 +530,7 @@ def _rewrite(self, state: ConverterState) -> None:
self._remember(state.settings)
self._state = self._redirected(state.selecting(state.selected))
self._rows = self._read_rows()
self._converted = self._read_converted()
self._run.return_to_idle()
self._emit(self._messages.idle, 0.0)

Expand Down Expand Up @@ -642,10 +644,19 @@ def _read_rows(self) -> Tuple[StemRowViewModel, ...]:
"""
return stem_rows(self._state.gathering, mixes=self.mixes)

def _read_converted(self) -> Tuple[Path, ...]:
"""The recordings that take part in the setup now standing, read once for that setup.

A run writing one reconstruction apiece derives an entry for every gathered recording, so
the reading is taken where the setup changes. The button names the run from it before, during
and after the run, whatever the run comes to.
"""
return playing_sources(self._state)

def _action_label(self) -> str:
"""What the button says the run writes, read from the recordings taking part in it."""
return self._messages.action_label(
phase=self._run.phase,
mixes=self.mixes,
converted=playing_sources(self._state),
converted=self._converted,
)
17 changes: 10 additions & 7 deletions src/sampletones_application/logic/main/converter/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from sampletones_core.configs import Config
from sampletones_core.library import InstructionLibraryKey, LibraryState
from sampletones_core.parallelization import TaskProgress
from sampletones_core.reconstructions.converter import ConversionPlan
from sampletones_core.reconstructions.converter import ConversionOutcome, ConversionPlan
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin

Expand All @@ -26,12 +26,14 @@

@dataclass(frozen=True)
class ConversionSuccess:
"""The outcome a completed conversion hands to its listener: the reconstructions it wrote.
"""The outcome a completed conversion hands to its listener: the reconstructions it wrote, and
the recordings it skipped because they failed.

One written reconstruction is one the reader can open straight away; several are a batch,
which the reader reaches as a folder."""

written: Tuple[Path, ...]
skipped: Tuple[Path, ...]

@property
def is_single(self) -> bool:
Expand Down Expand Up @@ -194,8 +196,8 @@ def _on_service_result(self, result: ConversionResult) -> None:
self._handle_progress_result(progress)
case ServiceIntermediate(data=progress):
self._handle_library_progress(progress)
case ServiceSuccess(value=written):
self._settle_as_complete(written)
case ServiceSuccess(value=outcome):
self._settle_as_complete(outcome)
case ServiceError(exception=exception):
self._settle_as_failed(exception)
case ServiceCanceled():
Expand Down Expand Up @@ -229,15 +231,16 @@ def _handle_library_progress(self, progress: TaskProgress) -> None:
total = max(progress.total, 1)
self._report(self._messages.preparing_library(request.library_state), progress.completed / total)

def _settle_as_complete(self, written: Tuple[Path, ...]) -> None:
def _settle_as_complete(self, outcome: ConversionOutcome) -> None:
"""Settles a finished run, telling its listener what was written before reporting.

The reconstruction a run wrote is what a reader is then looking at, so the listener takes
it up first and the report that follows names it.
"""
self._written = written
self._written = outcome.written
self._phase = ConversionPhase.COMPLETED
self.call(self.on_success, ConversionSuccess(written=written))
skipped = tuple(source for skipped_job in outcome.skipped for source in skipped_job.job.sources)
self.call(self.on_success, ConversionSuccess(written=outcome.written, skipped=skipped))
self._report(self._messages.completed, 1.0)

def _settle_as_failed(self, exception: Exception) -> None:
Expand Down
Loading
Loading