diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 92d2a182..f4fe5dda 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -186,47 +186,62 @@ Build caching: ccache (macOS/Linux) and sccache (Windows) with GitHub Actions ca ## Release Process -Versions are defined in `versions.cmake`. The CI workflow (`.github/workflows/ci.yml`) triggers releases on `v*` tags. +Versions are defined in `versions.cmake`. The CI workflow (`.github/workflows/ci.yml`) triggers releases on `v*` tags. Tags can be created on either `develop` or `main`: +- **`develop`** — alpha/dev pre-releases (tagged with `-alpha`, `-dev`, etc.) +- **`main`** — stable releases only (tagged without suffix) **Important**: The `main` branch is protected — it requires pull requests and passing status checks. You cannot push directly to `main`. -### Steps -1. Ensure you are on `develop`: +### Alpha/Pre-release (from `develop`) +1. Bump version in `versions.cmake` on `develop`: ```bash git checkout develop + sed -i '' 's/PROJECT_CORE_VERSION ".*"/PROJECT_CORE_VERSION "0.4.9"/' versions.cmake + git add versions.cmake + git commit -m "bump version to 0.4.9" + git push origin develop ``` -2. Update version in `versions.cmake` (edit the `PROJECT_CORE_VERSION` line): +2. Tag the `develop` HEAD and push — this triggers the release jobs: ```bash - # Example: bump to 0.4.9 - sed -i '' 's/PROJECT_CORE_VERSION ".*"/PROJECT_CORE_VERSION "0.4.9"/' versions.cmake + git tag v0.4.9-alpha + git push origin v0.4.9-alpha ``` -3. Commit and push to `develop`: + +### Stable Release (from `main`) +1. Bump version in `versions.cmake` on `develop`: ```bash + git checkout develop + sed -i '' 's/PROJECT_CORE_VERSION ".*"/PROJECT_CORE_VERSION "0.5.0"/' versions.cmake git add versions.cmake - git commit -m "bump version to 0.4.9" + git commit -m "bump version to 0.5.0" git push origin develop ``` -4. Create a PR from `develop` to `main`: +2. Create a PR from `develop` to `main`: ```bash gh pr create --base main --head develop \ - --title "Bump version to 0.4.9" \ - --body "Version bump for v0.4.9-alpha release." + --title "Release v0.5.0" \ + --body "Stable release v0.5.0." ``` -5. Wait for CI to pass, then merge the PR (via GitHub UI or CLI): +3. Wait for CI to pass, then merge the PR (via GitHub UI or CLI): ```bash gh pr merge --merge ``` -6. Tag the merged commit on `main` and push — this triggers the release jobs: +4. Tag the merged commit on `main` and push — this triggers the release jobs: ```bash git fetch origin main - git tag v0.4.9-alpha origin/main - git push origin v0.4.9-alpha + git tag v0.5.0 origin/main + git push origin v0.5.0 ``` ### Version Tag Convention -- Stable release: `v0.5.0` -- Alpha/pre-release: `v0.4.9-alpha` -- The version in `versions.cmake` should match the numeric part of the tag + +| Tag | Branch | Meaning | +|-----|--------|---------| +| `v0.5.0` | `main` | Stable release | +| `v0.4.9-alpha` | `develop` | Alpha pre-release | +| `v0.4.9-dev` | `develop` | Dev pre-release | + +The version in `versions.cmake` should match the numeric part of the tag. ### CI Behavior - **Branch push**: runs `build` matrix job across macOS/Linux/Windows (tests only, with ccache/sccache) diff --git a/.claude/skills/release-process/SKILL.md b/.claude/skills/release-process/SKILL.md new file mode 100644 index 00000000..c4cd8b79 --- /dev/null +++ b/.claude/skills/release-process/SKILL.md @@ -0,0 +1,94 @@ +--- +name: release-process +description: GitHub release policy and CI/CD pipeline for uZX. Handles version bumping, tagging, and triggering releases. Use when bumping versions, creating alpha/dev pre-releases from develop, creating stable releases from main, or checking release status. +user-invocable: true +allowed-tools: Read, Edit, Bash, Glob, Grep, AskUserQuestion +--- + +# uZX Release Process + +## Branching Model + +| Branch | Purpose | Tag suffix | +|--------|---------|------------| +| `develop` | Alpha/dev pre-releases | `-alpha`, `-dev`, etc. | +| `main` | Stable releases only | none (e.g. `v0.5.0`) | + +`main` is protected — requires PRs and passing CI. Never push directly to `main`. + +## Version File + +Version is defined in `versions.cmake` as `PROJECT_CORE_VERSION`. This value propagates to all app targets (Studio, Tuning, Player). + +## Release Flows + +### Alpha/Pre-release (from `develop`) + +No PR to `main` required. Steps: + +1. Ensure on `develop` branch +2. Bump `PROJECT_CORE_VERSION` in `versions.cmake` +3. Commit: `git commit -m "bump version to X.Y.Z"` +4. Push: `git push origin develop` +5. Tag: `git tag vX.Y.Z-alpha` (or `-dev`, `-beta`, etc.) +6. Push tag: `git push origin vX.Y.Z-alpha` + +### Stable Release (from `main`) + +Requires PR from `develop` to `main`. Steps: + +1. Ensure on `develop` branch +2. Bump `PROJECT_CORE_VERSION` in `versions.cmake` +3. Commit and push to `develop` +4. Create PR: `gh pr create --base main --head develop --title "Release vX.Y.Z" --body "Stable release vX.Y.Z."` +5. Wait for CI, merge the PR +6. Tag merged commit: `git fetch origin main && git tag vX.Y.Z origin/main` +7. Push tag: `git push origin vX.Y.Z` + +## CI Pipeline (`.github/workflows/ci.yml`) + +### Branch push (`main`/`develop`) +- Runs `build` job (Debug, tests only) on macOS, Linux, Windows +- Uses ccache (macOS/Linux) and sccache (Windows) + +### Tag push (`v*`) — Release Pipeline +Triggers regardless of which branch the tag points to. + +1. **`create-release`** — Creates a draft GitHub Release + - Sets `prerelease: true` if tag contains `-alpha` or `-beta` + - Generates release notes automatically +2. **`release-macos`** / **`release-linux`** / **`release-windows`** — Build + test + package + upload (in parallel) + - Builds Release config for targets: `uZX`, `uZXPlayer`, `uZXTests` + - Runs tests + - Packages and uploads artifacts: + - macOS: `uZX-{ver}-macOS.zip`, `uZXPlayer-{ver}-macOS.zip` + - Linux: `uZX-{ver}-linux-x86_64.tar.gz`, `uZXPlayer-{ver}-linux-x86_64.tar.gz` + - Windows: `uZX-{ver}-windows-x64.zip`, `uZXPlayer-{ver}-windows-x64.zip` +3. **`publish-release`** — After all platforms finish: + - Downloads all artifacts, generates `SHA256SUMS.txt` + - Publishes the release (removes draft status) + +### Concurrency +- Branch runs are auto-cancelled when superseded +- Tag (release) runs are never cancelled + +## Instructions for Claude + +When the user asks to create a release: + +1. **Verify branch**: Must be on `develop` for alpha/dev, or have a clean `develop` for stable. +2. **Check current version**: Read `versions.cmake` to know the current version. +3. **Ask if needed**: If the user doesn't specify a version or tag suffix, ask. +4. **Bump version**: Edit `versions.cmake` if the version needs changing. +5. **Commit and push**: Stage, commit with message `bump version to X.Y.Z`, push to the correct branch. +6. **Tag and push**: Create the tag and push it. This triggers the CI release pipeline. +7. **For stable releases**: Create the PR to `main` first, wait for merge, then tag `origin/main`. + +Always confirm before pushing tags or creating PRs — these are visible actions that trigger CI pipelines. + +### Checking release status +```bash +gh run list --workflow=ci.yml --limit=5 +gh run view +gh release view +``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78ab69ee..1a7974f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: build-target: uZXTests test-bin: build/src/uZXTests_artefacts/Debug/uZXTests cache-path: ~/.cache/ccache + # Linux runners have 16 GB RAM; JUCE/Tracktion TUs are memory-heavy. + # Unbounded -j OOM-kills the runner (SIGTERM/143), so cap parallelism. parallel: 3 install-deps: true configure-extra: "" diff --git a/.gitignore b/.gitignore index 2f2855d6..77c64d33 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ Testing/ .claude/settings.local.json src/version.h +DEVLOG.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..228cb9f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +uZX is a C++23 JUCE/Tracktion Engine application. App and library code lives under `src/`: `controllers`, `models`, `viewmodels`, `gui`, `plugins/uZX`, `formats`, and `util`. `src/sources.cmake` is authoritative for source membership: add shared code to `SHARED_SOURCES`, UI/app-only code to `GUI_SOURCES`, and tests to `TEST_SOURCES`. Unit tests are colocated with implementation as `*.test.cpp`; `tests/main.cpp` provides the JUCE test runner. Assets live in `resources/`, docs in `docs/`, CMake helpers in `cmake/`, and vendored dependencies in `third_party/`. + +## Build, Test, and Development Commands + +- `git submodule update --init --depth=1`: fetch JUCE, Tracktion, and ayumi dependencies after cloning. +- `cmake --preset default`: configure a Debug build in `build/`. +- `cmake --build --preset default`: build the default targets. +- `cmake --build build --target uZX`: build Studio; use `uZXTuning`, `uZXPlayer`, or `uZXTests` for other targets. +- `ctest --preset default --output-on-failure`: run CTest after `uZXTests` has been built. +- `build/src/uZXTests_artefacts/Debug/uZXTests AYChip`: run tests matching a filter. +- `./format-code.sh -n` then `./format-code.sh`: dry-run and apply clang-format. + +## Coding Style & Naming Conventions + +Use `.clang-format`: 4 spaces, no tabs, 120 columns, C++23, sorted include blocks, and left-aligned pointers. Namespaces use `MoTool::` with sub-namespaces where appropriate. Classes and types use PascalCase; functions and methods use camelCase; private members use a trailing underscore; constants use upper-case or enum-style PascalCase. Put opening braces on the same line for functions and on a new line for classes and structs. + +## Testing Guidelines + +Add JUCE `UnitTest` classes in adjacent `*.test.cpp` files and register them with a static instance. Name tests by feature, such as `TuningViewModel` or `AYChip`, so direct binary filtering remains useful. Cover changed model, tuning, plugin, and controller behavior; add regression tests for bug fixes. Remember to list new test files in `TEST_SOURCES`. + +## Commit & Pull Request Guidelines + +Prefer concise imperative messages; use a lowercase scope when helpful (`docs:`, `ci:`, `build:`). Branch from `develop` and open PRs back to `develop`; `main` is stable and protected. PRs should describe behavior changes, link issues, list tests run, and include screenshots or recordings for UI changes. + +## Agent-Specific Instructions + +Do not edit generated build trees or vendored `third_party/` code unless explicitly required. Keep build artifacts out of commits. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fe15f845..ed3b0273 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,6 +11,7 @@ - [ ] Some bug with pure env bass viz on B channel in SkyTrainFunk close to the start - [ ] Playhead disappears while dragging fast enough - [ ] **Fix** sometimes stopping after 2 seconds of playback +- [ ] **Tone/envelope visual mismatch** — detuning between tone and envelope viz when periods are in ratio - [x] **Drag-and-drop** — accept `.psg` and `.uzx` files dropped onto the window - [x] **Open from Finder** — handle `anotherInstanceStarted()` / command-line args - [x] **File association** — `.psg` and `.uzx` macOS file types @@ -23,8 +24,8 @@ #### P1 — Important -- [ ] **Add left padding for player** -- [ ] **Global master volume** saved in app settings, not per-edit +- [x] **Add left padding for player** +- [ ] Save/Save As.. menu item for Player - [ ] Beat+frames-based timeline - [ ] Different rendering in seconds-only and beat-based timeline - [x] **PSG** file icons in Finder @@ -37,13 +38,18 @@ - [ ] **AY reset** at the start of playback - [x] **Shift-wheel** horizontal scrolling - [x] **Mouse gestures** work over the playhead (transparent hit zone) +- [ ] **Middle mouse drag-pan** — pan timeline by dragging with middle mouse button (DAW convention) +- [ ] **Global master volume** — save master volume in app settings, apply to all opened files +- [ ] **UI shortcuts documentation** — document keyboard/mouse shortcuts for users +- [ ] **Cross-platform shortcut unification** — check modifier key behavior consistency across macOS/Windows - [ ] **Last opened edit** not always working #### P2 — Nice to have -- [ ] **GitHub Actions CI for macOS** — build, upload as release artifact -- [ ] **Windows build** — GitHub Actions -- [ ] **Linux build** — GitHub Actions +- [x] **GitHub Actions CI for macOS** — build, upload as release artifact +- [x] **Windows build** — GitHub Actions +- [x] **Linux build** — GitHub Actions +- [ ] **Global default AY settings** — apply saved AY config (frequency, stereo, channels) when opening PSG from command line - [ ] **Mute/solo buttons** — per-channel mute in AY side panel --- @@ -64,7 +70,7 @@ #### P1 — Important -- [ ] **Release branch strategy** — tagging `main` recommended +- [x] **Release branch strategy** — tagging `main` recommended - [ ] **About dialog** — working links to GitHub repo, scroller for greets - [ ] **Error handling** — graceful message for corrupt/invalid PSG files - [ ] **Audio device fallback** — handle "no audio device" cleanly diff --git a/docs/TODO.md b/docs/TODO.md index 8880b96d..e12dbf2f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,7 @@ Granular tasks and implementation notes for active development. ## Arranger mode +- [x] Bug with playhead in uZX Studio (not in uZX Player) — root cause: failed audio device freezes audible time; now detected with an error alert + Audio Settings - [ ] Timecode switching (frames, seconds, bars/beats) - [ ] Grid respect new timecode format with beats and frames - [ ] FPS editing of timecode or of a separate edit FPS setting diff --git a/docs/contributing.md b/docs/contributing.md index bea9f7d0..f32aee3b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -42,9 +42,13 @@ build/tests/MoToolTests_artefacts/Debug/uZXTests AYChip # filter by name ## Release Process -Releases are triggered by pushing a `v*` tag. The `main` branch is protected, so changes must go through a PR. +Releases are triggered by pushing a `v*` tag. Tags can be created on either `develop` or `main`: +- **`develop`** — alpha/dev pre-releases (tagged with `-alpha`, `-dev`, etc.) +- **`main`** — stable releases only (tagged without suffix) -### Steps +The `main` branch is protected, so changes must go through a PR. + +### Alpha/Pre-release (from `develop`) 1. **Bump the version** on `develop`: ```bash @@ -55,11 +59,30 @@ Releases are triggered by pushing a `v*` tag. The `main` branch is protected, so git push origin develop ``` +2. **Tag `develop` HEAD** and push: + ```bash + git tag v0.4.9-alpha + git push origin v0.4.9-alpha + ``` + +No PR to `main` needed — this triggers the full release pipeline directly from `develop`. + +### Stable Release (from `main`) + +1. **Bump the version** on `develop`: + ```bash + git checkout develop + # Edit versions.cmake — update PROJECT_CORE_VERSION + git add versions.cmake + git commit -m "bump version to 0.5.0" + git push origin develop + ``` + 2. **Create a PR** from `develop` to `main`: ```bash gh pr create --base main --head develop \ - --title "Bump version to 0.4.9" \ - --body "Version bump for v0.4.9-alpha release." + --title "Release v0.5.0" \ + --body "Stable release v0.5.0." ``` 3. **Wait for CI**, then merge the PR. @@ -67,18 +90,19 @@ Releases are triggered by pushing a `v*` tag. The `main` branch is protected, so 4. **Tag the merged commit** on `main`: ```bash git fetch origin main - git tag v0.4.9-alpha origin/main - git push origin v0.4.9-alpha + git tag v0.5.0 origin/main + git push origin v0.5.0 ``` -This triggers the full release pipeline: build + test + package on macOS/Linux/Windows, then upload artifacts to a GitHub Release with SHA256 checksums. +Both flows trigger the full release pipeline: build + test + package on macOS/Linux/Windows, then upload artifacts to a GitHub Release with SHA256 checksums. ### Version Tag Convention -| Tag format | Meaning | -|---|---| -| `v0.5.0` | Stable release | -| `v0.4.9-alpha` | Pre-release / alpha | +| Tag | Branch | Meaning | +|-----|--------|---------| +| `v0.5.0` | `main` | Stable release | +| `v0.4.9-alpha` | `develop` | Alpha pre-release | +| `v0.4.9-dev` | `develop` | Dev pre-release | The version in `versions.cmake` (`PROJECT_CORE_VERSION`) should match the numeric part of the tag. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 00000000..b4b72af1 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,243 @@ +# µZX User Guide + +µZX is a suite of applications for working with PSG chip music (AY-3-8910 / YM2149), targeting ZX Spectrum demoscene production. + +--- + +## Applications + +| App | Description | +|-----|-------------| +| **µZX Studio** | Full-featured PSG music editor with timeline, instruments, and automation (Work in progress) | +| **µZX Player** | Lightweight player with visualization for `.psg` and `.uzx` files | +| **µZX Tuning** | Tuning table (note table) editor/viewer. Supports equal temperament and just-intonation | + +--- + +## µZX Player + +A minimal player for listening to PSG chip music files. + +![µZX Player](uzx-player.gif) + +### Opening Files + +- **Drag and drop** a `.psg` or `.uzx` file onto the window. +- **File menu → Open** (`Cmd+O` / `Ctrl+O`). +- **Double-click** a `.psg` or `.uzx` file in Finder / Explorer (file association registered on install). +- Pass a file path as a command-line argument. + +### Transport Bar + +Located at the top of the window. + +| Control | Description | +|---------|-------------| +| Rewind (⏮) | Jump to the beginning | +| Play/Pause (▶/⏸) | Start or pause playback | +| Position readout | Current time; format switches between bars/beats and mm:ss | +| BPM control | Tempo in BPM (inc/dec buttons or direct entry) | +| Master volume knob | Global output level | + +**Keyboard shortcuts:** + +| Key | Action | +|-----|--------| +| `Space` | Play / Pause | +| `Home` | Rewind to start | +| `Cmd+O` / `Ctrl+O` | Open file | +| `Cmd+Q` / `Ctrl+Q` | Quit | +| `Shift+Wheel` | Horizontal scroll | + +### Timeline + +The timeline shows the loaded file as a clip on a track. Click anywhere on the ruler or timeline to seek to that position. + +Scroll horizontally with `Shift+Wheel` or the scrollbar. + +### AY Side Panel + +A panel on the right side of the window shows the AY chip plugin controls for the currently selected track. Here you can adjust: + +- **Chip clock frequency** — affects pitch of the whole output +- **Stereo mode** — mono, ABC, ACB, or custom channel panning +- Channel-level output settings + +If no track is selected the panel shows "No AY plugin". + +--- + +## µZX Studio + +A DAW-style editor for composing PSG chip music. + +### Layout + +``` ++---------------------------------------------+ +| Transport Bar | ++-------------+-------------------------------+ +| Track | Timeline / Clip Area | +| Headers | | +| | [PSG Clip] [PSG Clip] ... | +| | | ++-------------+-------------------------------+ +| Details Panel (tabbed) | ++---------------------------------------------+ +``` + +### Transport Bar + +Same controls as µZX Player, plus: + +| Control | Description | +|---------|-------------| +| Record (⏺) | Arm recording | +| Automation Read | Read automation from recorded data | +| Automation Write | Write automation from control movements | +| Time signature | Displayed next to the BPM control | + +### Timeline + +The timeline contains **tracks** stacked vertically. Each track has a **header** on the left and a **body** on the right. + +#### Track Header + +| Control | Description | +|---------|-------------| +| Track name | Displays the track name; click to rename | +| R (Arm) | Arm track for recording | +| M (Mute) | Mute track output | +| S (Solo) | Solo this track (mutes all others) | +| I (Input) | Show/configure MIDI input | + +#### Clips + +Clips appear in the track body as colored blocks. PSG clips visualize chip register activity as miniature graphics inside each block. + +Click a clip to select it. The Details Panel updates to show clip-specific editors. + +#### Ruler + +A time ruler runs along the top of the timeline. The display format switches between bars/beats/frames and mm:ss depending on settings. + +#### Zoom and Scroll + +- **Scroll wheel** — vertical scroll +- **Shift + Scroll wheel** — horizontal scroll +- **Pinch gesture** — zoom in/out (trackpad) +- Drag the resizable edge on the track header area to resize the header column. +- Drag the bottom edge of a track row to resize track height. + +### Details Panel + +The panel below the timeline is tabbed. Available tabs depend on what is selected. + +#### PSG Parameter Editor + +When a PSG clip is selected, the parameter editor appears. It shows an automation curve for a chosen PSG parameter over time. + +- **Parameter list** — left sidebar lists available parameters (Tone Period A/B/C, Noise Period, Envelope Period, Volume, etc.). Click to switch. +- **Curve area** — shows the value of the parameter across the clip duration. Click to add points; drag points to edit. +- The curve aligns with the timeline grid. + +#### Track Devices / Plugins + +When a track is selected, the Devices panel shows the plugin chain on that track (e.g., the AY chip instrument plugin). Click **+** between device slots to insert a new plugin. + +--- + +## µZX Tuning + +A standalone editor for generating and previewing AY chip tuning tables. + +### Layout + +``` ++-------------------+-------------------------------+ +| Controls | Tuning Grid | +| | | +| Tuning table list | (note → period mapping) | +| Chip clock | | +| A4 frequency | | +| Reference tuning | | +| Key / Scale | | +| Play controls | | +| [Export] | | ++-------------------+-------------------------------+ +``` + +### Controls + +#### Tuning Table List + +Lists all available built-in tuning tables (equal temperament, just intonation, historical scales, etc.). Click a row to load and preview that tuning. + +#### Chip Clock + +Sets the AY chip clock frequency in Hz. This affects how register period values map to musical pitches. Common values: + +| Platform | Clock | +|----------|-------| +| ZX Spectrum (PAL) | 1,773,400 Hz | +| ZX Spectrum (NTSC) | 1,789,773 Hz | +| Amstrad CPC | 1,000,000 Hz | + +Select a preset from the dropdown or type a custom value. + +#### A4 Frequency + +Sets the concert pitch reference for A4 in Hz (default 440 Hz). Drag the slider or double-click to type a value. + +#### Reference Tuning + +Selects the base tuning system (e.g., equal temperament, Pythagorean, various just intonation variants). This determines the interval ratios used when computing the tuning grid. + +#### Key / Scale + +- **Key** — root note (C, C#, D … B) +- **Scale** — scale type (chromatic, major, minor, pentatonic, various microtonal scales) + +The tuning grid updates to show only the notes in the selected scale. + +#### Play Controls + +Allows auditioning notes directly through the AY chip emulator. + +| Control | Description | +|---------|-------------| +| Play Chords | Play selected note as a chord | +| Play Tone | Enable tone generator for preview | +| Retrigger Tone | Retrigger note on each click | +| Play Envelope | Enable AY envelope generator | +| Envelope Shape | Select AY envelope shape (0–15) | +| Modulation Mode | Choose modulation behavior | + +Click a cell in the tuning grid to hear the corresponding note. + +#### Export Button + +Exports the current tuning table as an assembly include file (`.asm`/`.inc`) with period values for all notes. The output is ready to include in ZX Spectrum or other target machine source code. + +### Tuning Grid + +The main grid displays the computed AY period register values for each note across multiple octaves. Columns are notes, rows are octaves. The cell color indicates the tuning error relative to the ideal frequency (cents deviation). + +Click a cell to preview the note through the AY emulator using the current play control settings. + +--- + +## File Formats + +| Extension | Description | +|-----------|-------------| +| `.psg` | Raw PSG register dump — standard format for AY chip music | +| `.uzx` | µZX project format — timeline edit with multiple tracks and metadata | + +--- + +## See Also + +- [ROADMAP.md](ROADMAP.md) — planned features and release milestones +- [docs/Tuning Systems.md](Tuning%20Systems.md) — background on tuning theory used in µZX Tuning +- [docs/Vision.md](Vision.md) — long-term project vision diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 388bdf7c..840f0d76 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -113,6 +113,7 @@ target_sources(${PLAYER_TARGET_NAME} PRIVATE controllers/Main.cpp ) + # Per-target ID so Main.cpp can resolve the app target at link time. # motool_common cannot use ProjectInfo::projectName because it is compiled # once with the first JuceHeader found (uZX Studio). @@ -193,10 +194,11 @@ add_custom_target(project_generate_juce_headers add_dependencies(motool_common project_generate_juce_headers) -add_dependencies(${STUDIO_TARGET_NAME} generate_version_header) -add_dependencies(${TUNING_TARGET_NAME} generate_version_header) -add_dependencies(${PLAYER_TARGET_NAME} generate_version_header) -add_dependencies(${TEST_TARGET_NAME} generate_version_header) +# AboutDialog.cpp (in motool_common) includes the generated version.h, so the +# header must exist before motool_common compiles. Attaching the dependency to +# motool_common covers every consumer (all app targets and uZXTests) — a clean +# CI build otherwise fails with "version.h: No such file or directory". +add_dependencies(motool_common generate_version_header) # Tracktion Utils # include("${CMAKE_CURRENT_SOURCE_DIR}/../third_party/tracktion_engine/tests/utils.cmake") diff --git a/src/controllers/MainController.cpp b/src/controllers/MainController.cpp index a9d14c82..1f9c6459 100644 --- a/src/controllers/MainController.cpp +++ b/src/controllers/MainController.cpp @@ -50,6 +50,24 @@ AppController::AppController() // Workaround for JUCE CoreAudio buffer overflow bug at low sample rates ensureMinimumSampleRate(); + + // A previously-saved setup may pair a separate input device with the output device, which + // freezes playback on macOS. Drop the mismatched input before the user hits an unplayable state. + sanitizeAudioInputDevice(); +} + +void AppController::sanitizeAudioInputDevice() { + auto& deviceManager = engine_.getDeviceManager().deviceManager; + + juce::AudioDeviceManager::AudioDeviceSetup setup; + deviceManager.getAudioDeviceSetup(setup); + + if (setup.inputDeviceName.isNotEmpty() && setup.inputDeviceName != setup.outputDeviceName) { + setup.inputDeviceName = {}; + setup.inputChannels.clear(); + setup.useDefaultInputChannels = true; + deviceManager.setAudioDeviceSetup(setup, true); + } } void AppController::initialize() { @@ -152,11 +170,60 @@ void AppController::ensureMinimumSampleRate() { } } +String AppController::getAudioDeviceError(bool requireRunning) const { + auto& deviceManager = engine_.getDeviceManager().deviceManager; + auto* device = deviceManager.getCurrentAudioDevice(); + + if (device == nullptr) + return "No audio output device is selected."; + + if (!device->isOpen()) + return "The audio device \"" + device->getName() + "\" could not be opened."; + + if (auto lastError = device->getLastError(); lastError.isNotEmpty()) + return "Audio device \"" + device->getName() + "\" error: " + lastError; + + if (device->getActiveOutputChannels().isZero()) + return "The audio device \"" + device->getName() + "\" has no active output channels."; + + // The device callback not running is the symptom behind the frozen playhead (e.g. a failed + // input device). Only treat it as fatal when we're about to start playback, since at startup + // the device may not have spun up yet. + if (requireRunning && !device->isPlaying()) + return "The audio device \"" + device->getName() + "\" is not running."; + + return {}; +} + +void AppController::reportAudioDeviceProblem(const String& error) { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "Audio Problem", + error + "\n\nPlayback will not advance until a working audio device is configured. " + "Please check your audio settings.", + "Open Audio Settings", + nullptr, + ModalCallbackFunction::create([](int) { + te::AppFunctions::showSettingsScreen(); + })); +} + +bool AppController::ensureAudioReadyForPlayback() { + auto error = getAudioDeviceError(/* requireRunning */ true); + if (error.isEmpty()) + return true; + + reportAudioDeviceProblem(error); + return false; +} + void AppController::changeListenerCallback(ChangeBroadcaster* source) { if (source == &selectionManager_) { // Selection changed, update command status commandManager_.commandStatusChanged(); } + // Note: an invalid audio configuration is prevented at the source by AudioSettingsComponent, + // and a leftover bad setup is caught by the play guard (ensureAudioReadyForPlayback). } void AppController::handlePluginManager() { @@ -483,9 +550,17 @@ bool AppController::perform(const InvocationInfo& info) { te::AppFunctions::deleteSelected(); break; // Transport commands - case MainAppCommands::transportPlay: + case MainAppCommands::transportPlay: { + // Guard against a misconfigured audio device: starting playback with a broken + // device leaves the audible time frozen and the playhead stuck (see getAudioDeviceError). + auto* activeEdit = isPlayer ? MoToolApp::getPlayerController().getEdit() + : MoToolApp::getArrangerController().getEdit(); + const bool isPlaying = activeEdit != nullptr && activeEdit->getTransport().isPlaying(); + if (!isPlaying && !ensureAudioReadyForPlayback()) + break; te::AppFunctions::startStopPlay(); break; + } case MainAppCommands::transportRecord: if (!isPlayer) MoToolApp::getArrangerController().handleRecord(); diff --git a/src/controllers/MainController.h b/src/controllers/MainController.h index 5bdd0b20..730d7a39 100644 --- a/src/controllers/MainController.h +++ b/src/controllers/MainController.h @@ -49,8 +49,19 @@ class AppController : public MenuBarModel, private: void handlePluginManager(); void ensureMinimumSampleRate(); + /** Drops a saved input device that differs from the output device (unplayable on macOS). */ + void sanitizeAudioInputDevice(); void showAboutDialog(); + /** Returns an error description if the audio output device is not usable, or an empty + string when audio is working. When requireRunning is true, a device whose callback is + not currently running is also treated as an error (use before starting playback). */ + String getAudioDeviceError(bool requireRunning = false) const; + /** Shows an alert about the audio problem and offers to open the Audio/MIDI settings screen. */ + void reportAudioDeviceProblem(const String& error); + /** Verifies audio is usable before starting playback; returns true if it is safe to play. */ + bool ensureAudioReadyForPlayback(); + te::Engine engine_; ApplicationCommandManager commandManager_; te::SelectionManager selectionManager_ {engine_}; diff --git a/src/controllers/UIBehavior.h b/src/controllers/UIBehavior.h index 57a22c8e..811df00d 100644 --- a/src/controllers/UIBehavior.h +++ b/src/controllers/UIBehavior.h @@ -4,6 +4,7 @@ #include "App.h" +#include "../gui/common/AudioSettingsComponent.h" #include "../gui/common/ProgressDialog.h" #include "../gui/common/Utilities.h" #include "../util/Helpers.h" @@ -128,14 +129,15 @@ class ExtUIBehaviour : public te::UIBehaviour { DialogWindow::LaunchOptions o; o.dialogTitle = TRANS("Audio Settings"); o.dialogBackgroundColour = LookAndFeel::getDefaultLookAndFeel().findColour(ResizableWindow::backgroundColourId); + // AudioSettingsComponent wraps the JUCE selector and prevents an unplayable configuration + // (a separate input device different from the output) from being applied - see its docs. o.content.setOwned( - new AudioDeviceSelectorComponent(getActiveController().getEngine().getDeviceManager().deviceManager, - 0, 512, 1, 512, - true, true, true, false)); + new AudioSettingsComponent(getActiveController().getEngine().getDeviceManager().deviceManager)); o.useNativeTitleBar = true; o.escapeKeyTriggersCloseButton = true; o.resizable = true; - o.content->setSize(500, 600); + // Sized to fit the device selector content - avoids the large empty area below it. + o.content->setSize(500, 470); o.launchAsync(); } diff --git a/src/gui/common/AudioSettingsComponent.h b/src/gui/common/AudioSettingsComponent.h new file mode 100644 index 00000000..a2e9ef61 --- /dev/null +++ b/src/gui/common/AudioSettingsComponent.h @@ -0,0 +1,140 @@ +#pragma once + +#include + +namespace MoTool { + +namespace te = tracktion; + +//============================================================================== +/** + * Audio settings panel that wraps JUCE's AudioDeviceSelectorComponent and blocks an + * unplayable device configuration from being applied. + * + * On macOS, selecting an input device that differs from the output device makes CoreAudio run + * them as an aggregate. With no usable input clock this freezes playback, and forcing input + * channels on instead triggers a per-block engine assertion (tracktion_WaveInputDevice). uZX + * synthesises its own audio and only needs input for recording, so a mismatched input device is + * simply rejected: the selection is reverted to the previous valid one and the user is told why. + */ +class AudioSettingsComponent : public Component, + private ChangeListener { +public: + explicit AudioSettingsComponent(juce::AudioDeviceManager& dm) + : deviceManager_(dm) + { + selector_ = std::make_unique( + deviceManager_, 0, 512, 1, 512, true, true, true, false); + addAndMakeVisible(*selector_); + + // Remember the starting configuration as the baseline to revert to. + deviceManager_.getAudioDeviceSetup(lastValidSetup_); + deviceManager_.addChangeListener(this); + validate(); + } + + ~AudioSettingsComponent() override { + deviceManager_.removeChangeListener(this); + } + + void resized() override { + // Pin the selector to a fixed width and a generous height; it self-shrinks its own height + // to fit its content (see AudioDeviceSelectorComponent::resized), so we read that back. + // Width must be fixed - feeding back the live width lets the selector collapse it. + selector_->setBounds(0, verticalPadding_, contentWidth_, 1000); + fitDialogToContent(); + } + + void parentHierarchyChanged() override { + fitDialogToContent(); + } + +private: + void changeListenerCallback(ChangeBroadcaster*) override { + validate(); + // The selector adds/removes the input-channels row as the input device changes, so re-fit + // the dialog to the new content height (otherwise empty space or clipping remains). + if (selector_ != nullptr) + selector_->setBounds(0, verticalPadding_, contentWidth_, 1000); + fitDialogToContent(); + } + + /** Resize the enclosing dialog window so it tightly fits the device selector content height. + The width is held fixed - the selector's internal layout collapses if it's allowed to drive + the width, so we always pin it to contentWidth_. */ + void fitDialogToContent() { + if (selector_ == nullptr) + return; + + // The selector shrinks its own height to its content when laid out. + const int contentHeight = selector_->getHeight(); + if (contentHeight <= 0) + return; + + const int desiredContentH = contentHeight + 2 * verticalPadding_; + + if (auto* dialog = findParentComponentOfClass()) { + const int chromeH = dialog->getHeight() - getHeight(); // title bar + borders + const int chromeW = dialog->getWidth() - getWidth(); + const int targetW = contentWidth_ + chromeW; + const int targetH = desiredContentH + chromeH; + if (dialog->getWidth() != targetW || dialog->getHeight() != targetH) + dialog->setSize(targetW, targetH); + } + } + + /** A configuration is valid when there is no separate input device: input must be either + "none" or the same physical device as the output. */ + static bool isValid(const juce::AudioDeviceManager::AudioDeviceSetup& s) { + return s.inputDeviceName.isEmpty() || s.inputDeviceName == s.outputDeviceName; + } + + void validate() { + if (reverting_) + return; + + juce::AudioDeviceManager::AudioDeviceSetup setup; + deviceManager_.getAudioDeviceSetup(setup); + + if (isValid(setup)) { + lastValidSetup_ = setup; + return; + } + + // Reject the mismatched input device: revert to the last valid configuration. Keep the + // output device the user just chose, but drop the incompatible input. + const auto rejectedInput = setup.inputDeviceName; + + auto reverted = setup; + reverted.inputDeviceName = (lastValidSetup_.inputDeviceName == setup.outputDeviceName) + ? lastValidSetup_.inputDeviceName + : juce::String(); + reverted.inputChannels.clear(); + reverted.useDefaultInputChannels = true; + + { + const ScopedValueSetter svs(reverting_, true); + deviceManager_.setAudioDeviceSetup(reverted, true); + } + deviceManager_.getAudioDeviceSetup(lastValidSetup_); + + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "Incompatible Audio Input", + "The input \"" + rejectedInput + "\" can't be used together with output \"" + + setup.outputDeviceName + "\".\n\n" + "On macOS, an input device that differs from the output device stops playback " + "from advancing. The input has been reset. Choose the same device for input and " + "output if you need recording, otherwise leave the input as \"<< none >>\".", + "OK"); + } + + juce::AudioDeviceManager& deviceManager_; + std::unique_ptr selector_; + juce::AudioDeviceManager::AudioDeviceSetup lastValidSetup_; + bool reverting_ = false; + static constexpr int verticalPadding_ = 8; + static constexpr int contentWidth_ = 500; +}; + +} // namespace MoTool diff --git a/src/gui/main/Footer.cpp b/src/gui/main/Footer.cpp index 1a6a05ae..5dea0125 100644 --- a/src/gui/main/Footer.cpp +++ b/src/gui/main/Footer.cpp @@ -11,7 +11,9 @@ FooterBar::FooterBar(te::Engine& engine) &pluginListButton_, &audioSettingsButton_ }); - audioSettingsButton_.onClick = [this] { EngineHelpers::showAudioDeviceSettings(engine_); }; + // Route through our UIBehaviour so the dialog uses the same input/output validation + // (see ExtUIBehaviour::showSettingsScreen) as the menu/transport entry points. + audioSettingsButton_.onClick = [] { te::AppFunctions::showSettingsScreen(); }; // Show the plugin scan dialog // If you're loading an Edit with plugins in, you'll need to perform a scan first diff --git a/src/models/PsgList.cpp b/src/models/PsgList.cpp index 2d0563c1..fbe43f42 100644 --- a/src/models/PsgList.cpp +++ b/src/models/PsgList.cpp @@ -448,8 +448,22 @@ double PsgList::getTimeInBase(const PsgParamFrame& frame, PsgClip& clip, te::Mid [[nodiscard]] juce::MidiMessageSequence PsgList::exportToPlaybackMidiSequence(PsgClip& clip, te::MidiList::TimeBase timeBase) const { // DBG("Exporting PSG to MIDI sequence, channel " << getMidiChannel().getChannelNumber() << ", timebase " << (timeBase == te::MidiList::TimeBase::beats ? "beats" : "seconds")); PsgParamsMidiWriter writer {getMidiChannel().getChannelNumber()}; - for (auto f : getFrames()) { - writer.write(getTimeInBase(*f, clip, timeBase), f->getData()); + const auto& frames = getFrames(); + for (int i = 0; i < frames.size(); ++i) { + auto* f = frames[i]; + if (i == 0) { + // Export the complete accumulated initial state at time 0. + // PSG frames only mask registers that changed, but recomputeAccumulatedState() + // copies the full accumulated values (including the resetMixer baseline and + // defaults) into every frame. Forcing all masks on at frame 0 emits a complete + // AY register snapshot, so playback/reposition always starts from a deterministic + // state instead of inheriting stale registers from a previous play. + PsgParamFrameData fullState = f->getData(); + std::fill(fullState.masks.begin(), fullState.masks.end(), true); + writer.write(getTimeInBase(*f, clip, timeBase), fullState); + } else { + writer.write(getTimeInBase(*f, clip, timeBase), f->getData()); + } } return writer.getSequence(); } diff --git a/src/models/PsgList.test.cpp b/src/models/PsgList.test.cpp index 2e307fe2..9f54ba88 100644 --- a/src/models/PsgList.test.cpp +++ b/src/models/PsgList.test.cpp @@ -1,6 +1,8 @@ #include #include "formats/psg/PsgData.h" +#include "PsgClip.h" #include "PsgList.h" +#include "PsgMidi.h" #include "PsgParameter.h" namespace MoTool::Tests { @@ -235,4 +237,76 @@ class PsgListAccumulatedStateTests : public UnitTest { static PsgListAccumulatedStateTests psgListAccumulatedStateTests; +//============================================================================== +class PsgListInitialStateExportTests : public UnitTest { +public: + PsgListInitialStateExportTests() : UnitTest("PsgListInitState", "MoTool") {} + + void runTest() override { + auto& engine = *te::Engine::getEngines()[0]; + + beginTest("exportToPlaybackMidiSequence emits all params at time 0"); + { + // PSG data where frame 0 only sets VolumeA — a typical sparse PSG start + PsgData data { + { + {{PsgRegType::VolumeA, 10}}, // F0: only volume A set + {{PsgRegType::VolumeB, 8}}, // F1 + }, + {50.0, 1} + }; + + auto edit = Edit::createSingleTrackEdit(engine); + + // Create a PsgClip to call exportToPlaybackMidiSequence + auto t = te::getAudioTracks(*edit)[0]; + auto* clip = CustomClip::insertClipWithState(*t, {}, {}, CustomClip::Type::psg, + {{0_tp, 4_td}, {}}, te::DeleteExistingClips::no, false); + auto* psgClip = dynamic_cast(clip); + expect(psgClip != nullptr, "PsgClip created"); + + psgClip->getPsg().loadFrom(data, *edit, nullptr); + + auto seq = psgClip->getPsg().exportToPlaybackMidiSequence( + *psgClip, te::MidiList::TimeBase::seconds); + + // Count distinct MIDI CC (controller number, channel) pairs at time 0 + // Each PsgParamType maps to one or more CC events on specific channels. + // With the fix, the first frame should produce CC events for ALL param types. + double firstTime = -1.0; + int eventsAtTime0 = 0; + for (int i = 0; i < seq.getNumEvents(); ++i) { + auto& msg = seq.getEventPointer(i)->message; + if (msg.isController()) { + if (firstTime < 0.0) + firstTime = msg.getTimeStamp(); + if (std::abs(msg.getTimeStamp() - firstTime) < 1.0e-9) + eventsAtTime0++; + } + } + + // Minimum: 22 param types, some produce 2 CCs = at least 25 CC events + expect(eventsAtTime0 > 10, + "Should have many CC events at time 0 for complete AY init, got " + String(eventsAtTime0)); + + // The accumulated value for VolumeA (set in frame 0) must be emitted at time 0. + // VolumeA -> CC7 (Volume) on the base channel (psgChan 0). + const int baseChannel = psgClip->getPsg().getMidiChannel().getChannelNumber(); + bool foundVolumeA = false; + for (int i = 0; i < seq.getNumEvents(); ++i) { + auto& msg = seq.getEventPointer(i)->message; + if (std::abs(msg.getTimeStamp() - firstTime) < 1.0e-9 + && msg.isControllerOfType(static_cast(MidiCCType::Volume)) + && msg.getChannel() == baseChannel) { + foundVolumeA = true; + expectEquals(msg.getControllerValue(), 10, "VolumeA accumulated value at time 0"); + } + } + expect(foundVolumeA, "VolumeA CC emitted at time 0"); + } + } +}; + +static PsgListInitialStateExportTests psgListInitialStateExportTests; + } // namespace MoTool::Tests diff --git a/third_party/tracktion_engine b/third_party/tracktion_engine index c82ecb3b..c8a601ad 160000 --- a/third_party/tracktion_engine +++ b/third_party/tracktion_engine @@ -1 +1 @@ -Subproject commit c82ecb3b895e42e714b6920369e4c385ef420d7d +Subproject commit c8a601ad650aa1c24ee5a82026dd23e42bf91e4b