Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -45,10 +45,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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

<div align="center">
<img src="https://raw.githubusercontent.com/JakimPL/SampleToNES/main/docs/images/sampletones.webp" alt="SampleToNES" width="760">
Expand Down
13 changes: 9 additions & 4 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion docs/development/release/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,6 +60,23 @@ 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 four things 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 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.
Expand Down
13 changes: 11 additions & 2 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/archive_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions scripts/bootstrap/cuda.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions scripts/bootstrap/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@
BUNDLES: Final[str] = "bundles"
BUILD_ENVIRONMENT: Final[str] = ".venv-build"
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",)
CUPY_PACKAGES: Final[Tuple[str, ...]] = ("cupy", "cupy_backends", "cupyx")
CUDA_PACKAGE: Final[str] = "nvidia"
GPU_PACKAGES: Final[Tuple[str, ...]] = (*CUPY_PACKAGES, CUDA_PACKAGE)
CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage")
CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",)
CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",)
Expand Down
7 changes: 4 additions & 3 deletions scripts/bootstrap/platforms/bundling.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ class Bundling:
tkinter_advice: str
tkinter_warning: 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
22 changes: 18 additions & 4 deletions scripts/bootstrap/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down
Loading
Loading