diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 77a01a4fc..523da6044 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,11 @@ jobs:
- name: Check the committed icons match the mark
run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always
+ - name: Check a built wheel carries its documentation
+ run: |
+ uv build --wheel --out-dir dist
+ uv run --isolated --no-project --with dist/sampletones-*.whl sampletones self-check --documentation
+
tests:
name: Tests (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index bd22d7aa4..034b094ec 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -86,9 +86,9 @@ jobs:
sampletones --version
sampletones --help
- - name: Check the wheel carries every resource it needs at startup
+ - name: Check the wheel carries every resource it needs at startup and its documentation
shell: bash
- run: sampletones self-check
+ run: sampletones self-check --documentation
- name: Check a developer command refuses to run outside a checkout
shell: bash
@@ -124,7 +124,7 @@ jobs:
- name: Build the bundle
shell: bash
- run: python scripts/bundle.py --release
+ run: python scripts/bundle.py --release --gpu 0
- name: Check the bundle runs and carries its notices
shell: bash
diff --git a/.gitignore b/.gitignore
index f9ba5ecf8..8c984bc08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ wheels/
/bin/
/build/
/bundles/
+/src/sampletones_assets/documentation/
sampletones
!src/sampletones
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 876af936f..a24dd1307 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
* Optimized reconstruction size.
* Optimized algorithm memory usage.
* Improved FamiTracker/Bitphase compatibility.
+* Supported CUDA for local installation if available.
* Bumped the reconstruction data- version to `2.2`, with backward compatibility for `2.1`.
* Bumped the project data version to `1.1`, with backward compatibility.
* Bumped the library data version to `2.1`.
diff --git a/Makefile b/Makefile
index 10a16227d..c71627598 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help setup install system-deps build release run calibration tracker-playback icons demo pictures clean pre-commit test test-docs benchmarks screens lint format
+.PHONY: help setup install system-deps build release run calibration tracker-playback icons docs demo pictures clean pre-commit test test-docs benchmarks screens lint format
ifeq ($(OS),Windows_NT)
PYTHON := python
@@ -21,8 +21,8 @@ help:
@echo $(Q) make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU$(Q)
@echo $(Q) make pre-commit - Install pre-commit hooks$(Q)
@echo $(Q) make system-deps - Install system packages required to build and run (apt on Debian-based Linux, Homebrew on macOS)$(Q)
- @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history)$(Q)
- @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history)$(Q)
+ @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history); GPU auto-detected, GPU=0 forces CPU$(Q)
+ @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history); CPU only$(Q)
@echo $(Q) make test - Run the test suite with coverage$(Q)
@echo $(Q) make test-docs - Run the doctests$(Q)
@echo $(Q) make benchmarks - Run the measured-duration suite$(Q)
@@ -30,6 +30,7 @@ help:
@echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q)
@echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q)
@echo $(Q) make icons - Write the application icon suite from the mark into the package$(Q)
+ @echo $(Q) make docs - Write the user documentation as HTML pages into the package$(Q)
@echo $(Q) make demo - Make the demo tree (recordings, a library, reconstructions, a project) in DEMO=build/demo$(Q)
@echo $(Q) make pictures - Draw the guide's and the README's pictures of the application from the demo tree (needs Xvfb)$(Q)
@echo $(Q) make clean - Remove build artifacts and cache files$(Q)
@@ -45,10 +46,10 @@ install:
$(MAKE) build
build:
- $(PYTHON) scripts/bundle.py
+ $(PYTHON) scripts/bundle.py --gpu $(GPU)
release:
- $(PYTHON) scripts/bundle.py --release
+ $(PYTHON) scripts/bundle.py --release --gpu 0
system-deps:
$(PYTHON) scripts/system_dependencies.py
@@ -67,6 +68,9 @@ tracker-playback:
icons:
uv run python -m assets.icons
+docs:
+ uv run python -m assets.documentation
+
demo:
uv run python -m assets.demo -o $(DEMO)
diff --git a/README.md b/README.md
index 6fbec4e20..14945a005 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
## Overview
-_SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/).
+_SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/) or [_Bitphase_](https://bitphase.app/).
@@ -67,7 +67,7 @@ recording without the interface, for example. Run `sampletones --help` for the l
## Documentation
-The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes.
+The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The app opens it in your browser from **Help ▸ Documentation**. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes.
## License
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 0d147c3d5..4b139647a 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -105,10 +105,15 @@ three years from the date of that release.
### GPU acceleration
-The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA libraries
-are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes
-from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the
-CUDA components from their publishers straight to your machine.
+The published bundles are **CPU-only** and carry no CuPy or CUDA libraries.
+
+CuPy is MIT-licensed. The CUDA libraries (cuBLAS, cuFFT, cuRAND, cuSOLVER, cuSPARSE, NVRTC and the CUDA
+runtime) are NVIDIA software under the
+[CUDA EULA](https://docs.nvidia.com/cuda/eula/index.html), which lists them as distributable inside an
+application with material functionality of its own, without modification and for use by that application
+alone. GPU support comes from installing _SampleToNES_ from PyPI with the `gpu` extra, or from building
+the standalone app on your own machine with an NVIDIA driver, which bundles the libraries fetched from
+NVIDIA's wheels for your own use.
## Build-time tooling
diff --git a/assets/documentation/__init__.py b/assets/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/assets/documentation/__main__.py b/assets/documentation/__main__.py
new file mode 100644
index 000000000..5d15c9268
--- /dev/null
+++ b/assets/documentation/__main__.py
@@ -0,0 +1,48 @@
+import sys
+from argparse import ArgumentParser
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, Optional, Sequence
+
+from assets.documentation.build import SiteRequest, build_site
+from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE, SITE_DIRECTORY
+
+PROGRAM: Final[str] = "python -m assets.documentation"
+DESCRIPTION: Final[str] = "write the user's documentation as HTML pages in the application's look"
+OUTPUT_HELP: Final[str] = "the folder receiving the site; without it, the folder the package ships it from"
+
+
+@dataclass(frozen=True)
+class DocumentationArguments:
+ """What a documentation run is given: where the site goes, if anywhere but the package."""
+
+ output: Optional[Path]
+
+ @classmethod
+ def parse(cls, argv: Sequence[str]) -> "DocumentationArguments":
+ """The arguments ``argv`` states, read by the maker's own parser."""
+ arguments = cls._parser().parse_args(list(argv))
+ return cls(output=arguments.output)
+
+ @staticmethod
+ def _parser() -> ArgumentParser:
+ parser = ArgumentParser(prog=PROGRAM, description=DESCRIPTION)
+ parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP)
+ return parser
+
+ @property
+ def directory(self) -> Path:
+ """The folder the site is written into: the one given, or the one the package ships it from."""
+ return self.output if self.output is not None else SITE_DIRECTORY
+
+
+def main(argv: Sequence[str]) -> int:
+ """Writes the documentation site and reports its index."""
+ given = DocumentationArguments.parse(argv)
+ site = build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=given.directory))
+ print(f"Wrote {site.index}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/assets/documentation/build.py b/assets/documentation/build.py
new file mode 100644
index 000000000..bcd3a4b69
--- /dev/null
+++ b/assets/documentation/build.py
@@ -0,0 +1,172 @@
+import posixpath
+import shutil
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from shutil import copyfile
+from string import Template
+from typing import Final, Iterable, List, Set
+
+from markdown_it import MarkdownIt
+from markdown_it.utils import EnvType
+
+from assets.documentation.links.resolver import EXTERNAL_PREFIXES, BrokenLinkError, LinkResolver
+from assets.documentation.markdown.parser import markdown_parser
+from assets.documentation.navigation import Navigation
+from assets.documentation.pages.model import ParsedPage
+from assets.documentation.pages.reader import parse_page
+from assets.documentation.pages.sections import image_sources, split_sections
+from assets.documentation.paths import STATIC_DIRECTORY
+from assets.documentation.project import ProjectFacts
+from assets.documentation.published import INDEX_PAGE, page_path, published_pages
+from assets.documentation.rendering.cards import section_card
+from assets.documentation.rendering.navigation import navigation_html
+from assets.documentation.rendering.page import LAYOUT_DIRECTORY, PageChrome, PageContent, render_page
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_shared.documentation.site import DocumentationSite
+from sampletones_tools.web.design import ApplicationDesign
+from sampletones_tools.web.writer import write_layout
+
+TEMPLATE_FILE: Final[str] = "page.html"
+DOCUMENTATION_STYLESHEET: Final[str] = "documentation.css"
+TEXT_NEWLINE: Final[str] = "\n"
+PARENT: Final[str] = ".."
+
+
+class OutputOccupiedError(ValueError):
+ pass
+
+
+@dataclass(frozen=True)
+class SiteRequest:
+ """Where the documentation is read from and where its site is written.
+
+ Attributes:
+ docs: The docs folder of a checkout.
+ project: The checkout's ``pyproject.toml``, for the version and the online address.
+ output: The folder the site is written into; empty, or holding an earlier site.
+ """
+
+ docs: Path
+ project: Path
+ output: Path
+
+
+def build_site(request: SiteRequest) -> DocumentationSite:
+ """Write the user's documentation as HTML pages in the application's look.
+
+ Every published document becomes a page at the same place in the site, wearing the web
+ layout in the application's default palette, with the navigation the docs index lays out. The
+ pictures the pages show are copied beside them, and the site is sealed with its stamp last.
+
+ Raises:
+ OutputOccupiedError: If the output holds files other than an earlier site.
+ BrokenLinkError: If a page links to a missing document, heading or picture.
+ """
+ _prepare(request.output)
+ facts = ProjectFacts.read(request.project)
+ parser = markdown_parser()
+ pages = {relative: parse_page(parser, request.docs, relative) for relative in published_pages(request.docs)}
+ navigation = Navigation.read(pages[INDEX_PAGE], pages)
+ resolver = LinkResolver(
+ request.docs,
+ {relative: page.anchors for relative, page in pages.items()},
+ navigation.folder_pages,
+ facts.documentation_address,
+ )
+ design = ApplicationDesign.load(DEFAULT_PALETTE_NAME)
+ layout = request.output / LAYOUT_DIRECTORY
+ stylesheets = write_layout(layout, design)
+ copyfile(STATIC_DIRECTORY / DOCUMENTATION_STYLESHEET, layout / DOCUMENTATION_STYLESHEET)
+ chrome = PageChrome(
+ version=facts.version,
+ address=facts.documentation_address,
+ stylesheets=(*stylesheets, DOCUMENTATION_STYLESHEET),
+ )
+ template = Template((STATIC_DIRECTORY / TEMPLATE_FILE).read_text(encoding="utf-8"))
+ pictures: Set[PurePosixPath] = set()
+ for page in pages.values():
+ _resolve_links(page, resolver)
+ html = _page_html(parser, template, chrome, navigation, page, design.glyphs.common.tick)
+ target = request.output / page_path(page.relative)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(html, encoding="utf-8", newline=TEXT_NEWLINE)
+ pictures.update(_pictures(request.docs, page))
+
+ _copy_pictures(request.docs, request.output, pictures)
+ site = DocumentationSite(request.output)
+ site.write_stamp()
+ return site
+
+
+def _prepare(output: Path) -> None:
+ site = DocumentationSite(output)
+ if output.exists() and any(output.iterdir()):
+ if site.read_stamp() is None:
+ raise OutputOccupiedError(f"{output} holds files other than a documentation site; give an empty folder")
+
+ shutil.rmtree(output)
+
+ output.mkdir(parents=True, exist_ok=True)
+
+
+def _resolve_links(
+ page: ParsedPage,
+ resolver: LinkResolver,
+) -> None:
+ for token in page.tokens:
+ for child in token.children or []:
+ href = child.attrGet("href") if child.type == "link_open" else None
+ if href is not None:
+ child.attrSet("href", resolver.resolve(str(href), page.relative))
+
+
+def _page_html(
+ parser: MarkdownIt,
+ template: Template,
+ chrome: PageChrome,
+ navigation: Navigation,
+ page: ParsedPage,
+ glyph: str,
+) -> str:
+ env: EnvType = {}
+
+ def inline(text: str) -> str:
+ return str(parser.renderInline(text, env))
+
+ sections = split_sections(parser, page.tokens, env)
+ content = PageContent(
+ relative=page.relative,
+ title_html=inline(page.title),
+ navigation_html=navigation_html(navigation, page.relative, sections, inline),
+ cards_html="\n".join(section_card(section, glyph) for section in sections),
+ )
+ return render_page(template, chrome, content)
+
+
+def _pictures(
+ docs: Path,
+ page: ParsedPage,
+) -> List[PurePosixPath]:
+ pictures: List[PurePosixPath] = []
+ for source in image_sources(page.tokens):
+ if source.startswith(EXTERNAL_PREFIXES):
+ continue
+
+ target = posixpath.normpath(posixpath.join(page.relative.parent.as_posix(), source))
+ if target.startswith(PARENT) or not (docs / target).is_file():
+ raise BrokenLinkError(f"{page.relative} shows a picture outside the docs or missing: {source}")
+
+ pictures.append(PurePosixPath(target))
+
+ return pictures
+
+
+def _copy_pictures(
+ docs: Path,
+ output: Path,
+ pictures: Iterable[PurePosixPath],
+) -> None:
+ for picture in sorted(pictures):
+ target = output / picture
+ target.parent.mkdir(parents=True, exist_ok=True)
+ copyfile(docs / picture, target)
diff --git a/assets/documentation/links/__init__.py b/assets/documentation/links/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/assets/documentation/links/resolver.py b/assets/documentation/links/resolver.py
new file mode 100644
index 000000000..704915ddc
--- /dev/null
+++ b/assets/documentation/links/resolver.py
@@ -0,0 +1,111 @@
+import posixpath
+from pathlib import Path, PurePosixPath
+from typing import Dict, Final, FrozenSet, Optional, Tuple
+from urllib.parse import urljoin
+
+from assets.documentation.published import MARKDOWN_SUFFIX, is_published, page_path
+
+EXTERNAL_PREFIXES: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:")
+FRAGMENT_SEPARATOR: Final[str] = "#"
+PARENT: Final[str] = ".."
+DIRECTORY_INDEX: Final[str] = "index.md"
+
+
+class BrokenLinkError(ValueError):
+ pass
+
+
+class LinkResolver:
+ """Turns a link a document writes into the link its page holds.
+
+ A link to another published document becomes a link to its page, and a link to a folder
+ becomes a link to the folder's index or to the first of its pages the navigation lists. A link
+ to anything the site leaves out, the development pages among them, becomes a link to the online
+ copy, so it still leads somewhere. A link to a missing file or heading fails the build.
+ """
+
+ def __init__(
+ self,
+ docs: Path,
+ anchors: Dict[PurePosixPath, FrozenSet[str]],
+ folder_pages: Dict[str, PurePosixPath],
+ address: str,
+ ) -> None:
+ self._docs = docs
+ self._anchors = anchors
+ self._folder_pages = folder_pages
+ self._address = address.rstrip("/") + "/"
+
+ def resolve(
+ self,
+ href: str,
+ page: PurePosixPath,
+ ) -> str:
+ """The link ``href``, written in the document ``page``, as the page's link.
+
+ Raises:
+ BrokenLinkError: If the link names a missing file or a missing heading.
+ """
+ if href.startswith(EXTERNAL_PREFIXES):
+ return href
+
+ path, _, fragment = href.partition(FRAGMENT_SEPARATOR)
+ if not path:
+ self._check_fragment(page, fragment, page, href)
+ return href
+
+ target = posixpath.normpath(posixpath.join(page.parent.as_posix(), path))
+ if target == PARENT or target.startswith(f"{PARENT}/") or not is_published(PurePosixPath(target)):
+ return self._online(target, fragment, page, href)
+
+ relative = PurePosixPath(target)
+ if relative not in self._anchors:
+ raise BrokenLinkError(f"{page} links to a missing document: {href}")
+
+ self._check_fragment(relative, fragment, page, href)
+ return self._page_link(page, relative, fragment)
+
+ def _online(
+ self,
+ target: str,
+ fragment: str,
+ page: PurePosixPath,
+ href: str,
+ ) -> str:
+ source = (self._docs / target).resolve()
+ if not source.exists():
+ raise BrokenLinkError(f"{page} links to a missing file: {href}")
+
+ folder = self._folder_page(target) if source.is_dir() else None
+ if folder is not None:
+ self._check_fragment(folder, fragment, page, href)
+ return self._page_link(page, folder, fragment)
+
+ address = urljoin(self._address, f"{target}/" if source.is_dir() else target)
+ return f"{address}{FRAGMENT_SEPARATOR}{fragment}" if fragment else address
+
+ def _folder_page(self, target: str) -> Optional[PurePosixPath]:
+ index = PurePosixPath(target) / DIRECTORY_INDEX
+ if index in self._anchors:
+ return index
+
+ return self._folder_pages.get(target)
+
+ def _check_fragment(
+ self,
+ target: PurePosixPath,
+ fragment: str,
+ page: PurePosixPath,
+ href: str,
+ ) -> None:
+ if fragment and target.suffix == MARKDOWN_SUFFIX and fragment not in self._anchors[target]:
+ raise BrokenLinkError(f"{page} links to a missing heading: {href}")
+
+ @staticmethod
+ def _page_link(
+ page: PurePosixPath,
+ target: PurePosixPath,
+ fragment: str,
+ ) -> str:
+ link = posixpath.relpath(page_path(target).as_posix(), page.parent.as_posix())
+ return f"{link}{FRAGMENT_SEPARATOR}{fragment}" if fragment else link
diff --git a/assets/documentation/markdown/__init__.py b/assets/documentation/markdown/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/assets/documentation/markdown/anchors.py b/assets/documentation/markdown/anchors.py
new file mode 100644
index 000000000..1d931dca5
--- /dev/null
+++ b/assets/documentation/markdown/anchors.py
@@ -0,0 +1,28 @@
+import re
+from typing import Dict, Final
+
+FORMATTING: Final[re.Pattern[str]] = re.compile(r"[`*]")
+PUNCTUATION: Final[re.Pattern[str]] = re.compile(r"[^\w\s-]")
+
+
+def heading_anchor(heading: str) -> str:
+ """The fragment GitHub gives a heading: punctuation dropped, every remaining space a hyphen."""
+ text = FORMATTING.sub("", heading).strip().lower()
+ return PUNCTUATION.sub("", text).replace(" ", "-")
+
+
+class HeadingAnchors:
+ """The anchors of one page's headings, each unique, as GitHub numbers a repeated one.
+
+ A heading whose anchor an earlier heading already took gains ``-1``, then ``-2``, so every
+ heading on a page has a fragment of its own.
+ """
+
+ def __init__(self) -> None:
+ self._taken: Dict[str, int] = {}
+
+ def claim(self, heading: str) -> str:
+ anchor = heading_anchor(heading)
+ repeats = self._taken.get(anchor)
+ self._taken[anchor] = 0 if repeats is None else repeats + 1
+ return anchor if repeats is None else f"{anchor}-{repeats + 1}"
diff --git a/assets/documentation/markdown/parser.py b/assets/documentation/markdown/parser.py
new file mode 100644
index 000000000..a841b3ba6
--- /dev/null
+++ b/assets/documentation/markdown/parser.py
@@ -0,0 +1,40 @@
+from typing import Final, Sequence
+
+from markdown_it import MarkdownIt
+from markdown_it.renderer import RendererHTML
+from markdown_it.token import Token
+from markdown_it.utils import EnvType, OptionsDict
+
+COMMONMARK: Final[str] = "commonmark"
+TABLE_RULE: Final[str] = "table"
+TABLE_OPEN: Final[str] = '
\n'
+TABLE_CLOSE: Final[str] = "
\n"
+
+
+def markdown_parser() -> MarkdownIt:
+ """The parser the documentation is read with: CommonMark, the pipe tables GitHub draws, and
+ the raw HTML the pages hold for their pictures, with every table in the application's style."""
+ parser = MarkdownIt(COMMONMARK, {"html": True}).enable(TABLE_RULE)
+ parser.add_render_rule("table_open", _table_open)
+ parser.add_render_rule("table_close", _table_close)
+ return parser
+
+
+def _table_open(
+ _renderer: RendererHTML,
+ _tokens: Sequence[Token],
+ _index: int,
+ _options: OptionsDict,
+ _env: EnvType,
+) -> str:
+ return TABLE_OPEN
+
+
+def _table_close(
+ _renderer: RendererHTML,
+ _tokens: Sequence[Token],
+ _index: int,
+ _options: OptionsDict,
+ _env: EnvType,
+) -> str:
+ return TABLE_CLOSE
diff --git a/assets/documentation/navigation.py b/assets/documentation/navigation.py
new file mode 100644
index 000000000..5f431dfbd
--- /dev/null
+++ b/assets/documentation/navigation.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+import posixpath
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from typing import Dict, Final, List, Mapping, Optional, Tuple
+
+from assets.documentation.pages.model import ParsedPage
+
+GROUP_LEVEL: Final[str] = "h2"
+FRAGMENT_SEPARATOR: Final[str] = "#"
+
+
+@dataclass(frozen=True)
+class NavigationEntry:
+ page: PurePosixPath
+ title: str
+
+
+@dataclass(frozen=True)
+class NavigationGroup:
+ title: str
+ entries: Tuple[NavigationEntry, ...]
+
+
+@dataclass(frozen=True)
+class Navigation:
+ """The site's contents as the docs index lays them out: each of its sections a group, and each
+ published document it links an entry, in the order the index names them.
+
+ A section that links no published document, such as the development one, makes no group.
+ """
+
+ groups: Tuple[NavigationGroup, ...]
+
+ @classmethod
+ def read(
+ cls,
+ index: ParsedPage,
+ pages: Mapping[PurePosixPath, ParsedPage],
+ ) -> Navigation:
+ groups: List[NavigationGroup] = []
+ title: Optional[str] = None
+ entries: Dict[PurePosixPath, NavigationEntry] = {}
+ tokens = index.tokens
+ for position, token in enumerate(tokens):
+ if token.type == "heading_open" and token.tag == GROUP_LEVEL:
+ cls._close(groups, title, entries)
+ title, entries = tokens[position + 1].content, {}
+ elif token.type == "inline" and title is not None:
+ for child in token.children or []:
+ href = child.attrGet("href") if child.type == "link_open" else None
+ target = cls._target(index, str(href)) if href is not None else None
+ if target is not None and target in pages and target != index.relative:
+ entries.setdefault(target, NavigationEntry(page=target, title=pages[target].title))
+
+ cls._close(groups, title, entries)
+ return cls(groups=tuple(groups))
+
+ @property
+ def folder_pages(self) -> Dict[str, PurePosixPath]:
+ """The first page the navigation lists in each folder, where a link to the folder leads."""
+ firsts: Dict[str, PurePosixPath] = {}
+ for group in self.groups:
+ for entry in group.entries:
+ if len(entry.page.parts) > 1:
+ firsts.setdefault(entry.page.parts[0], entry.page)
+
+ return firsts
+
+ @staticmethod
+ def _target(
+ index: ParsedPage,
+ href: str,
+ ) -> Optional[PurePosixPath]:
+ path = href.partition(FRAGMENT_SEPARATOR)[0]
+ if not path:
+ return None
+
+ return PurePosixPath(posixpath.normpath(posixpath.join(index.relative.parent.as_posix(), path)))
+
+ @staticmethod
+ def _close(
+ groups: List[NavigationGroup],
+ title: Optional[str],
+ entries: Dict[PurePosixPath, NavigationEntry],
+ ) -> None:
+ if title is not None and entries:
+ groups.append(NavigationGroup(title=title, entries=tuple(entries.values())))
diff --git a/assets/documentation/pages/__init__.py b/assets/documentation/pages/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/assets/documentation/pages/model.py b/assets/documentation/pages/model.py
new file mode 100644
index 000000000..d2035d0cd
--- /dev/null
+++ b/assets/documentation/pages/model.py
@@ -0,0 +1,37 @@
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from typing import FrozenSet, List, Optional
+
+from markdown_it.token import Token
+
+
+@dataclass(frozen=True)
+class ParsedPage:
+ """A document read and its headings anchored, before its links are resolved.
+
+ Attributes:
+ relative: The document's path relative to the docs folder.
+ tokens: The document's tokens, each heading carrying its anchor as its ``id``.
+ anchors: Every anchor the document's headings hold.
+ title: The document's title, its first-level heading as written.
+ """
+
+ relative: PurePosixPath
+ tokens: List[Token]
+ anchors: FrozenSet[str]
+ title: str
+
+
+@dataclass(frozen=True)
+class Section:
+ """One card of a page: a second-level heading and what follows it, or what precedes the first.
+
+ Attributes:
+ title_html: The heading as inline markup, or ``None`` for the opening card.
+ anchor: The heading's anchor, or ``None`` for the opening card.
+ body_html: Everything the section holds, as markup.
+ """
+
+ title_html: Optional[str]
+ anchor: Optional[str]
+ body_html: str
diff --git a/assets/documentation/pages/reader.py b/assets/documentation/pages/reader.py
new file mode 100644
index 000000000..9124bb94e
--- /dev/null
+++ b/assets/documentation/pages/reader.py
@@ -0,0 +1,51 @@
+from pathlib import Path, PurePosixPath
+from typing import Final, List, Set
+
+from markdown_it import MarkdownIt
+from markdown_it.token import Token
+
+from assets.documentation.markdown.anchors import HeadingAnchors
+from assets.documentation.pages.model import ParsedPage
+
+HEADING_OPEN: Final[str] = "heading_open"
+TITLE_LEVEL: Final[str] = "h1"
+ANCHOR_ATTRIBUTE: Final[str] = "id"
+
+
+class MissingTitleError(ValueError):
+ pass
+
+
+def parse_page(
+ parser: MarkdownIt,
+ docs: Path,
+ relative: PurePosixPath,
+) -> ParsedPage:
+ """Read one document and give each heading its anchor, the fragment links to it name.
+
+ Raises:
+ MissingTitleError: If the document holds no first-level heading.
+ """
+ tokens = parser.parse((docs / relative).read_text(encoding="utf-8"))
+ anchors = HeadingAnchors()
+ claimed: Set[str] = set()
+ for index, token in enumerate(tokens):
+ if token.type == HEADING_OPEN:
+ anchor = anchors.claim(tokens[index + 1].content)
+ token.attrSet(ANCHOR_ATTRIBUTE, anchor)
+ claimed.add(anchor)
+
+ return ParsedPage(
+ relative=relative,
+ tokens=tokens,
+ anchors=frozenset(claimed),
+ title=_title(tokens, relative),
+ )
+
+
+def _title(tokens: List[Token], relative: PurePosixPath) -> str:
+ for index, token in enumerate(tokens):
+ if token.type == HEADING_OPEN and token.tag == TITLE_LEVEL:
+ return tokens[index + 1].content
+
+ raise MissingTitleError(f"{relative} holds no first-level heading to name its page by")
diff --git a/assets/documentation/pages/sections.py b/assets/documentation/pages/sections.py
new file mode 100644
index 000000000..5211dafbd
--- /dev/null
+++ b/assets/documentation/pages/sections.py
@@ -0,0 +1,76 @@
+import re
+from dataclasses import dataclass, field
+from typing import Final, List, Optional, Tuple
+
+from markdown_it import MarkdownIt
+from markdown_it.token import Token
+from markdown_it.utils import EnvType
+
+from assets.documentation.pages.model import Section
+
+HEADING_OPEN: Final[str] = "heading_open"
+HEADING_CLOSE: Final[str] = "heading_close"
+TITLE_LEVEL: Final[str] = "h1"
+SECTION_LEVEL: Final[str] = "h2"
+ANCHOR_ATTRIBUTE: Final[str] = "id"
+IMAGE_SOURCE: Final[re.Pattern[str]] = re.compile(r']*\bsrc="([^"]+)"')
+HTML_TOKENS: Final[Tuple[str, ...]] = ("html_block", "html_inline")
+
+
+@dataclass
+class _Segment:
+ title: Optional[Token]
+ anchor: Optional[str]
+ body: List[Token] = field(default_factory=list)
+
+
+def split_sections(
+ parser: MarkdownIt,
+ tokens: List[Token],
+ env: EnvType,
+) -> List[Section]:
+ """A page as its cards: what precedes the first second-level heading, then one card per such
+ heading with what follows it. The first-level heading is the page's title and stands apart.
+
+ An opening that holds nothing makes no card.
+ """
+ segments: List[_Segment] = [_Segment(title=None, anchor=None)]
+ position = 0
+ while position < len(tokens):
+ token = tokens[position]
+ if token.type == HEADING_OPEN and token.tag in (TITLE_LEVEL, SECTION_LEVEL):
+ if token.tag == SECTION_LEVEL:
+ anchor = token.attrGet(ANCHOR_ATTRIBUTE)
+ segments.append(_Segment(title=tokens[position + 1], anchor=str(anchor)))
+ position = _after_heading(tokens, position)
+ continue
+
+ segments[-1].body.append(token)
+ position += 1
+
+ return [_section(parser, segment, env) for segment in segments if segment.title is not None or segment.body]
+
+
+def image_sources(tokens: List[Token]) -> List[str]:
+ """The source of every picture the page holds as raw HTML, as the page writes it."""
+ return [source for token in tokens if token.type in HTML_TOKENS for source in IMAGE_SOURCE.findall(token.content)]
+
+
+def _after_heading(tokens: List[Token], position: int) -> int:
+ while tokens[position].type != HEADING_CLOSE:
+ position += 1
+
+ return position + 1
+
+
+def _section(
+ parser: MarkdownIt,
+ segment: _Segment,
+ env: EnvType,
+) -> Section:
+ title_html = parser.renderer.render([segment.title], parser.options, env) if segment.title is not None else None
+ return Section(
+ title_html=title_html,
+ anchor=segment.anchor,
+ body_html=parser.renderer.render(segment.body, parser.options, env),
+ )
diff --git a/assets/documentation/paths.py b/assets/documentation/paths.py
new file mode 100644
index 000000000..1f7bb30ff
--- /dev/null
+++ b/assets/documentation/paths.py
@@ -0,0 +1,14 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.documentation.site import DOCUMENTATION_DIRECTORY
+from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ASSETS_PACKAGE
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
+
+STATIC_PACKAGE: Final[str] = "assets.documentation.static"
+
+DOCS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "docs"
+PROJECT_FILE: Final[Path] = REPOSITORY_ROOT / "pyproject.toml"
+SITE_DIRECTORY: Final[Path] = SOURCE_ROOT / ASSETS_PACKAGE / DOCUMENTATION_DIRECTORY
+STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE)
diff --git a/assets/documentation/project.py b/assets/documentation/project.py
new file mode 100644
index 000000000..dfe25ac88
--- /dev/null
+++ b/assets/documentation/project.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final
+
+DOCUMENTATION_URL_KEY: Final[str] = "Documentation"
+
+
+@dataclass(frozen=True)
+class ProjectFacts:
+ """What ``pyproject.toml`` states that the documentation shows: the version, and where the
+ documentation lives online."""
+
+ version: str
+ documentation_address: str
+
+ @classmethod
+ def read(cls, path: Path) -> ProjectFacts:
+ """The facts ``path`` states.
+
+ Raises:
+ KeyError: If the file names no version or no documentation address.
+ """
+ with path.open("rb") as stream:
+ project = tomllib.load(stream)["project"]
+
+ return cls(
+ version=str(project["version"]),
+ documentation_address=str(project["urls"][DOCUMENTATION_URL_KEY]),
+ )
diff --git a/assets/documentation/published.py b/assets/documentation/published.py
new file mode 100644
index 000000000..38b816277
--- /dev/null
+++ b/assets/documentation/published.py
@@ -0,0 +1,34 @@
+from pathlib import Path, PurePosixPath
+from typing import Final, List, Tuple
+
+MARKDOWN_SUFFIX: Final[str] = ".md"
+PAGE_SUFFIX: Final[str] = ".html"
+INDEX_PAGE: Final[PurePosixPath] = PurePosixPath("index.md")
+PUBLISHED_FOLDERS: Final[Tuple[str, ...]] = ("guide", "concepts", "tools", "formats", "api")
+PUBLISHED_ROOT_PAGES: Final[Tuple[PurePosixPath, ...]] = (INDEX_PAGE, PurePosixPath("glossary.md"))
+
+
+def is_published(relative: PurePosixPath) -> bool:
+ """Whether a document, named relative to the docs folder, belongs to the user's documentation.
+
+ The documentation carries what a user of the application reads: the index, the glossary, and
+ the guide, concepts, tools, formats and API sections. The development section stays online.
+ """
+ if relative.suffix != MARKDOWN_SUFFIX:
+ return False
+
+ if len(relative.parts) == 1:
+ return relative in PUBLISHED_ROOT_PAGES
+
+ return relative.parts[0] in PUBLISHED_FOLDERS
+
+
+def published_pages(docs: Path) -> List[PurePosixPath]:
+ """Every published document under ``docs``, relative to it, in a stable order."""
+ relatives = (PurePosixPath(path.relative_to(docs).as_posix()) for path in docs.rglob(f"*{MARKDOWN_SUFFIX}"))
+ return sorted(relative for relative in relatives if is_published(relative))
+
+
+def page_path(relative: PurePosixPath) -> PurePosixPath:
+ """The page a document becomes, at the same place in the site as the document in the docs."""
+ return relative.with_suffix(PAGE_SUFFIX)
diff --git a/assets/documentation/rendering/__init__.py b/assets/documentation/rendering/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/assets/documentation/rendering/cards.py b/assets/documentation/rendering/cards.py
new file mode 100644
index 000000000..878b9738a
--- /dev/null
+++ b/assets/documentation/rendering/cards.py
@@ -0,0 +1,22 @@
+from html import escape
+
+from assets.documentation.pages.model import Section
+
+
+def section_card(
+ section: Section,
+ glyph: str,
+) -> str:
+ """One section as the application's card: the header strip with the purpose glyph and the
+ heading, then the section's prose. The opening section is a card without a header."""
+ body = f'
\n{section.body_html}
'
+ if section.title_html is None or section.anchor is None:
+ return f'\n{body}\n'
+
+ header = (
+ '
+
+
diff --git a/automation/application/running.py b/automation/application/running.py
index 7ed0382f1..2bc402f22 100644
--- a/automation/application/running.py
+++ b/automation/application/running.py
@@ -59,6 +59,12 @@ def language(self) -> LanguageManager:
"""The application's language manager, which turns a language key into the words a user reads."""
return self._application.language_manager
+ def sound_settled(self) -> bool:
+ """Whether every sound asked for stands landed: the open reconstruction's rebuilds and listenings, and a
+ voice's preview on its way to the sequencer.
+ """
+ return not (self._application.reconstruction_rewrites.is_busy or self._application.document_previews.is_busy)
+
@property
def shortcuts(self) -> ShortcutManager:
"""The application's shortcut manager, which says what keys each action has under the scheme in
diff --git a/automation/boundaries/reveals.py b/automation/boundaries/reveals.py
index 76516d47e..43f3493ea 100644
--- a/automation/boundaries/reveals.py
+++ b/automation/boundaries/reveals.py
@@ -12,22 +12,29 @@
class FileManagerStandIn:
- """Stands in for the desktop's file manager, noting every path the application asks it to show.
+ """Stands in for the desktop's file manager and its handler of files, noting every path the
+ application hands them.
The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file
- manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads
- the search path, so the application's own reveal code runs and reaches them: the first names a
- file manager they stand for, and the others write the path they were given to ``log``, one line
- each.
+ manager, or ``xdg-open``, to show a path, and it asks ``xdg-open`` to open a page in the
+ browser. Programs of those names lie in ``folder``, which leads the search path, so the
+ 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__(
self,
folder: Path,
- log: Path,
+ shown_log: Path,
+ opened_log: Path,
) -> None:
self.folder = folder
- self._log = log
+ self._shown_log = shown_log
+ self._opened_log = opened_log
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Lays the programs in the folder and puts the folder first on the search path."""
@@ -44,15 +51,30 @@ def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
def shown(self) -> Tuple[Path, ...]:
"""Every path the file manager was asked to show, in order."""
- if not self._log.exists():
- return ()
+ return _noted(self._shown_log)
- return tuple(Path(line) for line in self._log.read_text().splitlines())
+ def opened(self) -> Tuple[Path, ...]:
+ """Every file handed to the desktop's program for its kind, in order."""
+ return _noted(self._opened_log)
def _programs(self) -> Mapping[str, str]:
- noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n'
+ noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._shown_log}"; done\n'
+ sorting = (
+ "#!/bin/sh\n"
+ 'for path in "$@"; do\n'
+ f' if [ -d "$path" ]; then log="{self._shown_log}"; else log="{self._opened_log}"; fi\n'
+ ' printf \'%s\\n\' "$path" >> "$log"\n'
+ "done\n"
+ )
return {
"xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n",
FILE_MANAGER: noting,
- "xdg-open": noting,
+ "xdg-open": sorting,
}
+
+
+def _noted(log: Path) -> Tuple[Path, ...]:
+ if not log.exists():
+ return ()
+
+ return tuple(Path(line) for line in log.read_text().splitlines())
diff --git a/automation/dearpygui/gestures/pointer.py b/automation/dearpygui/gestures/pointer.py
index 4963f1794..b8a247aaf 100644
--- a/automation/dearpygui/gestures/pointer.py
+++ b/automation/dearpygui/gestures/pointer.py
@@ -218,6 +218,8 @@ def _press_where_it_stands(
)
if running and count > SINGLE_PRESS:
self._require_double_click(imgui_button, releases, double_clicks)
+ if running and count > DOUBLE_PRESS:
+ self._require_one_burst(imgui_button, releases)
def _counts(self, imgui_button: int) -> Tuple[int, int]:
"""The releases and the double-clicks the witness counted of ``imgui_button`` so far."""
@@ -226,6 +228,30 @@ def _counts(self, imgui_button: int) -> Tuple[int, int]:
self._witness.double_clicks(imgui_button),
)
+ def _require_one_burst(
+ self,
+ imgui_button: int,
+ releases: int,
+ ) -> None:
+ """Checks that every press after the double-click landed within Dear ImGui's double-click time of the one
+ before, which is what makes the presses one burst to a plot.
+
+ A plot takes a press that follows a double-click within that time as one more click of the
+ burst, and a later one as a click of its own, so a frame drawn slowly between two presses
+ turns a triple click into a double-click and a click.
+
+ Raises:
+ SlowFramesError: If a press of the burst came later than that after the one before it.
+ """
+ times = self._bridge.ask(lambda: self._witness.button_release_times(imgui_button))[releases:]
+ for earlier, later in zip(times[DOUBLE_PRESS - 1 :], times[DOUBLE_PRESS:]):
+ took = later - earlier
+ if took >= IMGUI_DOUBLE_CLICK_SECONDS:
+ raise SlowFramesError(
+ f"Dear ImGui read a press of the burst as a click of its own: it came {took:.2f} s after "
+ f"the one before, past the {IMGUI_DOUBLE_CLICK_SECONDS} s double-click time"
+ )
+
def _require_double_click(
self,
imgui_button: int,
diff --git a/automation/environment.py b/automation/environment.py
index 6c17c3748..c7733815a 100644
--- a/automation/environment.py
+++ b/automation/environment.py
@@ -27,6 +27,8 @@
SIZE_SEPARATOR: Final[str] = "x"
ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS"
REPORT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPORT"
+REPEATS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPEATS"
+SINGLE_RUN: Final[int] = 1
STRICT_HISTORY_VARIABLE: Final[str] = f"{SAMPLETONES_ENV_PREFIX}STRICT_HISTORY"
STRICT_HISTORY: Final[str] = "true"
NO_INPUT_METHOD: Final[str] = "@im=none"
@@ -111,6 +113,16 @@ def homes_root(environment: Mapping[str, str]) -> Path:
return Path(stated) if stated is not None else Path(tempfile.gettempdir())
+def repeats(environment: Mapping[str, str]) -> int:
+ """How many times a scenario written to be tried over runs: once, which ``SAMPLETONES_SCREENS_REPEATS`` raises.
+
+ A scenario that holds the application to a clean exit under a race is run many times in one
+ pass to be believed, and once in every other pass.
+ """
+ stated = environment.get(REPEATS_VARIABLE)
+ return int(stated) if stated is not None else SINGLE_RUN
+
+
@dataclass(frozen=True)
class ScenarioFolders:
"""Where one scenario's run keeps its files: the home its application lives in, and what the run keeps.
diff --git a/automation/holds/regeneration.py b/automation/holds/regeneration.py
index 05697e30e..cfbe9584e 100644
--- a/automation/holds/regeneration.py
+++ b/automation/holds/regeneration.py
@@ -1,15 +1,13 @@
import threading
-from typing import AbstractSet, Optional
+from typing import Optional
import pytest
+from sampletones_application.services.regeneration.jobs import ListeningJob, RenderJob
from sampletones_application.services.regeneration.service import (
RegenerationService,
)
from sampletones_application.services.result import ServiceError
-from sampletones_core.constants.enums import ChannelName
-from sampletones_core.exporters import Features
-from sampletones_core.reconstructions import Reconstruction
class RegenerationHold:
@@ -17,7 +15,10 @@ class RegenerationHold:
it.
The rebuild waits where the real one runs, so the application draws and answers while the edit
- is on its way, and once released the real rebuild computes the edit's result. The hold lets
+ is on its way, and once released the real rebuild computes the edit's result. A rendering of
+ what the reader hears, which the same worker runs on opening a document and on a box ticked,
+ goes through at once, so the document stands drawn while the rebuilds wait; a voice's preview
+ in the sequencer is a listening on a worker of its own and passes through the same way. The hold lets
every rebuild through from its release on, until the scenario holds again. A scenario standing
for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service
reports the way it reports a rebuild that raised.
@@ -31,21 +32,19 @@ def __init__(self) -> None:
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Replaces the service's rebuild with one that waits for the release before it computes."""
- rebuild = RegenerationService._run # pylint: disable=protected-access
-
- def held(
- service: RegenerationService,
- reconstruction: Reconstruction,
- channel_name: ChannelName,
- features: Features,
- heard: AbstractSet[int],
- ) -> None:
+ run = RegenerationService._run # pylint: disable=protected-access
+
+ def held(service: RegenerationService, job: RenderJob) -> None:
+ if isinstance(job, ListeningJob):
+ run(service, job)
+ return
+
self._wait()
if self._failure is not None:
service._emit(ServiceError(exception=self._failure)) # pylint: disable=protected-access
return
- rebuild(service, reconstruction, channel_name, features, heard)
+ run(service, job)
monkeypatch.setattr(RegenerationService, "_run", held)
diff --git a/automation/paths.py b/automation/paths.py
index 17ee9a728..50acd520a 100644
--- a/automation/paths.py
+++ b/automation/paths.py
@@ -13,3 +13,4 @@
REPORTS_FILE: Final[str] = "reports.jsonl"
NO_BUS_FILE: Final[str] = "no-bus"
FAILURE_SCREENSHOT: Final[str] = "failure.png"
+SHIPPED_DOCUMENTATION_FOLDER: Final[str] = "shipped-documentation"
diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py
index f7289fe42..7862d8299 100644
--- a/automation/plugin/fixtures.py
+++ b/automation/plugin/fixtures.py
@@ -28,17 +28,19 @@
screen_size,
)
from automation.holds.base import Holds
-from automation.paths import FAILURE_SCREENSHOT
+from automation.paths import FAILURE_SCREENSHOT, SHIPPED_DOCUMENTATION_FOLDER
from automation.plugin.constants import DISPLAY_KEY
from automation.render_thread import QueueRenderThread
from automation.screen import Screen
from automation.worlds.home import World, lived_in_world
from sampletones_application.application import Application
from sampletones_application.config.profile import UserProfile
+from sampletones_shared.documentation.site import DocumentationSite
ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0
STAND_IN_PROGRAMS: Final[str] = "programs"
REVEALS_FILE: Final[str] = "reveals.log"
+OPENS_FILE: Final[str] = "opens.log"
DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey()
@@ -87,7 +89,7 @@ def screen_boundaries_fixture(
errors = ErrorRecords()
errors.install()
artifacts = Path(os.environ[ARTIFACTS_VARIABLE])
- file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE)
+ file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE, artifacts / OPENS_FILE)
file_manager.install(monkeypatch)
spawns = SpawnGuard()
spawns.install()
@@ -125,6 +127,7 @@ def screen_application_fixture(
world.write(profile)
application = Application(
profile=profile,
+ documentation=DocumentationSite(Path.home() / SHIPPED_DOCUMENTATION_FOLDER),
reconstruction_path=startup.reconstruction,
project_path=startup.project,
)
@@ -155,6 +158,7 @@ def screen_fixture(
window_manager=window_manager,
language=screen_application.language,
shortcuts=screen_application.shortcuts,
+ sound_settled=screen_application.sound_settled,
dialogs=screen_boundaries.dialogs,
errors=screen_boundaries.errors,
file_manager=screen_boundaries.file_manager,
diff --git a/automation/screen.py b/automation/screen.py
index e5c011723..6b0e89e2a 100644
--- a/automation/screen.py
+++ b/automation/screen.py
@@ -68,6 +68,7 @@
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_BUTTON,
+ TAG_GLOBAL_DIALOG_DOCUMENTATION,
TAG_GLOBAL_DIALOG_ERROR,
TAG_GLOBAL_DIALOG_FILE_NOT_FOUND,
TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT,
@@ -106,6 +107,7 @@ def __init__(
window_manager: WindowManager,
language: LanguageManager,
shortcuts: ShortcutManager,
+ sound_settled: Callable[[], bool],
dialogs: ScriptedFileDialogs,
errors: ErrorRecords,
file_manager: FileManagerStandIn,
@@ -118,6 +120,7 @@ def __init__(
self._window_manager = window_manager
self._render_thread = render_thread
self._shortcuts = shortcuts
+ self._sound_settled = sound_settled
self._dialogs = dialogs
self._errors = errors
self._file_manager = file_manager
@@ -142,6 +145,11 @@ def __init__(
self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND)
self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT)
self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN)
+ self.documentation_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_DOCUMENTATION)
+
+ def sound_settled(self) -> bool:
+ """Whether the open reconstruction's sound stands landed, which a box or an edit sends to the worker."""
+ return bool(self.bridge.ask(self._sound_settled))
def expect(
self,
@@ -186,6 +194,10 @@ def status(self) -> str:
"""What the status bar along the bottom of the window says."""
return self.bridge.ask(lambda: read_label(STATUS_BAR))
+ def status_drawn(self) -> str:
+ """What the status bar says, read on the render thread itself, which a recording takes after every frame."""
+ return read_label(STATUS_BAR)
+
def shows_text(self, words: str) -> bool:
"""Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open."""
return self.bridge.ask(lambda: read_visible_text(words))
@@ -272,6 +284,11 @@ def revealed(self) -> Tuple[Path, ...]:
"""Every path the application asked the desktop's file manager to show, in order."""
return self._file_manager.shown()
+ def opened(self) -> Tuple[Path, ...]:
+ """Every file the application handed to the desktop's program for its kind, such as a page for
+ the browser, in order."""
+ return self._file_manager.opened()
+
def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]:
"""Every highlight standing on a table, with the color the application laid it in."""
return self._highlights.standing()
diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py
index 773429fa4..167ef648b 100644
--- a/automation/steps/reconstructions.py
+++ b/automation/steps/reconstructions.py
@@ -1,17 +1,24 @@
import operator
+from functools import partial
from pathlib import Path
-from typing import Final, List, Optional
+from typing import Callable, Final, List, Optional, Tuple
+from automation.dearpygui.items.colors import read_theme
from automation.dearpygui.items.types import Item
from automation.holds.regeneration import RegenerationHold
from automation.screen import Screen
+from automation.views.bar_graph import BarGraph
+from automation.vocabulary.graphs import WAVEFORM_RECONSTRUCTION
from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
from sampletones_shared.constants.symbols import TITLE_SEPARATOR
from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY
+FADE_TIMEOUT_SECONDS: Final[float] = 120.0
+
APPLICATION_NAME: Final[str] = "global.dialog.title.main_window"
UNTITLED: Final[str] = "global.dialog.label.untitled"
BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration"
@@ -57,7 +64,7 @@ def reconstruction_row(screen: Screen, path: Path) -> Item:
if not browser.is_open(above):
browser.open_by_click(above)
screen.expect(
- lambda: browser.is_open(above),
+ partial(browser.is_open, above),
bool,
description=f"{browser.label(above)} open",
)
@@ -72,12 +79,68 @@ def load_from_the_browser(screen: Screen, path: Path) -> None:
def expect_open(screen: Screen, path: Path) -> None:
- """Waits until the reconstruction stored at ``path`` is the one open."""
+ """Waits until the reconstruction stored at ``path`` is the one open and its sound has landed."""
screen.expect(
lambda: screen.reconstructions.shows_open(path),
bool,
description=f"{path.name} open",
)
+ screen.expect(screen.sound_settled, bool, description=f"the sound of {path.name} landed")
+
+
+def reconstruction_shade(screen: Screen) -> str:
+ """The theme the waveform's reconstruction line wears, which the fade swaps while its sound is on its way.
+
+ Raises:
+ AssertionError: If the line wears no theme, which would leave a fade nothing to swap.
+ """
+ waveform = screen.reconstructions.waveform
+ shade = screen.theme_of(waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION)))
+ assert shade is not None, "the reconstruction line wears no theme"
+ return shade
+
+
+def shade_reading(screen: Screen) -> Callable[[], Optional[str]]:
+ """A reading of the shade the reconstruction line wears, for a frame recording to take after every frame.
+
+ The reading runs on the render thread, so it reads the theme directly.
+ """
+ tag = screen.reconstructions.waveform.series_tag(screen.words(WAVEFORM_RECONSTRUCTION))
+ return partial(read_theme, tag)
+
+
+def wait_for_the_fade_to_end(
+ screen: Screen,
+ unfaded: str,
+) -> None:
+ """Waits until the reconstruction line wears ``unfaded`` again, which is when the last sound has landed."""
+ screen.bridge.expect(
+ lambda: reconstruction_shade(screen),
+ unfaded.__eq__,
+ description="the fade ended",
+ timeout=FADE_TIMEOUT_SECONDS,
+ )
+
+
+def drag_item_to(
+ graph: BarGraph,
+ standing: List[int],
+ item: int,
+ *,
+ end: float,
+ bounds: Tuple[int, int],
+) -> Tuple[int, List[int]]:
+ """Drags the bar of ``item`` to ``end`` and returns the item the plot moved and the items expected after.
+
+ The item the plot puts under the release takes the value there, brought within ``bounds``, and
+ every other item stands as it did.
+ """
+ release = graph.drag_item(item, start=standing[item], end=end)
+ index, value = graph.item_under(release)
+ low, high = bounds
+ expected = [*standing]
+ expected[index] = min(max(round(value), low), high)
+ return index, expected
def edit_envelope(
@@ -223,3 +286,14 @@ def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, cha
def edited_title(screen: Screen, document: Path) -> str:
"""The window title while the open reconstruction ``document`` carries unsaved changes."""
return titled(screen, marked(document.name, unsaved=True))
+
+
+def leave_letting_it_go(screen: Screen) -> None:
+ """Presses Exit, confirms the question about unsaved changes and waits for the application to close."""
+ prompt = screen.reconstructions.unsaved_prompt
+ screen.press_shortcut(ShortcutId.EXIT)
+ screen.expect(prompt.is_shown, bool, description="the question about leaving")
+
+ prompt.confirm()
+
+ assert screen.wait_for_exit()
diff --git a/automation/views/instruments.py b/automation/views/instruments.py
index c0cf84926..f44ca7548 100644
--- a/automation/views/instruments.py
+++ b/automation/views/instruments.py
@@ -1,4 +1,4 @@
-from typing import Final, Optional, Tuple
+from typing import Final, List, Optional, Tuple
from automation.dearpygui.bridge import Bridge
from automation.dearpygui.hand import Hand
@@ -22,6 +22,7 @@
from sampletones_application.tags.graphs import SUF_GRAPH, SUF_GRAPH_RAW_DATA
from sampletones_application.tags.reconstructions import (
SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE,
+ SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE,
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW,
TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT,
TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION,
@@ -31,6 +32,7 @@
from sampletones_core.constants.enums import ChannelName, FeatureKey
AUDITION_GROUP: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP)
+MESSAGE: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE)
class Instruments:
@@ -87,18 +89,22 @@ def read() -> Tuple[FeatureKey, ...]:
def field(self, channel: ChannelName, feature: FeatureKey) -> str:
"""The name of the field holding the sequence of ``channel``'s ``feature`` envelope."""
- return compose_tag(
- TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR,
- channel,
- feature,
- SUF_GRAPH_RAW_DATA,
- SUF_TEXT,
- )
+ return sequence_field(channel, feature)
+
+ def takes_edits(self, channel: ChannelName, feature: FeatureKey) -> bool:
+ """Whether ``channel``'s ``feature`` field takes a typed sequence, which it holds while the envelopes are on
+ their way.
+ """
+ return self._bridge.ask(lambda: read_takes_edits(channel, feature))
def envelope(self, channel: ChannelName, feature: FeatureKey) -> str:
"""The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it."""
return str(self._bridge.ask(lambda: read_value(self.field(channel, feature))))
+ def envelope_items(self, channel: ChannelName, feature: FeatureKey) -> List[int]:
+ """The items of the envelope ``channel`` draws for ``feature``, read from the field under its graph."""
+ return [int(item) for item in self.envelope(channel, feature).split()]
+
def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str]:
"""The theme the field wears, which marks a sequence refused or one an export shortens."""
return self._bridge.ask(lambda: read_theme(self.field(channel, feature)))
@@ -147,6 +153,10 @@ def offers_audition(self) -> bool:
"""Whether the Audition switch stands, which it does while an instrument is open."""
return self._bridge.ask(lambda: read_item(AUDITION_GROUP)).shown
+ def says(self) -> Optional[str]:
+ """The words the card shows in place of its tabs, or ``None`` while the tabs stand."""
+ return self._bridge.ask(read_words)
+
def audition(self) -> str:
"""The generator the Audition switch names."""
return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION)))
@@ -171,6 +181,33 @@ def export(self, channel: ChannelName) -> None:
self._hand.click(export_button(channel))
+def sequence_field(channel: ChannelName, feature: FeatureKey) -> str:
+ """The name of the field holding the sequence of ``channel``'s ``feature`` envelope."""
+ return compose_tag(
+ TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR,
+ channel,
+ feature,
+ SUF_GRAPH_RAW_DATA,
+ SUF_TEXT,
+ )
+
+
+def read_takes_edits(channel: ChannelName, feature: FeatureKey) -> bool:
+ """Whether ``channel``'s ``feature`` field takes a typed sequence. Runs on the render thread."""
+ return read_item(sequence_field(channel, feature)).enabled
+
+
+def read_words() -> Optional[str]:
+ """The words the instruments card shows in place of its tabs, or ``None`` while the tabs stand.
+
+ Runs on the render thread, so a frame recording can take it after every frame.
+ """
+ if not read_item(MESSAGE).shown:
+ return None
+
+ return str(read_value(MESSAGE))
+
+
def tab(channel: ChannelName) -> str:
"""The name of the tab of ``channel``."""
return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel)
diff --git a/automation/views/sequencer.py b/automation/views/sequencer.py
index 8161526e8..5ca7de6d6 100644
--- a/automation/views/sequencer.py
+++ b/automation/views/sequencer.py
@@ -3,8 +3,9 @@
import dearpygui.dearpygui as dpg
from automation.dearpygui.bridge import Bridge
-from automation.dearpygui.geometry import Point
+from automation.dearpygui.geometry import Point, Rect
from automation.dearpygui.hand import Hand
+from automation.dearpygui.items.colors import read_theme_text_color
from automation.dearpygui.items.reading import read_item
from automation.dearpygui.items.regions import read_region_view, read_table
from automation.dearpygui.items.texts import read_label
@@ -207,6 +208,19 @@ def positions(self) -> int:
lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS
)
+ def cell_box(self, channel: Optional[ChannelName], position: int) -> Optional[Rect]:
+ """Where the entry of ``channel`` at frame ``position`` stands on the screen."""
+ return self._bridge.ask(lambda: read_item(_order_entry(channel, position)).rect)
+
+ def text_color(self, channel: Optional[ChannelName], position: int) -> Optional[Tuple[float, ...]]:
+ """The text color the theme bound to the entry of ``channel`` at frame ``position`` sets."""
+
+ def read() -> Optional[Tuple[float, ...]]:
+ theme = dpg.get_item_info(_order_entry(channel, position))["theme"]
+ return read_theme_text_color(theme) if theme is not None else None
+
+ return self._bridge.ask(read)
+
def click(self, channel: Optional[ChannelName], position: int) -> None:
"""Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first."""
entry = self._bridge.ask(lambda: _order_entry(channel, position))
diff --git a/automation/views/tracker.py b/automation/views/tracker.py
index 9c7ca9cc0..e64888d93 100644
--- a/automation/views/tracker.py
+++ b/automation/views/tracker.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from automation.dearpygui.bridge import Bridge
-from automation.dearpygui.geometry import Point
+from automation.dearpygui.geometry import Point, Rect
from automation.dearpygui.hand import Hand
from automation.dearpygui.items.colors import read_theme_text_color
from automation.dearpygui.items.reading import read_item
@@ -61,7 +61,7 @@ def _table_rows() -> Tuple[Item, ...]:
return tuple(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1))
-def _frame_rows() -> Dict[int, int]:
+def frame_rows() -> Dict[int, int]:
"""Where each pattern row of the shown frame stands among the table's rows, by its index. Runs on the render
thread.
"""
@@ -181,6 +181,10 @@ def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool:
"""Whether the caret stands in ``channel``'s cell on ``row``, as the box drawn over the grid shows it."""
return self._bridge.ask(_caret_cell) == (row, channel)
+ def caret_box(self) -> Optional[Rect]:
+ """Where the caret's mark stands on the screen, while it shows one."""
+ return self._bridge.ask(caret_mark)
+
def caret_row(self, channel: Optional[ChannelName]) -> Optional[int]:
"""The pattern row the caret stands on in ``channel``'s column, if it stands in that column."""
cell = self._bridge.ask(_caret_cell)
@@ -208,7 +212,7 @@ def read() -> Optional[int]:
return None
middle = band.y + band.height * HALF
- for row, table_index in _frame_rows().items():
+ for row, table_index in frame_rows().items():
number = read_item(_number(table_index))
if number.rect is None or not number.visible:
continue
@@ -237,7 +241,7 @@ def numbers_below(self, count: int) -> Tuple[str, ...]:
"""What the row numbers of the ``count`` rows nearest below the frame read, top to bottom."""
def read() -> Tuple[str, ...]:
- last = max(_frame_rows().values())
+ last = max(frame_rows().values())
return tuple(read_label(_number(index)) for index in range(last + 1, last + 1 + count))
return self._bridge.ask(read)
@@ -262,7 +266,7 @@ def tinted_rows_in_view(self) -> FrozenSet[int]:
def read() -> FrozenSet[int]:
return frozenset(
row
- for row, table_index in _frame_rows().items()
+ for row, table_index in frame_rows().items()
if read_item(_number(table_index)).visible
and dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, table_index)
)
@@ -304,7 +308,7 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]:
return None
x, y = middle
- for row, table_index in _frame_rows().items():
+ for row, table_index in frame_rows().items():
number = read_item(_number(table_index))
if number.rect is None or not number.visible or not number.rect.y <= y < number.rect.y + number.rect.height:
continue
@@ -318,9 +322,9 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]:
return None
-def _caret_middle() -> Optional[Tuple[float, float]]:
- """The middle of the box the caret overlay draws, while it shows one. Runs on the render thread."""
- rectangle = CaretOverlay._rectangle # pylint: disable=protected-access
+def caret_mark() -> Optional[Rect]:
+ """The mark the caret overlay draws under the active character, while it shows one. Runs on the render thread."""
+ rectangle = CaretOverlay._mark # pylint: disable=protected-access
if rectangle is None or not dpg.does_item_exist(rectangle):
return None
@@ -329,4 +333,13 @@ def _caret_middle() -> Optional[Tuple[float, float]]:
return None
corner, far = configuration["pmin"], configuration["pmax"]
- return (corner[0] + far[0]) * HALF, (corner[1] + far[1]) * HALF
+ return Rect(x=corner[0], y=corner[1], width=far[0] - corner[0], height=far[1] - corner[1])
+
+
+def _caret_middle() -> Optional[Tuple[float, float]]:
+ """The middle of the caret's mark, while it shows one. Runs on the render thread."""
+ mark = caret_mark()
+ if mark is None:
+ return None
+
+ return mark.x + mark.width * HALF, mark.y + mark.height * HALF
diff --git a/automation/vocabulary/graphs.py b/automation/vocabulary/graphs.py
new file mode 100644
index 000000000..5fb5726a9
--- /dev/null
+++ b/automation/vocabulary/graphs.py
@@ -0,0 +1,4 @@
+from typing import Final
+
+WAVEFORM_READING: Final[str] = "global.graph.message.waveform_reading"
+WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction"
diff --git a/automation/vocabulary/instruments.py b/automation/vocabulary/instruments.py
index e6e1c52db..17dc88cb3 100644
--- a/automation/vocabulary/instruments.py
+++ b/automation/vocabulary/instruments.py
@@ -1,3 +1,5 @@
from typing import Final
TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long"
+PREPARING: Final[str] = "reconstructions.instruments.message.preparing"
+NOTHING_LOADED: Final[str] = "global.dialog.message.reconstruction_no_data"
diff --git a/automation/vocabulary/playback.py b/automation/vocabulary/playback.py
index 7361bedc0..5434e193c 100644
--- a/automation/vocabulary/playback.py
+++ b/automation/vocabulary/playback.py
@@ -2,4 +2,5 @@
PAUSE: Final[str] = "global.menu.label.item_playback_pause"
PLAY: Final[str] = "global.menu.label.item_playback_play"
+PLAY_VOICE: Final[str] = "global.context.label.play"
RESUME: Final[str] = "global.menu.label.item_playback_resume"
diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md
index 863e4e858..3e778c12c 100644
--- a/docs/concepts/reconstruction.md
+++ b/docs/concepts/reconstruction.md
@@ -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
diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md
index dee2b7839..ba07661e8 100644
--- a/docs/development/application/playback.md
+++ b/docs/development/application/playback.md
@@ -22,7 +22,7 @@ Two terms recur. A **source** is what plays one kind of audio: a reconstruction,
An instrument's audition is a preview of that kind. It reads the generator chosen on the instruments card, takes the note from the keyboard's two octaves above the octave the tracker types in, and renders the voice through the same two steps a tracker row takes: the step from the instrument's own pitch to the note, at full volume. The plot card draws the same rendering at the pitch the instrument stands at, so what is seen and what is heard name one generator.
-**A preview sounds the frames it renders.** An instrument sounds the envelopes it has, and a sample previews the frames its reconstruction recorded. Both sound exactly what they carry, because a recording's drive matters only while the conversion runs.
+**A preview sounds the frames it renders.** An instrument sounds the envelopes it has, and a sample previews the frames its reconstruction recorded. Both sound exactly what they carry, because a recording's drive matters only while the conversion runs. A sample's preview sounds the render the render cache keeps, or the one a worker of its own lands a moment later, so the render thread computes no sample for it; a newer click replaces a preview still on its way, and playback the reader starts forgets one, so the voice heard is the last one asked for and a song started meanwhile keeps the device. A reconstruction a browser tree plays is read from disk and previewed the same way.
**A voice sounded on its own runs to its release, or to the length it is offered.** A row holds a voice for as long as the pattern asks. An audition and the voice list's preview have no row behind them, so each has a span of its own. A volume dimension that ends at silence releases the voice, and the sound stops there. A voice that circles from a loop point never reaches a last item, so it sounds for the ticks `AUDITION_TICKS` offers it. One count serves both spans, so the plot card draws exactly the frames the keyboard sounds.
@@ -153,6 +153,7 @@ The manager's own playback is held to the same rule. A stop waits a while for it
| What the reader is told when a sound fails | `PlaybackFailurePresenter` (`coordinators/playback/failures.py`) |
| The boundary that hands a transport command's failure to the presenter | `GuardedPlayer` (`coordinators/playback/guard.py`) |
| The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) |
+| A whole document's preview, read from the render cache or rendered on a worker of its own | `DocumentPreviews` (`logic/shared/previews.py`) |
| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) |
| The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.py`) |
| What a row's voice and pitch start, and what they move | `apply_row` and `note_step` (`sampletones_core/performance/rows.py`) |
diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md
index f6ed122ef..701ac6179 100644
--- a/docs/development/application/reconstruction-edits.md
+++ b/docs/development/application/reconstruction-edits.md
@@ -5,9 +5,14 @@ This document describes how the Reconstructions tab changes the document it has
wait on its edits. Consult it when adding a gesture that edits the open document, one that reads or puts
away the whole document, or one that replaces the document from outside the tab.
-A rebuild of an edited channel runs on a worker thread, so the reader can make the next change before the
-last one has landed. Every rule below keeps the document as though each change had landed before the next
-one was made.
+A rebuild of an edited channel, and the render of the audio it sounds, run on a worker thread, so the
+reader can make the next change before the last one has landed and the frames keep coming while the
+rebuild renders. The worker takes the renders the cache holds for the document with it and hands back the
+audio of every channel in play, the mix of what the reader hears, and the envelopes each channel exports
+for the recordings heard; the landing keeps the audio in the cache and installs the envelopes where the
+instruments panel reads them before the tab reads either. A change to what is heard whose sound the
+cache keeps none of is a step of the same line, rendered by the same worker. Every rule below keeps the
+document as though each change had landed before the next one was made.
---
@@ -18,6 +23,10 @@ frequency, and a gesture that reads or puts away the whole document. The line ta
the order the reader asked for them, and each step is built from the document the step before it left. Two
quick edits therefore both land, whichever dimensions or channels they touch.
+A step the line takes, or a result it lands, may ask for another step, as an undo redrawing the document
+asks for a listening. The ask joins the line and is taken once the step or the landing has returned, so a
+gesture never runs inside another one.
+
## A step carries what the reader moved
A channel change holds the dimensions the reader moved alone. At its turn it is written over the
@@ -45,6 +54,11 @@ The instruments panel reads the document's envelopes through the changes still o
the plots and the byte figures answer for the document once the line empties. A change the line lets go of,
such as a failed rebuild, redraws the panel from the document as it stands.
+A removal, a retune, an undo and a new choice of what is heard leave the envelopes to the worker. The panel
+keeps the envelopes it drew until the next ones land, and it takes no edit of them meanwhile, since an edit
+would be written against frames the reader no longer hears. A document just opened has none to keep, so the
+panel says its instruments are being prepared.
+
## A step that no longer applies is skipped
A step meets the document the steps before it left. A removal of a recording an earlier step already took
@@ -69,7 +83,37 @@ edits before it, and the sample open on the tab then takes it as a step of its o
joins the rate change's history entry with every other sample. A batch result for a sample edited since the
batch started is retuned again from what the sample now holds.
+## A change to what is heard is a step
+
+A box on the stems card or above the waveform changes the reader's listening. The stems card and the
+ribbon answer at once. The waveform, the audio and the envelopes the instruments panel draws are read
+from what the worker landed for the hearing. Where the screen holds none of it, the panel owes the showing
+and asks for a listening step, which renders the hearing of the document as it then stands and lands like
+a rebuild. The reader's choice travels onto a new record by one rule, written in core and read by the
+worker and the listening state alike (`carried_hearing`), so the sound and envelopes a landing installs
+are the ones the listening then reads. One listening waiting answers every choice made before it is
+taken. A freshly opened document, a removal, a retune, an undo and a save-as are shown the same way,
+their sound and envelopes landed by a listening. Every gesture that reads the sound joins what the panel
+owes, so a flip of the source or a retune during a showing on its way settles with it. A WAV export is a
+reading of what plays like a showing: it writes the kept sound at once, or waits beside the showing and
+is written for what the reader hears once the sound lands. A document put away before that, or a reading
+that fails, drops the export and tells the reader so. A channel change reaching its turn while the
+envelopes of what is heard are on their way waits behind a listening of that choice.
+
+## The line catches up with what is heard
+
+A listening renders the choice standing when it starts, and the reader may move on before it lands. Its
+envelopes are kept only where they still answer the reader's choice. Once the line stands idle after a step
+or a landing, the open document holds the envelopes of what the reader hears, or the line asks the worker
+for them. The panel and the waveform therefore settle on the reader's last choice once the reader stops.
+A listening already running for the choice answers it, so no reading is rendered twice. A choice whose
+listening failed is asked for again once the reader makes another.
+
## The waveform fades while the line is busy
-The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the
-document is being rewritten for the whole span, however many steps it takes.
+The waveform is faded from the moment a step waits or runs until the last one lands, so a reader sees the
+document is being rewritten for the whole span, however many steps it takes. The status bar names what
+the fade waits for: `global.graph.message.waveform_regenerating` for an edit on its way, and
+`global.graph.message.waveform_reading` for a change to what is heard or a document just opened. The line
+reports the kind of work it is busy with, so a hand-over from an edit to a reading of the sound changes
+the words under the same fade.
diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md
index c38ae2d30..b9af65f9b 100644
--- a/docs/development/application/render-thread.md
+++ b/docs/development/application/render-thread.md
@@ -19,6 +19,24 @@ 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
+recordings heard. It hands them back inside its result. The landing keeps the audio in the render cache
+and installs the envelopes, on the render thread. A reading the cache leaves unanswered is asked of the
+same worker and lands the same way. What a worker produces for the interface therefore reaches what the
+interface reads through the queue alone, and the render thread computes no sample while the worker
+renders. A document previewed elsewhere reads the same cache and asks a worker of its own for what the
+cache lacks (`DocumentPreviews` in `logic/shared/previews.py`), and sounds at the landing; a
+reconstruction file a browser tree plays is read from disk and previewed the same way. The render cache
+keeps what the landings adopt, so its writers are the landings, every one of them on the render thread.
+
A logic object hearing a worker's report, such as a library generation or the audio device's position,
posts its own handler to the queue the same way. The logic layer reaches the queue, and `utils/gui`
belongs to the visual layers.
diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md
index f0e330ceb..2b70e8b9e 100644
--- a/docs/development/application/screens.md
+++ b/docs/development/application/screens.md
@@ -149,7 +149,9 @@ key held past 0.275 s repeats. A click and a key press therefore go down and com
ImGui reads the press on one frame and the release on the next at any frame rate. A key stands down for a
single frame, too briefly to repeat, and the presses of a double-click land two frames apart. Where two
frames take longer than the double-click time, the hand raises `SlowFramesError` and says how far apart the
-presses landed.
+presses landed. A burst of more presses, such as the triple click a plot plays nothing on, holds every later
+press to the same time after the one before it, since a plot takes a press landing later than that as a
+click of its own.
A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its
second press goes down. A scenario holding that press for as long as a person does sees what the answer
@@ -200,8 +202,9 @@ how a menu popup's width is measured.
browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and
so do the stand-ins below.
- **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that
- file manager and `xdg-open` note each path they are asked to show. The application's own reveal code
- runs, and `screen.revealed()` reads what it showed.
+ file manager and `xdg-open` note each path they are handed. The application's own code runs:
+ `screen.revealed()` reads the folders and files it asked the file manager to show, and `screen.opened()`
+ the files `xdg-open` handed to their program, such as the documentation's page for the browser.
- **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request
a title bar's close button sends.
- **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and
diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md
index 4530fb5ca..6da7bfdbf 100644
--- a/docs/development/application/sequencer-blocks.md
+++ b/docs/development/application/sequencer-blocks.md
@@ -132,6 +132,8 @@ Both panels read the cell under a held pointer off their own geometry, because D
The tracker's row lookup is arithmetic. It measures from the first row's top edge and divides by the row height, which holds while the rows are evenly pitched. That is what the pattern table's zero vertical cell padding and item spacing are for: a vertical padding there would drift the lookup further down the grid. The order's position lookup is arithmetic in the same way and takes its pitch from the first two columns. Its channel lookup walks the rows, because the master row stands apart from the channels beneath it.
+The caret (`ui/elements/table/caret.py`) draws on a front viewport drawlist from the rectangle the active cell reported: a mark under one character, measured off the label's width in the cell's font, and a frame around the cell. Both tables give their selectables zero item spacing, so a label starts at its rectangle's left edge and the character arithmetic holds in each. A table answers a scroll on the frame after the one that draws its highlights, so a move that re-centers the grid holds its painting one frame, the way the playhead does, and hands the caret the distance the pending scroll will carry the row.
+
### A drag past the edge carries the view
A pointer held past the cells on screen travels the grid under it, so a selection reaches further than the viewport holds. `grid/scroll/` holds the travel. A grid says which axis it scrolls along and where its cells stand along it, and one class reads the two each frame. The tracker travels vertically and the order horizontally, from that same class.
diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md
index f5ea864d1..b4b1ca1e6 100644
--- a/docs/development/application/stems.md
+++ b/docs/development/application/stems.md
@@ -36,7 +36,7 @@ The reconstruction tab's Stems card turns the recorded assignment into a listene
### What the code guarantees
-A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and the original mix covers the recordings of the stems heard on any channel. It is silence while no stem is heard, and there is none while no recording loaded. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state.
+A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`). A channel every owner of which is heard keeps its render as it is, which is exact because a resting frame renders as silence already. A document heard whole therefore reads the kept renders as they are. The original mix covers the recordings of the stems heard on any channel. It is a worker's render like the rest, kept under the recordings it was mixed from, so every document holding those recordings reads one mix. It is silence while no stem is heard, and there is none while no recording loaded. A surface paints a lane as one series per recording, traced over the stretches that recording holds, so a lane costs as many items as it has owners however many times the document divides. A box on the stems card repaints as a theme bound again per recording. The listening state holds the channels each stem is heard on and the channels kept on. It carries both onto a new record by the core's one rule (`carried_hearing`), which the regeneration worker applies to the document it rebuilds. The panel logic re-answers the stems view model, the waveform and the audio data whenever either choice changes. The waveform and the audio are read from the render cache under the hearing they sound, and the envelopes the instruments panel draws are the ones the regeneration worker exported for the recordings heard. A hearing the cache keeps no sound for, or no envelopes for, is rendered on that worker as a step of the open document's line ([Editing the open reconstruction](reconstruction-edits.md)), and the showing waits for it, so a box computes no sample and reads no frame on the render thread. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state.
Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. A confirmed removal is a step of the open document's edits, taken after the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)), and it lands as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. They differ in what the instruments panel does. A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel keeps drawing them. A removal redraws every channel from the document it leaves.
diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md
index d53478f55..10cfad301 100644
--- a/docs/development/application/undo.md
+++ b/docs/development/application/undo.md
@@ -31,11 +31,11 @@ An entry therefore owns the values its gesture made:
| Project rate | each sample's document with its new setup, which shares the instructions |
| Settings, properties | the settings and info shells |
-A reconstruction edit follows the same rule. `RegenerationService` rebuilds one channel and builds the new document around it. The apply path installs that document through `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)).
+A reconstruction edit follows the same rule. `RegenerationService` rebuilds one channel, builds the new document around it and renders the audio the document sounds, all on its worker. The apply path keeps that audio in the render cache and installs the document through `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)).
**The types enforce the split.** A value's model is frozen, so a write in place raises where it is made. A core test walks every class a project reaches (`tests/unit/sampletones_core/project/test_values.py`). It requires each class to be a declared shell or a frozen value made of values. The history audit (`tests/suite/history/`) replays gestures against a model of the stack. After every step, it checks each stored entry against the state it recorded.
-**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render.
+**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds and the render thread fills with what the regeneration workers hand back: the channels and the mix of a rebuilt document, the mix of what the reader hears of it, and the whole of a voice previewed in the sequencer. The cache renders nothing itself. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render.
## The open voice across a restore
@@ -53,7 +53,7 @@ A transaction can carry a *coalesce key* that names the gesture's target, such a
**Detection happens in the controller.** `ProjectController._touch()` fires `on_mutation` on every fine-grained mutation as it lands. A batch defers the view notifications and the dirty stamp and leaves this signal immediate, so the check below sees each mutation inside the transaction that caused it. `HistoryManager.handle_mutation` counts the mutations inside a transaction and rejects any that occur outside one. Under strict deployment it raises `UntrackedMutationError`. Otherwise it heals by recording the mutation as its own entry. This makes completeness a checkable property.
-Under strict deployment each committed snapshot also carries a fingerprint, and every restore verifies that the reproduced project matches it. Capture-time fingerprints memoize each reconstruction's hash by object identity, since a reconstruction is a value and its content is fixed for the object's lifetime. That reduces the per-gesture cost to the light structure. Restore-time verification always hashes fresh, so an in-place mutation of shared state is caught and not masked by the memo.
+Under strict deployment each committed snapshot also carries a fingerprint, and every restore verifies that the reproduced project matches it. Capture-time fingerprints memoize by object identity the hash of every value the project is made of: each reconstruction, each stream and each owner record in it, and each pattern of the song. Each is a value whose content is fixed for the object's lifetime, and a gesture keeps every value it leaves alone by identity, so a commit hashes the values the gesture replaced and the light structure around them. Restore-time verification always hashes everything fresh, so an in-place mutation of shared state is caught and not masked by the memo.
## Save point and lifecycle
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index e748e4ef3..0957d55b4 100644
--- a/docs/development/architecture.md
+++ b/docs/development/architecture.md
@@ -205,9 +205,12 @@ 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 knows no panel, view model or logic object.
+- A service imports nothing from `logic/`, `view_model/`, `ui/`, `coordinators/` or `config/`, which the boundary check holds.
+- What a service is handed must be safe on its worker: a value, a kernel it drives alone for the span of a job (`RowSynthesizerProtocol`), or a store built for concurrent use. Each is named as a Protocol in the service's own subpackage and proven by a case. What a job produces for the interface travels in its result, so the landing installs it on the render thread. `RenderedDocument` carries the audio its worker rendered.
+- A service instance serves one client's line. A result reaches every subscriber of the instance that made it and names no sender, so a second line, such as the sequencer's previews beside the tab's rewrites, takes an instance of its own.
---
diff --git a/docs/development/assets.md b/docs/development/assets.md
index 68b4b298e..4374a74fe 100644
--- a/docs/development/assets.md
+++ b/docs/development/assets.md
@@ -1,7 +1,8 @@
# Assets
This document governs `assets/`, the checkout unit that makes the repository's own content: the icon suite
-the application ships, the demo tree, and the pictures of the application the guide and the README show.
+the application ships, the HTML documentation it opens, the demo tree, and the pictures of the application
+the guide and the README show.
Consult it before adding something the repository keeps that a person would
otherwise draw, record or arrange by hand, and before changing how one of these is made. What a checkout
unit is, and what it may import, is in [tooling](tooling.md).
@@ -28,6 +29,9 @@ same hook. A maker whose output depends on the machine's arithmetic, such as a c
`build/` and is run by hand. A maker whose output may differ by a pixel between machines, as a software
renderer's does, is run by hand too, and the pull request shows what changed.
+The documentation site is the one output the history leaves out. It repeats `docs/` in another form, so
+`docs/` stays the single copy and every wheel build derives the site afresh ([documentation](#documentation)).
+
## The makers
### Icons
@@ -40,6 +44,28 @@ from, `src/sampletones_assets/icons/`. `make icons` writes them, and `python -m
them elsewhere. The whole suite is committed, so every wheel, bundle and test run finds the icons where
they lie. Pillow rasterizes the suite; it is a development dependency, so the maker runs in a checkout alone.
+### Documentation
+
+`assets/documentation/` writes the user's documentation as HTML pages in the application's look, the pages
+**Help > Documentation** opens. The Markdown under `docs/` is the specification: the index, the glossary,
+and the guide, concepts, tools, formats and API sections become pages, each at the place its document holds
+in `docs/`, so a picture a page shows keeps its relative link. Each second-level heading becomes one of the
+application's cards, and the index's sections lay out the navigation beside every page. The look is the
+shared web layout ([tooling](tooling.md#pages-a-tool-writes)), in the application's default palette.
+
+The development pages, the changelog and anything else under `docs/` stay online: a link to one leads to
+the copy at the documentation address `pyproject.toml` names. A link to a missing document, heading or
+picture fails the run, so a site that builds holds no broken link. A site is sealed by a stamp written
+last, a hash over every file beside it, which names the pages exactly: the application replaces its copy
+of the pages when the stamp differs, and the self-check holds a build's pages to it.
+
+`make docs` writes the site into `src/sampletones_assets/documentation/`, which the history ignores, so a
+checkout opens its own documentation. `python -m assets.documentation -o DIR` writes it elsewhere; the
+folder must be empty or hold an earlier site, which the run replaces. The wheel's build hook,
+`hatch_build.py`, runs the same maker into a temporary folder for every wheel and carries the site at the
+same package path, so every installation and every bundle built from a wheel opens the pages it was built
+with. An editable install reads the checkout, where `make docs` writes the site.
+
### Demo tree
`assets/demo/` writes a documents folder a person can run the application from, with recordings beside
diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md
index ae5bc7fee..2025272bd 100644
--- a/docs/development/bugs-and-todos.md
+++ b/docs/development/bugs-and-todos.md
@@ -54,8 +54,9 @@ dimension the import starts carrying.
### Features
-* In-application guide/tutorial
-* Language selector
+* MIDI keyboard support.
+* In-application guide/tutorial.
+* Language selector.
* Reading only the bins the refinement asks for, since it transforms every bin the spectrum covers and
uses a handful per frame.
* Keeping the recordings a stopped folder scan has found, so stopping a long walk keeps the count the reader
@@ -87,11 +88,6 @@ dimension the import starts carrying.
frames. A rebuild makes each frame of the channel anew, so on a four-channel conversion an entry has
about a quarter of the document. Keeping the old instruction wherever the rebuilt one equals it brings an
entry down to the frames the edit changed ([undo](application/undo.md)).
-* Strict history hashes the whole song and the whole new document at every commit. On a four-channel
- conversion of 13,677 frames, hashing the document costs more than twice the rebuild of the edited
- channel, and the song's hash grows with the song. Development runs and the test suite pay for it.
- Memoizing each pattern's and each stream's hash by identity, as each document's is memoized now, brings
- a commit down to the parts its gesture replaced.
* Improve performance of the browser's favorite scan of the entire tree per click
* The `fft` and `logfft` analysis floors. Their window spans two cycles of the pulse's lowest note, so the
triangle's lowest octave reaches them by its harmonics alone. Covering it doubles their window and
@@ -126,6 +122,10 @@ currently out of line. An entry leaves when the code meets the contract again.
`RegenerationService._run` and reports through `_emit`, and the tracker view reads the caret's box from
`CaretOverlay._rectangle`. Each is a seam a scenario needs, which [screens](application/screens.md)
says the application offers as a public one.
+* The render thread still reads a reconstruction file and its recordings from disk, at an opening and
+ when a browser tree plays one, and renders an instrument's audition and the sequencer's instrument
+ preview; every other sound the interface draws or plays, the original line included, is a worker's
+ result ([the render thread](application/render-thread.md)).
## Bugs
diff --git a/docs/development/packages.md b/docs/development/packages.md
index 72bd29e9c..85383bf57 100644
--- a/docs/development/packages.md
+++ b/docs/development/packages.md
@@ -44,7 +44,7 @@ graph TD
|---------|---------------|
| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the command type the entry and the tools share |
| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, behavior, deployment, and these boundaries themselves — reached as package data and not by import |
-| `sampletones_assets` | The application icons and the bundled fonts, reached as package data |
+| `sampletones_assets` | The application icons, the bundled fonts and, in a wheel, the HTML documentation, reached as package data |
| `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats |
| `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file |
| `sampletones_application` | The DearPyGui front end |
diff --git a/docs/development/progress.md b/docs/development/progress.md
index 2be200d28..435042e17 100644
--- a/docs/development/progress.md
+++ b/docs/development/progress.md
@@ -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,
@@ -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
@@ -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
diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md
index 6027810e1..9e4e182a2 100644
--- a/docs/development/release/dependencies.md
+++ b/docs/development/release/dependencies.md
@@ -10,7 +10,7 @@ The interface is built with [DearPyGui](https://github.com/hoffstadt/DearPyGui),
## Core
-The reconstruction engine stands on the usual numerical stack, with `cupy` as an optional GPU backend. The GPU backend is installed as the extra that matches the machine's NVIDIA driver. See [GPU acceleration](../../guide/installation.md#gpu-acceleration) for enabling it.
+The reconstruction engine stands on the usual numerical stack, with `cupy` as an optional GPU backend. The GPU backend is installed as the extra that matches the machine's NVIDIA driver. See [GPU acceleration](../../guide/installation.md#gpu-acceleration) for enabling it. [GPU bundles](#gpu-bundles) says how a standalone executable carries it.
## Serialization
@@ -42,6 +42,10 @@ The icon suite is generated from a mark declared in `assets/icons/mark/config` a
Pillow rasterizes the suite. It is declared in the `assets` dependency group, which the `dev` group includes. It serves the assets unit alone, which the build environment never installs, and the bundle script passes `--exclude-module PIL` as well. `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is installed, and PyInstaller follows that import into the bundle. The application reads its icons as files, so the exclusion keeps Pillow's extension modules and the imaging libraries that come with them out of every bundle. `scripts/verify_bundle.py` holds the release bundles to it.
+## Documentation
+
+Every wheel build writes the HTML documentation ([assets](../assets.md#documentation)), so the build needs what that maker imports. `markdown-it-py` reads the Markdown, with the tables GitHub draws, and belongs to the `assets` group beside Pillow. The build hook names its own dependencies in `pyproject.toml`: markdown-it-py and the libraries the application's theme and palette code imports, each held to the project's own range, which a test compares. Every wheel is built in an isolated environment that installs them, so the first build on a machine reaches the network. `hatchling` 1.27 is the oldest build backend that reads the project's license fields.
+
## Calibration
The calibration harness scores renders with referees of its own, built on `numpy` and `scipy`. The `calibration` dependency group adds [Zimtohrli](https://github.com/google/zimtohrli), a psychoacoustic model, as a second opinion. PyPI has its wheels for Windows, Intel macOS and x86-64 Linux, and other systems compile it on install. The group stays out of `dev`. `uv sync --group calibration`, with the extras the environment already uses named beside it, installs it. See [Calibration](../../tools/calibration.md).
@@ -60,6 +64,24 @@ Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) with the `libgme` de
CI runs the driver on py65, which arrives with the package's own dependencies, and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`.
+## GPU bundles
+
+A standalone executable built on a machine with an NVIDIA driver carries CuPy and the CUDA libraries. The build asks the same driver detector as `make setup`, and `--gpu 0` opts out. The published bundles are built with it off, and `scripts/verify_bundle.py` holds a release to carrying none of them.
+
+A GPU bundle is a directory. The CUDA libraries would otherwise unpack from a single file on every start.
+
+PyInstaller needs the following to carry a CuPy that computes, and the repository supplies each:
+
+- **CuPy whole.** CuPy imports its submodules by name at run time, so the build collects all of `cupy`, `cupy_backends` and `cupyx`.
+- **The hook beside the build** (`scripts/pyinstaller_hooks/hook-cupy.py`). It names the CUDA wheels installed in the build environment as hidden imports, which brings in the hooks that collect their shared libraries. It adds their header directories as data, because CuPy compiles kernels with NVRTC and looks the headers up, and it adds `graphlib`, which a compiled CuPy module imports out of sight of the analysis.
+- **The runtime hook** (`scripts/runtime_hooks/cuda_bundle.py`). The CUDA library search walks site-packages and then re-runs `sys.executable` to probe the system. Frozen, that re-runs the application and hangs. The hook makes the bundle the only site-packages, so every library is found inside it.
+- **A build environment that matches.** A CPU build excludes the GPU packages by name, so a `.venv-build` left over from a GPU build ships none of them.
+- **The wheels ahead of a system toolkit.** CuPy links the CUDA libraries by file name, and PyInstaller looks each name up the way the system loader does. A GPU build puts the wheels' library directories first on that search (`LD_LIBRARY_PATH`, or `PATH` on Windows). A CUDA toolkit installed on the build machine then adds no second copy of a different version, and the build fails if one still lands beside the wheels' own.
+
+The self-check is what proves a bundle. `sampletones self-check --gpu` computes a kernel and a matrix product on the card and holds the answer to NumPy's, and a GPU build runs it before it finishes. Importing CuPy alone passes in a bundle that lost its CUDA libraries.
+
+The CUDA license lets an application distribute these libraries inside itself, on its terms ([third-party notices](../../../THIRD-PARTY-NOTICES.md#gpu-acceleration)). A GPU bundle is for its builder's machine.
+
## Linux (standalone executable)
Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. `make system-deps` installs them (or run `python3 scripts/system_dependencies.py`), and the script has the full list.
@@ -67,3 +89,5 @@ Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11
PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained.
The executable links against the glibc of the machine that builds it and runs on that version or newer. A redistributable artifact therefore belongs on the oldest Debian or Ubuntu release being supported, so it runs on every newer one.
+
+The bootloader points `LD_LIBRARY_PATH` at the libraries the bundle carries, and a program the application starts would load them in place of the system's. Every system program the application starts, a file manager, a browser or a dialog tool, therefore receives the reader's own library path, which `program_environment()` in `sampletones_shared/utils/system/programs.py` restores.
diff --git a/docs/development/tooling.md b/docs/development/tooling.md
index 8cad8fd1c..2d7814fd0 100644
--- a/docs/development/tooling.md
+++ b/docs/development/tooling.md
@@ -73,7 +73,7 @@ The Makefile is the developer's index, one line per target. A target names the s
and passes its flag. The `run` and `calibration` targets name the `sampletones` command they start with no
options, and the `tracker-playback` target passes the inputs its command takes: the Bitphase source folder
its `BITPHASE` variable names, the `FamiTracker.exe` its `FAMITRACKER` variable names, and the project
-files in `PROJECT`, if any. The `icons` and `demo` targets run makers of the assets unit. It runs each target it is
+files in `PROJECT`, if any. The `icons`, `docs` and `demo` targets run makers of the assets unit. It runs each target it is
given. `install.sh` and `install.bat` at the root
exist for the double-click path and call the same bundle script.
@@ -127,11 +127,21 @@ A test guards principle 4. It imports the registry in a subprocess and asserts t
registry and package modules of the tools load and no heavy library does. A startup failure in any tool
module would break every invocation, the GUI included.
-A tool that writes a page ships that page's files as they are read. `calibration/board/static/` holds the
-markup, the stylesheet and the script, copied out byte for byte, and the builder writes the palette, the
-faces and the run's own contents beside them. The stylesheet names color tokens and the script names no
-measurement, so a page is edited in place and drawn in the application's palettes. A test holds every
-shipped file to reaching nothing beyond the page, which lets one open from a file.
+### Pages a tool writes
+
+A page the repository writes wears the application's look, read from the application itself.
+`sampletones_tools/web/` resolves the theme set and a palette into *roles*: the ground, a card's header
+strip, the text, the accent, each the color or size the application draws that part with. It writes them
+as one stylesheet of variables beside the fonts the application ships, and the layout's own stylesheets
+name roles alone. A change to a theme or a palette therefore reaches every page the next time it is
+written. The fonts travel inside their stylesheet, which lets a page open from a file wherever it lies.
+A page is read rather than worked in, so it spaces its cards and its prose wider than the application
+does, in multiples of the application's own spacing, and its proportions follow the application.
+
+The calibration board and the documentation site ([assets](assets.md#documentation)) are written this
+way. A page's own files are shipped as they are read: `calibration/board/static/` holds the board's markup,
+its stylesheet and its script, copied out byte for byte beside the layout and the run's own contents. A test
+holds every shipped file to reaching nothing beyond the page.
## The bootstrap scripts
diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md
index 13449d239..107e3ed4f 100644
--- a/docs/guide/command-line.md
+++ b/docs/guide/command-line.md
@@ -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.
diff --git a/docs/guide/converting.md b/docs/guide/converting.md
index 219844014..e20fdcf5d 100644
--- a/docs/guide/converting.md
+++ b/docs/guide/converting.md
@@ -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.
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index f0206baf6..c6f93b362 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -57,19 +57,28 @@ After you pull new changes, run `make setup` again.
On Windows and Linux, you can also build a standalone app from the source code:
-- **Windows**: double-click `install.bat`. It builds `bin\sampletones.exe`.
-- **Linux**: run `make system-deps`, then `./install.sh`. It builds `bin/sampletones`.
+- **Windows**: double-click `install.bat`. It builds `bin\sampletones\sampletones.exe`.
+- **Linux**: run `make system-deps`, then `./install.sh`. It builds `bin/sampletones/sampletones`.
+
+The build adds GPU support when it finds an NVIDIA graphics card. See [GPU acceleration](#gpu-acceleration).
## GPU acceleration
_SampleToNES_ can use an NVIDIA graphics card to build libraries and convert recordings faster. It
needs an NVIDIA card with a current driver, on Windows or Linux. On macOS, the app runs without GPU acceleration.
+- **Standalone app you build**: `install.bat` and `./install.sh` check your NVIDIA driver and bundle the
+ matching GPU support. The app folder then holds the NVIDIA libraries and grows to a few gigabytes.
+ Add `--gpu 0` to build the smaller app for the CPU alone: `./install.sh --gpu 0`, or `install.bat --gpu 0`.
+ The build finishes only once the app has computed on your card.
- **From source**: `make setup` checks your NVIDIA driver and installs the matching GPU support.
`make setup GPU=0` installs the app for the CPU alone.
- **From PyPI**: add the `gpu` extra, `uv tool install "sampletones[gpu]"`. If your driver supports
CUDA 11 only, use the `gpu-cuda11` extra instead.
+The apps on the releases page run on the CPU. To see which one your app uses, run `sampletones self-check`
+and read the "array backend" line.
+
---
Once the app runs, [Getting started](getting-started.md) walks you through your first
diff --git a/docs/guide/interface.md b/docs/guide/interface.md
index e80918a3f..9f55046d6 100644
--- a/docs/guide/interface.md
+++ b/docs/guide/interface.md
@@ -17,10 +17,11 @@ The **Instructions** tab is optional. Use it to explore single [instructions](..
## The menus
-Two menu items are easy to miss:
+A few menu items are easy to miss:
- **View ▸ Show advanced settings** adds the **Advanced settings** card to the **Main** tab. See [Configuration](configuration.md).
- **Playback ▸ Audio settings...** sets the device you listen through. Its sample rate is separate from **Sample rate** on the **Main** tab, which sets how audio is converted.
+- **Help ▸ Documentation** opens this guide in your web browser, from a copy that comes with the app.
Project properties belong to a project and are covered in the [sequencer guide](sequencer.md).
diff --git a/docs/images/guide/converting/source-settings.webp b/docs/images/guide/converting/source-settings.webp
index c90cf104c..29981d6d4 100644
Binary files a/docs/images/guide/converting/source-settings.webp and b/docs/images/guide/converting/source-settings.webp differ
diff --git a/docs/images/guide/interface/tabs.webp b/docs/images/guide/interface/tabs.webp
index e46dbef63..c619dd423 100644
Binary files a/docs/images/guide/interface/tabs.webp and b/docs/images/guide/interface/tabs.webp differ
diff --git a/docs/images/guide/reconstruction/export-menu.webp b/docs/images/guide/reconstruction/export-menu.webp
index 2c93feb52..e0a2ed64e 100644
Binary files a/docs/images/guide/reconstruction/export-menu.webp and b/docs/images/guide/reconstruction/export-menu.webp differ
diff --git a/docs/images/guide/sequencer/history.webp b/docs/images/guide/sequencer/history.webp
index c80c1ecf2..456ae1ce9 100644
Binary files a/docs/images/guide/sequencer/history.webp and b/docs/images/guide/sequencer/history.webp differ
diff --git a/docs/images/guide/sequencer/nsf-window.webp b/docs/images/guide/sequencer/nsf-window.webp
index 49b4c95a7..0f56889b3 100644
Binary files a/docs/images/guide/sequencer/nsf-window.webp and b/docs/images/guide/sequencer/nsf-window.webp differ
diff --git a/docs/images/guide/sequencer/playback.webp b/docs/images/guide/sequencer/playback.webp
index 1d3ac7ccb..55a7980dd 100644
Binary files a/docs/images/guide/sequencer/playback.webp and b/docs/images/guide/sequencer/playback.webp differ
diff --git a/docs/images/guide/sequencer/project-properties.webp b/docs/images/guide/sequencer/project-properties.webp
index 5f691940f..16bdfe302 100644
Binary files a/docs/images/guide/sequencer/project-properties.webp and b/docs/images/guide/sequencer/project-properties.webp differ
diff --git a/docs/images/guide/sequencer/render.webp b/docs/images/guide/sequencer/render.webp
index 954b6806c..66069fafa 100644
Binary files a/docs/images/guide/sequencer/render.webp and b/docs/images/guide/sequencer/render.webp differ
diff --git a/docs/images/guide/sequencer/tracker.webp b/docs/images/guide/sequencer/tracker.webp
index 422de943c..0e21e7576 100644
Binary files a/docs/images/guide/sequencer/tracker.webp and b/docs/images/guide/sequencer/tracker.webp differ
diff --git a/docs/images/guide/sequencer/voices.webp b/docs/images/guide/sequencer/voices.webp
index 20a113f04..918ac591c 100644
Binary files a/docs/images/guide/sequencer/voices.webp and b/docs/images/guide/sequencer/voices.webp differ
diff --git a/docs/images/sampletones.webp b/docs/images/sampletones.webp
index 7699554c2..8a09d59b9 100644
Binary files a/docs/images/sampletones.webp and b/docs/images/sampletones.webp differ
diff --git a/hatch_build.py b/hatch_build.py
new file mode 100644
index 000000000..12be63614
--- /dev/null
+++ b/hatch_build.py
@@ -0,0 +1,58 @@
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any, Dict, Final
+
+from hatchling.builders.config import BuilderConfig
+from hatchling.builders.hooks.plugin.interface import BuildHookInterface
+
+EDITABLE: Final[str] = "editable"
+FORCE_INCLUDE: Final[str] = "force_include"
+SOURCE_DIRECTORY: Final[str] = "src"
+SITE_DESTINATION: Final[str] = "sampletones_assets/documentation"
+SITE_PREFIX: Final[str] = "sampletones-documentation-"
+
+
+class DocumentationBuildHook(BuildHookInterface[BuilderConfig]):
+ """Writes the user documentation into every wheel, as the pages Help > Documentation opens.
+
+ The maker in ``assets/documentation`` writes the site from the checkout's docs into a
+ temporary folder, which the wheel carries at the assets package's documentation path and
+ which is removed once the wheel is written. An editable install reads the checkout itself,
+ where ``make docs`` writes the same site.
+ """
+
+ PLUGIN_NAME = "custom"
+
+ def initialize(self, version: str, build_data: Dict[str, Any]) -> None:
+ if version == EDITABLE:
+ return
+
+ root = Path(self.root)
+ for entry in (root / SOURCE_DIRECTORY, root):
+ if str(entry) not in sys.path:
+ sys.path.insert(0, str(entry))
+
+ from assets.documentation.build import ( # pylint: disable=import-outside-toplevel
+ SiteRequest,
+ build_site,
+ )
+ from assets.documentation.paths import ( # pylint: disable=import-outside-toplevel
+ DOCS_DIRECTORY,
+ PROJECT_FILE,
+ )
+
+ site = Path(tempfile.mkdtemp(prefix=SITE_PREFIX))
+ build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=site))
+ build_data[FORCE_INCLUDE][str(site)] = SITE_DESTINATION
+
+ def finalize(
+ self,
+ version: str,
+ build_data: Dict[str, Any],
+ artifact_path: str,
+ ) -> None:
+ for source, destination in build_data[FORCE_INCLUDE].items():
+ if destination == SITE_DESTINATION:
+ shutil.rmtree(source)
diff --git a/pyproject.toml b/pyproject.toml
index d7d900362..8e87d292e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,11 +76,12 @@ gpu-cuda11 = [
]
[dependency-groups]
-assets = ["pillow>=11,<13"]
+assets = ["markdown-it-py>=3,<5", "pillow>=11,<13"]
calibration = ["zimtohrli>=0.2.1,<0.3"]
dev = [
{ include-group = "assets" },
"black==26.5.1",
+ "hatchling>=1.27",
"isort==8.0.1",
"mypy==2.1.0",
"pre-commit==4.6.0",
@@ -96,7 +97,7 @@ dev = [
]
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[tool.uv]
@@ -114,6 +115,16 @@ packages = [
"src/sampletones_tools",
]
+[tool.hatch.build.targets.wheel.hooks.custom]
+path = "hatch_build.py"
+dependencies = [
+ "markdown-it-py>=3,<5",
+ "numpy>=2.0,<3",
+ "pydantic>=2.9,<3",
+ "PyYAML>=6.0,<7",
+ "rich>=13.0,<16",
+]
+
[tool.black]
line-length = 120
target-version = ["py312"]
@@ -166,6 +177,7 @@ files = [
"scripts",
"automation",
"assets",
+ "hatch_build.py",
]
exclude = ["tests"]
disallow_subclassing_any = true
diff --git a/scripts/archive_bundle.py b/scripts/archive_bundle.py
index 19a586392..cdc9bee81 100644
--- a/scripts/archive_bundle.py
+++ b/scripts/archive_bundle.py
@@ -67,7 +67,7 @@ def archive_release(root: Path, platform: Platform, *, label: str) -> int:
SystemExit: If the system builds no bundle.
"""
project = read_project(root)
- source = platform.bundling().launcher(root / DISTRIBUTION, name=project.name, release=True).parent
+ source = platform.bundling().launcher(root / DISTRIBUTION, name=project.name, directory=True).parent
if not source.is_dir():
print(f"::error::Bundle directory {source} is missing")
return 1
diff --git a/scripts/bootstrap/cuda.py b/scripts/bootstrap/cuda.py
index ec43e3868..72c172dd2 100644
--- a/scripts/bootstrap/cuda.py
+++ b/scripts/bootstrap/cuda.py
@@ -1,6 +1,7 @@
import re
import shutil
import subprocess
+import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Mapping, Optional, Sequence, Tuple
@@ -13,6 +14,15 @@
CUDA11_MAJOR: Final[int] = 11
CUDA_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"CUDA Version\s*:?\s*(\d+)\.(\d+)")
QUERY_ARGUMENTS: Final[Tuple[Tuple[str, ...], ...]] = ((), ("-q",))
+GPU_AUTO: Final[str] = "auto"
+GPU_OFF: Final[str] = "0"
+GPU_CHOICES: Final[Tuple[str, ...]] = (
+ GPU_AUTO,
+ GPU_OFF,
+ GPU_EXTRA,
+ GPU_CUDA11_EXTRA,
+)
+DEFAULT_GPU: Final[str] = GPU_AUTO
@dataclass(frozen=True, kw_only=True)
@@ -140,3 +150,29 @@ def detect(platform: Platform, environment: Mapping[str, str]) -> CudaDetection:
cuda_version = query_driver_cuda_version(nvidia_smi)
extra = select_extra(cuda_version)
return CudaDetection(extra=extra, reason=_describe(cuda_version=cuda_version, extra=extra))
+
+
+def gpu_extra(
+ choice: str,
+ platform: Platform,
+ environment: Mapping[str, str],
+) -> Optional[str]:
+ """The optional-dependency extra a GPU choice selects, shared by the setup and the build.
+
+ Args:
+ choice: ``auto`` to read the NVIDIA driver, ``0`` for the CPU backend, or an extra's name.
+ platform: The system the choice is made on.
+ environment: The variables the driver's locations are read from.
+
+ Returns:
+ Optional[str]: The extra, or ``None`` for the CPU backend.
+ """
+ if choice == GPU_OFF:
+ return None
+
+ if choice == GPU_AUTO:
+ detection = detect(platform, environment)
+ print(detection.reason, file=sys.stderr)
+ return detection.extra
+
+ return choice
diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py
index 4be482b5e..12b7b5455 100644
--- a/scripts/bootstrap/layout.py
+++ b/scripts/bootstrap/layout.py
@@ -9,10 +9,28 @@
DISTRIBUTION: Final[str] = "bin"
BUNDLES: Final[str] = "bundles"
BUILD_ENVIRONMENT: Final[str] = ".venv-build"
+DOCUMENTATION_PACKAGE_PATH: Final[str] = "sampletones_assets/documentation"
+DOCUMENTATION_SITE: Final[str] = f"{SOURCE_DIRECTORY}/{DOCUMENTATION_PACKAGE_PATH}"
+DOCUMENTATION_FILES: Final[Tuple[str, ...]] = ("index.html", "stamp")
RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py"
+GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py"
+PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks"
NOTICES: Final[Tuple[str, ...]] = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt")
BUILD_TOOLS: Final[Tuple[str, ...]] = ("PIL",)
-CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage")
+CUPY_PACKAGES: Final[Tuple[str, ...]] = ("cupy", "cupy_backends", "cupyx")
+CUDA_PACKAGE: Final[str] = "nvidia"
+CUDA_LIBRARY_DIRECTORIES: Final[Tuple[str, ...]] = ("lib", "bin")
+INTERNAL_DIRECTORY: Final[str] = "_internal"
+GPU_PACKAGES: Final[Tuple[str, ...]] = (*CUPY_PACKAGES, CUDA_PACKAGE)
+CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (
+ DISTRIBUTION,
+ BUNDLES,
+ DOCUMENTATION_SITE,
+ "build",
+ "dist",
+ "htmlcov",
+ ".coverage",
+)
CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",)
CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",)
CACHE_DIRECTORY_SUFFIXES: Final[Tuple[str, ...]] = (".egg-info",)
diff --git a/scripts/bootstrap/platforms/bundling.py b/scripts/bootstrap/platforms/bundling.py
index 02eaad2ac..96237e75f 100644
--- a/scripts/bootstrap/platforms/bundling.py
+++ b/scripts/bootstrap/platforms/bundling.py
@@ -12,6 +12,8 @@ class Bundling:
pyaudio_advice: How to supply PortAudio to the build interpreter.
tkinter_advice: How to supply Tk to the build interpreter for a release bundle.
tkinter_warning: What a development bundle built without Tk does about file dialogs.
+ library_search_variable: The variable naming the directories the system searches for
+ shared libraries, which PyInstaller's dependency analysis follows.
"""
icon: str
@@ -19,20 +21,22 @@ class Bundling:
pyaudio_advice: str
tkinter_advice: str
tkinter_warning: str
+ library_search_variable: str
- def launcher(self, distribution: Path, *, name: str, release: bool) -> Path:
+ def launcher(self, distribution: Path, *, name: str, directory: bool) -> Path:
"""The executable PyInstaller writes under ``distribution``.
Args:
distribution: The directory the bundle is written into.
name: The bundle's name.
- release: Whether the bundle is a release, which is a directory beside its launcher.
+ directory: Whether the bundle is a directory beside its launcher, as a release and a
+ GPU bundle are, or one file.
Returns:
Path: The launcher.
"""
executable = f"{name}{self.executable_suffix}"
- if release:
+ if directory:
return distribution / name / executable
return distribution / executable
diff --git a/scripts/bootstrap/platforms/linux.py b/scripts/bootstrap/platforms/linux.py
index 4a1a0ac4d..e4b8e7145 100644
--- a/scripts/bootstrap/platforms/linux.py
+++ b/scripts/bootstrap/platforms/linux.py
@@ -2,10 +2,11 @@
from typing import Dict, Final, Mapping, Optional, Sequence, Tuple
from bootstrap.platforms.bundling import Bundling
-from bootstrap.platforms.unix import unix_interpreter
+from bootstrap.platforms.unix import unix_interpreter, unix_site_packages
LINUX: Final[str] = "Linux"
PNG_ICON: Final[str] = "src/sampletones_assets/icons/sampletones.png"
+LIBRARY_SEARCH_VARIABLE: Final[str] = "LD_LIBRARY_PATH"
SYSTEM_PACKAGES: Final[Tuple[str, ...]] = (
"libportaudio2",
"libasound-dev",
@@ -38,6 +39,7 @@
"This bundle opens file dialogs through zenity or kdialog, which the machine running it has to "
"provide. Run 'make system-deps' to install python3-tk and carry Tk as a self-contained fallback."
),
+ library_search_variable=LIBRARY_SEARCH_VARIABLE,
)
@@ -55,6 +57,9 @@ def cpu_backend_reason(self) -> Optional[str]:
def interpreter(self, environment: Path) -> Path:
return unix_interpreter(environment)
+ def site_packages(self, environment: Path) -> Path:
+ return unix_site_packages(environment)
+
def bundling(self) -> Bundling:
return BUNDLING
diff --git a/scripts/bootstrap/platforms/macos.py b/scripts/bootstrap/platforms/macos.py
index bd6d64c95..1d31923e4 100644
--- a/scripts/bootstrap/platforms/macos.py
+++ b/scripts/bootstrap/platforms/macos.py
@@ -4,7 +4,7 @@
from typing import Dict, Final, Mapping, Optional, Sequence
from bootstrap.platforms.bundling import Bundling
-from bootstrap.platforms.unix import unix_interpreter
+from bootstrap.platforms.unix import unix_interpreter, unix_site_packages
DARWIN: Final[str] = "Darwin"
HOMEBREW: Final[str] = "brew"
@@ -65,6 +65,9 @@ def architecture_flag(machine: str) -> str:
def interpreter(self, environment: Path) -> Path:
return unix_interpreter(environment)
+ def site_packages(self, environment: Path) -> Path:
+ return unix_site_packages(environment)
+
def bundling(self) -> Bundling:
raise SystemExit(NO_BUNDLE)
diff --git a/scripts/bootstrap/platforms/protocol.py b/scripts/bootstrap/platforms/protocol.py
index e5bfdf9c0..b676f54e5 100644
--- a/scripts/bootstrap/platforms/protocol.py
+++ b/scripts/bootstrap/platforms/protocol.py
@@ -25,6 +25,19 @@ def interpreter(self, environment: Path) -> Path:
Path: The interpreter.
"""
+ def site_packages(self, environment: Path) -> Path:
+ """The directory a virtual environment at ``environment`` installs packages into.
+
+ Args:
+ environment: The virtual environment's directory.
+
+ Returns:
+ Path: The environment's site-packages directory.
+
+ Raises:
+ FileNotFoundError: If the environment's layout names no single site-packages directory.
+ """
+
def bundling(self) -> Bundling:
"""What building a standalone bundle takes on the system.
diff --git a/scripts/bootstrap/platforms/unix.py b/scripts/bootstrap/platforms/unix.py
index b6e97c559..a64705233 100644
--- a/scripts/bootstrap/platforms/unix.py
+++ b/scripts/bootstrap/platforms/unix.py
@@ -2,8 +2,25 @@
from typing import Final, Tuple
UNIX_INTERPRETER: Final[Tuple[str, str]] = ("bin", "python")
+UNIX_SITE_PACKAGES: Final[str] = "lib/python3.*/site-packages"
def unix_interpreter(environment: Path) -> Path:
"""The interpreter a virtual environment at ``environment`` runs on Linux and macOS."""
return environment.joinpath(*UNIX_INTERPRETER)
+
+
+def unix_site_packages(environment: Path) -> Path:
+ """The directory a virtual environment at ``environment`` installs packages into on Linux and macOS.
+
+ The directory is named after the version of the interpreter that created the environment, so
+ it is found among the directories the environment holds.
+
+ Raises:
+ FileNotFoundError: If the environment holds no such directory, or several.
+ """
+ found = sorted(environment.glob(UNIX_SITE_PACKAGES))
+ if len(found) != 1:
+ raise FileNotFoundError(f"Expected one site-packages directory in {environment}, found {len(found)}")
+
+ return found[0]
diff --git a/scripts/bootstrap/platforms/windows.py b/scripts/bootstrap/platforms/windows.py
index 9f67f0fba..c97d3b30f 100644
--- a/scripts/bootstrap/platforms/windows.py
+++ b/scripts/bootstrap/platforms/windows.py
@@ -5,6 +5,8 @@
WINDOWS: Final[str] = "Windows"
WINDOWS_INTERPRETER: Final[Tuple[str, str]] = ("Scripts", "python.exe")
+WINDOWS_SITE_PACKAGES: Final[Tuple[str, str]] = ("Lib", "site-packages")
+LIBRARY_SEARCH_VARIABLE: Final[str] = "PATH"
ICO_ICON: Final[str] = "src/sampletones_assets/icons/sampletones.ico"
EXECUTABLE_SUFFIX: Final[str] = ".exe"
NVIDIA_SMI_LOCATIONS: Final[Tuple[Tuple[str, str], ...]] = (
@@ -19,6 +21,7 @@
tkinter_warning=(
"This bundle opens no file dialogs. Install Python from python.org or the Microsoft Store to include Tk."
),
+ library_search_variable=LIBRARY_SEARCH_VARIABLE,
)
@@ -40,6 +43,9 @@ def cpu_backend_reason(self) -> Optional[str]:
def interpreter(self, environment: Path) -> Path:
return environment.joinpath(*WINDOWS_INTERPRETER)
+ def site_packages(self, environment: Path) -> Path:
+ return environment.joinpath(*WINDOWS_SITE_PACKAGES)
+
def bundling(self) -> Bundling:
return BUNDLING
diff --git a/scripts/bootstrap/preflight.py b/scripts/bootstrap/preflight.py
index 1757f71f7..414d98c8f 100644
--- a/scripts/bootstrap/preflight.py
+++ b/scripts/bootstrap/preflight.py
@@ -6,6 +6,8 @@
PYAUDIO: Final[str] = "pyaudio"
TKINTER: Final[str] = "tkinter"
+CUPY: Final[str] = "cupy"
+CUPY_ADVICE: Final[str] = "Check the NVIDIA driver and build again, or pass '--gpu 0' to build for the CPU alone."
def can_import(
@@ -42,26 +44,29 @@ def check_build_interpreter(
bundling: Bundling,
*,
release: bool,
+ gpu: bool,
runner: Runner,
cwd: Path,
environment: Mapping[str, str],
) -> None:
"""Holds the build interpreter to what a bundle has to carry.
- Audio playback is required of every bundle. Tk is required of a release bundle, so the
- shipped executable opens file dialogs on its own; a development bundle built without it is
- warned about what it leans on instead.
+ Audio playback is required of every bundle, and CuPy of a GPU bundle. Tk is required of a
+ release bundle, so the shipped executable opens file dialogs on its own; a development bundle
+ built without it is warned about what it leans on instead.
Args:
python: The build environment's interpreter.
bundling: What building a bundle takes on the system, whose advice a refusal gives.
release: Whether the bundle is a release.
+ gpu: Whether the bundle carries GPU support.
runner: What runs the probes.
cwd: The directory the probes run in.
environment: The variables the probes see.
Raises:
- SystemExit: If the interpreter cannot play audio, or a release lacks Tk.
+ SystemExit: If the interpreter cannot play audio, a GPU bundle's cannot import CuPy, or a
+ release lacks Tk.
"""
print("Checking the build environment...")
if not can_import(python, PYAUDIO, runner=runner, cwd=cwd, environment=environment):
@@ -71,6 +76,15 @@ def check_build_interpreter(
)
print(f"{PYAUDIO}: available")
+ if gpu:
+ if not can_import(python, CUPY, runner=runner, cwd=cwd, environment=environment):
+ raise SystemExit(
+ "ERROR: the build interpreter cannot import cupy, so the bundle would carry no GPU support.\n"
+ f"{CUPY_ADVICE}"
+ )
+
+ print(f"{CUPY}: available")
+
if can_import(python, TKINTER, runner=runner, cwd=cwd, environment=environment):
print(f"{TKINTER}: available")
return
diff --git a/scripts/bundle.py b/scripts/bundle.py
index 88b2a94a0..e9e5dca65 100644
--- a/scripts/bundle.py
+++ b/scripts/bundle.py
@@ -4,20 +4,38 @@
import sys
from dataclasses import dataclass
from pathlib import Path
-from typing import Final, List, Mapping, Sequence, Tuple
+from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple
+from bootstrap.cuda import DEFAULT_GPU, GPU_CHOICES, gpu_extra
from bootstrap.files import remove_path
from bootstrap.interpreter import running_version
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, RELEASE_HOOK, repository_root
+from bootstrap.layout import (
+ BUILD_ENVIRONMENT,
+ BUILD_TOOLS,
+ CUDA_LIBRARY_DIRECTORIES,
+ CUDA_PACKAGE,
+ CUPY_PACKAGES,
+ DISTRIBUTION,
+ GPU_HOOK,
+ GPU_PACKAGES,
+ INTERNAL_DIRECTORY,
+ NOTICES,
+ PYINSTALLER_HOOKS,
+ RELEASE_HOOK,
+ repository_root,
+)
from bootstrap.platforms.bundling import Bundling
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.preflight import check_build_interpreter
from bootstrap.processes import Runner, expect_success, run
-from bootstrap.project import BUILD_EXTRA, GPU_EXTRA, Project, read_project
+from bootstrap.project import BUILD_EXTRA, Project, read_project
from bootstrap.venv_build import ensure_build_venv, install
SELF_CHECK: Final[str] = "self-check"
+GPU_FLAG: Final[str] = "--gpu"
+DOCUMENTATION_FLAG: Final[str] = "--documentation"
+CPU_BUILD_ADVICE: Final[str] = "Build with '--gpu 0' for the CPU alone."
@dataclass(frozen=True)
@@ -25,23 +43,168 @@ class BundleOptions:
"""How a bundle is built.
Attributes:
- release: Whether the bundle is a release: a directory beside its launcher, carrying the
- release deployment configuration and the notices.
- gpu: Whether the bundle carries GPU support.
+ release: Whether the bundle is a release: carrying the release deployment configuration
+ and the notices.
+ gpu_extra: The optional-dependency extra bringing GPU support, or ``None`` for a bundle
+ computing on the CPU.
"""
release: bool
- gpu: bool
+ gpu_extra: Optional[str]
+
+ @property
+ def gpu(self) -> bool:
+ """Whether the bundle carries GPU support."""
+ return self.gpu_extra is not None
+
+ @property
+ def directory(self) -> bool:
+ """Whether the bundle is a directory beside its launcher.
+
+ A release is one so its notices sit beside the launcher. A GPU bundle is one because the
+ CUDA libraries it carries weigh gigabytes, which a single file would unpack on every start.
+ """
+ return self.release or self.gpu
def extras(options: BundleOptions) -> Tuple[str, ...]:
"""The optional-dependency extras a bundle is built with."""
- if options.gpu:
- return (BUILD_EXTRA, GPU_EXTRA)
+ if options.gpu_extra is not None:
+ return (BUILD_EXTRA, options.gpu_extra)
return (BUILD_EXTRA,)
+def backend_arguments(options: BundleOptions) -> List[str]:
+ """The PyInstaller arguments shaping what the bundle computes on.
+
+ A CPU bundle leaves the GPU packages out, so a build environment that carried them for an
+ earlier GPU build ships nothing of them. A GPU bundle collects CuPy whole, since it imports its
+ modules dynamically, and brings the CUDA wheels in through the repository's own hooks.
+ """
+ if options.gpu_extra is None:
+ arguments: List[str] = []
+ for package in GPU_PACKAGES:
+ arguments.extend(("--exclude-module", package))
+
+ return arguments
+
+ arguments = ["--additional-hooks-dir", PYINSTALLER_HOOKS, "--runtime-hook", GPU_HOOK]
+ for package in CUPY_PACKAGES:
+ arguments.extend(("--collect-all", package))
+
+ return arguments
+
+
+def cuda_library_directories(site_packages: Path) -> List[Path]:
+ """The directories the CUDA wheels installed in ``site_packages`` keep their shared libraries in.
+
+ Args:
+ site_packages: The site-packages directory of the environment the bundle is built in.
+
+ Returns:
+ List[Path]: The directories, in name order, empty where the CUDA wheels are absent.
+ """
+ root = site_packages / CUDA_PACKAGE
+ if not root.is_dir():
+ return []
+
+ return sorted(
+ component / libraries
+ for component in root.iterdir()
+ for libraries in CUDA_LIBRARY_DIRECTORIES
+ if (component / libraries).is_dir()
+ )
+
+
+def leading_search_path(
+ environment: Mapping[str, str],
+ *,
+ variable: str,
+ directories: Sequence[Path],
+) -> Dict[str, str]:
+ """The variables ``environment`` holds, with ``directories`` leading the search path ``variable`` names.
+
+ Args:
+ environment: The variables to extend.
+ variable: The search path's variable.
+ directories: The directories searched first, in order.
+
+ Returns:
+ Dict[str, str]: The variables, the search path keeping its earlier entries after ``directories``.
+ """
+ entries = [str(directory) for directory in directories]
+ existing = environment.get(variable)
+ if existing:
+ entries.append(existing)
+
+ return {**environment, variable: os.pathsep.join(entries)}
+
+
+def pyinstaller_environment(
+ root: Path,
+ platform: Platform,
+ options: BundleOptions,
+ environment: Mapping[str, str],
+) -> Dict[str, str]:
+ """The variables PyInstaller runs under.
+
+ CuPy names the CUDA libraries it links by their file names alone, and PyInstaller looks each
+ name up the way the system loader does. A GPU build places the CUDA wheels' library directories
+ first on that search, so every name resolves to the wheel the bundle carries, ahead of a CUDA
+ toolkit installed on the build machine, whose copy would land at the top of the bundle.
+
+ Args:
+ root: The repository, which holds the build environment.
+ platform: The system the bundle is built on, which places the search path and the packages.
+ options: How the bundle is built.
+ environment: The variables the build was started with.
+
+ Returns:
+ Dict[str, str]: The variables, as given for a CPU build.
+ """
+ if not options.gpu:
+ return dict(environment)
+
+ site_packages = platform.site_packages(root / BUILD_ENVIRONMENT)
+ return leading_search_path(
+ environment,
+ variable=platform.bundling().library_search_variable,
+ directories=cuda_library_directories(site_packages),
+ )
+
+
+def stray_cuda_libraries(bundle: Path) -> List[str]:
+ """The libraries at the top of a bundle's internals that repeat one the CUDA wheels carry.
+
+ Args:
+ bundle: The bundle's directory, beside its launcher.
+
+ Returns:
+ List[str]: The repeated file names, in name order, empty for a bundle carrying each CUDA library once.
+ """
+ internal = bundle / INTERNAL_DIRECTORY
+ carried = {path.name for path in (internal / CUDA_PACKAGE).rglob("*") if path.is_file()}
+ return sorted(path.name for path in internal.iterdir() if path.name in carried)
+
+
+def check_cuda_libraries(bundle: Path) -> None:
+ """Holds a GPU bundle to carrying each CUDA library once, the copy its wheel brings.
+
+ Args:
+ bundle: The bundle's directory, beside its launcher.
+
+ Raises:
+ SystemExit: If a library sits at the top of the bundle beside the wheel's copy.
+ """
+ stray = stray_cuda_libraries(bundle)
+ if stray:
+ raise SystemExit(
+ f"Build failed: {bundle} carries a second copy of CUDA libraries its wheels bring: "
+ f"{', '.join(stray)}. {CPU_BUILD_ADVICE}"
+ )
+
+
def pyinstaller_command(
python: Path,
bundling: Bundling,
@@ -68,7 +231,7 @@ def pyinstaller_command(
"PyInstaller",
"--name",
project.name,
- "--onedir" if options.release else "--onefile",
+ "--onedir" if options.directory else "--onefile",
"--noconfirm",
"--distpath",
DISTRIBUTION,
@@ -82,6 +245,7 @@ def pyinstaller_command(
for module in BUILD_TOOLS:
command.extend(("--exclude-module", module))
+ command.extend(backend_arguments(options))
if options.release:
command.extend(("--runtime-hook", RELEASE_HOOK))
@@ -89,11 +253,20 @@ def pyinstaller_command(
return command
+def self_check_command(launcher: Path, options: BundleOptions) -> Tuple[str, ...]:
+ """The launcher's self-check, held to the documentation the wheel carries, which a GPU bundle runs
+ with the GPU check that proves it computes on the card."""
+ if options.gpu:
+ return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG, GPU_FLAG)
+
+ return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG)
+
+
def remove_previous(distribution: Path, bundling: Bundling, name: str) -> None:
- """Removes what an earlier build left under ``distribution``, a release's directory or a single file."""
+ """Removes what an earlier build left under ``distribution``, a bundle's directory or a single file."""
for previous in (
- bundling.launcher(distribution, name=name, release=True).parent,
- bundling.launcher(distribution, name=name, release=False),
+ bundling.launcher(distribution, name=name, directory=True).parent,
+ bundling.launcher(distribution, name=name, directory=False),
):
if remove_path(previous):
print(f"Removed the previous artifact: {previous}")
@@ -128,13 +301,17 @@ def build_bundle(
Path: The launcher the bundle offers.
Raises:
- SystemExit: If the system builds no bundle, a step fails, or PyInstaller produced no launcher.
+ SystemExit: If the system builds no bundle, a step fails, PyInstaller produced no launcher, or a
+ GPU bundle carries a CUDA library twice.
"""
bundling = platform.bundling()
project = read_project(root)
if options.release:
print("Release build: onedir bundle, injecting release deployment configuration")
+ if options.gpu_extra is not None:
+ print(f"GPU build: onedir bundle carrying the '{options.gpu_extra}' extra")
+
python = ensure_build_venv(
root,
platform,
@@ -152,6 +329,7 @@ def build_bundle(
python,
bundling,
release=options.release,
+ gpu=options.gpu,
runner=runner,
cwd=root,
environment=environment,
@@ -163,14 +341,17 @@ def build_bundle(
runner,
pyinstaller_command(python, bundling, project, options),
cwd=root,
- environment=environment,
+ environment=pyinstaller_environment(root, platform, options, environment),
)
- launcher = bundling.launcher(distribution, name=project.name, release=options.release)
+ launcher = bundling.launcher(distribution, name=project.name, directory=options.directory)
if not launcher.is_file():
raise SystemExit(f"Build failed: PyInstaller produced no executable at {launcher}.")
+ if options.gpu:
+ check_cuda_libraries(launcher.parent)
+
print("Verifying the bundle...")
- status = runner((str(launcher), SELF_CHECK), cwd=root, environment=environment, quiet=False)
+ status = runner(self_check_command(launcher, options), cwd=root, environment=environment, quiet=False)
if status != 0:
raise SystemExit(f"Build failed: {launcher} did not pass its self-check.")
@@ -182,7 +363,7 @@ def build_bundle(
def main(argv: Sequence[str]) -> int:
- """Builds the standalone bundle, as a development build or a release."""
+ """Builds the standalone bundle, as a development build or a release, with GPU support where a driver is found."""
parser = argparse.ArgumentParser(description="Build the standalone SampleToNES bundle.")
parser.add_argument(
"--release",
@@ -191,16 +372,21 @@ def main(argv: Sequence[str]) -> int:
)
parser.add_argument(
"--gpu",
- action="store_true",
- help="build with GPU support",
+ default=DEFAULT_GPU,
+ choices=GPU_CHOICES,
+ help="auto to match the NVIDIA driver, 0 for the CPU backend, or the GPU extra to bundle",
)
arguments = parser.parse_args(list(argv))
- options = BundleOptions(release=arguments.release, gpu=arguments.gpu)
+ platform = current_platform()
+ options = BundleOptions(
+ release=arguments.release,
+ gpu_extra=gpu_extra(arguments.gpu, platform, os.environ),
+ )
print(f"Detected Python version: {running_version()}")
build_bundle(
repository_root(),
- current_platform(),
+ platform,
options,
runner=run,
environment=os.environ,
diff --git a/scripts/pyinstaller_hooks/hook-cupy.py b/scripts/pyinstaller_hooks/hook-cupy.py
new file mode 100644
index 000000000..4acbac231
--- /dev/null
+++ b/scripts/pyinstaller_hooks/hook-cupy.py
@@ -0,0 +1,50 @@
+# pylint: disable=invalid-name
+import sysconfig
+from pathlib import Path
+from typing import Final, List, Tuple
+
+CUDA_PACKAGE: Final[str] = "nvidia"
+LIBRARY_DIRECTORIES: Final[Tuple[str, ...]] = ("lib", "bin")
+INCLUDE_DIRECTORY: Final[str] = "include"
+STANDARD_LIBRARY_IMPORTS: Final[Tuple[str, ...]] = ("graphlib",)
+PURE_LIBRARY: Final[str] = "purelib"
+
+
+def cuda_components(site_packages: Path) -> List[Path]:
+ """The CUDA components the NVIDIA wheels installed, each a directory holding shared libraries.
+
+ Args:
+ site_packages: The site-packages directory of the environment the bundle is built in.
+
+ Returns:
+ List[Path]: The component directories under the ``nvidia`` package, in name order.
+ """
+ root = site_packages / CUDA_PACKAGE
+ if not root.is_dir():
+ return []
+
+ return sorted(
+ component
+ for component in root.iterdir()
+ if any((component / libraries).is_dir() for libraries in LIBRARY_DIRECTORIES)
+ )
+
+
+def hidden_imports(components: List[Path]) -> List[str]:
+ """The modules PyInstaller's analysis misses: a standard-library import made inside compiled code, and
+ each CUDA component, whose hook collects its shared libraries."""
+ return [*STANDARD_LIBRARY_IMPORTS, *(f"{CUDA_PACKAGE}.{component.name}" for component in components)]
+
+
+def header_directories(components: List[Path]) -> List[Tuple[str, str]]:
+ """The CUDA header directories, placed where CuPy's kernel compiler finds them in the bundle."""
+ return [
+ (str(component / INCLUDE_DIRECTORY), f"{CUDA_PACKAGE}/{component.name}/{INCLUDE_DIRECTORY}")
+ for component in components
+ if (component / INCLUDE_DIRECTORY).is_dir()
+ ]
+
+
+INSTALLED: Final[List[Path]] = cuda_components(Path(sysconfig.get_paths()[PURE_LIBRARY]))
+hiddenimports = hidden_imports(INSTALLED)
+datas = header_directories(INSTALLED)
diff --git a/scripts/runtime_hooks/cuda_bundle.py b/scripts/runtime_hooks/cuda_bundle.py
new file mode 100644
index 000000000..9aa97afe6
--- /dev/null
+++ b/scripts/runtime_hooks/cuda_bundle.py
@@ -0,0 +1,15 @@
+import site
+import sys
+from typing import Final, Iterable, List, Optional
+
+BUNDLE: Final[str] = sys._MEIPASS # type: ignore[attr-defined] # pylint: disable=protected-access,no-member
+
+
+def bundle_site_packages(prefixes: Optional[Iterable[str]] = None) -> List[str]:
+ """The directories the CUDA wheels are searched in: the bundle, where PyInstaller placed them."""
+ del prefixes
+ return [BUNDLE]
+
+
+site.getsitepackages = bundle_site_packages
+site.ENABLE_USER_SITE = False
diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py
index e1358e9ba..4c9a94889 100644
--- a/scripts/setup_environment.py
+++ b/scripts/setup_environment.py
@@ -3,56 +3,27 @@
import platform as running
import sys
from pathlib import Path
-from typing import Final, List, Mapping, Optional, Sequence, Tuple
+from typing import List, Mapping, Optional, Sequence
-from bootstrap.cuda import detect
+from bootstrap.cuda import DEFAULT_GPU, GPU_CHOICES, gpu_extra
from bootstrap.layout import repository_root
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.processes import Runner, expect_success, run
-from bootstrap.project import DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA, read_project
+from bootstrap.project import DEVELOPMENT_GROUP, read_project
-GPU_AUTO: Final[str] = "auto"
-GPU_OFF: Final[str] = "0"
-GPU_CHOICES: Final[Tuple[str, ...]] = (
- GPU_AUTO,
- GPU_OFF,
- GPU_EXTRA,
- GPU_CUDA11_EXTRA,
-)
-DEFAULT_GPU: Final[str] = GPU_AUTO
-
-def gpu_extra(
- choice: str,
- platform: Platform,
- environment: Mapping[str, str],
-) -> Optional[str]:
- """The optional-dependency extra a GPU choice selects.
-
- Args:
- choice: ``auto`` to read the NVIDIA driver, ``0`` for the CPU backend, or an extra's name.
- platform: The system the setup runs on.
- environment: The variables the driver's locations are read from.
-
- Returns:
- Optional[str]: The extra, or ``None`` for the CPU backend.
- """
- if choice == GPU_OFF:
- return None
-
- if choice == GPU_AUTO:
- detection = detect(platform, environment)
- print(detection.reason, file=sys.stderr)
- return detection.extra
-
- return choice
-
-
-def setup_commands(extra: Optional[str]) -> List[List[str]]:
+def setup_commands(
+ name: str,
+ extra: Optional[str],
+) -> List[List[str]]:
"""The commands that create the development environment and install the global command.
+ The global command is rebuilt from the checkout on every run, so it carries the checkout's
+ code and documentation as they stand.
+
Args:
+ name: The distribution's name, the package the global command is rebuilt from.
extra: The GPU extra installed with the package, or ``None`` for the CPU backend.
Returns:
@@ -66,12 +37,13 @@ def setup_commands(extra: Optional[str]) -> List[List[str]]:
return [
synchronize,
- ["uv", "tool", "install", "--force", package],
+ ["uv", "tool", "install", "--force", "--reinstall-package", name, package],
]
def set_up_environment(
root: Path,
+ name: str,
platform: Platform,
extra: Optional[str],
*,
@@ -83,6 +55,7 @@ def set_up_environment(
Args:
root: The repository.
+ name: The distribution's name, the package the global command is rebuilt from.
platform: The system the setup runs on, which adds the variables the build needs.
extra: The GPU extra installed with the package, or ``None`` for the CPU backend.
machine: The processor architecture ``platform.machine()`` reports.
@@ -93,7 +66,7 @@ def set_up_environment(
SystemExit: If a command fails.
"""
variables = platform.setup_variables(environment, machine=machine)
- for command in setup_commands(extra):
+ for command in setup_commands(name, extra):
expect_success(runner, command, cwd=root, environment=variables)
@@ -109,10 +82,11 @@ def main(argv: Sequence[str]) -> int:
arguments = parser.parse_args(list(argv))
root = repository_root()
- read_project(root)
+ project = read_project(root)
platform = current_platform()
set_up_environment(
root,
+ project.name,
platform,
gpu_extra(arguments.gpu, platform, os.environ),
machine=running.machine(),
diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py
index daeb751d9..64458b603 100644
--- a/scripts/verify_bundle.py
+++ b/scripts/verify_bundle.py
@@ -4,14 +4,22 @@
from pathlib import Path
from typing import Final, List, Mapping, Sequence
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, repository_root
+from bootstrap.layout import (
+ BUILD_TOOLS,
+ DISTRIBUTION,
+ DOCUMENTATION_FILES,
+ DOCUMENTATION_PACKAGE_PATH,
+ GPU_PACKAGES,
+ INTERNAL_DIRECTORY,
+ NOTICES,
+ repository_root,
+)
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.processes import Runner, run
from bootstrap.project import read_project
VERSION_FLAG: Final[str] = "--version"
-INTERNAL_DIRECTORY: Final[str] = "_internal"
def missing_notices(bundle: Path) -> List[str]:
@@ -19,6 +27,22 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in NOTICES if not (bundle / name).is_file()]
+def missing_documentation(bundle: Path) -> List[str]:
+ """The files of the documentation site a bundle ships that are absent from its internals.
+
+ The wheel the bundle is built from carries the site at the assets package's path, and the
+ bundle unpacks its data under the same path, where Help > Documentation reads it.
+ """
+ site = bundle / INTERNAL_DIRECTORY / DOCUMENTATION_PACKAGE_PATH
+ return [name for name in DOCUMENTATION_FILES if not (site / name).is_file()]
+
+
+def carried_packages(bundle: Path, names: Sequence[str]) -> List[str]:
+ """The packages among ``names`` found in a bundle, beside its launcher or among its internals."""
+ directories = (bundle, bundle / INTERNAL_DIRECTORY)
+ return [name for name in names if any((directory / name).is_dir() for directory in directories)]
+
+
def carried_build_tools(bundle: Path) -> List[str]:
"""The build-time packages found in a bundle, which the notices place on the build machine.
@@ -26,8 +50,16 @@ def carried_build_tools(bundle: Path) -> List[str]:
belongs to the machine that builds it, so finding it here means the notices describe a
different set of components than the bundle ships.
"""
- directories = (bundle, bundle / INTERNAL_DIRECTORY)
- return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)]
+ return carried_packages(bundle, BUILD_TOOLS)
+
+
+def carried_gpu_packages(bundle: Path) -> List[str]:
+ """The GPU packages found in a bundle, which the published bundles leave out.
+
+ A release computes on the CPU, and GPU support is built locally. Finding CuPy or the CUDA
+ wheels here means the release was built with a GPU extra.
+ """
+ return carried_packages(bundle, GPU_PACKAGES)
def bundle_failures(
@@ -39,8 +71,8 @@ def bundle_failures(
) -> List[str]:
"""What keeps the release bundle under ``root`` from shipping, each as a line of its own.
- The bundle ships its notices, carries the application and its runtime dependencies alone, and
- offers a launcher that starts.
+ The bundle ships its notices and its documentation, carries the application and its CPU runtime
+ dependencies alone, and offers a launcher that starts.
Args:
root: The repository the bundle was built in.
@@ -55,17 +87,25 @@ def bundle_failures(
SystemExit: If the system builds no bundle.
"""
name = read_project(root).name
- launcher = platform.bundling().launcher(root / DISTRIBUTION, name=name, release=True)
+ launcher = platform.bundling().launcher(root / DISTRIBUTION, name=name, directory=True)
bundle = launcher.parent
failures: List[str] = []
absent = missing_notices(bundle)
if absent:
failures.append(f"Bundle {bundle} is missing {', '.join(absent)}")
+ pages = missing_documentation(bundle)
+ if pages:
+ failures.append(f"Bundle {bundle} is missing its documentation: {', '.join(pages)}")
+
carried = carried_build_tools(bundle)
if carried:
failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
+ gpu = carried_gpu_packages(bundle)
+ if gpu:
+ failures.append(f"Bundle {bundle} carries GPU packages the published bundles leave out: {', '.join(gpu)}")
+
if not launcher.is_file():
failures.append(f"Bundle {bundle} offers no launcher at {launcher}")
return failures
@@ -78,7 +118,8 @@ def bundle_failures(
def main(argv: Sequence[str]) -> int:
- """Confirms the release bundle ships its notices, holds to them, and that its launcher starts."""
+ """Confirms the release bundle ships its notices and its documentation, holds to the notices, and that its
+ launcher starts."""
parser = argparse.ArgumentParser(description="Verify the release bundle before it is archived.")
parser.parse_args(list(argv))
@@ -89,7 +130,7 @@ def main(argv: Sequence[str]) -> int:
if failures:
return 1
- print("The bundle ships its notices and its launcher starts")
+ print("The bundle ships its notices and its documentation, and its launcher starts")
return 0
diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py
index a059dd93b..2d52d0467 100644
--- a/src/sampletones/__init__.py
+++ b/src/sampletones/__init__.py
@@ -19,7 +19,7 @@
)
from sampletones_core.library import InstructionLibrary
from sampletones_core.reconstructions import Reconstruction, Reconstructor
- from sampletones_shared.application import SAMPLETONES_VERSION as __version__
+ from sampletones_shared.distribution import SAMPLETONES_VERSION as __version__
def __getattr__(name: str) -> Any:
@@ -29,7 +29,7 @@ def __getattr__(name: str) -> Any:
return Config
if name == "__version__":
- from sampletones_shared.application import SAMPLETONES_VERSION
+ from sampletones_shared.distribution import SAMPLETONES_VERSION
return SAMPLETONES_VERSION
diff --git a/src/sampletones/commands/self_check.py b/src/sampletones/commands/self_check.py
index a9957caa0..bae870cae 100644
--- a/src/sampletones/commands/self_check.py
+++ b/src/sampletones/commands/self_check.py
@@ -5,19 +5,28 @@
NAME: Final[str] = "self-check"
HELP: Final[str] = "verify that this build's imports, bundled resources and configuration files are usable"
+GPU_FLAG: Final[str] = "--gpu"
+DOCUMENTATION_FLAG: Final[str] = "--documentation"
def configure(parser: ArgumentParser) -> None:
- del parser
+ parser.add_argument(
+ GPU_FLAG,
+ action="store_true",
+ help="also verify that this build computes on an NVIDIA graphics card",
+ )
+ parser.add_argument(
+ DOCUMENTATION_FLAG,
+ action="store_true",
+ help="also verify that this build carries its documentation, as an installed one does",
+ )
def run(arguments: Namespace) -> int:
"""Runs every startup check and answers with the status a packaged build is held to."""
- del arguments
-
from sampletones.self_check import run_self_check
- return run_self_check()
+ return run_self_check(gpu=arguments.gpu, documentation=arguments.documentation)
SELF_CHECK: Final[Command] = Command(
diff --git a/src/sampletones/dispatcher.py b/src/sampletones/dispatcher.py
index b0d85872a..885e39fd9 100644
--- a/src/sampletones/dispatcher.py
+++ b/src/sampletones/dispatcher.py
@@ -1,8 +1,8 @@
from argparse import ArgumentParser
from typing import Final, Sequence
-from sampletones_shared.application import SAMPLETONES_NAME_VERSION
from sampletones_shared.command import Command
+from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION
PROGRAM: Final[str] = "sampletones"
DESCRIPTION: Final[str] = "SampleToNES turns recordings into NES instruments and plays them back."
diff --git a/src/sampletones/run.py b/src/sampletones/run.py
index a99e233be..cf1543583 100644
--- a/src/sampletones/run.py
+++ b/src/sampletones/run.py
@@ -3,7 +3,8 @@
from sampletones_application.application import Application
from sampletones_application.config.profile import UserProfile
-from sampletones_shared.application import SAMPLETONES_NAME_VERSION
+from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION
+from sampletones_shared.documentation.site import DocumentationSite
from sampletones_shared.logger import logger
@@ -17,6 +18,7 @@ def run_application(
logger.info(SAMPLETONES_NAME_VERSION)
gui = Application(
profile=UserProfile.user(),
+ documentation=DocumentationSite.shipped(),
config_path=config_path,
library_path=library_path,
reconstruction_path=reconstruction_path,
diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py
index 613c6ef53..1cbcc937c 100644
--- a/src/sampletones/self_check.py
+++ b/src/sampletones/self_check.py
@@ -14,6 +14,7 @@
KeyError,
TypeError,
ValueError,
+ RuntimeError,
SystemError,
SampleToNESError,
)
@@ -148,6 +149,41 @@ def _check_file_dialog_backend() -> str:
return type(select_file_dialog_backend()).__name__
+def _check_array_backend() -> str:
+ """Names the backend the build computes on, so a GPU build that lost CuPy says so in its inventory."""
+ from sampletones_shared.array import describe_array_backend
+
+ return describe_array_backend()
+
+
+def _check_documentation() -> str:
+ """Reads the documentation the build ships, whose stamp has to match the pages beside it.
+
+ The wheel's build hook writes the pages and seals them, so a build carrying no pages, or pages
+ other than the ones sealed, names that here.
+ """
+ from sampletones_shared.documentation.site import DocumentationSite
+ from sampletones_shared.exceptions import DocumentationUnavailableError
+
+ site = DocumentationSite.shipped()
+ stamp = site.read_stamp()
+ if stamp is None or not site.index.is_file():
+ raise DocumentationUnavailableError(f"No documentation site lies in {site.directory}")
+
+ if stamp != site.content_stamp():
+ raise DocumentationUnavailableError(f"The documentation in {site.directory} differs from its stamp {stamp}")
+
+ pages = sum(1 for _ in site.directory.rglob(f"*{site.index.suffix}"))
+ return f"{pages} pages, stamp {stamp}"
+
+
+def _check_gpu_backend() -> str:
+ """Computes on the graphics card, which is what a GPU build is held to."""
+ from sampletones_shared.array import exercise_gpu
+
+ return exercise_gpu()
+
+
CHECKS: Final[Tuple[SelfCheck, ...]] = (
SelfCheck(name="application import", run=_check_application_import),
SelfCheck(name="deployment config", run=_check_deployment_config),
@@ -159,17 +195,42 @@ def _check_file_dialog_backend() -> str:
SelfCheck(name="resources", run=_check_resources),
SelfCheck(name="export backends", run=_check_export_backends),
SelfCheck(name="file dialog backend", run=_check_file_dialog_backend),
+ SelfCheck(name="array backend", run=_check_array_backend),
)
-
-
-def run_self_check() -> int:
+DOCUMENTATION_CHECK: Final[SelfCheck] = SelfCheck(name="documentation", run=_check_documentation)
+GPU_CHECK: Final[SelfCheck] = SelfCheck(name="gpu backend", run=_check_gpu_backend)
+
+
+def checks_for(
+ *,
+ gpu: bool,
+ documentation: bool,
+) -> Tuple[SelfCheck, ...]:
+ """The checks a build runs: every startup check, the documentation check where the build was
+ installed from a wheel, and the GPU check last where the build carries GPU support."""
+ held = ((documentation, DOCUMENTATION_CHECK), (gpu, GPU_CHECK))
+ return (*CHECKS, *(check for wanted, check in held if wanted))
+
+
+def run_self_check(
+ *,
+ gpu: bool,
+ documentation: bool,
+) -> int:
"""Runs every startup check in order and returns the process exit status.
Prints one line per check so a passing run doubles as an inventory of what the build
carries, and stops at the first failure with the offending check named on the standard
- error stream. A packaged build runs this to prove it starts before it is shipped.
+ error stream. A packaged build runs this to prove it starts before it is shipped, with
+ ``documentation`` set to prove it carries its pages, and a GPU bundle runs it with ``gpu`` set
+ to prove it computes on the graphics card.
+
+ Args:
+ gpu: Whether the build is held to the GPU backend.
+ documentation: Whether the build is held to carrying its documentation.
"""
- for check in CHECKS:
+ checks = checks_for(gpu=gpu, documentation=documentation)
+ for check in checks:
try:
detail = check.run()
except CHECK_FAILURES as exception:
@@ -181,5 +242,5 @@ def run_self_check() -> int:
print(f"{SUCCESS_PREFIX} {check.name}: {detail}")
- print(f"{SUCCESS_PREFIX} {len(CHECKS)} checks passed")
+ print(f"{SUCCESS_PREFIX} {len(checks)} checks passed")
return SUCCESS_STATUS
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index 8ddcb2ca0..10f7fb7ac 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -18,6 +18,7 @@
from sampletones_application.constants.playback import FollowMode
from sampletones_application.coordinators.config import ConfigCoordinator
from sampletones_application.coordinators.display import DisplayCoordinator
+from sampletones_application.coordinators.documentation import DocumentationCoordinator
from sampletones_application.coordinators.edit.router import EditRouter
from sampletones_application.coordinators.export import (
InstrumentExportCoordinator,
@@ -47,6 +48,7 @@
from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
from sampletones_application.exports import ExportBackends
from sampletones_application.layout import LayoutConfig, load_layout_config
+from sampletones_application.logic.documentation.shelf import DocumentationShelf
from sampletones_application.logic.export import SongExportLogic
from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic
from sampletones_application.logic.export.nsf.logic import NSFExportLogic
@@ -75,6 +77,7 @@
)
from sampletones_application.logic.reconstruction.rewrites.steps import RateChange
from sampletones_application.logic.render import SongRenderLogic
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.logic.shared.renders import RenderCache
from sampletones_application.parameters import (
InstructionsTabParameters,
@@ -194,12 +197,17 @@
from sampletones_shared.application import (
SAMPLETONES_AUTHOR,
SAMPLETONES_GROUP,
+)
+from sampletones_shared.distribution import (
+ SAMPLETONES_DOCUMENTATION_ADDRESS,
SAMPLETONES_NAME_VERSION,
)
+from sampletones_shared.documentation.site import DocumentationSite
from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.logger import logger
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
+from sampletones_shared.utils.system.opening import select_file_opener
SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}"
SEQUENCER_SAMPLE_ORDINAL_FORMAT: Final[str] = "02X"
@@ -223,6 +231,7 @@ class Application:
def __init__(
self,
profile: UserProfile,
+ documentation: DocumentationSite,
config_path: Optional[Path] = None,
library_path: Optional[Path] = None,
reconstruction_path: Optional[Path] = None,
@@ -291,6 +300,8 @@ def __init__(
self.reconstruction_manager,
self.regeneration_service,
)
+ self.preview_service: RegenerationService = RegenerationService(priority=_priority)
+ self.document_previews: DocumentPreviews = DocumentPreviews(self.renders, self.preview_service)
self.export_service: ExportService = ExportService(priority=_priority)
self.export_service.subscribe(self._on_export_activity)
self.render_service: SongRenderService = SongRenderService(priority=_priority)
@@ -495,6 +506,13 @@ def __init__(
dialogs=self.dialogs,
language_manager=self.language_manager,
)
+ self._documentation_coordinator = DocumentationCoordinator(
+ shelf=DocumentationShelf(site=documentation, directory=profile.documentation),
+ opener=select_file_opener(),
+ dialogs=self.dialogs,
+ language_manager=self.language_manager,
+ address=SAMPLETONES_DOCUMENTATION_ADDRESS,
+ )
self._instrument_exports = InstrumentExportCoordinator(
InstrumentExportLogic(
@@ -528,6 +546,7 @@ def __init__(
instrument_exports=self._instrument_exports,
history=self.history,
instrument_edit_detail=self._instrument_edit_detail,
+ previews=self.document_previews,
key_router=self.key_router,
tab_active=self._is_reconstructions_tab_current,
layout=ReconstructionTabParameters.from_config(self.layout),
@@ -548,6 +567,7 @@ def __init__(
on_generation_state_changed=self._on_library_operation_changed,
is_operation_active=self._is_operation_active,
is_converter_visible=self._is_converter_panel_visible,
+ previews=self.document_previews,
layout=InstructionsTabParameters.from_config(self.layout),
language_manager=self.language_manager,
dialogs=self.dialogs,
@@ -573,6 +593,7 @@ def __init__(
on_refresh_trees=self._refresh_browsers,
on_prepare_library=self._instructions_tab.prepare_library,
),
+ previews=self.document_previews,
layout=MainTabParameters.from_config(self.layout),
language_manager=self.language_manager,
dialogs=self.dialogs,
@@ -593,7 +614,7 @@ def __init__(
browser_manager=self.browser_manager,
project_controller=self.project_controller,
history=self.history,
- renders=self.renders,
+ previews=self.document_previews,
original_audio_locator=self._original_audio_locator,
instrument_exports=self._instrument_exports,
tab_active=self._is_sequencer_tab_current,
@@ -623,6 +644,7 @@ def __init__(
active_source_resolver=self._get_active_source,
audio_device_manager=self.audio_device_manager,
language_manager=self.language_manager,
+ previews=self.document_previews,
)
self._config_coordinator = ConfigCoordinator(
@@ -814,6 +836,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions,
toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories,
toggle_fullscreen=self._shell.toggle_fullscreen,
+ documentation=self._documentation_coordinator.open,
about=self._open_about_dialog,
next_tab=self._next_tab,
previous_tab=self._previous_tab,
diff --git a/src/sampletones_application/categories/conversion.py b/src/sampletones_application/categories/conversion.py
new file mode 100644
index 000000000..e6d65a7e7
--- /dev/null
+++ b/src/sampletones_application/categories/conversion.py
@@ -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)
diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py
index bb2ed7505..27b955018 100644
--- a/src/sampletones_application/categories/elements/global_.py
+++ b/src/sampletones_application/categories/elements/global_.py
@@ -99,6 +99,7 @@ class MenuElements(AbstractElement):
ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings"
ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings"
GROUP_HELP = "group_help"
+ ITEM_HELP_DOCUMENTATION = "item_help_documentation"
ITEM_HELP_ABOUT = "item_help_about"
TAB_MAIN = "tab_main"
TAB_INSTRUCTIONS = "tab_instructions"
diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py
index c99ff24dd..d05644bc4 100644
--- a/src/sampletones_application/categories/elements/settings.py
+++ b/src/sampletones_application/categories/elements/settings.py
@@ -72,6 +72,7 @@ class KeybindingActionElements(AbstractElement):
TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions"
TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories"
TOGGLE_FULLSCREEN = "toggle_fullscreen"
+ DOCUMENTATION = "documentation"
ABOUT_DIALOG = "about_dialog"
NEXT_TAB = "next_tab"
PREVIOUS_TAB = "previous_tab"
diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py
index 4a0cf458d..6203826b3 100644
--- a/src/sampletones_application/categories/export.py
+++ b/src/sampletones_application/categories/export.py
@@ -25,6 +25,7 @@ class ExportMessages:
instruments_failed: Shown when a reconstruction's instrument export fails.
wav_success: Shown when the reconstruction reaches a WAV file.
wav_failed: Shown when the WAV export fails.
+ wav_dropped: Shown when a WAV export waited for a sound that was never rendered.
"""
status_title: str
@@ -37,6 +38,7 @@ class ExportMessages:
instruments_failed: str
wav_success: str
wav_failed: str
+ wav_dropped: str
@classmethod
def build(cls, language_manager: LanguageManager) -> ExportMessages:
@@ -59,4 +61,5 @@ def build(cls, language_manager: LanguageManager) -> ExportMessages:
instruments_failed=language_manager["reconstructions.instruments.message.export_instruments_failed"],
wav_success=language_manager["reconstructions.reconstruction.message.export_wav_success"],
wav_failed=language_manager["reconstructions.reconstruction.message.export_wav_failed"],
+ wav_dropped=language_manager["reconstructions.reconstruction.message.export_wav_dropped"],
)
diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py
index 239bfeafa..40a943308 100644
--- a/src/sampletones_application/config/profile.py
+++ b/src/sampletones_application/config/profile.py
@@ -3,13 +3,16 @@
from dataclasses import dataclass
from pathlib import Path
-from sampletones_application.paths import APPLICATION_STATE_PATH
-from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
+from sampletones_shared.paths.user import (
+ APPLICATION_CONFIG_PATH,
+ APPLICATION_STATE_PATH,
+ DOCUMENTATION_COPIES_DIRECTORY,
+)
@dataclass(frozen=True)
class UserProfile:
- """The two files a run keeps its settings and its session in.
+ """The places a run keeps its settings, its session and its copy of the documentation in.
A profile is chosen at startup and travels to the managers that read and write it, which is
what lets a run be pointed at a location of its own.
@@ -17,11 +20,13 @@ class UserProfile:
config: Path
state: Path
+ documentation: Path
@classmethod
def user(cls) -> UserProfile:
- """The profile in the user's configuration directory, which is where a normal run reads."""
+ """The profile in the user's own folders, which is where a normal run reads and writes."""
return cls(
config=APPLICATION_CONFIG_PATH,
state=APPLICATION_STATE_PATH,
+ documentation=DOCUMENTATION_COPIES_DIRECTORY,
)
diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py
index 282f1df89..8a8218a3a 100644
--- a/src/sampletones_application/coordinators/config.py
+++ b/src/sampletones_application/coordinators/config.py
@@ -26,7 +26,7 @@
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_shared.application import SAMPLETONES_VERSION
+from sampletones_shared.distribution import SAMPLETONES_VERSION
from sampletones_shared.logger import logger
from sampletones_shared.paths.extensions import EXT_FILE_JSON
from sampletones_shared.utils.validation import flatten_location
diff --git a/src/sampletones_application/coordinators/documentation.py b/src/sampletones_application/coordinators/documentation.py
new file mode 100644
index 000000000..379e394f5
--- /dev/null
+++ b/src/sampletones_application/coordinators/documentation.py
@@ -0,0 +1,47 @@
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.documentation.shelf import DocumentationShelf
+from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_DOCUMENTATION
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_shared.exceptions import DocumentationUnavailableError
+from sampletones_shared.utils.system.opening import FileOpener
+
+
+class DocumentationCoordinator:
+ """Opens the documentation the installation carries in the reader's browser.
+
+ The shelf places a copy of the pages in the user's data folder, and the opener shows its index
+ in the program the system gives web pages. An installation that came without its pages tells
+ the reader where the documentation lives online, and a copy or a browser that fails is
+ reported as an error.
+ """
+
+ def __init__(
+ self,
+ *,
+ shelf: DocumentationShelf,
+ opener: FileOpener,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ address: str,
+ ) -> None:
+ self._shelf = shelf
+ self._opener = opener
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+ self._address = address
+
+ def open(self) -> None:
+ """Shows the documentation's index in the reader's browser."""
+ try:
+ self._opener.open(self._shelf.place())
+ except DocumentationUnavailableError:
+ self._dialogs.show_info(
+ TAG_GLOBAL_DIALOG_DOCUMENTATION,
+ self._language_manager["global.dialog.template.documentation_unavailable"].format(
+ address=self._address,
+ ),
+ self._language_manager["global.dialog.title.documentation"],
+ modal=True,
+ )
+ except OSError as error:
+ self._dialogs.show_error(error, self._language_manager["global.dialog.message.documentation_not_opened"])
diff --git a/src/sampletones_application/coordinators/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py
index 9f86d8a48..6181ccc3a 100644
--- a/src/sampletones_application/coordinators/playback/guard.py
+++ b/src/sampletones_application/coordinators/playback/guard.py
@@ -1,5 +1,6 @@
from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.types.callback import VoidCallback
@@ -11,7 +12,9 @@ class GuardedPlayer:
Panels only fire intent hooks, so this wrapper is the coordinator-layer
recovery boundary for the transport commands that can raise
``PlaybackError``; queries pass straight through. A start that fails at once
- reads the way a failure on the playing thread does.
+ reads the way a failure on the playing thread does. Every command here is
+ playback the reader asked for, so each forgets a preview still on its way:
+ the sound the reader asked for last is the one that takes the device.
"""
def __init__(
@@ -19,9 +22,11 @@ def __init__(
player: AudioPlayerProtocol,
*,
failures: PlaybackFailurePresenter,
+ previews: DocumentPreviews,
) -> None:
self._player = player
self._failures = failures
+ self._previews = previews
def play(self) -> None:
self.run_guarded(self._player.play)
@@ -30,6 +35,7 @@ def pause_or_resume(self) -> None:
self.run_guarded(self._player.pause_or_resume)
def stop(self) -> None:
+ self._previews.cancel()
self._player.stop()
def is_playing(self) -> bool:
@@ -48,8 +54,10 @@ def run_guarded(self, command: VoidCallback) -> None:
"""Runs a playback command, showing the reader a failure to start the audio.
A command beyond the transport — sounding a sample from a point the reader clicked — goes
- through the same boundary the transport commands do.
+ through the same boundary the transport commands do, and forgets a preview on its way the
+ same way.
"""
+ self._previews.cancel()
try:
command()
except PlaybackError as exception:
diff --git a/src/sampletones_application/coordinators/playback/router.py b/src/sampletones_application/coordinators/playback/router.py
index ab1206d27..f625a4e97 100644
--- a/src/sampletones_application/coordinators/playback/router.py
+++ b/src/sampletones_application/coordinators/playback/router.py
@@ -2,6 +2,7 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_core.audio import AudioDeviceManager
ActiveSourceResolver = Callable[[], Optional[AudioPlayerProtocol]]
@@ -12,7 +13,8 @@ class PlaybackRouter:
Every command acts on one *target*: the active tab's own source when that tab has something to
play, and otherwise the intentional source currently engaged (owning the device). Preview
- sounds belong to no source and answer only to Stop. The full contract is documented in
+ sounds belong to no source and answer only to Stop, which silences one sounding and forgets
+ one still on its way. The full contract is documented in
``docs/development/application/playback.md``.
It is stateless: the target is recomputed from the live sources on each call, so the transport
@@ -26,11 +28,13 @@ def __init__(
active_source_resolver: ActiveSourceResolver,
audio_device_manager: AudioDeviceManager,
language_manager: LanguageManager,
+ previews: DocumentPreviews,
) -> None:
self._language_manager = language_manager
self._sources = tuple(sources)
self._active_source_resolver = active_source_resolver
self._audio_device_manager = audio_device_manager
+ self._previews = previews
def play(self) -> None:
"""Play/Pause: pause or resume the target when engaged, otherwise start it."""
@@ -45,7 +49,8 @@ def play_from_start(self) -> None:
active_source.play()
def stop(self) -> None:
- """Silence everything: the engaged source and any sounding preview."""
+ """Silence everything: the engaged source, any sounding preview, and a preview still on its way."""
+ self._previews.cancel()
engaged_source = self._engaged_source()
if engaged_source is not None:
engaged_source.stop()
@@ -60,6 +65,7 @@ def shutdown(self) -> None:
stream. Teardown therefore reaches every source rather than the engaged one alone, so a
source holding a stream is wound down whatever the transport reports at that moment.
"""
+ self._previews.cancel()
for source in self._sources:
source.stop()
diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py
index 081560cb1..98e662dbb 100644
--- a/src/sampletones_application/coordinators/reconstruction.py
+++ b/src/sampletones_application/coordinators/reconstruction.py
@@ -38,6 +38,7 @@
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome
+from sampletones_application.view_model.reconstruction.work import Work
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.project.voices.instrument import Instrument
from sampletones_core.project.voices.sample import Sample
@@ -99,9 +100,11 @@ def __init__(
self._reconstruction_manager.session.on_state_changed = self._on_state_changed
self._rewrites.on_edit = self.apply_edit
+ self._rewrites.on_heard_rendered = self._show_listening
self._rewrites.on_dropped = self._redraw_open_document
self._rewrites.on_failed = self._report_rebuild_failure
- self._rewrites.on_busy_changed = self._set_reconstruction_dimmed
+ self._rewrites.on_reading_failed = self._abandon_showing
+ self._rewrites.on_work_changed = self._show_work
self._reconstruction_manager.set_callbacks(
on_reconstruction_loaded=self.on_reconstruction_loaded,
on_reconstruction_closed=self._on_closed,
@@ -419,7 +422,12 @@ def apply_edit(self, edit: ReconstructionEdit) -> None:
the tab shows it.
"""
self._on_reconstruction_updated_callback(edit)
- self._reconstruction_manager.apply_edited(edit.reconstruction)
+ match edit:
+ case ChannelEdit():
+ self._reconstruction_manager.apply_edited(edit.reconstruction, edit.envelopes)
+ case Retune() | StemRemoval():
+ self._reconstruction_manager.apply_edited(edit.reconstruction, None)
+
self._show_edit(edit)
self._reconstruction_manager.mark_updated()
@@ -552,7 +560,7 @@ def _rebind(self, reconstruction: Reconstruction) -> None:
retimed = reconstruction.config.nes_frequency != open_reconstruction.config.nes_frequency
self._drop_pending()
- self._reconstruction_manager.apply_edited(reconstruction)
+ self._reconstruction_manager.apply_edited(reconstruction, None)
self._tab.redraw_reconstruction(refit_waveform=retimed)
def _redraw_open_document(self) -> None:
@@ -560,20 +568,34 @@ def _redraw_open_document(self) -> None:
if self.is_loaded():
self._tab.redraw_reconstruction(refit_waveform=False)
+ def _show_listening(self, envelopes_changed: bool) -> None:
+ """Shows what the reader hears once the worker has rendered it for the open document.
+
+ Args:
+ envelopes_changed: Whether the rendering brought envelopes the instruments panel has
+ yet to draw.
+ """
+ self._tab.redraw_listening(envelopes_changed=envelopes_changed)
+
def _report_rebuild_failure(self, exception: Exception) -> None:
logger.error_with_traceback(exception, "Regeneration failed")
self._dialogs.show_error(exception)
- def _set_reconstruction_dimmed(self, dimmed: bool) -> None:
- """Fades the reconstruction waveform while the open document is being rewritten.
+ def _abandon_showing(self) -> None:
+ """Lets the tab go of what it owes the reader, where the listening that would have paid it failed."""
+ self._tab.abandon_showing()
+
+ def _show_work(self, work: Optional[Work]) -> None:
+ """Fades the reconstruction waveform under the words naming what the line is busy with.
- The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform
- faded until the last step lands, and it restores once nothing runs or waits.
+ The fade follows the rewrites' busy span, so a continuous edit stream keeps the waveform
+ faded until the last step lands, and it restores once nothing runs or waits. The words
+ follow the work, so a hand-over from an edit to a reading of the sound changes them.
"""
if self._reconstructions_tab is None:
return
- self._reconstructions_tab.set_reconstruction_dimmed(dimmed)
+ self._reconstructions_tab.show_work(work)
def _on_state_changed(self) -> None:
self._on_session_state_changed_callback()
diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py
index 3b6c36e76..81fc10049 100644
--- a/src/sampletones_application/coordinators/tabs/instructions.py
+++ b/src/sampletones_application/coordinators/tabs/instructions.py
@@ -19,6 +19,7 @@
)
from sampletones_application.logic.shared.file_playback import FilePlayback
from sampletones_application.logic.shared.player import PlayerLogic
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.instructions import InstructionsTabParameters
from sampletones_application.tags.compose import compose_tag
@@ -101,6 +102,7 @@ def __init__(
on_generation_state_changed: VoidCallback,
is_operation_active: Callable[[], bool],
is_converter_visible: Callable[[], bool],
+ previews: DocumentPreviews,
*,
layout: InstructionsTabParameters,
language_manager: LanguageManager,
@@ -131,7 +133,7 @@ def __init__(
language_manager=language_manager,
is_operation_active=is_operation_active,
)
- self._file_playback: FilePlayback = FilePlayback(audio_device_manager)
+ self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews)
self._library_tree_logic = TreeLogic(
session_manager,
self._file_playback,
@@ -180,6 +182,7 @@ def __init__(
self._guarded_player = GuardedPlayer(
self._instruction_player_logic,
failures=playback_failures,
+ previews=previews,
)
self._waveform_panel = GUIInstructionWaveformPanel(
initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM),
diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py
index 6a8945ab9..385336b2a 100644
--- a/src/sampletones_application/coordinators/tabs/main.py
+++ b/src/sampletones_application/coordinators/tabs/main.py
@@ -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
@@ -17,6 +19,7 @@
from sampletones_application.logic.main.explorer_manager import ExplorerManager
from sampletones_application.logic.main.sources.scan import FolderScan
from sampletones_application.logic.shared.file_playback import FilePlayback
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.main import MainTabParameters
from sampletones_application.services.conversion.service import ConversionService
@@ -105,6 +108,7 @@ def __init__(
library_manager: InstructionsLibraryManager,
conversion_service: ConversionService,
hooks: MainTabHooks,
+ previews: DocumentPreviews,
*,
layout: MainTabParameters,
language_manager: LanguageManager,
@@ -129,12 +133,14 @@ 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(
config_manager,
session_manager,
audio_device_manager,
+ previews,
layout=layout,
language_manager=language_manager,
status_bar=status_bar,
@@ -165,6 +171,7 @@ def _build_explorer(
config_manager: ConfigManager,
session_manager: SessionManager,
audio_device_manager: AudioDeviceManager,
+ previews: DocumentPreviews,
*,
layout: MainTabParameters,
language_manager: LanguageManager,
@@ -176,7 +183,7 @@ def _build_explorer(
language_manager=language_manager,
open_directories=session_manager.expanded_directories,
)
- self._file_playback: FilePlayback = FilePlayback(audio_device_manager)
+ self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews)
self._folder_scan: FolderScan = FolderScan(FolderScanService(priority=self._repaint_priority))
self._explorer_tree_logic: TreeLogic = TreeLogic(
session_manager,
@@ -515,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"]
@@ -527,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,
@@ -536,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.
diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py
index b427fadee..faf9f05e9 100644
--- a/src/sampletones_application/coordinators/tabs/reconstruction.py
+++ b/src/sampletones_application/coordinators/tabs/reconstruction.py
@@ -39,12 +39,14 @@
ReconstructionPanelLogic,
)
from sampletones_application.logic.reconstruction.rewrites.steps import (
+ ListeningChange,
RateChange,
Rewrite,
StemRemovalRequest,
)
from sampletones_application.logic.shared.file_playback import FilePlayback
from sampletones_application.logic.shared.player import PlayerLogic
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.reconstruction import (
ReconstructionTabParameters,
@@ -103,6 +105,7 @@
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
+from sampletones_application.view_model.reconstruction.work import Work
from sampletones_application.view_model.shared.audio_data import AudioData
from sampletones_application.view_model.shared.recording import NamedRecordingViewModel
from sampletones_core.audio import AudioDeviceManager
@@ -151,6 +154,7 @@ def __init__(
instrument_exports: InstrumentExportCoordinator,
history: HistoryManager,
instrument_edit_detail: InstrumentEditDetail,
+ previews: DocumentPreviews,
*,
key_router: KeyRouter,
tab_active: ActivePredicate,
@@ -199,7 +203,7 @@ def __init__(
config_manager,
browser_manager,
)
- self._file_playback: FilePlayback = FilePlayback(audio_device_manager)
+ self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews)
self._browser_tree_logic: TreeLogic = TreeLogic(
session_manager,
self._file_playback,
@@ -231,6 +235,7 @@ def __init__(
self._guarded_player = GuardedPlayer(
self._reconstruction_player_logic,
failures=playback_failures,
+ previews=previews,
)
self._reconstruction_audio_panel: GUIReconstructionAudioPanel = GUIReconstructionAudioPanel(
path_colors=layout.path_colors,
@@ -305,18 +310,20 @@ def __init__(
self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view
self._reconstruction_panel_logic.on_stems_view_changed = self._reconstruction_stems_panel.update_view
self._reconstruction_panel_logic.on_ownership_changed = self._reconstruction_plot_panel.update_ownership
- self._reconstruction_panel_logic.on_heard_changed = self._reconstruction_instruments_logic.update_display
+ self._reconstruction_panel_logic.on_listening_changed = self._reconstruction_instruments_logic.follow_listening
self._reconstruction_panel_logic.on_audio_data_changed = self._on_audio_data_changed
self._reconstruction_panel_logic.on_waveform_load_changed = self._reconstruction_plot_panel.load_waveform_data
self._reconstruction_panel_logic.on_waveform_update_changed = (
self._reconstruction_plot_panel.update_waveform_data
)
self._reconstruction_panel_logic.on_waveform_cleared = self._reconstruction_plot_panel.clear_waveform
+ self._reconstruction_panel_logic.on_renders_wanted = self._request_listening
self._reconstruction_panel_logic.on_waveform_source_changed = (
self._reconstruction_plot_panel.set_waveform_top_source
)
self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog
self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog
+ self._reconstruction_panel_logic.on_export_dropped = self._report_dropped_export
self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found(
path,
language_manager["reconstructions.reconstruction.message.locate_audio_failed"],
@@ -390,6 +397,11 @@ def _on_export_result(self, result: ExportResult) -> None:
case ExportError(kind=ExportKind.SAMPLE, exception=exception):
self._dialogs.show_error(exception, messages.instruments_failed)
+ def _report_dropped_export(self, filepath: Path) -> None:
+ """Reports an export that waited for a sound the worker never rendered, in the WAV dialog's words."""
+ messages = self._export_messages
+ self._dialogs.show_message_with_path(messages.wav_title, messages.wav_dropped, filepath)
+
def _export_message(
self,
success: str,
@@ -731,6 +743,10 @@ def _request_rate_change(self, nes_frequency: int) -> None:
"""Asks for the open document to be re-timed to the rate the reader typed."""
self._on_rewrite_requested(RateChange(nes_frequency=nes_frequency))
+ def _request_listening(self) -> None:
+ """Asks for what the reader hears to be rendered, where the screen holds no audio for it yet."""
+ self._on_rewrite_requested(ListeningChange())
+
def _request_remove_directory(self, directory: Path) -> None:
self._dialogs.show_confirmation(
tag=TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_DIRECTORY_CONFIRMATION,
@@ -806,8 +822,23 @@ def redraw_reconstruction(self, *, refit_waveform: bool) -> None:
self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform)
self._reconstruction_instruments_logic.update_display()
- def set_reconstruction_dimmed(self, dimmed: bool) -> None:
- self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed)
+ def abandon_showing(self) -> None:
+ """Lets the panel go of what it owes the reader, where the worker will never render it."""
+ self._reconstruction_panel_logic.abandon_showing()
+
+ def redraw_listening(self, *, envelopes_changed: bool) -> None:
+ """Shows what the reader hears once the worker has rendered it, where a showing waited for it.
+
+ A box on the stems card changes the part the instruments panel draws, so the panel is
+ redrawn when the rendering brought envelopes for a new choice; a box above the waveform
+ leaves them as they were.
+ """
+ self._reconstruction_panel_logic.redraw_listening()
+ if envelopes_changed:
+ self._reconstruction_instruments_logic.update_display()
+
+ def show_work(self, work: Optional[Work]) -> None:
+ self._reconstruction_plot_panel.show_work(work)
def toggle_channel(self, channel: ChannelName) -> None:
"""Switches one channel's slice in and out of the waveform and of what plays."""
@@ -827,7 +858,10 @@ def request_export_instruments_dialog(
"""Writes the loaded reconstruction's slices in ``export_format``, asking first for what the
format leaves open: its own setup where it has one, and the destination alone otherwise."""
if export_format in self._format_setups:
- self._format_setups[export_format].open_sample(self._reconstruction_panel_logic.sample_request())
+ request = self._reconstruction_panel_logic.sample_request()
+ if request is not None:
+ self._format_setups[export_format].open_sample(request)
+
return
self._reconstruction_panel_logic.request_export_instruments_dialog(export_format)
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py
index 58921e891..146b2e1fa 100644
--- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py
+++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py
@@ -54,7 +54,7 @@
)
from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
from sampletones_application.logic.shared.file_playback import FilePlayback
-from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.sequencer import SequencerTabParameters
from sampletones_application.services.song_player.service import SongPlayerService
@@ -119,7 +119,7 @@ def __init__(
browser_manager: BrowserManager,
project_controller: ProjectController,
history: HistoryManager,
- renders: RenderCache,
+ previews: DocumentPreviews,
original_audio_locator: OriginalAudioLocator,
instrument_exports: InstrumentExportCoordinator,
*,
@@ -162,7 +162,7 @@ def __init__(
browser_manager,
project_controller,
)
- self._file_playback: FilePlayback = FilePlayback(audio_device_manager)
+ self._file_playback: FilePlayback = FilePlayback(audio_device_manager, previews)
self._sequencer_tree_logic: TreeLogic = TreeLogic(
session_manager,
self._file_playback,
@@ -195,7 +195,7 @@ def __init__(
session_manager,
audio_device_manager,
scheduling=layout.scheduling,
- renders=renders,
+ previews=previews,
)
self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic()
self._song_player_logic: SongPlayerLogic = SongPlayerLogic(
@@ -217,6 +217,7 @@ def __init__(
self._guarded_player = GuardedPlayer(
self._song_player_logic,
failures=playback_failures,
+ previews=previews,
)
self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel(
self._sequencer_tracker_logic.settings,
diff --git a/src/sampletones_application/layout/general/caret.py b/src/sampletones_application/layout/general/caret.py
index 4e9bb687f..d02a4a576 100644
--- a/src/sampletones_application/layout/general/caret.py
+++ b/src/sampletones_application/layout/general/caret.py
@@ -4,7 +4,10 @@
class CaretLayout(BaseModel, extra="forbid", frozen=True):
- offset: int
- width_padding: int
- fill: WrittenColor
- border: WrittenColor
+ """How the tracker's caret is drawn: the underline's height and color, and the color of the frame around the
+ cell.
+ """
+
+ height: int
+ color: WrittenColor
+ frame: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
index c297d3682..ea537a970 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
@@ -11,11 +11,14 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True):
is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are
the two kinds a named voice can be, so the slot's color reports what it holds.
``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row``
- indices and the ``order`` entries carry the grids around them. Defining them once
+ indices and the ``order`` entries carry the grids around them. ``pending`` is what a slot
+ wears while an entry is being typed into it, whatever kind the entry will turn out to be,
+ so the digits being written stand out from everything around them. Defining them once
keeps every panel in step.
"""
voice: WrittenColor
+ pending: WrittenColor
transpose: WrittenColor
volume: WrittenColor
sample: WrittenColor
diff --git a/src/sampletones_application/logic/documentation/__init__.py b/src/sampletones_application/logic/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/logic/documentation/shelf.py b/src/sampletones_application/logic/documentation/shelf.py
new file mode 100644
index 000000000..ea414de13
--- /dev/null
+++ b/src/sampletones_application/logic/documentation/shelf.py
@@ -0,0 +1,69 @@
+import os
+import shutil
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.documentation.site import DocumentationSite
+from sampletones_shared.exceptions import DocumentationUnavailableError
+
+HIDDEN_PREFIX: Final[str] = "."
+STAGING_SUFFIX: Final[str] = ".staging"
+RETIRED_SUFFIX: Final[str] = ".retired"
+
+
+class DocumentationShelf:
+ """Keeps a copy of the shipped documentation in the user's data folder, where the browser reads it.
+
+ The shipped pages may live only as long as a run, since a one-file bundle unpacks itself into
+ a temporary folder; the copy outlives the run, so an open page stays readable after the
+ application closes. The copy keeps one address across versions, so a page the reader bookmarked
+ still opens after an update. It carries the stamp of the pages it was copied from, and a
+ different stamp in the installation replaces it whole.
+ """
+
+ def __init__(
+ self,
+ *,
+ site: DocumentationSite,
+ directory: Path,
+ ) -> None:
+ self._site = site
+ self._copy = DocumentationSite(directory)
+
+ def place(self) -> Path:
+ """The index of the copy of the shipped pages, replaced first where it holds other pages.
+
+ The new pages are written beside the copy and swapped in once whole, so the copy is either
+ the earlier pages or the new ones.
+
+ Raises:
+ DocumentationUnavailableError: If the installation carries no sealed site.
+ OSError: If the copy cannot be written.
+ """
+ stamp = self._site.read_stamp()
+ if stamp is None or not self._site.index.is_file():
+ raise DocumentationUnavailableError(f"No documentation site lies in {self._site.directory}")
+
+ if self._copy.read_stamp() != stamp:
+ self._replace()
+
+ return self._copy.index
+
+ def _replace(self) -> None:
+ target = self._copy.directory
+ staging = self._beside(STAGING_SUFFIX)
+ retired = self._beside(RETIRED_SUFFIX)
+ for leftover in (staging, retired):
+ if leftover.exists():
+ shutil.rmtree(leftover)
+
+ shutil.copytree(self._site.directory, staging)
+ if target.exists():
+ os.replace(target, retired)
+
+ os.replace(staging, target)
+ shutil.rmtree(retired, ignore_errors=True)
+
+ def _beside(self, suffix: str) -> Path:
+ target = self._copy.directory
+ return target.with_name(f"{HIDDEN_PREFIX}{target.name}{suffix}")
diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py
index 55bba7f44..beb9c5a84 100644
--- a/src/sampletones_application/logic/history/fingerprint.py
+++ b/src/sampletones_application/logic/history/fingerprint.py
@@ -1,83 +1,285 @@
-from typing import Callable, Dict, Final, Iterable, List, Tuple
+from typing import (
+ AbstractSet,
+ Callable,
+ Dict,
+ Final,
+ Iterable,
+ List,
+ Optional,
+ Protocol,
+ Set,
+ Tuple,
+ TypeVar,
+)
from pydantic import BaseModel
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project import Project
+from sampletones_core.project.patterns.channel import Channel
+from sampletones_core.project.patterns.pattern import Pattern
+from sampletones_core.project.song import Song
from sampletones_core.project.voices.instrument import Instrument
from sampletones_core.project.voices.sample import Sample
from sampletones_core.project.voices.voice import samples
from sampletones_core.reconstructions import Reconstruction
-from sampletones_shared.utils.hashing import identity_digest
+from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_shared.utils.hashing import hash_model, identity_digest
from sampletones_shared.utils.serialization import dump
FINGERPRINT_LENGTH: Final[int] = 64
+SONG_STRUCTURE: Final[Set[str]] = {"rows_per_pattern", "order"}
-ReconstructionHash = Callable[[Reconstruction], str]
+StreamHash = Callable[[InstructionsItem], str]
+AssignmentHash = Callable[[ChannelAssignment], str]
+PatternHash = Callable[[Pattern], str]
+Value = TypeVar("Value")
-def _canonical(model: BaseModel) -> str:
- """The model spelled as JSON with its keys sorted, one spelling for one state."""
- return dump(model.model_dump(mode="json"))
+
+class PartHashes(Protocol):
+ """How the heavy parts of a project are hashed: each sample's document and the song's patterns.
+
+ A fingerprint spells the project's light structure itself and hands the heavy parts here, so
+ one object states the choice for every part a commit pays for: a digest read afresh, or one
+ memoized by the object's identity.
+ """
+
+ def reconstruction(self, reconstruction: Reconstruction) -> str: ...
+
+ def song(self, song: Song) -> str: ...
+
+
+def _canonical(
+ model: BaseModel,
+ *,
+ fields: Optional[Set[str]] = None,
+) -> str:
+ """The model, or the fields of it named, spelled as JSON with its keys sorted, one spelling for one state.
+
+ ``fields`` is the set pydantic's ``include`` takes.
+ """
+ return dump(model.model_dump(mode="json", include=fields))
def fingerprint_project(
project: Project,
*,
- reconstruction_hash: ReconstructionHash,
+ hashes: PartHashes,
) -> str:
"""Returns a content hash used to verify that a restore reproduces a snapshot.
The hash covers the full project state, spelled out part by part so that two projects
differing anywhere in it differ here. Each part is hashed with its keys sorted, so a mapping
- holding the same entries in another order reads as the same state. Each sample's
- reconstruction content enters through ``reconstruction_hash``, so the caller decides between
- a memoized digest (capture, where copy-on-write keeps it valid) and a fresh one
- (verification, where recomputing from scratch catches any divergence).
+ holding the same entries in another order reads as the same state. The song and each
+ sample's document enter through ``hashes``, so the caller decides between memoized digests
+ (capture, where copy-on-write keeps them valid) and fresh ones (verification, where
+ recomputing from scratch catches any divergence).
"""
parts: List[str] = [
_canonical(project.metadata),
_canonical(project.info),
_canonical(project.settings),
- _canonical(project.song),
+ hashes.song(project.song),
]
for voice in project.voices:
parts.append(voice.id)
parts.append(voice.name)
match voice:
case Sample():
- parts.append(reconstruction_hash(voice.reconstruction))
+ parts.append(hashes.reconstruction(voice.reconstruction))
case Instrument():
parts.append(_canonical(voice))
return identity_digest(*parts, length=FINGERPRINT_LENGTH)
-class ReconstructionHashCache:
- """Memoizes per-reconstruction content hashes by object identity.
+def reconstruction_digest(
+ reconstruction: Reconstruction,
+ *,
+ stream_hash: StreamHash,
+ assignment_hash: AssignmentHash,
+) -> str:
+ """A reconstruction's content spelled out part by part, its streams and its owner records through the hashes given.
- Copy-on-write discipline makes a reconstruction's content immutable for the
- object's lifetime, so one hash per object suffices and capture-time
- fingerprinting collapses to hashing the light structure. Each entry stores a
- strong reference to its reconstruction alongside the digest: the reference
- keeps the object alive, so its ``id()`` stays bound to that object for as
- long as the entry exists. :meth:`prune` keeps only entries whose
- reconstruction still appears in one of the given projects, releasing the rest
- and bounding the cache by what the history retains.
+ Every field the document stores enters: its metadata, id, configuration and coefficient, each
+ channel's stream, and the record of its recordings, which is the setup, the sources, the
+ assignment of each frame to an owner and the scale. The streams' digests join as one part and
+ the assignments' as another, so a document of three streams and two records reads apart from
+ one of two streams and three. The light parts are spelled in JSON mode; a stream and a record
+ are spelled by the hash handed in, which capture memoizes by identity and verification reads
+ afresh, and the two agree because each part is spelled one way on both sides.
+
+ Args:
+ reconstruction: The document hashed.
+ stream_hash: How one channel's stream is hashed.
+ assignment_hash: How one channel's owner record is hashed.
+
+ Returns:
+ str: The digest of the document's content.
+ """
+ stems_data = reconstruction.stems_data
+ return identity_digest(
+ _canonical(reconstruction.metadata),
+ reconstruction.id,
+ _canonical(reconstruction.config),
+ identity_digest(*(stream_hash(stream) for stream in reconstruction.instructions_data)),
+ _canonical(stems_data.config),
+ identity_digest(*(_canonical(source) for source in stems_data.sources)),
+ identity_digest(*(assignment_hash(assignment) for assignment in stems_data.assignments)),
+ dump(stems_data.scale),
+ dump(reconstruction.coefficient),
+ )
+
+
+def song_digest(
+ song: Song,
+ *,
+ pattern_hash: PatternHash,
+) -> str:
+ """A song's content spelled out part by part, its patterns through the hash given.
+
+ The row count and the order are spelled in JSON mode as they stand. Each channel's pool is
+ one nested part: the channel it is keyed under and named for, then each index it holds a
+ pattern at beside that pattern's digest, the pools in channel order and the indices
+ ascending, so a pool read in another order reads as the same pool and a pattern moved to
+ another index reads as a change. A pattern is spelled by the hash handed in, which capture
+ memoizes by identity and verification reads afresh.
+
+ Args:
+ song: The song hashed.
+ pattern_hash: How one pattern is hashed.
+
+ Returns:
+ str: The digest of the song's content.
"""
+ return identity_digest(
+ _canonical(song, fields=SONG_STRUCTURE),
+ *(_pool_digest(name, song.channels[name], pattern_hash) for name in sorted(song.channels)),
+ )
+
- def __init__(self, *, reconstruction_hash: ReconstructionHash) -> None:
- self._reconstruction_hash = reconstruction_hash
- self._hashes: Dict[int, Tuple[Reconstruction, str]] = {}
+def _pool_digest(
+ name: ChannelName,
+ channel: Channel,
+ pattern_hash: PatternHash,
+) -> str:
+ parts = [dump(name), dump(channel.name)]
+ for index in sorted(channel.patterns):
+ parts.append(dump(index))
+ parts.append(pattern_hash(channel.patterns[index]))
- def hash(self, reconstruction: Reconstruction) -> str:
- key = id(reconstruction)
- cached = self._hashes.get(key)
+ return identity_digest(*parts)
+
+
+def hash_reconstruction(reconstruction: Reconstruction) -> str:
+ """A reconstruction's digest with every part read afresh, which a verification spells."""
+ return reconstruction_digest(reconstruction, stream_hash=hash_model, assignment_hash=hash_model)
+
+
+def hash_song(song: Song) -> str:
+ """A song's digest with every pattern read afresh, which a verification spells."""
+ return song_digest(song, pattern_hash=hash_model)
+
+
+class FreshHashes:
+ """Every heavy part read afresh, so a verification reproduces a digest from the content alone."""
+
+ def reconstruction(self, reconstruction: Reconstruction) -> str:
+ return hash_reconstruction(reconstruction)
+
+ def song(self, song: Song) -> str:
+ return hash_song(song)
+
+
+class _Memo:
+ """Digests by object identity, each entry holding its object so its ``id()`` stays bound to it."""
+
+ def __init__(self) -> None:
+ self._entries: Dict[int, Tuple[object, str]] = {}
+
+ def digest(
+ self,
+ value: Value,
+ compute: Callable[[Value], str],
+ ) -> str:
+ """The digest kept for ``value``, computed by ``compute`` the first time the object is met."""
+ cached = self._entries.get(id(value))
if cached is None:
- cached = (reconstruction, self._reconstruction_hash(reconstruction))
- self._hashes[key] = cached
+ cached = (value, compute(value))
+ self._entries[id(value)] = cached
return cached[1]
+ def keep(self, live: AbstractSet[int]) -> None:
+ """Releases every entry whose object's identity is outside ``live``."""
+ self._entries = {key: value for key, value in self._entries.items() if key in live}
+
+
+class HashCache:
+ """Memoizes content hashes by object identity for every value a project is made of.
+
+ Copy-on-write discipline makes a value's content immutable for the object's lifetime: a
+ reconstruction, each stream and each owner record it is made of, and each pattern of the
+ song. A gesture replaces the values it changes and keeps every other by identity, so a fresh
+ document's digest composes from one new stream's hash and the memoized rest, the song's from
+ the one pattern a row edit replaced and the memoized rest, and capture-time fingerprinting
+ collapses to hashing what the gesture replaced. The song and its channels are shells written
+ in place, so they are spelled afresh on every commit around the memoized patterns. Each entry
+ stores a strong reference beside the digest: the reference keeps the object alive, so its
+ ``id()`` stays bound to that object for as long as the entry exists. :meth:`prune` keeps only
+ the entries whose object still appears in one of the given projects, releasing the rest and
+ bounding the cache by what the history retains.
+ """
+
+ def __init__(
+ self,
+ *,
+ stream_hash: StreamHash,
+ assignment_hash: AssignmentHash,
+ pattern_hash: PatternHash,
+ ) -> None:
+ self._stream_hash = stream_hash
+ self._assignment_hash = assignment_hash
+ self._pattern_hash = pattern_hash
+ self._memo = _Memo()
+
+ def reconstruction(self, reconstruction: Reconstruction) -> str:
+ return self._memo.digest(reconstruction, self._compose_reconstruction)
+
+ def song(self, song: Song) -> str:
+ return song_digest(song, pattern_hash=self._pattern)
+
+ def _compose_reconstruction(self, reconstruction: Reconstruction) -> str:
+ return reconstruction_digest(
+ reconstruction,
+ stream_hash=self._stream,
+ assignment_hash=self._assignment,
+ )
+
+ def _stream(self, stream: InstructionsItem) -> str:
+ return self._memo.digest(stream, self._stream_hash)
+
+ def _assignment(self, assignment: ChannelAssignment) -> str:
+ return self._memo.digest(assignment, self._assignment_hash)
+
+ def _pattern(self, pattern: Pattern) -> str:
+ return self._memo.digest(pattern, self._pattern_hash)
+
def prune(self, projects: Iterable[Project]) -> None:
- live = {id(sample.reconstruction) for project in projects for sample in samples(project.voices)}
- self._hashes = {key: value for key, value in self._hashes.items() if key in live}
+ """Keeps the entries of the objects ``projects`` still hold, one live set over every kind of part."""
+ retained = list(projects)
+ reconstructions = [sample.reconstruction for project in retained for sample in samples(project.voices)]
+ live: Set[int] = {id(reconstruction) for reconstruction in reconstructions}
+ live.update(id(stream) for reconstruction in reconstructions for stream in reconstruction.instructions_data)
+ live.update(
+ id(assignment) for reconstruction in reconstructions for assignment in reconstruction.stems_data.assignments
+ )
+ live.update(
+ id(pattern)
+ for project in retained
+ for channel in project.song.channels.values()
+ for pattern in channel.patterns.values()
+ )
+ self._memo.keep(live)
diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py
index 64dec819c..b05a455b2 100644
--- a/src/sampletones_application/logic/history/manager.py
+++ b/src/sampletones_application/logic/history/manager.py
@@ -10,7 +10,7 @@
from .action import HistoryAction
from .errors import HistoryIntegrityError, UntrackedMutationError
-from .fingerprint import ReconstructionHashCache, fingerprint_project
+from .fingerprint import FreshHashes, HashCache, fingerprint_project
from .snapshot import HistoryEntry
from .transaction import CoalesceKey, PendingTransaction
@@ -41,8 +41,8 @@ def __init__(
self._controller = controller
self._budget = budget
self._strict = strict
- self._hash_cache: Optional[ReconstructionHashCache] = (
- ReconstructionHashCache(reconstruction_hash=hash_model) if strict else None
+ self._hash_cache: Optional[HashCache] = (
+ HashCache(stream_hash=hash_model, assignment_hash=hash_model, pattern_hash=hash_model) if strict else None
)
self._entries: List[HistoryEntry] = []
self._cursor: int = -1
@@ -305,20 +305,13 @@ def _capture(
) -> HistoryEntry:
"""Snapshots the live project, fingerprinting it under strict deployment.
- Capture-time fingerprints reuse the memoized per-reconstruction hashes:
- copy-on-write keeps a reconstruction's content fixed for the object's
- lifetime, so the per-gesture cost collapses to hashing the light
- structure.
+ Capture-time fingerprints reuse the memoized hashes of every value the project is made
+ of: each reconstruction with its streams and owner records, and each pattern of the song.
+ Copy-on-write keeps their content fixed for the object's lifetime, so the per-gesture cost
+ collapses to hashing what the gesture replaced and the light structure.
"""
project = self._controller.project
- fingerprint = (
- fingerprint_project(
- project,
- reconstruction_hash=self._hash_cache.hash,
- )
- if self._hash_cache is not None
- else None
- )
+ fingerprint = fingerprint_project(project, hashes=self._hash_cache) if self._hash_cache is not None else None
return HistoryEntry(
project=project.snapshot(),
action=action,
@@ -371,7 +364,7 @@ def _reinstall(self, entry: HistoryEntry) -> None:
def _verify(self, entry: HistoryEntry) -> None:
"""Checks the restored project against the entry's recorded fingerprint.
- Verification always hashes reconstructions fresh: an in-place mutation of
+ Verification always hashes every value fresh: an in-place mutation of
shared state keeps the object's identity, so a memoized digest would
reproduce the pre-mutation hash and mask exactly the divergence this
tripwire exists to catch.
@@ -379,10 +372,7 @@ def _verify(self, entry: HistoryEntry) -> None:
if entry.fingerprint is None:
return
- actual = fingerprint_project(
- self._controller.project,
- reconstruction_hash=hash_model,
- )
+ actual = fingerprint_project(self._controller.project, hashes=FreshHashes())
if actual != entry.fingerprint:
raise HistoryIntegrityError(
f"Restoring history entry '{entry.action}' produced a project that "
@@ -390,12 +380,12 @@ def _verify(self, entry: HistoryEntry) -> None:
)
def _prune_hash_cache(self) -> None:
- """Prunes the hash cache to the reconstructions the history still retains.
+ """Prunes the hash cache to the values the history still retains.
Runs wherever snapshots are discarded — reset, redo-branch truncation,
budget eviction, and coalescing replacement — keeping the cache bounded
- by the reconstructions still reachable from the stack or the live
- project.
+ by the reconstructions and patterns still reachable from the stack or
+ the live project.
"""
if self._hash_cache is None:
return
diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py
index e6192536c..c37d2dcfb 100644
--- a/src/sampletones_application/logic/instruction/library.py
+++ b/src/sampletones_application/logic/instruction/library.py
@@ -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,
@@ -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),
@@ -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)
diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py
index e33dab690..e5cf0a932 100644
--- a/src/sampletones_application/logic/main/converter/logic.py
+++ b/src/sampletones_application/logic/main/converter/logic.py
@@ -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
@@ -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)
@@ -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,
)
diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py
index 48e7da89d..33218c5f1 100644
--- a/src/sampletones_application/logic/main/converter/run.py
+++ b/src/sampletones_application/logic/main/converter/run.py
@@ -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
@@ -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:
@@ -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():
@@ -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:
diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py
index db1d0f0d5..977e148c9 100644
--- a/src/sampletones_application/logic/reconstruction/data.py
+++ b/src/sampletones_application/logic/reconstruction/data.py
@@ -1,21 +1,19 @@
from dataclasses import dataclass, replace
from functools import cached_property
from pathlib import Path
-from typing import Dict, List, Optional, Self, Tuple
+from typing import Dict, Optional, Self, Tuple
import numpy as np
from sampletones_application.logic.shared.renders import RenderCache
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.audio import mix
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.reconstructions.reconstruction.recordings import load_recordings
from sampletones_core.reconstructions.reconstruction.renders import rendered_length
-from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
-from sampletones_core.reconstructions.reconstruction.stems.filter import (
- filter_approximations,
+from sampletones_core.reconstructions.reconstruction.stems.hearing import (
+ HeardRenders,
+ Hearing,
)
from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
from sampletones_shared.logger import logger
@@ -23,6 +21,17 @@
@dataclass(frozen=True)
class ReconstructionData:
+ """The open document beside the recordings read for it.
+
+ Attributes:
+ name: The name the document is shown under.
+ config: The configuration the document was converted at.
+ reconstruction: The document itself.
+ stem_audios: The loaded recordings, one per entry in entry order, or none at all; each is
+ read-only, since a worker mixes them while the render thread holds them.
+ filepath: The file the document lives in, ``None`` for one held in memory.
+ """
+
name: str
config: Config
reconstruction: Reconstruction
@@ -48,7 +57,7 @@ def from_reconstruction(cls, reconstruction: Reconstruction, *, name: str) -> Se
caller (the project sample's name).
A document whose file was removed in the Browser is wrapped the same way, under the name
- it had. It keeps the locations of its recordings, so its original audio loads again from them.
+ it had. It keeps the locations of its recordings, so they load again from them.
"""
return cls._assemble(
reconstruction,
@@ -63,7 +72,7 @@ def detached_copy(self, filepath: Path) -> Self:
document. The copy holds a reconstruction object of its own, which shares every part with
the one it was copied from, so a document that was a project sample becomes a standalone
entity: later edits reach only the saved file, leaving the project's sample unchanged. The
- copy shares the loaded recordings, so the original audio carries over without a reload.
+ copy shares the loaded recordings, so the mix of them the cache keeps serves it too.
"""
reconstruction = self.reconstruction.model_copy()
return replace(
@@ -137,11 +146,13 @@ def _derive_name(filepath: Path) -> str:
def _load_stem_audios(
reconstruction: Reconstruction,
) -> Tuple[np.ndarray, ...]:
- """Loads the recorded source, one recording per path, in path order.
+ """Loads the recorded source, one recording per path, in path order, each kept read-only.
Every recording is read at the level its conversion read it at, which the document
records, so one heard on its own sounds at the level it holds in the mix, and it keeps
- that level once another recording leaves the document.
+ that level once another recording leaves the document. A worker mixes the recordings
+ heard while the render thread holds them, so each is kept read-only and what both read
+ stands as it was loaded.
A reconstruction detached from its origin (a project sample) records no source
path, and a file-backed reconstruction may point at audio absent or unreadable on
@@ -150,122 +161,76 @@ def _load_stem_audios(
own in playback and the display.
"""
try:
- return load_recordings(reconstruction)
+ recordings = load_recordings(reconstruction)
except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as error:
logger.warning(f"Could not load the original audio: {error}. The original is unavailable")
return ()
- @cached_property
- def original_audio(self) -> Optional[np.ndarray]:
- """The recorded source mixed into one waveform, ``None`` while no source loads."""
- return mix(list(self.stem_audios)) if self.stem_audios else None
+ for recording in recordings:
+ recording.setflags(write=False)
- @cached_property
- def _stem_recording_indexes(self) -> Dict[int, int]:
- """Maps each stem id to the index of its recording in ``stem_audios``.
+ return recordings
- The entries' ids map to their recordings in entry order, and source audio absent
- or unreadable maps nothing.
+ @cached_property
+ def recordings_by_stem(self) -> Dict[int, np.ndarray]:
+ """The loaded recordings by the id of the entry each was loaded for, in record order, or none where
+ the recordings are absent or unreadable.
"""
if not self.stem_audios:
return {}
- stems_data = self.reconstruction.stems_data
- return {entry.id: index for index, entry in enumerate(stems_data.config.entries)}
+ entries = self.reconstruction.stems_data.config.entries
+ return {entry.id: self.stem_audios[index] for index, entry in enumerate(entries)}
- def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]:
- """The original audio of the stems heard anywhere.
-
- Returns:
- Optional[np.ndarray]: The mix of the recordings heard, silence where the recordings
- loaded and none of them is heard, and ``None`` where no recording loaded, which leaves
- the approximation on its own in playback and the display.
- """
- if not self.stem_audios:
- return None
-
- selected_stem_ids = selection.any_channel()
- indexes = self._stem_recording_indexes
- recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids]
- if not recordings:
- return np.zeros(rendered_length(self.reconstruction), dtype=np.float32)
-
- return mix(recordings)
+ def recordings_heard(self, selection: StemSelection) -> Tuple[np.ndarray, ...]:
+ """The loaded recordings ``selection`` hears anywhere, in record order."""
+ heard = selection.any_channel()
+ return tuple(recording for stem_id, recording in self.recordings_by_stem.items() if stem_id in heard)
def waveform_data(
self,
renders: RenderCache,
- selection: Optional[StemSelection] = None,
- ) -> WaveformData:
+ hearing: Hearing,
+ ) -> Optional[WaveformData]:
"""Projects the slice of this data the waveform display renders, the audio read from ``renders``.
- With a stems selection, the projection carries the frames each channel's selected
- stems own and the mix of the recordings heard anywhere, so the waveform answers
- exactly what plays.
+ The projection carries what ``hearing`` sounds of the document and the mix of the
+ recordings heard anywhere, both read from what the cache keeps, so the reading computes
+ no sample. A document whose recordings loaded and none of which is heard draws a silent
+ original, answered here, and one with no recording loaded draws none.
+
+ Returns:
+ Optional[WaveformData]: The projection, or ``None`` where the cache keeps no audio for
+ ``hearing`` yet or no mix of the recordings heard, which is what a worker is then
+ asked to render.
"""
- if selection is None:
- return self._unfiltered_waveform(renders)
+ heard = renders.reading(self.reconstruction, hearing)
+ if heard is None:
+ return None
- return self._filtered_waveform(
- renders,
- selection,
- self.reconstruction.stems_data,
- )
+ if not self.stem_audios:
+ return self._waveform_data(None, heard)
- def _unfiltered_waveform(self, renders: RenderCache) -> WaveformData:
- """The whole document: every channel's rendered audio and the full original."""
- return self._waveform_data(
- self.original_audio,
- renders.channels(self.reconstruction),
- renders.mix(self.reconstruction),
- )
+ recordings = self.recordings_heard(hearing.selection)
+ if not recordings:
+ return self._waveform_data(np.zeros(rendered_length(self.reconstruction), dtype=np.float32), heard)
- def _filtered_waveform(
- self,
- renders: RenderCache,
- selection: StemSelection,
- stems_data: StemsData,
- ) -> WaveformData:
- """The selected stems' frames and their original mix, in the unfiltered shape."""
- approximations = filter_approximations(
- stems_data,
- renders.channels(self.reconstruction),
- selection,
- self.reconstruction.config.frame_length,
- )
- return self._waveform_data(
- self.original_mix_for(selection),
- approximations,
- mix(list(approximations.values())),
- )
+ original = renders.original(recordings, self.config)
+ if original is None:
+ return None
+
+ return self._waveform_data(original, heard)
def _waveform_data(
self,
original_audio: Optional[np.ndarray],
- approximations: Dict[ChannelName, np.ndarray],
- approximation: np.ndarray,
+ heard: HeardRenders,
) -> WaveformData:
return WaveformData(
original_audio=original_audio,
- approximation=approximation,
- approximations=approximations,
+ approximation=heard.mix,
+ playing=heard.playing,
coefficient=self.reconstruction.coefficient,
frame_length=self.reconstruction.config.frame_length,
sample_rate=self.reconstruction.config.sample_rate,
)
-
- def get_partials(
- self,
- renders: RenderCache,
- channel_names: List[ChannelName],
- ) -> np.ndarray:
- return self.waveform_data(renders).partials(channel_names)
-
- def partials_for(
- self,
- renders: RenderCache,
- channel_names: List[ChannelName],
- selection: StemSelection,
- ) -> np.ndarray:
- """Sums the selected channels with the unselected stems' frames silenced."""
- return self.waveform_data(renders, selection).partials(channel_names)
diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py
index 7f9643183..997a51b5b 100644
--- a/src/sampletones_application/logic/reconstruction/edit.py
+++ b/src/sampletones_application/logic/reconstruction/edit.py
@@ -3,21 +3,24 @@
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.transaction import CoalesceKey
+from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes
from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.reconstructions import Reconstruction
@dataclass(frozen=True)
class ChannelEdit:
- """A regenerated instrument paired with the channel and feature the reader moved.
+ """A regenerated instrument paired with the channel and feature the reader moved, and its envelopes.
Carrying the request context alongside the fresh reconstruction lets the project history
- record which channel and feature an edit touched.
+ record which channel and feature an edit touched. The envelopes the worker exported travel
+ with it, so the landing installs them where the instruments panel reads them.
"""
reconstruction: Reconstruction
channel_name: ChannelName
feature_key: FeatureKey
+ envelopes: HeardEnvelopes
@property
def history_action(self) -> HistoryAction:
diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py
index fb211c06a..fb433918c 100644
--- a/src/sampletones_application/logic/reconstruction/editing.py
+++ b/src/sampletones_application/logic/reconstruction/editing.py
@@ -28,7 +28,23 @@ class InstrumentEdit:
features: Features
-EditedVoice = Union[ChannelEnvelopesViewModel, InstrumentEdit]
+@dataclass(frozen=True)
+class EnvelopesOwed:
+ """A reconstruction in front of the panel whose envelopes the worker is still rendering.
+
+ An opening, an edit, a retune, an undo and a new choice of what is heard send the envelopes to
+ the worker with the sound, so the document has channels to edit once they land, and an edit
+ reaching the panel meanwhile goes nowhere.
+
+ Attributes:
+ opening: Whether no envelopes have landed for the document since it was opened, so the
+ panel has none of it to keep showing.
+ """
+
+ opening: bool
+
+
+EditedVoice = Union[ChannelEnvelopesViewModel, InstrumentEdit, EnvelopesOwed]
class InstrumentEditingProtocol(Protocol):
@@ -41,7 +57,9 @@ class InstrumentEditingProtocol(Protocol):
"""
def edited_instrument(self) -> Optional[EditedVoice]:
- """What the panel is editing, or ``None`` while it holds nothing."""
+ """What the panel is editing: an instrument, a reconstruction's channels, or a reconstruction whose
+ channels are on their way from the worker; ``None`` while it holds nothing.
+ """
def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None:
"""Writes one dimension of the instrument in front of the panel."""
diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py
index 57e9e7515..3b3a066e5 100644
--- a/src/sampletones_application/logic/reconstruction/editor.py
+++ b/src/sampletones_application/logic/reconstruction/editor.py
@@ -5,6 +5,7 @@
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.reconstruction.editing import (
EditedVoice,
+ EnvelopesOwed,
InstrumentEdit,
)
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
@@ -61,7 +62,11 @@ def instrument(self) -> Optional[Instrument]:
return voice if isinstance(voice, Instrument) else None
def edited_instrument(self) -> Optional[EditedVoice]:
- """What the instruments panel is editing, or ``None`` while it holds nothing."""
+ """What the instruments panel is editing, or ``None`` while it holds nothing.
+
+ An open reconstruction answers with its channels once the worker has exported their
+ envelopes for what the reader hears, and until then with the word that they are on their way.
+ """
instrument = self.instrument
if instrument is not None:
return InstrumentEdit(
@@ -70,7 +75,14 @@ def edited_instrument(self) -> Optional[EditedVoice]:
features=instrument.instrument_features(),
)
- return self._reconstruction_manager.current_features
+ features = self._reconstruction_manager.current_features
+ if features is not None:
+ return features
+
+ if self._reconstruction_manager.reconstruction is not None:
+ return EnvelopesOwed(opening=not self._reconstruction_manager.has_landed_envelopes)
+
+ return None
def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None:
"""Writes one dimension of the instrument in front of the tab, as one history entry.
diff --git a/src/sampletones_application/logic/reconstruction/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py
index 14f9a0f01..80d3288b0 100644
--- a/src/sampletones_application/logic/reconstruction/envelopes.py
+++ b/src/sampletones_application/logic/reconstruction/envelopes.py
@@ -1,4 +1,5 @@
-from typing import Dict
+from dataclasses import dataclass
+from typing import Dict, Mapping
from sampletones_application.logic.reconstruction.ownership import (
ownership_lanes,
@@ -11,35 +12,44 @@
from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.ownership import runs_up_to
from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
-def heard_envelopes(
+@dataclass(frozen=True)
+class HeardEnvelopes:
+ """The envelopes a document exports for one choice of recordings, beside that choice.
+
+ The worker exports them with the sound it renders, so a landing installs envelopes made for
+ the selection it names and tells a stale reading from a fresh one by that selection.
+
+ Attributes:
+ selection: The recordings the reader heard when the envelopes were exported.
+ channels: The envelopes each channel exports for them.
+ """
+
+ selection: StemSelection
+ channels: Mapping[ChannelName, Features]
+
+
+def envelopes_view(
reconstruction: Reconstruction,
- selection: StemSelection,
+ heard: HeardEnvelopes,
) -> ChannelEnvelopesViewModel:
- """The envelopes of the part each channel plays for the recordings a reader hears.
-
- A reconstruction exports one entry per channel whatever it sounds, so a subscript answers
- for any of them and :attr:`Features.has_frames` says which ones play. What is drawn, what is
- measured and what an export writes are one reading, so a recording switched off on a channel
- leaves the envelopes it held there, and a channel every recording is switched off on
- describes no frame at all.
+ """The envelopes the instruments panel draws, built from what the document exported for the reader.
Args:
- reconstruction: The reconstruction being read.
- selection: The recordings the reader hears, channel by channel.
+ reconstruction: The reconstruction the envelopes were exported from.
+ heard: The envelopes each channel exports for the recordings heard, beside that choice.
Returns:
- ChannelEnvelopesViewModel: One entry per channel the reconstruction exports.
+ ChannelEnvelopesViewModel: One entry per channel the reconstruction exports, with the
+ stretches under each channel's bars.
"""
- channels = {
- ChannelName(generator_name): features
- for generator_name, features in reconstruction.export_heard(selection).items()
- }
+ channels = {ChannelName(generator_name): features for generator_name, features in heard.channels.items()}
return ChannelEnvelopesViewModel(
channels=channels,
- ownership=_lanes(reconstruction, selection, channels),
+ ownership=_lanes(reconstruction, heard.selection, channels),
)
@@ -60,10 +70,10 @@ def _lanes(
if not tells_owners_apart(stems_data):
return {}
- owned = stems_data.assignments_by_channel
- assignments = {
- channel_name: owned[channel_name][: features.frame_count]
+ owned = stems_data.owner_runs_by_channel
+ runs = {
+ channel_name: runs_up_to(owned[channel_name], features.frame_count)
for channel_name, features in channels.items()
if features.has_frames and channel_name in owned
}
- return ownership_lanes(assignments, selection.stems_for)
+ return ownership_lanes(runs, selection.stems_for)
diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py
index 84c282f55..be337eabc 100644
--- a/src/sampletones_application/logic/reconstruction/instruments.py
+++ b/src/sampletones_application/logic/reconstruction/instruments.py
@@ -2,6 +2,7 @@
from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL
from sampletones_application.logic.reconstruction.editing import (
+ EnvelopesOwed,
InstrumentEdit,
InstrumentEditingProtocol,
)
@@ -10,6 +11,7 @@
ChannelEnvelopesViewModel,
)
from sampletones_application.view_model.reconstruction.instruments import (
+ InstrumentsShowing,
InstrumentViewModel,
ReconstructionInstrumentsViewModel,
)
@@ -65,7 +67,7 @@ def update_display(self) -> None:
the same voice, so the render is reported once it has been made and they settle on it: an
edit to an instrument redraws its waveform here.
"""
- self.call(self.on_view_changed, self._build_view_model(self._measured_channels()))
+ self.call(self.on_view_changed, self._build_view_model())
self.call(self.on_feature_data_changed, self._displayed_features())
self.call(self.on_display_refreshed)
@@ -102,12 +104,17 @@ def refresh_view(self) -> None:
panel draws it empty and the next edit starts from what the document holds. A document
rewritten anywhere else is drawn whole through :meth:`update_display`.
"""
- self.call(
- self.on_view_changed,
- self._build_view_model(self._measured_channels()),
- )
+ self.call(self.on_view_changed, self._build_view_model())
self._redraw_silenced()
+ def follow_listening(self) -> None:
+ """Locks the panel while the envelopes of a new choice of what is heard are on their way, and frees it
+ once the choice is one whose envelopes it draws.
+
+ The bars stay as drawn either way: the envelopes of the new choice redraw them as they land.
+ """
+ self.call(self.on_view_changed, self._build_view_model())
+
def _redraw_silenced(self) -> None:
"""Draws the channels an edit silenced as the document now holds them, once they stand by there."""
envelopes = self._reconstruction_envelopes()
@@ -133,32 +140,29 @@ def _redraw_silenced(self) -> None:
),
)
- def _measured_channels(self) -> Optional[Dict[ChannelName, Features]]:
- """The channels of the reconstruction in front of the panel, as its figures measure them.
+ def _measured_channels(
+ self,
+ document: ChannelEnvelopesViewModel,
+ ) -> Dict[ChannelName, Features]:
+ """The channels of ``document``, the reconstruction in front of the panel, as its figures measure them.
A channel measures as the document will hold it once the changes on their way land. A
change silencing every frame of a channel leaves it standing by, so that channel measures
standing by from the moment the change is made, with no frame and no figure.
"""
- document = self._document_envelopes()
- if document is None:
- return None
-
return {
- channel_name: (
- features if features == document[channel_name] else self._as_regenerated(channel_name, features)
- )
+ channel_name: (features if features == document[channel_name] else self._as_regenerated(features))
for channel_name, features in self._pending_changes.drawn(document).channels.items()
}
@staticmethod
- def _as_regenerated(channel_name: ChannelName, features: Features) -> Features:
+ def _as_regenerated(features: Features) -> Features:
"""The envelopes a channel holds once the regeneration has rebuilt it from ``features``.
A channel the envelopes silence rests through every frame, which the document stands by,
describing no frame.
"""
- if stands_by(channel_name, features):
+ if stands_by(features):
return features.leave_to_channel(features.envelopes)
return features
@@ -185,23 +189,40 @@ def instrument_edit(self) -> Optional[InstrumentEdit]:
case _:
return None
- def _build_view_model(
- self,
- channels: Optional[Dict[ChannelName, Features]],
- ) -> ReconstructionInstrumentsViewModel:
- instrument = self.instrument_edit
- if instrument is not None:
- return self._instrument_view_model(instrument)
+ def _build_view_model(self) -> ReconstructionInstrumentsViewModel:
+ """What the panel shows of the voice in front of it.
- if channels is None:
- return ReconstructionInstrumentsViewModel(
- reconstruction_loaded=False,
- playing_channels=frozenset(),
- footprint=None,
- )
+ A reconstruction is shown by its channels once the worker has exported their envelopes for
+ what the reader hears. While the next ones are on their way, the panel keeps the ones it
+ drew, and a document just opened, which has none yet, is shown as being prepared. An
+ instrument is shown by its own set.
+ """
+ match self._editor.edited_instrument():
+ case InstrumentEdit() as instrument:
+ return self._instrument_view_model(instrument)
+ case ChannelEnvelopesViewModel() as document:
+ return self._reconstruction_view_model(self._measured_channels(document))
+ case EnvelopesOwed() as owed:
+ return self._bare_view_model(InstrumentsShowing.PREPARING if owed.opening else InstrumentsShowing.KEPT)
+ case None:
+ return self._bare_view_model(InstrumentsShowing.NOTHING)
+
+ @staticmethod
+ def _bare_view_model(showing: InstrumentsShowing) -> ReconstructionInstrumentsViewModel:
+ """A view naming what the panel shows and nothing it measures, which the panel keeps or has none of."""
+ return ReconstructionInstrumentsViewModel(
+ showing=showing,
+ playing_channels=frozenset(),
+ footprint=None,
+ )
+ def _reconstruction_view_model(
+ self,
+ channels: Dict[ChannelName, Features],
+ ) -> ReconstructionInstrumentsViewModel:
+ """What the panel shows of a reconstruction: the channels that play and what they cost."""
return ReconstructionInstrumentsViewModel(
- reconstruction_loaded=True,
+ showing=InstrumentsShowing.EDITABLE,
playing_channels=playing_channels(channels),
footprint=self._build_footprint(channels),
)
@@ -217,7 +238,7 @@ def _instrument_view_model(
writes one.
"""
return ReconstructionInstrumentsViewModel(
- reconstruction_loaded=False,
+ showing=InstrumentsShowing.EDITABLE,
playing_channels=playing_channels(self._instrument_channels(instrument)),
footprint=VoiceFootprintViewModel.from_instrument(features_footprint(instrument.features)),
instrument=InstrumentViewModel(name=instrument.name),
@@ -264,7 +285,7 @@ def handle_pitch_value_changed(
initial_pitch=value,
),
)
- case _:
+ case InstrumentEdit() | EnvelopesOwed() | None:
self.update_display()
def handle_envelope_changed(
@@ -295,7 +316,7 @@ def handle_envelope_changed(
initial_pitch=None,
),
)
- case None:
+ case EnvelopesOwed() | None:
self.update_display()
def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> None:
@@ -312,11 +333,11 @@ def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) ->
drawn = self._pending_changes.drawn(document)
self._note_silenced(change.channel_name, change.rebased(drawn[change.channel_name]))
self.call(self.on_channel_changed, change)
- self.call(self.on_view_changed, self._build_view_model(self._measured_channels()))
+ self.call(self.on_view_changed, self._build_view_model())
def _note_silenced(self, channel_name: ChannelName, features: Features) -> None:
"""Remembers whether the latest edit of a channel silences it, which its rebuild then shows."""
- if stands_by(channel_name, features):
+ if stands_by(features):
self._silenced.add(channel_name)
else:
self._silenced.discard(channel_name)
diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py
index 4ed21375d..a799758dd 100644
--- a/src/sampletones_application/logic/reconstruction/listening.py
+++ b/src/sampletones_application/logic/reconstruction/listening.py
@@ -1,22 +1,34 @@
-from typing import Dict, FrozenSet, Optional, Set
+from typing import Dict, FrozenSet, Optional
-from sampletones_core.constants.algorithm import AUTHORED_STEM_ID
from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.hearing import (
+ Hearing,
+ carried_hearing,
+ heard_by_stem,
+ offered_channels,
+ selection_of,
+)
from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
-class StemListening:
- """Which recordings of the open reconstruction a reader is listening to, channel by channel.
+class Listening:
+ """What a reader is listening to of the open reconstruction: the recordings heard on each channel, and
+ the channels kept on.
One choice serves every reading of the document: the frames it admits are the frames the
waveform draws, the envelopes the instruments panel shows, the figures measuring them, what
playback sounds and what an export writes, and they are the frames an edit reaches. Holding
- it beside the open reconstruction is what lets those readings answer from one state.
+ it beside the open reconstruction is what lets those readings answer from one state. The
+ channels kept on are the boxes above the waveform: a channel switched off keeps every choice
+ made for its recordings, so switching it back on restores them intact.
- The choice is the reader's, so it travels across an edit rather than being remade: a channel
- a recording keeps holding frames on keeps whatever was chosen for it, and one the recording
- reaches for the first time joins heard.
+ The choice is the reader's, so it travels across an edit by the core's one rule
+ (``carried_hearing``): a channel a recording keeps holding frames on keeps whatever was chosen
+ for it, one the recording reaches for the first time joins heard, and a channel coming into
+ play joins the channels on while one kept playing keeps the reader's choice for it. The
+ regeneration worker carries the choice onto the document it rebuilds by the same rule, so the
+ sound and the envelopes a landing installs are read for the choice this state then makes.
Soloing a recording silences every other one and remembers the choice it replaced, so soloing
it again returns to that choice. A choice made by hand afterwards stands as the new state.
@@ -26,6 +38,8 @@ def __init__(self) -> None:
self._offered: Dict[int, FrozenSet[ChannelName]] = {}
self._heard: Dict[int, FrozenSet[ChannelName]] = {}
self._heard_before_solo: Optional[Dict[int, FrozenSet[ChannelName]]] = None
+ self._playing: FrozenSet[ChannelName] = frozenset()
+ self._channels_on: FrozenSet[ChannelName] = frozenset()
@property
def offered(self) -> Dict[int, FrozenSet[ChannelName]]:
@@ -39,40 +53,58 @@ def heard(self) -> Dict[int, FrozenSet[ChannelName]]:
@property
def selection(self) -> StemSelection:
- """What the reader hears, read the way the filters ask for it.
-
- The card keeps the channels each recording is heard on; a filter asks each channel which
- recordings it keeps, so the selection is that map turned around.
- """
- channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()}
- for stem_id, stem_channels in self._heard.items():
- for channel_name in stem_channels:
- channels[channel_name].add(stem_id)
-
- return StemSelection(
- channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()}
- )
+ """What the reader hears, read the way the filters ask for it: the heard map turned around."""
+ return selection_of(self._heard)
def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]:
"""The recordings heard on one channel, which is the scope an edit writes in."""
return self.selection.stems_for(channel_name)
- def adopt(self, stems_data: StemsData) -> None:
- """Carries the reader's choice onto the record as it now stands.
+ @property
+ def playing(self) -> FrozenSet[ChannelName]:
+ """The channels the document plays, which are the boxes above the waveform a reader can tick."""
+ return self._playing
+
+ @property
+ def channels_on(self) -> FrozenSet[ChannelName]:
+ """The playing channels the reader keeps on, which are what plays and what the waveform draws."""
+ return self._channels_on
+
+ @property
+ def hearing(self) -> Hearing:
+ """What the reader hears, as one value a reading of the document is taken for."""
+ return Hearing(selection=self.selection, channels_on=self._channels_on)
+
+ def adopt(self, stems_data: StemsData, playing: FrozenSet[ChannelName]) -> None:
+ """Carries the reader's choice onto the record as it now stands, by the core's rule.
A recording appearing for the first time offers everything it holds and is heard on all
of it, which is the same rule read against the nothing it offered before, so a freshly
- opened reconstruction is heard whole.
+ opened reconstruction is heard whole. A channel coming into play joins the channels on
+ the same way, and one kept playing keeps whatever the reader chose for it.
+
+ Args:
+ stems_data: The record naming the owner of each frame.
+ playing: The channels the document plays.
"""
offered = offered_channels(stems_data)
- self._heard = {
- stem_id: (self._heard.get(stem_id, frozenset()) & channels)
- | (channels - self._offered.get(stem_id, frozenset()))
- for stem_id, channels in offered.items()
- }
+ hearing = carried_hearing(
+ self.hearing,
+ offered_before=self._offered,
+ playing_before=self._playing,
+ offered_after=offered,
+ playing_after=playing,
+ )
self._offered = offered
+ self._playing = playing
+ self._heard = heard_by_stem(hearing.selection, offered)
+ self._channels_on = hearing.channels_on
self._heard_before_solo = None
+ def set_channels_on(self, channels: FrozenSet[ChannelName]) -> None:
+ """Takes the channels the reader keeps on, as the boxes above the waveform leave them."""
+ self._channels_on = channels & self._playing
+
def set_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None:
"""Takes the channels one recording is heard on, as a box on its row leaves them."""
self._heard[stem_id] = channels
@@ -104,10 +136,12 @@ def solo(self, stem_id: int) -> None:
}
def release(self) -> None:
- """Lets go of the choice, which is what closing the reconstruction it describes does."""
+ """Lets go of the choice, which is what closing the reconstruction it describes, or opening another, does."""
self._offered = {}
self._heard = {}
self._heard_before_solo = None
+ self._playing = frozenset()
+ self._channels_on = frozenset()
def _is_alone(self, stem_id: int) -> bool:
"""Whether the recording is heard on every channel it offers while no other is heard at all."""
@@ -116,23 +150,3 @@ def _is_alone(self, stem_id: int) -> bool:
return False
return all(not channels for other_id, channels in self._heard.items() if other_id != stem_id)
-
-
-def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]:
- """The channels each recording holds frames on, which is what its row offers a box for.
-
- A recording the picker never chose on a channel contributes nothing there whatever the
- reader ticks, so the row draws a box exactly where the choice reaches something. A recording
- holding no frame anywhere has left the document, so every recording offers a box somewhere
- once any of them holds a frame. The frames the reader wrote gather under the authored stem,
- which offers its boxes the same way and appears exactly where it holds a frame.
- """
- offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries}
- for channel_name, stem_ids in stems_data.assignments_by_channel.items():
- for stem_id in set(stem_ids):
- if stem_id == AUTHORED_STEM_ID:
- offered.setdefault(AUTHORED_STEM_ID, set()).add(channel_name)
- elif stem_id in offered:
- offered[stem_id].add(channel_name)
-
- return {stem_id: frozenset(channels) for stem_id, channels in offered.items()}
diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py
index 7d9c986f9..fcacd1f35 100644
--- a/src/sampletones_application/logic/reconstruction/manager.py
+++ b/src/sampletones_application/logic/reconstruction/manager.py
@@ -1,12 +1,15 @@
import errno
import os
+from dataclasses import dataclass
from pathlib import Path
-from typing import Optional, Tuple
+from typing import Mapping, Optional, Tuple
+
+import numpy as np
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.reconstruction.data import ReconstructionData
-from sampletones_application.logic.reconstruction.envelopes import heard_envelopes
-from sampletones_application.logic.reconstruction.listening import StemListening
+from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view
+from sampletones_application.logic.reconstruction.listening import Listening
from sampletones_application.logic.reconstruction.session import ReconstructionSession
from sampletones_application.logic.shared.renders import RenderCache
from sampletones_application.utils.callbacks.queue import CallbackQueue
@@ -14,6 +17,7 @@
ChannelEnvelopesViewModel,
)
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -21,6 +25,29 @@
from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer
+@dataclass(frozen=True)
+class InstalledEnvelopes:
+ """The envelopes a worker exported, beside the document and the choice of recordings they were exported for.
+
+ Attributes:
+ reconstruction: The document the envelopes read.
+ selection: The recordings the reader heard when they were exported.
+ view: The envelopes as the instruments panel draws them.
+ """
+
+ reconstruction: Reconstruction
+ selection: StemSelection
+ view: ChannelEnvelopesViewModel
+
+ def answers_for(
+ self,
+ reconstruction: Optional[Reconstruction],
+ selection: StemSelection,
+ ) -> bool:
+ """Whether the envelopes were exported for ``reconstruction`` heard as ``selection``."""
+ return self.reconstruction is reconstruction and self.selection == selection
+
+
class ReconstructionManager(CallbackMixin):
"""
The single authority on which reconstruction is currently loaded.
@@ -43,9 +70,9 @@ def __init__(
self._renders = renders
self._session: ReconstructionSession = ReconstructionSession()
self._current_reconstruction: Optional[ReconstructionData] = None
- self._current_features: Optional[ChannelEnvelopesViewModel] = None
+ self._envelopes: Optional[InstalledEnvelopes] = None
self._voice_id: Optional[str] = None
- self._listening: StemListening = StemListening()
+ self._listening: Listening = Listening()
self.on_reconstruction_loaded: Optional[VoidCallback] = None
self.on_reconstruction_closed: Optional[VoidCallback] = None
@@ -56,14 +83,18 @@ def session(self) -> ReconstructionSession:
@property
def renders(self) -> RenderCache:
- """Where the audio of the open document is rendered and kept for as long as the screen reads it."""
+ """Where the audio of the open document is kept for as long as the screen reads it, handed back by the
+ regeneration worker's jobs.
+ """
return self._renders
def load_reconstruction(self, path: Path) -> None:
logger.info(f"Loading reconstruction: {logger.format_path(path)}")
- self._adopt_reconstruction(ReconstructionData.load(path), voice_id=None)
- self._session.mark_loaded(path.name)
- self.call(self.on_reconstruction_loaded)
+ self._open_reconstruction(
+ ReconstructionData.load(path),
+ name=path.name,
+ voice_id=None,
+ )
logger.info(f"Reconstruction {logger.format_path(path)} loaded successfully")
def load_reconstruction_object(
@@ -80,10 +111,27 @@ def load_reconstruction_object(
from. The document remembers the sample's voice id, which is how an edit finds the sample
to write back into and how a project change finds the voice the tab shows.
"""
- self._adopt_reconstruction(
+ self._open_reconstruction(
ReconstructionData.from_reconstruction(reconstruction, name=name),
+ name=name,
voice_id=voice_id,
)
+
+ def _open_reconstruction(
+ self,
+ reconstruction_data: ReconstructionData,
+ *,
+ name: str,
+ voice_id: Optional[str],
+ ) -> None:
+ """Puts a document in place of the open one, heard whole, under ``name``.
+
+ Every document numbers its recordings from the same ids, so the reader's listening choice
+ belongs to the document it was made on, and an opened document starts a choice of its own.
+ """
+ self._listening.release()
+ self._envelopes = None
+ self._adopt_reconstruction(reconstruction_data, voice_id=voice_id, envelopes=None)
self._session.mark_loaded(name)
self.call(self.on_reconstruction_loaded)
@@ -92,40 +140,75 @@ def _adopt_reconstruction(
reconstruction_data: ReconstructionData,
*,
voice_id: Optional[str],
+ envelopes: Optional[HeardEnvelopes],
) -> None:
- """Makes ``reconstruction_data`` the open document and refreshes its derived state.
-
- The reader's listening choice and the cached features track whichever reconstruction is
- open, so every rebinding funnels through here to recompute them in one place. The
- listening is carried onto the new record before the features are read, so the envelopes
- answer for the part the reader is listening to as it now stands. ``voice_id`` names the
- project sample the document is, and ``None`` a standalone document.
+ """Makes ``reconstruction_data`` the open document and carries the reader's listening choice onto it.
+
+ Every rebinding funnels through here, so the listening tracks whichever reconstruction is
+ open. ``voice_id`` names the project sample the document is, and ``None`` a standalone
+ document. ``envelopes`` are the ones a worker exported for the document, which a rebuild
+ hands over: the worker carries the choice onto the document it rebuilds by the rule the
+ listening carries it by, so they answer for the choice the listening makes on the new
+ record. A document arriving with none leaves its envelopes owed to the worker, like its
+ sound, until a listening lands them.
"""
self._current_reconstruction = reconstruction_data
self._voice_id = voice_id
- self._listening.adopt(reconstruction_data.reconstruction.stems_data)
- self._load_reconstruction_features()
+ reconstruction = reconstruction_data.reconstruction
+ self._listening.adopt(reconstruction.stems_data, frozenset(reconstruction.playing_channels))
+ if envelopes is not None:
+ self._install_envelopes(reconstruction, envelopes)
- def _load_reconstruction_features(self) -> None:
- """Reads the envelopes of the part the reader is listening to.
+ def _install_envelopes(
+ self,
+ reconstruction: Reconstruction,
+ envelopes: HeardEnvelopes,
+ ) -> None:
+ self._envelopes = InstalledEnvelopes(
+ reconstruction=reconstruction,
+ selection=envelopes.selection,
+ view=envelopes_view(reconstruction, envelopes),
+ )
- Raises:
- RuntimeError: If no reconstruction is open to read.
+ def adopt_envelopes(
+ self,
+ reconstruction: Reconstruction,
+ envelopes: HeardEnvelopes,
+ ) -> bool:
+ """Takes the envelopes the worker exported for ``reconstruction``, where it is the open document heard as the
+ reader hears it now.
+
+ A box on the stems card moves which recordings a channel is heard on, and the plot, the
+ figures and an export all read from the same envelopes, so they follow it together once
+ the worker's reading lands. A reading started before the reader moved on exports for a
+ choice they have left, so its envelopes are passed over and the ones answering stay.
+ Envelopes exported for the document and the choice the open ones already answer change
+ nothing.
+
+ Returns:
+ bool: True where the open document's envelopes changed.
"""
- if self._current_reconstruction is None:
- raise RuntimeError("No reconstruction is loaded when trying to load features")
+ if self.reconstruction is not reconstruction or envelopes.selection != self._listening.selection:
+ return False
- reconstruction = self._current_reconstruction.reconstruction
- self._current_features = heard_envelopes(reconstruction, self._listening.selection)
+ if self._envelopes is not None and self._envelopes.answers_for(reconstruction, envelopes.selection):
+ return False
+
+ self._install_envelopes(reconstruction, envelopes)
+ return True
- def refresh_features(self) -> None:
- """Reads the envelopes again after a change to what the reader is listening to.
+ def has_envelopes_for(self, selection: StemSelection) -> bool:
+ """Whether the worker's envelopes for the open document heard as ``selection`` are in place."""
+ return self._envelopes is not None and self._envelopes.answers_for(self.reconstruction, selection)
- A box on the stems card moves which recordings a channel is heard on, and the plot,
- the figures and an export all read from the same envelopes, so they follow it together.
+ @property
+ def has_landed_envelopes(self) -> bool:
+ """Whether the worker has landed envelopes for the open document since it was opened.
+
+ An edit, a retune, an undo and a new choice of what is heard keep the ones landed last
+ until the worker lands the next, so the instruments panel keeps drawing them meanwhile.
"""
- if self._current_reconstruction is not None:
- self._load_reconstruction_features()
+ return self._envelopes is not None
def save_reconstruction(self, filepath: Optional[Path] = None) -> bool:
"""Writes the open reconstruction to disk, reporting whether the write happened.
@@ -164,6 +247,7 @@ def save_reconstruction_as(self, filepath: Path) -> None:
self._adopt_reconstruction(
self._current_reconstruction.detached_copy(filepath),
voice_id=None,
+ envelopes=None,
)
self._session.mark_saved(self._current_reconstruction.name)
@@ -189,11 +273,16 @@ def detach_current_reconstruction(self) -> None:
name=name,
)
- def apply_edited(self, reconstruction: Reconstruction) -> None:
- """Adopts a reconstruction an edit produced.
+ def apply_edited(
+ self,
+ reconstruction: Reconstruction,
+ envelopes: Optional[HeardEnvelopes],
+ ) -> None:
+ """Adopts a reconstruction an edit produced, with the envelopes its worker exported where it had one.
The open document rebinds to the fresh reconstruction object and stays the voice it was,
- while the previous object is left untouched for the history to retain.
+ while the previous object is left untouched for the history to retain. An edit arriving
+ bare, as a removal, a retune and an undo do, leaves the envelopes owed to the worker.
"""
if self._current_reconstruction is None:
return
@@ -201,6 +290,7 @@ def apply_edited(self, reconstruction: Reconstruction) -> None:
self._adopt_reconstruction(
self._current_reconstruction.with_reconstruction(reconstruction),
voice_id=self._voice_id,
+ envelopes=envelopes,
)
def mark_updated(self) -> None:
@@ -208,7 +298,7 @@ def mark_updated(self) -> None:
def close_reconstruction(self) -> None:
self._current_reconstruction = None
- self._current_features = None
+ self._envelopes = None
self._voice_id = None
self._listening.release()
self._session.mark_closed()
@@ -237,9 +327,23 @@ def locate_original_audio(self) -> None:
def current_reconstruction(self) -> Optional[ReconstructionData]:
return self._current_reconstruction
+ @property
+ def recordings(self) -> Mapping[int, np.ndarray]:
+ """The recordings loaded for the open document, by stem id, which a worker mixes the original from."""
+ if self._current_reconstruction is None:
+ return {}
+
+ return self._current_reconstruction.recordings_by_stem
+
@property
def current_features(self) -> Optional[ChannelEnvelopesViewModel]:
- return self._current_features
+ """The envelopes the worker exported for the open document heard as the reader hears it now, or ``None``
+ while they are on their way.
+ """
+ if self._envelopes is None or not self._envelopes.answers_for(self.reconstruction, self._listening.selection):
+ return None
+
+ return self._envelopes.view
@property
def voice_id(self) -> Optional[str]:
@@ -252,8 +356,8 @@ def is_project_sample(self) -> bool:
return self._voice_id is not None
@property
- def listening(self) -> StemListening:
- """Which recordings of the open document the reader is listening to."""
+ def listening(self) -> Listening:
+ """What the reader is listening to of the open document: its recordings per channel, and its channels."""
return self._listening
@property
diff --git a/src/sampletones_application/logic/reconstruction/ownership.py b/src/sampletones_application/logic/reconstruction/ownership.py
index cd5d32ebe..1d3e84fef 100644
--- a/src/sampletones_application/logic/reconstruction/ownership.py
+++ b/src/sampletones_application/logic/reconstruction/ownership.py
@@ -1,6 +1,5 @@
from typing import AbstractSet, Callable, Dict, Final, Mapping, Sequence, Tuple
-from sampletones_application.logic.reconstruction.listening import offered_channels
from sampletones_application.view_model.shared.ownership import (
OwnershipLaneViewModel,
OwnershipRunViewModel,
@@ -9,7 +8,8 @@
from sampletones_core.constants.algorithm import RESTING_STEM_ID
from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
-from sampletones_core.reconstructions.reconstruction.stems.ownership import heard_frame, owner_runs
+from sampletones_core.reconstructions.reconstruction.stems.hearing import offered_channels
+from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun, heard_frame
DISTINGUISHABLE_OWNERS: Final[int] = 2
@@ -55,40 +55,41 @@ def tells_owners_apart(stems_data: StemsData) -> bool:
def ownership_lanes(
- assignments: Mapping[ChannelName, Sequence[int]],
+ runs: Mapping[ChannelName, Sequence[OwnerRun]],
heard_on: HeardOn,
) -> Dict[ChannelName, OwnershipLaneViewModel]:
- """A lane per channel of ``assignments``, each divided into the stretches its owners hold.
-
- Each surface states its own reading as the assignments it passes — which channels stand, and
- how far each lane runs — so the rule dividing a channel into stretches is written once and
- the ribbon under the waveform and the band under an instrument's bars agree by construction.
- Whether a lane is worth drawing at all is the caller's own question: a surface with room for
- only one reading, such as an instrument's bars, asks ``tells_owners_apart`` first and skips
- this call where a document answering to a single owner has nothing to tell apart; a surface
- reading every channel of a document at once, such as the waveform's ribbon, has a plainer
- question to answer instead — whether a channel plays — and takes a lane for every one that
- does.
+ """A lane per channel of ``runs``, each divided into the stretches its owners hold.
+
+ Each surface states its own reading as the stretches it passes — which channels stand, and
+ how far each lane runs — so the rule painting a stretch is written once and the ribbon under
+ the waveform and the band under an instrument's bars agree by construction. The stretches
+ themselves are the record's own (``StemsData.owner_runs_by_channel``), read from its frames
+ once and shared by every surface. Whether a lane is worth drawing at all is the caller's own
+ question: a surface with room for only one reading, such as an instrument's bars, asks
+ ``tells_owners_apart`` first and skips this call where a document answering to a single owner
+ has nothing to tell apart; a surface reading every channel of a document at once, such as the
+ waveform's ribbon, has a plainer question to answer instead — whether a channel plays — and
+ takes a lane for every one that does.
Args:
- assignments: The stem holding each frame, per channel the surface draws.
+ runs: The stretches each channel divides into, per channel the surface draws.
heard_on: The recordings the reader hears on a channel.
Returns:
Dict[ChannelName, OwnershipLaneViewModel]: One lane per channel given, in that order.
"""
return {
- channel_name: _ownership_lane(channel_name, stem_ids, heard_on(channel_name))
- for channel_name, stem_ids in assignments.items()
+ channel_name: _ownership_lane(channel_name, channel_runs, heard_on(channel_name))
+ for channel_name, channel_runs in runs.items()
}
def _ownership_lane(
channel_name: ChannelName,
- stem_ids: Sequence[int],
+ runs: Sequence[OwnerRun],
heard: AbstractSet[int],
) -> OwnershipLaneViewModel:
- """One channel's lane: the stretches it divides into, each under the recording holding it.
+ """One channel's lane: its stretches, each under the recording holding it.
A stretch keeps the recording holding it and says whether the reader hears it there, so
the lane names an owner wherever the record does and shows the reader's choice on top of it.
@@ -104,7 +105,7 @@ def _ownership_lane(
stem_id=run.stem_id,
heard=heard_frame(run.stem_id, heard),
)
- for run in owner_runs(stem_ids)
+ for run in runs
if run.stem_id != RESTING_STEM_ID
),
)
diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py
index dc6660ad2..f0dad6e63 100644
--- a/src/sampletones_application/logic/reconstruction/reconstruction.py
+++ b/src/sampletones_application/logic/reconstruction/reconstruction.py
@@ -1,3 +1,7 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
from pathlib import Path
from typing import (
Callable,
@@ -17,7 +21,7 @@
from sampletones_application.constants.sources import SourceKind
from sampletones_application.logic.export.instrument.source import ExportableInstrument
from sampletones_application.logic.reconstruction.data import ReconstructionData
-from sampletones_application.logic.reconstruction.listening import StemListening
+from sampletones_application.logic.reconstruction.listening import Listening
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.logic.reconstruction.ownership import ownership_lanes
from sampletones_application.view_model.reconstruction.envelopes import (
@@ -57,6 +61,7 @@
)
from sampletones_core.exports.scope import ExportScope
from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun
from sampletones_core.reconstructions.reconstruction.stems.selection import (
StemSelection,
)
@@ -73,6 +78,73 @@
EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel.empty()
+class Drawing(Enum):
+ """How a showing draws the waveform: loaded afresh after a listening change, or updated in place after an edit."""
+
+ LOAD = "load"
+ UPDATE = "update"
+
+
+@dataclass(frozen=True)
+class OwedShowing:
+ """What the screen owes the reader of the document's sound, settled once the worker has rendered what
+ the reader hears.
+
+ Every gesture that reads the sound joins what is owed, so a flip of the source during an
+ opening still loads the waveform, and a retune followed by a flip still re-fits it.
+
+ Attributes:
+ drawing: How the waveform is drawn, or ``None`` where the showing plays or writes alone.
+ refit: Whether the waveform's view is re-fitted to the audio's length.
+ play: Whether the audio is handed to the player.
+ export_to: The file what plays is written to, where an export waits for the sound.
+ """
+
+ drawing: Optional[Drawing]
+ refit: bool
+ play: bool
+ export_to: Optional[Path]
+
+ def joined(self, later: OwedShowing) -> OwedShowing:
+ """What is owed once ``later`` is asked for on top of this: every showing kept, the newest export's file."""
+ return OwedShowing(
+ drawing=self._drawn_either(self.drawing, later.drawing),
+ refit=self.refit or later.refit,
+ play=self.play or later.play,
+ export_to=later.export_to if later.export_to is not None else self.export_to,
+ )
+
+ @staticmethod
+ def _drawn_either(
+ first: Optional[Drawing],
+ second: Optional[Drawing],
+ ) -> Optional[Drawing]:
+ """The drawing covering both: a load draws everything an update would, so it outranks one."""
+ drawings = {first, second}
+ if Drawing.LOAD in drawings:
+ return Drawing.LOAD
+
+ if Drawing.UPDATE in drawings:
+ return Drawing.UPDATE
+
+ return None
+
+
+class WaveformUpdate(Protocol):
+ """What draws an updated waveform in place, re-fitting its view to the audio's length where asked.
+
+ The waveform is handed over by position, so a drawer may name it as it likes.
+ """
+
+ def __call__(
+ self,
+ waveform: WaveformData,
+ /,
+ *,
+ refit: bool,
+ ) -> None: ...
+
+
class ExportServiceProtocol(Protocol):
"""The slice of the export service the reconstruction panel logic drives.
@@ -116,32 +188,32 @@ def __init__(
self._export_backends = export_backends
self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION
- self._playing_channels: FrozenSet[ChannelName] = frozenset()
- self._selected_channels: List[ChannelName] = []
+ self._owed: Optional[OwedShowing] = None
self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None
self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None
- self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None
- self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None
+ self.on_waveform_load_changed: Optional[Callable[[WaveformData], None]] = None
+ self.on_waveform_update_changed: Optional[WaveformUpdate] = None
+ self.on_renders_wanted: Optional[VoidCallback] = None
+ self.on_listening_changed: Optional[VoidCallback] = None
self.on_waveform_cleared: Optional[VoidCallback] = None
self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None
self.on_open_export_instruments_dialog: Optional[Callable[[str, str, ExportFormat], None]] = None
self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None
+ self.on_export_dropped: Optional[PathCallback] = None
self.on_locate_audio_not_found: Optional[PathCallback] = None
self.on_stems_view_changed: Optional[Callable[[ReconstructionStemsViewModel], None]] = None
self.on_ownership_changed: Optional[Callable[[OwnershipRibbonViewModel], None]] = None
- self.on_heard_changed: Optional[VoidCallback] = None
def display_reconstruction(self) -> None:
+ """Puts the open document in front of the reader, letting go of what the one before it was owed."""
+ self._forget_owed()
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
- self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels)
- self._selected_channels = self._in_channel_order(self._playing_channels)
-
view_model = self._build_view_model(reconstruction_data)
if not view_model.audio_source_enabled:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
@@ -152,61 +224,112 @@ def display_reconstruction(self) -> None:
self._build_stems_view_model(reconstruction_data),
)
self.call(self.on_waveform_source_changed, self._current_audio_source)
- self.call(
- self.on_waveform_load_changed,
- reconstruction_data.waveform_data(
- self._reconstruction_manager.renders,
- self._stem_selection,
- ),
- self._selected_channels,
- )
self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data))
- self._emit_audio_data()
+ self._show(reconstruction_data, self._loading())
def update_reconstruction(self, *, refit_waveform: bool = False) -> None:
"""Re-answers every reading of the document after an edit.
``refit_waveform`` names an edit that moved the audio's own length, such as a retune,
- so the waveform's view is re-fitted to the new span rather than held at a position the
- old length no longer answers to.
+ so the waveform's view is re-fitted to the new span.
"""
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
- self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels))
-
self.call(self.on_view_changed, self._build_view_model(reconstruction_data))
self.call(
self.on_stems_view_changed,
self._build_stems_view_model(reconstruction_data),
)
- self.call(
- self.on_waveform_update_changed,
- reconstruction_data.waveform_data(
- self._reconstruction_manager.renders,
- self._stem_selection,
+ self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data))
+ self._show(
+ reconstruction_data,
+ OwedShowing(
+ drawing=Drawing.UPDATE,
+ refit=refit_waveform,
+ play=self._current_audio_source != AudioSourceType.ORIGINAL,
+ export_to=None,
),
- self._selected_channels,
- refit=refit_waveform,
)
- self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data))
- if self._current_audio_source != AudioSourceType.ORIGINAL:
- self._emit_audio_data()
- def _adopt_playing_channels(
+ def redraw_listening(self) -> None:
+ """Settles what the reader is owed once the sound of what they hear is kept."""
+ reconstruction_data = self._reconstruction_data
+ owed = self._owed
+ if reconstruction_data is None or owed is None:
+ return
+
+ self._owed = None
+ self._show(reconstruction_data, owed)
+
+ def abandon_showing(self) -> None:
+ """Lets go of what the reader is owed once the worker has given up the render it waited for, keeping the
+ waveform as it stands; an export that waited is reported dropped.
+ """
+ self._forget_owed()
+
+ def _forget_owed(self) -> None:
+ owed = self._owed
+ self._owed = None
+ if owed is not None and owed.export_to is not None:
+ logger.info(f"The export to {logger.format_path(owed.export_to)} waited for a sound that never landed")
+ self.call(self.on_export_dropped, owed.export_to)
+
+ @staticmethod
+ def _loading() -> OwedShowing:
+ """A showing loading the waveform afresh and playing it, which a change to what is heard asks for."""
+ return OwedShowing(drawing=Drawing.LOAD, refit=False, play=True, export_to=None)
+
+ def _show(
self,
- playing_channels: FrozenSet[ChannelName],
+ reconstruction_data: ReconstructionData,
+ showing: OwedShowing,
) -> None:
- """Carries the reader's choice of channels across an edit.
+ """Settles ``showing`` with what is owed already, or owes the whole where the screen holds no sound for
+ it yet and asks the worker for it.
- An edit puts a channel in play or takes it out. A channel that keeps playing keeps
- whatever the reader chose for it, and one gaining its first frame joins the waveform,
- so the checkboxes report what plays while a deliberate choice survives.
+ The reading is whole when the cache keeps the sound of what the reader hears and the
+ manager holds the envelopes the worker exported for it, so a showing computes no sample
+ on the render thread and draws what an export writes. What is owed settles in one reading
+ once the sound lands.
"""
- selected = (set(self._selected_channels) & playing_channels) | (playing_channels - self._playing_channels)
- self._playing_channels = playing_channels
- self._selected_channels = self._in_channel_order(frozenset(selected))
+ owed = showing if self._owed is None else self._owed.joined(showing)
+ waveform = self._kept_waveform(reconstruction_data)
+ if waveform is None:
+ self._owed = owed
+ self.call(self.on_renders_wanted)
+ return
+
+ self._owed = None
+ self._settle(reconstruction_data, waveform, owed)
+
+ def _kept_waveform(self, reconstruction_data: ReconstructionData) -> Optional[WaveformData]:
+ """The waveform of what the reader hears, where the screen keeps both its sound and its envelopes."""
+ hearing = self._listening.hearing
+ if not self._reconstruction_manager.has_envelopes_for(hearing.selection):
+ return None
+
+ return reconstruction_data.waveform_data(self._reconstruction_manager.renders, hearing)
+
+ def _settle(
+ self,
+ reconstruction_data: ReconstructionData,
+ waveform: WaveformData,
+ owed: OwedShowing,
+ ) -> None:
+ """Draws, plays and writes what ``owed`` names from one reading of the sound."""
+ match owed.drawing:
+ case Drawing.LOAD:
+ self.call(self.on_waveform_load_changed, waveform)
+ case Drawing.UPDATE:
+ self.call(self.on_waveform_update_changed, waveform, refit=owed.refit)
+
+ if owed.play:
+ self._emit_audio_data(reconstruction_data, waveform)
+
+ if owed.export_to is not None:
+ self._write_wav(reconstruction_data, waveform.playing, owed.export_to)
@staticmethod
def _in_channel_order(channels: FrozenSet[ChannelName]) -> List[ChannelName]:
@@ -219,8 +342,8 @@ def _build_view_model(
reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data)
return ReconstructionViewModel(
reconstruction_loaded=True,
- playing_channels=self._playing_channels,
- selected_channels=frozenset(self._selected_channels),
+ playing_channels=self._listening.playing,
+ selected_channels=self._listening.channels_on,
reconstruction_file=reconstruction_file,
original_audio=original_audio,
nes_frequency=reconstruction_data.config.nes_frequency,
@@ -243,8 +366,7 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo
def close_reconstruction(self) -> None:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
- self._playing_channels = frozenset()
- self._selected_channels = []
+ self._forget_owed()
self.call(self.on_audio_data_changed, None)
self.call(self.on_waveform_cleared)
self.call(
@@ -273,12 +395,24 @@ def close_reconstruction(self) -> None:
def set_audio_source(self, audio_source: AudioSourceType) -> None:
self._current_audio_source = audio_source
- self._emit_audio_data()
+ reconstruction_data = self._reconstruction_data
+ if reconstruction_data is None:
+ self.call(self.on_audio_data_changed, None)
+ else:
+ self._play(reconstruction_data)
+
self.call(self.on_waveform_source_changed, audio_source)
+ def _play(self, reconstruction_data: ReconstructionData) -> None:
+ """Hands the player what the source chosen plays, or owes it where the screen holds no sound yet."""
+ self._show(
+ reconstruction_data,
+ OwedShowing(drawing=None, refit=False, play=True, export_to=None),
+ )
+
def set_selected_channels(self, channels: List[ChannelName]) -> None:
"""Adopts the reader's channel choice, which the stems list reports as muted columns."""
- self._selected_channels = channels
+ self._listening.set_channels_on(frozenset(channels))
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
@@ -287,16 +421,8 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None:
self.on_stems_view_changed,
self._build_stems_view_model(reconstruction_data),
)
- self.call(
- self.on_waveform_load_changed,
- reconstruction_data.waveform_data(
- self._reconstruction_manager.renders,
- self._stem_selection,
- ),
- channels,
- )
self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data))
- self._emit_audio_data()
+ self._show(reconstruction_data, self._loading())
def set_stem_channels(
self,
@@ -321,8 +447,13 @@ def solo_stem(self, stem_id: int) -> None:
self._refresh_listening()
def _refresh_listening(self) -> None:
- """Re-answers every reading of the document after the listening choice changed."""
- self._reconstruction_manager.refresh_features()
+ """Re-answers every reading of the document after the listening choice changed.
+
+ The stems card and the ribbon answer at once; the waveform, the audio and the envelopes
+ the instruments panel draws answer once the worker has rendered the new choice. The
+ choice is reported first, so the instruments panel takes no edit of envelopes read for
+ another choice.
+ """
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
@@ -331,21 +462,13 @@ def _refresh_listening(self) -> None:
self.on_stems_view_changed,
self._build_stems_view_model(reconstruction_data),
)
- self.call(
- self.on_waveform_load_changed,
- reconstruction_data.waveform_data(
- self._reconstruction_manager.renders,
- self._stem_selection,
- ),
- self._selected_channels,
- )
self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data))
- self._emit_audio_data()
- self.call(self.on_heard_changed)
+ self.call(self.on_listening_changed)
+ self._show(reconstruction_data, self._loading())
@property
- def _listening(self) -> StemListening:
- """Which recordings the reader is listening to, which the open document holds."""
+ def _listening(self) -> Listening:
+ """What the reader is listening to, which the open document holds."""
return self._reconstruction_manager.listening
@property
@@ -367,17 +490,18 @@ def _build_ownership_ribbon(
through it.
"""
stems_data = reconstruction_data.reconstruction.stems_data
- owned = stems_data.assignments_by_channel
- assignments: Dict[ChannelName, Sequence[int]] = {
- channel_name: owned[channel_name] if channel_name in self._selected_channels else ()
- for channel_name in self._in_channel_order(self._playing_channels)
+ owned = stems_data.owner_runs_by_channel
+ runs: Dict[ChannelName, Sequence[OwnerRun]] = {
+ channel_name: owned[channel_name] if channel_name in self._listening.channels_on else ()
+ for channel_name in self._in_channel_order(self._listening.playing)
if channel_name in owned
}
- lanes = tuple(ownership_lanes(assignments, self.heard_on).values())
+ lanes = tuple(ownership_lanes(runs, self.heard_on).values())
+ frames = stems_data.assignments_by_channel
return OwnershipRibbonViewModel(
lanes=lanes,
frame_length=reconstruction_data.reconstruction.config.frame_length,
- total_frames=max((len(owned[lane.channel_name]) for lane in lanes), default=0),
+ total_frames=max((len(frames[lane.channel_name]) for lane in lanes), default=0),
)
def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]:
@@ -395,7 +519,7 @@ def _build_stems_view_model(
"""The recorded assignment as the stems list draws it, banded by the picking levels.
Each row reads as the recording the document remembers, so one whose file this machine
- no longer names — a sample embedded in a project — keeps its name, its boxes and its
+ names nowhere — a sample embedded in a project — keeps its name, its boxes and its
place among the levels. A document whose record names its recordings nowhere shows the
card's empty state.
"""
@@ -428,7 +552,7 @@ def _build_stems_view_model(
stems=StemsListViewModel(
rows=rows,
channels_in_play=tuple(channels_in_play),
- muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels),
+ muted_channels=frozenset(channels_in_play) - self._listening.channels_on,
picked_keys=frozenset(),
picking_room=None,
live=True,
@@ -495,12 +619,17 @@ def exportable_instrument(
Returns:
Optional[ExportableInstrument]: The slice and the name to suggest for it, or ``None``
- where that channel describes no frame and is written nowhere.
+ where that channel describes no frame and is written nowhere, or where the envelopes
+ of what the reader hears are still on their way from the worker.
Raises:
AssertionError: If no reconstruction is loaded.
"""
- features = self._heard_features()[channel_name]
+ envelopes = self._heard_features()
+ if envelopes is None:
+ return None
+
+ features = envelopes[channel_name]
if not features.has_frames:
return None
@@ -514,15 +643,19 @@ def exportable_instrument(
),
)
- def _heard_features(self) -> ChannelEnvelopesViewModel:
- """The envelopes of the part the reader is listening to, as the open document reads them.
+ def _heard_features(self) -> Optional[ChannelEnvelopesViewModel]:
+ """The envelopes of the part the reader is listening to, as the worker exported them for the open document,
+ or ``None`` while they are still on their way.
Raises:
AssertionError: If no reconstruction is loaded.
"""
+ if self._reconstruction_data is None:
+ raise AssertionError("Expected reconstruction data to be loaded before reading its envelopes")
+
features = self._reconstruction_manager.current_features
if features is None:
- raise AssertionError("Expected reconstruction data to be loaded before reading its envelopes")
+ logger.info("The envelopes of what is heard are still on their way from the worker")
return features
@@ -588,6 +721,9 @@ def handle_export_instruments_confirmed(
return
request = self._sample_request(destination.stem)
+ if request is None:
+ return
+
self._session_manager.set_instrument_path(destination.parent)
self._export_service.export_sample(
destination,
@@ -595,8 +731,9 @@ def handle_export_instruments_confirmed(
request,
)
- def sample_request(self) -> SampleExport:
- """The loaded reconstruction's slices as one export, under the reconstruction's own name.
+ def sample_request(self) -> Optional[SampleExport]:
+ """The loaded reconstruction's slices as one export, under the reconstruction's own name, or ``None``
+ while the envelopes of what the reader hears are still on their way from the worker.
A format asking for its choices in a setup of its own names the export before a
destination exists, so the slices take the name the reconstruction is known by.
@@ -610,12 +747,17 @@ def sample_request(self) -> SampleExport:
return self._sample_request(reconstruction_data.name)
- def _sample_request(self, name: str) -> SampleExport:
- """The slice of every playing channel of the reconstruction as one export named ``name``.
+ def _sample_request(self, name: str) -> Optional[SampleExport]:
+ """The slice of every playing channel of the reconstruction as one export named ``name``, or ``None``
+ while the envelopes of what the reader hears are still on their way from the worker.
Each slice takes its channel suffix from the name, and a channel describing no frame of
what the reader is listening to is left to rest.
"""
+ envelopes = self._heard_features()
+ if envelopes is None:
+ return None
+
return SampleExport(
name=name,
instruments=tuple(
@@ -624,7 +766,7 @@ def _sample_request(self, name: str) -> SampleExport:
feature,
instrument_slice_name(name, channel_name),
)
- for channel_name, feature in self._heard_features().channels.items()
+ for channel_name, feature in envelopes.channels.items()
if feature.has_frames
),
nes_frequency=self._nes_frequency(),
@@ -681,14 +823,33 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None:
logger.warning("No reconstruction data available for WAV export")
return
- audio_snapshot = reconstruction_data.partials_for(
- self._reconstruction_manager.renders,
- self._selected_channels,
- self._stem_selection,
+ self._export(reconstruction_data, filepath)
+
+ def _export(
+ self,
+ reconstruction_data: ReconstructionData,
+ filepath: Path,
+ ) -> None:
+ """Writes what the reader hears to ``filepath``, or owes the file where the screen holds no sound yet.
+
+ An export is a reading of what plays like a showing, so it reads the kept sound and computes
+ no sample on the render thread. An export owed is written once the sound lands, for what
+ the reader hears then; a document put away before that reports the export dropped.
+ """
+ self._show(
+ reconstruction_data,
+ OwedShowing(drawing=None, refit=False, play=False, export_to=filepath),
)
+
+ def _write_wav(
+ self,
+ reconstruction_data: ReconstructionData,
+ playing: np.ndarray,
+ filepath: Path,
+ ) -> None:
sample_rate = reconstruction_data.reconstruction.config.sample_rate
self._session_manager.set_audio_path(filepath)
- self._export_service.export_wav(filepath, sample_rate, audio_snapshot)
+ self._export_service.export_wav(filepath, sample_rate, playing)
def handle_locate_original_audio(self) -> None:
if not self._reconstruction_manager.source_paths:
@@ -720,29 +881,27 @@ def _get_instrument_name(self, channel_name: ChannelName) -> str:
return instrument_slice_name(reconstruction_data.name, channel_name)
- def _emit_audio_data(self) -> None:
- audio_data = self._compute_audio_data()
- self.call(self.on_audio_data_changed, audio_data)
-
- def _compute_audio_data(self) -> Optional[AudioData]:
- reconstruction_data = self._reconstruction_data
- if not reconstruction_data:
- return None
+ def _emit_audio_data(
+ self,
+ reconstruction_data: ReconstructionData,
+ waveform: WaveformData,
+ ) -> None:
+ self.call(self.on_audio_data_changed, self._audio_data(reconstruction_data, waveform))
+ def _audio_data(
+ self,
+ reconstruction_data: ReconstructionData,
+ waveform: WaveformData,
+ ) -> Optional[AudioData]:
+ """What plays for the source chosen: the recordings heard, or the channels ticked of ``waveform``."""
sample_rate = reconstruction_data.reconstruction.config.sample_rate
if self._current_audio_source == AudioSourceType.ORIGINAL:
- selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection)
- if selected_original_audio is None:
+ if waveform.original_audio is None:
return None
- return AudioData.from_array(selected_original_audio, sample_rate)
+ return AudioData.from_array(waveform.original_audio, sample_rate)
- partial_approximation = reconstruction_data.partials_for(
- self._reconstruction_manager.renders,
- self._selected_channels,
- self._stem_selection,
- )
- return AudioData.from_array(partial_approximation, sample_rate)
+ return AudioData.from_array(waveform.playing, sample_rate)
def _build_path_view_models(
self,
@@ -759,7 +918,7 @@ def _build_path_view_models(
reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath)
original_audio = self._build_audio_path_view_model(
reconstruction_data.reconstruction.audio_filepath,
- reconstruction_data.original_audio,
+ loaded=bool(reconstruction_data.stem_audios),
)
return reconstruction_file, original_audio
@@ -781,11 +940,12 @@ def _build_file_path_view_model(
@staticmethod
def _build_audio_path_view_model(
source_paths: Tuple[Path, ...],
- original_audio: Optional[np.ndarray],
+ *,
+ loaded: bool,
) -> ReconstructionPathViewModel:
"""Reports the original-audio location, treating a recorded path with unusable content
the same as a missing one, so the source toggle and waveform agree with what actually loaded."""
- if source_paths and original_audio is None:
+ if source_paths and not loaded:
return ReconstructionPathViewModel(
state=ReconstructionPathState.NOT_FOUND,
paths=(),
diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py
index 6e69f08e0..c207cc708 100644
--- a/src/sampletones_application/logic/reconstruction/rewrites/queue.py
+++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py
@@ -7,6 +7,7 @@
Retune,
StemRemoval,
)
+from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.logic.reconstruction.rewrites.regeneration import (
RegenerationServiceProtocol,
@@ -14,19 +15,23 @@
from sampletones_application.logic.reconstruction.rewrites.steps import (
AfterEdits,
ChannelChange,
+ ListeningChange,
RateChange,
Rewrite,
StemRemovalRequest,
)
from sampletones_application.services.regeneration.result import (
- RegeneratedInstrument,
RegenerationResult,
+ RenderedDocument,
)
from sampletones_application.services.result import ServiceError, ServiceSuccess
from sampletones_application.view_model.reconstruction.envelopes import (
ChannelEnvelopesViewModel,
)
+from sampletones_application.view_model.reconstruction.work import Work
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing
from sampletones_core.reconstructions.reconstruction.stems.removal import (
can_remove_stem,
without_stem,
@@ -37,16 +42,14 @@
@dataclass(frozen=True)
-class Rebuild:
- """A channel change the regeneration is rebuilding, beside the document it started from.
+class Running:
+ """A job the worker runs, beside the document it started from.
Attributes:
- change: What the reader moved.
- base: The document the rebuild started from, or ``None`` once an outside replacement put
- that document away.
+ base: The document the job started from, or ``None`` once an outside replacement put that
+ document away.
"""
- change: ChannelChange
base: Optional[Reconstruction]
def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool:
@@ -54,23 +57,62 @@ def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool:
return self.base is not None and self.base is reconstruction
+@dataclass(frozen=True)
+class Rebuild(Running):
+ """A channel change the worker is rebuilding.
+
+ Attributes:
+ change: What the reader moved.
+ """
+
+ change: ChannelChange
+
+
+@dataclass(frozen=True)
+class Reading(Running):
+ """A reading of what the reader hears, which the worker is rendering.
+
+ Attributes:
+ hearing: What the reader heard when the reading started, which is what it renders.
+ """
+
+ hearing: Hearing
+
+ def answers(
+ self,
+ reconstruction: Optional[Reconstruction],
+ hearing: Hearing,
+ ) -> bool:
+ """Whether the reading renders ``reconstruction`` heard as ``hearing``."""
+ return self.lands_on(reconstruction) and self.hearing == hearing
+
+
class ReconstructionRewrites(CallbackMixin):
- """The steps that change the open document, taken one at a time in the order the reader asked.
+ """The steps that change the open document or what is heard of it, taken one at a time in the order the
+ reader asked.
Every edit of the Reconstructions tab's document passes through here: a channel the reader
moved, a recording taken out, a new NES frequency, and a gesture that reads or puts away the
- whole document. Each step reads the document the step before it left, so one edit never
- overwrites another and a rebuild always starts from what stands.
-
- Channel changes wait while a rebuild runs, and a change joins the one waiting at the end of the
- line when both move the same channel, so a drag collapses into the place it ended. A rebuild's
- result lands only on the document it was computed from. A removal or a whole-document gesture
- changes what the panel was drawn on, so a channel change asked for while one waits is refused,
- and the panel is redrawn once the line empties.
+ whole document. So does a change to what the reader hears whose sound the screen holds no
+ audio for yet, which the worker renders from the document as it then stands. Each step reads
+ the document the step before it left, so each edit stands on the one before it, a rebuild
+ starts from what stands, and a listening sounds the document the edits before it made.
+
+ Channel changes wait while a job runs, and a change joins the one waiting at the end of the
+ line when both move the same channel, so a drag collapses into the place it ended; one
+ listening waiting answers every choice made before it is taken. A change reaching its turn
+ while the envelopes of what is heard are still on their way, as after a box ticked behind it,
+ waits behind a listening of that choice and rebuilds from the envelopes it lands. A result
+ lands only on the document it was computed from. A removal or a whole-document gesture
+ changes what the panel was drawn on, so a channel change asked for while one waits is
+ refused, and the panel is redrawn once the line empties.
The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that
- lands, ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a
- rebuild's failure, and ``on_busy_changed`` as the line fills and empties.
+ lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with
+ whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel
+ draws a change that will never land, ``on_failed`` with a job's failure, ``on_reading_failed``
+ when a listening the open document waits for failed, and ``on_work_changed`` as the line
+ fills, hands over from one kind of work to the other, and empties.
"""
def __init__(
@@ -81,44 +123,64 @@ def __init__(
self._manager = reconstruction_manager
self._regeneration = regeneration
self._waiting: List[Rewrite] = []
- self._running: Optional[Rebuild] = None
+ self._running: Optional[Running] = None
+ self._awaiting_envelopes: Optional[ChannelName] = None
+ self._taking = False
+ self._unreadable: Optional[Reading] = None
self._redraw_owed: bool = False
- self._reported_busy: bool = False
+ self._reported_work: Optional[Work] = None
self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None
+ self.on_heard_rendered: Optional[Callable[[bool], None]] = None
self.on_dropped: Optional[VoidCallback] = None
self.on_failed: Optional[Callable[[Exception], None]] = None
- self.on_busy_changed: Optional[Callable[[bool], None]] = None
+ self.on_reading_failed: Optional[VoidCallback] = None
+ self.on_work_changed: Optional[Callable[[Optional[Work]], None]] = None
regeneration.subscribe(self._on_result)
+ @property
+ def work(self) -> Optional[Work]:
+ """What the running job does, which the waveform's fade names, or ``None`` while none runs."""
+ match self._running:
+ case Rebuild():
+ return Work.REBUILDING
+ case Reading():
+ return Work.READING
+
+ return None
+
@property
def is_busy(self) -> bool:
- """Whether a rebuild runs or a step waits, which is the span the open document is being rewritten."""
+ """Whether a job runs or a step waits, which is the span the open document's sound is on its way."""
return self._running is not None or bool(self._waiting)
def request(self, rewrite: Rewrite) -> None:
- """Puts a step at the end of the line, and takes it at once where nothing runs before it.
+ """Puts a step at the end of the line, and takes it at once where the line stands idle.
- A channel change joins a waiting change of the same channel, and a new rate replaces a
- waiting one, so what the reader asked for last is what lands.
+ A channel change joins a waiting change of the same channel, a new rate replaces a
+ waiting one, and one listening waiting stands for every one asked for after it, so what
+ the reader asked for last is what lands.
"""
match rewrite:
case ChannelChange() as change:
self._enqueue_change(change)
case RateChange() as rate_change:
self._enqueue_rate(rate_change)
+ case ListeningChange() as listening:
+ self._enqueue_listening(listening)
case StemRemovalRequest() | AfterEdits():
self._waiting.append(rewrite)
self._advance()
+ self._catch_up()
self._settle()
def drop(self) -> None:
"""Lets go of the edits meant for a document an outside replacement puts away.
- The channel changes, removals and rates waiting leave the line, and the running rebuild's
- result no longer lands. A whole-document gesture the reader asked for keeps its place, so a
+ The channel changes, removals, rates and listenings waiting leave the line, and the running
+ job's result is let go. A whole-document gesture the reader asked for keeps its place, so a
save, a load or an undo still happens, in order, on the document now open. The replacement
draws the document it puts in place, which answers a redraw still owed.
"""
@@ -133,7 +195,7 @@ def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewMod
"""The document's envelopes with every change still on its way written over them, in order.
The instruments panel draws these, so what the reader sees is what the document will hold
- once the line empties. A rebuild whose document was put away writes nothing.
+ once the line empties. A rebuild whose document was put away leaves the envelopes as drawn.
Args:
envelopes: The envelopes the open document holds.
@@ -151,7 +213,7 @@ def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewMod
def _pending_changes(self) -> List[ChannelChange]:
"""The channel changes on their way to the open document, the running one first."""
changes: List[ChannelChange] = []
- if self._running is not None and self._running.lands_on(self._manager.reconstruction):
+ if isinstance(self._running, Rebuild) and self._running.lands_on(self._manager.reconstruction):
changes.append(self._running.change)
changes.extend(step for step in self._waiting if isinstance(step, ChannelChange))
@@ -174,6 +236,23 @@ def _enqueue_change(self, change: ChannelChange) -> None:
case _:
self._waiting.append(change)
+ def _enqueue_listening(self, listening: ListeningChange) -> None:
+ """Puts a listening in line where none in line or running renders what the reader hears now.
+
+ A listening waiting reads the choice at its turn, so it answers every choice made before
+ it; the one running answers only the choice it started with.
+ """
+ if any(isinstance(step, ListeningChange) for step in self._waiting):
+ return
+
+ if isinstance(self._running, Reading) and self._running.answers(
+ self._manager.reconstruction,
+ self._manager.listening.hearing,
+ ):
+ return
+
+ self._waiting.append(listening)
+
def _enqueue_rate(self, rate_change: RateChange) -> None:
match self._waiting[-1] if self._waiting else None:
case RateChange():
@@ -182,9 +261,22 @@ def _enqueue_rate(self, rate_change: RateChange) -> None:
self._waiting.append(rate_change)
def _advance(self) -> None:
- """Takes the waiting steps in order until a rebuild runs or the line empties."""
- while self._running is None and self._waiting:
- self._take(self._waiting.pop(0))
+ """Takes the waiting steps in order until a job runs or the line empties.
+
+ A step taken, or a result landing, may ask for a step of its own, as a gesture redrawing
+ the document asks for a listening. The ask joins the line and is taken in its turn once
+ the step or the landing has returned, so a gesture never runs inside a landing or inside
+ another gesture.
+ """
+ if self._taking:
+ return
+
+ self._taking = True
+ try:
+ while self._running is None and self._waiting:
+ self._take(self._waiting.pop(0))
+ finally:
+ self._taking = False
def _take(self, step: Rewrite) -> None:
match step:
@@ -196,25 +288,84 @@ def _take(self, step: Rewrite) -> None:
self._retune(rate_change)
case AfterEdits(gesture=gesture):
gesture()
+ case ListeningChange():
+ self._listen()
def _rebuild(self, change: ChannelChange) -> None:
"""Starts the rebuild of a changed channel from the document and the listening as they stand.
- The rebuild is marked running before it starts, since a result can arrive before the start
- returns.
+ Everything the job takes is read first, so a reading that raises leaves the line idle;
+ the rebuild is then marked running before it starts, since a case's synchronous seam can
+ deliver the result inside the start. The renders the cache holds for the document go with
+ it, so the worker renders the edited channel alone and the landing finds every channel's
+ audio kept. A change finding the envelopes of what is heard on their way waits for them.
"""
reconstruction = self._manager.reconstruction
- envelopes = self._manager.current_features
- if reconstruction is None or envelopes is None:
+ if reconstruction is None:
logger.info(f"A change of {change.channel_name} found no document open")
return
+ envelopes = self._manager.current_features
+ if envelopes is None:
+ self._await_envelopes(change)
+ return
+
+ self._awaiting_envelopes = None
+ rebased = change.rebased(envelopes[change.channel_name])
+ hearing = self._manager.listening.hearing
+ kept = self._manager.renders.held(reconstruction)
self._running = Rebuild(change=change, base=reconstruction)
self._regeneration.start(
reconstruction,
change.channel_name,
- change.rebased(envelopes[change.channel_name]),
- self._manager.listening.heard_on(change.channel_name),
+ rebased,
+ hearing=hearing,
+ kept=kept,
+ recordings=self._manager.recordings,
+ )
+
+ def _await_envelopes(self, change: ChannelChange) -> None:
+ """Puts ``change`` back in line behind a listening of the choice its envelopes are owed for.
+
+ A listening already waiting, as a box ticked meanwhile asks for, lands them, so the change
+ takes its place behind it. With none waiting, the line asks for one, once per channel: a
+ change whose listening landed no envelopes for it, as a failed one does, is let go and the
+ line moves on.
+ """
+ for index, step in enumerate(self._waiting):
+ if isinstance(step, ListeningChange):
+ self._waiting.insert(index + 1, change)
+ return
+
+ if self._awaiting_envelopes == change.channel_name:
+ logger.info(f"A change of {change.channel_name} found no envelopes to rebuild from and was let go")
+ self._awaiting_envelopes = None
+ return
+
+ self._awaiting_envelopes = change.channel_name
+ self._waiting[:0] = [ListeningChange(), change]
+
+ def _listen(self) -> None:
+ """Starts rendering what the reader hears of the document as it stands.
+
+ Everything the job takes is read first, so a reading that raises leaves the line idle; the
+ reading is then marked running before it starts, since a case's synchronous seam can
+ deliver the result inside the start. The renders the cache holds go with it, so the worker
+ renders what is missing alone.
+ """
+ reconstruction = self._manager.reconstruction
+ if reconstruction is None:
+ logger.info("A change to what is heard found no document open")
+ return
+
+ hearing = self._manager.listening.hearing
+ kept = self._manager.renders.held(reconstruction)
+ self._running = Reading(base=reconstruction, hearing=hearing)
+ self._regeneration.start_listening(
+ reconstruction,
+ hearing=hearing,
+ kept=kept,
+ recordings=self._manager.recordings,
)
def _remove(self, removal: StemRemovalRequest) -> None:
@@ -253,57 +404,111 @@ def _retune(self, rate_change: RateChange) -> None:
)
def _on_result(self, result: RegenerationResult) -> None:
- """Lands a finished rebuild on the document it was computed from, then takes the next step.
+ """Lands a finished job on the document it was computed from, then takes the next step.
Raises:
- RuntimeError: If a result arrives while no rebuild runs.
+ RuntimeError: If a result arrives while no job runs.
"""
- rebuild = self._running
- if rebuild is None:
- raise RuntimeError("A regeneration result arrived while no rebuild was running")
+ running = self._running
+ if running is None:
+ raise RuntimeError("A regeneration result arrived while no job was running")
self._running = None
- match result:
- case ServiceSuccess(value=outcome):
- self._land(rebuild, outcome)
- case ServiceError(exception=exception):
- self.call(self.on_failed, exception)
- self._let_go(rebuild)
+ was_taking = self._taking
+ self._taking = True
+ try:
+ match result:
+ case ServiceSuccess(value=outcome):
+ self._land(running, outcome)
+ case ServiceError(exception=exception):
+ self.call(self.on_failed, exception)
+ self._fail(running)
+ finally:
+ self._taking = was_taking
self._advance()
+ self._catch_up()
self._settle()
- def _land(self, rebuild: Rebuild, outcome: RegeneratedInstrument) -> None:
- if not rebuild.lands_on(self._manager.reconstruction):
- logger.info(f"A rebuild of {rebuild.change.channel_name} finished for a document put away since")
- self._let_go(rebuild)
+ def _land(
+ self,
+ running: Running,
+ outcome: RenderedDocument,
+ ) -> None:
+ """Keeps the renders the job made and reports it, where its document is still open."""
+ if not running.lands_on(self._manager.reconstruction):
+ logger.info("A job finished for a document put away since")
+ self._let_go(running)
return
- self.call(
- self.on_edit,
- ChannelEdit(
- reconstruction=outcome.reconstruction,
- channel_name=rebuild.change.channel_name,
- feature_key=rebuild.change.feature_key,
- ),
- )
-
- def _let_go(self, rebuild: Rebuild) -> None:
+ self._manager.renders.adopt_rendered(outcome)
+ envelopes = HeardEnvelopes(selection=outcome.hearing.selection, channels=outcome.envelopes)
+ match running:
+ case Rebuild(change=change):
+ self.call(
+ self.on_edit,
+ ChannelEdit(
+ reconstruction=outcome.reconstruction,
+ channel_name=change.channel_name,
+ feature_key=change.feature_key,
+ envelopes=envelopes,
+ ),
+ )
+ case Reading():
+ changed = self._manager.adopt_envelopes(outcome.reconstruction, envelopes)
+ self.call(self.on_heard_rendered, changed)
+
+ def _let_go(self, running: Running) -> None:
"""Redraws the panel where it still draws a change that will never land.
- A rebuild an outside replacement dropped needs nothing, since the replacement drew the
- document it put in place.
+ A rebuild an outside replacement dropped is answered by the replacement, which drew the
+ document it put in place, and a listening draws only once its sound lands.
"""
- if rebuild.base is not None:
+ if isinstance(running, Rebuild) and running.base is not None:
self.call(self.on_dropped)
+ def _fail(self, running: Running) -> None:
+ """Tells the panel what a failed job leaves it with: a change drawn that will never land, or a sound the
+ open document waited for that will never land.
+
+ A failed listening is remembered, so the line asks the worker again for another choice
+ alone and a failure that repeats leaves the line idle.
+ """
+ self._let_go(running)
+ if isinstance(running, Reading) and running.lands_on(self._manager.reconstruction):
+ self._unreadable = running
+ self.call(self.on_reading_failed)
+
+ def _catch_up(self) -> None:
+ """Asks the worker for the envelopes of what the reader hears, where the line stands idle without them.
+
+ Every gesture and every landing passes here once the line has taken what it could, so an
+ idle line always holds the open document's envelopes for the reader's choice or is
+ rendering them: a reader who stops lets the last listening land what they hear. A choice
+ whose listening failed is left alone until the reader makes another.
+ """
+ reconstruction = self._manager.reconstruction
+ if self.is_busy or reconstruction is None:
+ return
+
+ hearing = self._manager.listening.hearing
+ if self._manager.has_envelopes_for(hearing.selection):
+ return
+
+ if self._unreadable is not None and self._unreadable.answers(reconstruction, hearing):
+ return
+
+ self._unreadable = None
+ self._waiting.append(ListeningChange())
+ self._advance()
+
def _settle(self) -> None:
- """Pays a redraw owed once the line empties, and reports the line filling or emptying."""
+ """Pays a redraw owed once the line empties, and reports what the line is busy with as it changes."""
if not self.is_busy and self._redraw_owed:
self._redraw_owed = False
self.call(self.on_dropped)
- busy = self.is_busy
- if busy != self._reported_busy:
- self._reported_busy = busy
- self.call(self.on_busy_changed, busy)
+ work = self.work
+ if work != self._reported_work:
+ self._reported_work = work
+ self.call(self.on_work_changed, work)
diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py
index 46cc25c3a..60ddc22e8 100644
--- a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py
+++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py
@@ -1,24 +1,24 @@
-from typing import AbstractSet, Callable, Protocol
+from typing import Mapping, Protocol
-from sampletones_application.services.regeneration.result import RegenerationResult
+import numpy as np
+
+from sampletones_application.logic.shared.regeneration import ListeningServiceProtocol
from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing
-class RegenerationServiceProtocol(Protocol):
- """The slice of the regeneration service the rewrites drive.
-
- Typing the collaborator structurally keeps the logic layer bound to the service's result
- contract alone; the composition root supplies the real service.
- """
-
- def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ...
+class RegenerationServiceProtocol(ListeningServiceProtocol, Protocol):
+ """The slice of the regeneration service the rewrites drive: a listening's calls, and the rebuild."""
def start(
self,
reconstruction: Reconstruction,
channel_name: ChannelName,
features: Features,
- heard: AbstractSet[int],
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
) -> None: ...
diff --git a/src/sampletones_application/logic/reconstruction/rewrites/steps.py b/src/sampletones_application/logic/reconstruction/rewrites/steps.py
index dc83b7736..052ddd7d3 100644
--- a/src/sampletones_application/logic/reconstruction/rewrites/steps.py
+++ b/src/sampletones_application/logic/reconstruction/rewrites/steps.py
@@ -83,4 +83,13 @@ class AfterEdits:
gesture: VoidCallback
-Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits]
+@dataclass(frozen=True)
+class ListeningChange:
+ """A change to what the reader hears, whose sound the worker renders for the document as it then stands.
+
+ The step carries nothing: what the reader hears is read at its turn, so the latest choice is
+ what is rendered, and one such step waiting answers every choice made before it is taken.
+ """
+
+
+Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits, ListeningChange]
diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py
index 6ba283ede..ae77b7a82 100644
--- a/src/sampletones_application/logic/sequencer/voices.py
+++ b/src/sampletones_application/logic/sequencer/voices.py
@@ -1,3 +1,4 @@
+from functools import partial
from pathlib import Path
from typing import Callable, Final, Optional, Tuple
@@ -10,7 +11,7 @@
)
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
-from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.view_model.sequencer.kind import voice_kind
from sampletones_application.view_model.sequencer.voices import (
@@ -58,7 +59,10 @@ class SequencerVoicesLogic(CallbackMixin):
Previewing mirrors the reconstruction browser: a single click schedules a debounced autoplay
that fires only when the session's autoplay flag is on, and a double-click (edit) cancels the
- pending preview before it plays.
+ pending preview before it plays. A sample sounds through the document previews, which play
+ the render the cache keeps at once and otherwise sound the sample once a worker lands it, so
+ the debounce is this logic's own span and the preview on its way is the previews' own: a
+ double-click outliving the debounce cancels there too.
"""
def __init__(
@@ -68,13 +72,13 @@ def __init__(
audio_device_manager: AudioDeviceManager,
*,
scheduling: SchedulingBehavior,
- renders: RenderCache,
+ previews: DocumentPreviews,
) -> None:
self._controller = project_controller
self._session_manager = session_manager
self._audio_device_manager = audio_device_manager
self._scheduling = scheduling
- self._renders = renders
+ self._previews = previews
self._pending_autoplay_voice: Optional[str] = None
self.on_voices_changed: Optional[Callable[[SequencerVoicesViewModel], None]] = None
@@ -271,7 +275,9 @@ def request_autoplay(self, voice_id: str) -> None:
)
def cancel_autoplay(self) -> None:
+ """Forgets the preview pending, whether it waits for the debounce or for a worker's render."""
self._pending_autoplay_voice = None
+ self._previews.cancel()
def _execute_autoplay(self) -> None:
if self._pending_autoplay_voice is None:
@@ -282,8 +288,8 @@ def _execute_autoplay(self) -> None:
if self._session_manager.autoplay:
self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW)
- def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]:
- """The audio a preview sounds: a sample's approximation, or an instrument on the pulse.
+ def _audition_audio(self, instrument: Instrument) -> Optional[np.ndarray]:
+ """The audio an instrument's preview sounds, on the pulse.
The pulse channel offers every dimension an instrument writes, so sounding the preview
there sounds the whole instrument rather than the part another channel would read. The
@@ -291,24 +297,18 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]:
measured against.
Args:
- voice_id: The voice to preview.
+ instrument: The voice to preview.
Returns:
Optional[np.ndarray]: The waveform to play, or ``None`` where the voice sounds nothing.
"""
- match self._controller.project.voices.get(voice_id):
- case Sample() as sample:
- return self._renders.mix(sample.reconstruction)
- case Instrument() as instrument:
- return audition_audio(
- instrument,
- PREVIEW_CHANNEL,
- self._preview_config(),
- pitch=instrument.reference(PREVIEW_CHANNEL),
- ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
- )
- case _:
- return None
+ return audition_audio(
+ instrument,
+ PREVIEW_CHANNEL,
+ self._preview_config(),
+ pitch=instrument.reference(PREVIEW_CHANNEL),
+ ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
+ )
def _preview_config(self) -> Config:
settings = self._controller.project.settings
@@ -323,7 +323,32 @@ def _play_voice(
*,
priority: PlaybackPriority,
) -> None:
- audio = self._preview_audio(voice_id)
+ """Sounds one voice: a sample through the document previews, an instrument rendered on the spot.
+
+ A preview asked for replaces one still on its way, so the voice heard is the last one
+ asked for.
+ """
+ self._previews.cancel()
+ match self._controller.project.voices.get(voice_id):
+ case Sample() as sample:
+ self._previews.play(
+ sample.reconstruction,
+ partial(self._sound, priority=priority),
+ self._report_failed_render,
+ )
+ case Instrument() as instrument:
+ self._sound(self._audition_audio(instrument), priority=priority)
+
+ def _report_failed_render(self, exception: Exception) -> None:
+ """Reports a sample's preview whose render failed the way a refused one is reported."""
+ self.call(self.on_autoplay_error, exception)
+
+ def _sound(
+ self,
+ audio: Optional[np.ndarray],
+ *,
+ priority: PlaybackPriority,
+ ) -> None:
if audio is None:
return
diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py
index f33d0a653..e2b340668 100644
--- a/src/sampletones_application/logic/shared/file_playback.py
+++ b/src/sampletones_application/logic/shared/file_playback.py
@@ -1,10 +1,13 @@
+from functools import partial
from pathlib import Path
from typing import Callable, Optional
+import numpy as np
+
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.logic.shared.previews import DocumentPreviews
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.reconstructions.reconstruction.renders import rendered_mix
from sampletones_shared.exceptions import PlaybackError, SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.paths import extensions
@@ -14,13 +17,22 @@
class FilePlayback(CallbackMixin):
"""Wherever a file is named and asked to sound, this is what answers.
- A reconstruction is read and its approximation played; an audio file is played from disk. The
- browser's tree, a row in the converter's list and a menu item all name a file the same way, so
- what a suffix means and what a failure to read one reports stand in one place.
+ A reconstruction is read and previewed whole through the document previews, so its sound is
+ the render the cache keeps or the one a worker lands a moment later; an audio file is played
+ from disk. The browser's tree, a row in the converter's list and a menu item all name a file
+ the same way, so what a suffix means and what a failure to read one reports stand in one
+ place. A device that refuses the sound reports through ``on_error`` whenever the refusal
+ comes, at once for a file or at the landing for a document, and so does a document's render
+ that fails.
"""
- def __init__(self, audio_device_manager: AudioDeviceManager) -> None:
+ def __init__(
+ self,
+ audio_device_manager: AudioDeviceManager,
+ previews: DocumentPreviews,
+ ) -> None:
self._audio_device_manager = audio_device_manager
+ self._previews = previews
self.on_error: Optional[Callable[[Exception], None]] = None
@@ -64,4 +76,18 @@ def _play_reconstruction(self, path: Path, priority: PlaybackPriority) -> None:
self.call(self.on_error, exception)
return
- self._audio_device_manager.play(rendered_mix(reconstruction), update=False, priority=priority)
+ self._previews.play(reconstruction, partial(self._sound, priority=priority), self._report)
+
+ def _sound(
+ self,
+ audio: np.ndarray,
+ *,
+ priority: PlaybackPriority,
+ ) -> None:
+ try:
+ self._audio_device_manager.play(audio, update=False, priority=priority)
+ except PlaybackError as exception:
+ self.call(self.on_error, exception)
+
+ def _report(self, exception: Exception) -> None:
+ self.call(self.on_error, exception)
diff --git a/src/sampletones_application/logic/shared/previews.py b/src/sampletones_application/logic/shared/previews.py
new file mode 100644
index 000000000..d6fa76042
--- /dev/null
+++ b/src/sampletones_application/logic/shared/previews.py
@@ -0,0 +1,153 @@
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Callable, Final, Mapping, Optional
+
+import numpy as np
+
+from sampletones_application.logic.shared.regeneration import ListeningServiceProtocol
+from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.services.regeneration.result import (
+ RegenerationResult,
+ RenderedDocument,
+)
+from sampletones_application.services.result import ServiceError, ServiceSuccess
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import whole_hearing
+from sampletones_shared.logger import logger
+
+Play = Callable[[np.ndarray], None]
+Failed = Callable[[Exception], None]
+NO_RECORDINGS: Final[Mapping[int, np.ndarray]] = MappingProxyType({})
+
+
+@dataclass(frozen=True)
+class WantedPreview:
+ """A document a reader asked to hear whole, how it sounds once its audio is in hand, and who hears of a
+ render that failed.
+
+ Attributes:
+ reconstruction: The document to sound.
+ play: What sounds the audio, which owns the device, the priority and the refusal a device
+ answers with.
+ failed: What reports a render that failed, which owns the way the reader is told.
+ """
+
+ reconstruction: Reconstruction
+ play: Play
+ failed: Failed
+
+
+class DocumentPreviews:
+ """Sounds whole documents from the render cache, and asks a worker of its own for what the cache lacks.
+
+ A preview of a document is a reading like any other on the Reconstructions tab: sound the
+ cache keeps plays at once, and sound it lacks is rendered on a worker and played when it
+ lands, so the render thread computes no sample for a voice clicked in the sequencer. The
+ cache is the one the tab reads, so a document the tab has landed previews from it and a
+ document previewed here opens on the tab rendered.
+
+ The latest preview asked for is the one wanted: a newer request replaces one still on its
+ way, whose landing then fills the cache alone, and ``cancel`` forgets the wanted preview the
+ same way. One job runs at a time and the next wanted document is pursued once it lands,
+ reading the cache first, since the tab may have landed it meanwhile. A landing sounds the
+ result's own audio and pursues the next wanted document from there. A render that fails is
+ reported to the reader that asked for it, which forgets the preview, so the next wanted
+ document is pursued and the failed one is asked for again by a new gesture alone.
+
+ The service is this reader's own instance: a result reaches every subscriber of the instance
+ that made it and names no sender, so a line of previews and the tab's line of rewrites each
+ take one. Every result lands on the render thread through the callback queue, which keeps the
+ cache's writers there.
+ """
+
+ def __init__(
+ self,
+ renders: RenderCache,
+ listening: ListeningServiceProtocol,
+ ) -> None:
+ self._renders = renders
+ self._listening = listening
+ self._wanted: Optional[WantedPreview] = None
+ self._running: Optional[WantedPreview] = None
+
+ listening.subscribe(self._on_result)
+
+ @property
+ def is_busy(self) -> bool:
+ """Whether a preview's sound is still on its way, running on the worker or waiting for its turn."""
+ return self._running is not None or self._wanted is not None
+
+ def play(
+ self,
+ reconstruction: Reconstruction,
+ play: Play,
+ failed: Failed,
+ ) -> None:
+ """Sounds ``reconstruction`` whole through ``play``, at once or once its render lands.
+
+ Args:
+ reconstruction: The document to sound.
+ play: What sounds the audio once it is in hand.
+ failed: What reports a render that failed.
+ """
+ self._wanted = WantedPreview(reconstruction, play, failed)
+ self._pursue()
+
+ def cancel(self) -> None:
+ """Forgets the preview wanted; a render on its way still lands and fills the cache."""
+ self._wanted = None
+
+ def _pursue(self) -> None:
+ """Sounds the wanted preview from the cache, or asks the worker for it while no job runs.
+
+ A preview sounds the document itself, so its job carries the document alone.
+ """
+ wanted = self._wanted
+ if wanted is None or self._running is not None:
+ return
+
+ hearing = whole_hearing(wanted.reconstruction)
+ heard = self._renders.reading(wanted.reconstruction, hearing)
+ if heard is not None:
+ self._wanted = None
+ wanted.play(heard.playing)
+ return
+
+ self._running = wanted
+ self._listening.start_listening(
+ wanted.reconstruction,
+ hearing=hearing,
+ kept=self._renders.held(wanted.reconstruction),
+ recordings=NO_RECORDINGS,
+ )
+
+ def _on_result(self, result: RegenerationResult) -> None:
+ """Takes a job's result on the render thread: keeps what it rendered, sounds it where still wanted.
+
+ Raises:
+ RuntimeError: If a result arrives while no preview is on its way, which is a second
+ reader sharing the service instance.
+ """
+ running = self._running
+ if running is None:
+ raise RuntimeError("A preview's render arrived while no preview was on its way")
+
+ self._running = None
+ match result:
+ case ServiceSuccess(value=outcome):
+ self._land(outcome)
+ case ServiceError(exception=exception):
+ if self._wanted is not None and self._wanted.reconstruction is running.reconstruction:
+ self._wanted = None
+
+ logger.error_with_traceback(exception, "A preview's render failed")
+ running.failed(exception)
+
+ self._pursue()
+
+ def _land(self, outcome: RenderedDocument) -> None:
+ self._renders.adopt_rendered(outcome)
+ wanted = self._wanted
+ if wanted is not None and wanted.reconstruction is outcome.reconstruction:
+ self._wanted = None
+ wanted.play(outcome.heard.playing)
diff --git a/src/sampletones_application/logic/shared/regeneration.py b/src/sampletones_application/logic/shared/regeneration.py
new file mode 100644
index 000000000..2262f404d
--- /dev/null
+++ b/src/sampletones_application/logic/shared/regeneration.py
@@ -0,0 +1,30 @@
+from typing import Callable, Mapping, Protocol
+
+import numpy as np
+
+from sampletones_application.services.regeneration.result import RegenerationResult
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing
+
+
+class ListeningServiceProtocol(Protocol):
+ """The slice of the regeneration service a reader of a document's sound drives.
+
+ A reader asks for what a hearing of a document sounds and takes the result where it lands, so
+ it names the two calls that make a listening and nothing of the rebuilds. Typing the
+ collaborator structurally keeps the logic layer bound to the service's result contract alone;
+ the composition root supplies the real service, one instance per reader, since a result
+ reaches every subscriber of the instance that made it.
+ """
+
+ def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ...
+
+ def start_listening(
+ self,
+ reconstruction: Reconstruction,
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
+ ) -> None: ...
diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py
index b6aacf2a9..9d6294ef9 100644
--- a/src/sampletones_application/logic/shared/renders.py
+++ b/src/sampletones_application/logic/shared/renders.py
@@ -1,33 +1,72 @@
import threading
import weakref
from collections import OrderedDict
+from contextlib import contextmanager
from dataclasses import dataclass
from enum import StrEnum
-from typing import Dict, List, Optional, Set, Tuple
+from typing import Dict, FrozenSet, Iterator, List, Mapping, Optional, Sequence, Set, Tuple, Union
import numpy as np
-from sampletones_core.audio.mixing import mix
+from sampletones_application.services.regeneration.result import RenderedDocument
from sampletones_core.configs import Config
from sampletones_core.constants.enums import ChannelName
-from sampletones_core.generators.render import render_instructions
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
+from sampletones_core.reconstructions.reconstruction.renders import sounding_streams
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing
class RenderKind(StrEnum):
CHANNEL = "channel"
MIX = "mix"
+ ORIGINAL = "original"
+
+
+@dataclass(frozen=True)
+class RenderPart:
+ """One part as a render sounds it: a channel's stream and the owners heard where the reader leaves one
+ out, or a recording.
+
+ Attributes:
+ source: The identity of the object the part sounds: a channel's stream, or a recording.
+ assignment: The identity of the record naming the owner of each frame, where an owner is
+ left out.
+ heard: The owners heard, where an owner is left out; ``None`` where the part is heard whole.
+ """
+
+ source: int
+ assignment: Optional[int]
+ heard: Optional[FrozenSet[int]]
@dataclass(frozen=True)
class RenderKey:
- """What one render was made from: the identities of the streams it sounds and the configuration it ran at."""
+ """What one render was made from: the parts it sounds and the configuration it ran at."""
kind: RenderKind
- streams: Tuple[int, ...]
+ parts: Tuple[RenderPart, ...]
config: Config
+ @property
+ def identities(self) -> FrozenSet[int]:
+ """The identities of every object the render was made from."""
+ named = {part.source for part in self.parts}
+ named.update(part.assignment for part in self.parts if part.assignment is not None)
+ return frozenset(named)
+
+
+Watched = Union[InstructionsItem, ChannelAssignment, np.ndarray]
+
+
+@dataclass(frozen=True)
+class _Part:
+ """A part beside the objects it names, which the cache watches."""
+
+ part: RenderPart
+ watched: Tuple[Watched, ...]
+
class RenderCache:
"""The audio the documents on screen render to, kept apart from the documents themselves.
@@ -35,15 +74,28 @@ class RenderCache:
A reconstruction is a value that the undo history holds for as long as an entry names it,
so audio cached on it would live as long as the history does. The cache keeps renders
instead, each made from one channel's stream at one configuration, or one mix of such
- streams. A stream an edit leaves alone is the very object the edited document holds, so its
- render serves both documents.
-
- A render is keyed by the identity of the streams it sounds, which a lookup reads at once,
- where a stream's value would take every instruction it carries to hash. A render goes with
- the stream it was made from, and beyond ``budget_bytes`` the renders read longest ago go
- first, so the cache keeps audio alone. The render thread reads the cache, and a stream's
- collection may drop its renders from any thread, so every change to the cache is made
- under one lock.
+ streams as a reader hears them. A stream an edit leaves alone is the very object the edited
+ document holds, so its render serves both documents, and so is the record naming who holds
+ each of its frames.
+
+ A render is keyed by the identity of the parts it sounds, which a lookup reads at once, where
+ a stream's value would take every instruction it carries to hash. A mix as a reader hears it
+ names, for each channel the reader leaves an owner out on, the record and the owners heard, so
+ two hearings leaving nothing out read one mix. The mix of the recordings heard, which the
+ waveform draws as the original, is kept under the recordings it was mixed from, so every
+ document holding those recordings reads one mix. A render goes with the objects it was made
+ from, and beyond ``budget_bytes`` the renders read longest ago go first, so the cache keeps
+ audio alone. A landing is kept whole: the renders one landing keeps are spared by its own
+ eviction, so a document larger than the budget stands until the next landing, and a reading
+ taken right after a landing answers. A hearing with no channel on plays silence as long as
+ the document's mix, answered at the read and kept nowhere, since it names no part. Every
+ render the cache keeps is a worker's result, adopted at its landing on the render thread, and
+ a reading the cache leaves unanswered is what a worker is asked for, so the render thread
+ computes no sample. The render thread reads the cache and keeps what it adopts,
+ and an object's collection may drop its renders from any thread, so every change to the cache
+ is made under one lock. The lock lets its holder in again, since a stream let go of while a
+ keep runs on the same thread is forgotten from inside that keep. A kept render is read-only,
+ so the readers it is shared with copy what they change.
"""
def __init__(self, *, budget_bytes: int) -> None:
@@ -51,50 +103,209 @@ def __init__(self, *, budget_bytes: int) -> None:
self._renders: OrderedDict[RenderKey, np.ndarray] = OrderedDict()
self._watched: Set[int] = set()
self._held_bytes = 0
- self._lock = threading.Lock()
+ self._lock = threading.RLock()
+ self._landing_depth = 0
+ self._landed: Set[RenderKey] = set()
@property
def held_bytes(self) -> int:
"""How many bytes the renders the cache keeps take."""
return self._held_bytes
- def channels(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]:
- """The audio each channel in play renders from the stream it carries."""
- return {
- stream.channel_name: self._channel(stream, reconstruction.config)
- for stream in self._sounding(reconstruction)
- }
-
- def mix(self, reconstruction: Reconstruction) -> np.ndarray:
- """The whole reconstruction, summed from the channels that play."""
- streams = self._sounding(reconstruction)
- key = RenderKey(RenderKind.MIX, tuple(id(stream) for stream in streams), reconstruction.config)
- cached = self._read(key)
- if cached is not None:
- return cached
-
- mixed = mix([self._channel(stream, reconstruction.config) for stream in streams])
- self._keep(key, mixed, streams)
- return mixed
+ def held(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]:
+ """The renders the cache holds for the channels in play, by channel, which a lookup answers at once.
- @staticmethod
- def _sounding(reconstruction: Reconstruction) -> List[InstructionsItem]:
- """The streams that describe a frame, in channel order, the channels a render sounds."""
- return [stream for stream in reconstruction.instructions_data if stream.instructions]
-
- def _channel(self, stream: InstructionsItem, config: Config) -> np.ndarray:
- key = RenderKey(RenderKind.CHANNEL, (id(stream),), config)
- cached = self._read(key)
- if cached is not None:
- return cached
-
- rendered = render_instructions(
- [data.instruction for data in stream.instructions],
- stream.channel_name,
- config,
+ A rebuild takes these with it, so the worker renders the channel the edit rewrote and
+ carries the rest over.
+ """
+ held: Dict[ChannelName, np.ndarray] = {}
+ for stream in sounding_streams(reconstruction):
+ cached = self._read(self._channel_key(stream, reconstruction.config))
+ if cached is not None:
+ held[stream.channel_name] = cached
+
+ return held
+
+ def reading(
+ self,
+ reconstruction: Reconstruction,
+ hearing: Hearing,
+ ) -> Optional[HeardRenders]:
+ """What the cache holds of ``hearing``'s reading of ``reconstruction``, which a lookup answers at once.
+
+ Returns:
+ Optional[HeardRenders]: The heard mix and what plays, or ``None`` where either is
+ missing, which is what a worker is then asked to render.
+ """
+ parts = self._heard_parts(reconstruction, hearing)
+ mixed = self._read(self._mix_key(list(parts.values()), reconstruction.config))
+ if mixed is None:
+ return None
+
+ playing_parts = self._playing_parts(parts, hearing)
+ if not playing_parts:
+ return HeardRenders(mix=mixed, playing=np.zeros_like(mixed))
+
+ playing = self._read(self._mix_key(playing_parts, reconstruction.config))
+ if playing is None:
+ return None
+
+ return HeardRenders(mix=mixed, playing=playing)
+
+ def original(
+ self,
+ recordings: Sequence[np.ndarray],
+ config: Config,
+ ) -> Optional[np.ndarray]:
+ """The mix of ``recordings``, heard in record order, which a lookup answers at once.
+
+ Returns:
+ Optional[np.ndarray]: The mix, or ``None`` where the cache keeps none, which is what a
+ worker is then asked for.
+ """
+ return self._read(self._original_key(recordings, config))
+
+ def adopt_original(
+ self,
+ recordings: Sequence[np.ndarray],
+ config: Config,
+ mixed: np.ndarray,
+ ) -> None:
+ """Keeps the mix of ``recordings`` made elsewhere, under the recordings it was mixed from.
+
+ A recording is shared by every document holding it, so its mix serves each of them, and
+ the mix goes with the recordings once the last document lets them go.
+ """
+ with self._landing():
+ self._keep(self._original_key(recordings, config), mixed, list(recordings))
+
+ def adopt_rendered(self, outcome: RenderedDocument) -> None:
+ """Keeps everything a job rendered as one landing: the channels, the mix, the hearing's reading, and the
+ original of the recordings heard.
+
+ Args:
+ outcome: What the worker handed back.
+ """
+ with self._landing():
+ self.adopt(outcome.reconstruction, outcome.channels, outcome.mix)
+ self.adopt_heard(outcome.reconstruction, outcome.hearing, outcome.heard)
+ if outcome.original is not None:
+ self.adopt_original(outcome.original.recordings, outcome.reconstruction.config, outcome.original.audio)
+
+ def adopt(
+ self,
+ reconstruction: Reconstruction,
+ channels: Mapping[ChannelName, np.ndarray],
+ mixed: np.ndarray,
+ ) -> None:
+ """Keeps renders made elsewhere, one per channel in play and their mix, as the document's own.
+
+ A render the cache already holds for a stream stays as it is, so a channel rendered twice
+ keeps the one the screen read first.
+
+ Args:
+ reconstruction: The document the renders sound.
+ channels: The audio each channel in play renders to, by channel.
+ mixed: The whole document, summed from ``channels``.
+ """
+ streams = sounding_streams(reconstruction)
+ with self._landing():
+ for stream in streams:
+ self._keep(self._channel_key(stream, reconstruction.config), channels[stream.channel_name], [stream])
+
+ parts = [self._plain_part(stream) for stream in streams]
+ self._keep(self._mix_key(parts, reconstruction.config), mixed, streams)
+
+ def adopt_heard(
+ self,
+ reconstruction: Reconstruction,
+ hearing: Hearing,
+ heard: HeardRenders,
+ ) -> None:
+ """Keeps what a hearing of the document sounds, rendered elsewhere, under the parts it was heard as.
+
+ What plays with no channel on is silence, answered at every read, so it is kept nowhere.
+
+ Args:
+ reconstruction: The document the renders sound.
+ hearing: What the reader heard of it when the renders were made.
+ heard: The heard mix and what plays.
+ """
+ parts = self._heard_parts(reconstruction, hearing)
+ with self._landing():
+ self._keep_mix(list(parts.values()), reconstruction.config, heard.mix)
+ playing_parts = self._playing_parts(parts, hearing)
+ if playing_parts:
+ self._keep_mix(playing_parts, reconstruction.config, heard.playing)
+
+ @contextmanager
+ def _landing(self) -> Iterator[None]:
+ """One landing, whose keeps are spared by the eviction it runs; a landing inside another joins it."""
+ with self._lock:
+ self._landing_depth += 1
+
+ try:
+ yield
+ finally:
+ with self._lock:
+ self._landing_depth -= 1
+ if self._landing_depth == 0:
+ self._landed.clear()
+
+ def _keep_mix(
+ self,
+ parts: List[_Part],
+ config: Config,
+ mixed: np.ndarray,
+ ) -> None:
+ self._keep(
+ self._mix_key(parts, config),
+ mixed,
+ [watched for part in parts for watched in part.watched],
)
- self._keep(key, rendered, [stream])
- return rendered
+
+ @staticmethod
+ def _plain_part(stream: InstructionsItem) -> _Part:
+ return _Part(part=RenderPart(source=id(stream), assignment=None, heard=None), watched=(stream,))
+
+ @staticmethod
+ def _heard_parts(reconstruction: Reconstruction, hearing: Hearing) -> Dict[ChannelName, _Part]:
+ """Each channel in play as ``hearing`` sounds it, in channel order."""
+ stems_data = reconstruction.stems_data
+ parts: Dict[ChannelName, _Part] = {}
+ for stream in sounding_streams(reconstruction):
+ heard = hearing.filter_for(stream.channel_name, stems_data)
+ if heard is None:
+ parts[stream.channel_name] = RenderCache._plain_part(stream)
+ continue
+
+ record = stems_data.assignment_records[stream.channel_name]
+ parts[stream.channel_name] = _Part(
+ part=RenderPart(source=id(stream), assignment=id(record), heard=heard),
+ watched=(stream, record),
+ )
+
+ return parts
+
+ @staticmethod
+ def _playing_parts(parts: Mapping[ChannelName, _Part], hearing: Hearing) -> List[_Part]:
+ return [part for channel_name, part in parts.items() if channel_name in hearing.channels_on]
+
+ @staticmethod
+ def _channel_key(stream: InstructionsItem, config: Config) -> RenderKey:
+ return RenderKey(RenderKind.CHANNEL, (RenderPart(source=id(stream), assignment=None, heard=None),), config)
+
+ @staticmethod
+ def _original_key(
+ recordings: Sequence[np.ndarray],
+ config: Config,
+ ) -> RenderKey:
+ parts = tuple(RenderPart(source=id(recording), assignment=None, heard=None) for recording in recordings)
+ return RenderKey(RenderKind.ORIGINAL, parts, config)
+
+ @staticmethod
+ def _mix_key(parts: Sequence[_Part], config: Config) -> RenderKey:
+ return RenderKey(RenderKind.MIX, tuple(part.part for part in parts), config)
def _read(self, key: RenderKey) -> Optional[np.ndarray]:
with self._lock:
@@ -108,26 +319,37 @@ def _keep(
self,
key: RenderKey,
rendered: np.ndarray,
- streams: List[InstructionsItem],
+ watched: Sequence[Watched],
) -> None:
with self._lock:
+ self._landed.add(key)
if key in self._renders:
return
+ rendered.setflags(write=False)
self._renders[key] = rendered
self._held_bytes += rendered.nbytes
- for stream in streams:
- if id(stream) not in self._watched:
- self._watched.add(id(stream))
- weakref.finalize(stream, self._forget, id(stream))
+ for named in watched:
+ if id(named) not in self._watched:
+ self._watched.add(id(named))
+ weakref.finalize(named, self._forget, id(named))
+
+ self._evict_beyond_the_budget()
+
+ def _evict_beyond_the_budget(self) -> None:
+ """Lets the renders read longest ago go, the landing's own spared, until the budget holds the rest."""
+ for key in list(self._renders):
+ if self._held_bytes <= self._budget_bytes:
+ return
+
+ if key in self._landed:
+ continue
- while self._held_bytes > self._budget_bytes and len(self._renders) > 1:
- _, evicted = self._renders.popitem(last=False)
- self._held_bytes -= evicted.nbytes
+ self._held_bytes -= self._renders.pop(key).nbytes
- def _forget(self, stream_id: int) -> None:
- """Drops every render a collected stream sounds in, before its identity can name another."""
+ def _forget(self, identity: int) -> None:
+ """Drops every render a collected stream or record sounds in, before its identity can name another."""
with self._lock:
- self._watched.discard(stream_id)
- for key in [key for key in self._renders if stream_id in key.streams]:
+ self._watched.discard(identity)
+ for key in [key for key in self._renders if identity in key.identities]:
self._held_bytes -= self._renders.pop(key).nbytes
diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py
index 0ae44e42d..362ab73fd 100644
--- a/src/sampletones_application/paths.py
+++ b/src/sampletones_application/paths.py
@@ -2,7 +2,6 @@
from typing import Final
from sampletones_shared.paths.resources import CONFIG_DIRECTORY
-from sampletones_shared.paths.user import USER_PATH_CONFIG
APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application"
BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior"
@@ -14,4 +13,3 @@
LANG_EN: Final[Path] = LANG_DIRECTORY / "en.yaml"
DEPLOYMENT_CONFIG_PATH: Final[Path] = APPLICATION_DIRECTORY / "deployment.yaml"
-APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml"
diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py
index 0eebac09c..2141c6626 100644
--- a/src/sampletones_application/services/__init__.py
+++ b/src/sampletones_application/services/__init__.py
@@ -11,8 +11,8 @@
from sampletones_application.services.export.service import ExportService
from sampletones_application.services.export.success import ExportSuccess
from sampletones_application.services.regeneration.result import (
- RegeneratedInstrument,
RegenerationResult,
+ RenderedDocument,
)
from sampletones_application.services.regeneration.service import RegenerationService
from sampletones_application.services.render import (
@@ -41,7 +41,7 @@
"ExportService",
"ExportSuccess",
"ReconstructionStep",
- "RegeneratedInstrument",
+ "RenderedDocument",
"RegenerationResult",
"RegenerationService",
"RenderResult",
diff --git a/src/sampletones_application/services/base.py b/src/sampletones_application/services/base.py
index d543d3046..9dc5c6ff8 100644
--- a/src/sampletones_application/services/base.py
+++ b/src/sampletones_application/services/base.py
@@ -1,11 +1,21 @@
from abc import ABC
+from dataclasses import dataclass
from typing import Callable, Generic, List, TypeVar
+from sampletones_application.utils.callbacks.latest import LatestDelivery
from sampletones_application.utils.callbacks.queue import CallbackQueue
T = TypeVar("T")
+@dataclass(frozen=True)
+class _Subscription(Generic[T]):
+ """One subscriber, and the delivery its reports wait in."""
+
+ handler: Callable[[T], None]
+ reports: LatestDelivery[[T]]
+
+
class ServiceBase(ABC, Generic[T]):
"""
The boundary between synchronous application logic and asynchronous computation.
@@ -17,22 +27,37 @@ class ServiceBase(ABC, Generic[T]):
ordinary synchronous values.
- *Exhaustive result types* — result types form a tagged union; subscribers
handle each case with ``match``, making incomplete handling a type error.
+
+ A result travels one of two ways. ``_emit`` delivers every result it is given, which is how an
+ outcome travels. ``_report`` carries where a run stands, and a report still waiting for the
+ render thread is superseded by the next, so a subscriber draws the newest one once per drain.
"""
def __init__(self, priority: int = 0) -> None:
self._priority = priority
- self._listeners: List[Callable[[T], None]] = []
+ self._subscriptions: List[_Subscription[T]] = []
def subscribe(self, handler: Callable[[T], None]) -> None:
- self._listeners.append(handler)
+ self._subscriptions.append(
+ _Subscription(
+ handler=handler,
+ reports=LatestDelivery(handler, priority=self._priority),
+ )
+ )
def unsubscribe(self, handler: Callable[[T], None]) -> None:
- if handler in self._listeners:
- self._listeners.remove(handler)
+ for subscription in self._subscriptions:
+ if subscription.handler == handler:
+ self._subscriptions.remove(subscription)
+ return
def _emit(self, result: T) -> None:
- for listener in self._listeners:
- CallbackQueue.add(listener, result, priority=self._priority)
+ for subscription in self._subscriptions:
+ CallbackQueue.add(subscription.handler, result, priority=self._priority)
+
+ def _report(self, result: T) -> None:
+ for subscription in self._subscriptions:
+ subscription.reports.post(result)
@property
def class_name(self) -> str:
diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py
index f53dd89a9..62ac721af 100644
--- a/src/sampletones_application/services/conversion/result.py
+++ b/src/sampletones_application/services/conversion/result.py
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from pathlib import Path
-from typing import Optional, Tuple, Union
+from typing import Optional, Union
from sampletones_application.services.result import (
ServiceCanceled,
@@ -11,6 +11,7 @@
ServiceSuccess,
)
from sampletones_core.parallelization import TaskProgress
+from sampletones_core.reconstructions.converter import ConversionOutcome
from sampletones_core.reconstructions.stage import ReconstructionStage
@@ -46,7 +47,7 @@ class ConversionItem:
ServiceStarted,
ServiceProgress[ConversionItem],
ServiceIntermediate[TaskProgress],
- ServiceSuccess[Tuple[Path, ...]],
+ ServiceSuccess[ConversionOutcome],
ServiceError,
ServiceCanceled,
]
diff --git a/src/sampletones_application/services/conversion/service.py b/src/sampletones_application/services/conversion/service.py
index 811467a9f..6fcebd45d 100644
--- a/src/sampletones_application/services/conversion/service.py
+++ b/src/sampletones_application/services/conversion/service.py
@@ -1,5 +1,4 @@
-from pathlib import Path
-from typing import Optional, Tuple
+from typing import Optional
from sampletones_application.services.base import ServiceBase
from sampletones_application.services.conversion.result import (
@@ -19,7 +18,7 @@
from sampletones_core.configs import Config
from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus
from sampletones_core.parallelization.task import TaskStep
-from sampletones_core.reconstructions.converter import ConversionPlan, ReconstructionConverter
+from sampletones_core.reconstructions.converter import ConversionOutcome, ConversionPlan, ReconstructionConverter
from sampletones_core.reconstructions.stage import ReconstructionStage
from sampletones_shared.logger import logger
@@ -90,7 +89,7 @@ def _on_progress(
) -> None:
match task_status:
case TaskStatus.RUNNING | TaskStatus.CANCELING:
- self._emit(self._reading(task_progress))
+ self._report(self._reading(task_progress))
case _:
pass
@@ -156,8 +155,8 @@ def _step(task_progress: TaskProgress) -> Optional[ReconstructionStep]:
total=step.total,
)
- def _on_completed(self, written: Tuple[Path, ...]) -> None:
- self._emit(ServiceSuccess(value=written))
+ def _on_completed(self, outcome: ConversionOutcome) -> None:
+ self._emit(ServiceSuccess(value=outcome))
def _on_error(self, exception: Exception) -> None:
self._emit(ServiceError(exception=exception))
@@ -170,4 +169,4 @@ def forward_library_progress(
_status: TaskStatus,
progress: TaskProgress,
) -> None:
- self._emit(ServiceIntermediate(data=progress))
+ self._report(ServiceIntermediate(data=progress))
diff --git a/src/sampletones_application/services/regeneration/__init__.py b/src/sampletones_application/services/regeneration/__init__.py
index 2f6415ed7..e2d80119e 100644
--- a/src/sampletones_application/services/regeneration/__init__.py
+++ b/src/sampletones_application/services/regeneration/__init__.py
@@ -1,8 +1,8 @@
-from .result import RegeneratedInstrument, RegenerationResult
+from .result import RegenerationResult, RenderedDocument
from .service import RegenerationService
__all__ = [
- "RegeneratedInstrument",
+ "RenderedDocument",
"RegenerationResult",
"RegenerationService",
]
diff --git a/src/sampletones_application/services/regeneration/jobs.py b/src/sampletones_application/services/regeneration/jobs.py
new file mode 100644
index 000000000..923accc05
--- /dev/null
+++ b/src/sampletones_application/services/regeneration/jobs.py
@@ -0,0 +1,53 @@
+from dataclasses import dataclass
+from typing import Mapping, Union
+
+import numpy as np
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters import Features
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing
+
+
+@dataclass(frozen=True)
+class RebuildJob:
+ """One channel of a document rebuilt from envelopes, and the document rendered as the reader hears it.
+
+ Attributes:
+ reconstruction: The document the rebuild starts from, which stays as it is.
+ channel_name: The channel rebuilt.
+ features: The envelopes the channel is rebuilt from.
+ hearing: What the reader hears, which names the recordings the edit writes and the reading
+ rendered.
+ kept: The renders the caller holds for the document's channels, by channel.
+ recordings: The recordings loaded for the document, by stem id, read-only values the
+ worker mixes the original of the recordings heard from.
+ """
+
+ reconstruction: Reconstruction
+ channel_name: ChannelName
+ features: Features
+ hearing: Hearing
+ kept: Mapping[ChannelName, np.ndarray]
+ recordings: Mapping[int, np.ndarray]
+
+
+@dataclass(frozen=True)
+class ListeningJob:
+ """A document rendered as the reader hears it, as it stands.
+
+ Attributes:
+ reconstruction: The document read.
+ hearing: What the reader hears of it.
+ kept: The renders the caller holds for the document's channels, by channel.
+ recordings: The recordings loaded for the document, by stem id, read-only values the
+ worker mixes the original of the recordings heard from.
+ """
+
+ reconstruction: Reconstruction
+ hearing: Hearing
+ kept: Mapping[ChannelName, np.ndarray]
+ recordings: Mapping[int, np.ndarray]
+
+
+RenderJob = Union[RebuildJob, ListeningJob]
diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py
index a341bfd05..cbf738ca2 100644
--- a/src/sampletones_application/services/regeneration/result.py
+++ b/src/sampletones_application/services/regeneration/result.py
@@ -1,27 +1,61 @@
from dataclasses import dataclass
-from typing import Union
+from typing import Mapping, Optional, Tuple, Union
+
+import numpy as np
from sampletones_application.services.result import (
ServiceError,
ServiceSuccess,
)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.hearing import HeardRenders, Hearing
+
+
+@dataclass(frozen=True)
+class OriginalMix:
+ """The recordings heard anywhere mixed into one waveform, beside the recordings it was mixed from.
+
+ Attributes:
+ recordings: The recordings mixed, in the document's record order.
+ audio: Their mix.
+ """
+
+ recordings: Tuple[np.ndarray, ...]
+ audio: np.ndarray
@dataclass(frozen=True)
-class RegeneratedInstrument:
- """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed.
+class RenderedDocument:
+ """A document beside its sound: every channel's render, their mix, what one hearing of it sounds, and the
+ original of the recordings heard.
- The caller keeps what it asked for, so the result carries the rebuilt document alone.
+ The caller keeps what it asked for, so the result carries the document and the audio the
+ worker rendered for it, which the landing installs where the screen reads it. A rebuild
+ carries a fresh document with one channel rebuilt; a listening carries the document as it was.
Attributes:
- reconstruction: The fresh reconstruction carrying the rebuilt channel.
+ reconstruction: The document the renders sound.
+ channels: The audio each sounding channel of ``reconstruction`` renders to, by channel.
+ mix: The whole document, summed from ``channels``.
+ hearing: What the reader heard when the renders were made.
+ heard: What ``hearing`` sounds of the document.
+ envelopes: The envelopes each channel exports for the recordings ``hearing`` hears.
+ original: The mix of the recordings ``hearing`` hears anywhere, which the waveform draws as
+ the original, or ``None`` where none is heard or none loaded.
"""
reconstruction: Reconstruction
+ channels: Mapping[ChannelName, np.ndarray]
+ mix: np.ndarray
+ hearing: Hearing
+ heard: HeardRenders
+ envelopes: Mapping[ChannelName, Features]
+ original: Optional[OriginalMix]
RegenerationResult = Union[
- ServiceSuccess[RegeneratedInstrument],
+ ServiceSuccess[RenderedDocument],
ServiceError,
]
diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py
index d0f3d6f65..2117e3130 100644
--- a/src/sampletones_application/services/regeneration/service.py
+++ b/src/sampletones_application/services/regeneration/service.py
@@ -1,30 +1,52 @@
from functools import partial
-from typing import AbstractSet, List, cast
+from typing import Dict, List, Mapping, Optional, Tuple, cast
+
+import numpy as np
from sampletones_application.services.base import ServiceBase
+from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob, RenderJob
from sampletones_application.services.regeneration.result import (
- RegeneratedInstrument,
+ OriginalMix,
RegenerationResult,
+ RenderedDocument,
)
from sampletones_application.services.result import ServiceError, ServiceSuccess
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
+from sampletones_core.audio.mixing import mix
from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features
+from sampletones_core.generators.render import render_instructions
from sampletones_core.instructions import InstructionUnion
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.renders import sounding_streams
+from sampletones_core.reconstructions.reconstruction.stems.hearing import (
+ Hearing,
+ heard_renders,
+ hearing_carried_onto,
+)
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
class RegenerationService(ServiceBase[RegenerationResult]):
- """Recomputes one generator's instructions for a reconstruction.
+ """Recomputes one generator's instructions for a reconstruction and renders what the reader hears of it.
- An edit reaches the frames the reader hears on the channel, so ``heard`` travels with it:
+ An edit reaches the frames the reader hears on the channel, so the hearing travels with it:
a frame of a recording the reader left out stands as it is, which is what lets one
- recording's part be shaped while the recordings beside it carry on.
+ recording's part be shaped while the recordings beside it carry on. The result sounds that
+ hearing carried onto the rebuilt document by the core's rule, the one the listening state
+ carries it by at the landing: the reader's own row joins what is heard on a channel the edit
+ first writes frames of its own on, and a channel coming into play joins the channels on, so
+ the landing installs sound and envelopes for the choice the listening then makes.
- The result is a fresh reconstruction carrying the updated generator data; the
- source reconstruction is left intact. Producing a new object lets callers swap
- the edited reconstruction in while any history snapshot that shares the prior
- object stays valid.
+ A rebuild's result is a fresh reconstruction carrying the updated generator data, with the
+ audio of every sounding channel, their mix, what the hearing sounds of it, the envelopes
+ each channel exports for the recordings heard, and the mix of the recordings heard anywhere,
+ which the waveform draws as the original; the source reconstruction is left intact.
+ Producing a new object lets callers swap the edited reconstruction in while any history
+ snapshot that shares the prior object stays valid. A listening renders the same reading of the
+ document as it stands, for a hearing the screen holds no audio for yet. Carrying the audio
+ lets the landing install it where the screen reads
+ it, so the render thread keeps drawing while the worker renders.
One job runs at a time on a single worker thread. Its caller starts the next job once the
previous result has arrived, so each rebuild starts from the document the one before it left.
@@ -39,7 +61,10 @@ def start(
reconstruction: Reconstruction,
channel_name: ChannelName,
features: Features,
- heard: AbstractSet[int],
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
) -> None:
"""Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread.
@@ -47,40 +72,135 @@ def start(
reconstruction: The document the rebuild starts from, which stays as it is.
channel_name: The channel rebuilt.
features: The envelopes the channel is rebuilt from.
- heard: The recordings whose frames the rebuild writes.
+ hearing: What the reader hears, which names the recordings the rebuild writes on the
+ channel and, carried onto the rebuilt document, the reading the result sounds.
+ kept: The renders the caller holds for ``reconstruction``'s channels, by channel. Every
+ channel but ``channel_name`` keeps its stream through the rebuild, so a render
+ handed for it carries over into the result, and the worker renders the rest. A
+ render handed for ``channel_name`` is passed over, since the edit rebuilds it.
+ recordings: The recordings loaded for the document, by stem id; the worker mixes the
+ ones the reading hears anywhere.
"""
- self._executor.execute(
- partial(
- self._run,
- reconstruction,
- channel_name,
- features,
- heard,
- ),
- wait=True,
- )
+ self._execute(RebuildJob(reconstruction, channel_name, features, hearing, kept, recordings))
- def _run(
+ def start_listening(
self,
reconstruction: Reconstruction,
- channel_name: ChannelName,
- features: Features,
- heard: AbstractSet[int],
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
) -> None:
+ """Renders what ``hearing`` sounds of ``reconstruction`` as it stands, on the worker thread.
+
+ Args:
+ reconstruction: The document read.
+ hearing: What the reader hears of it.
+ kept: The renders the caller holds for ``reconstruction``'s channels, by channel, each of
+ which carries over; the worker renders the rest.
+ recordings: The recordings loaded for the document, by stem id; the worker mixes the
+ ones ``hearing`` hears anywhere.
+ """
+ self._execute(ListeningJob(reconstruction, hearing, kept, recordings))
+
+ def _execute(self, job: RenderJob) -> None:
+ self._executor.execute(partial(self._run, job), wait=True)
+
+ def _run(self, job: RenderJob) -> None:
try:
- exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name]
- instructions = cast(
- List[InstructionUnion],
- exporter_class.from_features(features),
- )
+ match job:
+ case RebuildJob():
+ document = self._rebuilt(job)
+ edited: Optional[ChannelName] = job.channel_name
+ hearing = hearing_carried_onto(job.hearing, job.reconstruction, document)
+ case ListeningJob():
+ document = job.reconstruction
+ edited = None
+ hearing = job.hearing
+
+ channels = self._rendered(document, edited, job.kept)
+ if channels is None or SingleThreadExecutor.is_shutting_down():
+ return
- updated = reconstruction.with_channel_data(
- channel_name,
- instructions,
- features.initial_pitch,
- features.held_features,
- heard=heard,
+ mixed = mix(list(channels.values()))
+ self._emit(
+ ServiceSuccess(
+ value=RenderedDocument(
+ reconstruction=document,
+ channels=channels,
+ mix=mixed,
+ hearing=hearing,
+ heard=heard_renders(document, hearing, channels, mixed),
+ envelopes=document.export_heard(hearing.selection),
+ original=self._original(document, hearing.selection, job.recordings),
+ )
+ )
)
- self._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=updated)))
except Exception as exception: # pylint: disable=broad-exception-caught
self._emit(ServiceError(exception=exception))
+
+ @staticmethod
+ def _rebuilt(job: RebuildJob) -> Reconstruction:
+ """The document with the job's channel rebuilt from its envelopes, on the recordings the reader hears."""
+ exporter_class = CHANNEL_TO_EXPORTER_MAP[job.channel_name]
+ instructions = cast(
+ List[InstructionUnion],
+ exporter_class.from_features(job.features),
+ )
+ return job.reconstruction.with_channel_data(
+ job.channel_name,
+ instructions,
+ job.features.initial_pitch,
+ job.features.held_features,
+ heard=job.hearing.selection.stems_for(job.channel_name),
+ )
+
+ @staticmethod
+ def _original(
+ document: Reconstruction,
+ selection: StemSelection,
+ recordings: Mapping[int, np.ndarray],
+ ) -> Optional[OriginalMix]:
+ """The recordings ``selection`` hears anywhere mixed into one waveform, in ``document``'s record order, or
+ ``None`` where none is heard or none loaded.
+ """
+ heard = selection.any_channel()
+ mixed_from: Tuple[np.ndarray, ...] = tuple(
+ recordings[entry.id]
+ for entry in document.stems_data.config.entries
+ if entry.id in heard and entry.id in recordings
+ )
+ if not mixed_from:
+ return None
+
+ return OriginalMix(recordings=mixed_from, audio=mix(list(mixed_from)))
+
+ @staticmethod
+ def _rendered(
+ document: Reconstruction,
+ edited: Optional[ChannelName],
+ kept: Mapping[ChannelName, np.ndarray],
+ ) -> Optional[Dict[ChannelName, np.ndarray]]:
+ """The audio each sounding channel of ``document`` renders to, a handed render carried over where the
+ job left the channel alone, or ``None`` where a shutdown asked for meanwhile ends the job.
+
+ The shutdown is read before each channel, so a worker joined at the teardown gives up at
+ its next step.
+ """
+ rendered: Dict[ChannelName, np.ndarray] = {}
+ for stream in sounding_streams(document):
+ if SingleThreadExecutor.is_shutting_down():
+ return None
+
+ handed = kept.get(stream.channel_name)
+ if handed is not None and stream.channel_name != edited:
+ rendered[stream.channel_name] = handed
+ continue
+
+ rendered[stream.channel_name] = render_instructions(
+ [data.instruction for data in stream.instructions],
+ stream.channel_name,
+ document.config,
+ )
+
+ return rendered
diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py
index 7abc0fb12..2ba2153bb 100644
--- a/src/sampletones_application/shell.py
+++ b/src/sampletones_application/shell.py
@@ -112,6 +112,7 @@ class ShortcutBindings:
toggle_auto_expand_favorite_reconstructions: Callback
toggle_auto_expand_favorite_directories: Callback
toggle_fullscreen: Callback
+ documentation: Callback
about: Callback
next_tab: Callback
previous_tab: Callback
@@ -281,6 +282,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback
),
ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories,
ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen,
+ ShortcutId.DOCUMENTATION: bindings.documentation,
ShortcutId.ABOUT_DIALOG: bindings.about,
ShortcutId.NEXT_TAB: bindings.next_tab,
ShortcutId.PREVIOUS_TAB: bindings.previous_tab,
diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py
index 8c56083f9..b2b981788 100644
--- a/src/sampletones_application/tags/general.py
+++ b/src/sampletones_application/tags/general.py
@@ -518,6 +518,12 @@
Widget.DIALOG,
"about",
)
+TAG_GLOBAL_DIALOG_DOCUMENTATION = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.DIALOG,
+ "documentation",
+)
TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py
index a36b22b87..e089d59ab 100644
--- a/src/sampletones_application/tags/reconstructions.py
+++ b/src/sampletones_application/tags/reconstructions.py
@@ -197,7 +197,7 @@
PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel")
PRE_RECONSTRUCTION_STEMS = compose_tag("reconstruction", "stems")
-SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message"
+SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE = "message"
SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size"
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window"
SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE = "autoscale"
diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py
index a0a327084..5dfeb80e4 100644
--- a/src/sampletones_application/ui/elements/graphs/bar.py
+++ b/src/sampletones_application/ui/elements/graphs/bar.py
@@ -108,6 +108,7 @@ def __init__(
self.on_bar_point_hovered: Optional[OnBarPointHoveredCallback] = None
self._press: LeftPress[HeldStroke] = LeftPress()
+ self._locked = False
_min_x = layout.graph.min_x
_max_x = layout.graph.max_x
@@ -173,8 +174,17 @@ def _setup_handlers(self) -> None:
callback=self._on_release,
)
+ def set_locked(self, locked: bool) -> None:
+ """Takes presses on the bars, or holds them while what the bars draw is on its way, ending a press held."""
+ self._locked = locked
+ if locked:
+ self._press.end()
+
def _on_press(self, sender: Sender) -> None:
- """Starts a press on the plot, which draws the bar it lands on."""
+ """Starts a press on the plot, which draws the bar it lands on, where the bars take presses."""
+ if self._locked:
+ return
+
self._press.begin(HeldStroke())
self._on_mouse_action(sender)
diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py
index 87ec8abac..0fa7e3389 100644
--- a/src/sampletones_application/ui/elements/graphs/layers/array.py
+++ b/src/sampletones_application/ui/elements/graphs/layers/array.py
@@ -1,28 +1,29 @@
from dataclasses import dataclass
+from typing import Tuple
import numpy as np
from sampletones_application.ui.elements.graphs.layers.layer import Layer
from sampletones_application.utils.palette.colors.base import BaseColor
-from sampletones_core.audio import minmax_decimate
@dataclass(frozen=True)
class ArrayLayer(Layer):
+ """A waveform drawn as a line, at the points the display reduced it to.
+
+ Attributes:
+ data: The waveform, one-dimensional.
+ name: The name the series is labeled by.
+ color: The color the line is drawn in.
+ points: The positions and values ``data`` was reduced to for the display.
+ """
+
data: np.ndarray
name: str
color: BaseColor
- max_display_points: int
+ points: Tuple[np.ndarray, np.ndarray]
def __post_init__(self) -> None:
- y_data = self.data.astype(np.float32)
- length = y_data.shape[0]
-
- assert y_data.ndim == 1, "ArrayLayer data must be a 1D numpy array"
- if length > self.max_display_points:
- x_data, y_data = minmax_decimate(y_data, num_buckets=self.max_display_points)
- else:
- x_data = np.arange(y_data.shape[0], dtype=np.float32)
-
+ x_data, y_data = self.points
object.__setattr__(self, "x_data", x_data)
object.__setattr__(self, "y_data", y_data)
diff --git a/src/sampletones_application/ui/elements/graphs/layers/decimation.py b/src/sampletones_application/ui/elements/graphs/layers/decimation.py
new file mode 100644
index 000000000..cbddd7667
--- /dev/null
+++ b/src/sampletones_application/ui/elements/graphs/layers/decimation.py
@@ -0,0 +1,65 @@
+import weakref
+from collections import OrderedDict
+from typing import Final, Tuple
+
+import numpy as np
+
+from sampletones_core.audio import minmax_decimate
+
+KEPT_DECIMATIONS: Final[int] = 8
+
+Points = Tuple[np.ndarray, np.ndarray]
+
+
+class Decimations:
+ """The points a display draws an array as, kept by the array's identity for as long as the array lives.
+
+ A render the cache keeps is read-only and keeps its identity for as long as the cache holds it,
+ so the min-max reduction a display draws it as is made once and read back on every showing. A
+ reduction goes when its array is collected, before the identity can name another, and the
+ reductions read longest ago go first beyond a small number kept, so the memo holds the few
+ arrays a display shows. The reduction keeps every extreme, so the peak of the points is the
+ array's own.
+ """
+
+ def __init__(self, max_display_points: int) -> None:
+ self._max_display_points = max_display_points
+ self._points: OrderedDict[int, Points] = OrderedDict()
+
+ def points(self, audio: np.ndarray) -> Points:
+ """The positions and the values a display draws ``audio`` as.
+
+ An array longer than the points the display shows reduces to its extremes per bucket; a
+ shorter one draws every sample.
+
+ Args:
+ audio: The waveform, one-dimensional.
+
+ Returns:
+ Points: The positions along the array and the value at each.
+ """
+ kept = self._points.get(id(audio))
+ if kept is not None:
+ self._points.move_to_end(id(audio))
+ return kept
+
+ points = self._reduced(audio)
+ self._points[id(audio)] = points
+ weakref.finalize(audio, self._forget, id(audio))
+ while len(self._points) > KEPT_DECIMATIONS:
+ self._points.popitem(last=False)
+
+ return points
+
+ def _reduced(self, audio: np.ndarray) -> Points:
+ values = audio.astype(np.float32, copy=False)
+ if values.ndim != 1:
+ raise ValueError("A display draws a one-dimensional waveform")
+
+ if values.shape[0] > self._max_display_points:
+ return minmax_decimate(values, num_buckets=self._max_display_points)
+
+ return np.arange(values.shape[0], dtype=np.float32), values
+
+ def _forget(self, identity: int) -> None:
+ self._points.pop(identity, None)
diff --git a/src/sampletones_application/ui/elements/graphs/ownership.py b/src/sampletones_application/ui/elements/graphs/ownership.py
index 992992d69..7922c083d 100644
--- a/src/sampletones_application/ui/elements/graphs/ownership.py
+++ b/src/sampletones_application/ui/elements/graphs/ownership.py
@@ -1,4 +1,5 @@
-from typing import Dict, List, Sequence, Tuple
+from dataclasses import dataclass
+from typing import Dict, List, Optional, Sequence, Tuple
import dearpygui.dearpygui as dpg
@@ -12,26 +13,79 @@
)
from sampletones_application.utils.gui.dpg import dpg_bind_item_theme, dpg_delete_item
from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
-from sampletones_application.view_model.shared.ownership import OwnershipRunViewModel
+from sampletones_application.view_model.shared.ownership import (
+ OwnershipRunViewModel,
+ OwnerStretchesViewModel,
+ runs_by_owner,
+)
+
+Stretches = Tuple[Tuple[int, int], ...]
+Band = Tuple[float, float]
+Geometry = Tuple[Stretches, float, Band]
+
+
+def traced(
+ stretches: Stretches,
+ frame_span: float,
+ band: Band,
+) -> Tuple[List[float], List[float], List[float]]:
+ """The points one shade series runs through to fill ``stretches`` and nothing between them.
+
+ The series rises to the band's top at the start of each stretch and falls back to its bottom at
+ the end, so the fill between its two edges covers the stretches alone and has no height anywhere
+ else.
+
+ Args:
+ stretches: The first frame and the frame one past the last of each stretch, in frame order.
+ frame_span: What one frame measures on the axis the plot counts along.
+ band: The lower and upper edge the stretches are drawn between.
+
+ Returns:
+ Tuple[List[float], List[float], List[float]]: The positions along the axis, the lower edge at
+ each, and the upper edge at each.
+ """
+ bottom, top = band
+ positions: List[float] = []
+ upper: List[float] = []
+ for start, end in stretches:
+ left, right = start * frame_span, end * frame_span
+ positions.extend((left, left, right, right))
+ upper.extend((bottom, top, top, bottom))
+
+ return positions, [bottom] * len(positions), upper
+
+
+@dataclass(frozen=True)
+class _Painted:
+ """One recording's series on an axis, with what it was traced from and how it is heard."""
+
+ series_tag: str
+ geometry: Geometry
+ heard: bool
class OwnershipRuns:
- """Paints the recordings behind a stretch of a plot, each run a flat bar in its own color.
+ """Paints the recordings behind a stretch of a plot, each recording one flat series in its own color.
Two surfaces show a frame's owner — the ribbon under the waveform and the stretch under an
instrument's bars — so both paint through this, and a recording is one color wherever it is
shown. Each surface states the span its own axis counts in and the band the bars sit in, so
the same runs land under a waveform read in samples and under an envelope read in frames.
- A theme is named after the reading it paints — the recording, the place it stands on the
- record and whether the reader hears it — so two stretches of one reading share a theme
- however many times a document divides, and a name reads the same from one run of the
- application to the next.
+ A recording's stretches along one lane are one series traced over them, so a lane costs as many
+ series as it has owners however many times the document divides. A repaint touches a series only
+ where something changed: its points are traced again where the stretches moved, and its theme is
+ bound again where the reader's hearing of the recording changed, so a box on the stems card
+ repaints as a few theme bindings.
+
+ A theme is named after the reading it paints — the recording and whether the reader hears it —
+ so two stretches of one reading share a theme however many times a document divides, and a name
+ reads the same from one run of the application to the next.
"""
def __init__(self, stem_colors: StemColors) -> None:
self._stem_colors = stem_colors
- self._painted: Dict[str, List[str]] = {}
+ self._painted: Dict[str, Dict[int, _Painted]] = {}
def paint(
self,
@@ -39,7 +93,7 @@ def paint(
runs: Sequence[OwnershipRunViewModel],
*,
frame_span: float,
- band: Tuple[float, float],
+ band: Band,
) -> None:
"""Draws one lane's stretches into an axis.
@@ -49,38 +103,50 @@ def paint(
frame_span: What one frame measures on the axis the plot counts along.
band: The lower and upper edge the stretches are drawn between.
"""
- self._clear(y_axis_tag)
- bottom, top = band
- painted: List[str] = []
- for run in runs:
- series_tag = compose_tag(y_axis_tag, SUF_OWNERSHIP_RUN, str(run.start_frame))
- dpg.add_shade_series(
- [run.start_frame * frame_span, run.end_frame * frame_span],
- y1=[bottom, bottom],
- y2=[top, top],
- tag=series_tag,
- parent=y_axis_tag,
- )
- self._bind(series_tag, run)
- painted.append(series_tag)
-
- self._painted[y_axis_tag] = painted
-
- def _clear(self, y_axis_tag: str) -> None:
- """Takes the stretches this axis carries away, which is what a repaint begins with."""
- for series_tag in self._painted.pop(y_axis_tag, []):
- dpg_delete_item(series_tag)
-
- def _bind(self, series_tag: str, run: OwnershipRunViewModel) -> None:
- """Binds the run its recording's fill, through the theme every like reading shares."""
+ owners = runs_by_owner(runs)
+ painted = self._painted.setdefault(y_axis_tag, {})
+ for stem_id in [stem_id for stem_id in painted if stem_id not in owners]:
+ dpg_delete_item(painted.pop(stem_id).series_tag)
+
+ for stem_id, owner in owners.items():
+ painted[stem_id] = self._paint_owner(y_axis_tag, painted.get(stem_id), owner, frame_span, band)
+
+ def _paint_owner(
+ self,
+ y_axis_tag: str,
+ standing: Optional[_Painted],
+ owner: OwnerStretchesViewModel,
+ frame_span: float,
+ band: Band,
+ ) -> _Painted:
+ """Brings one recording's series on the axis to ``owner``, drawing it where the axis holds none."""
+ series_tag = compose_tag(y_axis_tag, SUF_OWNERSHIP_RUN, str(owner.stem_id))
+ geometry: Geometry = (owner.stretches, frame_span, band)
+ if standing is not None and not dpg.does_item_exist(series_tag):
+ standing = None
+
+ if standing is None:
+ positions, lower, upper = traced(owner.stretches, frame_span, band)
+ dpg.add_shade_series(positions, lower, y2=upper, tag=series_tag, parent=y_axis_tag)
+ elif standing.geometry != geometry:
+ positions, lower, upper = traced(owner.stretches, frame_span, band)
+ dpg.configure_item(series_tag, x=positions, y1=lower, y2=upper)
+
+ if standing is None or standing.heard != owner.heard:
+ self._bind(series_tag, owner.stem_id, owner.heard)
+
+ return _Painted(series_tag=series_tag, geometry=geometry, heard=owner.heard)
+
+ def _bind(self, series_tag: str, stem_id: int, heard: bool) -> None:
+ """Binds the series its recording's fill, through the theme every like reading shares."""
theme_tag = compose_tag(
SUF_OWNERSHIP_RUN,
SUF_GRAPH_THEME,
- str(run.stem_id),
- SUF_OWNERSHIP_HEARD if run.heard else SUF_OWNERSHIP_LEFT_OUT,
+ str(stem_id),
+ SUF_OWNERSHIP_HEARD if heard else SUF_OWNERSHIP_LEFT_OUT,
)
if not dpg.does_item_exist(theme_tag):
- color = self._stem_colors.for_stem(run.stem_id, heard=run.heard)
+ color = self._stem_colors.for_stem(stem_id, heard=heard)
with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvShadeSeries):
dpg_add_palette_theme_color(dpg.mvPlotCol_Fill, color, category=dpg.mvThemeCat_Plots)
diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py
index 29493d7f5..6597ca011 100644
--- a/src/sampletones_application/ui/elements/graphs/waveform.py
+++ b/src/sampletones_application/ui/elements/graphs/waveform.py
@@ -29,6 +29,7 @@
from sampletones_application.ui.elements.graphs.gesture import PlotClickGesture
from sampletones_application.ui.elements.graphs.graph import GUIGraph
from sampletones_application.ui.elements.graphs.layers.array import ArrayLayer
+from sampletones_application.ui.elements.graphs.layers.decimation import Decimations
from sampletones_application.ui.elements.graphs.layers.instruction import (
InstructionLayer,
)
@@ -46,6 +47,7 @@
from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.utils.palette.colors.faded import FadedColor
from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor
+from sampletones_application.view_model.reconstruction.work import Work
from sampletones_application.view_model.shared.waveform_data import WaveformData
from sampletones_core.constants.audio import START_OF_AUDIO
from sampletones_core.constants.enums import AudioSourceType, ChannelName
@@ -86,15 +88,17 @@ def __init__(
):
self._language_manager = language_manager
self._layout = layout
+ self._decimations = Decimations(layout.waveform.max_display_points)
self._channel_colors = channel_colors
self._status_bar = status_bar
self._lbl_waveform_original = language_manager["global.graph.label.waveform_original"]
self._lbl_waveform_reconstruction = language_manager["global.graph.label.waveform_reconstruction"]
self._msg_regenerating = language_manager["global.graph.message.waveform_regenerating"]
+ self._msg_reading = language_manager["global.graph.message.waveform_reading"]
self.reconstruction_autoscale = True
- self._reconstruction_dimmed: bool = False
+ self._work: Optional[Work] = None
self._top_source: AudioSourceType = AudioSourceType.RECONSTRUCTION
self.position_indicator_tag = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR)
@@ -357,8 +361,8 @@ def _on_hover(self, sender: Sender, app_data: Any, user_data: Any) -> None:
super()._on_hover(sender, app_data, user_data)
FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks)
self._status_bar.set(
- self._msg_regenerating
- if self._reconstruction_dimmed
+ self._work_words(self._work)
+ if self._work is not None
else self._language_manager[
(
"global.graph.message.waveform_playable_navigation"
@@ -435,68 +439,34 @@ def load_voice_waveform(
name: The name the series is labeled by.
color: The color the line is drawn in.
"""
- self._reconstruction_dimmed = False
+ self._work = None
self._sample_rate = 0
self.clear_layers()
- self.add_layer(
- ArrayLayer(
- data=audio,
- name=name,
- color=color,
- max_display_points=self._layout.waveform.max_display_points,
- )
- )
-
- def _extract_reconstruction_layer_data(
- self,
- waveform_data: WaveformData,
- selected_channels: Optional[List[ChannelName]] = None,
- ) -> Tuple[Optional[np.ndarray], np.ndarray, float]:
- if selected_channels is None:
- selected_channels = list(waveform_data.approximations.keys())
-
- original_audio = waveform_data.original_audio
- approximation = waveform_data.partials(selected_channels)
- full_approximation = waveform_data.approximation
-
- if not self.reconstruction_autoscale or original_audio is None:
- return original_audio, approximation, 1.0
-
- original_audio = original_audio / waveform_data.coefficient
-
- coefficient = max(
- np.max(np.abs(full_approximation)),
- np.max(np.abs(original_audio)),
- )
-
- return original_audio, approximation, coefficient
+ self.add_layer(self._array_layer(audio, name=name, color=color))
def _display_layers(
self,
waveform_data: WaveformData,
- selected_channels: Optional[List[ChannelName]] = None,
) -> List[Union[ArrayLayer, InstructionLayer]]:
"""Builds the ordered waveform layers for the current data.
The original-audio layer joins the reconstruction layer while the source audio is loaded,
so a reconstruction whose recordings are missing or unreadable shows the approximation on
- its own.
+ its own. With the autoscale on, the original is drawn at the level the reconstruction was
+ scaled to, so the two lines compare.
"""
- original_audio, approximation_data, _ = self._extract_reconstruction_layer_data(
- waveform_data,
- selected_channels,
- )
- reconstruction_layer = self.reconstruction_layer(approximation_data)
+ reconstruction_layer = self.reconstruction_layer(waveform_data.playing)
+ original_audio = waveform_data.original_audio
if original_audio is None:
return [reconstruction_layer]
- sample_layer = self.sample_layer(original_audio)
+ divisor = waveform_data.coefficient if self.reconstruction_autoscale else 1.0
+ sample_layer = self.sample_layer(original_audio, divisor=divisor)
return self._ordered_layers(sample_layer, reconstruction_layer)
def update_waveform_data(
self,
waveform_data: WaveformData,
- selected_channels: Optional[List[ChannelName]] = None,
*,
refit: bool = False,
) -> None:
@@ -510,7 +480,7 @@ def update_waveform_data(
return
self.current_data = waveform_data
- displayed = self._display_layers(waveform_data, selected_channels)
+ displayed = self._display_layers(waveform_data)
if any(layer.name not in self.layers for layer in displayed):
self._delete_series()
@@ -521,33 +491,33 @@ def update_waveform_data(
self._update_display()
- def load_waveform_data(
- self,
- waveform_data: WaveformData,
- selected_channels: Optional[List[ChannelName]] = None,
- ) -> None:
+ def load_waveform_data(self, waveform_data: WaveformData) -> None:
self.clear_layers()
self.current_data = waveform_data
self._sample_rate = waveform_data.sample_rate
self._plays_what_it_draws = True
- for layer in self._display_layers(waveform_data, selected_channels):
+ for layer in self._display_layers(waveform_data):
self.add_layer(layer)
self._restate_clock_ticks(*self.x_range)
- def set_reconstruction_dimmed(self, dimmed: bool) -> None:
- """Grays the reconstruction line while its audio is being regenerated, restoring it when done.
+ @property
+ def _reconstruction_dimmed(self) -> bool:
+ return self._work is not None
+
+ def show_work(self, work: Optional[Work]) -> None:
+ """Grays the reconstruction line under the words naming ``work``, and restores it once nothing is on its way.
Only the reconstruction series is grayed; the original-audio series and the axes keep full
- strength, so the fade reads as "this waveform is being recomputed", and the status bar shows a
- regenerating hint for the same span. The state is remembered so an async data update arriving
- mid-regeneration redraws the reconstruction still grayed.
+ strength, so the fade reads as "this waveform is being recomputed", and the status bar names
+ what it waits for: the document being rebuilt, or its sound being prepared. The work is
+ remembered so an async data update arriving mid-way redraws the reconstruction still grayed.
"""
- if self._reconstruction_dimmed == dimmed:
+ if self._work == work:
return
- self._reconstruction_dimmed = dimmed
- self._status_bar.set(self._msg_regenerating if dimmed else "")
+ self._work = work
+ self._status_bar.set(self._work_words(work))
layer = self.layers.get(self._lbl_waveform_reconstruction)
if layer is None:
@@ -557,20 +527,55 @@ def set_reconstruction_dimmed(self, dimmed: bool) -> None:
if dpg.does_item_exist(series_tag):
self._bind_series_theme(series_tag, layer)
+ def _work_words(self, work: Optional[Work]) -> str:
+ """The status line naming ``work``, and an empty one where nothing is on its way."""
+ match work:
+ case Work.REBUILDING:
+ return self._msg_regenerating
+ case Work.READING:
+ return self._msg_reading
+ case None:
+ return ""
+
def reconstruction_layer(self, data: np.ndarray) -> ArrayLayer:
- return ArrayLayer(
- data=data,
+ return self._array_layer(
+ data,
name=self._lbl_waveform_reconstruction,
color=self._layout.colors.waveform_reconstruction,
- max_display_points=self._layout.waveform.max_display_points,
)
- def sample_layer(self, data: np.ndarray) -> ArrayLayer:
- return ArrayLayer(
- data=data,
+ def sample_layer(self, data: np.ndarray, *, divisor: float = 1.0) -> ArrayLayer:
+ """The original's line, its values divided by ``divisor``, which an autoscale sets to the document's level."""
+ return self._array_layer(
+ data,
name=self._lbl_waveform_original,
color=self._layout.colors.waveform_sample,
- max_display_points=self._layout.waveform.max_display_points,
+ divisor=divisor,
+ )
+
+ def _array_layer(
+ self,
+ data: np.ndarray,
+ *,
+ name: str,
+ color: BaseColor,
+ divisor: float = 1.0,
+ ) -> ArrayLayer:
+ """A line over ``data`` drawn from the points the display keeps for it.
+
+ The reduction to the display's points is kept by the array's identity, so a render the
+ cache hands back again is drawn without reducing it again. A division scales the kept
+ points alone, since the extremes of a scaled waveform are its scaled extremes.
+ """
+ positions, values = self._decimations.points(data)
+ if divisor != 1.0:
+ values = values / divisor
+
+ return ArrayLayer(
+ data=data,
+ name=name,
+ color=color,
+ points=(positions, values),
)
def _ordered_layers(
@@ -621,7 +626,7 @@ def _delete_series(self) -> None:
def clear(self) -> None:
"""Empties the plot down to the marks it keeps for whatever it draws next: the position
indicator and the overlay rectangle, both of which live among the axis's children."""
- self._reconstruction_dimmed = False
+ self._work = None
self._sample_rate = 0
self._release_clock_ticks()
self.clear_layers()
diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py
index a5901260f..ba0cc7b7e 100644
--- a/src/sampletones_application/ui/elements/pitch_stepper.py
+++ b/src/sampletones_application/ui/elements/pitch_stepper.py
@@ -121,6 +121,12 @@ def set_shown(self, shown: bool) -> None:
"""Shows or hides the stepper, label and buttons together, which is the whole of it."""
dpg_configure_item(self._table_tag, show=shown)
+ def set_enabled(self, enabled: bool) -> None:
+ """Takes a typed value and a step of the buttons, or holds both while the value is not the reader's to move."""
+ dpg_configure_item(self._input_tag, enabled=enabled)
+ if self._buttons is not None:
+ self._buttons.set_enabled(enabled)
+
def _build(self) -> None:
self._clear_existing_items()
with dpg.table(
diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py
index 5a7f346aa..329491853 100644
--- a/src/sampletones_application/ui/elements/plus_minus_buttons.py
+++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py
@@ -100,6 +100,15 @@ def set_decrement_enabled(self, enabled: bool) -> None:
if self._decrement_button is not None:
self._decrement_button.set_enabled(enabled)
+ def set_enabled(self, enabled: bool) -> None:
+ """Takes presses on both buttons, or holds them and ends a hold under way."""
+ for button in (self._decrement_button, self._increment_button):
+ if button is not None:
+ button.set_enabled(enabled)
+
+ if not enabled:
+ self._press.end()
+
def delete(self) -> None:
"""Removes the buttons and, when hold-repeat is armed, the shared mouse handler."""
self._clear_existing_items()
diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py
index a7a4d1bd7..1afcddf4a 100644
--- a/src/sampletones_application/ui/elements/table/caret.py
+++ b/src/sampletones_application/ui/elements/table/caret.py
@@ -1,4 +1,5 @@
-from typing import Any, Optional, Tuple
+from dataclasses import dataclass
+from typing import Any, Final, Optional, Tuple
import dearpygui.dearpygui as dpg
@@ -7,11 +8,14 @@
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.utils.gui.dpg import dpg_get_item_parent
from sampletones_shared.meta import NonInstantiableMeta
-from sampletones_shared.types.application import Sender
+from sampletones_shared.types.application import ColorRGBA, Sender
Box = Tuple[float, float, float, float]
-
+STILL: Final[float] = 0.0
+UNFILLED: Final[ColorRGBA] = (0, 0, 0, 0)
+FRAME_THICKNESS: Final[float] = 1.0
+MARK_CLEARANCE: Final[float] = 1.0
_ANCESTOR_WALK_LIMIT = 32
@@ -27,31 +31,46 @@ def _as_item_id(item: Sender) -> Sender:
return item
+@dataclass(frozen=True)
+class CaretBoxes:
+ """Where the caret draws this frame: the mark under the active character and the frame around its cell."""
+
+ mark: Box
+ frame: Box
+
+
class CaretOverlay(metaclass=NonInstantiableMeta):
- """A single-character translucent box marking the tracker edit cursor.
-
- Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a
- whole, so a single rectangle is drawn on a front viewport drawlist at the active
- character's position and redrawn every frame (from the application loop) so it
- follows the table as it scrolls. Because at
- most one cell across both tracker tables holds the cursor at a time, one
- shared rectangle is enough; the ``owner`` token keeps the order and tracker
- panels' arm/clear calls from clobbering each other during focus hand-off.
+ """A mark under the character the tracker edits and a frame around the cell it stands in.
+
+ Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a whole, so the
+ mark is drawn on a front viewport drawlist at the active character's position and redrawn
+ every frame (from the application loop) so it follows the table as it scrolls. The mark is an
+ underline, so the glyph it stands under stays whole; the frame is an outline, so the cell's own
+ wash can stay faint without the cursor getting lost in a full grid. Because at most one cell
+ across both tracker tables holds the cursor at a time, one shared pair of rectangles is
+ enough; the ``owner`` token keeps the order and tracker panels' arm/clear calls from clobbering
+ each other during focus hand-off.
+
+ A grid that re-centers the row it moved to asks for a scroll the table answers a frame later
+ than the rectangles it reports, so the panel hands the overlay the distance that scroll will
+ carry the row (``scroll_shift``), which the next redraw takes off the boxes and then forgets.
"""
_layout: Optional[CaretLayout] = None
- _rectangle: Optional[Sender] = None
+ _mark: Optional[Sender] = None
+ _frame: Optional[Sender] = None
_owner: Optional[Any] = None
_widget: Optional[Sender] = None
_caret_index: int = 0
_font: Optional[Font] = None
_clip_widget: Optional[Sender] = None
+ _scroll_shift: float = STILL
_root_window: Optional[str] = None
@classmethod
def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None:
- """Creates the (hidden) overlay rectangle. Call once after the viewport exists.
+ """Creates the (hidden) overlay rectangles. Call once after the viewport exists.
``root_window_tag`` is the primary window that hosts the tracker tables. The caret
shows only while the active window sits within that window's tree, so a dialog or
@@ -61,12 +80,21 @@ def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None:
cls._layout = layout
cls._root_window = root_window_tag
drawlist = dpg.add_viewport_drawlist(front=True)
- cls._rectangle = dpg.draw_rectangle(
+ cls._frame = dpg.draw_rectangle(
(0.0, 0.0),
(0.0, 0.0),
parent=drawlist,
- fill=layout.fill.rgba,
- color=layout.border.rgba,
+ fill=UNFILLED,
+ color=layout.frame.rgba,
+ thickness=FRAME_THICKNESS,
+ show=False,
+ )
+ cls._mark = dpg.draw_rectangle(
+ (0.0, 0.0),
+ (0.0, 0.0),
+ parent=drawlist,
+ fill=layout.color.rgba,
+ color=UNFILLED,
show=False,
)
@@ -79,11 +107,14 @@ def set_target(
caret_index: int,
font: Font,
clip_widget: Sender,
+ scroll_shift: float,
) -> None:
"""Arms the caret on ``widget`` at character ``caret_index``.
- ``clip_widget`` is the scrolling table the cell lives in; the box is
- clamped to its on-screen rectangle so it stays within the table.
+ ``clip_widget`` is the scrolling table the cell lives in; the boxes are clamped to its
+ on-screen rectangle so they stay within the table. ``scroll_shift`` is how far a scroll
+ already asked for will carry the cell before the next frame is drawn, ``STILL`` where
+ none is pending.
"""
if widget is None:
cls.clear(owner)
@@ -94,6 +125,7 @@ def set_target(
cls._caret_index = caret_index
cls._font = font
cls._clip_widget = clip_widget
+ cls._scroll_shift = scroll_shift
@classmethod
def clear(cls, owner: Any) -> None:
@@ -107,36 +139,49 @@ def clear(cls, owner: Any) -> None:
@classmethod
def redraw(cls) -> None:
- """Repositions the box for the current frame. Cheap no-op when disarmed.
+ """Repositions the boxes for the current frame. Cheap no-op when disarmed.
- Hides the box while the active window sits outside the tracker's window tree (a
+ Hides the boxes while the active window sits outside the tracker's window tree (a
dialog or another window holds focus), keeping the armed state so the caret returns
to the same cell once focus comes back.
"""
- if cls._rectangle is None or cls._layout is None:
+ if cls._mark is None or cls._frame is None or cls._layout is None:
return
if not cls._active_within_root():
cls._hide()
return
- box = cls._compute_box()
- if box is None:
+ boxes = cls._compute_boxes()
+ cls._scroll_shift = STILL
+ if boxes is None:
cls._hide()
return
- pmin, pmax = (box[0], box[1]), (box[2], box[3])
dpg.configure_item(
- cls._rectangle,
- pmin=pmin,
- pmax=pmax,
- fill=cls._layout.fill.rgba,
- color=cls._layout.border.rgba,
+ cls._frame,
+ pmin=(boxes.frame[0], boxes.frame[1]),
+ pmax=(boxes.frame[2], boxes.frame[3]),
+ color=cls._layout.frame.rgba,
+ show=True,
+ )
+ dpg.configure_item(
+ cls._mark,
+ pmin=(boxes.mark[0], boxes.mark[1]),
+ pmax=(boxes.mark[2], boxes.mark[3]),
+ fill=cls._layout.color.rgba,
show=True,
)
@classmethod
- def _compute_box(cls) -> Optional[Box]:
+ def _compute_boxes(cls) -> Optional[CaretBoxes]:
+ """The mark and the frame for this frame, or None while the cell is off screen or empty.
+
+ The mark spans one character of the cell's label, measured from the label's own width
+ in the cell's font, and stands under the text band, which the cell centers vertically.
+ The frame takes the cell the widget belongs to, the group that holds its slots, and the
+ widget's own rectangle where the widget stands alone in its cell.
+ """
widget = cls._widget
if widget is None or cls._font is None or cls._layout is None:
return None
@@ -161,10 +206,36 @@ def _compute_box(cls) -> Optional[Box]:
x0, y0, _, y1 = cell
char_width = size[0] / len(text)
- caret_x0 = x0 + cls._layout.offset + cls._caret_index * char_width
- caret_x1 = caret_x0 + char_width + cls._layout.width_padding
+ mark_x0 = x0 + cls._caret_index * char_width
+ mark_x1 = mark_x0 + char_width
+ text_bottom = (y0 + y1 + size[1]) / 2
+ mark_y1 = text_bottom + MARK_CLEARANCE
+ mark_y0 = mark_y1 - cls._layout.height
+
+ mark = cls._clip(cls._shifted((mark_x0, mark_y0, mark_x1, mark_y1)))
+ frame = cls._clip(cls._shifted(cls._cell_box(widget, cell)))
+ if mark is None or frame is None:
+ return None
+
+ return CaretBoxes(mark=mark, frame=frame)
- return cls._clip((caret_x0, y0, caret_x1, y1))
+ @classmethod
+ def _cell_box(cls, widget: Sender, own: Box) -> Box:
+ """The cell the frame outlines: the parent group's rectangle, or the widget's own where the parent reports
+ none.
+ """
+ parent = dpg_get_item_parent(widget)
+ if parent is None:
+ return own
+
+ group = cls._rect(parent)
+ return group if group is not None else own
+
+ @classmethod
+ def _shifted(cls, box: Box) -> Box:
+ """``box`` carried by the scroll the table is about to answer."""
+ shift = cls._scroll_shift
+ return (box[0], box[1] - shift, box[2], box[3] - shift)
@classmethod
def _clip(cls, box: Box) -> Optional[Box]:
@@ -241,5 +312,6 @@ def _active_within_root(cls) -> bool:
@classmethod
def _hide(cls) -> None:
- if cls._rectangle is not None and dpg.does_item_exist(cls._rectangle):
- dpg.configure_item(cls._rectangle, show=False)
+ for rectangle in (cls._mark, cls._frame):
+ if rectangle is not None and dpg.does_item_exist(rectangle):
+ dpg.configure_item(rectangle, show=False)
diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py
index 74f737998..02d22a7ec 100644
--- a/src/sampletones_application/ui/menu.py
+++ b/src/sampletones_application/ui/menu.py
@@ -594,6 +594,10 @@ def _create_auto_expand_favorites_menu(self) -> None:
def _create_help_menu(self) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_HELP)):
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.DOCUMENTATION,
+ label=self._label(MenuElements.ITEM_HELP_DOCUMENTATION),
+ )
self._shortcut_manager.add_menu_item(
ShortcutId.ABOUT_DIALOG,
label=self._label(MenuElements.ITEM_HELP_ABOUT),
diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
index 17d1bbd9e..9a74c4a34 100644
--- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
+++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
@@ -41,7 +41,7 @@
)
from sampletones_application.tags.reconstructions import (
SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE,
- SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE,
+ SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE,
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW,
TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT,
TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL,
@@ -88,6 +88,7 @@
ChannelEnvelopesViewModel,
)
from sampletones_application.view_model.reconstruction.instruments import (
+ InstrumentsShowing,
ReconstructionInstrumentsViewModel,
)
from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
@@ -155,7 +156,7 @@ def __init__(
self._export_buttons: Dict[ChannelName, GUIButton] = {}
self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR
- self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE)
+ self.message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_MESSAGE)
self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY)
self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE
self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP)
@@ -187,6 +188,8 @@ def __init__(
self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None
self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"]
+ self._msg_nothing_loaded = language_manager["global.dialog.message.reconstruction_no_data"]
+ self._msg_preparing = language_manager["reconstructions.instruments.message.preparing"]
self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
self._lbl_instrument_size = context_label(language_manager, ContextElements.INSTRUMENT_SIZE)
self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES)
@@ -236,9 +239,9 @@ def create_panel(self, parent: str) -> None:
def _create_content(self) -> None:
dpg.add_text(
- tag=self.no_data_message_tag,
+ tag=self.message_tag,
parent=self._body_container,
- default_value=self._language_manager["global.dialog.message.reconstruction_no_data"],
+ default_value=self._msg_nothing_loaded,
show=True,
)
@@ -505,13 +508,24 @@ def update_view(
muted tab label and a withheld export say which channels are there. An instrument is one
set every channel reads, so it shows a single tab under its own name, and the audition
takes the pitch stepper's place: a row states the note an instrument sounds at, and
- what the panel offers instead is the generator to hear it on.
+ what the panel offers instead is the generator to hear it on. Words stand in place of the
+ tabs while nothing is open, and while a document just opened waits for its first envelopes
+ they say the instruments are being prepared. Envelopes kept while the next ones are on
+ their way stay drawn as they are, and the panel takes no edit of them.
"""
instrument = view_model.instrument
is_open = view_model.is_open
- dpg_configure_item(self.no_data_message_tag, show=not is_open)
+ dpg_set_value(
+ self.message_tag,
+ self._msg_preparing if view_model.showing is InstrumentsShowing.PREPARING else self._msg_nothing_loaded,
+ )
+ dpg_configure_item(self.message_tag, show=not is_open)
dpg_configure_item(self.tab_bar_tag, show=is_open)
dpg_configure_item(self.sample_size_group_tag, show=is_open)
+ self._lock(view_model.is_locked)
+ if view_model.is_locked:
+ return
+
self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None)
self._show_pitch_steppers(shown=instrument is None)
self._show_audition_selector(shown=instrument is not None)
@@ -530,6 +544,25 @@ def update_view(
channel_name in view_model.playing_channels,
)
+ def _lock(self, locked: bool) -> None:
+ """Takes no edit of the envelopes drawn while the next ones are on their way, and takes edits again once
+ they land.
+
+ Every gesture writing an envelope is held: a bar pressed, a sequence typed, a pitch stepped
+ and an instrument exported. A view that frees the panel marks the exports anew.
+ """
+ for channel_name, plots in self.channel_plots.items():
+ for feature_key, plot in plots.items():
+ plot.set_locked(locked)
+ dpg_configure_item(self._get_feature_text_tag(channel_name, feature_key), enabled=not locked)
+
+ for stepper in self._pitch_steppers.values():
+ stepper.set_enabled(not locked)
+
+ if locked:
+ for export_button in self._export_buttons.values():
+ export_button.set_enabled(False)
+
def _show_pitch_steppers(self, *, shown: bool) -> None:
"""Offers the pitch a channel measures its arpeggio against, where a reader may move it.
diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py
index de9babb3d..46c40eee2 100644
--- a/src/sampletones_application/ui/panels/reconstruction/plot.py
+++ b/src/sampletones_application/ui/panels/reconstruction/plot.py
@@ -31,6 +31,7 @@
from sampletones_application.view_model.reconstruction.waveform import (
InstrumentWaveformViewModel,
)
+from sampletones_application.view_model.reconstruction.work import Work
from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel
from sampletones_application.view_model.shared.waveform_data import WaveformData
from sampletones_core.constants.enums import AudioSourceType, ChannelName
@@ -141,22 +142,17 @@ def _show_recording_controls(self, *, shown: bool) -> None:
dpg_configure_item(self.autoscale_tag, show=shown)
dpg_configure_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, show=shown)
- def load_waveform_data(
- self,
- waveform_data: WaveformData,
- generators: List[ChannelName],
- ) -> None:
+ def load_waveform_data(self, waveform_data: WaveformData) -> None:
self._frame_length = waveform_data.frame_length
- self.waveform_display.load_waveform_data(waveform_data, generators)
+ self.waveform_display.load_waveform_data(waveform_data)
def update_waveform_data(
self,
waveform_data: WaveformData,
- generators: List[ChannelName],
*,
refit: bool = False,
) -> None:
- self.waveform_display.update_waveform_data(waveform_data, generators, refit=refit)
+ self.waveform_display.update_waveform_data(waveform_data, refit=refit)
def clear_waveform(self) -> None:
self._frame_length = None
@@ -166,8 +162,8 @@ def clear_waveform(self) -> None:
def set_waveform_top_source(self, audio_source: AudioSourceType) -> None:
self.waveform_display.set_top_source(audio_source)
- def set_reconstruction_dimmed(self, dimmed: bool) -> None:
- self.waveform_display.set_reconstruction_dimmed(dimmed)
+ def show_work(self, work: Optional[Work]) -> None:
+ self.waveform_display.show_work(work)
def set_overlay(self, index: Optional[int]) -> None:
if self._frame_length is None:
diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py
index 4996e37ca..356361afc 100644
--- a/src/sampletones_application/ui/panels/sequencer/order/panel.py
+++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py
@@ -32,7 +32,7 @@
GUIPlusMinusButtons,
PlusMinusOrder,
)
-from sampletones_application.ui.elements.table.caret import CaretOverlay
+from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay
from sampletones_application.ui.elements.table.cells import EditableCells, pending_label
from sampletones_application.ui.elements.table.selection import TableSelection
from sampletones_application.ui.panels.sequencer.channels import (
@@ -860,6 +860,7 @@ def _update_caret(self) -> None:
caret_index=len(self._input_state.pending),
font=font,
clip_widget=TAG_SEQUENCER_ORDER_WINDOW,
+ scroll_shift=STILL,
)
def _on_cell_clicked(
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/move.py b/src/sampletones_application/ui/panels/sequencer/tracker/move.py
new file mode 100644
index 000000000..178d9604f
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/move.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
+from sampletones_core.constants.enums import ChannelName
+
+CellPlace = Tuple[int, Optional[ChannelName]]
+
+
+def cell_place(cursor: Optional[TrackerCursor]) -> Optional[CellPlace]:
+ """The cell a cursor stands in, its row and its channel, or None for no cursor."""
+ if cursor is None:
+ return None
+
+ return (cursor.row, cursor.channel)
+
+
+@dataclass(frozen=True)
+class CursorMove:
+ """The painting a change of the cursor owes the grid: the cell it left and the cell it reached.
+
+ Attributes:
+ left: The cell the cursor stood in before, or None where it stood in none.
+ reached: The cell the cursor stands in now, or None where it stands in none.
+ """
+
+ left: Optional[CellPlace]
+ reached: Optional[CellPlace]
+
+ def then(self, move: CursorMove) -> CursorMove:
+ """This move carried on by ``move``: the cell first left and the cell last reached."""
+ return CursorMove(left=self.left, reached=move.reached)
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py
index 71010bc97..c0aeed0ba 100644
--- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py
@@ -1,4 +1,6 @@
-from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple
+from contextlib import contextmanager
+from functools import partial
+from typing import Callable, Dict, Final, FrozenSet, Iterator, Optional, Set, Tuple
import dearpygui.dearpygui as dpg
@@ -32,7 +34,7 @@
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
-from sampletones_application.ui.elements.table.caret import CaretOverlay
+from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay
from sampletones_application.ui.elements.table.cells import EditableCells
from sampletones_application.ui.elements.table.selection import TableSelection
from sampletones_application.ui.panels.sequencer import display as tracker_display
@@ -106,6 +108,7 @@
)
from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows
from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
+from sampletones_application.ui.panels.sequencer.tracker.move import CursorMove, cell_place
from sampletones_application.ui.panels.sequencer.tracker.row import (
add_empty_cell,
add_slot_group,
@@ -177,9 +180,14 @@
from sampletones_shared.types.callback import VoidCallback
PLAYHEAD_PAINT_FRAMES: Final[int] = 1
+CURSOR_PAINT_FRAMES: Final[int] = 1
class GUISequencerTrackerPanel(GUIPanel):
+ _input_state: TrackerInputState = TrackerInputState()
+ _paint_held: bool = False
+ _held_move: Optional[CursorMove] = None
+
def __init__(
self,
initial_settings: SequencerSettingsViewModel,
@@ -950,7 +958,7 @@ def _update_cursor(self) -> None:
self._input_state = TrackerInputState()
self._selection.repaint()
- self._update_caret()
+ self._update_caret(STILL)
def deselect_cell(self) -> None:
cursor = self._input_state.cursor
@@ -959,33 +967,93 @@ def deselect_cell(self) -> None:
self._remove_cell_highlight(cursor.row, cursor.channel)
self._selection.repaint()
- self._update_caret()
+ self._update_caret(STILL)
def _apply_state(self, new_state: TrackerInputState) -> None:
- old_cursor = self._input_state.cursor
- new_cursor = new_state.cursor
-
- old_pos = (old_cursor.row, old_cursor.channel) if old_cursor is not None else None
- new_pos = (new_cursor.row, new_cursor.channel) if new_cursor is not None else None
+ """Takes the new input state and paints the cursor's move, at once or when the held painting is released.
+ The cells the cursor leaves and reaches are reported as soon as the state changes; their
+ painting waits while a gesture holds it (see :meth:`_holding_paint`), so a move that also
+ re-centers the grid lands on screen together with the scroll it asked for.
+ """
+ move = CursorMove(left=cell_place(self._input_state.cursor), reached=cell_place(new_state.cursor))
self._input_state = new_state
- if old_pos != new_pos and old_cursor is not None:
- self._remove_cell_highlight(old_cursor.row, old_cursor.channel)
+ if move.reached != move.left and move.reached is not None:
+ self.call(self.on_cell_selected)
- if old_cursor is not None:
- self._update_cell_display(old_cursor.row, old_cursor.channel)
+ if self._paint_held:
+ self._held_move = move if self._held_move is None else self._held_move.then(move)
+ else:
+ self._paint_move(move, STILL)
- if new_cursor is not None:
- if old_pos != new_pos:
- self._apply_cell_highlight(new_cursor.row, new_cursor.channel)
- self._update_cell_display(new_cursor.row, new_cursor.channel)
+ @contextmanager
+ def _holding_paint(self) -> Iterator[None]:
+ """Holds the painting of every cursor change made inside, for :meth:`_release_paint` to paint as one.
- if new_pos != old_pos and new_cursor is not None:
- self.call(self.on_cell_selected)
+ A hold belongs to the gesture that opens it, so between gestures the panel stands at its
+ class's rest: nothing held and nothing owed.
+ """
+ self._paint_held = True
+ try:
+ yield
+ finally:
+ self._paint_held = False
+
+ def _release_paint(self, *, recentering: bool) -> None:
+ """Paints the held cursor move: now, or on the frame the scroll of a re-centering grid lands on.
+
+ A cell's mark is drawn on the very next frame while the grid answers a scroll on the frame
+ after that, so a move painted as it is made stands a row clear of the band's center for a
+ frame. The painting of a re-centering move is held until its scroll lands and carries the
+ distance that scroll moves the row, which the caret takes off the rectangle the cell
+ reported a frame earlier.
+ """
+ landing = self._center_followed_row() if recentering else None
+ if landing is None:
+ self._paint_held_move(STILL)
+ return
+
+ FrameCallbackManager.set_frame_callback(partial(self._paint_landing_move, landing), CURSOR_PAINT_FRAMES)
+
+ def _paint_landing_move(self, landing: float) -> None:
+ """Paints the held move for the frame on which the grid's scroll reaches ``landing``."""
+ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
+ return
+
+ self._paint_held_move(landing - dpg.get_y_scroll(TAG_SEQUENCER_TRACKER_TABLE))
+
+ def _paint_held_move(self, scroll_shift: float) -> None:
+ move = self._held_move
+ self._held_move = None
+ if move is not None:
+ self._paint_move(move, scroll_shift)
+
+ def _paint_move(self, move: CursorMove, scroll_shift: float) -> None:
+ """Marks the cell the cursor reached, clears the one it left, and redraws both cells' labels.
+
+ A cell of a row the table no longer holds is left alone: the grid was rebuilt under the
+ move, and the rebuild painted the cursor where it stands.
+ """
+ left = move.left if move.left is not None and self._holds_row(move.left[0]) else None
+ reached = move.reached if move.reached is not None and self._holds_row(move.reached[0]) else None
+
+ if left is not None and left != reached:
+ self._remove_cell_highlight(*left)
+
+ if left is not None:
+ self._update_cell_display(*left)
+
+ if reached is not None:
+ if reached != left:
+ self._apply_cell_highlight(*reached)
+ self._update_cell_display(*reached)
self._selection.repaint()
- self._update_caret()
+ self._update_caret(scroll_shift)
+
+ def _holds_row(self, row_index: int) -> bool:
+ return row_index < self._current_row_count
def update_samples(self, view_model: SequencerVoicesViewModel) -> None:
self._current_samples = view_model
@@ -1035,8 +1103,12 @@ def _cell_theme(self, key: CellKey) -> int:
A voice slot takes the color of the kind of voice standing in it, so a reader tells a
sample from an instrument across the whole grid; a slot naming none takes the
- neutral shade the other slots' colors are read against.
+ neutral shade the other slots' colors are read against. The slot an entry is being
+ typed into wears the pending color instead, so the digits being written read first.
"""
+ if self._is_pending_cell(key):
+ return self._themes.pending
+
_, channel, subcolumn = key
return self._themes.cell(
subcolumn,
@@ -1044,6 +1116,15 @@ def _cell_theme(self, key: CellKey) -> int:
muted=self._is_muted_cell(channel),
)
+ def _is_pending_cell(self, key: CellKey) -> bool:
+ """Whether an entry is being typed into the slot ``key`` names."""
+ cursor = self._input_state.cursor
+ return (
+ bool(self._input_state.pending)
+ and cursor is not None
+ and (cursor.row, cursor.channel, cursor.subcolumn) == key
+ )
+
def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool:
"""Whether a cell's channel is silenced, the sample column speaking for every channel."""
return channel is not None and self._is_muted(channel)
@@ -1059,14 +1140,22 @@ def _update_cell_display(
row: int,
channel: Optional[ChannelName],
) -> None:
+ """Redraws a cell's three slots, label and theme, as the input state now has them.
+
+ The theme comes with the label because a slot changes color as an entry starts being typed
+ into it and again as the entry commits or is let go.
+ """
for subcolumn in SubColumn:
key = (row, channel, subcolumn)
cell_id = self._editable_cells.widget(key)
if cell_id is not None:
dpg.configure_item(cell_id, label=self._render_cell(key))
+ dpg.bind_item_theme(cell_id, self._cell_theme(key))
- def _update_caret(self) -> None:
- """Arms (or clears) the shared caret box on the active subcolumn cell."""
+ def _update_caret(self, scroll_shift: float) -> None:
+ """Arms (or clears) the shared caret on the active subcolumn cell, ``scroll_shift`` ahead of a landing
+ scroll.
+ """
cursor = self._input_state.cursor
if cursor is None:
CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE)
@@ -1079,6 +1168,7 @@ def _update_caret(self) -> None:
caret_index=len(self._input_state.pending),
font=slot_font(cursor.channel),
clip_widget=TAG_SEQUENCER_TRACKER_WINDOW,
+ scroll_shift=scroll_shift,
)
def _resolve_voice(
@@ -1540,11 +1630,11 @@ def _on_key_pressed(self, event: KeyEvent) -> bool:
if cursor is None:
return False
- consumed = self._answer_key(event, cursor)
- moved = self._input_state.cursor
- if consumed and moved is not None and moved.row != cursor.row:
- self._center_followed_row()
+ with self._holding_paint():
+ consumed = self._answer_key(event, cursor)
+ moved = self._input_state.cursor
+ self._release_paint(recentering=consumed and moved is not None and moved.row != cursor.row)
return consumed
def _answer_key(self, event: KeyEvent, cursor: TrackerCursor) -> bool:
@@ -1663,8 +1753,10 @@ def _select(self, new_state: TrackerInputState) -> None:
A shape ends at the frame's last row, so the band carries the end the cursor now holds —
the same landing a Shift+End reach makes, whether a key or a menu item selected it.
"""
- self._apply_state(new_state)
- self._center_followed_row()
+ with self._holding_paint():
+ self._apply_state(new_state)
+
+ self._release_paint(recentering=True)
def _block_action(self, shortcut_id: ShortcutId) -> bool:
"""Acts on the selected block, reporting whether the action was one of its gestures.
@@ -1802,30 +1894,31 @@ def _followed_row(self) -> Optional[int]:
cursor = self._input_state.cursor
return cursor.row if cursor is not None else None
- def _center_followed_row(self) -> None:
- """Brings the row the band follows to its center, where it follows one."""
+ def _center_followed_row(self) -> Optional[float]:
+ """Brings the row the band follows to its center, where it follows one, and answers the scroll asked for."""
row_index = self._followed_row
- if row_index is not None:
- self._center_row(row_index)
+ if row_index is None:
+ return None
+
+ return self._center_row(row_index)
- def _center_row(self, row_index: int) -> None:
- """Scrolls the grid so a pattern row stands at the band's center.
+ def _center_row(self, row_index: int) -> Optional[float]:
+ """Scrolls the grid so a pattern row stands at the band's center, and answers the scroll asked for.
The table holds as many rows of the song either side of the frame as stand above the
band's center, so every row of the frame has the room to reach it, and the scroll is worked
out from the rows' height. A band awaiting its first measurement, or a table holding no
- frame, stays as it stands.
+ frame, stays as it stands, which answers None.
"""
if self._band.height <= UNMEASURED_BAND or self._current_row_count == 0:
- return
+ return None
if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
- return
+ return None
- dpg.set_y_scroll(
- TAG_SEQUENCER_TRACKER_TABLE,
- self._band.centering(self._rows_layout.body_row(row_index)),
- )
+ landing = self._band.centering(self._rows_layout.body_row(row_index))
+ dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, landing)
+ return landing
def _row_top(self, row_index: int) -> Optional[float]:
"""Where a pattern row's top edge stands, in the coordinates the viewport is drawn in."""
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py
index eeddb942c..aa59a7670 100644
--- a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py
@@ -30,6 +30,7 @@ def __init__(self, layout: SequencerLayout) -> None:
self._subcolumn: Dict[ThemeKey, int] = {}
self._muted_subcolumn: Dict[ThemeKey, int] = {}
self._context_subcolumn: Dict[ThemeKey, int] = {}
+ self._pending: int = UNBUILT_THEME
self._header: int = UNBUILT_THEME
self._muted_header: int = UNBUILT_THEME
self._column_label: int = UNBUILT_THEME
@@ -39,6 +40,7 @@ def __init__(self, layout: SequencerLayout) -> None:
def create(self) -> None:
"""Builds every theme the grid binds, which a DearPyGui context has to stand behind."""
self._create_subcolumn_themes()
+ self._pending = create_selectable_text_theme(self._layout.colors.text.pending)
self._create_header_themes()
row = self._layout.colors.text.row
self._row_number = create_selectable_text_theme(row)
@@ -113,6 +115,11 @@ def cell(
themes = self._muted_subcolumn if muted else self._subcolumn
return themes[(subcolumn, kind)]
+ @property
+ def pending(self) -> int:
+ """The theme a cell wears while an entry is being typed into it: the plain text color, whatever the channel."""
+ return self._pending
+
def context_cell(self, subcolumn: SubColumn, kind: Optional[VoiceKind]) -> int:
"""The theme a cell of another frame's row wears: its slot's color, dimmed, as a plain label."""
return self._context_subcolumn[(subcolumn, kind)]
diff --git a/src/sampletones_application/ui/resources/loader.py b/src/sampletones_application/ui/resources/loader.py
index 327a1fa7c..808e588d4 100644
--- a/src/sampletones_application/ui/resources/loader.py
+++ b/src/sampletones_application/ui/resources/loader.py
@@ -1,11 +1,9 @@
from pathlib import Path
-from typing import Final
from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ASSETS_PACKAGE
from sampletones_shared.types.path import Pathlike
-ASSETS_PACKAGE: Final[str] = "sampletones_assets"
-
class ResourceLoader:
"""Reads the files of one directory of the assets package, where the package lies in every copy."""
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index 5949fa7ab..ef61eb2b5 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Dict, Final, List, Optional, Set
+from typing import Dict, List
from sampletones_application.ui.themes.dpg_constants import (
CATEGORY_MAP,
@@ -16,11 +16,6 @@
ThemeEntryKey,
ThemeItems,
)
-from sampletones_application.ui.themes.spec import (
- ThemeColorEntrySpec,
- ThemeEntrySpec,
- ThemeSpec,
-)
from sampletones_application.ui.themes.style import (
ThemeColor,
ThemeParameter,
@@ -28,37 +23,35 @@
ThemeValue,
)
from sampletones_application.ui.themes.theme import Theme
-from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
from sampletones_application.utils.palette.source import PaletteSource
-from sampletones_shared.paths.extensions import EXT_FILE_YAML
-from sampletones_shared.utils.serialization import load_yaml
-
-_BASE_THEME_NAME: Final[str] = "default"
+from sampletones_application.utils.theme.catalog import ThemeSpecCatalog
+from sampletones_application.utils.theme.spec import (
+ ThemeColorEntrySpec,
+ ThemeEntrySpec,
+ ThemeSpec,
+)
class ThemeLoader:
"""Resolves the theme YAML directory into complete runtime themes.
- Each spec composes on top of its parent (the base theme by default), and every
- enabled-state entry gains a disabled-state counterpart, so a loaded theme fully
- describes both item states and stays authoritative wherever it is bound.
+ The specs come from :class:`ThemeSpecCatalog`, parents first. Each spec composes on top of
+ its parent (the base theme by default), and every enabled-state entry gains a disabled-state
+ counterpart, so a loaded theme fully describes both item states and stays authoritative
+ wherever it is bound.
"""
def __init__(self, theme_directory: Path, palette_source: PaletteSource) -> None:
self._directory = theme_directory
- self._context: Dict[str, PaletteSource] = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
+ self._palette_source = palette_source
def load_all(self) -> List[Theme]:
- specs = self._load_specs()
- name_index = {spec.name: spec for spec in specs}
- self._check_for_cycles(name_index)
- ordered = self._topological_sort(specs, name_index)
-
+ catalog = ThemeSpecCatalog.load(self._directory, self._palette_source)
resolved: Dict[str, ThemeEntries] = {}
themes: List[Theme] = []
- for spec in ordered:
+ for spec in catalog.ordered:
entries = self._spec_to_entries(spec)
- parent = self._effective_parent(spec)
+ parent = spec.parent
merged = {**resolved[parent], **entries} if parent is not None else entries
resolved[spec.name] = merged
items = self._entries_to_items(self._mirror_disabled_entries(merged))
@@ -66,36 +59,6 @@ def load_all(self) -> List[Theme]:
return themes
- def _load_specs(self) -> List[ThemeSpec]:
- specs: List[ThemeSpec] = []
- for path in sorted(self._directory.rglob(f"*{EXT_FILE_YAML}")):
- raw = load_yaml(path)
- if not isinstance(raw, dict):
- raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}")
-
- specs.append(ThemeSpec.model_validate(raw, context=self._context))
-
- return specs
-
- @staticmethod
- def _effective_parent(spec: ThemeSpec) -> Optional[str]:
- """The theme a spec composes on top of.
-
- A spec inherits from its explicit ``extends`` when given, and otherwise from
- the base theme, so every theme carries the shared base's complete component
- set and states only its own overrides. The base theme itself stands alone.
- This makes inheritance the default: a bound item theme always resolves to
- the global base plus its overrides, so every color it omits keeps the
- base's value.
- """
- if spec.extends is not None:
- return spec.extends
-
- if spec.name != _BASE_THEME_NAME:
- return _BASE_THEME_NAME
-
- return None
-
@staticmethod
def _resolve_color_key(key: str, category: str) -> int:
color_map = PLOTS_COLOR_MAP if category == "Plots" else CORE_COLOR_MAP
@@ -238,46 +201,3 @@ def _entries_to_items(entries: ThemeEntries) -> ThemeItems:
ordered = sorted(grouped, key=lambda parameter: parameter.item_type != EVERY_ITEM_TYPE)
return ThemeItems(items={parameter: grouped[parameter] for parameter in ordered})
-
- @classmethod
- def _check_for_cycles(cls, name_index: Dict[str, ThemeSpec]) -> None:
- for name in name_index:
- visited: Set[str] = set()
- current: Optional[str] = name
- while current is not None:
- if current in visited:
- raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}")
-
- visited.add(current)
- spec = name_index.get(current)
- if spec is None:
- raise ValueError(
- f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(name_index)}"
- )
-
- current = cls._effective_parent(spec)
-
- @classmethod
- def _topological_sort(
- cls,
- specs: List[ThemeSpec],
- name_index: Dict[str, ThemeSpec],
- ) -> List[ThemeSpec]:
- ordered: List[ThemeSpec] = []
- visited: Set[str] = set()
-
- def visit(spec: ThemeSpec) -> None:
- if spec.name in visited:
- return
-
- parent = cls._effective_parent(spec)
- if parent is not None:
- visit(name_index[parent])
-
- visited.add(spec.name)
- ordered.append(spec)
-
- for spec in specs:
- visit(spec)
-
- return ordered
diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py
deleted file mode 100644
index 3eeb63711..000000000
--- a/src/sampletones_application/ui/themes/spec.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from __future__ import annotations
-
-from typing import Annotated, List, Literal, Optional, Union
-
-from pydantic import BaseModel, Field
-
-from sampletones_application.utils.palette.colors.written import WrittenColor
-
-
-class ThemeColorEntrySpec(BaseModel, frozen=True):
- type: Literal["color"]
- key: str
- value: WrittenColor
- category: str = "Core"
-
-
-class ThemeStyleEntrySpec(BaseModel, frozen=True):
- type: Literal["style"]
- key: str
- x: float
- y: float = -1.0
- category: str = "Core"
-
-
-ThemeEntrySpec = Annotated[
- Union[ThemeColorEntrySpec, ThemeStyleEntrySpec],
- Field(discriminator="type"),
-]
-
-
-class ThemeComponentSpec(BaseModel, frozen=True):
- item_type: str
- enabled: bool = True
- entries: List[ThemeEntrySpec]
-
-
-class ThemeSpec(BaseModel, frozen=True):
- name: str
- tag: str
- extends: Optional[str] = None
- components: List[ThemeComponentSpec]
diff --git a/src/sampletones_application/utils/callbacks/latest.py b/src/sampletones_application/utils/callbacks/latest.py
new file mode 100644
index 000000000..865ed899b
--- /dev/null
+++ b/src/sampletones_application/utils/callbacks/latest.py
@@ -0,0 +1,59 @@
+import threading
+from functools import partial
+from typing import Callable, Generic, Optional, ParamSpec
+
+from sampletones_application.utils.callbacks.queue import CallbackQueue
+from sampletones_shared.types.callback import VoidCallback
+
+ReportParameters = ParamSpec("ReportParameters")
+
+
+class LatestDelivery(Generic[ReportParameters]):
+ """Carries a run's reports to the render thread, where the newest one stands for those before it.
+
+ A run reports where it stands as often as it moves, from whichever thread moves it, and each
+ report states the whole of that reading. One delivery therefore waits in the queue at a time,
+ and the drain that reaches it hands ``handler`` the newest report filed. A run filing thousands
+ of reports between two frames costs the render thread one handling per drain.
+
+ The delivery takes its place in the queue with the first report it carries, so whatever is
+ posted after that report, such as the run's outcome, still runs after it.
+ """
+
+ def __init__(
+ self,
+ handler: Callable[ReportParameters, None],
+ *,
+ priority: int,
+ ) -> None:
+ """Holds the handler the newest report reaches.
+
+ Args:
+ handler: What takes a report on the render thread.
+ priority: The priority the delivery waits in the queue at, which the run's other
+ results share so they keep their order.
+ """
+ self._handler = handler
+ self._priority = priority
+ self._lock = threading.Lock()
+ self._newest: Optional[VoidCallback] = None
+
+ def post(
+ self,
+ *args: ReportParameters.args,
+ **kwargs: ReportParameters.kwargs,
+ ) -> None:
+ """Files a report from any thread, which supersedes a report still waiting for the render thread."""
+ with self._lock:
+ waiting = self._newest is not None
+ self._newest = partial(self._handler, *args, **kwargs)
+
+ if not waiting:
+ CallbackQueue.add(self._deliver, priority=self._priority)
+
+ def _deliver(self) -> None:
+ with self._lock:
+ newest, self._newest = self._newest, None
+
+ if newest is not None:
+ newest()
diff --git a/src/sampletones_application/utils/callbacks/priority.py b/src/sampletones_application/utils/callbacks/priority.py
index a72cb8899..2cef62aa2 100644
--- a/src/sampletones_application/utils/callbacks/priority.py
+++ b/src/sampletones_application/utils/callbacks/priority.py
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Tuple
+from typing import Final, Tuple
+
+DEFAULT_PRIORITY: Final[int] = 0
@dataclass(frozen=True)
diff --git a/src/sampletones_application/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py
index 3f4c7f322..7c47220b8 100644
--- a/src/sampletones_application/utils/callbacks/queue.py
+++ b/src/sampletones_application/utils/callbacks/queue.py
@@ -6,7 +6,7 @@
from typing import Any, ClassVar, List
from sampletones_application.utils.callbacks.failures import UnhandledFailures
-from sampletones_application.utils.callbacks.priority import CallbackPriority
+from sampletones_application.utils.callbacks.priority import DEFAULT_PRIORITY, CallbackPriority
from sampletones_application.utils.callbacks.task import CallbackTask
from sampletones_shared.exceptions import CallbackQueueStop
from sampletones_shared.logger import logger
@@ -60,7 +60,7 @@ def add(
cls,
callback: Callback,
*args: Any,
- priority: int = 0,
+ priority: int = DEFAULT_PRIORITY,
delay: int = 0,
**kwargs: Any,
) -> None:
diff --git a/src/sampletones_application/utils/file_dialogs/backends/command.py b/src/sampletones_application/utils/file_dialogs/backends/command.py
index 96cd0da8e..2c04ff3df 100644
--- a/src/sampletones_application/utils/file_dialogs/backends/command.py
+++ b/src/sampletones_application/utils/file_dialogs/backends/command.py
@@ -3,6 +3,7 @@
from typing import List, Optional
from sampletones_shared.utils.system.paths import normalize_path
+from sampletones_shared.utils.system.programs import program_environment
def run_dialog_command(command: List[str]) -> Optional[Path]:
@@ -24,5 +25,6 @@ def run_dialog_command(command: List[str]) -> Optional[Path]:
capture_output=True,
text=True,
check=False,
+ env=program_environment(),
)
return normalize_path(result.stdout.strip())
diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py
index 52aa7003d..c5831dc7a 100644
--- a/src/sampletones_application/utils/gui/shortcuts/ids.py
+++ b/src/sampletones_application/utils/gui/shortcuts/ids.py
@@ -100,6 +100,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
ShortcutCategory.APPLICATION,
)
TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION)
+ DOCUMENTATION = ("Documentation", ShortcutCategory.APPLICATION)
ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION)
NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION)
PREVIOUS_TAB = ("PreviousTab", ShortcutCategory.APPLICATION)
diff --git a/src/sampletones_application/utils/theme/__init__.py b/src/sampletones_application/utils/theme/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/utils/theme/catalog.py b/src/sampletones_application/utils/theme/catalog.py
new file mode 100644
index 000000000..9d3719f38
--- /dev/null
+++ b/src/sampletones_application/utils/theme/catalog.py
@@ -0,0 +1,198 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Dict, Iterator, List, Optional, Set, Tuple
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.utils.theme.spec import (
+ EVERY_ITEM_TYPE_NAME,
+ ThemeColorEntrySpec,
+ ThemeEntrySpec,
+ ThemeSpec,
+ ThemeStyleEntrySpec,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+from sampletones_shared.utils.serialization import load_yaml
+
+
+@dataclass(frozen=True)
+class ThemeEntryAddress:
+ """Where one entry of a theme sits: the item type it styles, the state it applies in, its
+ category and its key, all as the theme files spell them."""
+
+ item_type: str
+ enabled: bool
+ category: str
+ key: str
+
+
+ThemeEntries = Dict[ThemeEntryAddress, ThemeEntrySpec]
+
+
+class ThemeSpecCatalog:
+ """Every theme the configuration states, each with the entries it wears once composed.
+
+ A theme composes on top of its parent (:attr:`ThemeSpec.parent`), so a theme's entries are
+ its parent's with its own laid over them, and the specs are kept parents first. A lookup
+ answers what an item of a given type and state draws with: the entry its own component
+ states, the enabled state's entry where the disabled state states none, and the component
+ naming every item type where the item's own type states nothing. That is how DearPyGui
+ resolves a bound theme, so a reader of this catalog sees the value the application draws.
+ """
+
+ def __init__(self, specs: List[ThemeSpec]) -> None:
+ self._index = {spec.name: spec for spec in specs}
+ self._check_parents()
+ self._ordered = self._parents_first(specs)
+ self._entries = self._compose()
+
+ @classmethod
+ def load(
+ cls,
+ directory: Path,
+ palette_source: PaletteSource,
+ ) -> ThemeSpecCatalog:
+ """Every theme file under ``directory``, its colors bound to ``palette_source``.
+
+ Raises:
+ TypeError: If a theme file holds something other than a mapping.
+ ValueError: If a theme extends an unknown theme or the inheritance forms a cycle.
+ """
+ context = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
+ specs: List[ThemeSpec] = []
+ for path in sorted(directory.rglob(f"*{EXT_FILE_YAML}")):
+ raw = load_yaml(path)
+ if not isinstance(raw, dict):
+ raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}")
+
+ specs.append(ThemeSpec.model_validate(raw, context=context))
+
+ return cls(specs)
+
+ @property
+ def ordered(self) -> List[ThemeSpec]:
+ """Every theme, each after the theme it composes on."""
+ return list(self._ordered)
+
+ def entries(self, name: str) -> ThemeEntries:
+ """The entries theme ``name`` wears: its parent's, with its own laid over them.
+
+ Raises:
+ KeyError: If no theme carries that name.
+ """
+ return dict(self._entries[name])
+
+ def color(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> BaseColor:
+ """The color theme ``name`` gives the entry at ``address``.
+
+ Raises:
+ KeyError: If the theme states no color there, for that item type or every item type.
+ """
+ entry = self._resolve(name, address)
+ if not isinstance(entry, ThemeColorEntrySpec):
+ raise KeyError(f"Theme {name!r} states a style, not a color, at {address}")
+
+ return entry.value
+
+ def style(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> Tuple[float, float]:
+ """The style value theme ``name`` gives the entry at ``address``, as its two components.
+
+ Raises:
+ KeyError: If the theme states no style there, for that item type or every item type.
+ """
+ entry = self._resolve(name, address)
+ if not isinstance(entry, ThemeStyleEntrySpec):
+ raise KeyError(f"Theme {name!r} states a color, not a style, at {address}")
+
+ return entry.x, entry.y
+
+ def _resolve(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> ThemeEntrySpec:
+ entries = self._entries[name]
+ for candidate in self._candidates(address):
+ if candidate in entries:
+ return entries[candidate]
+
+ raise KeyError(f"Theme {name!r} states nothing at {address}")
+
+ @staticmethod
+ def _candidates(address: ThemeEntryAddress) -> Iterator[ThemeEntryAddress]:
+ for item_type in dict.fromkeys((address.item_type, EVERY_ITEM_TYPE_NAME)):
+ yield replace(address, item_type=item_type)
+ if not address.enabled:
+ yield replace(address, item_type=item_type, enabled=True)
+
+ def _compose(self) -> Dict[str, ThemeEntries]:
+ composed: Dict[str, ThemeEntries] = {}
+ for spec in self._ordered:
+ own = self._own_entries(spec)
+ parent = spec.parent
+ composed[spec.name] = {**composed[parent], **own} if parent is not None else own
+
+ return composed
+
+ @staticmethod
+ def _own_entries(spec: ThemeSpec) -> ThemeEntries:
+ entries: ThemeEntries = {}
+ for component in spec.components:
+ for entry in component.entries:
+ address = ThemeEntryAddress(
+ item_type=component.item_type,
+ enabled=component.enabled,
+ category=entry.category,
+ key=entry.key,
+ )
+ entries[address] = entry
+
+ return entries
+
+ def _check_parents(self) -> None:
+ for name in self._index:
+ visited: Set[str] = set()
+ current: Optional[str] = name
+ while current is not None:
+ if current in visited:
+ raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}")
+
+ visited.add(current)
+ spec = self._index.get(current)
+ if spec is None:
+ raise ValueError(
+ f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(self._index)}"
+ )
+
+ current = spec.parent
+
+ def _parents_first(self, specs: List[ThemeSpec]) -> List[ThemeSpec]:
+ ordered: List[ThemeSpec] = []
+ visited: Set[str] = set()
+
+ def visit(spec: ThemeSpec) -> None:
+ if spec.name in visited:
+ return
+
+ parent = spec.parent
+ if parent is not None:
+ visit(self._index[parent])
+
+ visited.add(spec.name)
+ ordered.append(spec)
+
+ for spec in specs:
+ visit(spec)
+
+ return ordered
diff --git a/src/sampletones_application/utils/theme/spec.py b/src/sampletones_application/utils/theme/spec.py
new file mode 100644
index 000000000..7421f2ff8
--- /dev/null
+++ b/src/sampletones_application/utils/theme/spec.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from typing import Annotated, Final, List, Literal, Optional, Union
+
+from pydantic import BaseModel, Field
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+BASE_THEME_NAME: Final[str] = "default"
+EVERY_ITEM_TYPE_NAME: Final[str] = "All"
+CORE_CATEGORY: Final[str] = "Core"
+
+
+class ThemeColorEntrySpec(BaseModel, frozen=True):
+ type: Literal["color"]
+ key: str
+ value: WrittenColor
+ category: str = CORE_CATEGORY
+
+
+class ThemeStyleEntrySpec(BaseModel, frozen=True):
+ type: Literal["style"]
+ key: str
+ x: float
+ y: float = -1.0
+ category: str = CORE_CATEGORY
+
+
+ThemeEntrySpec = Annotated[
+ Union[ThemeColorEntrySpec, ThemeStyleEntrySpec],
+ Field(discriminator="type"),
+]
+
+
+class ThemeComponentSpec(BaseModel, frozen=True):
+ item_type: str
+ enabled: bool = True
+ entries: List[ThemeEntrySpec]
+
+
+class ThemeSpec(BaseModel, frozen=True):
+ name: str
+ tag: str
+ extends: Optional[str] = None
+ components: List[ThemeComponentSpec]
+
+ @property
+ def parent(self) -> Optional[str]:
+ """The theme this one composes on top of.
+
+ A theme inherits from its explicit ``extends`` when given, and otherwise from the base
+ theme, so every theme carries the shared base's complete component set and states only
+ its own overrides. The base theme itself stands alone. This makes inheritance the
+ default: a bound item theme always resolves to the global base plus its overrides, so
+ every color it omits keeps the base's value.
+ """
+ if self.extends is not None:
+ return self.extends
+
+ if self.name != BASE_THEME_NAME:
+ return BASE_THEME_NAME
+
+ return None
diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py
index b298605a1..6bec9b022 100644
--- a/src/sampletones_application/view_model/reconstruction/instruments.py
+++ b/src/sampletones_application/view_model/reconstruction/instruments.py
@@ -1,3 +1,4 @@
+from enum import StrEnum
from typing import FrozenSet, Optional
from pydantic import BaseModel
@@ -16,16 +17,33 @@ class InstrumentViewModel(BaseModel, frozen=True):
name: str
-class ReconstructionInstrumentsViewModel(BaseModel, frozen=True):
+class InstrumentsShowing(StrEnum):
+ """What the instruments panel shows of the voice in front of it.
+
+ A reconstruction's envelopes are the worker's to export. A document just opened has none yet,
+ so the panel says they are being prepared. An edit, a retune, an undo and a new choice of what
+ is heard owe the next ones, so the panel keeps the ones it drew and takes no edit until they
+ land.
+ """
+
+ NOTHING = "nothing"
+ PREPARING = "preparing"
+ KEPT = "kept"
+ EDITABLE = "editable"
+
+
+class ReconstructionInstrumentsViewModel(BaseModel, frozen=True, extra="forbid"):
"""What the instruments panel renders: the voice in front of it, and how it is read.
A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays
editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel
reads to mark the standing-by tabs and to offer their export. An instrument is one set every
- channel reads, so :attr:`instrument` is what the panel renders instead.
+ channel reads, so :attr:`instrument` is what the panel renders instead. While the envelopes
+ are :attr:`InstrumentsShowing.KEPT`, the panel keeps the tabs, the figures and the bars it drew
+ and reads nothing else of the view.
"""
- reconstruction_loaded: bool
+ showing: InstrumentsShowing
playing_channels: FrozenSet[ChannelName]
footprint: Optional[VoiceFootprintViewModel]
instrument: Optional[InstrumentViewModel] = None
@@ -35,7 +53,17 @@ def edits_an_instrument(self) -> bool:
"""Whether the panel is showing an instrument rather than a reconstruction's channels."""
return self.instrument is not None
+ @property
+ def reconstruction_loaded(self) -> bool:
+ """Whether a reconstruction stands in front of the panel, its envelopes landed or on their way."""
+ return self.instrument is None and self.showing is not InstrumentsShowing.NOTHING
+
@property
def is_open(self) -> bool:
- """Whether the panel has a voice in front of it at all."""
- return self.reconstruction_loaded or self.edits_an_instrument
+ """Whether the panel draws envelopes: an instrument's, or a reconstruction's landed or kept."""
+ return self.showing in (InstrumentsShowing.KEPT, InstrumentsShowing.EDITABLE)
+
+ @property
+ def is_locked(self) -> bool:
+ """Whether the panel keeps what it drew and takes no edit until the worker lands the next envelopes."""
+ return self.showing is InstrumentsShowing.KEPT
diff --git a/src/sampletones_application/view_model/reconstruction/work.py b/src/sampletones_application/view_model/reconstruction/work.py
new file mode 100644
index 000000000..289c851b0
--- /dev/null
+++ b/src/sampletones_application/view_model/reconstruction/work.py
@@ -0,0 +1,13 @@
+from enum import StrEnum
+
+
+class Work(StrEnum):
+ """What the open document's line is busy with, which the waveform's fade names.
+
+ An edit on its way rebuilds the document, and a change to what is heard, or a document just
+ opened, has its sound rendered. The reader sees one fade for both and reads from its words
+ which of the two the waveform waits for.
+ """
+
+ REBUILDING = "rebuilding"
+ READING = "reading"
diff --git a/src/sampletones_application/view_model/shared/ownership.py b/src/sampletones_application/view_model/shared/ownership.py
index a1f34f554..8e92b5b38 100644
--- a/src/sampletones_application/view_model/shared/ownership.py
+++ b/src/sampletones_application/view_model/shared/ownership.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Tuple
+from typing import Dict, List, Sequence, Tuple
from pydantic import BaseModel
@@ -23,8 +23,51 @@ class OwnershipRunViewModel(BaseModel, extra="forbid", frozen=True):
heard: bool
+class OwnerStretchesViewModel(BaseModel, extra="forbid", frozen=True):
+ """One recording's stretches along a lane, as a surface paints them in one piece.
+
+ Attributes:
+ stem_id: The stem holding the stretches, which picks the color.
+ stretches: The first frame and the frame one past the last of each stretch, in frame order.
+ heard: Whether the reader hears the recording on this lane, which settles how solidly it paints.
+ """
+
+ stem_id: int
+ stretches: Tuple[Tuple[int, int], ...]
+ heard: bool
+
+
+def runs_by_owner(runs: Sequence[OwnershipRunViewModel]) -> Dict[int, OwnerStretchesViewModel]:
+ """The runs of a lane gathered by the recording holding them, in the order the recordings first appear.
+
+ A surface paints a recording's stretches as one piece however many times the lane divides, so
+ what it draws costs as many pieces as the lane has owners. Hearing is chosen per channel, so a
+ lane hears a recording on every stretch it holds or on none, and the recording's stretches share
+ one reading of it.
+
+ Args:
+ runs: The lane's runs, in frame order.
+
+ Returns:
+ Dict[int, OwnerStretchesViewModel]: Each owner's stretches, keyed by stem id.
+ """
+ stretches: Dict[int, List[Tuple[int, int]]] = {}
+ heard: Dict[int, bool] = {}
+ for run in runs:
+ stretches.setdefault(run.stem_id, []).append((run.start_frame, run.end_frame))
+ heard[run.stem_id] = run.heard
+
+ return {
+ stem_id: OwnerStretchesViewModel(stem_id=stem_id, stretches=tuple(owned), heard=heard[stem_id])
+ for stem_id, owned in stretches.items()
+ }
+
+
class OwnershipLaneViewModel(BaseModel, extra="forbid", frozen=True):
- """One channel's stretch of ribbon: the channel it stands for and the runs along it."""
+ """One channel's stretch of ribbon: the channel it stands for and the runs along it.
+
+ Hearing is chosen per channel, so the lane hears a recording on every stretch it holds or on none.
+ """
channel_name: ChannelName
runs: Tuple[OwnershipRunViewModel, ...]
@@ -47,8 +90,10 @@ class OwnershipRibbonViewModel(BaseModel, extra="forbid", frozen=True):
"""The recordings behind each stretch of what a reader is listening to.
The ribbon reads along the same span the waveform above it draws, so a lane is stated in
- frames and the frame length turns them into the samples the plot counts. A document whose
- frames all answer to one recording has nothing to tell apart, so it offers no lanes.
+ frames and the frame length turns them into the samples the plot counts. A lane stands for
+ every channel the document plays and paints the stretches the recordings heard there hold,
+ so a channel switched off keeps its lane and stands empty, and a document answering to one
+ recording throughout paints each lane as one stretch.
"""
lanes: Tuple[OwnershipLaneViewModel, ...]
diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py
index c75e23801..83d6f9b70 100644
--- a/src/sampletones_application/view_model/shared/waveform_data.py
+++ b/src/sampletones_application/view_model/shared/waveform_data.py
@@ -1,36 +1,25 @@
from dataclasses import dataclass
-from typing import Dict, List, Optional
+from typing import Optional
import numpy as np
-from sampletones_core.audio.mixing import align, mix
-from sampletones_core.constants.enums import ChannelName
-
@dataclass(frozen=True)
class WaveformData:
+ """What the waveform display draws of a document and the player plays, for one hearing of it.
+
+ Attributes:
+ original_audio: The recordings heard mixed into one waveform, ``None`` while no recording loads.
+ approximation: Every channel in play mixed, the frames of the recordings left out silenced.
+ playing: The channels kept on mixed the same way, which is what plays and is drawn.
+ coefficient: The level the original is scaled by to stand beside the approximation.
+ frame_length: How many samples one frame spans.
+ sample_rate: How many samples one second spans.
+ """
+
original_audio: Optional[np.ndarray]
approximation: np.ndarray
- approximations: Dict[ChannelName, np.ndarray]
+ playing: np.ndarray
coefficient: float
frame_length: int
sample_rate: int
-
- def partials(self, channel_names: List[ChannelName]) -> np.ndarray:
- """Sums the selected generators' approximations, silent when none apply.
-
- The approximation sets the length, so the silent result matches the waveform even when
- no original audio is present. Each selected approximation pads to that length before
- the sum, so a channel that ends early leaves the tail in silence.
- """
- if not channel_names:
- return np.zeros_like(self.approximation)
-
- selected_approximations = [
- self.approximations[channel_name] for channel_name in channel_names if channel_name in self.approximations
- ]
-
- if not selected_approximations:
- return np.zeros_like(self.approximation)
-
- return mix(align(selected_approximations, len(self.approximation)))
diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml
index 22be58d6f..27b1f67a3 100644
--- a/src/sampletones_config/keybindings/default.yaml
+++ b/src/sampletones_config/keybindings/default.yaml
@@ -64,6 +64,7 @@ bindings:
ToggleFullscreen: {combination: "F11"}
ToggleAutoExpandFavoriteReconstructions: {combination: ~}
ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ Documentation: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Ctrl+PgDn", field_transparent: true}
PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true}
diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml
index 0adfb26c1..2fdc2e41a 100644
--- a/src/sampletones_config/keybindings/macos.yaml
+++ b/src/sampletones_config/keybindings/macos.yaml
@@ -64,6 +64,7 @@ bindings:
ToggleFullscreen: {combination: "Cmd+Ctrl+F"}
ToggleAutoExpandFavoriteReconstructions: {combination: ~}
ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ Documentation: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true}
PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true}
diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml
index 76f660893..c02135a69 100644
--- a/src/sampletones_config/lang/en.yaml
+++ b/src/sampletones_config/lang/en.yaml
@@ -48,6 +48,7 @@ global.dialog.title.remove_voice: "Remove voice"
global.dialog.title.change_nes_frequency: "Change NES frequency"
global.dialog.title.frequency_mismatch: "Different NES frequency"
global.dialog.title.about: "About"
+global.dialog.title.documentation: "Documentation"
# Global — Dialog file filters
global.dialog.filter.project: "Project files"
@@ -109,6 +110,7 @@ global.dialog.message.audio_playback_error: "Audio playback error"
global.dialog.message.unexpected_failure: "Something went wrong, and the last action did not finish."
global.dialog.message.no_project_open: "No project is open. Open or create a project to add voices."
global.dialog.message.no_audio_output: "Can't play sound: no audio output is available."
+global.dialog.message.documentation_not_opened: "Can't open the documentation."
global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?"
global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?"
global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?"
@@ -117,6 +119,7 @@ global.dialog.message.about_description: "An application for approximating audio
# Global — Dialog templates
global.dialog.template.time_estimation: " (ETA: {eta_string})"
+global.dialog.template.documentation_unavailable: "This installation came without its documentation. You can read it online at {address}"
global.dialog.template.configuration_recovery_intro: "Your configuration was updated from {source} to version {target}."
global.dialog.template.fps: "{fps:.0f} FPS"
global.dialog.template.about_author: "by {author} / {group}"
@@ -254,6 +257,7 @@ global.menu.label.item_view_auto_expand_favorite_directories: "Directories"
global.menu.label.item_view_display_settings: "Display settings..."
global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..."
global.menu.label.group_help: "Help"
+global.menu.label.item_help_documentation: "Documentation"
global.menu.label.item_help_about: "About"
global.menu.label.tab_main: "Main"
global.menu.label.tab_instructions: "Instructions"
@@ -294,7 +298,7 @@ global.stems.label.remove: "x"
global.stems.label.edits: "Edits"
global.stems.label.channel_on: "on"
global.stems.label.channel_bend: "bend"
-global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording."
+global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording. Otherwise, snap to the nearest note."
global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level."
global.stems.message.inert_tooltip: "Tick a channel to use this recording."
global.stems.message.missing_tooltip: "This recording is missing from disk."
@@ -332,6 +336,7 @@ global.graph.message.spectrum_navigation: "Right-click for more options."
global.graph.message.waveform_navigation: "Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options."
global.graph.message.waveform_playable_navigation: "Click to play from a point. Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options."
global.graph.message.waveform_regenerating: "Regenerating reconstruction..."
+global.graph.message.waveform_reading: "Preparing the sound..."
# =============================================================================
# Main tab — Explorer
@@ -423,6 +428,11 @@ main.converter.title.load_dialog: "Reconstruction complete"
main.converter.title.cancel_dialog: "Cancel conversion?"
main.converter.message.load_file_prompt: "The reconstruction is ready. Load it now?"
main.converter.message.load_directory_prompt: "The reconstructions are ready. Open the Reconstruction tab?"
+main.converter.message.skipped_recording_heading: "This recording couldn't be converted, so it was skipped:"
+main.converter.template.skipped_recordings_heading: "{count} recordings couldn't be converted, so they were skipped:"
+main.converter.template.skipped_recordings_more: "and {count} more"
+main.converter.title.nothing_written_dialog: "Nothing converted"
+main.converter.message.nothing_written: "No reconstruction was written."
main.converter.message.cancel_prompt: "Stop the current reconstruction?"
main.converter.template.progress_template: "Progress: {}/{} files"
main.converter.template.stage_template: " — {stage} {completed}/{total}"
@@ -515,6 +525,7 @@ reconstructions.reconstruction.tooltip.autoscale_tooltip: "Scale the waveforms b
reconstructions.reconstruction.message.locate_audio_failed: "Original audio file could not be found."
reconstructions.reconstruction.message.export_wav_success: "Reconstruction rendered successfully."
reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed to save."
+reconstructions.reconstruction.message.export_wav_dropped: "The WAV file was not saved. The sound it was waiting for was never rendered."
reconstructions.reconstruction.label.stems: "Stems"
reconstructions.reconstruction.label.stems_empty: "This reconstruction has no sources"
reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin"
@@ -550,6 +561,7 @@ reconstructions.instruments.message.status_audition: "Click to choose the genera
reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Alt + scroll to pan. Right-click for more options."
reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply."
reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature} has {items} items. {kept}."
+reconstructions.instruments.message.preparing: "Preparing the instruments..."
reconstructions.instruments.template.kept_famitracker: "FamiTracker keeps the first {limit}"
reconstructions.instruments.template.kept_bitphase: "a Bitphase project keeps the first {limit}"
reconstructions.instruments.template.kept_bitphase_preset: "a Bitphase preset keeps the first {limit}"
@@ -1013,6 +1025,7 @@ settings.keybindings.label.toggle_advanced_settings: "Advanced settings"
settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions"
settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories"
settings.keybindings.label.toggle_fullscreen: "Fullscreen"
+settings.keybindings.label.documentation: "Documentation"
settings.keybindings.label.about_dialog: "About"
settings.keybindings.label.next_tab: "Next tab"
settings.keybindings.label.previous_tab: "Previous tab"
diff --git a/src/sampletones_config/layout/general/caret.yaml b/src/sampletones_config/layout/general/caret.yaml
index 76ba18227..ca942d320 100644
--- a/src/sampletones_config/layout/general/caret.yaml
+++ b/src/sampletones_config/layout/general/caret.yaml
@@ -1,4 +1,3 @@
-offset: 3
-width_padding: 2
-fill: .caret_fill
-border: .caret_border
+height: 2
+color: .caret
+frame: .caret_frame
diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
index c2fd30026..58a6c1516 100644
--- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
@@ -30,6 +30,7 @@ history:
separator: .history_separator
text:
voice: .text_disabled
+ pending: .text
transpose: .tracker_transpose
volume: .tracker_volume
sample: .voice_sample
diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
index 10a90f729..8a3e4d232 100644
--- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
@@ -4,7 +4,7 @@ row_height: 29
header_height: 30
octave_width: 90
channel_column_tint: 0.09
-muted_text_fraction: 0.45
+muted_text_fraction: 0.7
subcolumn_widths:
voice: 26
transpose: 30
diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml
index fe9493c6a..f543f4c14 100644
--- a/src/sampletones_config/palettes/dark.yaml
+++ b/src/sampletones_config/palettes/dark.yaml
@@ -103,7 +103,7 @@ colors:
text: "#e6e6e8"
text_muted: "#9a9aa0"
- text_disabled: "#75757b"
+ text_disabled: "#85858c"
text_trace: "#c0c0c4"
contrast: "#ffffff"
@@ -172,8 +172,8 @@ colors:
feature_hi_pitch: "#a99cff"
feature_duty_cycle: "#e6c26a"
- caret_fill: "#4fa6ff55"
- caret_border: "#7cc4ffff"
+ caret: "#7cc4ffff"
+ caret_frame: "#7cc4ffff"
graph_bar: "#4fa6ff"
waveform_sample: "#4fa6ff"
@@ -182,12 +182,12 @@ colors:
spectrum_dim: "#2a2a30"
tracker_beat_row: "#ffffff14"
- tracker_bar_row: "#ffffff28"
+ tracker_bar_row: "#ffffff1c"
- block_selection: "#b98af360"
+ block_selection: "#b98af330"
pattern_highlight: "#ffffff40"
- cell_cursor: "#4fa6ffb0"
- cursor_row: "#4fa6ff3a"
+ cell_cursor: "#4fa6ff24"
+ cursor_row: "#4fa6ff2c"
playback_row: "#5ec46e42"
order_label: "#2f3a46"
@@ -209,8 +209,8 @@ colors:
tracker_transpose: "#c0c0c4ff"
tracker_volume: "#7ee787ff"
tracker_frame: "#4fa6ffff"
- tracker_row: "#9a9aa2ff"
+ tracker_row: "#a6a6aeff"
tracker_order: "#c4d0e0ff"
voice_sample: "#e0c860ff"
- voice_instrument: "#f476d8ff"
+ voice_instrument: "#ffa3ecff"
diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml
index 5c0f08cdc..1db7633b8 100644
--- a/src/sampletones_config/palettes/light.yaml
+++ b/src/sampletones_config/palettes/light.yaml
@@ -103,7 +103,7 @@ colors:
text: "#0a0c10"
text_muted: "#414852"
- text_disabled: "#767d88"
+ text_disabled: "#6b7380"
text_trace: "#2c323b"
contrast: "#000000"
@@ -172,8 +172,8 @@ colors:
feature_hi_pitch: "#4a3fa8"
feature_duty_cycle: "#7a5c00"
- caret_fill: "#0b4c8c58"
- caret_border: "#073a6cff"
+ caret: "#073a6cff"
+ caret_frame: "#073a6cff"
graph_bar: "#0a5aa8"
waveform_sample: "#0a5aa8"
@@ -182,12 +182,12 @@ colors:
spectrum_dim: "#dfe4ec"
tracker_beat_row: "#00000016"
- tracker_bar_row: "#0000002c"
+ tracker_bar_row: "#00000024"
- block_selection: "#6b4ea840"
+ block_selection: "#6b4ea82a"
pattern_highlight: "#00000018"
- cell_cursor: "#0b4c8c60"
- cursor_row: "#0b4c8c24"
+ cell_cursor: "#0b4c8c1e"
+ cursor_row: "#0b4c8c1c"
playback_row: "#16702e48"
order_label: "#c6cbd5"
@@ -207,10 +207,10 @@ colors:
history_separator: "#8f97a4ff"
tracker_transpose: "#3d434dff"
- tracker_volume: "#16702eff"
+ tracker_volume: "#0f5a24ff"
tracker_frame: "#0a5aa8ff"
- tracker_row: "#5f6773ff"
+ tracker_row: "#4a515cff"
tracker_order: "#28303dff"
- voice_sample: "#7a5200ff"
+ voice_sample: "#704b00ff"
voice_instrument: "#9a137cff"
diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml
index 2223d0cd2..e6cbc49ca 100644
--- a/src/sampletones_config/palettes/studio.yaml
+++ b/src/sampletones_config/palettes/studio.yaml
@@ -103,7 +103,7 @@ colors:
text: "#e7e7f0"
text_muted: "#9497ab"
- text_disabled: "#767a8e"
+ text_disabled: "#8c90a4"
text_trace: "#c0c0c0"
contrast: "#ffffff"
@@ -172,8 +172,8 @@ colors:
feature_hi_pitch: "#9664ff"
feature_duty_cycle: "#ffc864"
- caret_fill: "#8888ff80"
- caret_border: "#88bbffff"
+ caret: "#a8c8ffff"
+ caret_frame: "#a8c8ffff"
graph_bar: "#64c8ff"
waveform_sample: "#64c8ff"
@@ -182,12 +182,12 @@ colors:
spectrum_dim: "#1c1c1c"
tracker_beat_row: "#ffffff14"
- tracker_bar_row: "#ffffff26"
+ tracker_bar_row: "#ffffff1a"
- block_selection: "#b98af360"
+ block_selection: "#b98af32e"
pattern_highlight: "#ffffff40"
- cell_cursor: "#66bbffa0"
- cursor_row: "#ffffff18"
+ cell_cursor: "#66bbff18"
+ cursor_row: "#ffffff14"
playback_row: "#64dc6440"
order_label: "#2d3241"
@@ -206,11 +206,11 @@ colors:
history_value: "#d0d0d0ff"
history_separator: "#707070ff"
- tracker_transpose: "#c0c0c0ff"
- tracker_volume: "#64dc64ff"
+ tracker_transpose: "#c8c8ccff"
+ tracker_volume: "#78e878ff"
tracker_frame: "#22ccffff"
- tracker_row: "#a0a0a0ff"
+ tracker_row: "#aaaab0ff"
tracker_order: "#c8d0e0ff"
voice_sample: "#e0c860ff"
- voice_instrument: "#ff70dfff"
+ voice_instrument: "#ffadeeff"
diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml
index 71a8af6e8..e7f0ad197 100644
--- a/src/sampletones_config/theme/tables/order.yaml
+++ b/src/sampletones_config/theme/tables/order.yaml
@@ -12,6 +12,14 @@ components:
value: .transparent
- item_type: Selectable
entries:
+ - type: style
+ key: ItemSpacing
+ x: 0
+ y: 0
+ - type: style
+ key: SelectableTextAlign
+ x: 0
+ y: 0.5
- type: color
key: Header
value: .block_selection
diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml
index 1b0d55127..97e9213f3 100644
--- a/src/sampletones_config/theme/tables/pattern.yaml
+++ b/src/sampletones_config/theme/tables/pattern.yaml
@@ -10,7 +10,7 @@ components:
y: 0
- type: color
key: HeaderHovered
- value: .overlay/0.25
+ value: .overlay/0.08
- type: color
key: HeaderActive
value: .transparent
diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py
index 9f43e9d45..f869f7fd0 100644
--- a/src/sampletones_core/data/metadata.py
+++ b/src/sampletones_core/data/metadata.py
@@ -9,9 +9,9 @@
SAMPLETONES_LIBRARY_DATA_VERSION,
SAMPLETONES_NAME,
SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
- SAMPLETONES_VERSION,
)
from sampletones_shared.deployment.version import compare_versions
+from sampletones_shared.distribution import SAMPLETONES_VERSION
from sampletones_shared.exceptions import InvalidMetadataError
from sampletones_shared.exceptions.version import IncompatibleVersionError
diff --git a/src/sampletones_core/exporters/sounding.py b/src/sampletones_core/exporters/sounding.py
index 128262a38..c86bc0ae9 100644
--- a/src/sampletones_core/exporters/sounding.py
+++ b/src/sampletones_core/exporters/sounding.py
@@ -1,23 +1,27 @@
-from sampletones_core.constants.enums import ChannelName
-from sampletones_core.instructions import sounds
-
from .feature import Features
-from .maps import CHANNEL_TO_EXPORTER_MAP
-def stands_by(channel_name: ChannelName, features: Features) -> bool:
+def stands_by(features: Features) -> bool:
"""Whether a channel given these envelopes rests through every frame, and so stands by.
An edit reaches a reconstruction as envelopes, which the regeneration rebuilds into the frames
- the channel plays. Reading them through that same rebuild answers what the document will hold
- before it holds it, so a figure measured as an edit arrives agrees with the one the
- regenerated document reports.
+ the channel plays, and a frame sounds where its volume is above zero, on every kind of channel.
+ A volume left to the channel holds the level a channel starts a song at, which sounds, and a
+ volume written holds one of its own items at every frame, the last or the looped one past its
+ end. The envelopes therefore stand by where they describe no frame, or where every item the
+ volume writes is zero, which is the reading the rebuilt frames give without rebuilding them.
Args:
- channel_name: The channel the envelopes are written into.
features: The envelopes the channel is given.
Returns:
bool: True where no frame the envelopes describe sounds.
"""
- return not sounds(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features))
+ if not features.has_frames:
+ return True
+
+ volume = features.volume
+ if not volume.written:
+ return False
+
+ return all(item == 0 for item in volume.items)
diff --git a/src/sampletones_core/fft/instantaneous.py b/src/sampletones_core/fft/instantaneous.py
index 9d74c6866..afe9aefc7 100644
--- a/src/sampletones_core/fft/instantaneous.py
+++ b/src/sampletones_core/fft/instantaneous.py
@@ -41,11 +41,12 @@ class InstantaneousPitch:
Reading around a stated reference is what makes the answer usable: the reference names which
bins carry the note's harmonics, and each harmonic's estimate is divided back down and weighted
- by the energy standing behind it. The harmonics are read in order, each one settled against the
- fundamental the ones below it agreed on, which is what keeps an upper harmonic on the right side
- of the whole turn its phase states the reading to within. A later harmonic counts where its partial
- stands within half a semitone of where that fundamental puts it, the room a note owns, so a
- partial another voice sounds beside it stays out of the reading.
+ by the energy standing behind it. A harmonic is read where its bin carries energy, since a phase
+ turns at a partial's rate only where a partial sounds. The harmonics are read in order, each one
+ settled against the fundamental the ones below it agreed on, which is what keeps an upper harmonic
+ on the right side of the whole turn its phase states the reading to within. A later harmonic counts
+ where its partial stands within half a semitone of where that fundamental puts it, the room a note
+ owns, so a partial another voice sounds beside it stays out of the reading.
The first harmonic read has the note alone to be settled against. From about 1 kHz up a note's
room is wider than the whole turn the reading repeats over, so there a second reading, over a lag
@@ -100,7 +101,8 @@ def at(self, frame: int, reference: float) -> Optional[FundamentalReading]:
Returns:
Optional[FundamentalReading]: The reading, or ``None`` where the frame lies outside
- the transform or the reference names no bin the transform covers.
+ the transform, the reference names no bin the transform covers, or every bin it
+ names is silent in that frame.
"""
opening, closing = self._pair(frame)
if opening is None or closing is None:
@@ -115,6 +117,10 @@ def at(self, frame: int, reference: float) -> Optional[FundamentalReading]:
if bin_index is None:
continue
+ energy = float(self._magnitudes[bin_index, opening]) ** 2
+ if energy <= 0.0:
+ continue
+
expected = running * harmonic
if weight <= 0.0:
expected = self._first_guide(bin_index, opening, expected)
@@ -123,7 +129,6 @@ def at(self, frame: int, reference: float) -> Optional[FundamentalReading]:
if weight > 0.0 and not expected / HARMONIC_ROOM <= partial <= expected * HARMONIC_ROOM:
continue
- energy = float(self._magnitudes[bin_index, opening]) ** 2
weighted += energy * partial / harmonic
weight += energy
share += float(self._bin_energies[bin_index, opening])
diff --git a/src/sampletones_core/headless/conversion/runners.py b/src/sampletones_core/headless/conversion/runners.py
index 77454b670..bcf3b370f 100644
--- a/src/sampletones_core/headless/conversion/runners.py
+++ b/src/sampletones_core/headless/conversion/runners.py
@@ -5,11 +5,13 @@
from sampletones_core.configs import Config
from sampletones_core.headless.conversion.request import ConversionRequest
+from sampletones_core.headless.conversion.skipped import skipped_lines
from sampletones_core.headless.library import ensure_library
from sampletones_core.parallelization import TaskProgress, TaskStatus
from sampletones_core.reconstructions import Reconstructor
from sampletones_core.reconstructions.converter import (
ConversionJob,
+ ConversionOutcome,
DirectoryConversion,
ReconstructionConverter,
reconstruct_job,
@@ -83,7 +85,8 @@ def reconstruct_directory(
"""Reconstructs every recording under the directory, each alone under the setup's stem.
The library the configuration names is prepared first. The results mirror the directory's
- tree inside the configuration's reconstructions directory.
+ tree inside the configuration's reconstructions directory. A recording that fails is skipped,
+ and the run names every skipped recording, with its error, once it ends.
Args:
directory: The directory of recordings.
@@ -97,9 +100,11 @@ def on_start() -> None:
progress_bar.disable = False
logger.info(f"Starting reconstruction for directory {directory}")
- def on_completed(written: Tuple[Path, ...]) -> None:
- logger.info(f"Reconstructed {len(written)} files from {directory}")
+ def on_completed(outcome: ConversionOutcome) -> None:
progress_bar.close()
+ logger.info(f"Reconstructed {len(outcome.written)} files from {directory}")
+ for line in skipped_lines(outcome):
+ logger.warning(line)
def on_progress(
task_status: TaskStatus,
diff --git a/src/sampletones_core/headless/conversion/skipped.py b/src/sampletones_core/headless/conversion/skipped.py
new file mode 100644
index 000000000..bed6c5711
--- /dev/null
+++ b/src/sampletones_core/headless/conversion/skipped.py
@@ -0,0 +1,25 @@
+from typing import Final, Tuple
+
+from sampletones_core.reconstructions.converter import ConversionOutcome, SkippedJob
+
+ONE_RECORDING: Final[int] = 1
+
+
+def skipped_lines(outcome: ConversionOutcome) -> Tuple[str, ...]:
+ """What a finished run says about the recordings it went on without: a count, then one line apiece.
+
+ A run that skipped nothing says nothing.
+ """
+ if not outcome.skipped:
+ return ()
+
+ count = len(outcome.skipped)
+ noun = "recording" if count == ONE_RECORDING else "recordings"
+ return (f"Skipped {count} {noun} that failed:", *(describe_skipped(skipped) for skipped in outcome.skipped))
+
+
+def describe_skipped(skipped: SkippedJob) -> str:
+ """One skipped job: the recordings it read and the error that ended it."""
+ sources = ", ".join(str(source) for source in skipped.job.sources)
+ exception = skipped.exception
+ return f" {sources}: {type(exception).__name__}: {exception}"
diff --git a/src/sampletones_core/library/creator/creator.py b/src/sampletones_core/library/creator/creator.py
index 9b3ad718a..53abcb84a 100644
--- a/src/sampletones_core/library/creator/creator.py
+++ b/src/sampletones_core/library/creator/creator.py
@@ -6,6 +6,7 @@
from sampletones_core.generators import GeneratorUnion, get_generators_map
from sampletones_core.instructions import InstructionUnion
from sampletones_core.parallelization import TaskProcessor
+from sampletones_core.parallelization.outcome import RunCompleted
from sampletones_shared.logger import LoggerProtocol
from sampletones_shared.logger import logger as default_logger
@@ -60,9 +61,9 @@ def _get_task_function(
def _process_results(
self,
- results: List[Any],
+ completed: RunCompleted[Any],
) -> Tuple[InstructionLibraryKey, InstructionLibraryData]:
- data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = dict(results)
+ data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = dict(completed.results)
library_data = InstructionLibraryData.create(self.config, data)
key = InstructionLibraryKey.create(self.config.library, self.window)
diff --git a/src/sampletones_core/parallelization/outcome.py b/src/sampletones_core/parallelization/outcome.py
index b85b833a0..690a82865 100644
--- a/src/sampletones_core/parallelization/outcome.py
+++ b/src/sampletones_core/parallelization/outcome.py
@@ -1,14 +1,27 @@
from dataclasses import dataclass
-from typing import Generic, List, TypeVar, Union
+from typing import Generic, List, Tuple, TypeVar, Union
T = TypeVar("T")
+@dataclass(frozen=True)
+class TaskFailure:
+ """A task that ended in an error while its run went on, named by its place among the tasks."""
+
+ index: int
+ exception: Exception
+
+
@dataclass(frozen=True)
class RunCompleted(Generic[T]):
- """A run whose every task answered, with the answers in the order the tasks were given."""
+ """A run whose every task was answered.
+
+ ``results`` holds the answers in the order the tasks were given. A run that outlives a failing
+ task holds that task in ``failures`` and leaves its answer out of ``results``.
+ """
results: List[T]
+ failures: Tuple[TaskFailure, ...]
@dataclass(frozen=True)
diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py
index 5081c6513..5c1bfebc5 100644
--- a/src/sampletones_core/parallelization/processor.py
+++ b/src/sampletones_core/parallelization/processor.py
@@ -2,7 +2,7 @@
import threading
from abc import ABC, abstractmethod
from concurrent.futures._base import CancelledError
-from typing import Any, Callable, Final, Generic, List, Optional, TypeVar
+from typing import Any, Callable, Final, Generic, Iterator, List, Optional, TypeVar
from pebble import ProcessMapFuture, ProcessPool
@@ -10,7 +10,13 @@
from sampletones_core.parallelization.channel.process import ProcessProgressChannel
from sampletones_core.parallelization.channel.protocol import ProgressChannel, StepReporter
from sampletones_core.parallelization.channel.pump import ProgressPump
-from sampletones_core.parallelization.outcome import RunCanceled, RunCompleted, RunFailed, RunOutcome
+from sampletones_core.parallelization.outcome import (
+ RunCanceled,
+ RunCompleted,
+ RunFailed,
+ RunOutcome,
+ TaskFailure,
+)
from sampletones_core.parallelization.steps import TaskSteps
from sampletones_core.parallelization.task import (
TaskProgress,
@@ -33,6 +39,10 @@ class TaskProcessor(ABC, CallbackMixin, Generic[T]):
The monitor thread builds the tasks, creates the pool, gathers the answers and, however the run
ends, ends the pool and the progress channel before it announces the outcome. Whoever reacts to
``on_completed``, ``on_canceled`` or ``on_error`` therefore meets a run with no worker left.
+
+ A task that raises, or whose worker dies, ends the run with that error. A run that
+ ``skips_failed_tasks`` records the failure against the task instead, logs it, and goes on, so
+ its completed outcome names every task it went on without.
"""
def __init__(
@@ -111,6 +121,11 @@ def shutdown(self) -> None:
def is_running(self) -> bool:
return self.running
+ @property
+ def skips_failed_tasks(self) -> bool:
+ """Whether a failing task leaves the rest of the run going, which a run decides from its tasks."""
+ return False
+
@abstractmethod
def _create_tasks(self) -> List[Any]: ...
@@ -118,7 +133,11 @@ def _create_tasks(self) -> List[Any]: ...
def _get_task_function(self) -> Callback: ...
@abstractmethod
- def _process_results(self, results: List[T]) -> Any: ...
+ def _process_results(self, completed: RunCompleted[T]) -> Any: ...
+
+ def _task_name(self, index: int) -> str:
+ """How the log names the task at ``index``."""
+ return f"Task {index}"
def _task_reporter(self, index: int) -> StepReporter:
"""The line the task at ``index`` reports its own progress on.
@@ -192,7 +211,7 @@ def _gather(self) -> RunOutcome[T]:
"""Builds the tasks and runs them on a pool that has ended by the time this returns."""
try:
tasks = self._create_tasks()
- except Exception as exception:
+ except Exception as exception: # pylint: disable=broad-exception-caught
return RunFailed(exception)
if self.canceling:
@@ -226,27 +245,57 @@ def _drain(
self.call(self.on_start)
results: List[T] = []
+ failures: List[TaskFailure] = []
self.running = True
self.status = TaskStatus.RUNNING
self._notify_progress()
try:
iterator = self.future.result()
while not self.canceling:
- results.append(next(iterator))
+ self._take_answer(iterator, results, failures)
self._steps.complete(self.completed_tasks)
self.completed_tasks += 1
self._notify_progress()
except StopIteration:
- return RunCompleted(results)
+ return RunCompleted(results, tuple(failures))
except (CancelledError, OperationCanceled):
return RunCanceled()
- except Exception as exception:
+ except Exception as exception: # pylint: disable=broad-exception-caught
return RunFailed(exception)
finally:
self.running = False
return RunCanceled()
+ def _take_answer(
+ self,
+ iterator: Iterator[T],
+ results: List[T],
+ failures: List[TaskFailure],
+ ) -> None:
+ """Takes the next task's answer, or the error it ended in where the run goes on without it.
+
+ Raises:
+ StopIteration: Once every task has answered.
+ CancelledError: If the run was withdrawn.
+ OperationCanceled: If a task gave up on hearing the run was withdrawn.
+ Exception: The error a task ended in, where the run ends with it.
+ """
+ try:
+ results.append(next(iterator))
+ except (StopIteration, CancelledError, OperationCanceled):
+ raise
+ except Exception as exception: # pylint: disable=broad-exception-caught
+ if not self.skips_failed_tasks:
+ raise
+
+ failure = TaskFailure(index=self.completed_tasks, exception=exception)
+ self.logger.error_with_traceback(
+ exception,
+ f"{self._task_name(failure.index)} failed, and the run goes on without it: {exception}",
+ )
+ failures.append(failure)
+
def _end_pool(
self,
pool: ProcessPool,
@@ -264,8 +313,8 @@ def _end_pool(
def _announce(self, outcome: RunOutcome[T]) -> None:
match outcome:
- case RunCompleted(results=results):
- self._finalize_completion(results)
+ case RunCompleted() as completed:
+ self._finalize_completion(completed)
case RunCanceled():
self._finalize_cancellation()
case RunFailed(exception=exception):
@@ -293,13 +342,13 @@ def _finalize_cancellation(self) -> None:
self._notify_progress()
self.call(self.on_canceled)
- def _finalize_completion(self, results: List[T]) -> None:
+ def _finalize_completion(self, completed: RunCompleted[T]) -> None:
self.logger.info("Conversion completed successfully")
self.status = TaskStatus.COMPLETED
self._notify_progress()
- processed_result = self._process_results(results)
+ processed_result = self._process_results(completed)
self.call(self.on_completed, processed_result)
def _stop_with_error(self, exception: Exception) -> None:
diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py
index 345cd560a..f32a02de9 100644
--- a/src/sampletones_core/reconstructions/converter/__init__.py
+++ b/src/sampletones_core/reconstructions/converter/__init__.py
@@ -1,6 +1,7 @@
from .conversion import reconstruct_job
from .converter import ReconstructionConverter
from .job import ConversionJob
+from .outcome import ConversionOutcome, SkippedJob
from .plan import (
BatchConversion,
BatchEntry,
@@ -13,9 +14,11 @@
"BatchConversion",
"BatchEntry",
"ConversionJob",
+ "ConversionOutcome",
"ConversionPlan",
"DirectoryConversion",
"GroupConversion",
"ReconstructionConverter",
+ "SkippedJob",
"reconstruct_job",
]
diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py
index 0306ee774..c649b548c 100644
--- a/src/sampletones_core/reconstructions/converter/conversion.py
+++ b/src/sampletones_core/reconstructions/converter/conversion.py
@@ -2,7 +2,6 @@
from pathlib import Path
from typing import Tuple
-from sampletones_shared.exceptions import UnsupportedAudioFormatError
from sampletones_shared.logger import logger
from ..progress import ReconstructionReporter
@@ -14,8 +13,8 @@ def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob, Reconstructio
"""Builds one job's reconstruction and writes it where the job says.
Runs in a pool worker, so the job travels with the reconstructor that builds it and the
- reporter it tells its progress to. A source in a format the loader has no reader for is
- reported and left, which keeps one such file from ending a batch.
+ reporter it tells its progress to. An error the job meets ends the job, and the run decides
+ whether the jobs around it go on.
Returns:
The file the job named, whether or not a reconstruction reached it.
@@ -35,8 +34,6 @@ def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob, Reconstructio
except KeyboardInterrupt:
logger.info("Reconstruction interrupted by user.")
raise
- except UnsupportedAudioFormatError:
- logger.warning(f"Skipping job due to unsupported audio format: {job.sources}")
finally:
gc.collect()
diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py
index dc4bc630d..f4e808223 100644
--- a/src/sampletones_core/reconstructions/converter/converter.py
+++ b/src/sampletones_core/reconstructions/converter/converter.py
@@ -4,6 +4,8 @@
from sampletones_core.configs import Config
from sampletones_core.constants.enums import ChannelName
from sampletones_core.parallelization import TaskProcessor
+from sampletones_core.parallelization.outcome import RunCompleted
+from sampletones_core.parallelization.task import SINGLE_TASK
from sampletones_shared.logger import LoggerProtocol
from sampletones_shared.logger import logger as default_logger
@@ -11,6 +13,7 @@
from ..reconstructor.reconstructor import Reconstructor
from .conversion import reconstruct_job
from .job import ConversionJob
+from .outcome import ConversionOutcome, SkippedJob
from .plan.protocol import ConversionPlan
from .progress import JobReporter
@@ -21,6 +24,9 @@ class ReconstructionConverter(TaskProcessor[Path]):
The plan is resolved once the run starts, on the monitor thread, so a plan that scans a
directory does its reading there. Every job is then built by one worker, and the run
reports the reconstructions it wrote.
+
+ A run of several jobs, such as a folder converted one recording at a time, goes on past a job
+ that fails and reports it among the jobs it skipped. A run of one job ends with that job's error.
"""
def __init__(
@@ -41,6 +47,11 @@ def start(self) -> None:
super().start()
+ @property
+ def skips_failed_tasks(self) -> bool:
+ """A run holding several jobs goes on past a job that fails."""
+ return len(self.jobs) > SINGLE_TASK
+
def _create_tasks(self) -> List[Any]:
"""One task per job, sharing the reconstructor the whole run's channels are built for."""
self.jobs = self.plan.jobs(self.config)
@@ -56,9 +67,18 @@ def _get_task_function(
) -> Callable[[Tuple[Reconstructor, ConversionJob, ReconstructionReporter]], Path]:
return reconstruct_job
- def _process_results(self, results: List[Path]) -> Tuple[Path, ...]:
- """The reconstructions the run wrote, in job order."""
- return tuple(output_path for output_path in results if output_path.exists())
+ def _process_results(self, completed: RunCompleted[Path]) -> ConversionOutcome:
+ """The reconstructions the run wrote, in job order, and the jobs it went on without."""
+ return ConversionOutcome(
+ written=tuple(output_path for output_path in completed.results if output_path.exists()),
+ skipped=tuple(
+ SkippedJob(job=self.jobs[failure.index], exception=failure.exception) for failure in completed.failures
+ ),
+ )
+
+ def _task_name(self, index: int) -> str:
+ """The recordings the job at ``index`` reads, which is how a reader finds it in the log."""
+ return ", ".join(str(source) for source in self.jobs[index].sources)
def _notify_progress(self) -> None:
job = self.job_under_way(self.completed_tasks)
diff --git a/src/sampletones_core/reconstructions/converter/outcome.py b/src/sampletones_core/reconstructions/converter/outcome.py
new file mode 100644
index 000000000..66c47b45f
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/outcome.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Tuple
+
+from .job import ConversionJob
+
+
+@dataclass(frozen=True)
+class SkippedJob:
+ """A job a run went on without, and the error that ended it."""
+
+ job: ConversionJob
+ exception: Exception
+
+
+@dataclass(frozen=True)
+class ConversionOutcome:
+ """What a finished run wrote, in job order, and the jobs it went on without."""
+
+ written: Tuple[Path, ...]
+ skipped: Tuple[SkippedJob, ...]
diff --git a/src/sampletones_core/reconstructions/reconstruction/renders.py b/src/sampletones_core/reconstructions/reconstruction/renders.py
index 5b5403a2c..fac358653 100644
--- a/src/sampletones_core/reconstructions/reconstruction/renders.py
+++ b/src/sampletones_core/reconstructions/reconstruction/renders.py
@@ -1,13 +1,19 @@
-from typing import Dict
+from typing import Dict, List
import numpy as np
from sampletones_core.audio.mixing import mix
from sampletones_core.constants.enums import ChannelName
from sampletones_core.generators.render import render_channels
+from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+def sounding_streams(reconstruction: Reconstruction) -> List[InstructionsItem]:
+ """The streams that describe a frame, in channel order, which are the channels a render sounds."""
+ return [stream for stream in reconstruction.instructions_data if stream.instructions]
+
+
def rendered_channels(reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]:
"""The audio each channel in play renders from the instructions it carries.
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py
index 5c4374494..dbea77305 100644
--- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py
@@ -6,9 +6,11 @@
from pydantic import ConfigDict, Field, model_validator
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
from sampletones_core.constants.enums import ChannelName
from sampletones_core.data import DataModel
from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.ownership import OwnerRun, owner_runs
from sampletones_core.reconstructions.reconstruction.stems.source import StemSource
from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings
@@ -89,6 +91,29 @@ def assignments_by_channel(self) -> Dict[ChannelName, Tuple[int, ...]]:
"""The per-frame stem ids each channel carries, keyed by channel."""
return {item.channel_name: item.stem_ids for item in self.assignments}
+ @cached_property
+ def owner_runs_by_channel(self) -> Dict[ChannelName, Tuple[OwnerRun, ...]]:
+ """The stretches each channel divides into, each held by one owner throughout, keyed by channel.
+
+ A record is read by its stretches wherever a surface paints who holds what, and a record
+ is a value, so the stretches are read from its frames once and every surface shares them.
+ """
+ return {item.channel_name: tuple(owner_runs(item.stem_ids)) for item in self.assignments}
+
+ @cached_property
+ def assignment_records(self) -> Dict[ChannelName, ChannelAssignment]:
+ """The record naming the owner of each frame, keyed by channel."""
+ return {item.channel_name: item for item in self.assignments}
+
+ @cached_property
+ def owners_by_channel(self) -> Dict[ChannelName, FrozenSet[int]]:
+ """Who holds a frame on each channel: the recordings and the reader's own row, keyed by channel.
+
+ A resting frame belongs to nobody, so it counts toward no owner; a channel whose owners a
+ reader all hears is heard whole.
+ """
+ return {item.channel_name: frozenset(item.stem_ids) - {RESTING_STEM_ID} for item in self.assignments}
+
@cached_property
def holding_stem_ids(self) -> FrozenSet[int]:
"""The recorded entries holding a frame on some channel.
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py
index 75d68d2b7..09c0353a2 100644
--- a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py
@@ -24,19 +24,39 @@ def filter_approximations(
keep their lengths, which is what aligns a filtered mix with the unfiltered one sample for
sample. The mask covers the frames the stored array holds; samples past the last recorded
frame keep their values.
+
+ A channel every owner of which is heard comes back as the very array it was given. A
+ resting frame holds a silent instruction, which every generator renders as silence, so the
+ frames a mask would zero on such a channel are zero already and the array stands as its own
+ filtered reading, which a reader tells by identity.
"""
filtered: Dict[ChannelName, np.ndarray] = {}
+ owners_by_channel = stems_data.owners_by_channel
for channel, stem_ids in stems_data.assignments_by_channel.items():
approximation = approximations[channel]
- keep = np.isin(np.array(stem_ids, dtype=int), list(selection.stems_for(channel)))
- keep_samples = np.repeat(keep, frame_length)
- masked = np.array(approximation, copy=True)
- masked[~keep_samples[: len(masked)]] = 0
- filtered[channel] = masked
+ heard = selection.stems_for(channel)
+ if owners_by_channel[channel] <= heard:
+ filtered[channel] = approximation
+ else:
+ filtered[channel] = _silenced(approximation, stem_ids, heard, frame_length)
return filtered
+def _silenced(
+ approximation: np.ndarray,
+ stem_ids: Sequence[int],
+ heard: AbstractSet[int],
+ frame_length: int,
+) -> np.ndarray:
+ """A copy of one channel's approximation with the frames of the recordings left out zeroed."""
+ keep = np.isin(np.array(stem_ids, dtype=int), list(heard))
+ keep_samples = np.repeat(keep, frame_length)
+ masked = np.array(approximation, copy=True)
+ masked[~keep_samples[: len(masked)]] = 0
+ return masked
+
+
def heard_instructions(
stems_data: StemsData,
instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py
new file mode 100644
index 000000000..ee6938f7c
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/hearing.py
@@ -0,0 +1,302 @@
+from dataclasses import dataclass
+from typing import AbstractSet, Dict, FrozenSet, List, Mapping, Optional, Set
+
+import numpy as np
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_core.audio.mixing import align, mix
+from sampletones_core.constants.algorithm import AUTHORED_STEM_ID
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.filter import filter_approximations
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+
+HeardChannels = Mapping[int, FrozenSet[ChannelName]]
+
+
+class Hearing(BaseModel):
+ """What a reader hears of a document: the recordings heard on each channel, and the channels kept on.
+
+ The two choices compose. The recordings heard filter each channel's frames, and the channels
+ kept on choose which of the filtered channels play and are drawn. Both are the reader's
+ listening state, so one hearing names one reading of a document wherever the reading is taken.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ selection: StemSelection
+ channels_on: FrozenSet[ChannelName]
+
+ def filter_for(
+ self,
+ channel: ChannelName,
+ stems_data: StemsData,
+ ) -> Optional[FrozenSet[int]]:
+ """The owners of ``channel`` the reader hears, where an owner is left out.
+
+ A channel every owner of which is heard is heard whole, which ``None`` names, so two hearings
+ leaving nothing out on a channel read it the same way whatever else they hear. Recordings
+ heard that hold no frame on the channel change nothing there and are left out of the answer.
+
+ Args:
+ channel: The channel read.
+ stems_data: The record naming the owner of each frame.
+
+ Returns:
+ Optional[FrozenSet[int]]: The owners heard, or ``None`` where every owner is heard.
+ """
+ owners = stems_data.owners_by_channel.get(channel, frozenset())
+ heard = self.selection.stems_for(channel) & owners
+ return None if heard == owners else heard
+
+
+def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]:
+ """The channels each recording holds frames on, which is what its row offers a box for.
+
+ A recording the picker never chose on a channel contributes nothing there whatever the
+ reader ticks, so the row draws a box exactly where the choice reaches something. A recording
+ holding no frame anywhere has left the document, so every recording offers a box somewhere
+ once any of them holds a frame. The frames the reader wrote gather under the authored stem,
+ which offers its boxes the same way and appears exactly where it holds a frame.
+
+ Args:
+ stems_data: The record naming the owner of each frame.
+
+ Returns:
+ Dict[int, FrozenSet[ChannelName]]: The channels each recording of the record offers, and
+ the authored row's where it holds a frame.
+ """
+ offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries}
+ for channel_name, stem_ids in stems_data.assignments_by_channel.items():
+ for stem_id in set(stem_ids):
+ if stem_id == AUTHORED_STEM_ID:
+ offered.setdefault(AUTHORED_STEM_ID, set()).add(channel_name)
+ elif stem_id in offered:
+ offered[stem_id].add(channel_name)
+
+ return {stem_id: frozenset(channels) for stem_id, channels in offered.items()}
+
+
+def selection_of(heard: HeardChannels) -> StemSelection:
+ """What a reader hears, read the way the filters ask for it.
+
+ A listening keeps the channels each recording is heard on; a filter asks each channel which
+ recordings it keeps, so the selection is that map turned around, with every channel a key.
+
+ Args:
+ heard: The channels each recording is heard on.
+
+ Returns:
+ StemSelection: The recordings heard on each channel.
+ """
+ channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()}
+ for stem_id, stem_channels in heard.items():
+ for channel_name in stem_channels:
+ channels[channel_name].add(stem_id)
+
+ return StemSelection(channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()})
+
+
+def heard_by_stem(
+ selection: StemSelection,
+ offered: HeardChannels,
+) -> Dict[int, FrozenSet[ChannelName]]:
+ """The channels each recording of ``offered`` is heard on under ``selection``, the selection turned back around.
+
+ Args:
+ selection: The recordings heard on each channel.
+ offered: The channels each recording holds frames on.
+
+ Returns:
+ Dict[int, FrozenSet[ChannelName]]: One entry per recording offered, holding the channels
+ it offers that hear it.
+ """
+ return {
+ stem_id: frozenset(channel_name for channel_name in channels if stem_id in selection.stems_for(channel_name))
+ for stem_id, channels in offered.items()
+ }
+
+
+def carried_heard(
+ heard: HeardChannels,
+ offered_before: HeardChannels,
+ offered_after: HeardChannels,
+) -> Dict[int, FrozenSet[ChannelName]]:
+ """The channels each recording is heard on once a record stands in place of the one the choice was made on.
+
+ A recording keeps whatever was chosen for it on a channel it keeps holding frames on, and is
+ heard on every channel it reaches for the first time. Read against the nothing it offered
+ before, that hears a recording appearing for the first time on all it holds, so a freshly
+ opened document is heard whole and the frames a reader first writes on a channel join what is
+ heard there. A recording the new record leaves out leaves the choice with it.
+
+ Args:
+ heard: The channels each recording is heard on, as the reader left them.
+ offered_before: The channels each recording offered on the record the choice was made on.
+ offered_after: The channels each recording offers on the record now standing.
+
+ Returns:
+ Dict[int, FrozenSet[ChannelName]]: One entry per recording offered now.
+ """
+ return {
+ stem_id: (heard.get(stem_id, frozenset()) & channels) | (channels - offered_before.get(stem_id, frozenset()))
+ for stem_id, channels in offered_after.items()
+ }
+
+
+def carried_channels_on(
+ channels_on: AbstractSet[ChannelName],
+ playing_before: AbstractSet[ChannelName],
+ playing_after: AbstractSet[ChannelName],
+) -> FrozenSet[ChannelName]:
+ """The channels kept on once the document plays ``playing_after``.
+
+ A channel coming into play joins the channels on, one kept playing keeps the reader's choice
+ for it, and one leaving play leaves them.
+
+ Args:
+ channels_on: The playing channels the reader kept on.
+ playing_before: The channels the document played when the choice was made.
+ playing_after: The channels the document plays now.
+
+ Returns:
+ FrozenSet[ChannelName]: The channels on, every one of them playing.
+ """
+ return frozenset((channels_on & playing_after) | (playing_after - playing_before))
+
+
+def carried_hearing(
+ hearing: Hearing,
+ *,
+ offered_before: HeardChannels,
+ playing_before: AbstractSet[ChannelName],
+ offered_after: HeardChannels,
+ playing_after: AbstractSet[ChannelName],
+) -> Hearing:
+ """``hearing`` carried onto a record offering ``offered_after`` and a document playing ``playing_after``.
+
+ One rule carries the reader's choice across an edit wherever a new record is met: the
+ regeneration worker applies it to the document it rebuilt, and the listening state to the
+ document that lands, so the two agree on what is heard and the sound and envelopes a landing
+ installs are the ones the listening then reads.
+
+ Args:
+ hearing: What the reader heard of the document the choice was made on.
+ offered_before: The channels each recording offered on that document.
+ playing_before: The channels that document played.
+ offered_after: The channels each recording offers on the document now standing.
+ playing_after: The channels that document plays.
+
+ Returns:
+ Hearing: What the reader hears of the document now standing.
+ """
+ heard = heard_by_stem(hearing.selection, offered_before)
+ return Hearing(
+ selection=selection_of(carried_heard(heard, offered_before, offered_after)),
+ channels_on=carried_channels_on(hearing.channels_on, playing_before, playing_after),
+ )
+
+
+def hearing_carried_onto(
+ hearing: Hearing,
+ before: Reconstruction,
+ after: Reconstruction,
+) -> Hearing:
+ """``hearing``, made on ``before``, carried onto ``after``.
+
+ A rebuild's worker hears the document it leaves this way, so the reading it renders and the
+ envelopes it exports are the ones the listening asks for once the document lands.
+
+ Args:
+ hearing: What the reader heard of ``before``.
+ before: The document the choice was made on.
+ after: The document now standing, such as the one a rebuild leaves.
+
+ Returns:
+ Hearing: What the reader hears of ``after``.
+ """
+ return carried_hearing(
+ hearing,
+ offered_before=offered_channels(before.stems_data),
+ playing_before=frozenset(before.playing_channels),
+ offered_after=offered_channels(after.stems_data),
+ playing_after=frozenset(after.playing_channels),
+ )
+
+
+def whole_hearing(reconstruction: Reconstruction) -> Hearing:
+ """Every recording and the reader's own row heard wherever they hold frames, every playing channel on.
+
+ It is the carry from a record offering nothing and a document playing nothing, which is how a
+ freshly opened document is heard, so a reading taken for it keys the render cache the way the
+ listening state keys the same document.
+
+ Args:
+ reconstruction: The document heard.
+
+ Returns:
+ Hearing: The document heard whole.
+ """
+ return carried_hearing(
+ Hearing(selection=StemSelection(channels={}), channels_on=frozenset()),
+ offered_before={},
+ playing_before=frozenset(),
+ offered_after=offered_channels(reconstruction.stems_data),
+ playing_after=frozenset(reconstruction.playing_channels),
+ )
+
+
+@dataclass(frozen=True)
+class HeardRenders:
+ """The audio a hearing of a document sounds.
+
+ Attributes:
+ mix: Every channel in play mixed, the frames of the recordings left out silenced.
+ playing: The channels kept on mixed the same way, which is what plays and is drawn.
+ """
+
+ mix: np.ndarray
+ playing: np.ndarray
+
+
+def heard_renders(
+ reconstruction: Reconstruction,
+ hearing: Hearing,
+ channels: Mapping[ChannelName, np.ndarray],
+ mixed: np.ndarray,
+) -> HeardRenders:
+ """What ``hearing`` sounds of ``reconstruction``, read from its channels' renders and their mix.
+
+ A hearing leaving no recording out sounds ``mixed`` itself, and one keeping every channel on
+ plays what it mixes, so a reading that filters nothing hands the arrays it was given back by
+ identity. A narrower choice of channels is summed in channel order, so the same choice reads the
+ same samples whatever order it was made in.
+
+ Args:
+ reconstruction: The document read.
+ hearing: What the reader hears of it.
+ channels: The render of every channel in play, by channel.
+ mixed: The mix of ``channels``.
+
+ Returns:
+ HeardRenders: The heard mix and what plays.
+ """
+ approximations = filter_approximations(
+ reconstruction.stems_data,
+ channels,
+ hearing.selection,
+ reconstruction.config.frame_length,
+ )
+ heard_whole = all(filtered is channels[name] for name, filtered in approximations.items())
+ heard_mix = mixed if heard_whole else mix(list(approximations.values()))
+ playing: List[np.ndarray] = [
+ approximations[name] for name in ChannelName.items() if name in hearing.channels_on and name in approximations
+ ]
+ if len(playing) == len(approximations):
+ return HeardRenders(mix=heard_mix, playing=heard_mix)
+
+ if not playing:
+ return HeardRenders(mix=heard_mix, playing=np.zeros_like(heard_mix))
+
+ return HeardRenders(mix=heard_mix, playing=mix(align(playing, len(heard_mix))))
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py
index 231ee7583..09aa421e4 100644
--- a/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py
@@ -53,6 +53,26 @@ class OwnerRun:
stem_id: int
+def runs_up_to(runs: Sequence[OwnerRun], frame_count: int) -> List[OwnerRun]:
+ """The stretches standing under the first ``frame_count`` frames, the last of them ending there.
+
+ A surface draws a reading as far as that reading goes, so a surface drawing fewer frames than
+ the channel holds asks for the stretches standing under them.
+
+ Args:
+ runs: A channel's stretches, in frame order.
+ frame_count: How many frames the surface draws.
+
+ Returns:
+ List[OwnerRun]: The stretches under those frames, cut where the last one runs past them.
+ """
+ return [
+ run if run.end <= frame_count else OwnerRun(start=run.start, end=frame_count, stem_id=run.stem_id)
+ for run in runs
+ if run.start < frame_count
+ ]
+
+
def owner_runs(stem_ids: Sequence[int]) -> List[OwnerRun]:
"""The stretches a channel divides into, each held by one recording throughout.
diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py
index 503597228..a2be681c8 100644
--- a/src/sampletones_shared/application.py
+++ b/src/sampletones_shared/application.py
@@ -1,16 +1,13 @@
-from importlib import metadata
from typing import Final
SAMPLETONES_NAME: Final[str] = "SampleToNES"
SAMPLETONES_PACKAGE_NAME: Final[str] = "sampletones"
SAMPLETONES_ENV_PREFIX: Final[str] = "SAMPLETONES_"
-SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME)
SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.1"
SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2"
SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1"
-SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}"
SAMPLETONES_AUTHOR: Final[str] = "Jakim"
SAMPLETONES_GROUP: Final[str] = "Stage Magician"
diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py
index 7b7dd1234..f50475bd4 100644
--- a/src/sampletones_shared/array.py
+++ b/src/sampletones_shared/array.py
@@ -1,12 +1,19 @@
import warnings
-from typing import Final, Optional, Type, Union
+from typing import Any, Final, Optional, Tuple, Type, Union
import numpy as np
-from sampletones_shared.exceptions import CuPyNotInstalledWarning
+from sampletones_shared.exceptions import CuPyUnavailableWarning, GPUBackendError
from sampletones_shared.logger import logger
CUPY_MISSING_MESSAGE: Final[str] = "CuPy is not available, falling back to NumPy."
+NO_DEVICE_MESSAGE: Final[str] = "CuPy finds no graphics card to compute on ({reason}), falling back to NumPy."
+NO_DEVICE_COUNTED: Final[str] = "the driver counts no device"
+MINIMUM_DEVICES: Final[int] = 1
+CUPY_BACKEND: Final[str] = "CuPy"
+NUMPY_BACKEND: Final[str] = "NumPy"
+GPU_PROBE_SIZE: Final[int] = 64
+GPU_PROBE_MISMATCH: Final[str] = "The graphics card answered {device}, while the CPU answered {host}."
def _preload_cuda_libraries() -> None:
@@ -46,18 +53,88 @@ def _format_warning_no_location(
return f"{category.__name__}: {message}\n"
-CUPY_AVAILABLE = False # pylint: disable=invalid-name
+def _absent_device_reason(cupy: Any) -> Optional[str]:
+ """Why CuPy has no graphics card to compute on, or ``None`` once the driver counts one.
+
+ Importing CuPy loads its libraries, and the driver is first asked when a computation starts.
+ Asking for the device count while the backend is chosen settles a machine without a card, or
+ without a working driver, on NumPy before anything is computed.
+
+ Args:
+ cupy: The imported CuPy module.
+
+ Returns:
+ Optional[str]: The reason the process falls back to NumPy, or ``None`` where a card answers.
+ """
+ try:
+ count = cupy.cuda.runtime.getDeviceCount()
+ except cupy.cuda.runtime.CUDARuntimeError as error:
+ return NO_DEVICE_MESSAGE.format(reason=error)
+
+ if count < MINIMUM_DEVICES:
+ return NO_DEVICE_MESSAGE.format(reason=NO_DEVICE_COUNTED)
+
+ return None
+
+
+FALLBACK_REASON: Optional[str]
try:
_preload_cuda_libraries()
import cupy as xp
import cupy.typing as xp_typing
+except (AttributeError, ImportError):
+ FALLBACK_REASON = CUPY_MISSING_MESSAGE
+else:
+ FALLBACK_REASON = _absent_device_reason(xp)
- CUPY_AVAILABLE = True # pylint: disable=invalid-name
-except (AttributeError, ImportError, ModuleNotFoundError):
+if FALLBACK_REASON is not None:
import numpy.typing as xp_typing # pylint: disable=ungrouped-imports
xp = np
+CUPY_AVAILABLE = FALLBACK_REASON is None
+
+
+def describe_array_backend() -> str:
+ """The array backend the process computes on, named with its version."""
+ name = CUPY_BACKEND if CUPY_AVAILABLE else NUMPY_BACKEND
+ return f"{name} {xp.__version__}"
+
+
+def _probe_sums(module: Any) -> Tuple[float, float]:
+ """The sum of squares and the summed outer product of the first probe-size integers, computed by ``module``.
+
+ Both sums stay below 2**24 at the probe size, so single precision carries them exactly in any
+ order of summation and the card's answer is held to the CPU's as an equality.
+ """
+ values = module.arange(GPU_PROBE_SIZE, dtype=module.float32)
+ return float((values * values).sum()), float((values[:, None] @ values[None, :]).sum())
+
+
+def exercise_gpu() -> str:
+ """Computes on the graphics card, holds its answer to the CPU's, and names the device.
+
+ A kernel is compiled for the elementwise product, and the matrix product goes through cuBLAS,
+ so a build missing the CUDA libraries or the headers the compiler reads fails here rather than
+ in a conversion.
+
+ Returns:
+ str: The backend and the device's name.
+
+ Raises:
+ GPUBackendError: If the process computes on the CPU, or the card's answer differs from the CPU's.
+ """
+ if FALLBACK_REASON is not None:
+ raise GPUBackendError(FALLBACK_REASON)
+
+ device = _probe_sums(xp)
+ host = _probe_sums(np)
+ if device != host:
+ raise GPUBackendError(GPU_PROBE_MISMATCH.format(device=device, host=host))
+
+ properties = xp.cuda.runtime.getDeviceProperties(xp.cuda.runtime.getDevice())
+ return f"{describe_array_backend()} on {properties['name'].decode()}"
+
def report_array_backend() -> None:
"""States which array backend the process computes on.
@@ -67,13 +144,13 @@ def report_array_backend() -> None:
a run of the application, or a reconstruction from the command line — and leaves a tool that
imports the array vocabulary for its types alone to its own output.
"""
- if CUPY_AVAILABLE:
- logger.info(f"CuPy {xp.__version__} is active")
+ if FALLBACK_REASON is None:
+ logger.info(f"{describe_array_backend()} is active")
return
warnings.formatwarning = _format_warning_no_location
- warnings.warn(CUPY_MISSING_MESSAGE, CuPyNotInstalledWarning)
- logger.warning(CUPY_MISSING_MESSAGE)
+ warnings.warn(FALLBACK_REASON, CuPyUnavailableWarning)
+ logger.warning(FALLBACK_REASON)
def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
@@ -88,6 +165,9 @@ def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
__all__ = [
"CUPY_AVAILABLE",
+ "FALLBACK_REASON",
+ "describe_array_backend",
+ "exercise_gpu",
"report_array_backend",
"to_numpy",
"xp",
diff --git a/src/sampletones_shared/distribution.py b/src/sampletones_shared/distribution.py
new file mode 100644
index 000000000..e4ff0e906
--- /dev/null
+++ b/src/sampletones_shared/distribution.py
@@ -0,0 +1,27 @@
+from importlib import metadata
+from typing import Final
+
+from sampletones_shared.application import SAMPLETONES_NAME, SAMPLETONES_PACKAGE_NAME
+
+PROJECT_URL_FIELD: Final[str] = "Project-URL"
+PROJECT_URL_SEPARATOR: Final[str] = ","
+DOCUMENTATION_LABEL: Final[str] = "Documentation"
+
+
+def project_address(label: str) -> str:
+ """The address the installed distribution lists under ``label``, as ``[project.urls]`` names it.
+
+ Raises:
+ KeyError: If the distribution lists no address under that label.
+ """
+ for entry in metadata.metadata(SAMPLETONES_PACKAGE_NAME).get_all(PROJECT_URL_FIELD) or []:
+ name, _, address = str(entry).partition(PROJECT_URL_SEPARATOR)
+ if name.strip() == label:
+ return address.strip()
+
+ raise KeyError(f"The {SAMPLETONES_PACKAGE_NAME} distribution lists no {label} address")
+
+
+SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME)
+SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}"
+SAMPLETONES_DOCUMENTATION_ADDRESS: Final[str] = project_address(DOCUMENTATION_LABEL)
diff --git a/src/sampletones_shared/documentation/__init__.py b/src/sampletones_shared/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_shared/documentation/site.py b/src/sampletones_shared/documentation/site.py
new file mode 100644
index 000000000..5f4fd797e
--- /dev/null
+++ b/src/sampletones_shared/documentation/site.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, Optional
+
+from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ASSETS_PACKAGE
+from sampletones_shared.utils.hashing import calculate_hash
+
+DOCUMENTATION_DIRECTORY: Final[str] = "documentation"
+INDEX_FILE: Final[str] = "index.html"
+STAMP_FILE: Final[str] = "stamp"
+FULL_DIGEST: Final[int] = 64
+ENTRY_SEPARATOR: Final[str] = "\n"
+
+
+@dataclass(frozen=True)
+class DocumentationSite:
+ """The HTML documentation as a folder of pages, and the stamp that names its contents.
+
+ The maker writes the stamp last, from the files beside it, so a site whose stamp matches its
+ contents was written whole. A copy of the site keyed by its stamp is a copy of these exact pages.
+ """
+
+ directory: Path
+
+ @classmethod
+ def shipped(cls) -> DocumentationSite:
+ """The site that ships with the application, inside its assets package."""
+ return cls(package_directory(ASSETS_PACKAGE) / DOCUMENTATION_DIRECTORY)
+
+ @property
+ def index(self) -> Path:
+ return self.directory / INDEX_FILE
+
+ @property
+ def stamp_path(self) -> Path:
+ return self.directory / STAMP_FILE
+
+ def read_stamp(self) -> Optional[str]:
+ """The stamp the site was written with, or ``None`` where no complete site lies."""
+ if not self.stamp_path.is_file():
+ return None
+
+ return self.stamp_path.read_text(encoding="utf-8").strip()
+
+ def content_stamp(self) -> str:
+ """The stamp the site's files earn now: a hash over every file but the stamp, by path and contents."""
+ entries = sorted(
+ f"{path.relative_to(self.directory).as_posix()}:{calculate_hash(path.read_bytes(), length=FULL_DIGEST)}"
+ for path in self.directory.rglob("*")
+ if path.is_file() and path != self.stamp_path
+ )
+ return calculate_hash(ENTRY_SEPARATOR.join(entries))
+
+ def write_stamp(self) -> str:
+ """Seal the site: write the stamp its files earn, and return it."""
+ stamp = self.content_stamp()
+ self.stamp_path.write_text(stamp + ENTRY_SEPARATOR, encoding="utf-8", newline=ENTRY_SEPARATOR)
+ return stamp
diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py
index 08c783f8c..9472b50db 100644
--- a/src/sampletones_shared/exceptions/__init__.py
+++ b/src/sampletones_shared/exceptions/__init__.py
@@ -6,8 +6,9 @@
)
from .base import SampleToNESError
from .callback import CallbackQueueStop
-from .cuda import CuPyNotInstalledWarning
+from .cuda import CuPyUnavailableWarning, GPUBackendError
from .dialog import FileDialogUnavailableError
+from .documentation import DocumentationUnavailableError
from .instrument import (
IncompatibleInstrumentVersionError,
InstrumentError,
@@ -29,6 +30,7 @@
NoLibraryDataError,
UnhandledLibraryError,
)
+from .opening import FileNotOpenedError
from .operation import OperationCanceled
from .player import (
DriverBuildError,
@@ -67,10 +69,13 @@
__all__ = [
"AudioWriteError",
"CallbackQueueStop",
- "CuPyNotInstalledWarning",
+ "CuPyUnavailableWarning",
"DeserializationError",
+ "DocumentationUnavailableError",
"DriverBuildError",
"FileDialogUnavailableError",
+ "FileNotOpenedError",
+ "GPUBackendError",
"IncompatibleInstrumentVersionError",
"IncompatibleLibraryDataVersionError",
"IncompatibleProjectVersionError",
diff --git a/src/sampletones_shared/exceptions/cuda.py b/src/sampletones_shared/exceptions/cuda.py
index 932b50f7c..7a354f818 100644
--- a/src/sampletones_shared/exceptions/cuda.py
+++ b/src/sampletones_shared/exceptions/cuda.py
@@ -1,5 +1,9 @@
-from .base import SampleToNESWarning
+from .base import SampleToNESError, SampleToNESWarning
-class CuPyNotInstalledWarning(SampleToNESWarning):
- """Warning raised when CuPy is not installed."""
+class CuPyUnavailableWarning(SampleToNESWarning):
+ """Warning raised when the process computes on NumPy because CuPy is missing or finds no graphics card."""
+
+
+class GPUBackendError(SampleToNESError):
+ """Raised when a build held to the GPU backend computes on the CPU, or the card answers wrong."""
diff --git a/src/sampletones_shared/exceptions/documentation.py b/src/sampletones_shared/exceptions/documentation.py
new file mode 100644
index 000000000..b62e5130b
--- /dev/null
+++ b/src/sampletones_shared/exceptions/documentation.py
@@ -0,0 +1,5 @@
+from .base import SampleToNESError
+
+
+class DocumentationUnavailableError(SampleToNESError):
+ """Raised when the installation carries no complete documentation site to open."""
diff --git a/src/sampletones_shared/exceptions/opening.py b/src/sampletones_shared/exceptions/opening.py
new file mode 100644
index 000000000..9fc13005b
--- /dev/null
+++ b/src/sampletones_shared/exceptions/opening.py
@@ -0,0 +1,5 @@
+from .base import SampleToNESError
+
+
+class FileNotOpenedError(SampleToNESError, OSError):
+ """Raised when no program on the system takes a file to show."""
diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py
index 2e415f2ff..00faa138e 100644
--- a/src/sampletones_shared/paths/resources.py
+++ b/src/sampletones_shared/paths/resources.py
@@ -3,6 +3,7 @@
from sampletones_shared.paths.package import package_directory
+ASSETS_PACKAGE: Final[str] = "sampletones_assets"
CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_config")
ICON_DIRECTORY: Final[str] = "icons"
diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py
index 6a34673d3..1d5b6164d 100644
--- a/src/sampletones_shared/paths/user.py
+++ b/src/sampletones_shared/paths/user.py
@@ -17,6 +17,8 @@
PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
+APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml"
+DOCUMENTATION_COPIES_DIRECTORY: Final[Path] = USER_PATH_DATA / "documentation"
PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py
index 69e9ba481..b14d866e4 100644
--- a/src/sampletones_shared/utils/color.py
+++ b/src/sampletones_shared/utils/color.py
@@ -8,6 +8,13 @@
from sampletones_shared.utils.arrays import clamp
MAX_CHANNEL_VALUE: Final[int] = 255
+RGB_CHANNELS: Final[int] = 3
+SRGB_LINEAR_LIMIT: Final[float] = 0.03928
+SRGB_LINEAR_SLOPE: Final[float] = 12.92
+SRGB_OFFSET: Final[float] = 0.055
+SRGB_GAMMA: Final[float] = 2.4
+LUMINANCE_WEIGHTS: Final[np.ndarray] = np.array([0.2126, 0.7152, 0.0722])
+CONTRAST_OFFSET: Final[float] = 0.05
def with_alpha_fraction(color: ColorRGBA, fraction: float) -> ColorRGBA:
@@ -53,6 +60,35 @@ def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA:
return (int(channels[0]), int(channels[1]), int(channels[2]), int(channels[3]))
+def relative_luminance(color: ColorRGBA) -> float:
+ """Return how bright ``color`` reads, from ``0.0`` for black to ``1.0`` for white, as the WCAG defines it.
+
+ Each channel is linearized out of the sRGB curve before the channels are weighted, so two
+ colors with the same figure read equally bright whatever their hue. The alpha is left out:
+ the figure describes the color as drawn.
+ """
+ channels = np.array(color[:RGB_CHANNELS], dtype=np.float64) / MAX_CHANNEL_VALUE
+ linear = np.where(
+ channels <= SRGB_LINEAR_LIMIT,
+ channels / SRGB_LINEAR_SLOPE,
+ ((channels + SRGB_OFFSET) / (1.0 + SRGB_OFFSET)) ** SRGB_GAMMA,
+ )
+ return float(np.dot(LUMINANCE_WEIGHTS, linear))
+
+
+def contrast_ratio(first: ColorRGBA, second: ColorRGBA) -> float:
+ """Return the WCAG contrast between two colors as drawn, from ``1.0`` for alike to ``21.0`` for black on white.
+
+ The ratio reads the same whichever color is the text and whichever the background, so a
+ floor a palette is held to can be stated once for every pairing.
+ """
+ first_luminance = relative_luminance(first)
+ second_luminance = relative_luminance(second)
+ lighter = max(first_luminance, second_luminance)
+ darker = min(first_luminance, second_luminance)
+ return (lighter + CONTRAST_OFFSET) / (darker + CONTRAST_OFFSET)
+
+
def to_grayscale(color: ColorRGBA) -> ColorRGBA:
"""Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha.
diff --git a/src/sampletones_shared/utils/system/opening/__init__.py b/src/sampletones_shared/utils/system/opening/__init__.py
new file mode 100644
index 000000000..9fa1e847e
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/__init__.py
@@ -0,0 +1,7 @@
+from .protocol import FileOpener
+from .selection import select_file_opener
+
+__all__ = [
+ "FileOpener",
+ "select_file_opener",
+]
diff --git a/src/sampletones_shared/utils/system/opening/browser.py b/src/sampletones_shared/utils/system/opening/browser.py
new file mode 100644
index 000000000..ef9ca8478
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/browser.py
@@ -0,0 +1,16 @@
+import webbrowser
+from pathlib import Path
+
+from sampletones_shared.exceptions import FileNotOpenedError
+
+
+class BrowserFileOpener:
+ """Shows a file through the standard library's ``webbrowser``, as a ``file://`` link.
+
+ ``webbrowser`` reaches the system's own handler on Windows and macOS and the browsers it finds
+ elsewhere, so it serves every system ``xdg-open`` is missing from.
+ """
+
+ def open(self, path: Path) -> None:
+ if not webbrowser.open(path.resolve().as_uri()):
+ raise FileNotOpenedError(f"No browser took {path}")
diff --git a/src/sampletones_shared/utils/system/opening/protocol.py b/src/sampletones_shared/utils/system/opening/protocol.py
new file mode 100644
index 000000000..dc2f4826b
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/protocol.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+from typing import Protocol
+
+
+class FileOpener(Protocol):
+ """The surface that shows a file in the program the system gives its kind.
+
+ Callers hand a file over through this type, a page to the browser among them, and read alike
+ on every system.
+ """
+
+ def open(self, path: Path) -> None:
+ """Shows ``path`` in the program the system gives its kind.
+
+ Raises:
+ OSError: If no program takes the file.
+ """
diff --git a/src/sampletones_shared/utils/system/opening/selection.py b/src/sampletones_shared/utils/system/opening/selection.py
new file mode 100644
index 000000000..400ece143
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/selection.py
@@ -0,0 +1,20 @@
+import shutil
+
+from sampletones_shared.utils.system.system import System
+
+from .browser import BrowserFileOpener
+from .protocol import FileOpener
+from .xdg_open import XDG_OPEN, XdgOpenFileOpener
+
+
+def select_file_opener() -> FileOpener:
+ """The file opener that fits the running system.
+
+ A Linux desktop with ``xdg-open`` opens a file through it, which hands the file to the
+ desktop's own program for its kind; every other system opens it through the standard library's
+ browser support.
+ """
+ if System.current() == System.LINUX and shutil.which(XDG_OPEN) is not None:
+ return XdgOpenFileOpener()
+
+ return BrowserFileOpener()
diff --git a/src/sampletones_shared/utils/system/opening/xdg_open.py b/src/sampletones_shared/utils/system/opening/xdg_open.py
new file mode 100644
index 000000000..e88e9e19f
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/xdg_open.py
@@ -0,0 +1,26 @@
+import subprocess
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.utils.system.programs import program_environment
+
+XDG_OPEN: Final[str] = "xdg-open"
+
+
+class XdgOpenFileOpener:
+ """Shows a file through ``xdg-open``, which hands it to the desktop's program for its kind.
+
+ ``xdg-open`` starts in a session of its own, so the program it starts outlives the
+ application, and it starts with the reader's own library path, so a browser started from a
+ bundle loads the system's libraries.
+ """
+
+ def open(self, path: Path) -> None:
+ subprocess.Popen( # pylint: disable=consider-using-with
+ [XDG_OPEN, str(path)],
+ env=program_environment(),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py
index dc4c3b3e1..7dd75b6ea 100644
--- a/src/sampletones_shared/utils/system/paths.py
+++ b/src/sampletones_shared/utils/system/paths.py
@@ -5,6 +5,7 @@
from sampletones_shared.types.path import GeneralPathlike, Pathlike
+from .programs import program_environment
from .system import System
DEFAULT_MAX_FILENAME_DISPLAY: Final[int] = 48
@@ -292,7 +293,7 @@ def open_directory_in_explorer_linux(path: Path) -> None:
path (Path): The directory or file path to open.
"""
path = path if path.is_dir() else path.parent
- subprocess.run(["xdg-open", str(path)], check=False)
+ subprocess.run(["xdg-open", str(path)], check=False, env=program_environment())
def open_file_in_explorer_linux(path: Path) -> None:
@@ -312,6 +313,7 @@ def open_file_in_explorer_linux(path: Path) -> None:
capture_output=True,
text=True,
check=False,
+ env=program_environment(),
)
if xdg_mime_result.returncode != 0:
@@ -330,7 +332,7 @@ def open_file_in_explorer_linux(path: Path) -> None:
command = file_manager_commands.get(desktop_file)
if command:
- result = subprocess.run(command, check=False, capture_output=True)
+ result = subprocess.run(command, check=False, capture_output=True, env=program_environment())
if result.returncode != 0:
open_directory_in_explorer_linux(path)
else:
@@ -363,14 +365,14 @@ def open_path_in_explorer(path: Pathlike) -> None:
match system:
case System.WINDOWS:
select = "/select," if path.is_file() else ""
- subprocess.run(["explorer", select, path_string], check=False)
+ subprocess.run(["explorer", select, path_string], check=False, env=program_environment())
case System.LINUX:
if path.is_file():
open_file_in_explorer_linux(path)
else:
- subprocess.run(["xdg-open", path_string], check=False)
+ subprocess.run(["xdg-open", path_string], check=False, env=program_environment())
case System.MACOS:
select = "-R" if path.is_file() else ""
- subprocess.run(["open", select, path_string], check=False)
+ subprocess.run(["open", select, path_string], check=False, env=program_environment())
case _:
raise OSError(f"Unsupported operating system: {system}")
diff --git a/src/sampletones_shared/utils/system/programs.py b/src/sampletones_shared/utils/system/programs.py
index 52e39136c..7a058dc7c 100644
--- a/src/sampletones_shared/utils/system/programs.py
+++ b/src/sampletones_shared/utils/system/programs.py
@@ -1,9 +1,14 @@
+import os
import shutil
+import sys
from pathlib import Path
-from typing import Mapping, Optional
+from typing import Dict, Final, Mapping, Optional
from .system import System
+LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH"
+ORIGINAL_LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH_ORIG"
+
def locate_program(program: str) -> Optional[Path]:
"""Where a program is installed, found the way a shell finds it.
@@ -44,3 +49,42 @@ def missing_program_message(
KeyError: If no hint is stated for this system.
"""
return f"{program} is missing: {purpose} ({hints[System.current()]})"
+
+
+def running_frozen() -> bool:
+ """Whether the application runs from a PyInstaller bundle, which marks ``sys`` as frozen."""
+ return bool(getattr(sys, "frozen", False))
+
+
+def reader_environment(
+ environment: Mapping[str, str],
+ *,
+ frozen: bool,
+) -> Dict[str, str]:
+ """The variables ``environment`` holds, with the library path the reader's own session set.
+
+ A PyInstaller bundle points ``LD_LIBRARY_PATH`` at the libraries it carries and keeps the
+ reader's own value in ``LD_LIBRARY_PATH_ORIG``. A frozen run therefore answers the reader's
+ value, or no library path where the reader set none, and any other run answers ``environment``
+ as it stands.
+ """
+ variables = dict(environment)
+ if not frozen:
+ return variables
+
+ original = variables.pop(ORIGINAL_LIBRARY_PATH, None)
+ if original is None:
+ variables.pop(LIBRARY_PATH, None)
+ else:
+ variables[LIBRARY_PATH] = original
+
+ return variables
+
+
+def program_environment() -> Dict[str, str]:
+ """The variables a program of the system starts with: this process's, with the reader's library path.
+
+ A file manager, a browser or a dialog tool started with them loads the system's libraries, in a
+ bundle as in an installed copy.
+ """
+ return reader_environment(os.environ, frozen=running_frozen())
diff --git a/src/sampletones_tools/calibration/board/browser.py b/src/sampletones_tools/calibration/board/browser.py
index 644b60b25..4139f6419 100644
--- a/src/sampletones_tools/calibration/board/browser.py
+++ b/src/sampletones_tools/calibration/board/browser.py
@@ -1,7 +1,7 @@
-import webbrowser
from pathlib import Path
from sampletones_shared.logger import logger
+from sampletones_shared.utils.system.opening import select_file_opener
def open_page(page: Path) -> bool:
@@ -17,8 +17,10 @@ def open_page(page: Path) -> bool:
Returns:
bool: Whether a browser took the page.
"""
- opened = webbrowser.open(page.resolve().as_uri())
- if not opened:
+ try:
+ select_file_opener().open(page.resolve())
+ except OSError:
logger.info("No browser answered; open the page from the link above")
+ return False
- return opened
+ return True
diff --git a/src/sampletones_tools/calibration/board/composition.py b/src/sampletones_tools/calibration/board/composition.py
index 298a3ba9b..a2bb804dd 100644
--- a/src/sampletones_tools/calibration/board/composition.py
+++ b/src/sampletones_tools/calibration/board/composition.py
@@ -6,10 +6,10 @@
from sampletones_shared.utils.system.paths import get_filename
from sampletones_tools.calibration.layout import RECORDINGS_DIRECTORY, combination_name
from sampletones_tools.calibration.renders import RenderRecord, item_directory, variant_directory
+from sampletones_tools.web.roles.table import channel_role
from .clips import ClipStore
from .model import BoardCell, BoardChannel, BoardGroup, BoardPage, BoardRow
-from .palette import channel_token
from .reading import RunReading
NO_CLIP: Final[str] = ""
@@ -19,6 +19,7 @@ def compose_page(
runs: Sequence[RunReading],
store: ClipStore,
title: str,
+ glyph: str,
) -> BoardPage:
"""
Assemble everything a page draws from the runs it reports.
@@ -31,6 +32,7 @@ def compose_page(
runs: The runs the page reports, in the order the page holds them.
store: Where the page finds each clip it plays.
title: The heading the page carries.
+ glyph: The purpose glyph each table's card header carries.
Returns:
The page, ready to be written.
@@ -45,7 +47,8 @@ def compose_page(
return BoardPage(
title=title,
referee=runs[0].referee,
- colors={channel.value: channel_token(channel.value) for channel in ChannelName},
+ glyph=glyph,
+ colors={channel.value: channel_role(channel.value) for channel in ChannelName},
groups=groups,
)
diff --git a/src/sampletones_tools/calibration/board/fonts.py b/src/sampletones_tools/calibration/board/fonts.py
deleted file mode 100644
index c6cb82861..000000000
--- a/src/sampletones_tools/calibration/board/fonts.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from base64 import b64encode
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Final, Tuple
-
-from sampletones_shared.paths.resources import (
- FONT_MONO_REGULAR,
- FONT_SANS_BOLD,
- FONT_SANS_REGULAR,
-)
-
-from .paths import FONTS_DIRECTORY
-
-SANS_FAMILY: Final[str] = "SampleToNES Sans"
-MONO_FAMILY: Final[str] = "SampleToNES Mono"
-
-REGULAR_WEIGHT: Final[int] = 400
-BOLD_WEIGHT: Final[int] = 600
-
-FONT_MEDIA_TYPE: Final[str] = "font/ttf"
-
-
-@dataclass(frozen=True)
-class PageFont:
- """One face the page draws with, and the file it is read from.
-
- Attributes:
- family: The family name the stylesheet asks for.
- weight: The weight the face answers at.
- filename: The face's file in the assets package.
- """
-
- family: str
- weight: int
- filename: str
-
-
-PAGE_FONTS: Final[Tuple[PageFont, ...]] = (
- PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_SANS_REGULAR),
- PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, filename=FONT_SANS_BOLD),
- PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_MONO_REGULAR),
-)
-
-
-def fonts_stylesheet(directory: Path = FONTS_DIRECTORY) -> str:
- """The faces of the application, embedded so the page draws with them wherever it is opened.
-
- A page opened from a file has an origin of its own, and a face carried inside the stylesheet
- reaches it there as surely as it does over the web.
-
- Args:
- directory: The directory the face files are read from.
-
- Returns:
- The stylesheet, one ``@font-face`` rule per face.
-
- Raises:
- FileNotFoundError: If the directory holds no file of a face's name.
- """
- return "\n".join(_face(font, directory) for font in PAGE_FONTS) + "\n"
-
-
-def _face(font: PageFont, directory: Path) -> str:
- encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii")
- return (
- "@font-face {\n"
- f' font-family: "{font.family}";\n'
- f" font-weight: {font.weight};\n"
- " font-style: normal;\n"
- " font-display: block;\n"
- f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");\n'
- "}"
- )
diff --git a/src/sampletones_tools/calibration/board/layout.py b/src/sampletones_tools/calibration/board/layout.py
index e0bb3d52f..baf340c8c 100644
--- a/src/sampletones_tools/calibration/board/layout.py
+++ b/src/sampletones_tools/calibration/board/layout.py
@@ -3,12 +3,10 @@
PAGE_FILE: Final[str] = "index.html"
STYLE_FILE: Final[str] = "style.css"
SCRIPT_FILE: Final[str] = "page.js"
-PALETTE_FILE: Final[str] = "palette.css"
-FONTS_FILE: Final[str] = "fonts.css"
DATA_FILE: Final[str] = "data.js"
PAGE_DIRECTORY: Final[str] = "page"
AUDIO_DIRECTORY: Final[str] = "audio"
SHIPPED_FILES: Final[Tuple[str, ...]] = (PAGE_FILE, STYLE_FILE, SCRIPT_FILE)
-GENERATED_FILES: Final[Tuple[str, ...]] = (PALETTE_FILE, FONTS_FILE, DATA_FILE)
+GENERATED_FILES: Final[Tuple[str, ...]] = (DATA_FILE,)
diff --git a/src/sampletones_tools/calibration/board/model.py b/src/sampletones_tools/calibration/board/model.py
index b8387e5b1..7e82665c8 100644
--- a/src/sampletones_tools/calibration/board/model.py
+++ b/src/sampletones_tools/calibration/board/model.py
@@ -79,7 +79,8 @@ class BoardPage(BaseModel):
Attributes:
title: The heading the page carries.
referee: The name of the referee whose scores the cells report.
- colors: The palette token each channel's color stands under, by channel name.
+ glyph: The purpose glyph each table's card header carries.
+ colors: The role each channel's color stands under, by channel name.
groups: The tables the page holds, one per tab.
"""
@@ -87,5 +88,6 @@ class BoardPage(BaseModel):
title: str
referee: str
+ glyph: str
colors: Dict[str, str]
groups: Tuple[BoardGroup, ...]
diff --git a/src/sampletones_tools/calibration/board/palette.py b/src/sampletones_tools/calibration/board/palette.py
deleted file mode 100644
index 238d0284a..000000000
--- a/src/sampletones_tools/calibration/board/palette.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from typing import Final, Tuple
-
-from sampletones_application.paths import PALETTES_DIRECTORY
-from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME, PaletteCatalog
-from sampletones_application.utils.palette.palette import Palette
-from sampletones_shared.types.application import ColorRGBA
-
-DEFAULT_PALETTE: Final[str] = DEFAULT_PALETTE_NAME
-OPAQUE_ALPHA: Final[int] = 255
-CHANNEL_TOKEN_PREFIX: Final[str] = "channel_"
-
-
-def board_palette(name: str) -> Palette:
- """The palette a page is drawn in, by the name the application knows it under.
-
- Raises:
- KeyError: If the build ships no palette of that name.
- """
- return PaletteCatalog.load(PALETTES_DIRECTORY).get(name)
-
-
-def palette_names() -> Tuple[str, ...]:
- """Every palette name a page may be drawn in, the application's own default among them."""
- return PaletteCatalog.load(PALETTES_DIRECTORY).names
-
-
-def palette_stylesheet(palette: Palette) -> str:
- """The palette's tokens as custom properties, under the names the application states them by.
-
- A token keeps its name from the palette file to the page, so a color the application draws with
- and the color the page draws with are one value read from one place.
-
- Args:
- palette: The palette the page is drawn in.
-
- Returns:
- The stylesheet, one ``:root`` block holding every token.
- """
- declarations = "".join(f" --{token}: {css_color(color)};\n" for token, color in sorted(palette.colors.items()))
- return f":root {{\n{declarations}}}\n"
-
-
-def channel_token(channel: str) -> str:
- """The palette token a channel's color stands under."""
- return f"{CHANNEL_TOKEN_PREFIX}{channel}"
-
-
-def css_color(color: ColorRGBA) -> str:
- """A palette color as CSS writes it: six hexadecimal digits, eight where it is translucent."""
- red, green, blue, alpha = color
- opaque = f"#{red:02x}{green:02x}{blue:02x}"
- return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}"
diff --git a/src/sampletones_tools/calibration/board/paths.py b/src/sampletones_tools/calibration/board/paths.py
index fcd858834..8c56729ef 100644
--- a/src/sampletones_tools/calibration/board/paths.py
+++ b/src/sampletones_tools/calibration/board/paths.py
@@ -4,7 +4,5 @@
from sampletones_shared.paths.package import package_directory
STATIC_PACKAGE: Final[str] = "sampletones_tools.calibration.board.static"
-FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts"
STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE)
-FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE)
diff --git a/src/sampletones_tools/calibration/board/session.py b/src/sampletones_tools/calibration/board/session.py
index d822c6164..03ad5b3fb 100644
--- a/src/sampletones_tools/calibration/board/session.py
+++ b/src/sampletones_tools/calibration/board/session.py
@@ -3,13 +3,15 @@
from pydantic import BaseModel, ConfigDict, Field
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_tools.web.design import ApplicationDesign
+from sampletones_tools.web.writer import write_layout
+
from .assets import write_assets
from .clips import clip_store
from .composition import compose_page
from .data import data_script
-from .fonts import fonts_stylesheet
-from .layout import DATA_FILE, FONTS_FILE, PALETTE_FILE
-from .palette import DEFAULT_PALETTE, board_palette, palette_stylesheet
+from .layout import DATA_FILE, PAGE_DIRECTORY
from .reading import read_runs
PAGE_TITLE: Final[str] = "Calibration"
@@ -29,7 +31,7 @@ class BoardRequest(BaseModel):
runs: List[Path] = Field(min_length=1)
output: Path
- palette: str = DEFAULT_PALETTE
+ palette: str = DEFAULT_PALETTE_NAME
title: str = PAGE_TITLE
@@ -37,8 +39,9 @@ def build_board(request: BoardRequest) -> Path:
"""
Build the page a person listens to a run on, from what the runs already wrote.
- Everything the page needs travels with it: the palette as custom properties, the application's
- own faces inside the stylesheet, and the run's contents as data. A page written into the run it
+ Everything the page needs travels with it: the web layout drawn from the application's own
+ configuration in the requested palette, the application's faces inside a stylesheet, and the
+ run's contents as data. A page written into the run it
reports plays that run's clips where they lie; anywhere else it carries copies, so either page
opens straight from the folder it was written into.
@@ -53,13 +56,8 @@ def build_board(request: BoardRequest) -> Path:
KeyError: If the build ships no palette of the requested name.
"""
runs = read_runs(request.runs)
+ design = ApplicationDesign.load(request.palette)
request.output.mkdir(parents=True, exist_ok=True)
- page = compose_page(runs, clip_store(runs, request.output), request.title)
- return write_assets(
- request.output,
- {
- PALETTE_FILE: palette_stylesheet(board_palette(request.palette)),
- FONTS_FILE: fonts_stylesheet(),
- DATA_FILE: data_script(page),
- },
- )
+ page = compose_page(runs, clip_store(runs, request.output), request.title, design.glyphs.headers.reconstruction)
+ write_layout(request.output / PAGE_DIRECTORY, design)
+ return write_assets(request.output, {DATA_FILE: data_script(page)})
diff --git a/src/sampletones_tools/calibration/board/static/index.html b/src/sampletones_tools/calibration/board/static/index.html
index 8d283fe49..5e0c97440 100644
--- a/src/sampletones_tools/calibration/board/static/index.html
+++ b/src/sampletones_tools/calibration/board/static/index.html
@@ -4,23 +4,42 @@
Calibration
-
+
+
+
+
+
+
+
+
-
-
Calibration
-
-
+
+
+
+ SampleToNES
+
+ Calibration
-
-
diff --git a/src/sampletones_tools/calibration/board/static/page.js b/src/sampletones_tools/calibration/board/static/page.js
index 0e1fb2b80..17c1c62c3 100644
--- a/src/sampletones_tools/calibration/board/static/page.js
+++ b/src/sampletones_tools/calibration/board/static/page.js
@@ -36,8 +36,8 @@ function channelRow(channel, color) {
swatch.style.background = `var(--${color})`;
swatch.title = channel.name;
row.append(swatch);
- if (channel.solo) { row.append(clip("S", channel.solo, "clip part")); }
- if (channel.mute) { row.append(clip("M", channel.mute, "clip part")); }
+ if (channel.solo) { row.append(clip("S", channel.solo, "button secondary compact clip part")); }
+ if (channel.mute) { row.append(clip("M", channel.mute, "button secondary compact clip part")); }
const bar = element("div", "bar");
bar.style.backgroundImage = gradient(channel.timeline, color);
bar.title = channel.name;
@@ -53,7 +53,7 @@ function closed(cell) {
function cellNode(cell, colors) {
const node = element("div", "cell");
- node.append(clip("play", cell.render, "clip"));
+ node.append(clip("play", cell.render, "button clip"));
const reading = element("div", "reading");
reading.append(element("b", null, cell.score.toFixed(2)));
reading.append(element("span", null, closed(cell)));
@@ -73,7 +73,7 @@ function rowNode(row, colors) {
sound.append(element("span", null, row.category));
node.append(sound);
const recording = element("td");
- recording.append(clip("recording", row.recording, "clip"));
+ recording.append(clip("recording", row.recording, "button clip"));
node.append(recording);
for (const cell of row.cells) {
const holder = element("td");
@@ -112,14 +112,14 @@ function legendNode(group, colors) {
return legend;
}
-function groupNode(group, colors) {
+function groupNode(group, glyph, colors) {
const card = element("section", "card");
card.dataset.group = group.name;
- const heading = element("h2");
- heading.append(element("span", null, group.name), legendNode(group, colors));
- card.append(heading);
- const scroll = element("div", "scroll");
- const table = element("table");
+ const header = element("div", "card-header");
+ header.append(element("span", "card-glyph", glyph), element("span", "card-title", group.name), legendNode(group, colors));
+ card.append(header);
+ const scroll = element("div", "table-scroll");
+ const table = element("table", "table");
const head = element("tr");
for (const heading of ["sound", "recording", ...group.columns, "change"]) {
head.append(element("th", null, heading));
@@ -207,13 +207,14 @@ function build(board) {
`Scored by ${board.referee}; lower is closer to the recording.`;
const groups = document.getElementById("groups");
for (const group of board.groups) {
- groups.append(groupNode(group, board.colors));
+ groups.append(groupNode(group, board.glyph, board.colors));
}
if (board.groups.length > 1) {
const tabs = document.getElementById("tabs");
tabs.hidden = false;
for (const group of board.groups) {
- const tab = element("button", null, group.name);
+ const tab = element("button", "tab", group.name);
+ tab.setAttribute("role", "tab");
tab.dataset.group = group.name;
tab.addEventListener("click", () => selectGroup(group.name));
tabs.append(tab);
diff --git a/src/sampletones_tools/calibration/board/static/style.css b/src/sampletones_tools/calibration/board/static/style.css
index 2b5069e05..d3d016c6f 100644
--- a/src/sampletones_tools/calibration/board/static/style.css
+++ b/src/sampletones_tools/calibration/board/static/style.css
@@ -1,113 +1,44 @@
:root {
- --page-gap: 8px;
- --card-radius: 8px;
- --frame-radius: 6px;
- --header-height: 35px;
--bar-height: 6px;
}
-* {
- box-sizing: border-box;
-}
-
-[hidden] {
- display: none !important;
-}
-
-body {
- margin: 0;
- padding: 16px 16px 32px;
- background: var(--ground);
- color: var(--text);
- font: 400 15px/1.45 "SampleToNES Sans", system-ui, sans-serif;
-}
-
-.head,
-main,
-.foot {
+.board {
+ display: flex;
+ flex-direction: column;
+ gap: var(--card-gap);
max-width: 1400px;
margin: 0 auto;
}
-h1 {
- margin: 0 0 4px;
- font-size: 22px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--accent);
-}
-
-.lead {
- margin: 0 0 var(--page-gap);
- color: var(--text_muted);
-}
-
-.tabs {
+.groups {
display: flex;
- flex-wrap: wrap;
- gap: 2px;
- margin-bottom: var(--page-gap);
- padding: 4px;
- background: var(--tab_strip);
- border: 1px solid var(--border);
- border-radius: var(--card-radius);
-}
-
-.tabs button {
- font: inherit;
- color: var(--text_muted);
- background: var(--tab);
- border: 1px solid transparent;
- border-radius: var(--frame-radius);
- padding: 6px 16px;
- cursor: pointer;
-}
-
-.tabs button:hover {
- background: var(--tab_hovered);
- color: var(--text);
+ flex-direction: column;
+ gap: var(--card-gap);
}
-.tabs button[aria-selected="true"] {
- background: var(--tab_active);
- border-color: var(--border);
- color: var(--text);
+.status-bar p {
+ margin: 0;
+ padding-block: var(--item-spacing-y);
}
-.card {
- margin-bottom: var(--page-gap);
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--card-radius);
- overflow: hidden;
+.card-header {
+ flex-wrap: wrap;
}
-.card > h2 {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- height: var(--header-height);
- margin: 0;
- padding: 0 12px;
- background: var(--surface_accent);
- border-bottom: 1px solid var(--border);
- color: var(--accent);
- font-size: 15px;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
+.card-header .card-title {
+ flex: 1 0 auto;
}
.legend {
display: flex;
- gap: 12px;
- color: var(--text_muted);
- font-family: "SampleToNES Mono", ui-monospace, monospace;
- font-size: 12px;
+ gap: calc(1.5 * var(--item-spacing-x));
+ margin-left: auto;
+ padding-left: var(--glyph-indent);
+ margin-right: var(--chevron-offset);
+ font-family: var(--font-mono);
+ font-size: var(--size-mono-small);
font-weight: 400;
- letter-spacing: 0;
+ color: var(--text-muted);
text-transform: none;
}
@@ -117,40 +48,8 @@ h1 {
gap: 4px;
}
-.scroll {
- overflow-x: auto;
-}
-
-table {
- width: 100%;
- border-collapse: collapse;
-}
-
-th,
-td {
- padding: 8px;
- text-align: left;
- vertical-align: top;
- border-bottom: 1px solid var(--table_border);
-}
-
-thead th {
- background: var(--table_header);
- color: var(--text);
- font-weight: 600;
- white-space: nowrap;
-}
-
-tbody tr:nth-child(odd) {
- background: var(--table_row);
-}
-
-tbody tr:nth-child(even) {
- background: var(--table_row_alt);
-}
-
-tbody tr.playing {
- outline: 1px solid var(--accent);
+.table tbody tr.playing {
+ outline: 1px solid var(--heading);
outline-offset: -1px;
}
@@ -159,67 +58,42 @@ tbody tr.playing {
}
.sound b {
- font-family: "SampleToNES Mono", ui-monospace, monospace;
+ font-family: var(--font-mono);
font-weight: 400;
}
.sound span {
display: block;
- color: var(--text_muted);
- font-size: 13px;
-}
-
-button.clip {
- font: 400 14px/1 "SampleToNES Sans", system-ui, sans-serif;
- color: var(--text);
- background: var(--button);
- border: 1px solid var(--border);
- border-radius: var(--frame-radius);
- padding: 7px 12px;
- cursor: pointer;
-}
-
-button.clip:hover {
- background: var(--button_hovered);
+ font-size: var(--size-sans-small);
+ color: var(--text-muted);
}
-button.clip[aria-pressed="true"] {
- background: var(--button_active);
- border-color: var(--accent);
-}
-
-button.part {
- padding: 2px 6px;
- font-family: "SampleToNES Mono", ui-monospace, monospace;
- font-size: 12px;
- background: var(--secondary);
-}
-
-button.part:hover {
- background: var(--secondary_hover);
+.sound,
+td.change {
+ width: 1%;
}
.cell {
display: inline-flex;
align-items: flex-start;
- gap: 10px;
+ gap: var(--item-spacing-x);
}
.reading {
min-width: 104px;
white-space: nowrap;
- font-family: "SampleToNES Mono", ui-monospace, monospace;
+ font-family: var(--font-mono);
}
.reading b {
+ font-size: var(--size-mono-medium);
font-weight: 400;
- font-size: 15px;
}
.reading span {
display: block;
- color: var(--text_muted);
- font-size: 12px;
+ font-size: var(--size-mono-small);
+ color: var(--text-muted);
}
.channels {
@@ -240,46 +114,17 @@ button.part:hover {
flex: 1;
min-width: 0;
height: var(--bar-height);
- border-radius: 2px;
background: var(--recess);
-}
-
-.swatch {
- width: 8px;
- height: 8px;
border-radius: 2px;
- flex: none;
-}
-
-.sound,
-td.change {
- width: 1%;
}
td.change {
- text-align: right;
white-space: nowrap;
- font-family: "SampleToNES Mono", ui-monospace, monospace;
-}
-
-.better {
- color: var(--accent);
-}
-
-.worse {
- color: var(--danger);
-}
-
-.foot {
- margin-top: var(--page-gap);
- color: var(--text_muted);
- font-size: 13px;
+ text-align: right;
+ font-family: var(--font-mono);
}
-kbd {
- padding: 0 5px;
- border: 1px solid var(--border);
- border-radius: 3px;
- color: var(--text);
- font-family: "SampleToNES Mono", ui-monospace, monospace;
+.button.part {
+ font-family: var(--font-mono);
+ font-size: var(--size-mono-small);
}
diff --git a/src/sampletones_tools/calibration/command.py b/src/sampletones_tools/calibration/command.py
index 74efa9684..36b6343b6 100644
--- a/src/sampletones_tools/calibration/command.py
+++ b/src/sampletones_tools/calibration/command.py
@@ -89,9 +89,9 @@ def run(arguments: Namespace) -> int:
def _measure(given: CalibrationArguments) -> int:
+ from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
from sampletones_core.headless.conversion.request import channels_named
from sampletones_shared.utils.validation import describe_failure
- from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE
from sampletones_tools.calibration.config.suite import SuiteConfig
from sampletones_tools.calibration.session import (
CalibrationRequest,
@@ -111,7 +111,7 @@ def _measure(given: CalibrationArguments) -> int:
perceptual_exponents=floats_named(given.perceptual_exponents, suite.perceptual_exponents),
temporal_weights=floats_named(given.temporal_weights, suite.temporal_weights),
channels=channels_named(given.channels) if given.channels is not None else list(suite.channels),
- palette=given.palette if given.palette is not None else DEFAULT_PALETTE,
+ palette=given.palette if given.palette is not None else DEFAULT_PALETTE_NAME,
)
except ValueError as error:
raise SystemExit(describe_failure(error)) from error
@@ -122,7 +122,7 @@ def _measure(given: CalibrationArguments) -> int:
def _compare(given: CalibrationArguments) -> int:
- from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE
+ from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
from sampletones_tools.calibration.board.session import BoardRequest, build_board
from sampletones_tools.calibration.session import default_page_output
@@ -130,7 +130,7 @@ def _compare(given: CalibrationArguments) -> int:
request = BoardRequest(
runs=list(given.board or []),
output=given.output if given.output is not None else default_page_output(),
- palette=given.palette if given.palette is not None else DEFAULT_PALETTE,
+ palette=given.palette if given.palette is not None else DEFAULT_PALETTE_NAME,
)
try:
page = build_board(request)
diff --git a/src/sampletones_tools/calibration/session.py b/src/sampletones_tools/calibration/session.py
index ad47d7d4d..9afe5d047 100644
--- a/src/sampletones_tools/calibration/session.py
+++ b/src/sampletones_tools/calibration/session.py
@@ -3,12 +3,12 @@
from pydantic import BaseModel, ConfigDict, Field
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
from sampletones_core.configs import Config
from sampletones_core.constants.enums import ChannelName, SpectrumMethod
from sampletones_shared.logger import logger
from sampletones_shared.paths.user import USER_PATH_DOCUMENTS
from sampletones_shared.utils.text import listed_items
-from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE
from sampletones_tools.calibration.board.session import BoardRequest, build_board
from sampletones_tools.calibration.config.corpus import CorpusConfig
from sampletones_tools.calibration.corpus.synthesis import build_corpus
@@ -103,7 +103,7 @@ class CalibrationRequest(BaseModel):
perceptual_exponents: List[float] = Field(min_length=1)
temporal_weights: List[float]
channels: List[ChannelName] = Field(min_length=1)
- palette: str = DEFAULT_PALETTE
+ palette: str = DEFAULT_PALETTE_NAME
class CalibrationOutcome(BaseModel):
diff --git a/src/sampletones_tools/web/__init__.py b/src/sampletones_tools/web/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_tools/web/colors.py b/src/sampletones_tools/web/colors.py
new file mode 100644
index 000000000..f173075b1
--- /dev/null
+++ b/src/sampletones_tools/web/colors.py
@@ -0,0 +1,22 @@
+from typing import Final
+
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import relative_luminance
+
+OPAQUE_ALPHA: Final[int] = 255
+DARK_SCHEME: Final[str] = "dark"
+LIGHT_SCHEME: Final[str] = "light"
+LIGHT_GROUND_LUMINANCE: Final[float] = 0.5
+
+
+def css_color(color: ColorRGBA) -> str:
+ """A color as CSS writes it: six hexadecimal digits, eight where it is translucent."""
+ red, green, blue, alpha = color
+ opaque = f"#{red:02x}{green:02x}{blue:02x}"
+ return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}"
+
+
+def color_scheme(ground: ColorRGBA) -> str:
+ """The CSS color scheme a page standing on ``ground`` asks for, so form controls and
+ scrollbars match it: ``light`` on a light ground, ``dark`` otherwise."""
+ return LIGHT_SCHEME if relative_luminance(ground) >= LIGHT_GROUND_LUMINANCE else DARK_SCHEME
diff --git a/src/sampletones_tools/web/design.py b/src/sampletones_tools/web/design.py
new file mode 100644
index 000000000..785bc563e
--- /dev/null
+++ b/src/sampletones_tools/web/design.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Tuple
+
+from sampletones_application.layout.fonts import FontsLayout
+from sampletones_application.layout.general import GeneralLayout
+from sampletones_application.layout.glyphs.glyphs import Glyphs
+from sampletones_application.paths import LAYOUT_DIRECTORY, PALETTES_DIRECTORY, THEME_DIRECTORY
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.utils.theme.catalog import ThemeSpecCatalog
+from sampletones_shared.utils.serialization import load_yaml_model, load_yaml_model_dir
+
+
+@dataclass(frozen=True)
+class ApplicationDesign:
+ """The application's design in one palette, as the configuration states it: the palette, the
+ themes its widgets are bound to, the general layout, the font sizes and the glyphs.
+
+ A page drawn from it wears what the application wears, read from the same files, so a
+ change to the application's look reaches every page that is built afterwards.
+ """
+
+ palette: Palette
+ themes: ThemeSpecCatalog
+ general: GeneralLayout
+ fonts: FontsLayout
+ glyphs: Glyphs
+
+ @classmethod
+ def load(cls, palette_name: str) -> ApplicationDesign:
+ """The design in the palette the application knows by ``palette_name``.
+
+ Raises:
+ KeyError: If the build ships no palette of that name.
+ """
+ palette = PaletteCatalog.load(PALETTES_DIRECTORY).get(palette_name)
+ source = PaletteSource(palette)
+ context: Dict[str, object] = {PALETTE_SOURCE_CONTEXT_KEY: source}
+ return cls(
+ palette=palette,
+ themes=ThemeSpecCatalog.load(THEME_DIRECTORY, source),
+ general=load_yaml_model_dir(LAYOUT_DIRECTORY / "general", GeneralLayout, context=context),
+ fonts=load_yaml_model(LAYOUT_DIRECTORY / "fonts.yaml", FontsLayout, context=context),
+ glyphs=load_yaml_model(LAYOUT_DIRECTORY / "glyphs.yaml", Glyphs, context=context),
+ )
+
+ @property
+ def glyph_characters(self) -> str:
+ """Every character the glyph catalog holds, each once, in a stable order."""
+ characters: Dict[str, None] = {}
+ for group in self.glyphs.model_dump().values():
+ for glyph in group.values():
+ characters.update(dict.fromkeys(glyph))
+
+ return "".join(sorted(characters))
+
+
+def palette_names() -> Tuple[str, ...]:
+ """Every palette name a page may be drawn in, the application's own default among them."""
+ return PaletteCatalog.load(PALETTES_DIRECTORY).names
diff --git a/src/sampletones_tools/web/fonts.py b/src/sampletones_tools/web/fonts.py
new file mode 100644
index 000000000..9288107f3
--- /dev/null
+++ b/src/sampletones_tools/web/fonts.py
@@ -0,0 +1,107 @@
+from base64 import b64encode
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Final, List, Optional, Tuple
+
+from sampletones_application.layout.fonts import Typeface
+from sampletones_shared.paths.resources import (
+ FONT_ICON,
+ FONT_MONO_BOLD,
+ FONT_MONO_REGULAR,
+ FONT_SANS_BOLD,
+ FONT_SANS_ITALIC,
+ FONT_SANS_REGULAR,
+)
+
+SANS_FAMILY: Final[str] = "SampleToNES Sans"
+MONO_FAMILY: Final[str] = "SampleToNES Mono"
+ICON_FAMILY: Final[str] = "SampleToNES Glyphs"
+
+REGULAR_WEIGHT: Final[int] = 400
+BOLD_WEIGHT: Final[int] = 700
+NORMAL_STYLE: Final[str] = "normal"
+ITALIC_STYLE: Final[str] = "italic"
+
+FONT_MEDIA_TYPE: Final[str] = "font/ttf"
+
+FONT_STACKS: Final[Dict[Typeface, str]] = {
+ Typeface.SANS: f'"{SANS_FAMILY}", "{ICON_FAMILY}", system-ui, sans-serif',
+ Typeface.MONO: f'"{MONO_FAMILY}", "{ICON_FAMILY}", ui-monospace, monospace',
+ Typeface.ICON: f'"{ICON_FAMILY}", sans-serif',
+}
+
+
+@dataclass(frozen=True)
+class PageFont:
+ """One face a page draws with, and the file it is read from."""
+
+ family: str
+ weight: int
+ style: str
+ filename: str
+
+
+PAGE_FONTS: Final[Tuple[PageFont, ...]] = (
+ PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, style=NORMAL_STYLE, filename=FONT_SANS_REGULAR),
+ PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, style=NORMAL_STYLE, filename=FONT_SANS_BOLD),
+ PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, style=ITALIC_STYLE, filename=FONT_SANS_ITALIC),
+ PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, style=NORMAL_STYLE, filename=FONT_MONO_REGULAR),
+ PageFont(family=MONO_FAMILY, weight=BOLD_WEIGHT, style=NORMAL_STYLE, filename=FONT_MONO_BOLD),
+)
+ICON_FONT: Final[PageFont] = PageFont(
+ family=ICON_FAMILY,
+ weight=REGULAR_WEIGHT,
+ style=NORMAL_STYLE,
+ filename=FONT_ICON,
+)
+
+
+def fonts_stylesheet(
+ directory: Path,
+ glyph_characters: str,
+) -> str:
+ """The application's faces, embedded so a page draws with them wherever it is opened.
+
+ A page opened from a file has an origin of its own, and browsers refuse a face read from a
+ folder other than the page's, so each face travels inside the stylesheet. The glyph face
+ answers only for the glyph characters, which keeps every other character in the text faces.
+
+ Raises:
+ FileNotFoundError: If ``directory`` holds no file of a face's name.
+ """
+ faces = [_face(font, directory, None) for font in PAGE_FONTS]
+ faces.append(_face(ICON_FONT, directory, unicode_range(glyph_characters)))
+ return "\n".join(faces) + "\n"
+
+
+def unicode_range(characters: str) -> str:
+ """The characters as a CSS ``unicode-range``, consecutive code points joined into spans."""
+ codepoints = sorted({ord(character) for character in characters})
+ spans: List[Tuple[int, int]] = []
+ for codepoint in codepoints:
+ if spans and codepoint == spans[-1][1] + 1:
+ spans[-1] = (spans[-1][0], codepoint)
+ else:
+ spans.append((codepoint, codepoint))
+
+ return ", ".join(f"U+{start:04X}" if start == end else f"U+{start:04X}-{end:04X}" for start, end in spans)
+
+
+def _face(
+ font: PageFont,
+ directory: Path,
+ characters: Optional[str],
+) -> str:
+ encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii")
+ lines = [
+ "@font-face {",
+ f' font-family: "{font.family}";',
+ f" font-weight: {font.weight};",
+ f" font-style: {font.style};",
+ " font-display: block;",
+ f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");',
+ ]
+ if characters is not None:
+ lines.append(f" unicode-range: {characters};")
+ lines.append("}")
+ return "\n".join(lines)
diff --git a/src/sampletones_tools/web/paths.py b/src/sampletones_tools/web/paths.py
new file mode 100644
index 000000000..624fd58e9
--- /dev/null
+++ b/src/sampletones_tools/web/paths.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME
+
+STATIC_PACKAGE: Final[str] = "sampletones_tools.web.static"
+FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts"
+ICONS_PACKAGE: Final[str] = "sampletones_assets.icons"
+
+STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE)
+FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE)
+MARK_SOURCE: Final[Path] = package_directory(ICONS_PACKAGE) / ICON_VECTOR_FILENAME
diff --git a/src/sampletones_tools/web/roles/__init__.py b/src/sampletones_tools/web/roles/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_tools/web/roles/resolver.py b/src/sampletones_tools/web/roles/resolver.py
new file mode 100644
index 000000000..55bbfd659
--- /dev/null
+++ b/src/sampletones_tools/web/roles/resolver.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass
+from typing import Dict
+
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_tools.web.design import ApplicationDesign
+from sampletones_tools.web.roles.table import COLOR_ROLES, DIMENSION_ROLES
+
+
+@dataclass(frozen=True)
+class RoleSheet:
+ """Every role resolved in one design: colors, and sizes in application pixels."""
+
+ colors: Dict[str, ColorRGBA]
+ dimensions: Dict[str, float]
+
+
+def resolve_roles(design: ApplicationDesign) -> RoleSheet:
+ """Every role of the table, read from ``design``.
+
+ Raises:
+ KeyError: If a role names a theme entry the configuration does not state.
+ """
+ return RoleSheet(
+ colors={name: role.resolve(design) for name, role in COLOR_ROLES.items()},
+ dimensions={name: role.resolve(design) for name, role in DIMENSION_ROLES.items()},
+ )
diff --git a/src/sampletones_tools/web/roles/sources.py b/src/sampletones_tools/web/roles/sources.py
new file mode 100644
index 000000000..93047cad3
--- /dev/null
+++ b/src/sampletones_tools/web/roles/sources.py
@@ -0,0 +1,70 @@
+from dataclasses import dataclass
+from enum import Enum
+from typing import Callable, Protocol
+
+from sampletones_application.layout.general import GeneralLayout
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.theme.catalog import ThemeEntryAddress
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_tools.web.design import ApplicationDesign
+
+
+class Axis(Enum):
+ X = "x"
+ Y = "y"
+
+
+class ColorRole(Protocol):
+ """Where a page's color comes from in the application's configuration."""
+
+ def resolve(self, design: ApplicationDesign) -> ColorRGBA: ...
+
+
+class DimensionRole(Protocol):
+ """Where a page's size comes from in the application's configuration, in application pixels."""
+
+ def resolve(self, design: ApplicationDesign) -> float: ...
+
+
+@dataclass(frozen=True)
+class ThemeColorRole:
+ """The color a theme gives one entry, the way the widget bound to that theme draws it."""
+
+ theme: str
+ address: ThemeEntryAddress
+
+ def resolve(self, design: ApplicationDesign) -> ColorRGBA:
+ return design.themes.color(self.theme, self.address).rgba
+
+
+@dataclass(frozen=True)
+class LayoutColorRole:
+ """A color the general layout states, such as a channel's or a link's."""
+
+ read: Callable[[GeneralLayout], BaseColor]
+
+ def resolve(self, design: ApplicationDesign) -> ColorRGBA:
+ return self.read(design.general).rgba
+
+
+@dataclass(frozen=True)
+class ThemeDimensionRole:
+ """One component of a style a theme states, such as a rounding or a padding."""
+
+ theme: str
+ address: ThemeEntryAddress
+ axis: Axis
+
+ def resolve(self, design: ApplicationDesign) -> float:
+ x, y = design.themes.style(self.theme, self.address)
+ return x if self.axis is Axis.X or y < 0 else y
+
+
+@dataclass(frozen=True)
+class LayoutDimensionRole:
+ """A size the general layout states, such as the card header's height."""
+
+ read: Callable[[GeneralLayout], float]
+
+ def resolve(self, design: ApplicationDesign) -> float:
+ return self.read(design.general)
diff --git a/src/sampletones_tools/web/roles/table.py b/src/sampletones_tools/web/roles/table.py
new file mode 100644
index 000000000..15204a119
--- /dev/null
+++ b/src/sampletones_tools/web/roles/table.py
@@ -0,0 +1,114 @@
+from typing import Dict, Final
+
+from sampletones_application.utils.theme.catalog import ThemeEntryAddress
+from sampletones_application.utils.theme.spec import CORE_CATEGORY, EVERY_ITEM_TYPE_NAME
+from sampletones_tools.web.roles.sources import (
+ Axis,
+ ColorRole,
+ DimensionRole,
+ LayoutColorRole,
+ LayoutDimensionRole,
+ ThemeColorRole,
+ ThemeDimensionRole,
+)
+
+BASE_THEME: Final[str] = "default"
+CHANNEL_ROLE_PREFIX: Final[str] = "channel-"
+ENABLED: Final[bool] = True
+DISABLED: Final[bool] = False
+
+
+def channel_role(channel: str) -> str:
+ """The role a channel's color stands under, by the channel's name."""
+ return f"{CHANNEL_ROLE_PREFIX}{channel}"
+
+
+def _color(theme: str, item_type: str, key: str, enabled: bool) -> ThemeColorRole:
+ return ThemeColorRole(
+ theme=theme,
+ address=ThemeEntryAddress(item_type=item_type, enabled=enabled, category=CORE_CATEGORY, key=key),
+ )
+
+
+def _style(theme: str, item_type: str, key: str, axis: Axis) -> ThemeDimensionRole:
+ return ThemeDimensionRole(
+ theme=theme,
+ address=ThemeEntryAddress(item_type=item_type, enabled=ENABLED, category=CORE_CATEGORY, key=key),
+ axis=axis,
+ )
+
+
+COLOR_ROLES: Final[Dict[str, ColorRole]] = {
+ "ground": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowBg", ENABLED),
+ "recess": _color("panel_ground", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "surface": _color("panel_surface", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "well": _color("tree_window", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "card-header": _color("collapse_header", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "card-header-hover": _color("collapse_header_hovered", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "heading": _color("section_header", "Text", "Text", ENABLED),
+ "emphasis-surface": _color("panel_emphasis", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "emphasis-border": _color("panel_emphasis", EVERY_ITEM_TYPE_NAME, "Border", ENABLED),
+ "border": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Border", ENABLED),
+ "separator": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Separator", ENABLED),
+ "text": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Text", ENABLED),
+ "text-muted": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TextDisabled", ENABLED),
+ "text-disabled": _color(BASE_THEME, "Button", "Text", DISABLED),
+ "text-strong": _color("table_pattern", EVERY_ITEM_TYPE_NAME, "Text", ENABLED),
+ "text-highlight": LayoutColorRole(read=lambda general: general.colors.text.highlight),
+ "text-error": LayoutColorRole(read=lambda general: general.colors.text.error),
+ "link": LayoutColorRole(read=lambda general: general.colors.paths.default),
+ "link-hover": LayoutColorRole(read=lambda general: general.colors.paths.hover),
+ "menu": _color(BASE_THEME, "MenuBar", "MenuBarBg", ENABLED),
+ "status": _color("status", EVERY_ITEM_TYPE_NAME, "WindowBg", ENABLED),
+ "status-text": _color("status", EVERY_ITEM_TYPE_NAME, "Text", ENABLED),
+ "tab-strip": _color("tab_strip", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED),
+ "tab": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Tab", ENABLED),
+ "tab-hover": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabHovered", ENABLED),
+ "tab-selected": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabSelected", ENABLED),
+ "button": _color(BASE_THEME, "Button", "Button", ENABLED),
+ "button-hover": _color(BASE_THEME, "Button", "ButtonHovered", ENABLED),
+ "button-active": _color(BASE_THEME, "Button", "ButtonActive", ENABLED),
+ "button-disabled": _color(BASE_THEME, "Button", "Button", DISABLED),
+ "secondary": _color("secondary_button", "Button", "Button", ENABLED),
+ "secondary-hover": _color("secondary_button", "Button", "ButtonHovered", ENABLED),
+ "secondary-active": _color("secondary_button", "Button", "ButtonActive", ENABLED),
+ "secondary-text": _color("secondary_button", "Button", "Text", ENABLED),
+ "secondary-border": _color("secondary_button", "Button", "Border", ENABLED),
+ "focus": _color("focused_button", "Button", "Border", ENABLED),
+ "table-header": _color(BASE_THEME, "Table", "TableHeaderBg", ENABLED),
+ "table-row": _color(BASE_THEME, "Table", "TableRowBg", ENABLED),
+ "table-row-alt": _color(BASE_THEME, "Table", "TableRowBgAlt", ENABLED),
+ "table-border": _color(BASE_THEME, "Table", "TableBorderStrong", ENABLED),
+ "selection": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Header", ENABLED),
+ "block-selection": _color("table_pattern", "Selectable", "Header", ENABLED),
+ "scrollbar-track": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ScrollbarBg", ENABLED),
+ "scrollbar-grab": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ScrollbarGrab", ENABLED),
+ channel_role("pulse1"): LayoutColorRole(read=lambda general: general.colors.channels.pulse1),
+ channel_role("pulse2"): LayoutColorRole(read=lambda general: general.colors.channels.pulse2),
+ channel_role("triangle"): LayoutColorRole(read=lambda general: general.colors.channels.triangle),
+ channel_role("noise"): LayoutColorRole(read=lambda general: general.colors.channels.noise),
+}
+
+DIMENSION_ROLES: Final[Dict[str, DimensionRole]] = {
+ "card-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ChildRounding", Axis.X),
+ "card-border": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ChildBorderSize", Axis.X),
+ "card-padding-x": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowPadding", Axis.X),
+ "card-padding-y": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowPadding", Axis.Y),
+ "frame-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "FrameRounding", Axis.X),
+ "tab-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabRounding", Axis.X),
+ "item-spacing-x": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ItemSpacing", Axis.X),
+ "item-spacing-y": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ItemSpacing", Axis.Y),
+ "button-padding-x": _style(BASE_THEME, "Button", "FramePadding", Axis.X),
+ "button-padding-y": _style(BASE_THEME, "Button", "FramePadding", Axis.Y),
+ "menu-padding-x": _style(BASE_THEME, "MenuBar", "FramePadding", Axis.X),
+ "cell-padding-x": _style("table", "Table", "CellPadding", Axis.X),
+ "cell-padding-y": _style("table", "Table", "CellPadding", Axis.Y),
+ "header-height": LayoutDimensionRole(read=lambda general: general.collapse.header_bar_height),
+ "glyph-indent": LayoutDimensionRole(read=lambda general: general.section_header.glyph.indent),
+ "glyph-width": LayoutDimensionRole(read=lambda general: general.section_header.glyph.width),
+ "glyph-top": LayoutDimensionRole(read=lambda general: general.section_header.glyph.top_offset),
+ "chevron-offset": LayoutDimensionRole(read=lambda general: general.section_header.chevron_offset),
+ "panel-gap": LayoutDimensionRole(read=lambda general: general.panel_gap),
+ "side-column": LayoutDimensionRole(read=lambda general: general.columns.side.width),
+ "status-height": LayoutDimensionRole(read=lambda general: general.status_bar.height),
+}
diff --git a/src/sampletones_tools/web/static/__init__.py b/src/sampletones_tools/web/static/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_tools/web/static/base.css b/src/sampletones_tools/web/static/base.css
new file mode 100644
index 000000000..861e5b789
--- /dev/null
+++ b/src/sampletones_tools/web/static/base.css
@@ -0,0 +1,101 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+:root {
+ --page-gutter: 16px;
+ --card-gap: calc(3 * var(--panel-gap));
+ --prose-inset: calc(2 * var(--card-padding-x));
+ --block-gap: 1em;
+ --section-gap: 2em;
+}
+
+html {
+ scrollbar-color: var(--scrollbar-grab) var(--scrollbar-track);
+}
+
+body {
+ margin: 0;
+ background: var(--ground);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: var(--size-sans-medium);
+ line-height: normal;
+}
+
+::selection {
+ background: var(--block-selection);
+}
+
+a {
+ color: var(--link);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--link-hover);
+ text-decoration: underline;
+}
+
+b,
+strong {
+ font-weight: 700;
+}
+
+code,
+kbd,
+samp,
+pre,
+.mono {
+ font-family: var(--font-mono);
+ font-size: var(--size-mono-medium);
+}
+
+.small {
+ font-size: var(--size-sans-small);
+}
+
+.mono.small {
+ font-size: var(--size-mono-small);
+}
+
+.muted {
+ color: var(--text-muted);
+}
+
+.strong {
+ color: var(--text-strong);
+}
+
+.highlight {
+ color: var(--text-highlight);
+}
+
+.error,
+.worse {
+ color: var(--text-error);
+}
+
+.better {
+ color: var(--heading);
+}
+
+.glyph {
+ font-family: var(--font-icon);
+}
+
+kbd {
+ padding: 0 5px;
+ font-size: var(--size-mono-small);
+ color: var(--text);
+ background: var(--recess);
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: calc(var(--frame-radius) / 2);
+}
diff --git a/src/sampletones_tools/web/static/card.css b/src/sampletones_tools/web/static/card.css
new file mode 100644
index 000000000..018ab1041
--- /dev/null
+++ b/src/sampletones_tools/web/static/card.css
@@ -0,0 +1,89 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--item-spacing-y);
+ min-width: 0;
+ padding: var(--card-padding-y) var(--card-padding-x);
+ background: var(--surface);
+ border: var(--card-border) solid var(--border);
+ border-radius: var(--card-radius);
+}
+
+.card.emphasis {
+ background: var(--emphasis-surface);
+ border-color: var(--emphasis-border);
+}
+
+.card-header {
+ display: flex;
+ flex: none;
+ align-items: center;
+ gap: var(--item-spacing-x);
+ min-height: var(--header-height);
+ margin: 0;
+ color: var(--heading);
+ background: var(--card-header);
+ border-radius: var(--card-radius);
+}
+
+summary.card-header {
+ list-style: none;
+ cursor: pointer;
+}
+
+summary.card-header::-webkit-details-marker {
+ display: none;
+}
+
+summary.card-header:hover {
+ background: var(--card-header-hover);
+}
+
+.card-glyph {
+ flex: none;
+ width: calc(var(--glyph-indent) + var(--glyph-width));
+ padding: var(--glyph-top) 0 0 var(--glyph-indent);
+ font-family: var(--font-icon);
+ font-size: var(--size-icon-small);
+}
+
+.card-title {
+ flex: 1;
+ min-width: 0;
+ padding-block: 2px;
+ font-size: var(--size-sans-large);
+ font-weight: 700;
+ overflow-wrap: anywhere;
+ text-transform: uppercase;
+}
+
+.card-chevron {
+ flex: none;
+ margin-right: var(--chevron-offset);
+ font-family: var(--font-icon);
+ font-size: var(--size-icon-small);
+}
+
+.separator {
+ width: 100%;
+ margin: var(--item-spacing-y) 0;
+ border: 0;
+ border-top: 1px solid var(--separator);
+}
+
+.subheader {
+ margin: var(--item-spacing-y) 0 0;
+ font-size: inherit;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.well {
+ padding: var(--card-padding-y) var(--card-padding-x);
+ background: var(--recess);
+ border-radius: var(--card-radius);
+}
+
+.well.deep {
+ background: var(--well);
+}
diff --git a/src/sampletones_tools/web/static/controls.css b/src/sampletones_tools/web/static/controls.css
new file mode 100644
index 000000000..50b9c08d0
--- /dev/null
+++ b/src/sampletones_tools/web/static/controls.css
@@ -0,0 +1,61 @@
+.button {
+ appearance: none;
+ margin: 0;
+ padding: var(--button-padding-y) var(--button-padding-x);
+ font: inherit;
+ line-height: normal;
+ color: var(--text);
+ text-align: center;
+ background: var(--button);
+ border: 0;
+ border-radius: var(--frame-radius);
+ cursor: pointer;
+}
+
+.button:hover {
+ background: var(--button-hover);
+}
+
+.button:active,
+.button[aria-pressed="true"] {
+ background: var(--button-active);
+}
+
+.button:focus-visible {
+ outline: 1px solid var(--focus);
+ outline-offset: 0;
+}
+
+.button:disabled {
+ color: var(--text-disabled);
+ background: var(--button-disabled);
+ cursor: default;
+}
+
+.button.secondary {
+ color: var(--secondary-text);
+ background: var(--secondary);
+ box-shadow: inset 0 0 0 1px var(--secondary-border);
+}
+
+.button.secondary:hover {
+ background: var(--secondary-hover);
+}
+
+.button.secondary:active,
+.button.secondary[aria-pressed="true"] {
+ background: var(--secondary-active);
+}
+
+.button.compact {
+ padding: 0 var(--button-padding-y);
+ min-width: 1.6em;
+}
+
+.swatch {
+ display: inline-block;
+ flex: none;
+ width: 0.6em;
+ height: 0.6em;
+ border-radius: 2px;
+}
diff --git a/src/sampletones_tools/web/static/print.css b/src/sampletones_tools/web/static/print.css
new file mode 100644
index 000000000..68a6aaa8d
--- /dev/null
+++ b/src/sampletones_tools/web/static/print.css
@@ -0,0 +1,29 @@
+@media print {
+ .menu-bar,
+ .tab-bar,
+ .status-bar {
+ display: none;
+ }
+
+ .card,
+ .card-header,
+ .table,
+ .prose pre {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ .card-header {
+ break-after: avoid;
+ }
+
+ .prose pre,
+ .figure,
+ tr {
+ break-inside: avoid;
+ }
+
+ .prose pre {
+ white-space: pre-wrap;
+ }
+}
diff --git a/src/sampletones_tools/web/static/prose.css b/src/sampletones_tools/web/static/prose.css
new file mode 100644
index 000000000..2c4b18c5b
--- /dev/null
+++ b/src/sampletones_tools/web/static/prose.css
@@ -0,0 +1,110 @@
+.prose {
+ min-width: 0;
+ padding: 0 var(--prose-inset) var(--prose-inset);
+ font-size: var(--size-sans-large);
+ line-height: 1.6;
+}
+
+.prose > :first-child {
+ margin-top: var(--prose-inset);
+}
+
+.prose > :last-child {
+ margin-bottom: 0;
+}
+
+.prose p,
+.prose ul,
+.prose ol,
+.prose blockquote,
+.prose pre,
+.prose .table-scroll,
+.prose .figure {
+ margin: var(--block-gap) 0;
+}
+
+.prose ul,
+.prose ol {
+ padding-left: 1.5em;
+}
+
+.prose li + li,
+.prose li > ul,
+.prose li > ol {
+ margin-top: 0.4em;
+}
+
+.prose h3,
+.prose h4 {
+ margin: var(--section-gap) 0 0.5em;
+ line-height: 1.3;
+ text-wrap: balance;
+}
+
+.prose h3 {
+ font-size: 1.15em;
+ color: var(--text);
+}
+
+.prose h4 {
+ font-size: 1em;
+ color: var(--text-muted);
+}
+
+.prose strong {
+ color: var(--text-strong);
+}
+
+.prose :not(pre) > code {
+ padding: 1px 5px;
+ font-size: 0.86em;
+ background: var(--recess);
+ border-radius: calc(var(--frame-radius) / 2);
+}
+
+.prose pre {
+ padding: calc(1.5 * var(--card-padding-y)) var(--card-padding-x);
+ overflow-x: auto;
+ font-size: var(--size-mono-small);
+ line-height: 1.5;
+ background: var(--recess);
+ border-radius: var(--frame-radius);
+}
+
+.prose pre code {
+ font-size: inherit;
+}
+
+.prose blockquote {
+ padding: 0 var(--card-padding-x);
+ color: var(--text-muted);
+ border-left: 3px solid var(--separator);
+}
+
+.prose hr {
+ margin: 1.2em 0;
+ border: 0;
+ border-top: 1px solid var(--separator);
+}
+
+.prose img {
+ max-width: 100%;
+ height: auto;
+}
+
+.prose .table {
+ font-size: var(--size-sans-medium);
+ line-height: 1.45;
+}
+
+.prose .table th,
+.prose .table td {
+ padding: calc(1.5 * var(--cell-padding-y)) calc(1.5 * var(--cell-padding-x));
+}
+
+@media (max-width: 600px) {
+ .prose {
+ padding-inline: var(--card-padding-x);
+ font-size: var(--size-sans-medium);
+ }
+}
diff --git a/src/sampletones_tools/web/static/shell.css b/src/sampletones_tools/web/static/shell.css
new file mode 100644
index 000000000..2f1f73931
--- /dev/null
+++ b/src/sampletones_tools/web/static/shell.css
@@ -0,0 +1,124 @@
+.shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.menu-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ column-gap: calc(2 * var(--menu-padding-x));
+ padding: 2px var(--page-gutter);
+ background: var(--menu);
+}
+
+.menu-bar .title {
+ font-weight: 700;
+}
+
+.menu-bar .brand-mark {
+ display: block;
+ width: 20px;
+ height: 20px;
+}
+
+.menu-bar .spacer {
+ flex: 1;
+}
+
+.tab-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 4px;
+ padding: var(--panel-gap) var(--page-gutter) 0;
+ background: var(--tab-strip);
+}
+
+.tab {
+ appearance: none;
+ margin: 0;
+ padding: 3px 11px;
+ font: inherit;
+ font-size: var(--size-sans-large);
+ color: var(--text);
+ background: var(--tab);
+ border: 0;
+ border-radius: var(--tab-radius) var(--tab-radius) 0 0;
+ cursor: pointer;
+}
+
+.tab:hover {
+ background: var(--tab-hover);
+}
+
+.tab[aria-selected="true"] {
+ background: var(--tab-selected);
+}
+
+.workspace {
+ flex: 1;
+ min-width: 0;
+ padding-block: var(--panel-gap);
+ padding-inline: var(--page-gutter);
+ background: var(--recess);
+}
+
+.columns {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--panel-gap);
+}
+
+.column {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--panel-gap);
+ min-width: 0;
+}
+
+.column.side {
+ flex: 0 0 var(--side-column);
+}
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--item-spacing-x);
+ min-height: var(--status-height);
+ padding: 0 var(--page-gutter);
+ color: var(--status-text);
+ background: var(--status);
+ border-top: 1px solid var(--border);
+}
+
+.masthead {
+ padding: var(--page-gutter) var(--card-padding-x) var(--card-padding-y);
+}
+
+.masthead h1 {
+ margin: 0;
+ font-size: var(--size-sans-title);
+ font-weight: 700;
+ line-height: 1.2;
+ text-wrap: balance;
+}
+
+.masthead .lead {
+ margin: 0.4em 0 0;
+ font-size: var(--size-sans-large);
+ color: var(--text-muted);
+}
+
+@media (max-width: 900px) {
+ .columns {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .column.side {
+ flex-basis: auto;
+ }
+}
diff --git a/src/sampletones_tools/web/static/table.css b/src/sampletones_tools/web/static/table.css
new file mode 100644
index 000000000..faaca1a18
--- /dev/null
+++ b/src/sampletones_tools/web/static/table.css
@@ -0,0 +1,51 @@
+.table-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+}
+
+.table th,
+.table td {
+ padding: var(--cell-padding-y) var(--cell-padding-x);
+ text-align: left;
+ vertical-align: middle;
+ border-bottom: 1px solid var(--table-border);
+}
+
+.table thead th {
+ font-weight: 700;
+ white-space: nowrap;
+ background: var(--table-header);
+}
+
+.table tbody tr:nth-child(odd) {
+ background: var(--table-row);
+}
+
+.table tbody tr:nth-child(even) {
+ background: var(--table-row-alt);
+}
+
+.table tbody tr.selected {
+ background: var(--selection);
+}
+
+.table .number {
+ font-family: var(--font-mono);
+ font-size: var(--size-mono-medium);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.table .center {
+ text-align: center;
+}
+
+.table .right {
+ text-align: right;
+}
diff --git a/src/sampletones_tools/web/stylesheets.py b/src/sampletones_tools/web/stylesheets.py
new file mode 100644
index 000000000..072037d1a
--- /dev/null
+++ b/src/sampletones_tools/web/stylesheets.py
@@ -0,0 +1,31 @@
+from typing import Dict, Final, List
+
+from sampletones_tools.web.colors import color_scheme, css_color
+from sampletones_tools.web.fonts import FONT_STACKS
+from sampletones_tools.web.roles.resolver import RoleSheet
+
+GROUND_ROLE: Final[str] = "ground"
+SIZE_PREFIX: Final[str] = "size-"
+FONT_PREFIX: Final[str] = "font-"
+
+
+def roles_stylesheet(
+ sheet: RoleSheet,
+ sizes: Dict[str, float],
+) -> str:
+ """The roles, sizes and font stacks as custom properties on ``:root``.
+
+ Colors are written as hexadecimal, sizes in pixels, and the color scheme follows the ground,
+ so a page's form controls and scrollbars stand on it as the rest does.
+ """
+ declarations: List[str] = [f"color-scheme: {color_scheme(sheet.colors[GROUND_ROLE])};"]
+ declarations.extend(f"--{name}: {css_color(color)};" for name, color in sorted(sheet.colors.items()))
+ declarations.extend(f"--{name}: {_pixels(value)};" for name, value in sorted(sheet.dimensions.items()))
+ declarations.extend(f"--{SIZE_PREFIX}{name}: {_pixels(value)};" for name, value in sorted(sizes.items()))
+ declarations.extend(f"--{FONT_PREFIX}{typeface.value}: {stack};" for typeface, stack in FONT_STACKS.items())
+ body = "".join(f" {declaration}\n" for declaration in declarations)
+ return f":root {{\n{body}}}\n"
+
+
+def _pixels(value: float) -> str:
+ return f"{value:g}px"
diff --git a/src/sampletones_tools/web/typography/__init__.py b/src/sampletones_tools/web/typography/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_tools/web/typography/metrics.py b/src/sampletones_tools/web/typography/metrics.py
new file mode 100644
index 000000000..bf05c2660
--- /dev/null
+++ b/src/sampletones_tools/web/typography/metrics.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import struct
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Final
+
+TABLE_COUNT_OFFSET: Final[int] = 4
+TABLE_RECORDS_OFFSET: Final[int] = 12
+TABLE_RECORD_SIZE: Final[int] = 16
+TABLE_RECORD_FORMAT: Final[str] = ">4sIII"
+HEAD_TAG: Final[bytes] = b"head"
+HHEA_TAG: Final[bytes] = b"hhea"
+UNITS_PER_EM_OFFSET: Final[int] = 18
+ASCENT_OFFSET: Final[int] = 4
+
+
+@dataclass(frozen=True)
+class FontMetrics:
+ """A TrueType face's vertical metrics, as its ``head`` and ``hhea`` tables state them."""
+
+ units_per_em: int
+ ascent: int
+ descent: int
+
+ @classmethod
+ def read(cls, path: Path) -> FontMetrics:
+ """The metrics of the TrueType file at ``path``.
+
+ Raises:
+ KeyError: If the file lacks a ``head`` or an ``hhea`` table.
+ """
+ data = path.read_bytes()
+ tables = cls._table_offsets(data)
+ (units_per_em,) = struct.unpack_from(">H", data, tables[HEAD_TAG] + UNITS_PER_EM_OFFSET)
+ ascent, descent = struct.unpack_from(">hh", data, tables[HHEA_TAG] + ASCENT_OFFSET)
+ return cls(units_per_em=units_per_em, ascent=ascent, descent=descent)
+
+ @property
+ def em_per_pixel_height(self) -> float:
+ """Ems in one unit of the height the application sizes a font by.
+
+ DearPyGui sizes a font by the span from its ascent to its descent, and CSS by its em, so a
+ CSS size equals the application's size times this factor.
+ """
+ return self.units_per_em / (self.ascent - self.descent)
+
+ @staticmethod
+ def _table_offsets(data: bytes) -> Dict[bytes, int]:
+ (count,) = struct.unpack_from(">H", data, TABLE_COUNT_OFFSET)
+ offsets: Dict[bytes, int] = {}
+ for index in range(count):
+ tag, _, offset, _ = struct.unpack_from(
+ TABLE_RECORD_FORMAT, data, TABLE_RECORDS_OFFSET + index * TABLE_RECORD_SIZE
+ )
+ offsets[tag] = offset
+
+ return offsets
diff --git a/src/sampletones_tools/web/typography/scale.py b/src/sampletones_tools/web/typography/scale.py
new file mode 100644
index 000000000..2b3043000
--- /dev/null
+++ b/src/sampletones_tools/web/typography/scale.py
@@ -0,0 +1,39 @@
+from pathlib import Path
+from typing import Dict, Final
+
+from sampletones_application.layout.fonts import FontsLayout, Step, Typeface
+from sampletones_shared.paths.resources import FONT_ICON, FONT_MONO_REGULAR, FONT_SANS_REGULAR
+from sampletones_tools.web.typography.metrics import FontMetrics
+
+SIZE_PRECISION: Final[int] = 2
+TYPEFACE_FILES: Final[Dict[Typeface, str]] = {
+ Typeface.SANS: FONT_SANS_REGULAR,
+ Typeface.MONO: FONT_MONO_REGULAR,
+ Typeface.ICON: FONT_ICON,
+}
+
+
+def size_name(typeface: Typeface, step: Step) -> str:
+ """The name a page knows a size by, such as ``sans-medium``."""
+ return f"{typeface.value}-{step.value}"
+
+
+def type_sizes(
+ fonts: FontsLayout,
+ directory: Path,
+) -> Dict[str, float]:
+ """Every size the application draws text at, in CSS pixels, keyed by :func:`size_name`.
+
+ Each typeface's sizes are converted with the metrics of its regular face, so a page at device
+ scale 1 sets text exactly as large as the application draws it.
+
+ Raises:
+ FileNotFoundError: If ``directory`` lacks a typeface's regular face.
+ """
+ sizes: Dict[str, float] = {}
+ for typeface in Typeface:
+ factor = FontMetrics.read(directory / TYPEFACE_FILES[typeface]).em_per_pixel_height * fonts.scale
+ for step in Step:
+ sizes[size_name(typeface, step)] = round(fonts.size_for(typeface, step) * factor, SIZE_PRECISION)
+
+ return sizes
diff --git a/src/sampletones_tools/web/writer.py b/src/sampletones_tools/web/writer.py
new file mode 100644
index 000000000..b030750cb
--- /dev/null
+++ b/src/sampletones_tools/web/writer.py
@@ -0,0 +1,62 @@
+from pathlib import Path
+from shutil import copyfile
+from typing import Final, Tuple
+
+from sampletones_tools.web.design import ApplicationDesign
+from sampletones_tools.web.fonts import fonts_stylesheet
+from sampletones_tools.web.paths import FONTS_DIRECTORY, MARK_SOURCE, STATIC_DIRECTORY
+from sampletones_tools.web.roles.resolver import resolve_roles
+from sampletones_tools.web.stylesheets import roles_stylesheet
+from sampletones_tools.web.typography.scale import type_sizes
+
+FONTS_STYLESHEET: Final[str] = "fonts.css"
+MARK_FILE: Final[str] = "mark.svg"
+ROLES_STYLESHEET: Final[str] = "roles.css"
+LAYOUT_STYLESHEETS: Final[Tuple[str, ...]] = (
+ "base.css",
+ "shell.css",
+ "card.css",
+ "controls.css",
+ "table.css",
+ "prose.css",
+ "print.css",
+)
+TEXT_NEWLINE: Final[str] = "\n"
+
+
+def write_layout(
+ directory: Path,
+ design: ApplicationDesign,
+) -> Tuple[str, ...]:
+ """Write the web layout into ``directory``: the faces, the roles of ``design``, the shared
+ stylesheets, and the application's mark as :data:`MARK_FILE`.
+
+ Every page the project writes links these files, so a board and the documentation wear one
+ layout, drawn from the application's own configuration.
+
+ Returns:
+ The stylesheet file names, in the order a page links them.
+
+ Raises:
+ FileNotFoundError: If the package lacks a shared stylesheet or a face.
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / FONTS_STYLESHEET).write_text(
+ fonts_stylesheet(FONTS_DIRECTORY, design.glyph_characters),
+ encoding="utf-8",
+ newline=TEXT_NEWLINE,
+ )
+ (directory / ROLES_STYLESHEET).write_text(
+ roles_stylesheet(resolve_roles(design), type_sizes(design.fonts, FONTS_DIRECTORY)),
+ encoding="utf-8",
+ newline=TEXT_NEWLINE,
+ )
+ for name in LAYOUT_STYLESHEETS:
+ source = STATIC_DIRECTORY / name
+ if not source.is_file():
+ raise FileNotFoundError(f"The package ships no layout stylesheet '{name}' at '{source}'")
+
+ copyfile(source, directory / name)
+
+ copyfile(MARK_SOURCE, directory / MARK_FILE)
+ return (FONTS_STYLESHEET, ROLES_STYLESHEET, *LAYOUT_STYLESHEETS)
diff --git a/tests/benchmarks/history/test_channel_edits.py b/tests/benchmarks/history/test_channel_edits.py
index 41eca3ac3..493077362 100644
--- a/tests/benchmarks/history/test_channel_edits.py
+++ b/tests/benchmarks/history/test_channel_edits.py
@@ -1,6 +1,6 @@
import copy
from pathlib import Path
-from typing import Dict, Final, List
+from typing import Final, List
import pytest
@@ -9,15 +9,11 @@
from sampletones_application.logic.project.controller import ProjectController
from sampletones_core.configs import Config
from sampletones_core.constants.enums import ChannelName
-from sampletones_core.instructions import (
- InstructionUnion,
- NoiseInstruction,
- PulseInstruction,
- TriangleInstruction,
-)
+from sampletones_core.instructions import InstructionUnion
from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_shared.constants.general import BYTES_PER_MEGABYTE
+from tests.suite.long_documents import turning_instructions
from tests.suite.memory import retained_bytes
from tests.suite.stems import single_entry_stems_data
from tests.suite.timing import seconds
@@ -25,40 +21,14 @@
DOCUMENT_FRAMES: Final[int] = 4_000
EDITS: Final[int] = 8
SHARE_MARGIN: Final[float] = 1.5
-LOWEST_PITCH: Final[int] = 36
-PITCH_SPAN: Final[int] = 48
-VOLUMES: Final[int] = 16
-DUTY_CYCLES: Final[int] = 4
-NOISE_PERIODS: Final[int] = 16
SAMPLE_NAME: Final[str] = "Long conversion"
EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1
-def _instruction(
- channel_name: ChannelName,
- frame: int,
-) -> InstructionUnion:
- match channel_name:
- case ChannelName.PULSE1 | ChannelName.PULSE2:
- return PulseInstruction(
- on=True,
- pitch=LOWEST_PITCH + frame % PITCH_SPAN,
- volume=frame % VOLUMES,
- duty_cycle=frame % DUTY_CYCLES,
- )
- case ChannelName.TRIANGLE:
- return TriangleInstruction(on=True, pitch=LOWEST_PITCH + frame % PITCH_SPAN)
- case ChannelName.NOISE:
- return NoiseInstruction(on=True, period=frame % NOISE_PERIODS, volume=frame % VOLUMES, short=False)
-
-
@pytest.fixture(scope="module", name="document")
def document_fixture() -> Reconstruction:
"""A document shaped like a long conversion: every channel sounding, each frame an instruction of its own."""
- instructions: Dict[ChannelName, List[InstructionUnion]] = {
- channel_name: [_instruction(channel_name, frame) for frame in range(DOCUMENT_FRAMES)]
- for channel_name in ChannelName.items()
- }
+ instructions = turning_instructions(DOCUMENT_FRAMES)
return Reconstruction.create(
instructions=instructions,
config=Config(),
diff --git a/tests/benchmarks/reconstruction/__init__.py b/tests/benchmarks/reconstruction/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/benchmarks/reconstruction/conftest.py b/tests/benchmarks/reconstruction/conftest.py
new file mode 100644
index 000000000..c451c3838
--- /dev/null
+++ b/tests/benchmarks/reconstruction/conftest.py
@@ -0,0 +1,3 @@
+from tests.suite.application import scheduling
+
+__all__ = ["scheduling"]
diff --git a/tests/benchmarks/reconstruction/test_edit_landing.py b/tests/benchmarks/reconstruction/test_edit_landing.py
new file mode 100644
index 000000000..1d88c80d6
--- /dev/null
+++ b/tests/benchmarks/reconstruction/test_edit_landing.py
@@ -0,0 +1,309 @@
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from statistics import median
+from typing import Callable, Dict, Final, FrozenSet, List, Tuple
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+import pytest
+
+from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator
+from sampletones_application.coordinators.tabs.reconstruction import (
+ ReconstructionTabCoordinator,
+)
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.reconstruction.edit import ReconstructionEdit
+from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.logic.reconstruction.reconstruction import (
+ ReconstructionPanelLogic,
+)
+from sampletones_application.logic.reconstruction.rewrites.queue import (
+ ReconstructionRewrites,
+)
+from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange, ListeningChange
+from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.services.regeneration.service import RegenerationService
+from sampletones_application.utils.callbacks.queue import CallbackQueue
+from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
+from sampletones_core.audio import write_wave
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_shared.constants.general import BYTES_PER_MEGABYTE
+from tests.suite.application import HeldQueue
+from tests.suite.history.wiring import wired_history
+from tests.suite.language import FakeLanguageManager
+from tests.suite.long_documents import VOLUMES, long_document
+from tests.suite.questions import dialogs_on_the_line
+
+SHORT_SECONDS: Final[float] = 30.0
+LONG_SECONDS: Final[float] = 120.0
+EDITS: Final[int] = 5
+HISTORY_BUDGET: Final[int] = 16
+RENDER_BUDGET: Final[int] = 1024 * BYTES_PER_MEGABYTE
+EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1
+MUTED_STEM_ID: Final[int] = 1
+RECORDINGS: Final[int] = 2
+RECORDING_FREQUENCY: Final[float] = 220.0
+RECORDING_AMPLITUDE: Final[float] = 0.5
+LOWEST_LEVEL: Final[int] = 1
+LANDING_SHARE_OF_A_COLD_RENDER: Final[float] = 0.25
+NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset()
+DOCUMENT_NAME: Final[str] = "Long conversion"
+
+
+@dataclass(frozen=True)
+class Landing:
+ """What one edit cost: the worker's seconds up to its report, and the render thread's seconds landing it."""
+
+ worker_seconds: float
+ landing_seconds: float
+
+
+def _timed(work: Callable[[], None]) -> float:
+ started = time.perf_counter()
+ work()
+ return time.perf_counter() - started
+
+
+def _discard(*readings: object, **named: object) -> None:
+ """Where the panel's readings go: the tab would draw them, and the meter reads their cost alone."""
+
+
+def _session_manager() -> MagicMock:
+ session_manager = MagicMock()
+ session_manager.get_instrument_path.return_value = Path("/tmp/instruments")
+ session_manager.get_audio_path.return_value = Path("/tmp/audio")
+ return session_manager
+
+
+class LandingMeter:
+ """The Reconstructions tab's edit path with no DearPyGui, wired the way the composition root wires it.
+
+ The real regeneration runs on its worker, the rewrites take the steps, the coordinator lands each
+ edit through a strict history over a project, and the panel logic answers every reading the tab
+ would draw. The queue the render loop drains is held, so what a landing costs the render thread
+ is read by draining it.
+ """
+
+ def __init__(self, scheduling: SchedulingBehavior) -> None:
+ self.renders = RenderCache(budget_bytes=RENDER_BUDGET)
+ self.manager = ReconstructionManager(scheduling=scheduling, renders=self.renders)
+ self.rewrites = ReconstructionRewrites(self.manager, RegenerationService())
+ self.controller = ProjectController(ProjectManager())
+ self.history = wired_history(self.controller, budget=HISTORY_BUDGET, strict=True)
+ export_backends: Dict[ExportFormat, ExportBackend] = {}
+ self.panel = ReconstructionPanelLogic(_session_manager(), self.manager, MagicMock(), export_backends)
+ self.panel.on_view_changed = _discard
+ self.panel.on_audio_data_changed = _discard
+ self.panel.on_waveform_load_changed = _discard
+ self.panel.on_waveform_update_changed = _discard
+ self.panel.on_waveform_cleared = _discard
+ self.panel.on_waveform_source_changed = _discard
+ self.panel.on_stems_view_changed = _discard
+ self.panel.on_ownership_changed = _discard
+ self.panel.on_renders_wanted = lambda: self.rewrites.request(ListeningChange())
+ self.coordinator = ReconstructionCoordinator(
+ self.manager,
+ MagicMock(),
+ self.rewrites,
+ MagicMock(),
+ self.controller,
+ self.history,
+ dialogs=dialogs_on_the_line(),
+ language_manager=FakeLanguageManager({}),
+ on_tab_switch=MagicMock(),
+ on_session_state_changed=MagicMock(),
+ on_reconstruction_updated=self._write_back,
+ )
+ self.coordinator.set_reconstructions_tab(self._tab())
+ self.controller.on_project_replaced = self.history.reset
+ self.controller.new()
+
+ def open_as_sample(self, document: Reconstruction) -> Landing:
+ """Adds ``document`` to the project and opens it on the tab, which renders every channel on the worker.
+
+ Returns:
+ Landing: The worker's seconds rendering the document cold, and the render thread's seconds
+ opening it and landing its sound.
+ """
+ with self.history.transaction(HistoryAction.ADD_SAMPLE):
+ sample = self.controller.add_sample(document, DOCUMENT_NAME)
+
+ return self._landed(
+ lambda: self.manager.load_reconstruction_object(
+ sample.reconstruction,
+ name=DOCUMENT_NAME,
+ voice_id=sample.id,
+ )
+ )
+
+ def open_standalone(self, document: Reconstruction) -> Landing:
+ """Opens ``document`` on the tab as a file would be, its recordings read, which renders every channel on
+ the worker.
+
+ Returns:
+ Landing: The worker's seconds rendering the document cold, and the render thread's seconds
+ opening it and landing its sound.
+ """
+ return self._landed(
+ lambda: self.manager.load_reconstruction_object(
+ document,
+ name=DOCUMENT_NAME,
+ voice_id=None,
+ )
+ )
+
+ def mute(self, stem_id: int) -> Landing:
+ """Leaves one recording out of what the reader hears, the way a box on the stems card does, and lands
+ the sound of what is heard.
+ """
+ return self._landed(lambda: self.panel.set_stem_channels(stem_id, NO_CHANNELS))
+
+ def edit(self, step: int) -> Landing:
+ """Moves the edited channel's volume by ``step`` and lands the rebuild, reading what each side cost."""
+ envelopes = self.manager.current_features
+ assert envelopes is not None
+ volume = envelopes[EDITED_CHANNEL].volume
+ moved = volume.model_copy(
+ update={"items": tuple(LOWEST_LEVEL + (item + step) % (VOLUMES - 1) for item in volume.items)}
+ )
+ change = ChannelChange(
+ channel_name=EDITED_CHANNEL,
+ feature_key=FeatureKey.VOLUME,
+ envelopes={FeatureKey.VOLUME: moved},
+ initial_pitch=None,
+ )
+ return self._landed(lambda: self.rewrites.request(change))
+
+ def _landed(self, gesture: Callable[[], None]) -> Landing:
+ """Makes ``gesture``, waits for the worker it started, and drains what it reported, reading what each side
+ cost: the render thread's seconds cover the gesture and the landing.
+ """
+ queue = HeldQueue()
+ with patch.object(CallbackQueue, "add", queue.add):
+ gesture_seconds = _timed(gesture)
+ worker_seconds = _timed(SingleThreadExecutor.join_all)
+ landing_seconds = _timed(queue.drain)
+
+ return Landing(worker_seconds=worker_seconds, landing_seconds=gesture_seconds + landing_seconds)
+
+ def _tab(self) -> MagicMock:
+ tab = MagicMock(spec=ReconstructionTabCoordinator)
+ tab.display_reconstruction.side_effect = self.panel.display_reconstruction
+ tab.update_reconstruction.side_effect = self.panel.update_reconstruction
+ tab.redraw_reconstruction.side_effect = self.panel.update_reconstruction
+ return tab
+
+ def _write_back(self, edit: ReconstructionEdit) -> None:
+ voice_id = self.manager.voice_id
+ if voice_id is None:
+ return
+
+ with self.history.transaction(edit.history_action, coalesce=edit.coalesce_key(voice_id)):
+ self.controller.replace_sample_reconstruction(voice_id, edit.reconstruction)
+
+
+def _report(
+ label: str,
+ opening: Landing,
+ landings: List[Landing],
+ cache_bytes: int,
+) -> None:
+ workers = [landing.worker_seconds for landing in landings]
+ landed = [landing.landing_seconds for landing in landings]
+ print(
+ f"\n{label}\n"
+ f" opening, every channel rendered cold: worker {opening.worker_seconds:.3f} s, "
+ f"render thread {opening.landing_seconds:.3f} s\n"
+ f" per edit, median of {len(landings)} (least): worker {median(workers):.3f} s ({min(workers):.3f} s), "
+ f"landing on the render thread {median(landed):.3f} s ({min(landed):.3f} s)\n"
+ f" renders kept: {cache_bytes / BYTES_PER_MEGABYTE:.0f} MB"
+ )
+
+
+@pytest.fixture(scope="module", name="recordings")
+def recordings_fixture(tmp_path_factory: pytest.TempPathFactory) -> Tuple[Path, ...]:
+ """Two recordings sharing the long document's length, each a tone of its own, written once for the module."""
+ folder = tmp_path_factory.mktemp("recordings")
+ config = Config()
+ seconds = LONG_SECONDS / RECORDINGS
+ times = np.arange(round(seconds * config.sample_rate)) / config.sample_rate
+ paths: List[Path] = []
+ for index in range(RECORDINGS):
+ path = folder / f"take{index}.wav"
+ tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * RECORDING_FREQUENCY * (index + 1) * times)
+ write_wave(path, config.sample_rate, tone.astype(np.float32))
+ paths.append(path)
+
+ return tuple(paths)
+
+
+def _lands_within_a_share_of_a_cold_render(opening: Landing, landings: List[Landing]) -> None:
+ """A landing on the render thread costs a share of what rendering the document cold costs the worker.
+
+ The worker renders every channel of the document once at the opening, so the opening's worker
+ seconds measure the document on this machine, and the landing is held to a share of them: the
+ landing installs what the worker made and draws it, so its cost stays a fraction of the render
+ whatever the machine's speed.
+ """
+ landing = median(landing.landing_seconds for landing in landings)
+ bound = opening.worker_seconds * LANDING_SHARE_OF_A_COLD_RENDER
+ assert landing < bound, f"landing {landing:.3f} s, a share of the cold render {bound:.3f} s"
+
+
+class TestWhatAnEditCosts:
+ """Readings of what one edit costs the worker and the render thread, beside what a cold opening costs.
+
+ The readings are what a change to the edit path is judged by, taken on this meter before and
+ after the change, and each case holds the landing to a share of the document's cold render on
+ the same machine. Each case lands a burst of edits of one channel the way a drag lands them,
+ one after the other, and reports the median and the least of each side's seconds. A project
+ sample carries the strict history's fingerprint on each landing; a standalone document with its
+ recordings read carries the original mix and, with a recording muted, the filtered reading.
+ """
+
+ @pytest.mark.parametrize("seconds", [SHORT_SECONDS, LONG_SECONDS], ids=["30s", "120s"])
+ def test_a_project_sample_every_recording_heard(
+ self,
+ scheduling: SchedulingBehavior,
+ seconds: float,
+ ) -> None:
+ meter = LandingMeter(scheduling)
+ opening = meter.open_as_sample(long_document(seconds, config=Config()))
+ before = meter.manager.reconstruction
+
+ landings = [meter.edit(step) for step in range(1, EDITS + 1)]
+
+ _report(f"project sample, {seconds:.0f} s, every recording heard", opening, landings, meter.renders.held_bytes)
+ assert meter.manager.reconstruction is not before
+ _lands_within_a_share_of_a_cold_render(opening, landings)
+
+ def test_a_standalone_document_with_a_recording_muted(
+ self,
+ scheduling: SchedulingBehavior,
+ recordings: Tuple[Path, ...],
+ ) -> None:
+ meter = LandingMeter(scheduling)
+ opening = meter.open_standalone(long_document(LONG_SECONDS, config=Config(), recordings=recordings))
+ meter.mute(MUTED_STEM_ID)
+ before = meter.manager.reconstruction
+
+ landings = [meter.edit(step) for step in range(1, EDITS + 1)]
+
+ _report(
+ f"standalone document, {LONG_SECONDS:.0f} s, recordings read, one muted",
+ opening,
+ landings,
+ meter.renders.held_bytes,
+ )
+ assert meter.manager.reconstruction is not before
+ _lands_within_a_share_of_a_cold_render(opening, landings)
diff --git a/tests/integration/assets/documentation/__init__.py b/tests/integration/assets/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/assets/documentation/test_build_environment.py b/tests/integration/assets/documentation/test_build_environment.py
new file mode 100644
index 000000000..fac84520c
--- /dev/null
+++ b/tests/integration/assets/documentation/test_build_environment.py
@@ -0,0 +1,154 @@
+import json
+import re
+import subprocess
+import sys
+import tomllib
+from importlib import metadata
+from pathlib import Path
+from typing import Any, Dict, Final, List, Set
+
+import pytest
+
+from hatch_build import SOURCE_DIRECTORY
+from sampletones_shared.application import SAMPLETONES_PACKAGE_NAME
+from sampletones_shared.paths.source import REPOSITORY_ROOT
+
+PROJECT_FILE: Final[Path] = REPOSITORY_ROOT / "pyproject.toml"
+REQUIREMENT_NAME: Final[re.Pattern[str]] = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
+EXTRA_MARKER: Final[str] = "extra =="
+PASSING_PREFIXES: Final[List[str]] = ["sampletones_", "assets", "_sysconfigdata"]
+FORBIDDEN_MODULES: Final[List[str]] = ["sampletones_shared.paths.user", "dearpygui", "platformdirs"]
+
+PROBE: Final[str] = """
+import importlib.abc
+import importlib.metadata
+import json
+import sys
+from pathlib import Path
+
+root, source, output, allowed, passing, package = sys.argv[1:7]
+allowed = set(json.loads(allowed))
+passing = tuple(json.loads(passing))
+
+
+class Fence(importlib.abc.MetaPathFinder):
+ def find_spec(self, name, path=None, target=None):
+ top = name.partition(".")[0]
+ if top in allowed or top in sys.stdlib_module_names or top.startswith(passing):
+ return None
+ raise ModuleNotFoundError(f"the build environment holds no {name}")
+
+
+real_version = importlib.metadata.version
+
+
+def version(name):
+ if name == package:
+ raise importlib.metadata.PackageNotFoundError(name)
+ return real_version(name)
+
+
+importlib.metadata.version = version
+sys.meta_path.insert(0, Fence())
+sys.path[:0] = [source, root]
+
+from assets.documentation.build import SiteRequest, build_site
+from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE
+
+site = build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=Path(output)))
+print(json.dumps({"stamp": site.read_stamp(), "modules": sorted(sys.modules)}))
+"""
+
+
+def normalized(name: str) -> str:
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+
+def requirement_name(requirement: str) -> str:
+ match = REQUIREMENT_NAME.match(requirement)
+ assert match is not None, requirement
+ return normalized(match.group(1))
+
+
+def project_tables() -> Dict[str, Any]:
+ with PROJECT_FILE.open("rb") as stream:
+ return tomllib.load(stream)
+
+
+def hook_dependencies() -> List[str]:
+ dependencies: List[str] = project_tables()["tool"]["hatch"]["build"]["targets"]["wheel"]["hooks"]["custom"][
+ "dependencies"
+ ]
+ return dependencies
+
+
+def distribution_closure(names: List[str]) -> Set[str]:
+ """Every distribution the named ones install, theirs included, as their normalized names."""
+ pending = [requirement_name(name) for name in names]
+ closure: Set[str] = set()
+ while pending:
+ name = pending.pop()
+ if name in closure:
+ continue
+
+ closure.add(name)
+ for requirement in metadata.requires(name) or []:
+ if EXTRA_MARKER not in requirement:
+ pending.append(requirement_name(requirement))
+
+ return closure
+
+
+def importable_modules(distributions: Set[str]) -> List[str]:
+ return sorted(
+ module
+ for module, owners in metadata.packages_distributions().items()
+ if any(normalized(owner) in distributions for owner in owners)
+ )
+
+
+@pytest.fixture(scope="module")
+def probe(tmp_path_factory: pytest.TempPathFactory) -> Dict[str, Any]:
+ output = tmp_path_factory.mktemp("build-environment") / "site"
+ allowed = importable_modules(distribution_closure(hook_dependencies()))
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-I",
+ "-c",
+ PROBE,
+ str(REPOSITORY_ROOT),
+ str(REPOSITORY_ROOT / SOURCE_DIRECTORY),
+ str(output),
+ json.dumps(allowed),
+ json.dumps(PASSING_PREFIXES),
+ SAMPLETONES_PACKAGE_NAME,
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=300,
+ )
+ assert completed.returncode == 0, completed.stderr
+ result: Dict[str, Any] = json.loads(completed.stdout)
+ return result
+
+
+class TestTheBuildEnvironment:
+ def test_the_maker_runs_on_the_hook_dependencies_alone(self, probe: Dict[str, Any]) -> None:
+ assert probe["stamp"]
+
+ @pytest.mark.parametrize("module", FORBIDDEN_MODULES)
+ def test_the_maker_leaves_the_user_folders_and_the_interface_alone(
+ self,
+ probe: Dict[str, Any],
+ module: str,
+ ) -> None:
+ assert module not in probe["modules"]
+
+ def test_each_hook_dependency_carries_the_project_range(self) -> None:
+ tables = project_tables()
+ declared = [*tables["project"]["dependencies"], *tables["dependency-groups"]["assets"]]
+ ranges = {requirement_name(requirement): requirement for requirement in declared}
+
+ assert [ranges.get(requirement_name(requirement)) for requirement in hook_dependencies()] == hook_dependencies()
diff --git a/tests/integration/assets/documentation/test_site.py b/tests/integration/assets/documentation/test_site.py
new file mode 100644
index 000000000..f2b377295
--- /dev/null
+++ b/tests/integration/assets/documentation/test_site.py
@@ -0,0 +1,155 @@
+import posixpath
+import re
+from html.parser import HTMLParser
+from pathlib import Path, PurePosixPath
+from typing import Dict, Final, List, Optional, Set, Tuple
+
+import pytest
+
+from assets.documentation.build import SiteRequest, build_site
+from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE
+from assets.documentation.published import INDEX_PAGE, page_path, published_pages
+from sampletones_shared.documentation.site import DocumentationSite
+
+EXTERNAL: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:")
+NETWORK_URL: Final[re.Pattern[str]] = re.compile(r"url\(\s*[\"']?(?:https?:)?//")
+FRAGMENT_SEPARATOR: Final[str] = "#"
+CURRENT_PAGE: Final[str] = "page"
+
+
+class PageReader(HTMLParser):
+ """The ids a page holds, the links it follows, the files it loads and its marked navigation."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.ids: List[str] = []
+ self.links: List[str] = []
+ self.loads: List[str] = []
+ self.navigation: List[str] = []
+ self.current: List[str] = []
+ self._in_navigation = False
+
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
+ attributes = {name: value or "" for name, value in attrs}
+ if "id" in attributes:
+ self.ids.append(attributes["id"])
+ if tag == "nav":
+ self._in_navigation = True
+ if tag == "a" and "href" in attributes:
+ self.links.append(attributes["href"])
+ if self._in_navigation:
+ self.navigation.append(attributes["href"])
+ if attributes.get("aria-current") == CURRENT_PAGE:
+ self.current.append(attributes["href"])
+ if tag == "link":
+ self.loads.append(attributes["href"])
+ if tag in ("img", "script") and "src" in attributes:
+ self.loads.append(attributes["src"])
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "nav":
+ self._in_navigation = False
+
+
+@pytest.fixture(scope="module")
+def site(tmp_path_factory: pytest.TempPathFactory) -> DocumentationSite:
+ output = tmp_path_factory.mktemp("documentation") / "site"
+ return build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=output))
+
+
+@pytest.fixture(scope="module")
+def readers(site: DocumentationSite) -> Dict[PurePosixPath, PageReader]:
+ readers: Dict[PurePosixPath, PageReader] = {}
+ for path in sorted(site.directory.rglob("*.html")):
+ reader = PageReader()
+ reader.feed(path.read_text(encoding="utf-8"))
+ readers[PurePosixPath(path.relative_to(site.directory).as_posix())] = reader
+
+ return readers
+
+
+def target(page: PurePosixPath, href: str) -> Tuple[PurePosixPath, str]:
+ path, _, fragment = href.partition(FRAGMENT_SEPARATOR)
+ if not path:
+ return page, fragment
+
+ return PurePosixPath(posixpath.normpath(posixpath.join(page.parent.as_posix(), path))), fragment
+
+
+class TestTheShippedDocumentation:
+ def test_every_published_document_is_a_page(self, readers: Dict[PurePosixPath, PageReader]) -> None:
+ assert sorted(readers) == sorted(page_path(relative) for relative in published_pages(DOCS_DIRECTORY))
+
+ def test_the_site_is_sealed(self, site: DocumentationSite) -> None:
+ assert site.read_stamp() == site.content_stamp()
+
+ def test_every_page_link_reaches_a_file_and_its_heading(
+ self,
+ site: DocumentationSite,
+ readers: Dict[PurePosixPath, PageReader],
+ ) -> None:
+ broken: List[str] = []
+ for page, reader in readers.items():
+ for href in reader.links:
+ if href.startswith(EXTERNAL):
+ continue
+
+ destination, fragment = target(page, href)
+ if not (site.directory / destination).is_file():
+ broken.append(f"{page}: {href}")
+ elif fragment and fragment not in readers[destination].ids:
+ broken.append(f"{page}: {href}")
+
+ assert broken == []
+
+ def test_every_file_a_page_loads_lies_in_the_site(
+ self,
+ site: DocumentationSite,
+ readers: Dict[PurePosixPath, PageReader],
+ ) -> None:
+ missing = [
+ f"{page}: {source}"
+ for page, reader in readers.items()
+ for source in reader.loads
+ if source.startswith(EXTERNAL) or not (site.directory / target(page, source)[0]).is_file()
+ ]
+
+ assert missing == []
+
+ def test_no_stylesheet_reaches_the_network(self, site: DocumentationSite) -> None:
+ reaching = [
+ path.name for path in site.directory.rglob("*.css") if NETWORK_URL.search(path.read_text(encoding="utf-8"))
+ ]
+
+ assert reaching == []
+
+ def test_no_page_links_a_document_source(self, readers: Dict[PurePosixPath, PageReader]) -> None:
+ sources = [
+ f"{page}: {href}"
+ for page, reader in readers.items()
+ for href in reader.links
+ if not href.startswith(EXTERNAL) and href.partition(FRAGMENT_SEPARATOR)[0].endswith(".md")
+ ]
+
+ assert sources == []
+
+ def test_every_id_on_a_page_is_its_own(self, readers: Dict[PurePosixPath, PageReader]) -> None:
+ repeated: Dict[str, Set[str]] = {}
+ for page, reader in readers.items():
+ seen: Set[str] = set()
+ for identifier in reader.ids:
+ if identifier in seen:
+ repeated.setdefault(str(page), set()).add(identifier)
+ seen.add(identifier)
+
+ assert repeated == {}
+
+ def test_the_navigation_reaches_every_page(self, readers: Dict[PurePosixPath, PageReader]) -> None:
+ index = readers[page_path(INDEX_PAGE)]
+ reached = {target(page_path(INDEX_PAGE), href)[0] for href in index.navigation}
+
+ assert set(readers) <= reached
+
+ def test_each_page_marks_itself_once(self, readers: Dict[PurePosixPath, PageReader]) -> None:
+ for page, reader in readers.items():
+ assert [target(page, href)[0] for href in reader.current] == [page], page
diff --git a/tests/integration/docs/test_documentation.py b/tests/integration/docs/test_documentation.py
index 4101300c8..7c307e7ca 100644
--- a/tests/integration/docs/test_documentation.py
+++ b/tests/integration/docs/test_documentation.py
@@ -5,6 +5,7 @@
import pytest
from PIL import Image
+from assets.documentation.markdown.anchors import heading_anchor
from sampletones_shared.paths.source import REPOSITORY_ROOT
ENCODING: Final[str] = "utf-8"
@@ -18,19 +19,13 @@
ROOT_PAGES: Final[Tuple[str, ...]] = ("README.md", "CHANGELOG.md")
-def anchor(heading: str) -> str:
- """The fragment GitHub gives a heading: punctuation dropped, every remaining space a hyphen."""
- text = re.sub(r"[`*]", "", heading).strip().lower()
- return re.sub(r"[^\w\s-]", "", text).replace(" ", "-")
-
-
def text(page: Path) -> str:
"""The page as it is stored, which is UTF-8 whatever encoding the platform prefers."""
return page.read_text(encoding=ENCODING)
def anchors(page: Path) -> Set[str]:
- return {anchor(heading) for heading in HEADING.findall(text(page))}
+ return {heading_anchor(heading) for heading in HEADING.findall(text(page))}
def images(page: Path) -> List[Dict[str, str]]:
diff --git a/tests/integration/history/test_interleavings.py b/tests/integration/history/test_interleavings.py
index 3763520b0..9c74f9232 100644
--- a/tests/integration/history/test_interleavings.py
+++ b/tests/integration/history/test_interleavings.py
@@ -29,10 +29,14 @@
@contextmanager
def held_reports(session: HistorySession) -> Iterator[HeldQueue]:
- """Holds what the workers report until the case drains it, each gesture meanwhile landing what it does at once."""
+ """Holds what the workers report until the case drains it, each gesture meanwhile landing what it does at once.
+
+ What the drain hands the workers lands before the case goes on, so the next gesture meets no work on its way.
+ """
queue = HeldQueue()
with patch.object(CallbackQueue, "add", queue.add), session.audit.settling(immediately):
yield queue
+ queue.settle()
def _edit_lead(
diff --git a/tests/integration/reconstruction/test_eventual_synchronization.py b/tests/integration/reconstruction/test_eventual_synchronization.py
new file mode 100644
index 000000000..5c5393f76
--- /dev/null
+++ b/tests/integration/reconstruction/test_eventual_synchronization.py
@@ -0,0 +1,187 @@
+import random
+from typing import Callable, Dict, Final, List, Tuple
+
+import pytest
+
+from sampletones_application.application import Application
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.reconstruction.rewrites.steps import StemRemovalRequest
+from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
+from sampletones_application.view_model.reconstruction.instruments import (
+ InstrumentsShowing,
+ ReconstructionInstrumentsViewModel,
+)
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.reconstructions import Reconstruction
+from tests.suite.application import HeldQueue, held_queue
+from tests.suite.headless import app
+from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, STEM_B_ID, taking_turns
+
+__all__ = ["app", "held_queue", "taking_turns"]
+
+STEPS: Final[int] = 40
+SEEDS: Final[range] = range(12)
+LOUDEST: Final[int] = 15
+LONGEST_DRAG: Final[int] = 4
+STEMS: Final[Tuple[int, ...]] = (STEM_A_ID, STEM_B_ID)
+CHANNELS: Final[Tuple[ChannelName, ...]] = (SHARED_CHANNEL, SOLE_CHANNEL)
+
+Gesture = Callable[[], None]
+
+
+@pytest.fixture
+def turns_sample(
+ app: Application,
+ held_queue: HeldQueue,
+ taking_turns: Reconstruction,
+) -> Sample:
+ """The two-recording document added to a new project and opened on the tab, its opening landed."""
+ app.project_controller.new()
+ with app.history.transaction(HistoryAction.ADD_SAMPLE):
+ sample = app.project_controller.add_sample(taking_turns, "turns")
+ app._reconstruction_coordinator.open_project_voice(sample.id)
+ _settle(app, held_queue)
+ return sample
+
+
+def _land(app: Application, held_queue: HeldQueue) -> None:
+ """Lets the worker finish what it runs and lands its report, which may start the next step."""
+ SingleThreadExecutor.join_all()
+ held_queue.drain()
+
+
+def _settle(app: Application, held_queue: HeldQueue) -> None:
+ """Lands every step until the line stands idle."""
+ _land(app, held_queue)
+ while app.reconstruction_rewrites.is_busy:
+ _land(app, held_queue)
+
+
+def _gestures(
+ app: Application,
+ held_queue: HeldQueue,
+ chance: random.Random,
+) -> Dict[str, Gesture]:
+ """Every gesture the reader can make on the open document, each drawing its own choices."""
+ panel = app._reconstructions_tab._reconstruction_panel_logic
+ instruments = app._reconstructions_tab._reconstruction_instruments_logic
+ coordinator = app._reconstruction_coordinator
+ floor = app.history.cursor
+
+ def tick() -> None:
+ stem = chance.choice(STEMS)
+ heard = frozenset(channel for channel in CHANNELS if chance.random() < 0.5)
+ panel.set_stem_channels(stem, heard)
+
+ def boxes() -> None:
+ panel.set_selected_channels([channel for channel in CHANNELS if chance.random() < 0.5])
+
+ def solo() -> None:
+ panel.solo_stem(chance.choice(STEMS))
+
+ def drag() -> None:
+ items = tuple(chance.randint(0, LOUDEST) for _ in range(chance.randint(1, LONGEST_DRAG)))
+ instruments.handle_envelope_changed(chance.choice(CHANNELS), FeatureKey.VOLUME, Envelope[int](items=items))
+
+ def silence() -> None:
+ instruments.handle_envelope_changed(chance.choice(CHANNELS), FeatureKey.VOLUME, Envelope[int](items=(0,)))
+
+ def remove() -> None:
+ coordinator.request_rewrite(StemRemovalRequest(stem_id=chance.choice(STEMS), stem_name="gone"))
+
+ def undo_an_edit() -> None:
+ """Undoes the latest edit, keeping the sample's own addition, so the document stays open."""
+ if app.history.cursor > floor:
+ app.history.undo()
+
+ def undo() -> None:
+ coordinator.after_edits(undo_an_edit)
+
+ def land() -> None:
+ _land(app, held_queue)
+
+ return {
+ "tick": tick,
+ "boxes": boxes,
+ "solo": solo,
+ "drag": drag,
+ "silence": silence,
+ "remove": remove,
+ "undo": undo,
+ "land": land,
+ }
+
+
+def _expect_caught_up(
+ app: Application,
+ views: List[ReconstructionInstrumentsViewModel],
+ walk: List[str],
+) -> None:
+ """The open document's envelopes answer what the reader hears, the waveform owes nothing, and the instruments
+ panel draws the envelopes, free to edit.
+ """
+ manager = app.reconstruction_manager
+ panel = app._reconstructions_tab._reconstruction_panel_logic
+ assert not app.reconstruction_rewrites.is_busy, walk
+ assert manager.reconstruction is not None, walk
+ assert manager.current_features is not None, walk
+ assert panel._owed is None, walk
+ assert views, walk
+ assert views[-1].showing is InstrumentsShowing.EDITABLE, walk
+
+
+class TestTheInstrumentsPanelCatchesUpWithEveryWalk:
+ """The instruments panel keeps the envelopes it drew through every gesture on an open document, and catches
+ up once the reader stops.
+
+ A seeded walk of ticks, channel boxes, solos, drags, silencings, removals and undos, each
+ landed or left on its way at random, ends with the line drained. Every view the panel was
+ handed must draw or keep envelopes, and at the end the envelopes must answer the open
+ document and the reader's choice, the waveform must owe nothing, and the panel must be free
+ to edit. A recording ticked away and back before the first listening lands is the gesture that
+ left the panel waiting for good: that listening landed envelopes for the choice the reader had
+ left.
+ """
+
+ def test_a_recording_ticked_away_and_back_before_the_listening_lands(
+ self,
+ app: Application,
+ held_queue: HeldQueue,
+ turns_sample: Sample,
+ ) -> None:
+ panel = app._reconstructions_tab._reconstruction_panel_logic
+ views: List[ReconstructionInstrumentsViewModel] = []
+ app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append
+ heard = app.reconstruction_manager.listening.heard[STEM_B_ID]
+
+ panel.set_stem_channels(STEM_B_ID, frozenset())
+ panel.set_stem_channels(STEM_B_ID, heard)
+ _settle(app, held_queue)
+
+ _expect_caught_up(app, views, ["tick away", "tick back"])
+
+ @pytest.mark.parametrize("seed", SEEDS)
+ def test_every_view_draws_or_keeps_the_envelopes(
+ self,
+ seed: int,
+ app: Application,
+ held_queue: HeldQueue,
+ turns_sample: Sample,
+ ) -> None:
+ chance = random.Random(seed)
+ gestures = _gestures(app, held_queue, chance)
+ views: List[ReconstructionInstrumentsViewModel] = []
+ app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append
+ walk: List[str] = []
+
+ for _ in range(STEPS):
+ name = chance.choice(list(gestures))
+ walk.append(name)
+ gestures[name]()
+
+ _settle(app, held_queue)
+
+ assert all(view.is_open for view in views), walk
+ _expect_caught_up(app, views, walk)
diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py
index 29b07f97e..06a4dbd16 100644
--- a/tests/integration/reconstruction/test_stems_reconstruction.py
+++ b/tests/integration/reconstruction/test_stems_reconstruction.py
@@ -6,6 +6,7 @@
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.view_model.shared.waveform_data import WaveformData
from sampletones_core.audio import mix, mix_scale, read_stems, scale_stems, write_wave
from sampletones_core.configs import Config
from sampletones_core.constants.algorithm import MAX_DRIVE, MIN_DRIVE, RESTING_STEM_ID, UNIT_DRIVE
@@ -19,6 +20,8 @@
from sampletones_core.reconstructions import Reconstruction, Reconstructor
from sampletones_core.reconstructions.converter import GroupConversion, reconstruct_job
from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix
+from sampletones_core.reconstructions.reconstruction.stems.filter import filter_approximations
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders
from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem
from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
@@ -27,6 +30,7 @@
from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings
from sampletones_shared.utils.progress import silent_reporter
from sampletones_tools.corpus.catalog import build_mini_library
+from tests.suite.renders import warmed
from tests.suite.stems import (
STEM_A_ID,
STEM_B_ID,
@@ -376,49 +380,53 @@ def heard(selected: AbstractSet[int]) -> StemSelection:
"""The selection hearing the named stems on every channel."""
return StemSelection.everywhere(frozenset(selected), channels)
- unfiltered = data.waveform_data(renders)
- full = data.waveform_data(renders, heard(all_stem_ids))
- np.testing.assert_allclose(full.approximation, unfiltered.approximation, atol=_MIX_TOLERANCE)
- np.testing.assert_allclose(full.original_audio, unfiltered.original_audio, atol=_MIX_TOLERANCE)
- np.testing.assert_allclose(
- data.partials_for(renders, channels, heard(all_stem_ids)),
- data.get_partials(renders, channels),
- atol=_MIX_TOLERANCE,
- )
+ def hearing(selected: AbstractSet[int]) -> Hearing:
+ """Hearing the named stems on every channel, every channel kept on."""
+ return Hearing(selection=heard(selected), channels_on=frozenset(channels))
+
+ def kept(selected: AbstractSet[int]) -> WaveformData:
+ """The reading of the loaded document once the worker's sound for it is kept."""
+ warmed(renders, data.reconstruction, hearing(selected), data.recordings_by_stem)
+ waveform = data.waveform_data(renders, hearing(selected))
+ assert waveform is not None
+ return waveform
+
+ def filtered(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]:
+ return filter_approximations(
+ stems_data,
+ rendered_channels(data.reconstruction),
+ heard(selected),
+ frame_length,
+ )
+
+ full = kept(all_stem_ids)
+ whole = renders.reading(data.reconstruction, hearing(all_stem_ids))
+ assert whole is not None
+ assert full.approximation is whole.mix
+ assert full.playing is full.approximation
+ np.testing.assert_allclose(full.original_audio, mix(list(data.stem_audios)), atol=_MIX_TOLERANCE)
for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID):
selected = frozenset({selected_id})
expected = masked_channels(selected)
- waveform = data.waveform_data(renders, heard(selected))
+ waveform = kept(selected)
for channel, expected_audio in expected.items():
- np.testing.assert_array_equal(waveform.approximations[channel], expected_audio)
+ np.testing.assert_array_equal(filtered(selected)[channel], expected_audio)
np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE)
- np.testing.assert_allclose(
- data.partials_for(renders, channels, heard(selected)),
- mix(list(expected.values())),
- atol=_MIX_TOLERANCE,
- )
- np.testing.assert_allclose(
- data.original_mix_for(heard(selected)), data.stem_audios[selected_id], atol=_MIX_TOLERANCE
- )
+ np.testing.assert_allclose(waveform.playing, mix(list(expected.values())), atol=_MIX_TOLERANCE)
+ np.testing.assert_allclose(waveform.original_audio, data.stem_audios[selected_id], atol=_MIX_TOLERANCE)
selected = frozenset()
- waveform = data.waveform_data(renders, heard(selected))
+ waveform = kept(selected)
for channel in stems_data.assignments_by_channel:
np.testing.assert_array_equal(
- waveform.approximations[channel],
+ filtered(selected)[channel],
np.zeros_like(rendered_channels(data.reconstruction)[channel]),
)
np.testing.assert_allclose(waveform.approximation, np.zeros_like(rendered_mix(data.reconstruction)))
- np.testing.assert_allclose(
- data.partials_for(renders, channels, heard(selected)),
- np.zeros_like(rendered_mix(data.reconstruction)),
- )
- np.testing.assert_allclose(
- data.original_mix_for(heard(selected)),
- np.zeros_like(rendered_mix(data.reconstruction)),
- )
+ np.testing.assert_allclose(waveform.playing, np.zeros_like(rendered_mix(data.reconstruction)))
+ np.testing.assert_allclose(waveform.original_audio, np.zeros_like(rendered_mix(data.reconstruction)))
class TestRemovingAStem:
@@ -621,15 +629,13 @@ def test_mixes_the_recorded_stems_at_the_balance_they_were_captured_in(self, tmp
assert data.reconstruction.audio_filepath == (tone_path, noise_path)
assert data.name == save_path.stem
- assert data.original_audio is not None
loaded_tone, loaded_noise = data.stem_audios
np.testing.assert_allclose(
np.max(np.abs(loaded_tone)) / np.max(np.abs(loaded_noise)),
np.max(np.abs(tone)) / np.max(np.abs(noise)),
rtol=1e-5,
)
- np.testing.assert_allclose(data.original_audio, mix([loaded_tone, loaded_noise]), atol=_MIX_TOLERANCE)
- np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-5)
+ np.testing.assert_allclose(np.max(np.abs(mix([loaded_tone, loaded_noise]))), 1.0, rtol=1e-5)
def test_the_conversion_records_the_scale_it_read_the_set_at(self, tmp_path: Path) -> None:
config = Config()
@@ -862,10 +868,14 @@ def test_soloing_a_stem_sounds_nothing_outside_its_span(self, tmp_path: Path, re
assignments = reconstruction.stems_data.assignments_by_channel
for stem_id, span in enumerate(spans):
selection = StemSelection.everywhere(frozenset({stem_id}), list(assignments))
- heard = ReconstructionData.from_reconstruction(reconstruction, name="disjoint").waveform_data(
- renders, selection
+ hearing = Hearing(selection=selection, channels_on=frozenset(assignments))
+ heard = heard_renders(
+ reconstruction,
+ hearing,
+ rendered_channels(reconstruction),
+ rendered_mix(reconstruction),
)
- outside = np.array(heard.approximation, copy=True)
+ outside = np.array(heard.mix, copy=True)
for frame in span:
outside[frame * frame_length : (frame + 1) * frame_length] = 0.0
diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py
index a3ce31308..844f640d3 100644
--- a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py
+++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py
@@ -1,6 +1,6 @@
import time
from pathlib import Path
-from typing import Final, Iterator, Tuple
+from typing import Any, Final, Iterator, List, Tuple
from unittest.mock import MagicMock
import pytest
@@ -17,11 +17,14 @@
)
from sampletones_application.logic.shared.renders import RenderCache
from sampletones_application.services.regeneration.service import RegenerationService
+from sampletones_application.view_model.reconstruction.work import Work
+from sampletones_core.constants.algorithm import AUTHORED_STEM_ID
from sampletones_core.constants.enums import FeatureKey
from sampletones_core.features.envelope import Envelope
from sampletones_core.reconstructions import Reconstruction
from tests.conftest import RENDER_BUDGET
from tests.suite.application import draw_frame, live_queue, scheduling
+from tests.suite.envelopes import land_envelopes
from tests.suite.stems import (
SHARED_CHANNEL,
SHARED_OWNERS,
@@ -37,6 +40,7 @@
DRAG: Final[Tuple[Tuple[int, int], ...]] = ((1, 1), (2, 2), (3, 3), (4, 4), (6, 6))
ARPEGGIO: Final[Tuple[int, int]] = (4, 3)
RETUNED_FREQUENCY: Final[int] = 50
+LONGER_VOLUME: Final[Tuple[int, ...]] = (5, 5, 5)
SETTLE_SECONDS: Final[float] = 10.0
POLL_SECONDS: Final[float] = 0.005
@@ -64,6 +68,7 @@ def coordinator(
)
coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator))
manager.load_reconstruction(taking_turns_file)
+ land_envelopes(manager)
yield coordinator
@@ -135,6 +140,48 @@ def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordina
assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY
def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None:
- dims = [entry.args[0] for entry in settled._tab.set_reconstruction_dimmed.call_args_list]
+ """The removal and the rate leave the envelopes owed, so the line reads them before the fade ends, once."""
+ work = [entry.args[0] for entry in settled._tab.show_work.call_args_list]
- assert dims == [True, False]
+ assert work == [Work.REBUILDING, Work.READING, None]
+
+
+class TestTheFirstFramesOfTheReadersOwn:
+ """An edit first writing frames of the reader's own on a channel, beside a recording left out, lands in one
+ job: the worker carries the choice onto the rebuilt record, so the sound kept and the envelopes installed are
+ the ones the listening asks for, and the panel has nothing left to ask a listening step for.
+ """
+
+ def test_the_landing_answers_the_listening_in_one_job(
+ self,
+ coordinator: ReconstructionCoordinator,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ manager = coordinator._reconstruction_manager
+ manager.listening.set_channels(STEM_B_ID, frozenset())
+ land_envelopes(manager)
+ listenings: List[Reconstruction] = []
+ service = coordinator._rewrites._regeneration
+
+ def counted(reconstruction: Reconstruction, **kwargs: Any) -> None:
+ listenings.append(reconstruction)
+ RegenerationService.start_listening(service, reconstruction, **kwargs)
+
+ monkeypatch.setattr(service, "start_listening", counted)
+
+ coordinator.request_rewrite(
+ ChannelChange(
+ channel_name=SHARED_CHANNEL,
+ feature_key=FeatureKey.VOLUME,
+ envelopes={FeatureKey.VOLUME: Envelope[int](items=LONGER_VOLUME)},
+ initial_pitch=None,
+ )
+ )
+ _settle(coordinator)
+
+ document = _open_document(coordinator)
+ hearing = manager.listening.hearing
+ assert AUTHORED_STEM_ID in hearing.selection.stems_for(SHARED_CHANNEL)
+ assert manager.renders.reading(document, hearing) is not None
+ assert manager.has_envelopes_for(hearing.selection)
+ assert listenings == []
diff --git a/tests/integration/sampletones_application/logic/shared/__init__.py b/tests/integration/sampletones_application/logic/shared/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/sampletones_application/logic/shared/test_previews.py b/tests/integration/sampletones_application/logic/shared/test_previews.py
new file mode 100644
index 000000000..e4016b454
--- /dev/null
+++ b/tests/integration/sampletones_application/logic/shared/test_previews.py
@@ -0,0 +1,54 @@
+import time
+from typing import Final, List
+
+import numpy as np
+import pytest
+
+from sampletones_application.logic.shared.previews import DocumentPreviews
+from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_application.services.regeneration.service import RegenerationService
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstruction.renders import rendered_mix
+from tests.conftest import RENDER_BUDGET
+from tests.suite.application import draw_frame, live_queue
+from tests.suite.sequencer import sample_reconstruction
+
+__all__ = ["live_queue"]
+
+PREVIEWED_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE]
+SETTLE_SECONDS: Final[float] = 10.0
+POLL_SECONDS: Final[float] = 0.005
+
+
+@pytest.fixture
+def previews(live_queue: None) -> DocumentPreviews:
+ """Previews over a cache of their own, rendered on the real worker and landed through the live queue."""
+ return DocumentPreviews(RenderCache(budget_bytes=RENDER_BUDGET), RegenerationService())
+
+
+def _settle(previews: DocumentPreviews) -> None:
+ """Draws frames until the preview has landed, the way the render loop drains the results."""
+ deadline = time.monotonic() + SETTLE_SECONDS
+ while previews.is_busy:
+ assert time.monotonic() < deadline, "the preview never landed"
+ draw_frame()
+ time.sleep(POLL_SECONDS)
+
+
+def _unexpected(exception: Exception) -> None:
+ raise AssertionError(f"a preview's render failed: {exception}")
+
+
+class TestAPreviewOnTheRealWorker:
+ def test_the_sound_lands_and_a_second_ask_answers_from_the_cache(self, previews: DocumentPreviews) -> None:
+ document = sample_reconstruction(PREVIEWED_CHANNELS)
+ played: List[np.ndarray] = []
+
+ previews.play(document, played.append, _unexpected)
+ _settle(previews)
+
+ assert len(played) == 1
+ np.testing.assert_array_equal(played[0], rendered_mix(document))
+ previews.play(document, played.append, _unexpected)
+ assert len(played) == 2
+ assert played[1] is played[0]
diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py
index 79b43f351..c3149a575 100644
--- a/tests/integration/sampletones_application/services/test_regeneration.py
+++ b/tests/integration/sampletones_application/services/test_regeneration.py
@@ -6,7 +6,7 @@
import pytest
from automation.scenario import BaseTestScenario, ScenarioStep
-from sampletones_application.logic.reconstruction.envelopes import heard_envelopes
+from sampletones_application.services.regeneration.jobs import RebuildJob
from sampletones_application.services.regeneration.service import RegenerationService
from sampletones_application.services.result import ServiceError, ServiceSuccess
from sampletones_application.utils.callbacks.queue import CallbackQueue
@@ -18,9 +18,16 @@
from sampletones_core.features.envelope import Envelope
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.reconstructions.reconstruction.renders import rendered_channels
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing
+from tests.suite.envelopes import heard_envelopes
from tests.suite.stems import everything_heard
+def _heard_whole(reconstruction: Reconstruction) -> Hearing:
+ """Every recording heard on every channel, and every channel kept on."""
+ return Hearing(selection=everything_heard(reconstruction), channels_on=frozenset(ChannelName.items()))
+
+
def _heard_features(reconstruction: Reconstruction) -> ChannelEnvelopesViewModel:
"""The envelopes of the whole document, which is what a fresh reader hears."""
return heard_envelopes(reconstruction, everything_heard(reconstruction))
@@ -53,10 +60,14 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) ->
service.subscribe(results.append)
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
assert len(results) == 1
@@ -68,10 +79,14 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat
service.subscribe(results.append)
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
emitted = results[0].value
@@ -80,14 +95,40 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat
len(rendered_channels(emitted.reconstruction).get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0
)
+ def test_the_result_sounds_what_the_core_renders(self, reconstruction_data, pulse_features) -> None:
+ """The audio the worker hands back is the render of the document it hands back."""
+ service = RegenerationService()
+ results: List[Any] = []
+ service.subscribe(results.append)
+
+ service._run(
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
+ )
+
+ outcome = results[0].value
+ expected = rendered_channels(outcome.reconstruction)
+ assert outcome.channels.keys() == expected.keys()
+ assert all(np.array_equal(outcome.channels[name], expected[name]) for name in expected)
+
def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None:
service = RegenerationService()
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
approximation = rendered_channels(reconstruction_data.reconstruction).get(
@@ -99,10 +140,14 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls
service = RegenerationService()
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1)
@@ -119,10 +164,14 @@ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_da
service.subscribe(results.append)
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- silenced,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ silenced,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
assert isinstance(results[0], ServiceSuccess)
@@ -136,10 +185,14 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da
service.subscribe(results.append)
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- {},
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ {},
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
assert len(results) == 1
@@ -154,7 +207,9 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_
reconstruction_data.reconstruction,
ChannelName.PULSE1,
pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ hearing=_heard_whole(reconstruction_data.reconstruction),
+ kept={},
+ recordings={},
)
assert len(results) == 1
@@ -183,10 +238,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None:
Envelope(items=tuple(int(offset) for offset in arpeggio)),
)
service._run(
- context.reconstruction,
- ChannelName.PULSE1,
- edited,
- context.reconstruction.recorded_stem_ids,
+ RebuildJob(context.reconstruction, ChannelName.PULSE1, edited, _heard_whole(context.reconstruction), {}, {})
)
assert len(results) == 1
@@ -303,10 +355,14 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio
# The real synthesis emits its ServiceSuccess onto the real queue, due at the current frame.
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
# The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails
@@ -333,10 +389,14 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data,
new_volume = np.zeros(len(pulse_features.volume.items), dtype=np.int8)
service._run(
- reconstruction_data.reconstruction,
- ChannelName.PULSE1,
- pulse_features,
- reconstruction_data.reconstruction.recorded_stem_ids,
+ RebuildJob(
+ reconstruction_data.reconstruction,
+ ChannelName.PULSE1,
+ pulse_features,
+ _heard_whole(reconstruction_data.reconstruction),
+ {},
+ {},
+ )
)
for _ in range(DELIVERY_BUDGET_FRAMES):
diff --git a/tests/integration/sampletones_core/parallelization/test_failed_tasks.py b/tests/integration/sampletones_core/parallelization/test_failed_tasks.py
new file mode 100644
index 000000000..b22a96209
--- /dev/null
+++ b/tests/integration/sampletones_core/parallelization/test_failed_tasks.py
@@ -0,0 +1,49 @@
+from typing import Final, List, Tuple
+
+import pytest
+from pebble import ProcessExpired
+
+from sampletones_core.parallelization.outcome import RunCompleted
+from tests.suite.base import BaseTestSuite
+from tests.suite.parallelization import FatedProcessor, TaskFate
+
+TWO_WORKERS: Final[int] = 2
+FATES: Final[Tuple[TaskFate, ...]] = (
+ TaskFate.ANSWERS,
+ TaskFate.RAISES,
+ TaskFate.ANSWERS,
+ TaskFate.DIES,
+ TaskFate.ANSWERS,
+)
+
+
+class TestARunGoingOnPastFailingTasks(BaseTestSuite):
+ """A run that skips failing tasks answers every other task and names each one that failed, whether the
+ task raised or its worker died. A run that does not skip them ends with the first error."""
+
+ def test_the_answers_and_the_failures_are_both_named(self) -> None:
+ completed: List[RunCompleted[int]] = []
+ run = FatedProcessor(FATES, skips=True, max_workers=TWO_WORKERS)
+ run.set_callbacks(on_completed=completed.append)
+
+ run.start()
+ run.wait()
+
+ (outcome,) = completed
+ assert outcome.results == [0, 2, 4]
+ assert [failure.index for failure in outcome.failures] == [1, 3]
+ assert isinstance(outcome.failures[0].exception, ValueError)
+ assert isinstance(outcome.failures[1].exception, ProcessExpired)
+
+ def test_a_run_holding_to_its_tasks_ends_with_the_first_error(self) -> None:
+ completed: List[RunCompleted[int]] = []
+ errors: List[Exception] = []
+ run = FatedProcessor(FATES, skips=False, max_workers=TWO_WORKERS)
+ run.set_callbacks(on_completed=completed.append, on_error=errors.append)
+
+ run.start()
+ with pytest.raises(ValueError):
+ run.wait()
+
+ assert completed == []
+ assert [type(error) for error in errors] == [ValueError]
diff --git a/tests/screens/application/closing/test_during_a_long_rebuild.py b/tests/screens/application/closing/test_during_a_long_rebuild.py
new file mode 100644
index 000000000..0f2b0f3cf
--- /dev/null
+++ b/tests/screens/application/closing/test_during_a_long_rebuild.py
@@ -0,0 +1,89 @@
+import os
+from typing import Final, List, Tuple
+
+import pytest
+
+from automation.application.startup import Startup
+from automation.environment import repeats
+from automation.holds.regeneration import RegenerationHold
+from automation.screen import Screen
+from automation.steps.reconstructions import LOUDEST, drag_item_to, expect_open
+from automation.worlds.home import World
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world
+
+DRAGGED_ITEM: Final[int] = 3
+QUIET: Final[float] = 2.0
+VOLUME_BOUNDS: Final[Tuple[int, int]] = (0, LOUDEST)
+NEAR_THE_START: Final[float] = 0.05
+ZOOM_NOTCHES: Final[int] = 5
+REPEATS: Final[int] = repeats(os.environ)
+
+
+class TestClosingWhileALongRebuildRuns:
+ """Closing the window the moment a two-minute document's rebuild is let go waits for it and leaves.
+
+ A volume bar is dragged while the rebuild is held, so the rebuild is released and the close asked
+ for in one breath, and the close meets the rebuild on its worker. The application waits for the
+ edit, asks about the unsaved document once the edit has landed and the bar reads as the drag
+ left it, and leaves on the answer. The scenario runs as many times as
+ ``SAMPLETONES_SCREENS_REPEATS`` says, which is how a change to the worker's work is held to a
+ clean exit over and over.
+ """
+
+ @pytest.fixture
+ def world(self) -> World:
+ """The home holds the two-minute reconstruction with its recording."""
+ return long_document_world()
+
+ @pytest.fixture
+ def startup(self) -> Startup:
+ """The two-minute reconstruction is open at start, with no project."""
+ return Startup(reconstruction=LONG_RECONSTRUCTION, project=None)
+
+ @pytest.mark.parametrize("attempt", range(REPEATS))
+ def test_the_close_waits_for_the_rebuild_and_leaves(
+ self,
+ screen: Screen,
+ regeneration_hold: RegenerationHold,
+ attempt: int,
+ ) -> None:
+ """The question about the edited document comes once the rebuild lands, and confirming it leaves."""
+ prompt = screen.reconstructions.unsaved_prompt
+ instruments = screen.reconstructions.instruments
+ graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME)
+ moved: List[Tuple[int, List[int]]] = []
+
+ def drag_while_the_rebuild_is_held(screen: Screen) -> None:
+ expect_open(screen, LONG_RECONSTRUCTION)
+ instruments.bring_forward(ChannelName.PULSE1)
+ screen.hand.scroll_into_view(graph.plot)
+ graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES)
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
+
+ moved.append(drag_item_to(graph, standing, DRAGGED_ITEM, end=QUIET, bounds=VOLUME_BOUNDS))
+
+ screen.expect(regeneration_hold.waiting, bool, description="the rebuild held")
+
+ def release_and_close_at_once(screen: Screen) -> None:
+ regeneration_hold.release()
+ screen.close_window()
+
+ def the_question_comes_once_the_edit_has_landed(screen: Screen) -> None:
+ screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction")
+
+ assert screen.sound_settled()
+ index, expected = moved[0]
+ assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[index] == expected[index]
+
+ def the_application_leaves_on_the_answer(screen: Screen) -> None:
+ prompt.confirm()
+
+ assert screen.wait_for_exit()
+
+ screen.scenario(
+ drag_while_the_rebuild_is_held,
+ release_and_close_at_once,
+ the_question_comes_once_the_edit_has_landed,
+ the_application_leaves_on_the_answer,
+ ).run()
diff --git a/tests/screens/application/old_files/test_session_files_from_elsewhere.py b/tests/screens/application/old_files/test_session_files_from_elsewhere.py
index ce3b887e9..4417fcab6 100644
--- a/tests/screens/application/old_files/test_session_files_from_elsewhere.py
+++ b/tests/screens/application/old_files/test_session_files_from_elsewhere.py
@@ -9,9 +9,8 @@
from automation.worlds.home import World, screen_filling_state
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.session.state.current import Current
-from sampletones_application.paths import APPLICATION_STATE_PATH
from sampletones_core.configs import Config
-from sampletones_shared.paths.user import CONFIG_PATH
+from sampletones_shared.paths.user import APPLICATION_STATE_PATH, CONFIG_PATH
from tests.suite.screens.seeds.archives import WrittenBytes
from tests.suite.screens.seeds.recordings import Recording
diff --git a/tests/screens/interface/dialogs/cases.py b/tests/screens/interface/dialogs/cases.py
index 4870e7ead..31c996419 100644
--- a/tests/screens/interface/dialogs/cases.py
+++ b/tests/screens/interface/dialogs/cases.py
@@ -2,7 +2,7 @@
from typing import Final, Tuple
from sampletones_application.categories.elements.global_ import MenuElements
-from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT
+from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT, TAG_GLOBAL_DIALOG_DOCUMENTATION
from sampletones_application.tags.settings import (
TAG_SETTINGS_AUDIO_WINDOW,
TAG_SETTINGS_DISPLAY_WINDOW,
@@ -67,6 +67,12 @@ class MenuDialog:
MenuElements.ITEM_FILE_EXPORT_NSF,
TAG_SETTINGS_NSF_WINDOW,
),
+ MenuDialog(
+ "Documentation notice",
+ MenuElements.GROUP_HELP,
+ MenuElements.ITEM_HELP_DOCUMENTATION,
+ TAG_GLOBAL_DIALOG_DOCUMENTATION,
+ ),
MenuDialog(
"About",
MenuElements.GROUP_HELP,
diff --git a/tests/screens/interface/menus/test_documentation.py b/tests/screens/interface/menus/test_documentation.py
new file mode 100644
index 000000000..93abc521c
--- /dev/null
+++ b/tests/screens/interface/menus/test_documentation.py
@@ -0,0 +1,80 @@
+from typing import Final, Tuple
+
+import pytest
+
+from automation.screen import Screen
+from automation.worlds.home import World, screen_filling_state
+from sampletones_application.categories.elements.global_ import MenuElements
+from sampletones_application.config.profile import UserProfile
+from sampletones_shared.distribution import SAMPLETONES_DOCUMENTATION_ADDRESS
+from sampletones_shared.documentation.site import DocumentationSite
+from tests.suite.screens.seeds.documentation import SHIPPED_INDEX, ShippedDocumentation, shipped_site
+
+STILL_FRAMES: Final[int] = 20
+
+
+def choose_documentation(screen: Screen) -> None:
+ screen.menu.choose(MenuElements.GROUP_HELP, MenuElements.ITEM_HELP_DOCUMENTATION)
+
+
+def one_page(opened: Tuple[object, ...]) -> bool:
+ return len(opened) == 1
+
+
+def expect_the_copied_index(screen: Screen) -> None:
+ """The browser was handed the index of the copy in the reader's data folder, holding the shipped page."""
+ [index] = screen.expect(screen.opened, one_page, description="the documentation handed to the browser")
+ copy = DocumentationSite(UserProfile.user().documentation)
+ assert index == copy.index
+ assert index.read_text(encoding="utf-8") == SHIPPED_INDEX
+ assert copy.read_stamp() == shipped_site().read_stamp()
+
+
+class TestDocumentationOpens:
+ """Help > Documentation copies the shipped pages into the reader's data folder and hands their index
+ to the browser."""
+
+ @pytest.fixture
+ def world(self) -> World:
+ return World(
+ state=screen_filling_state(),
+ application_config=None,
+ config=None,
+ files=(ShippedDocumentation(),),
+ )
+
+ def test_the_index_opens_from_the_reader_folder(self, screen: Screen) -> None:
+ def choosing_hands_the_copy_to_the_browser(screen: Screen) -> None:
+ choose_documentation(screen)
+
+ expect_the_copied_index(screen)
+ assert not screen.documentation_notice.is_shown()
+
+ screen.scenario(choosing_hands_the_copy_to_the_browser).run()
+
+
+class TestDocumentationMissing:
+ """An installation without its pages says where the documentation lives online and opens nothing,
+ and the same item opens the pages once they are there."""
+
+ def test_the_notice_names_the_online_copy(self, screen: Screen) -> None:
+ def a_notice_names_the_address(screen: Screen) -> None:
+ choose_documentation(screen)
+
+ screen.expect(screen.documentation_notice.is_shown, bool, description="the documentation notice")
+ assert SAMPLETONES_DOCUMENTATION_ADDRESS in screen.documentation_notice.words()
+ screen.frames(STILL_FRAMES)
+ assert screen.opened() == ()
+
+ screen.documentation_notice.dismiss()
+ screen.expect(screen.documentation_notice.is_shown, lambda shown: not shown, description="the notice gone")
+
+ def the_pages_open_once_they_are_there(screen: Screen) -> None:
+ ShippedDocumentation().write()
+
+ choose_documentation(screen)
+
+ expect_the_copied_index(screen)
+ assert not screen.documentation_notice.is_shown()
+
+ screen.scenario(a_notice_names_the_address, the_pages_open_once_they_are_there).run()
diff --git a/tests/screens/main/run/test_skipping.py b/tests/screens/main/run/test_skipping.py
new file mode 100644
index 000000000..a0838da11
--- /dev/null
+++ b/tests/screens/main/run/test_skipping.py
@@ -0,0 +1,123 @@
+import operator
+from pathlib import Path
+from typing import Final, List
+
+import pytest
+
+from automation.screen import Screen
+from automation.steps.main import gather, home_path
+from automation.worlds.home import World
+from tests.screens.main.run.conftest import recording
+from tests.screens.main.run.constants import RUN_TIMEOUT_SECONDS
+from tests.screens.main.run.steps import written
+from tests.suite.screens.seeds.recordings import UnreadableRecording
+from tests.suite.screens.worlds.recordings import converting_world
+
+TAKES: Final[str] = "Takes"
+BROKEN_TAKES: Final[str] = "Broken"
+FIRST: Final[str] = "first.wav"
+SECOND: Final[str] = "second.wav"
+BROKEN: Final[str] = "broken.wav"
+DAMAGED: Final[str] = "damaged.wav"
+SKIPPED_ONE: Final[str] = "main.converter.message.skipped_recording_heading"
+SKIPPED_SEVERAL: Final[str] = "main.converter.template.skipped_recordings_heading"
+NOTHING_WRITTEN: Final[str] = "main.converter.message.nothing_written"
+
+
+class TestABatchGoesOnPastABrokenRecording:
+ """A folder holding a recording no reader takes converts the rest, and the end names the one it skipped.
+
+ The folder is gathered and converted. The end of the run offers what it wrote and lists the broken
+ recording under its heading. The reconstructions of the readable recordings are written, and the
+ broken one has none.
+ """
+
+ @pytest.fixture
+ def world(self) -> World:
+ folder = home_path(TAKES)
+ return converting_world(
+ (
+ recording(folder / FIRST),
+ UnreadableRecording(destination=folder / BROKEN),
+ recording(folder / SECOND),
+ )
+ )
+
+ def test_the_rest_is_written_and_the_end_names_the_broken_one(self, screen: Screen) -> None:
+ converter = screen.main.converter
+ destinations: List[Path] = []
+
+ def convert(screen: Screen) -> None:
+ gather(screen, home_path(TAKES))
+ destinations.append(Path(converter.destination()))
+
+ converter.press_action()
+
+ screen.claim_error(BROKEN)
+ screen.bridge.expect(
+ converter.end_prompt.is_shown,
+ bool,
+ description="the run's end",
+ timeout=RUN_TIMEOUT_SECONDS,
+ )
+
+ def the_end_names_it(screen: Screen) -> None:
+ words = converter.end_prompt.words()
+ converter.end_prompt.cancel()
+ screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed")
+
+ assert screen.words(SKIPPED_ONE) in words
+ assert BROKEN in words
+ assert FIRST not in words
+ assert sorted(path.stem for path in written(destinations[0])) == [Path(FIRST).stem, Path(SECOND).stem]
+
+ screen.scenario(convert, the_end_names_it).run()
+
+
+class TestABatchOfBrokenRecordingsWritesNothing:
+ """A folder whose every recording is broken writes nothing, and its notice says so and names them all.
+
+ The notice offers nothing to load, and the button names the run again once it is dismissed.
+ """
+
+ @pytest.fixture
+ def world(self) -> World:
+ folder = home_path(BROKEN_TAKES)
+ return converting_world(
+ (
+ UnreadableRecording(destination=folder / BROKEN),
+ UnreadableRecording(destination=folder / DAMAGED),
+ )
+ )
+
+ def test_the_notice_names_every_broken_recording(self, screen: Screen) -> None:
+ converter = screen.main.converter
+ notice = converter.notice
+ destinations: List[Path] = []
+ named: List[str] = []
+
+ def convert(screen: Screen) -> None:
+ gather(screen, home_path(BROKEN_TAKES))
+ destinations.append(Path(converter.destination()))
+ named.append(converter.action())
+
+ converter.press_action()
+
+ screen.claim_error(BROKEN)
+ screen.claim_error(DAMAGED)
+ screen.bridge.expect(notice.is_shown, bool, description="the notice", timeout=RUN_TIMEOUT_SECONDS)
+
+ def the_notice_names_them(screen: Screen) -> None:
+ words = notice.words()
+ notice.dismiss()
+ screen.expect(notice.is_shown, operator.not_, description="the notice dismissed")
+
+ assert words.startswith(screen.words(NOTHING_WRITTEN))
+ assert screen.words(SKIPPED_SEVERAL).format(count=2) in words
+ assert BROKEN in words
+ assert DAMAGED in words
+ assert not converter.end_prompt.is_shown()
+ assert converter.action() == named[0]
+ assert written(destinations[0]) == []
+
+ screen.scenario(convert, the_notice_names_them).run()
diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py
index 1e50146a6..991d062ce 100644
--- a/tests/screens/prompts/closing/test_during_an_edit.py
+++ b/tests/screens/prompts/closing/test_during_an_edit.py
@@ -9,11 +9,11 @@
from automation.steps.reconstructions import (
expect_open,
first_raised,
+ leave_letting_it_go,
marked,
raise_the_first_level_while_held,
titled,
)
-from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_core.constants.enums import ChannelName, FeatureKey
from tests.screens.prompts.closing.constants import SETTLING_FRAMES
from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION
@@ -110,14 +110,6 @@ def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None:
)
assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True))
- def leave_letting_it_go(screen: Screen) -> None:
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving again")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
screen.scenario(
edit_and_close_while_the_rebuild_is_held,
edit_again_after_the_close,
diff --git a/tests/screens/prompts/every_door/test_edited_reconstruction.py b/tests/screens/prompts/every_door/test_edited_reconstruction.py
index adfa36ff0..c1c083246 100644
--- a/tests/screens/prompts/every_door/test_edited_reconstruction.py
+++ b/tests/screens/prompts/every_door/test_edited_reconstruction.py
@@ -7,6 +7,7 @@
from automation.screen import Screen
from automation.steps.reconstructions import (
expect_open,
+ leave_letting_it_go,
marked,
raise_the_first_level,
remove_from_the_browser,
@@ -25,7 +26,6 @@
LOAD_TITLE,
SAVE,
)
-from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_core.constants.enums import ChannelName, FeatureKey
from tests.screens.prompts.every_door.cases import ASKING_DOORS, Door, Question, Standing
from tests.screens.prompts.every_door.constants import EXIT, SETTLING_FRAMES
@@ -107,17 +107,6 @@ def step(screen: Screen) -> None:
return step
-def leave_letting_it_go(screen: Screen) -> None:
- """Exits and answers Exit to the question about the reconstruction."""
- prompt = screen.reconstructions.unsaved_prompt
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about the reconstruction")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
-
class TestAnEditedReconstructionAtEveryDoor:
"""Every door asks about an edited reconstruction in its own words, and Cancel keeps everything as it
stood.
diff --git a/tests/screens/prompts/modals/test_held_rebuild.py b/tests/screens/prompts/modals/test_held_rebuild.py
index fd091020f..c2f2555da 100644
--- a/tests/screens/prompts/modals/test_held_rebuild.py
+++ b/tests/screens/prompts/modals/test_held_rebuild.py
@@ -8,6 +8,7 @@
from automation.screen import Screen
from automation.steps.reconstructions import (
expect_open,
+ leave_letting_it_go,
load_from_the_browser,
marked,
raise_the_first_level_while_held,
@@ -15,7 +16,6 @@
titled,
)
from automation.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE
-from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_core.constants.enums import ChannelName, FeatureKey
from tests.screens.prompts.modals.constants import SETTLING_FRAMES
from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION
@@ -86,14 +86,6 @@ def nothing_asks_after_them(screen: Screen) -> None:
assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True))
assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0]
- def leave_letting_it_go(screen: Screen) -> None:
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
screen.scenario(
edit_while_the_rebuild_is_held,
load_and_close_while_it_is_held,
@@ -169,14 +161,6 @@ def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None:
assert screen.shown_windows() == ()
assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0]
- def leave_letting_it_go(screen: Screen) -> None:
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
screen.scenario(
edit_a_removed_reconstruction_while_the_rebuild_is_held,
close_while_it_is_held,
diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py
index 601f04ec5..7abf7cd02 100644
--- a/tests/screens/reconstructions/edits/test_gestures_at_once.py
+++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py
@@ -5,12 +5,13 @@
import pytest
from automation.application.startup import Startup
-from automation.dearpygui.geometry import Point
from automation.holds.regeneration import RegenerationHold
from automation.screen import Screen
from automation.steps.project import save_project_as
from automation.steps.reconstructions import (
+ drag_item_to,
expect_open,
+ leave_letting_it_go,
load_from_the_browser,
marked,
raise_the_first_level,
@@ -51,11 +52,6 @@
SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp"
-def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]:
- """The items of the envelope that ``channel`` draws for ``feature``, read from the instruments panel."""
- return [int(item) for item in instruments.envelope(channel, feature).split()]
-
-
def dragged(
graph: BarGraph,
standing: List[int],
@@ -69,12 +65,7 @@ def dragged(
The item the plot puts under the release takes the value there, brought within ``bounds``, and every other
item stands as it did.
"""
- release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end)
- index, value = graph.item_under(release)
- low, high = bounds
- expected = [*standing]
- expected[index] = min(max(round(value), low), high)
- return index, expected
+ return drag_item_to(graph, standing, DRAGGED_ITEM, end=end, bounds=bounds)
def expect_items(
@@ -82,23 +73,12 @@ def expect_items(
) -> None:
"""Waits until the envelope of ``channel`` and ``feature`` reads ``expected``."""
screen.expect(
- lambda: items(instruments, channel, feature),
+ lambda: instruments.envelope_items(channel, feature),
expected.__eq__,
description=f"{channel} {feature} as the edits left it",
)
-def leave_letting_it_go(screen: Screen) -> None:
- """Presses Exit, confirms the question about unsaved changes and waits for the application to close."""
- prompt = screen.reconstructions.unsaved_prompt
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
-
class TestADragAndARemovalAtOnce:
"""A volume dragged and a recording removed while the drag is on its way both land.
@@ -124,7 +104,7 @@ def drag_while_the_rebuild_is_held(screen: Screen) -> None:
instruments.bring_forward(ChannelName.PULSE1)
screen.hand.scroll_into_view(graph.plot)
graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES)
- standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST)))
@@ -145,7 +125,7 @@ def both_land(screen: Screen) -> None:
screen.expect(lambda: stems.has_row(LAST_RECORDING), operator.not_, description="the recording gone")
index, expected = moved[0]
screen.expect(
- lambda: items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1],
+ lambda: instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1],
expected[: index + 1].__eq__,
description="the drag landed",
)
@@ -189,7 +169,7 @@ def edit_the_sample_and_save(screen: Screen) -> None:
ChannelName.PULSE1,
title=voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True),
)
- edited.append(items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME))
+ edited.append(instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME))
save_project_as(screen, SAVED_SONG)
@@ -199,7 +179,7 @@ def drag_and_undo_while_the_rebuild_is_held(screen: Screen) -> None:
release = graph.drag_item(FIRST_ITEM, start=edited[0][FIRST_ITEM], end=QUIET)
index, _ = graph.item_under(release)
screen.expect(regeneration_hold.waiting, bool, description="the rebuild held")
- assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index]
+ assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index]
screen.press_shortcut(ShortcutId.UNDO)
@@ -208,7 +188,7 @@ def the_drag_alone_is_undone(screen: Screen) -> None:
expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, edited[0])
screen.frames(SETTLE_FRAMES)
- assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0]
+ assert instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0]
screen.scenario(
edit_the_sample_and_save, drag_and_undo_while_the_rebuild_is_held, the_drag_alone_is_undone
@@ -240,7 +220,7 @@ def drag_and_save_while_the_rebuild_is_held(screen: Screen) -> None:
stored.append(SHORT_RECONSTRUCTION.read_bytes())
instruments.bring_forward(ChannelName.PULSE1)
screen.hand.scroll_into_view(graph.plot)
- standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST)))
screen.expect(regeneration_hold.waiting, bool, description="the rebuild held")
@@ -287,7 +267,7 @@ def drag_the_volume_while_held(screen: Screen) -> None:
expect_open(screen, SHORT_RECONSTRUCTION)
instruments.bring_forward(ChannelName.PULSE1)
screen.hand.scroll_into_view(volume.plot)
- standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
moved.append(dragged(volume, standing, end=QUIET, bounds=(0, LOUDEST)))
@@ -295,7 +275,7 @@ def drag_the_volume_while_held(screen: Screen) -> None:
def drag_the_arpeggio_meanwhile(screen: Screen) -> None:
screen.hand.scroll_into_view(arpeggio.plot)
- standing = items(instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO)
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.ARPEGGIO)
moved.append(dragged(arpeggio, standing, end=ARPEGGIO_STEP, bounds=(ARPEGGIO_FLOOR, ARPEGGIO_CEILING)))
diff --git a/tests/screens/reconstructions/edits/test_long_document_drag.py b/tests/screens/reconstructions/edits/test_long_document_drag.py
new file mode 100644
index 000000000..f266b5251
--- /dev/null
+++ b/tests/screens/reconstructions/edits/test_long_document_drag.py
@@ -0,0 +1,217 @@
+import time
+from typing import Final, List, Tuple
+
+import pytest
+
+from automation.application.startup import Startup
+from automation.holds.regeneration import RegenerationHold
+from automation.screen import Screen
+from automation.steps.reconstructions import (
+ LOUDEST,
+ drag_item_to,
+ expect_open,
+ leave_letting_it_go,
+ reconstruction_shade,
+ shade_reading,
+ wait_for_the_fade_to_end,
+)
+from automation.views.bar_graph import BarGraph
+from automation.views.instruments import Instruments
+from automation.worlds.home import World
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from tests.suite.screens.readings import frame_readings, keep_readings
+from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, long_document_world
+
+DRAGGED_ITEMS: Final[Tuple[int, ...]] = (2, 4, 6, 8)
+QUIET: Final[float] = 2.0
+LOUD: Final[float] = 13.0
+VOLUME_BOUNDS: Final[Tuple[int, int]] = (0, LOUDEST)
+NEAR_THE_START: Final[float] = 0.05
+ZOOM_NOTCHES: Final[int] = 5
+FRAMES_AFTER_THE_LANDING: Final[int] = 3
+BURST_READINGS: Final[str] = "frame_gaps.json"
+HELD_BURST_READINGS: Final[str] = "frame_gaps_held.json"
+LANDING_READINGS: Final[str] = "frame_gaps_landing.json"
+
+
+def _zoom_in_on(screen: Screen, instruments: Instruments, graph: BarGraph) -> str:
+ """Opens the long document's first pulse volume graph wide enough to drag its first bars.
+
+ Returns:
+ str: The shade the reconstruction line wears at rest, which the fade swaps.
+ """
+ expect_open(screen, LONG_RECONSTRUCTION)
+ instruments.bring_forward(ChannelName.PULSE1)
+ screen.hand.scroll_into_view(graph.plot)
+ graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES)
+ return reconstruction_shade(screen)
+
+
+def _drag_the_bars(instruments: Instruments, graph: BarGraph, releases: List[float]) -> List[int]:
+ """Drags each of :data:`DRAGGED_ITEMS` once, quiet and loud in turn, noting the clock at each release.
+
+ Returns:
+ List[int]: The volume items expected once every drag has landed.
+ """
+ expected = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
+ for turn, item in enumerate(DRAGGED_ITEMS):
+ _, expected = drag_item_to(graph, expected, item, end=QUIET if turn % 2 == 0 else LOUD, bounds=VOLUME_BOUNDS)
+ releases.append(time.monotonic())
+
+ return expected
+
+
+def _expect_the_drags_drawn(screen: Screen, instruments: Instruments, expected: List[int]) -> None:
+ """Waits until the volume items read as the drags left them."""
+ screen.expect(
+ lambda: instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME),
+ expected.__eq__,
+ description="the drags drawn",
+ )
+
+
+@pytest.fixture(name="world")
+def world_fixture() -> World:
+ """The home holds the two-minute reconstruction with its recording."""
+ return long_document_world()
+
+
+@pytest.fixture(name="startup")
+def startup_fixture() -> Startup:
+ """The two-minute reconstruction is open at start, with no project."""
+ return Startup(reconstruction=LONG_RECONSTRUCTION, project=None)
+
+
+class TestADragOnALongDocument:
+ """How the frames keep coming while envelope bars of a two-minute document are dragged.
+
+ The scenario records the clock after every frame through a burst of drags and until the
+ waveform's fade ends, then keeps the longest gap between two frames, how many gaps ran over a
+ tenth of a second, the frames drawn a second, and how long the line took to empty after the
+ last release, beside the scenario's other files. The readings are what a change to the edit
+ path is judged by, taken before and after the change. What the scenario holds the application
+ to are the events: the fade is seen on some frame through the burst, ends once the rebuilds
+ land, and the bars read as the drags left them.
+ """
+
+ def test_the_frames_through_a_burst_of_drags(self, screen: Screen) -> None:
+ """The readings are kept; the fade begins and ends, the drags land, and the application leaves cleanly."""
+ instruments = screen.reconstructions.instruments
+ graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME)
+ stamps: List[float] = []
+ releases: List[float] = []
+ unfaded: List[str] = []
+ expected: List[int] = []
+
+ def open_and_zoom_in(screen: Screen) -> None:
+ unfaded.append(_zoom_in_on(screen, instruments, graph))
+
+ def drag_until_the_fade_ends(screen: Screen) -> None:
+ shade = shade_reading(screen)
+ with screen.record(lambda: (time.monotonic(), shade())) as recording:
+ expected.extend(_drag_the_bars(instruments, graph, releases))
+ wait_for_the_fade_to_end(screen, unfaded[0])
+
+ readings = recording.values()
+ stamps.extend(stamp for stamp, _ in readings)
+ assert any(seen != unfaded[0] for _, seen in readings), "the fade was never seen through the burst"
+ _expect_the_drags_drawn(screen, instruments, expected)
+
+ def keep_the_readings(screen: Screen) -> None:
+ keep_readings(BURST_READINGS, frame_readings(stamps, releases[-1]))
+
+ screen.scenario(open_and_zoom_in, drag_until_the_fade_ends, keep_the_readings, leave_letting_it_go).run()
+
+
+class TestADragWhileTheRebuildIsHeld:
+ """What the interface itself costs per move: the same burst of drags with every rebuild held, so no
+ edit lands while the bars move.
+
+ The instruments panel draws each change as the pointer moves, so what the frames cost here is
+ the panel's own drawing of a long document's envelopes, apart from any landing. The bars read as
+ the drags left them before the rebuilds land and after.
+ """
+
+ def test_the_frames_through_a_held_burst(self, screen: Screen, regeneration_hold: RegenerationHold) -> None:
+ """The readings are kept, then the held rebuilds are released and the application leaves cleanly."""
+ instruments = screen.reconstructions.instruments
+ graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME)
+ stamps: List[float] = []
+ releases: List[float] = []
+ unfaded: List[str] = []
+ expected: List[int] = []
+
+ def open_and_zoom_in(screen: Screen) -> None:
+ unfaded.append(_zoom_in_on(screen, instruments, graph))
+
+ def drag_while_held(screen: Screen) -> None:
+ with screen.record(time.monotonic) as recording:
+ expected.extend(_drag_the_bars(instruments, graph, releases))
+
+ stamps.extend(recording.values())
+ screen.expect(regeneration_hold.waiting, bool, description="the rebuild held")
+ assert reconstruction_shade(screen) != unfaded[0]
+ _expect_the_drags_drawn(screen, instruments, expected)
+
+ def keep_the_readings(screen: Screen) -> None:
+ keep_readings(HELD_BURST_READINGS, frame_readings(stamps, releases[-1]))
+
+ def release_and_wait(screen: Screen) -> None:
+ regeneration_hold.release()
+ wait_for_the_fade_to_end(screen, unfaded[0])
+ _expect_the_drags_drawn(screen, instruments, expected)
+
+ screen.scenario(
+ open_and_zoom_in,
+ drag_while_held,
+ keep_the_readings,
+ release_and_wait,
+ leave_letting_it_go,
+ ).run()
+
+
+class TestOneLandingOnALongDocument:
+ """What one landing costs on screen: a bar is dragged while the rebuild is held, and the frames are
+ recorded from the release until the waveform's fade ends.
+
+ The drag's own drawing happens before the recording starts, so the longest gap read here is the
+ frame the landing holds up, with what the tab draws from it. The bar reads as the drag left it
+ once the edit has landed.
+ """
+
+ def test_the_frames_through_one_landing(self, screen: Screen, regeneration_hold: RegenerationHold) -> None:
+ """The readings are kept; the edit lands, the fade ends, and the application leaves cleanly."""
+ instruments = screen.reconstructions.instruments
+ graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME)
+ stamps: List[float] = []
+ released: List[float] = []
+ unfaded: List[str] = []
+ expected: List[int] = []
+
+ def drag_one_bar_while_held(screen: Screen) -> None:
+ unfaded.append(_zoom_in_on(screen, instruments, graph))
+ standing = instruments.envelope_items(ChannelName.PULSE1, FeatureKey.VOLUME)
+ _, items = drag_item_to(graph, standing, DRAGGED_ITEMS[0], end=QUIET, bounds=VOLUME_BOUNDS)
+ expected.extend(items)
+ screen.expect(regeneration_hold.waiting, bool, description="the rebuild held")
+ assert reconstruction_shade(screen) != unfaded[0]
+
+ def release_and_record_the_landing(screen: Screen) -> None:
+ with screen.record(time.monotonic) as recording:
+ regeneration_hold.release()
+ released.append(time.monotonic())
+ wait_for_the_fade_to_end(screen, unfaded[0])
+ screen.frames(FRAMES_AFTER_THE_LANDING)
+
+ stamps.extend(recording.values())
+ _expect_the_drags_drawn(screen, instruments, expected)
+
+ def keep_the_readings(screen: Screen) -> None:
+ keep_readings(LANDING_READINGS, frame_readings(stamps, released[0]))
+
+ screen.scenario(
+ drag_one_bar_while_held,
+ release_and_record_the_landing,
+ keep_the_readings,
+ leave_letting_it_go,
+ ).run()
diff --git a/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py b/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py
new file mode 100644
index 000000000..7e07f5165
--- /dev/null
+++ b/tests/screens/reconstructions/instruments/test_export_waits_for_the_sound.py
@@ -0,0 +1,61 @@
+from typing import Final, Tuple
+
+import pytest
+
+from automation.application.startup import Startup
+from automation.screen import Screen
+from automation.steps.exports import leaving_asks_nothing
+from automation.steps.reconstructions import load_from_the_browser
+from automation.worlds.home import World
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world
+
+CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items())
+
+
+@pytest.fixture(name="world")
+def world_fixture() -> World:
+ """The home holds the two-minute reconstruction over three recordings taking turns."""
+ return alternating_document_world()
+
+
+@pytest.fixture(name="startup")
+def startup_fixture() -> Startup:
+ """The application opens with no reconstruction and no project."""
+ return Startup(reconstruction=None, project=None)
+
+
+class TestAnExportAskedRightAfterAnOpening:
+ """The instruments panel offers an export once the opening's sound has landed, and nothing before.
+
+ An opening sends the document's sound and its envelopes to the worker together, so the panel
+ draws no bars and offers no export until they land. The scenario opens the two-minute document
+ from the browser, reads the export buttons while the sound is on its way, waits for it to land,
+ and reads them again.
+ """
+
+ def test_the_export_answers_once_the_sound_has_landed(self, screen: Screen) -> None:
+ instruments = screen.reconstructions.instruments
+
+ def exportable() -> Tuple[ChannelName, ...]:
+ return tuple(channel for channel in CHANNELS if instruments.can_export(channel))
+
+ def open_it_and_find_no_export_yet(screen: Screen) -> None:
+ load_from_the_browser(screen, LONG_RECONSTRUCTION)
+
+ screen.expect(
+ lambda: screen.reconstructions.shows_open(LONG_RECONSTRUCTION),
+ bool,
+ description=f"{LONG_RECONSTRUCTION.name} open",
+ )
+ assert not screen.sound_settled()
+ assert exportable() == ()
+
+ def the_export_answers_with_the_sound(screen: Screen) -> None:
+ screen.expect(screen.sound_settled, bool, description="the sound landed")
+
+ offered = screen.expect(exportable, bool, description="an export offered")
+ assert offered is not None
+ assert all(instruments.envelope(channel, FeatureKey.VOLUME) for channel in offered)
+
+ screen.scenario(open_it_and_find_no_export_yet, the_export_answers_with_the_sound, leaving_asks_nothing).run()
diff --git a/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py b/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py
new file mode 100644
index 000000000..ca06679b8
--- /dev/null
+++ b/tests/screens/reconstructions/instruments/test_the_card_while_the_sound_is_on_its_way.py
@@ -0,0 +1,120 @@
+import operator
+from typing import Final, List, Optional, Tuple
+
+import pytest
+
+from automation.application.startup import Startup
+from automation.screen import Screen
+from automation.steps.reconstructions import leave_letting_it_go, load_from_the_browser
+from automation.views.instruments import read_takes_edits, read_words
+from automation.vocabulary.instruments import NOTHING_LOADED, PREPARING
+from automation.worlds.home import World
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world
+
+CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items())
+REMOVED_KEY: Final[str] = "0"
+TICKED_KEY: Final[str] = "1"
+
+CardReading = Tuple[Optional[str], bool]
+
+
+@pytest.fixture(name="world")
+def world_fixture() -> World:
+ """The home holds the two-minute reconstruction over three recordings taking turns."""
+ return alternating_document_world()
+
+
+@pytest.fixture(name="startup")
+def startup_fixture() -> Startup:
+ """The application opens with no reconstruction and no project."""
+ return Startup(reconstruction=None, project=None)
+
+
+class TestTheCardWhileTheSoundIsOnItsWay:
+ """The instruments card keeps the bars it drew while the sound of a change is on its way, takes no edit of
+ them meanwhile, and takes edits again once the sound lands.
+
+ A document just opened has no bars yet, so the card says the instruments are being prepared,
+ and never that nothing is loaded. A removal and a recording ticked away and back at once
+ each send the sound and the envelopes to the worker. The scenario records the card's words and
+ whether a sequence field takes edits after every frame through each gesture: the bars stay,
+ the field is held for a while, and once the sound lands the field takes edits and the bars
+ stand drawn.
+ """
+
+ def test_the_card_keeps_its_bars_and_holds_edits_until_the_sound_lands(self, screen: Screen) -> None:
+ reconstructions = screen.reconstructions
+ instruments = reconstructions.instruments
+ stems = reconstructions.stems
+ preparing = screen.words(PREPARING)
+ nothing_loaded = screen.words(NOTHING_LOADED)
+ watched: List[ChannelName] = []
+
+ def drawn() -> Tuple[ChannelName, ...]:
+ return tuple(channel for channel in CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME))
+
+ def read_card() -> CardReading:
+ return (read_words(), read_takes_edits(watched[0], FeatureKey.VOLUME))
+
+ def free_with_its_bars(screen: Screen) -> None:
+ screen.expect(screen.sound_settled, bool, description="the sound landed")
+ screen.expect(
+ lambda: instruments.takes_edits(watched[0], FeatureKey.VOLUME),
+ bool,
+ description="the card taking edits",
+ )
+ assert instruments.says() is None
+ assert drawn()
+
+ def kept_and_held_meanwhile(readings: List[CardReading]) -> None:
+ assert all(words is None for words, _ in readings), readings
+ assert any(not takes_edits for _, takes_edits in readings), readings
+
+ def open_it_reading_the_words(screen: Screen) -> None:
+ assert instruments.says() == nothing_loaded
+
+ with screen.record(read_words) as recording:
+ load_from_the_browser(screen, LONG_RECONSTRUCTION)
+ screen.expect(
+ lambda: reconstructions.shows_open(LONG_RECONSTRUCTION),
+ bool,
+ description=f"{LONG_RECONSTRUCTION.name} open",
+ )
+ screen.expect(screen.sound_settled, bool, description="the sound of the opening landed")
+
+ seen = recording.values()
+ assert preparing in seen, seen
+ assert nothing_loaded not in seen[seen.index(preparing) :], seen
+ screen.expect(drawn, bool, description="the bars drawn")
+ watched.append(drawn()[0])
+ free_with_its_bars(screen)
+
+ def remove_one_keeping_the_bars(screen: Screen) -> None:
+ stems.remove(REMOVED_KEY)
+ screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing")
+
+ with screen.record(read_card) as recording:
+ stems.remove_prompt.confirm()
+ screen.expect(lambda: stems.has_row(REMOVED_KEY), operator.not_, description="the recording gone")
+ screen.expect(screen.sound_settled, bool, description="the sound of the rest landed")
+
+ kept_and_held_meanwhile(recording.values())
+ free_with_its_bars(screen)
+
+ def tick_one_away_and_back_keeping_the_bars(screen: Screen) -> None:
+ with screen.record(read_card) as recording:
+ stems.tick(TICKED_KEY)
+ stems.tick(TICKED_KEY)
+ screen.expect(lambda: stems.hears(TICKED_KEY), bool, description="the recording heard again")
+ screen.expect(screen.sound_settled, bool, description="the sound of the choice landed")
+
+ kept_and_held_meanwhile(recording.values())
+ free_with_its_bars(screen)
+
+ screen.scenario(
+ open_it_reading_the_words,
+ remove_one_keeping_the_bars,
+ tick_one_away_and_back_keeping_the_bars,
+ leave_letting_it_go,
+ ).run()
diff --git a/tests/screens/reconstructions/stems/test_long_document_boxes.py b/tests/screens/reconstructions/stems/test_long_document_boxes.py
new file mode 100644
index 000000000..d8671798c
--- /dev/null
+++ b/tests/screens/reconstructions/stems/test_long_document_boxes.py
@@ -0,0 +1,116 @@
+import time
+from typing import Dict, Final, List
+
+import pytest
+
+from automation.application.startup import Startup
+from automation.screen import Screen
+from automation.steps.exports import leaving_asks_nothing
+from automation.steps.reconstructions import expect_open, reconstruction_shade, wait_for_the_fade_to_end
+from automation.vocabulary.graphs import WAVEFORM_READING
+from automation.worlds.home import World
+from sampletones_core.constants.enums import ChannelName
+from tests.suite.screens.readings import frame_readings, keep_readings, longest_gap_after_each
+from tests.suite.screens.worlds.recordings import LONG_RECONSTRUCTION, alternating_document_world
+
+TOGGLED_CHANNEL: Final[ChannelName] = ChannelName.NOISE
+RECORDING_KEY: Final[str] = "0"
+TICKS_PER_BOX: Final[int] = 2
+FRAMES_AFTER_A_TICK: Final[int] = 5
+BOX_READINGS: Final[str] = "frame_gaps_boxes.json"
+
+
+@pytest.fixture(name="world")
+def world_fixture() -> World:
+ """The home holds the two-minute reconstruction over three recordings taking turns."""
+ return alternating_document_world()
+
+
+@pytest.fixture(name="startup")
+def startup_fixture() -> Startup:
+ """The two-minute reconstruction is open at start, with no project."""
+ return Startup(reconstruction=LONG_RECONSTRUCTION, project=None)
+
+
+class TestTheBoxesOnALongDocument:
+ """How the frames keep coming while a channel's box above the waveform and a recording's box on the
+ stems card are ticked off and on again on a two-minute document over three recordings taking turns.
+
+ A box changes what is heard, so the waveform, the audio, the figures and the stretches every
+ surface paints are read again from the document, and a sound the screen holds none of yet is
+ rendered on the worker while the frames keep coming. The recordings take four-frame turns, so
+ every lane divides into as many stretches as a long conversion over competing recordings does.
+ The scenario records the clock after every frame through the ticks, each followed by the fade's
+ end, and keeps the longest gap after each tick beside the readings the drag scenarios keep. The
+ readings are what a change to the listening path is judged by, taken before and after the
+ change. What the scenario holds the application to are the events: each tick moves the box it
+ was aimed at, and the fade ends after each.
+ """
+
+ def test_the_frames_through_the_ticks(self, screen: Screen) -> None:
+ """The readings are kept; each box answers its tick, each fade ends, and the application leaves cleanly."""
+ reconstructions = screen.reconstructions
+ stems = reconstructions.stems
+ stamps: List[float] = []
+ ticks: List[float] = []
+ unfaded: List[str] = []
+
+ def open_it(screen: Screen) -> None:
+ expect_open(screen, LONG_RECONSTRUCTION)
+ unfaded.append(reconstruction_shade(screen))
+
+ def tick_the_boxes(screen: Screen) -> None:
+ with screen.record(time.monotonic) as recording:
+ for _ in range(TICKS_PER_BOX):
+ ticked = reconstructions.channel_ticked(TOGGLED_CHANNEL)
+ ticks.append(time.monotonic())
+ reconstructions.tick_channel(TOGGLED_CHANNEL)
+ wait_for_the_fade_to_end(screen, unfaded[0])
+ assert reconstructions.channel_ticked(TOGGLED_CHANNEL) != ticked
+ screen.frames(FRAMES_AFTER_A_TICK)
+
+ for _ in range(TICKS_PER_BOX):
+ heard = stems.hears(RECORDING_KEY)
+ ticks.append(time.monotonic())
+ stems.tick(RECORDING_KEY)
+ wait_for_the_fade_to_end(screen, unfaded[0])
+ assert stems.hears(RECORDING_KEY) != heard
+ screen.frames(FRAMES_AFTER_A_TICK)
+
+ stamps.extend(recording.values())
+
+ def keep_the_readings(screen: Screen) -> None:
+ frames = frame_readings(stamps, ticks[-1])
+ readings: Dict[str, object] = dict(frames)
+ readings["longest_gap_after_each_tick_seconds"] = longest_gap_after_each(stamps, ticks)
+ keep_readings(BOX_READINGS, readings)
+
+ screen.scenario(open_it, tick_the_boxes, keep_the_readings, leaving_asks_nothing).run()
+
+
+class TestWhatTheFadeSays:
+ """The status bar names what the fade waits for after a tick: the sound being prepared.
+
+ The words stand for the few frames the sound takes, so they are read after every frame through
+ the tick. The words on opening are pinned where the line reports a listening and where the
+ waveform shows them, since the browser's own hover text holds the status bar while the pointer
+ rests on the row that opened the document.
+ """
+
+ def test_a_tick_reads_as_the_sound_being_prepared(self, screen: Screen) -> None:
+ reconstructions = screen.reconstructions
+ reading = screen.words(WAVEFORM_READING)
+ unfaded: List[str] = []
+
+ def open_it(screen: Screen) -> None:
+ expect_open(screen, LONG_RECONSTRUCTION)
+ unfaded.append(reconstruction_shade(screen))
+
+ def tick_a_box(screen: Screen) -> None:
+ with screen.record(screen.status_drawn) as recording:
+ reconstructions.tick_channel(TOGGLED_CHANNEL)
+ wait_for_the_fade_to_end(screen, unfaded[0])
+
+ assert reading in recording.values(), f"the status bar never read {reading!r} through the tick"
+
+ screen.scenario(open_it, tick_a_box, leaving_asks_nothing).run()
diff --git a/tests/screens/reconstructions/stems/test_stems_card.py b/tests/screens/reconstructions/stems/test_stems_card.py
index c70a8ce10..5c1522b6b 100644
--- a/tests/screens/reconstructions/stems/test_stems_card.py
+++ b/tests/screens/reconstructions/stems/test_stems_card.py
@@ -7,7 +7,7 @@
from automation.application.startup import Startup
from automation.screen import Screen
-from automation.steps.reconstructions import expect_open, load_from_the_browser, marked, titled
+from automation.steps.reconstructions import expect_open, leave_letting_it_go, load_from_the_browser, marked, titled
from automation.views.stems import StemsCard
from automation.vocabulary.playback import PAUSE
from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED
@@ -83,11 +83,13 @@ def another_row_shows_its_own(screen: Screen) -> None:
class TestRemovingARecording:
- """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go.
+ """Removing a recording asks first; the rest keep their colors, draw their bars again and play, and the last
+ one cannot go.
The scenario cancels the question, then removes the middle recording and confirms. The rest play, the
first goes too, and the last recording stays without a remove gesture. Saved and opened again, the card
- lists only the last recording in its old color.
+ lists only the last recording in its old color. A removal sends the sound and the envelopes of what is
+ left to the worker, so the instruments panel draws its bars again once that sound has landed.
"""
@pytest.fixture
@@ -96,11 +98,13 @@ def startup(self) -> Startup:
return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None)
def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None:
- """Cancel keeps every row; Remove drops one, and the others keep their colors and play."""
+ """Cancel keeps every row; Remove drops one, and the others keep their colors, their bars and play."""
reconstructions = screen.reconstructions
stems = reconstructions.stems
+ instruments = reconstructions.instruments
prompt = stems.remove_prompt
colors: List[Dict[str, Tuple[float, ...]]] = []
+ drawn: List[Tuple[ChannelName, ...]] = []
def the_file_named(screen: Screen) -> None:
expect_open(screen, STEMS_RECONSTRUCTION)
@@ -108,6 +112,10 @@ def the_file_named(screen: Screen) -> None:
assert screen.title() == stems_title(screen, unsaved=False)
assert all(stems.has_row(key) for key in KEYS)
colors.append(swatches(stems, KEYS))
+ drawn.append(
+ tuple(channel for channel in PLAYING_CHANNELS if instruments.envelope(channel, FeatureKey.VOLUME))
+ )
+ assert drawn[0]
def cancel_keeps_it(screen: Screen) -> None:
stems.remove(MIDDLE)
@@ -132,6 +140,17 @@ def remove_the_middle(screen: Screen) -> None:
assert screen.title() == stems_title(screen, unsaved=True)
assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)}
+ def the_bars_come_back_with_the_sound(screen: Screen) -> None:
+ screen.expect(screen.sound_settled, bool, description="the sound of the rest landed")
+
+ for channel in drawn[0]:
+ screen.expect(
+ partial(instruments.envelope, channel, FeatureKey.VOLUME),
+ bool,
+ description=f"the bars of {channel} drawn again",
+ )
+ assert instruments.can_export(channel)
+
def the_rest_plays(screen: Screen) -> None:
waveform = reconstructions.waveform
@@ -170,6 +189,7 @@ def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None:
the_file_named,
cancel_keeps_it,
remove_the_middle,
+ the_bars_come_back_with_the_sound,
the_rest_plays,
the_last_cannot_go,
saved_and_opened_again_it_keeps_its_color,
@@ -227,15 +247,6 @@ def its_row_leaves(screen: Screen) -> None:
assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)}
- def leave_letting_it_go(screen: Screen) -> None:
- prompt = reconstructions.unsaved_prompt
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
screen.scenario(hear_the_middle_alone, silence_every_channel_of_it, its_row_leaves, leave_letting_it_go).run()
@@ -274,13 +285,4 @@ def drag_pulse_one_to_silence(screen: Screen) -> None:
assert instruments.size(ChannelName.PULSE1) == screen.words(NOTHING_EXPORTED).format(bytes=0)
assert not instruments.can_export(ChannelName.PULSE1)
- def leave_letting_it_go(screen: Screen) -> None:
- prompt = reconstructions.unsaved_prompt
- screen.press_shortcut(ShortcutId.EXIT)
- screen.expect(prompt.is_shown, bool, description="the question about leaving")
-
- prompt.confirm()
-
- assert screen.wait_for_exit()
-
screen.scenario(drag_pulse_one_to_silence, leave_letting_it_go).run()
diff --git a/tests/screens/sequencer/colors/test_readability.py b/tests/screens/sequencer/colors/test_readability.py
new file mode 100644
index 000000000..46478f4f5
--- /dev/null
+++ b/tests/screens/sequencer/colors/test_readability.py
@@ -0,0 +1,176 @@
+import operator
+from functools import partial
+from typing import Final, Optional, Tuple
+
+from automation.dearpygui.geometry import Rect
+from automation.dearpygui.items.reading import read_item
+from automation.dearpygui.keys import IMGUI_ESCAPE
+from automation.palettes import shipped_palettes
+from automation.screen import Screen
+from automation.steps.sequencer import leave_letting_the_project_go
+from automation.views.tracker import tracker_cell
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import MAX_CHANNEL_VALUE, composite, contrast_ratio
+from tests.suite.screens.worlds.songs import PAD_ROW
+
+FILLED_FLOOR: Final[float] = 4.5
+STACKED_FLOOR: Final[float] = 3.5
+DIM_FLOOR: Final[float] = 3.0
+BAR_ROW: Final[int] = 0
+PLAIN_ROW: Final[int] = 1
+TYPED_ROW: Final[int] = 2
+HOVERED_ROW: Final[int] = 3
+LINE_NUMBER: Final[str] = "00"
+PAD_NUMBER: Final[str] = "02"
+TYPED_DIGIT: Final[str] = "0"
+TYPING_FRAMES: Final[int] = 10
+SETTLING_FRAMES: Final[int] = 6
+INSET: Final[int] = 2
+
+Color = Tuple[float, ...]
+
+
+def in_steps(color: Color) -> ColorRGBA:
+ """A color DearPyGui holds in fractions, in the whole steps the contrast arithmetic takes."""
+ red, green, blue, alpha = (round(part * MAX_CHANNEL_VALUE) for part in color)
+ return (red, green, blue, alpha)
+
+
+class TestEveryKindReadsOnEveryBackground:
+ """Every kind of text the tracker draws keeps a readable contrast against every background it can land on, in
+ every palette: plain, beat and bar rows, the cursor's row and cell, a selected block, a hovered slot, and a
+ muted channel.
+
+ A filled voice, a transpose, a volume and the digit being typed are held to 4.5:1 on any row and under one
+ wash on a plain row: the cursor's, a selection's or the pointer's; 3.5:1 where such a wash stacks on a
+ beat or bar row. A placeholder and a muted channel's value are held to 3:1 on a plain row. The background is read from the
+ frame's own pixels beside the text, and the text color from the slot's theme, so the figure is the one a
+ reader sees. The scenario walks the default palette and then every shipped palette through Display
+ settings.
+ """
+
+ def test_contrast_holds_in_every_palette(self, screen: Screen) -> None:
+ tracker = screen.sequencer.tracker
+ settings = screen.display_settings
+ catalog = shipped_palettes()
+
+ def slot_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect:
+ rect: Optional[Rect] = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect)
+ assert rect is not None
+ return rect
+
+ def contrast(row: int, channel: ChannelName, slot: SubColumn) -> float:
+ """The contrast of a slot's text, as drawn over the background inside the slot's top left corner.
+
+ A muted channel's text is translucent, so the text is laid over the background first and the
+ color that reaches the screen is what is held against it.
+ """
+ screen.frames(SETTLING_FRAMES)
+ box = slot_box(row, channel, slot)
+ pixels = screen.frame_pixels()
+ background = in_steps(tuple(float(part) for part in pixels[int(box.y) + INSET, int(box.x) + INSET]))
+ text = tracker.text_color(row, channel, slot)
+ assert text is not None
+ return contrast_ratio(composite(background, in_steps(text)), background)
+
+ def expect_at_least(floor: float, row: int, channel: ChannelName, slot: SubColumn) -> None:
+ ratio = contrast(row, channel, slot)
+ assert ratio >= floor, f"{slot} at row {row} of {channel} reads at {ratio:.2f}, under {floor}"
+
+ def readable_in(palette: str) -> None:
+ """Reads every kind on every background in the palette now worn, leaving the grid as it found it."""
+ expect_at_least(DIM_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE)
+ expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOLUME)
+ expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(FILLED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(FILLED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE)
+
+ tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ screen.expect(
+ lambda: tracker.has_caret(TYPED_ROW, ChannelName.PULSE1), bool, description=f"the caret in {palette}"
+ )
+ tracker.leave()
+ expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE)
+
+ screen.hand.type_text(TYPED_DIGIT)
+ screen.frames(TYPING_FRAMES)
+ screen.expect(
+ partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE),
+ operator.methodcaller("startswith", TYPED_DIGIT),
+ description="the typed digit pending",
+ )
+ expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ screen.hand.press_key(IMGUI_ESCAPE, modifiers=[])
+ screen.expect(
+ partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE),
+ LINE_NUMBER.__eq__,
+ description="the entry let go",
+ )
+
+ tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE)
+ screen.expect(
+ lambda: tracker.has_caret(PAD_ROW, ChannelName.PULSE2), bool, description="the caret on a beat row"
+ )
+ tracker.leave()
+ expect_at_least(STACKED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE)
+
+ tracker.click(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ tracker.shift_click(TYPED_ROW, ChannelName.PULSE2, SubColumn.VOLUME)
+ tracker.leave()
+ expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE)
+ expect_at_least(STACKED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ screen.hand.press_key(IMGUI_ESCAPE, modifiers=[])
+
+ tracker.hover(HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE)
+ expect_at_least(FILLED_FLOOR, HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE)
+ tracker.hover(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ tracker.leave()
+
+ tracker.click_header(ChannelName.PULSE1)
+ tracker.leave()
+ expect_at_least(DIM_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ tracker.click_header(ChannelName.PULSE1)
+
+ def a_voice_on_a_plain_row(screen: Screen) -> None:
+ screen.tabs.bring_to_front(Tab.SEQUENCER)
+ tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE)
+ screen.hand.type_text(LINE_NUMBER)
+ screen.frames(TYPING_FRAMES)
+ screen.expect(
+ partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE),
+ LINE_NUMBER.__eq__,
+ description="the voice typed on a plain row",
+ )
+ tracker.leave()
+
+ def the_default_palette_reads(screen: Screen) -> None:
+ readable_in(DEFAULT_PALETTE_NAME)
+
+ def every_other_palette_reads(screen: Screen) -> None:
+ for name in catalog.names:
+ if name == DEFAULT_PALETTE_NAME:
+ continue
+
+ settings.open()
+ screen.expect(settings.is_shown, bool, description="Display settings")
+ settings.choose_palette(name)
+ screen.frames(SETTLING_FRAMES)
+ settings.confirm()
+ screen.expect(settings.is_shown, operator.not_, description="Display settings closed")
+
+ readable_in(name)
+
+ screen.scenario(
+ a_voice_on_a_plain_row,
+ the_default_palette_reads,
+ every_other_palette_reads,
+ leave_letting_the_project_go,
+ ).run()
diff --git a/tests/screens/sequencer/tracker/caret/__init__.py b/tests/screens/sequencer/tracker/caret/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/screens/sequencer/tracker/caret/constants.py b/tests/screens/sequencer/tracker/caret/constants.py
new file mode 100644
index 000000000..bfec1706f
--- /dev/null
+++ b/tests/screens/sequencer/tracker/caret/constants.py
@@ -0,0 +1,18 @@
+from typing import Final
+
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import ChannelName
+
+CHANNEL: Final[ChannelName] = ChannelName.PULSE2
+SLOT: Final[SubColumn] = SubColumn.VOICE
+CLICKED_ROW: Final[int] = 2
+QUIET_FRAMES: Final[int] = 3
+LANDING_FRAMES: Final[int] = 6
+BURST: Final[int] = 3
+FIRST_POSITION: Final[int] = 0
+FIRST_CHARACTER: Final[int] = 0
+SECOND_CHARACTER: Final[int] = 1
+TYPED_DIGIT: Final[str] = "0"
+TYPING_FRAMES: Final[int] = 10
+COLOR_TOLERANCE: Final[float] = 0.12
+RGB: Final[int] = 3
diff --git a/tests/screens/sequencer/tracker/caret/steps.py b/tests/screens/sequencer/tracker/caret/steps.py
new file mode 100644
index 000000000..9db178a0c
--- /dev/null
+++ b/tests/screens/sequencer/tracker/caret/steps.py
@@ -0,0 +1,104 @@
+from dataclasses import dataclass
+from itertools import pairwise
+from typing import Dict, List, Optional, Sequence, Tuple
+
+import numpy as np
+
+from automation.boundaries.highlights import HighlightKind
+from automation.dearpygui.geometry import Rect
+from automation.dearpygui.items.reading import read_item
+from automation.screen import Screen
+from automation.views.tracker import caret_mark, frame_rows, tracker_cell
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE
+from sampletones_application.ui.panels.sequencer.columns import tracker_table_column
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import ChannelName
+from tests.screens.sequencer.tracker.caret.constants import COLOR_TOLERANCE, RGB
+
+
+@dataclass(frozen=True)
+class Drawing:
+ """What one frame left for the next: the row the cursor's cell is laid on and the caret's mark, beside the row
+ tops the frame itself drew.
+
+ Attributes:
+ cursor_row: The pattern row whose cell the cursor highlight stands on, or None for none.
+ mark: The caret's mark as set for the next frame, or None while it is hidden.
+ row_tops: The top edge of every frame row's slot, as the frame drew them.
+ """
+
+ cursor_row: Optional[int]
+ mark: Optional[Rect]
+ row_tops: Dict[int, float]
+
+ def pitch(self) -> float:
+ """How far apart two rows' tops stand."""
+ tops = sorted(self.row_tops.values())
+ return min(later - earlier for earlier, later in pairwise(tops))
+
+
+def read_drawing(screen: Screen, channel: ChannelName, slot: SubColumn) -> Drawing:
+ """Reads the drawing a frame left: the highlighted row and the mark for the next frame, the row tops of this one.
+ Runs on the render thread.
+ """
+ rows = frame_rows()
+ column = tracker_table_column(channel)
+ cursor_row = None
+ for place in screen.table_highlights():
+ if place.table == TAG_SEQUENCER_TRACKER_TABLE and place.kind == HighlightKind.CELL and place.column == column:
+ cursor_row = next((row for row, index in rows.items() if index == place.row), None)
+
+ tops: Dict[int, float] = {}
+ for row in rows:
+ rect = read_item(tracker_cell(row, channel, slot)).rect
+ if rect is not None:
+ tops[row] = rect.y
+
+ return Drawing(cursor_row=cursor_row, mark=caret_mark(), row_tops=tops)
+
+
+def frames_apart(drawings: Sequence[Drawing]) -> List[Tuple[int, int, float, float]]:
+ """Every frame that drew the caret's mark a row clear of the cursor's cell.
+
+ A drawing's mark and cursor row are set for the frame after it, whose row tops the next drawing
+ reports, so a mark is held against the next drawing's top of the row it was set for. Each entry
+ names the frame's place in the run, the row, the row's top and the mark's top.
+ """
+ apart = []
+ for index, (set_for_next, next_frame) in enumerate(pairwise(drawings)):
+ if set_for_next.mark is None or set_for_next.cursor_row is None:
+ continue
+
+ top = next_frame.row_tops[set_for_next.cursor_row]
+ if not top <= set_for_next.mark.y < top + next_frame.pitch():
+ apart.append((index, set_for_next.cursor_row, top, set_for_next.mark.y))
+
+ return apart
+
+
+def glyph_runs(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> List[Tuple[int, int]]:
+ """The stretches of pixel columns inside ``cell`` that carry ``color``, each a glyph, as (first, last) columns."""
+ x0, y0 = int(cell.x), int(cell.y)
+ x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height)
+ window = pixels[y0:y1, x0:x1, :RGB]
+ distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2)
+ columns = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=0))
+ runs: List[Tuple[int, int]] = []
+ for column in columns:
+ absolute = x0 + int(column)
+ if runs and absolute == runs[-1][1] + 1:
+ runs[-1] = (runs[-1][0], absolute)
+ else:
+ runs.append((absolute, absolute))
+
+ return runs
+
+
+def glyph_bottom(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> int:
+ """The lowest pixel row inside ``cell`` that carries ``color``."""
+ x0, y0 = int(cell.x), int(cell.y)
+ x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height)
+ window = pixels[y0:y1, x0:x1, :RGB]
+ distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2)
+ rows = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=1))
+ return y0 + int(rows.max())
diff --git a/tests/screens/sequencer/tracker/caret/test_alignment.py b/tests/screens/sequencer/tracker/caret/test_alignment.py
new file mode 100644
index 000000000..afedd6c3b
--- /dev/null
+++ b/tests/screens/sequencer/tracker/caret/test_alignment.py
@@ -0,0 +1,114 @@
+import operator
+from functools import partial
+from typing import Optional, Sequence, Tuple
+
+import numpy as np
+
+from automation.dearpygui.geometry import Rect
+from automation.dearpygui.items.reading import read_item
+from automation.dearpygui.keys import IMGUI_ESCAPE
+from automation.screen import Screen
+from automation.steps.sequencer import on_the_sequencer
+from automation.views.tracker import tracker_cell
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import ChannelName
+from tests.screens.sequencer.tracker.caret.constants import (
+ CHANNEL,
+ FIRST_CHARACTER,
+ FIRST_POSITION,
+ SECOND_CHARACTER,
+ SLOT,
+ TYPED_DIGIT,
+ TYPING_FRAMES,
+)
+from tests.screens.sequencer.tracker.caret.steps import glyph_bottom, glyph_runs
+from tests.suite.screens.worlds.songs import PAD_ROW
+
+Color = Tuple[float, ...]
+
+
+def under_run(mark: Rect, run: Tuple[int, int]) -> bool:
+ """Whether the glyph drawn over the columns ``run`` lies inside the mark's span."""
+ first, last = run
+ return mark.x <= first and last < mark.x + mark.width
+
+
+class TestTheMarkStandsUnderItsCharacter:
+ """The caret's mark spans exactly the character it marks, from that character's left edge, under the glyph.
+
+ The frame's pixels say where each glyph of the cell stands. The mark is read on the first
+ character of a voice, on the second once a digit is typed, and on the order grid's first
+ position, and a glyph the mark is set beside stays outside it.
+ """
+
+ def test_the_mark_spans_the_marked_glyph_alone(self, screen: Screen) -> None:
+ tracker = screen.sequencer.tracker
+ order = screen.sequencer.order
+
+ def mark_and_glyphs(cell: Rect, color: Optional[Color]) -> Tuple[Rect, Sequence[Tuple[int, int]], int]:
+ assert color is not None
+ pixels = screen.frame_pixels()
+ mark = tracker.caret_box()
+ assert mark is not None
+ return mark, glyph_runs(pixels, cell, color), glyph_bottom(pixels, cell, color)
+
+ def tracker_cell_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect:
+ rect = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect)
+ assert rect is not None
+ return rect
+
+ def the_first_character_of_a_voice(screen: Screen) -> None:
+ on_the_sequencer(screen)
+ tracker.click(PAD_ROW, CHANNEL, SLOT)
+ screen.expect(lambda: tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret clicked in")
+
+ mark, runs, bottom = mark_and_glyphs(
+ tracker_cell_box(PAD_ROW, CHANNEL, SLOT),
+ tracker.text_color(PAD_ROW, CHANNEL, SLOT),
+ )
+
+ assert len(runs) == 2
+ assert under_run(mark, runs[FIRST_CHARACTER])
+ assert not under_run(mark, runs[SECOND_CHARACTER])
+ assert runs[SECOND_CHARACTER][0] >= mark.x + mark.width
+ assert mark.y >= bottom
+
+ def the_second_character_once_a_digit_is_typed(screen: Screen) -> None:
+ screen.hand.type_text(TYPED_DIGIT)
+ screen.frames(TYPING_FRAMES)
+ screen.expect(
+ partial(tracker.label, PAD_ROW, CHANNEL, SLOT),
+ operator.methodcaller("startswith", TYPED_DIGIT),
+ description="the typed digit pending",
+ )
+
+ mark, runs, _ = mark_and_glyphs(
+ tracker_cell_box(PAD_ROW, CHANNEL, SLOT),
+ tracker.text_color(PAD_ROW, CHANNEL, SLOT),
+ )
+
+ assert len(runs) == 2
+ assert under_run(mark, runs[SECOND_CHARACTER])
+ assert not under_run(mark, runs[FIRST_CHARACTER])
+
+ screen.hand.press_key(IMGUI_ESCAPE, modifiers=[])
+ screen.frames(TYPING_FRAMES)
+
+ def the_order_grids_first_position(screen: Screen) -> None:
+ order.click(CHANNEL, FIRST_POSITION)
+ screen.expect(lambda: not tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret handed over")
+
+ cell = order.cell_box(CHANNEL, FIRST_POSITION)
+ assert cell is not None
+ mark, runs, bottom = mark_and_glyphs(cell, order.text_color(CHANNEL, FIRST_POSITION))
+
+ assert len(runs) == 2
+ assert under_run(mark, runs[FIRST_CHARACTER])
+ assert not under_run(mark, runs[SECOND_CHARACTER])
+ assert mark.y >= bottom
+
+ screen.scenario(
+ the_first_character_of_a_voice,
+ the_second_character_once_a_digit_is_typed,
+ the_order_grids_first_position,
+ ).run()
diff --git a/tests/screens/sequencer/tracker/caret/test_follows_the_row.py b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py
new file mode 100644
index 000000000..f3b9799b6
--- /dev/null
+++ b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py
@@ -0,0 +1,76 @@
+from functools import partial
+from typing import List
+
+from automation.screen import Screen
+from automation.steps.sequencer import on_the_sequencer
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from tests.screens.sequencer.tracker.caret.constants import (
+ BURST,
+ CHANNEL,
+ CLICKED_ROW,
+ LANDING_FRAMES,
+ QUIET_FRAMES,
+ SLOT,
+)
+from tests.screens.sequencer.tracker.caret.steps import Drawing, frames_apart, read_drawing
+
+
+class TestTheCaretFollowsTheRow:
+ """A key that carries the caret to another row draws the caret and the cursor's cell on that row from the frame
+ the grid re-centers on, with no frame drawing the two a row apart.
+
+ Every frame between the press and the landing is read: the caret's mark and the highlighted
+ row set for the next frame, against the row tops that next frame draws. A step down, a burst
+ of steps, and a step back up are walked, and a step along the row, which asks for no scroll,
+ ends the run with the caret where it stood.
+ """
+
+ def test_the_mark_stands_on_the_cursor_row_on_every_frame(self, screen: Screen) -> None:
+ tracker = screen.sequencer.tracker
+ reading = partial(read_drawing, screen, CHANNEL, SLOT)
+
+ def frames_of(shortcut: ShortcutId, times: int) -> List[Drawing]:
+ with screen.record(reading) as recording:
+ screen.frames(QUIET_FRAMES)
+ for _ in range(times):
+ screen.press_shortcut(shortcut)
+ screen.frames(LANDING_FRAMES)
+
+ return recording.values()
+
+ def expect_landed(drawings: List[Drawing], row: int) -> None:
+ assert drawings[-1].cursor_row == row
+ assert frames_apart(drawings) == []
+
+ def a_step_down_lands_as_one(screen: Screen) -> None:
+ on_the_sequencer(screen)
+ tracker.click(CLICKED_ROW, CHANNEL, SLOT)
+ screen.expect(lambda: tracker.has_caret(CLICKED_ROW, CHANNEL), bool, description="the caret clicked in")
+
+ drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, 1)
+
+ expect_landed(drawings, CLICKED_ROW + 1)
+ assert any(drawing.cursor_row == CLICKED_ROW for drawing in drawings)
+
+ def a_burst_of_steps_lands_each_as_one(screen: Screen) -> None:
+ drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, BURST)
+
+ expect_landed(drawings, CLICKED_ROW + 1 + BURST)
+
+ def a_step_up_lands_as_one(screen: Screen) -> None:
+ drawings = frames_of(ShortcutId.TRACKER_PREVIOUS_ROW, 1)
+
+ expect_landed(drawings, CLICKED_ROW + BURST)
+
+ def a_step_along_the_row_keeps_the_rows_still(screen: Screen) -> None:
+ drawings = frames_of(ShortcutId.TRACKER_NEXT_SUBCOLUMN, 1)
+
+ expect_landed(drawings, CLICKED_ROW + BURST)
+ assert len({tuple(sorted(drawing.row_tops.items())) for drawing in drawings}) == 1
+
+ screen.scenario(
+ a_step_down_lands_as_one,
+ a_burst_of_steps_lands_each_as_one,
+ a_step_up_lands_as_one,
+ a_step_along_the_row_keeps_the_rows_still,
+ ).run()
diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py
index d8e59c06c..f8029cab8 100644
--- a/tests/screens/sequencer/tracker/test_notes_typed.py
+++ b/tests/screens/sequencer/tracker/test_notes_typed.py
@@ -107,7 +107,8 @@ def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None:
class TestAVoiceTyped:
"""A voice number typed into a slot arrives with its kind's color in the same frame.
- An instrument typed into the Sample column keeps the number and the color as they were.
+ An instrument typed into the Sample column is refused: the digits being typed wear the typing
+ color while they stand, and the cell ends with the number and the color it had.
"""
def test_the_color_comes_with_the_number(self, screen: Screen) -> None:
@@ -154,8 +155,10 @@ def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None:
frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER)
- assert all(frame[1] == standing[1] for frame in frames)
+ assert all(theme == standing[1] for label, theme in frames if label == standing[0])
+ assert all(theme != standing[1] for label, theme in frames if label != standing[0])
assert all(frame[0] != PAD_NUMBER for frame in frames)
+ assert frames[-1] == standing
assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0]
assert history_size(screen) == before
diff --git a/tests/screens/sequencer/voices/test_previews.py b/tests/screens/sequencer/voices/test_previews.py
new file mode 100644
index 000000000..924b13d51
--- /dev/null
+++ b/tests/screens/sequencer/voices/test_previews.py
@@ -0,0 +1,33 @@
+from functools import partial
+
+from automation.dearpygui.items.types import Item
+from automation.screen import Screen
+from automation.vocabulary.playback import PLAY_VOICE
+from sampletones_application.categories.hierarchy import Tab
+from tests.suite.screens.worlds.songs import LINE
+
+
+def voice_row(screen: Screen, name: str) -> Item:
+ """Brings the Sequencer tab to the front and returns the list row of the voice called ``name``."""
+ screen.tabs.bring_to_front(Tab.SEQUENCER)
+ return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}")
+
+
+class TestPlayingASampleFromItsRow:
+ """Play on a sample's row sounds the sample once its render lands, and the window answers meanwhile.
+
+ A sample never opened on the Reconstructions tab has no render kept, so the preview is rendered
+ on a worker of its own; the sound is heard once it lands, and the frames in between are drawn.
+ """
+
+ def test_play_sounds_the_sample_once_it_lands(self, screen: Screen) -> None:
+ voices = screen.sequencer.voices
+ menu = screen.context_menu
+ heard = screen.sound_heard()
+
+ voices.right_click(voice_row(screen, LINE))
+ screen.expect(menu.is_shown, bool, description="the row's menu")
+ menu.choose(screen.words(PLAY_VOICE))
+
+ screen.expect(screen.sound_settled, bool, description="the preview landed")
+ screen.expect(screen.sound_heard, heard.__lt__, description="the sample heard")
diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py
index f0d83f8c6..cebabd0b9 100644
--- a/tests/screens/sequencer/voices/test_voices_card.py
+++ b/tests/screens/sequencer/voices/test_voices_card.py
@@ -11,7 +11,7 @@
from automation.steps.reconstructions import BY_CONFIGURATION
from automation.steps.sequencer import leave_letting_the_project_go
from automation.views.menus import MenuEntry
-from automation.vocabulary.playback import PAUSE
+from automation.vocabulary.playback import PAUSE, PLAY_VOICE
from sampletones_application.categories.elements.global_ import MenuElements
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE
@@ -28,7 +28,6 @@
"sequencer.voices.label.import_instrument",
)
-PLAY_VOICE: Final[str] = "global.context.label.play"
NEW_INSTRUMENT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument"
NO_PROJECT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument_no_project"
STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample"
diff --git a/tests/suite/application.py b/tests/suite/application.py
index f9d152628..6f8e7e067 100644
--- a/tests/suite/application.py
+++ b/tests/suite/application.py
@@ -91,18 +91,30 @@ def drain(self) -> None:
callback, args = self._held.pop(0)
callback(*args)
+ def settle(self) -> None:
+ """Waits for the workers and runs what they reported, round after round, until a round brings nothing.
+
+ Running a report can hand a worker more to do, such as the render a landing asks for, and that
+ worker reports once the drain has passed. The rounds go on until the workers have ended with
+ nothing left to report.
+ """
+ SingleThreadExecutor.join_all()
+ while self._held:
+ self.drain()
+ SingleThreadExecutor.join_all()
+
def settled(gesture: VoidCallback) -> None:
"""Runs ``gesture``, waits for the workers it started, and runs what they reported.
A worker reports through the queue the render loop drains, so the reports are held while the
- workers run and drained once they have ended, in the order they arrived.
+ workers run and drained once they have ended, in the order they arrived, until the workers have
+ nothing more to report.
"""
queue = HeldQueue()
with patch.object(CallbackQueue, "add", queue.add):
gesture()
- SingleThreadExecutor.join_all()
- queue.drain()
+ queue.settle()
@pytest.fixture
diff --git a/tests/suite/envelopes.py b/tests/suite/envelopes.py
new file mode 100644
index 000000000..47066615b
--- /dev/null
+++ b/tests/suite/envelopes.py
@@ -0,0 +1,31 @@
+from sampletones_application.logic.reconstruction.envelopes import HeardEnvelopes, envelopes_view
+from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.view_model.reconstruction.envelopes import (
+ ChannelEnvelopesViewModel,
+)
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+
+
+def exported(reconstruction: Reconstruction, selection: StemSelection) -> HeardEnvelopes:
+ """What the worker exports for ``selection`` of ``reconstruction``, beside that choice."""
+ return HeardEnvelopes(selection=selection, channels=reconstruction.export_heard(selection))
+
+
+def heard_envelopes(reconstruction: Reconstruction, selection: StemSelection) -> ChannelEnvelopesViewModel:
+ """The envelopes the instruments panel draws for ``selection`` of ``reconstruction``, as a landing builds them."""
+ return envelopes_view(reconstruction, exported(reconstruction, selection))
+
+
+def land_envelopes(manager: ReconstructionManager) -> None:
+ """Installs on ``manager`` the envelopes the opening's listening lands: the open document's, for the choice
+ its listening makes.
+
+ A case driving the manager alone has no worker to export them, so it installs them the way a
+ landing does, and the panel reads them as it reads any landed envelopes.
+ """
+ reconstruction = manager.reconstruction
+ if reconstruction is None:
+ raise RuntimeError("No reconstruction is open to land the envelopes of")
+
+ manager.adopt_envelopes(reconstruction, exported(reconstruction, manager.listening.selection))
diff --git a/tests/suite/headless.py b/tests/suite/headless.py
index ddce2f68e..35624005d 100644
--- a/tests/suite/headless.py
+++ b/tests/suite/headless.py
@@ -11,6 +11,7 @@
from sampletones_application.utils.parallelization.background import stop_background_workers
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
from sampletones_core.configs import Config
+from sampletones_shared.documentation.site import DocumentationSite
from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
DISPLAY_FUNCTIONS: Final[Tuple[str, ...]] = (
@@ -63,6 +64,7 @@ def profile_in(directory: Path) -> UserProfile:
return UserProfile(
config=directory / "config.yaml",
state=directory / "state.yaml",
+ documentation=directory / "documentation",
)
@@ -87,7 +89,11 @@ def settings_in(directory: Path) -> Path:
def headless_application(directory: Path) -> Application:
"""The whole application on a profile and settings of its own in ``directory``."""
- return Application(profile=profile_in(directory), config_path=settings_in(directory))
+ return Application(
+ profile=profile_in(directory),
+ documentation=DocumentationSite(directory / "shipped-documentation"),
+ config_path=settings_in(directory),
+ )
@contextmanager
diff --git a/tests/suite/history/audit.py b/tests/suite/history/audit.py
index 280552c04..b783ab152 100644
--- a/tests/suite/history/audit.py
+++ b/tests/suite/history/audit.py
@@ -6,7 +6,11 @@
import numpy as np
from sampletones_application.logic.history.action import HistoryAction
-from sampletones_application.logic.history.fingerprint import ReconstructionHashCache, fingerprint_project
+from sampletones_application.logic.history.fingerprint import (
+ FreshHashes,
+ HashCache,
+ fingerprint_project,
+)
from sampletones_application.logic.history.manager import HistoryManager
from sampletones_application.logic.project.controller import ProjectController
from sampletones_core.constants.enums import ChannelName
@@ -22,8 +26,8 @@
def fresh_fingerprint(project: Project) -> str:
- """The project's whole state, every reconstruction hashed afresh rather than through a memo."""
- return fingerprint_project(project, reconstruction_hash=hash_model)
+ """The project's whole state, every reconstruction and every pattern hashed afresh."""
+ return fingerprint_project(project, hashes=FreshHashes())
def immediately(gesture: VoidCallback) -> None:
@@ -381,7 +385,7 @@ def walk(self) -> None:
def check(self) -> None:
self._check_shape()
- hashes = ReconstructionHashCache(reconstruction_hash=hash_model)
+ hashes = HashCache(stream_hash=hash_model, assignment_hash=hash_model, pattern_hash=hash_model)
projects = [entry.project for entry in self._history.entries]
if self._controller.is_open:
projects.append(self._controller.project)
@@ -407,15 +411,15 @@ def _check_shape(self) -> None:
assert self._history.can_undo == (self.model.cursor > 0)
assert self._history.can_redo == (self.model.cursor < len(self.model.entries) - 1)
- def _check_entries(self, hashes: ReconstructionHashCache) -> None:
+ def _check_entries(self, hashes: HashCache) -> None:
for index, (entry, expected) in enumerate(zip(self._history.entries, self.model.entries)):
- fingerprint = fingerprint_project(entry.project, reconstruction_hash=hashes.hash)
+ fingerprint = fingerprint_project(entry.project, hashes=hashes)
assert (
fingerprint == expected.fingerprint
), f"Entry {index} ({entry.action}) no longer holds the state it recorded"
- def _check_live(self, hashes: ReconstructionHashCache) -> None:
- fingerprint = fingerprint_project(self._controller.project, reconstruction_hash=hashes.hash)
+ def _check_live(self, hashes: HashCache) -> None:
+ fingerprint = fingerprint_project(self._controller.project, hashes=hashes)
assert (
fingerprint == self.model.live.fingerprint
), f"The live project differs from entry {self.model.cursor} ({self.model.live.action})"
diff --git a/tests/suite/history/projects.py b/tests/suite/history/projects.py
index 5c476d2b9..9b274b5f3 100644
--- a/tests/suite/history/projects.py
+++ b/tests/suite/history/projects.py
@@ -50,13 +50,12 @@ def lead_reconstruction() -> Reconstruction:
def bass_reconstruction() -> Reconstruction:
- """Two recordings each playing the triangle and the noise in turn."""
+ """Two recordings each playing the triangle and the noise in turn, detached as a project sample is."""
reconstruction = recorded_from(
sample_reconstruction([ChannelName.TRIANGLE, ChannelName.NOISE]),
BASS_RECORDINGS,
)
- reconstruction = reconstruction.detached()
- return reconstruction
+ return reconstruction.detached()
def pad_instrument() -> Instrument:
diff --git a/tests/suite/long_documents.py b/tests/suite/long_documents.py
new file mode 100644
index 000000000..b728834a1
--- /dev/null
+++ b/tests/suite/long_documents.py
@@ -0,0 +1,86 @@
+from pathlib import Path
+from typing import Dict, Final, List, Sequence, Tuple
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.reconstructions import Reconstruction
+from tests.suite.stems import recorded_from, single_entry_stems_data
+
+LOWEST_PITCH: Final[int] = 36
+PITCH_SPAN: Final[int] = 48
+VOLUMES: Final[int] = 16
+DUTY_CYCLES: Final[int] = 4
+NOISE_PERIODS: Final[int] = 16
+UNIT_COEFFICIENT: Final[float] = 1.0
+NO_RECORDINGS: Final[Tuple[Path, ...]] = ()
+
+
+def turning_instruction(
+ channel_name: ChannelName,
+ frame: int,
+) -> InstructionUnion:
+ """The instruction ``channel_name`` plays at ``frame`` of a long conversion.
+
+ The pitch, the level and the timbre each turn with the frame, so every frame is an instruction
+ of its own and a stream of them compresses as a conversion's does.
+ """
+ match channel_name:
+ case ChannelName.PULSE1 | ChannelName.PULSE2:
+ return PulseInstruction(
+ on=True,
+ pitch=LOWEST_PITCH + frame % PITCH_SPAN,
+ volume=frame % VOLUMES,
+ duty_cycle=frame % DUTY_CYCLES,
+ )
+ case ChannelName.TRIANGLE:
+ return TriangleInstruction(on=True, pitch=LOWEST_PITCH + frame % PITCH_SPAN)
+ case ChannelName.NOISE:
+ return NoiseInstruction(on=True, period=frame % NOISE_PERIODS, volume=frame % VOLUMES, short=False)
+
+
+def turning_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]:
+ """Every channel sounding through ``frames`` frames, each frame an instruction of its own."""
+ return {
+ channel_name: [turning_instruction(channel_name, frame) for frame in range(frames)]
+ for channel_name in ChannelName.items()
+ }
+
+
+def long_document(
+ seconds: float,
+ *,
+ config: Config,
+ recordings: Sequence[Path] = NO_RECORDINGS,
+) -> Reconstruction:
+ """A document shaped like a long conversion, lasting ``seconds`` at the rate ``config`` runs at.
+
+ Every channel sounds through every frame. With ``recordings``, the frames are shared out among
+ them in turn: the document names one recording per path, and each channel plays its part once
+ per recording, so a reader can leave a recording out and hear the rest.
+ """
+ frames = round(seconds * config.nes_frequency)
+ if not recordings:
+ instructions = turning_instructions(frames)
+ return Reconstruction.create(
+ instructions=instructions,
+ config=config,
+ coefficient=UNIT_COEFFICIENT,
+ audio_filepath=(),
+ stems_data=single_entry_stems_data(ChannelName.items(), instructions),
+ )
+
+ instructions = turning_instructions(frames // len(recordings))
+ document = Reconstruction.create(
+ instructions=instructions,
+ config=config,
+ coefficient=UNIT_COEFFICIENT,
+ audio_filepath=tuple(recordings[:1]),
+ stems_data=single_entry_stems_data(ChannelName.items(), instructions),
+ )
+ return recorded_from(document, recordings)
diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py
index a7b11b48a..95ab3ba46 100644
--- a/tests/suite/parallelization.py
+++ b/tests/suite/parallelization.py
@@ -1,9 +1,12 @@
+import os
import threading
from dataclasses import dataclass
+from enum import StrEnum
from pathlib import Path
from typing import Any, Callable, Final, FrozenSet, List, Optional, Tuple
from sampletones_core.parallelization.channel.protocol import StepReporter
+from sampletones_core.parallelization.outcome import RunCompleted
from sampletones_core.parallelization.processor import TaskProcessor
from sampletones_core.parallelization.task import TaskProgress, TaskStatus, TaskStep
from sampletones_shared.exceptions import OperationCanceled
@@ -15,6 +18,7 @@
NOTHING_COMPLETED: Final[int] = 0
WHOLE_TASK: Final[float] = 1.0
ONE_STEP: Final[int] = 1
+DIED_EXIT_CODE: Final[int] = 3
LIVE_STATUSES: Final[FrozenSet[TaskStatus]] = frozenset({TaskStatus.RUNNING, TaskStatus.COMPLETED})
@@ -81,8 +85,8 @@ def _create_tasks(self) -> List[Any]:
def _get_task_function(self) -> Callable[[CountingTask], int]:
return count_task
- def _process_results(self, results: List[int]) -> Tuple[int, ...]:
- return tuple(results)
+ def _process_results(self, completed: RunCompleted[int]) -> Tuple[int, ...]:
+ return tuple(completed.results)
def failing_task(task: CountingTask) -> int:
@@ -111,6 +115,65 @@ def _get_task_function(self) -> Callable[[CountingTask], int]:
return failing_task
+class TaskFate(StrEnum):
+ """How a task of a run ends: with an answer, with an error, or with its worker gone."""
+
+ ANSWERS = "answers"
+ RAISES = "raises"
+ DIES = "dies"
+
+
+@dataclass(frozen=True)
+class FatedTask:
+ """One task of a run whose ending the test decides."""
+
+ index: int
+ fate: TaskFate
+
+
+def fated_task(task: FatedTask) -> int:
+ """Answers which task it is, raises, or ends its worker, as the task's fate says.
+
+ Raises:
+ ValueError: Where the task's fate is to raise.
+ """
+ match task.fate:
+ case TaskFate.RAISES:
+ raise ValueError(f"task {task.index} raised")
+ case TaskFate.DIES:
+ os._exit(DIED_EXIT_CODE)
+
+ return task.index
+
+
+class FatedProcessor(TaskProcessor[int]):
+ """A run whose tasks end the way the test lays out, and which goes on past a failing one where asked."""
+
+ def __init__(
+ self,
+ fates: Tuple[TaskFate, ...],
+ *,
+ skips: bool,
+ max_workers: int,
+ ) -> None:
+ super().__init__(max_workers=max_workers)
+ self._fates = fates
+ self._skips = skips
+
+ @property
+ def skips_failed_tasks(self) -> bool:
+ return self._skips
+
+ def _create_tasks(self) -> List[Any]:
+ return [FatedTask(index=index, fate=fate) for index, fate in enumerate(self._fates)]
+
+ def _get_task_function(self) -> Callable[[FatedTask], int]:
+ return fated_task
+
+ def _process_results(self, completed: RunCompleted[int]) -> RunCompleted[int]:
+ return completed
+
+
class ProgressRecorder:
"""Keeps every reading a run offers, and waits for the one a test is after.
diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py
index 5832f2862..d54d0605c 100644
--- a/tests/suite/regeneration.py
+++ b/tests/suite/regeneration.py
@@ -1,38 +1,32 @@
-from dataclasses import dataclass
-from typing import AbstractSet, Callable, List, Tuple
+from typing import Callable, List, Mapping, Tuple
+import numpy as np
+
+from sampletones_application.services.regeneration.jobs import ListeningJob, RebuildJob, RenderJob
from sampletones_application.services.regeneration.result import (
- RegeneratedInstrument,
RegenerationResult,
+ RenderedDocument,
)
from sampletones_application.services.regeneration.service import RegenerationService
from sampletones_application.services.result import ServiceError, ServiceSuccess
from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
-
-
-@dataclass(frozen=True)
-class HeldRebuild:
- """One rebuild the regeneration was asked for, as it was asked."""
-
- reconstruction: Reconstruction
- channel_name: ChannelName
- features: Features
- heard: AbstractSet[int]
+from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders
class HeldRegeneration:
- """The regeneration service holding each rebuild it is asked for until a case lets it finish.
+ """The regeneration service holding each job it is asked for until a case lets it finish.
- A rebuild runs on a worker thread in the application, so its result arrives some time after it
+ A job runs on a worker thread in the application, so its result arrives some time after it
was asked for. Holding it lets a case put gestures in between, and finishing it runs the real
- rebuild.
+ job.
"""
def __init__(self) -> None:
self._service = RegenerationService()
- self._held: List[HeldRebuild] = []
+ self._held: List[RenderJob] = []
def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None:
self._service.subscribe(handler)
@@ -42,26 +36,54 @@ def start(
reconstruction: Reconstruction,
channel_name: ChannelName,
features: Features,
- heard: AbstractSet[int],
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
) -> None:
- self._held.append(HeldRebuild(reconstruction, channel_name, features, heard))
+ self._held.append(RebuildJob(reconstruction, channel_name, features, hearing, kept, recordings))
+
+ def start_listening(
+ self,
+ reconstruction: Reconstruction,
+ *,
+ hearing: Hearing,
+ kept: Mapping[ChannelName, np.ndarray],
+ recordings: Mapping[int, np.ndarray],
+ ) -> None:
+ self._held.append(ListeningJob(reconstruction, hearing, kept, recordings))
@property
- def held(self) -> Tuple[HeldRebuild, ...]:
- """The rebuilds asked for and not yet finished, the earliest first."""
+ def held(self) -> Tuple[RenderJob, ...]:
+ """The jobs asked for and not yet finished, the earliest first."""
return tuple(self._held)
def finish(self) -> None:
- """Runs the earliest held rebuild and delivers what it produced."""
- rebuild = self._held.pop(0)
- self._service._run(rebuild.reconstruction, rebuild.channel_name, rebuild.features, rebuild.heard)
+ """Runs the earliest held job and delivers what it produced."""
+ self._service._run(self._held.pop(0))
def finish_with(self, reconstruction: Reconstruction) -> None:
- """Delivers ``reconstruction`` as what the earliest held rebuild produced."""
- self._held.pop(0)
- self._service._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=reconstruction)))
+ """Delivers ``reconstruction``, rendered afresh for the hearing the earliest held job was asked with, as
+ what that job produced.
+ """
+ job = self._held.pop(0)
+ channels = rendered_channels(reconstruction)
+ mixed = rendered_mix(reconstruction)
+ self._service._emit(
+ ServiceSuccess(
+ value=RenderedDocument(
+ reconstruction=reconstruction,
+ channels=channels,
+ mix=mixed,
+ hearing=job.hearing,
+ heard=heard_renders(reconstruction, job.hearing, channels, mixed),
+ envelopes=reconstruction.export_heard(job.hearing.selection),
+ original=None,
+ )
+ )
+ )
def fail(self, exception: Exception) -> None:
- """Delivers ``exception`` as the earliest held rebuild's failure."""
+ """Delivers ``exception`` as the earliest held job's failure."""
self._held.pop(0)
self._service._emit(ServiceError(exception=exception))
diff --git a/tests/suite/renders.py b/tests/suite/renders.py
new file mode 100644
index 000000000..87d53e8b1
--- /dev/null
+++ b/tests/suite/renders.py
@@ -0,0 +1,31 @@
+from typing import Mapping
+
+import numpy as np
+
+from sampletones_application.logic.shared.renders import RenderCache
+from sampletones_core.audio import mix
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix
+from sampletones_core.reconstructions.reconstruction.stems.hearing import Hearing, heard_renders
+
+
+def warmed(
+ renders: RenderCache,
+ reconstruction: Reconstruction,
+ hearing: Hearing,
+ recordings: Mapping[int, np.ndarray],
+) -> None:
+ """Keeps in ``renders`` what the worker would hand back for ``hearing`` of ``reconstruction``, the mix of the
+ recordings heard included.
+
+ A case driving the panel logic alone has no worker to render what the reader hears, so it
+ keeps the renders the way a landing does, and the panel reads them as it reads any kept sound.
+ """
+ channels = rendered_channels(reconstruction)
+ mixed = rendered_mix(reconstruction)
+ renders.adopt(reconstruction, channels, mixed)
+ renders.adopt_heard(reconstruction, hearing, heard_renders(reconstruction, hearing, channels, mixed))
+ heard = hearing.selection.any_channel()
+ mixed_from = [recording for stem_id, recording in recordings.items() if stem_id in heard]
+ if mixed_from:
+ renders.adopt_original(mixed_from, reconstruction.config, mix(mixed_from))
diff --git a/tests/suite/screens/readings.py b/tests/suite/screens/readings.py
new file mode 100644
index 000000000..deb871600
--- /dev/null
+++ b/tests/suite/screens/readings.py
@@ -0,0 +1,47 @@
+import json
+import os
+from pathlib import Path
+from statistics import median
+from typing import Dict, Final, List, Sequence
+
+from automation.environment import ARTIFACTS_VARIABLE
+
+LONG_GAP_SECONDS: Final[float] = 0.1
+
+
+def frame_readings(stamps: List[float], last_gesture: float) -> Dict[str, float]:
+ """What the clock said after every frame, read as the gaps between frames and the frames a second.
+
+ Args:
+ stamps: The clock after each frame drawn, in order.
+ last_gesture: The clock at the last gesture, which the drain after it is measured from.
+ """
+ gaps = [later - earlier for earlier, later in zip(stamps, stamps[1:])]
+ span = stamps[-1] - stamps[0]
+ return {
+ "frames": len(stamps),
+ "seconds": span,
+ "longest_gap_seconds": max(gaps),
+ "median_gap_seconds": median(gaps),
+ "gaps_over_a_tenth": sum(1 for gap in gaps if gap > LONG_GAP_SECONDS),
+ "frames_per_second": (len(stamps) - 1) / span,
+ "drain_seconds_after_the_last_release": stamps[-1] - last_gesture,
+ }
+
+
+def longest_gap_after_each(stamps: List[float], gestures: Sequence[float]) -> List[float]:
+ """The longest gap between two frames after each gesture and before the next, one reading per gesture."""
+ readings: List[float] = []
+ bounds = [*gestures, float("inf")]
+ for started, ended in zip(bounds, bounds[1:]):
+ window = [stamp for stamp in stamps if started <= stamp < ended]
+ gaps = [later - earlier for earlier, later in zip(window, window[1:])]
+ readings.append(max(gaps, default=0.0))
+
+ return readings
+
+
+def keep_readings(name: str, readings: Dict[str, object]) -> None:
+ """Prints the readings and keeps them beside the scenario's other files."""
+ print(json.dumps(readings))
+ (Path(os.environ[ARTIFACTS_VARIABLE]) / name).write_text(json.dumps(readings, indent=2))
diff --git a/tests/suite/screens/seeds/documentation.py b/tests/suite/screens/seeds/documentation.py
new file mode 100644
index 000000000..a7b02a72a
--- /dev/null
+++ b/tests/suite/screens/seeds/documentation.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final
+
+from automation.paths import SHIPPED_DOCUMENTATION_FOLDER
+from sampletones_shared.documentation.site import DocumentationSite
+
+SHIPPED_INDEX: Final[str] = "SampleToNES documentation
Shipped pages
\n"
+
+
+def shipped_site() -> DocumentationSite:
+ """The site a scenario's application ships, in the home of the scenario's process."""
+ return DocumentationSite(Path.home() / SHIPPED_DOCUMENTATION_FOLDER)
+
+
+@dataclass(frozen=True)
+class ShippedDocumentation:
+ """The documentation an installation ships: an index sealed by its stamp, where the scenario's
+ application reads its pages from."""
+
+ def write(self) -> None:
+ """Writes the index and seals the site."""
+ site = shipped_site()
+ site.directory.mkdir(parents=True, exist_ok=True)
+ site.index.write_text(SHIPPED_INDEX, encoding="utf-8")
+ site.write_stamp()
diff --git a/tests/suite/screens/seeds/reconstructions.py b/tests/suite/screens/seeds/reconstructions.py
index a4c0a0aa0..df9389494 100644
--- a/tests/suite/screens/seeds/reconstructions.py
+++ b/tests/suite/screens/seeds/reconstructions.py
@@ -4,12 +4,19 @@
from sampletones_core.compatibility.kind import ObjectKind
from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
from sampletones_core.constants.enums import ChannelName
from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings
from tests.suite.compatibility import ARCHIVED_VERSIONS, archived
from tests.suite.screens.seeds.constants import TURNING_LEVELS
-from tests.suite.stems import recorded_from, single_entry_stems_data
+from tests.suite.stems import RECORDED_SCALE, recorded_from, single_entry_stems_data
LINE_START_PITCH: Final[int] = 60
LINE_STEP_FRAMES: Final[int] = 15
@@ -71,6 +78,58 @@ def write(self) -> None:
_save(self.destination, _playable_reconstruction(self.recordings, self.frames, self.nes_frequency))
+@dataclass(frozen=True)
+class AlternatingReconstruction:
+ """A :class:`PlayableReconstruction` whose recordings take turns holding the frames.
+
+ Each channel plays the ``frames`` frames once, and the recordings of ``recordings`` hold them in
+ turn, ``turn_frames`` frames at a time, so every lane divides into as many stretches as a long
+ conversion over competing recordings does. The recordings laid at those paths last the document's
+ length. It was converted at ``nes_frequency`` ticks a second.
+ """
+
+ destination: Path
+ recordings: Tuple[Path, ...]
+ frames: int
+ turn_frames: int
+ nes_frequency: int
+
+ def write(self) -> None:
+ """Saves the reconstruction to the destination, naming each recording as a source."""
+ instructions = _playable_instructions(self.frames)
+ stem_ids = list(range(len(self.recordings)))
+ assignments = [
+ ChannelAssignment(
+ channel_name=channel_name,
+ stem_ids=[
+ stem_ids[(frame // self.turn_frames) % len(stem_ids)] if instruction.on else RESTING_STEM_ID
+ for frame, instruction in enumerate(stream)
+ ],
+ )
+ for channel_name, stream in instructions.items()
+ ]
+ stems_data = StemsData(
+ config=StemsConfig(
+ entries=[
+ StemEntry(id=stem_id, settings=StemSettings.covering(list(instructions))) for stem_id in stem_ids
+ ],
+ hierarchy=StemsHierarchy(levels=[stem_ids]),
+ ),
+ assignments=assignments,
+ scale=RECORDED_SCALE,
+ ).with_sources(list(self.recordings))
+ _save(
+ self.destination,
+ Reconstruction.create(
+ instructions=instructions,
+ config=Config().with_library(nes_frequency=self.nes_frequency),
+ coefficient=1.0,
+ audio_filepath=tuple(self.recordings),
+ stems_data=stems_data,
+ ),
+ )
+
+
@dataclass(frozen=True)
class UnsoundReconstruction:
"""A :class:`PlayableReconstruction` over several recordings whose record states no scale.
diff --git a/tests/suite/screens/seeds/recordings.py b/tests/suite/screens/seeds/recordings.py
index a3deb9d33..f50c46a1a 100644
--- a/tests/suite/screens/seeds/recordings.py
+++ b/tests/suite/screens/seeds/recordings.py
@@ -13,6 +13,7 @@
STORED_RECORDING_SECONDS: Final[float] = 0.5
STORED_RECORDING_FREQUENCY: Final[float] = 220.0
AUDIO_PATH_FIELD: Final[str] = "audio_filepath"
+UNREADABLE_BYTES: Final[bytes] = b"these bytes hold no sound"
@dataclass(frozen=True)
@@ -31,6 +32,18 @@ def write(self) -> None:
soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE)
+@dataclass(frozen=True)
+class UnreadableRecording:
+ """A file in the home named like a recording, whose bytes no audio reader takes."""
+
+ destination: Path
+
+ def write(self) -> None:
+ """Writes bytes that hold no sound under the recording's name."""
+ self.destination.parent.mkdir(parents=True, exist_ok=True)
+ self.destination.write_bytes(UNREADABLE_BYTES)
+
+
def stored_recording() -> Recording:
"""The recording the archived reconstruction names, laid where its relative path leads from the home.
diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py
index 9021c4c43..746771f23 100644
--- a/tests/suite/screens/worlds/recordings.py
+++ b/tests/suite/screens/worlds/recordings.py
@@ -6,7 +6,11 @@
from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
from tests.suite.screens.seeds.libraries import MiniLibrary
from tests.suite.screens.seeds.projects import StoredProject
-from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, StoredReconstruction
+from tests.suite.screens.seeds.reconstructions import (
+ AlternatingReconstruction,
+ PlayableReconstruction,
+ StoredReconstruction,
+)
from tests.suite.screens.seeds.recordings import Recording, stored_recording
OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn"
@@ -22,10 +26,15 @@
SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn"
STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn"
SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn"
+LONG_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Long.stn"
TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav")
STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav")
+LONG_TAKE: Final[str] = "long.wav"
+LONG_TAKES: Final[Tuple[str, ...]] = ("long1.wav", "long2.wav", "long3.wav")
+TURN_FRAMES: Final[int] = 4
STEM_FRAMES: Final[int] = 60
PLAYABLE_FRAMES: Final[int] = 600
+LONG_FRAMES: Final[int] = 7200
FRAMES_PER_SECOND: Final[int] = 60
TAKE_FREQUENCY: Final[float] = 220.0
@@ -98,3 +107,42 @@ def playing_world() -> World:
StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT),
),
)
+
+
+def alternating_document_world() -> World:
+ """A home holding :data:`LONG_RECONSTRUCTION` as two minutes over three recordings taking four-frame turns,
+ whose every lane divides into as many stretches as a long conversion over competing recordings does.
+ """
+ takes = tuple(Path.cwd() / take for take in LONG_TAKES)
+ return World(
+ state=screen_filling_state(),
+ application_config=None,
+ config=None,
+ files=(
+ *(
+ Recording(
+ destination=take,
+ seconds=LONG_FRAMES / FRAMES_PER_SECOND,
+ frequency=TAKE_FREQUENCY * (index + 1),
+ )
+ for index, take in enumerate(takes)
+ ),
+ AlternatingReconstruction(LONG_RECONSTRUCTION, takes, LONG_FRAMES, TURN_FRAMES, DEFAULT_NES_FREQUENCY),
+ ),
+ )
+
+
+def long_document_world() -> World:
+ """A home holding :data:`LONG_RECONSTRUCTION`, two minutes of one recording, which an edit takes long enough
+ to rebuild and render for the frames to be read while it does.
+ """
+ take = Path.cwd() / LONG_TAKE
+ return World(
+ state=screen_filling_state(),
+ application_config=None,
+ config=None,
+ files=(
+ Recording(destination=take, seconds=LONG_FRAMES / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY),
+ PlayableReconstruction(LONG_RECONSTRUCTION, (take,), LONG_FRAMES, DEFAULT_NES_FREQUENCY),
+ ),
+ )
diff --git a/tests/unit/assets/documentation/__init__.py b/tests/unit/assets/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/documentation/conftest.py b/tests/unit/assets/documentation/conftest.py
new file mode 100644
index 000000000..d06615e5c
--- /dev/null
+++ b/tests/unit/assets/documentation/conftest.py
@@ -0,0 +1,121 @@
+from pathlib import Path, PurePosixPath
+from typing import Dict, Final
+
+import pytest
+from markdown_it import MarkdownIt
+
+from assets.documentation.markdown.parser import markdown_parser
+from assets.documentation.navigation import Navigation
+from assets.documentation.pages.model import ParsedPage
+from assets.documentation.pages.reader import parse_page
+from assets.documentation.published import INDEX_PAGE, published_pages
+
+ADDRESS: Final[str] = "https://example.com/project/docs"
+VERSION: Final[str] = "1.2.3"
+
+INDEX: Final[str] = """# Example documentation
+
+An opening paragraph.
+
+## Using it
+
+- [Start here](guide/start.md) — the first steps.
+- [Next steps](guide/next.md#second-part) — what follows.
+- [Glossary](glossary.md)
+
+## Development
+
+- [Notes](development/notes.md)
+"""
+
+START: Final[str] = """# Start here
+
+Before the first section.
+
+## First part
+
+See [the next part](next.md#second-part), [the guide](./), [the notes](../development/notes.md),
+[this part](#first-part) and [the web](https://example.com).
+
+
+
+## First part
+
+| Column | Value |
+|---|---|
+| one | 1 |
+"""
+
+NEXT: Final[str] = """# Next steps
+
+## Second part
+
+Text.
+"""
+
+GLOSSARY: Final[str] = """# Glossary
+
+## Term
+
+A word.
+"""
+
+NOTES: Final[str] = "# Notes\n"
+
+PROJECT: Final[str] = f"""[project]
+name = "example"
+version = "{VERSION}"
+
+[project.urls]
+Documentation = "{ADDRESS}"
+"""
+
+DOCUMENTS: Final[Dict[str, str]] = {
+ "index.md": INDEX,
+ "glossary.md": GLOSSARY,
+ "guide/start.md": START,
+ "guide/next.md": NEXT,
+ "development/notes.md": NOTES,
+}
+PICTURES: Final[Dict[str, bytes]] = {
+ "images/picture.webp": b"picture",
+ "images/unused.webp": b"unused",
+}
+
+
+@pytest.fixture
+def docs(tmp_path: Path) -> Path:
+ """A small docs folder: an index with a published and a development section, two guide pages,
+ a glossary, a development page and two pictures, one of them shown."""
+ root = tmp_path / "docs"
+ for relative, text in DOCUMENTS.items():
+ (root / relative).parent.mkdir(parents=True, exist_ok=True)
+ (root / relative).write_text(text, encoding="utf-8")
+
+ for relative, data in PICTURES.items():
+ (root / relative).parent.mkdir(parents=True, exist_ok=True)
+ (root / relative).write_bytes(data)
+
+ return root
+
+
+@pytest.fixture
+def project(tmp_path: Path) -> Path:
+ path = tmp_path / "pyproject.toml"
+ path.write_text(PROJECT, encoding="utf-8")
+ return path
+
+
+@pytest.fixture
+def parser() -> MarkdownIt:
+ return markdown_parser()
+
+
+@pytest.fixture
+def pages(parser: MarkdownIt, docs: Path) -> Dict[PurePosixPath, ParsedPage]:
+ return {relative: parse_page(parser, docs, relative) for relative in published_pages(docs)}
+
+
+@pytest.fixture
+def navigation(pages: Dict[PurePosixPath, ParsedPage]) -> Navigation:
+ return Navigation.read(pages[INDEX_PAGE], pages)
diff --git a/tests/unit/assets/documentation/links/__init__.py b/tests/unit/assets/documentation/links/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/documentation/links/test_resolver.py b/tests/unit/assets/documentation/links/test_resolver.py
new file mode 100644
index 000000000..4a65c95fa
--- /dev/null
+++ b/tests/unit/assets/documentation/links/test_resolver.py
@@ -0,0 +1,124 @@
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import Dict
+
+import pytest
+
+from assets.documentation.links.resolver import BrokenLinkError, LinkResolver
+from assets.documentation.navigation import Navigation
+from assets.documentation.pages.model import ParsedPage
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.unit.assets.documentation.conftest import ADDRESS
+
+START = PurePosixPath("guide/start.md")
+
+
+@pytest.fixture
+def resolver(
+ docs: Path,
+ pages: Dict[PurePosixPath, ParsedPage],
+ navigation: Navigation,
+) -> LinkResolver:
+ return LinkResolver(
+ docs,
+ {relative: page.anchors for relative, page in pages.items()},
+ navigation.folder_pages,
+ ADDRESS,
+ )
+
+
+class TestWhereALinkLeads(BaseTestSuite):
+ """A link written in a document leads to the page, the heading or the online copy it means."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ href: str
+ page: str
+ expected: str
+
+ test_cases = (
+ TestCase(
+ label="an external address",
+ href="https://example.com/a",
+ page="guide/start.md",
+ expected="https://example.com/a",
+ ),
+ TestCase(
+ label="a mail address",
+ href="mailto:someone@example.com",
+ page="index.md",
+ expected="mailto:someone@example.com",
+ ),
+ TestCase(label="a heading on the same page", href="#first-part", page="guide/start.md", expected="#first-part"),
+ TestCase(label="a page beside it", href="next.md", page="guide/start.md", expected="next.html"),
+ TestCase(
+ label="a heading on a page beside it",
+ href="next.md#second-part",
+ page="guide/start.md",
+ expected="next.html#second-part",
+ ),
+ TestCase(label="a page in a folder", href="guide/start.md", page="index.md", expected="guide/start.html"),
+ TestCase(
+ label="a page up a folder",
+ href="../glossary.md#term",
+ page="guide/start.md",
+ expected="../glossary.html#term",
+ ),
+ TestCase(label="a folder without an index", href="./", page="guide/start.md", expected="start.html"),
+ TestCase(label="a folder named from above", href="guide/", page="index.md", expected="guide/start.html"),
+ TestCase(
+ label="a development page",
+ href="../development/notes.md",
+ page="guide/start.md",
+ expected=f"{ADDRESS}/development/notes.md",
+ ),
+ TestCase(
+ label="a development folder", href="development/", page="index.md", expected=f"{ADDRESS}/development/"
+ ),
+ TestCase(
+ label="a picture",
+ href="../images/picture.webp",
+ page="guide/start.md",
+ expected=f"{ADDRESS}/images/picture.webp",
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_link(self, test_case: TestCase, resolver: LinkResolver) -> None:
+ assert resolver.resolve(test_case.href, PurePosixPath(test_case.page)) == test_case.expected
+
+
+class TestABrokenLink(BaseTestSuite):
+ """A link to a missing file or a missing heading fails the build, wherever it points."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ href: str
+
+ test_cases = (
+ TestCase(label="a missing heading here", href="#nowhere"),
+ TestCase(label="a missing page", href="missing.md"),
+ TestCase(label="a missing heading on a page", href="next.md#nowhere"),
+ TestCase(label="a missing development page", href="../development/missing.md"),
+ TestCase(label="a file above the docs", href="../../outside.md"),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_it_is_refused(self, test_case: TestCase, resolver: LinkResolver) -> None:
+ with pytest.raises(BrokenLinkError):
+ resolver.resolve(test_case.href, START)
+
+
+class TestAFolderWithAnIndex:
+ def test_a_folder_leads_to_its_index(
+ self,
+ docs: Path,
+ pages: Dict[PurePosixPath, ParsedPage],
+ navigation: Navigation,
+ ) -> None:
+ anchors = {relative: page.anchors for relative, page in pages.items()}
+ anchors[PurePosixPath("guide/index.md")] = frozenset()
+ resolver = LinkResolver(docs, anchors, navigation.folder_pages, ADDRESS)
+
+ assert resolver.resolve("guide/", PurePosixPath("index.md")) == "guide/index.html"
diff --git a/tests/unit/assets/documentation/markdown/__init__.py b/tests/unit/assets/documentation/markdown/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/documentation/markdown/test_anchors.py b/tests/unit/assets/documentation/markdown/test_anchors.py
new file mode 100644
index 000000000..4a3c717f2
--- /dev/null
+++ b/tests/unit/assets/documentation/markdown/test_anchors.py
@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+
+import pytest
+
+from assets.documentation.markdown.anchors import HeadingAnchors, heading_anchor
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+
+class TestTheAnchorAHeadingGets(BaseTestSuite):
+ """A heading's anchor is the one GitHub gives it: lower case, punctuation and code marks
+ dropped, every space a hyphen."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ heading: str
+ expected: str
+
+ test_cases = (
+ TestCase(label="plain words", heading="Choosing what to convert", expected="choosing-what-to-convert"),
+ TestCase(
+ label="punctuation", heading="Voices: samples and instruments", expected="voices-samples-and-instruments"
+ ),
+ TestCase(label="a code span", heading="The `.nsf` file", expected="the-nsf-file"),
+ TestCase(label="emphasis", heading="An *important* part", expected="an-important-part"),
+ TestCase(label="a lettered heading", heading="A. The file", expected="a-the-file"),
+ TestCase(label="hyphens kept", heading="One-shot and loops", expected="one-shot-and-loops"),
+ TestCase(label="surrounding spaces", heading=" Padded ", expected="padded"),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_anchor(self, test_case: TestCase) -> None:
+ assert heading_anchor(test_case.heading) == test_case.expected
+
+
+class TestHeadingAnchors:
+ def test_a_repeated_heading_is_numbered(self) -> None:
+ anchors = HeadingAnchors()
+
+ assert [anchors.claim("Part"), anchors.claim("Part"), anchors.claim("Part")] == ["part", "part-1", "part-2"]
+
+ def test_distinct_headings_keep_their_anchors(self) -> None:
+ anchors = HeadingAnchors()
+
+ assert [anchors.claim("First"), anchors.claim("Second")] == ["first", "second"]
diff --git a/tests/unit/assets/documentation/markdown/test_parser.py b/tests/unit/assets/documentation/markdown/test_parser.py
new file mode 100644
index 000000000..5cea2bde9
--- /dev/null
+++ b/tests/unit/assets/documentation/markdown/test_parser.py
@@ -0,0 +1,19 @@
+from markdown_it import MarkdownIt
+
+from assets.documentation.markdown.parser import TABLE_CLOSE, TABLE_OPEN
+
+TABLE = "| A | B |\n|---|---|\n| 1 | 2 |\n"
+
+
+class TestMarkdownParser:
+ def test_a_table_is_wrapped_to_scroll_on_its_own(self, parser: MarkdownIt) -> None:
+ html = parser.render(TABLE)
+
+ assert html.startswith(TABLE_OPEN)
+ assert html.endswith(TABLE_CLOSE)
+ assert "
1
" in html
+
+ def test_raw_html_passes_through(self, parser: MarkdownIt) -> None:
+ html = parser.render('